Update expanded roster checks and register Clementia impact visuals

This commit is contained in:
sganggs committed 2026-10-09 17:46:07 +08:00
1 parent c166479ff1
commit 0b0ce4fbd7
11 files changed
+34 -18

No files matched your search

+2
View File
@@ -1,5 +1,7 @@
# 卫戍协议:盟约 · Stronghold Protocol: Alliance # 卫戍协议:盟约 · Stronghold Protocol: Alliance
> 开发版(dev 分支):不稳定,请勿用于公开服务器。最近正式版请从 Releases 下载。
《明日方舟》季节性自走棋塔防玩法「卫戍协议:盟约」的**非官方同人复刻**:浏览器即开即玩,单人或 1–4 人联机合作。 《明日方舟》季节性自走棋塔防玩法「卫戍协议:盟约」的**非官方同人复刻**:浏览器即开即玩,单人或 1–4 人联机合作。
![version](https://img.shields.io/badge/version-0.2.3--dev-2ea44f) ![version](https://img.shields.io/badge/version-0.2.3--dev-2ea44f)
+11
View File
@@ -40,6 +40,9 @@ with mechanism notes from PRTS checked on 2026-10-09. Launch-day descriptions ma
defence, S1 cadence, S2 capacity / immunity / terrain / air vortex / target cap, and S3 ammo / global marks / defence, S1 cadence, S2 capacity / immunity / terrain / air vortex / target cap, and S3 ammo / global marks /
mixed damage. Golden changes and the final integration verification are recorded below once complete. mixed damage. Golden changes and the final integration verification are recorded below once complete.
Clementia S3 currently uses the shared blast visual as an explicit `[ASSUMED]` placeholder;
this preserves the previous fallback appearance, not a claim to reproduce her official effect assets.
### 28.2 New modules — 黍「好时节」and 乌尔比安「前路」 ### 28.2 New modules — 黍「好时节」and 乌尔比安「前路」
- **Sources:** [PRTS 黍](https://prts.wiki/w/黍), [PRTS 乌尔比安](https://prts.wiki/w/乌尔比安), official - **Sources:** [PRTS 黍](https://prts.wiki/w/黍), [PRTS 乌尔比安](https://prts.wiki/w/乌尔比安), official
@@ -167,3 +170,11 @@ Sleep, silence, no-heal targets, charge refunds, cooperative healing and S2 targ
Golden: all 285 scenarios recomputed; `diy-059`, `diy-133` change because Golden: all 285 scenarios recomputed; `diy-059`, `diy-133` change because
charged heals now act without enemy targets and S2 can open for enemies. Other scenarios remain byte-identical. charged heals now act without enemy targets and S2 can open for enemies. Other scenarios remain byte-identical.
### 28.11 Integration checks for the expanded roster
The fixed roster assertions now include 72 owned DIY operators, 259 forms, 192 voiced operators and 2688 Japanese
lines; the official-table comparison still checks every potential, skill and module. Clementia adds one 阿戈尔 pick.
The official EN overlay has no launch-day Clementia talents yet: overall coverage remains ≥97%, with the measured
talent floor explicitly updated to 94.9%; untranslated text keeps its Chinese fallback. The old Saria no-enemy
assertion is corrected to require actual healing, and development builds retain the standard top-of-README notice.
+2
View File
@@ -7,6 +7,8 @@
*/ */
export const FX_KINDS = Object.freeze({ export const FX_KINDS = Object.freeze({
// blasts // blasts
// [ASSUMED] Clementia's S3 uses the shared blast placeholder until her official effect is ported.
blast: { a: 'blast', c: 0xffb35c },
aoe: { a: 'blast', c: 0xffb35c }, explode: { a: 'blast', c: 0xff7a33 }, explosion: { a: 'blast', c: 0xff7a33 }, aoe: { a: 'blast', c: 0xffb35c }, explode: { a: 'blast', c: 0xff7a33 }, explosion: { a: 'blast', c: 0xff7a33 },
// `pt`: always at the event's (x, y) (its `id` is the shooter); `heavy`: debris + scorch // `pt`: always at the event's (x, y) (its `id` is the shooter); `heavy`: debris + scorch
bombard: { a: 'blast', c: 0xffa04a, r: 1.5, pt: true, heavy: true }, bombardShell: { a: 'shell', c: 0xff5a3a, r: 1.5, pt: true }, bombard: { a: 'blast', c: 0xffa04a, r: 1.5, pt: true, heavy: true }, bombardShell: { a: 'shell', c: 0xff5a3a, r: 1.5, pt: true },
+1 -1
View File
@@ -148,7 +148,7 @@ or a prototype (DATA.md §18, `shared/diy.js`). The prototypes run their stand-i
an owned 6★ can be picked only once it has a kit of its own: one file per operator, the same rules as every kit above. an owned 6★ can be picked only once it has a kit of its own: one file per operator, the same rules as every kit above.
Contributions are welcome — one operator per pull request is easiest to review. Contributions are welcome — one operator per pull request is easiest to review.
**Who.** The owned-6★ picks are `data/backups.json diy.ownedPool` (71 operators; the collab operators are not included, **Who.** The owned-6★ picks are `data/backups.json diy.ownedPool` (72 operators; the collab operators are not included,
the owner's decision of 2026-10-05). The ones still without a kit: the owner's decision of 2026-10-05). The ones still without a kit:
```sh ```sh
+6 -6
View File
@@ -1,7 +1,7 @@
// test/backups.test.js — the data of 补位 (stand-ins) and 自选 (DIY slots): chess.json `backup` and data/backups.json // test/backups.test.js — the data of 补位 (stand-ins) and 自选 (DIY slots): chess.json `backup` and data/backups.json
// (docs/DATA.md §18; tools/build-data.mjs buildBackups; shared/standIn.js, shared/diy.js). These tests pin the data the // (docs/DATA.md §18; tools/build-data.mjs buildBackups; shared/standIn.js, shared/diy.js). These tests pin the data the
// gameplay reads: the 133 base chess and their types, the 17 stand-in characters and their forms, every NORMAL chess // gameplay reads: the 133 base chess and their types, the 17 stand-in characters and their forms, every NORMAL chess
// resolving to its stand-in, the 4 DIY slots, the legal picks (the 71 owned 6★ with their forms and summons, the // resolving to its stand-in, the 4 DIY slots, the legal picks (the 72 owned 6★ with their forms and summons, the
// prototypes and their locked selections) and the faction bonds. // prototypes and their locked selections) and the faction bonds.
// With the official-data cache (.cache/gamedata) the backup fields, the stand-in numbers and the bond derivation are // With the official-data cache (.cache/gamedata) the backup fields, the stand-in numbers and the bond derivation are
// re-derived from the raw tables. // re-derived from the raw tables.
@@ -237,9 +237,9 @@ test('DIY: prototype picks — the 9 elites at tiers 5 and 6, six 4★ reserves
assert.equal(diyRecordOf(chess.chess_char_5_01_a, { charId: 'char_608_acpion' }, data), null, 'not a DIY slot'); assert.equal(diyRecordOf(chess.chess_char_5_01_a, { charId: 'char_608_acpion' }, data), null, 'not a DIY slot');
}); });
test('DIY: the 71 owned 6★ picks — a form at every slot status with all three skills, every module at stage 1 and 3, their summons', () => { test('DIY: the 72 owned 6★ picks — a form at every slot status with all three skills, every module at stage 1 and 3, their summons', () => {
const { ownedPool } = backups.diy; const { ownedPool } = backups.diy;
assert.equal(ownedPool.length, 71); assert.equal(ownedPool.length, 72);
let summoners = 0; let summoners = 0;
for (const id of ownedPool) { for (const id of ownedPool) {
const u = backups.units[id]; const u = backups.units[id];
@@ -311,7 +311,7 @@ test('DIY: prototype picks carry the skill / module of their 补位 rows at the
test('DIY: the owned-6★ pool and the faction → bond rule (mainPower + every subPower vs powerIdList, else 协防干员)', () => { test('DIY: the owned-6★ pool and the faction → bond rule (mainPower + every subPower vs powerIdList, else 协防干员)', () => {
const { ownedPool, operators, prototypes } = backups.diy; const { ownedPool, operators, prototypes } = backups.diy;
const roster = new Set(base.map((c) => c.charId).filter(Boolean)); const roster = new Set(base.map((c) => c.charId).filter(Boolean));
assert.equal(ownedPool.length, 71); assert.equal(ownedPool.length, 72);
for (const id of ownedPool) { for (const id of ownedPool) {
assert.ok(!roster.has(id), `${id}: a roster operator is never a pick`); assert.ok(!roster.has(id), `${id}: a roster operator is never a pick`);
assert.deepEqual([operators[id].rarity, operators[id].obtainable], [6, true], id); assert.deepEqual([operators[id].rarity, operators[id].obtainable], [6, true], id);
@@ -325,7 +325,7 @@ test('DIY: the owned-6★ pool and the faction → bond rule (mainPower + every
} }
const tally = {}; const tally = {};
for (const id of ownedPool) { const k = operators[id].bonds.join('+'); tally[k] = (tally[k] || 0) + 1; } for (const id of ownedPool) { const k = operators[id].bonds.join('+'); tally[k] = (tally[k] || 0) + 1; }
assert.deepEqual(tally, { emptyShip: 38, yanShip: 14, victoriaShip: 8, sargonShip: 4, siracusaShip: 4, lateranoShip: 1, kazimierzShip: 1, 'yanShip+victoriaShip': 1 }); assert.deepEqual(tally, { emptyShip: 38, yanShip: 14, victoriaShip: 8, sargonShip: 4, siracusaShip: 4, lateranoShip: 1, kazimierzShip: 1, egirShip: 1, 'yanShip+victoriaShip': 1 });
// the collab operators are out of the data and the pool (the owner's decision of 2026-10-05: copyright) // the collab operators are out of the data and the pool (the owner's decision of 2026-10-05: copyright)
assert.deepEqual(backups.diy.excluded.map((id) => [id, backups.units[id], operators[id]]), assert.deepEqual(backups.diy.excluded.map((id) => [id, backups.units[id], operators[id]]),
['char_456_ash', 'char_1029_yato2', 'char_1048_orchd2', 'char_4123_ela', 'char_4141_marcil', 'char_4182_oblvns', 'char_4217_makoto'].map((id) => [id, undefined, undefined]), ['char_456_ash', 'char_1029_yato2', 'char_1048_orchd2', 'char_4123_ela', 'char_4141_marcil', 'char_4182_oblvns', 'char_4217_makoto'].map((id) => [id, undefined, undefined]),
@@ -341,7 +341,7 @@ test('DIY: powers and the owned pool re-derived from character_table', { skip: !
const collab = (c) => [c.mainPower, ...(c.subPower || [])].some((p) => ['rainbow', 'action4', 'mujica', 'sees', 'laios'].includes(p?.teamId)) const collab = (c) => [c.mainPower, ...(c.subPower || [])].some((p) => ['rainbow', 'action4', 'mujica', 'sees', 'laios'].includes(p?.teamId))
|| /^(?:MH|RS|AM|PS|DD)\d/.test(c.displayNumber || ''); || /^(?:MH|RS|AM|PS|DD)\d/.test(c.displayNumber || '');
const legal = Object.entries(CT).filter(([id, c]) => id.startsWith('char_') && c.rarity === 'TIER_6' && !['TOKEN', 'TRAP'].includes(c.profession) && !c.isNotObtainable && !roster.has(id)); const legal = Object.entries(CT).filter(([id, c]) => id.startsWith('char_') && c.rarity === 'TIER_6' && !['TOKEN', 'TRAP'].includes(c.profession) && !c.isNotObtainable && !roster.has(id));
assert.equal(legal.length, 78, 'the excel\'s 78 obtainable 6★ outside the chess pool'); assert.equal(legal.length, 79, 'the excel\'s 79 obtainable 6★ outside the chess pool');
assert.deepEqual([...backups.diy.ownedPool].sort(), legal.filter(([, c]) => !collab(c)).map(([id]) => id).sort()); assert.deepEqual([...backups.diy.ownedPool].sort(), legal.filter(([, c]) => !collab(c)).map(([id]) => id).sort());
assert.deepEqual([...backups.diy.excluded].sort(), legal.filter(([, c]) => collab(c)).map(([id]) => id).sort()); assert.deepEqual([...backups.diy.excluded].sort(), legal.filter(([, c]) => collab(c)).map(([id]) => id).sort());
for (const [id, o] of Object.entries(backups.diy.operators)) { for (const [id, o] of Object.entries(backups.diy.operators)) {
+2 -2
View File
@@ -35,11 +35,11 @@ test('slots: two per tier (5, 6), each with its elite twin', () => {
assert.equal(diyTokenOwner(SIEGE, chess.chess_char_6_diy1_b.status), 'char_112_siege@2/60/7/3'); assert.equal(diyTokenOwner(SIEGE, chess.chess_char_6_diy1_b.status), 'char_112_siege@2/60/7/3');
}); });
test('diyPool: tier 5 = 15 prototypes + 71 owned 6★, tier 6 = 9 + 71; no preset, no collab; with the kit registry only kitted operators', () => { test('diyPool: tier 5 = 15 prototypes + 72 owned 6★, tier 6 = 9 + 72; no preset, no collab; with the kit registry only kitted operators', () => {
const p5 = diyPool(5, { data: DATA }), p6 = diyPool(6, { data: DATA }); const p5 = diyPool(5, { data: DATA }), p6 = diyPool(6, { data: DATA });
assert.deepEqual(p5, [...RESERVES5, ...ELITES, ...ownedPool]); assert.deepEqual(p5, [...RESERVES5, ...ELITES, ...ownedPool]);
assert.deepEqual(p6, [...ELITES, ...ownedPool]); assert.deepEqual(p6, [...ELITES, ...ownedPool]);
assert.deepEqual([p5.length, p6.length], [86, 80]); assert.deepEqual([p5.length, p6.length], [87, 81]);
const roster = new Set(Object.values(chess).map((c) => c.charId).filter(Boolean)); const roster = new Set(Object.values(chess).map((c) => c.charId).filter(Boolean));
for (const id of p5) assert.ok(!roster.has(id) && !COLLAB.includes(id), id); for (const id of p5) assert.ok(!roster.has(id) && !COLLAB.includes(id), id);
assert.deepEqual(diyPool(4, { data: DATA }), [], 'no 自选 slot at tier 4'); assert.deepEqual(diyPool(4, { data: DATA }), [], 'no 自选 slot at tier 4');
+3 -3
View File
@@ -62,7 +62,7 @@ test('plan: audio.voiceJp is the JP dub (ArknightsAssets2 voice/) of the very sl
assert.ok(plan({ voiceSlots: null }).audio.voiceJp.char_263_skadi.gacha); assert.ok(plan({ voiceSlots: null }).audio.voiceJp.char_263_skadi.gacha);
}); });
test('data/assets.json: voiceJp gives every voiced operator the JP twin of each Chinese line (191 operators, 2674 files); stand-ins, 盟约·辅助干员 and summons have neither', () => { test('data/assets.json: voiceJp gives every voiced operator the JP twin of each Chinese line (192 operators, 2688 files); stand-ins, 盟约·辅助干员 and summons have neither', () => {
const m = readJson('data/assets.json'); const m = readJson('data/assets.json');
const cn = m.audio.voice; const cn = m.audio.voice;
const jp = m.audio.voiceJp; const jp = m.audio.voiceJp;
@@ -70,13 +70,13 @@ test('data/assets.json: voiceJp gives every voiced operator the JP twin of each
assert.deepEqual(Object.keys(jp), Object.keys(cn), 'the same operators'); assert.deepEqual(Object.keys(jp), Object.keys(cn), 'the same operators');
assert.equal(m.stats.voiceJpChars, Object.keys(jp).length); assert.equal(m.stats.voiceJpChars, Object.keys(jp).length);
assert.equal(m.stats.voiceChars, Object.keys(cn).length); assert.equal(m.stats.voiceChars, Object.keys(cn).length);
assert.equal(Object.keys(jp).length, 191); assert.equal(Object.keys(jp).length, 192);
// the JP tree is the Chinese one with the folder swapped: same slots, same lines in the same order, same file names // the JP tree is the Chinese one with the folder swapped: same slots, same lines in the same order, same file names
assert.deepEqual(JSON.parse(JSON.stringify(cn).replaceAll('/assets/audio/voice/cn/', '/assets/audio/voice/jp/')), jp); assert.deepEqual(JSON.parse(JSON.stringify(cn).replaceAll('/assets/audio/voice/cn/', '/assets/audio/voice/jp/')), jp);
const lines = []; const lines = [];
const walk = (x) => { if (typeof x === 'string') lines.push(x); else if (Array.isArray(x)) x.forEach(walk); else if (x && typeof x === 'object') Object.values(x).forEach(walk); }; const walk = (x) => { if (typeof x === 'string') lines.push(x); else if (Array.isArray(x)) x.forEach(walk); else if (x && typeof x === 'object') Object.values(x).forEach(walk); };
walk(jp); walk(jp);
assert.equal(lines.length, 2674); assert.equal(lines.length, 2688);
for (const u of lines) assert.match(u, /^\/assets\/audio\/voice\/jp\/char_[^/]+\/cn_\d+\.mp3$/); for (const u of lines) assert.match(u, /^\/assets\/audio\/voice\/jp\/char_[^/]+\/cn_\d+\.mp3$/);
// no voice: the 17 stand-ins of data/backups.json (预备干员 / 原型干员), the mode's 盟约·辅助干员, every summon // no voice: the 17 stand-ins of data/backups.json (预备干员 / 原型干员), the mode's 盟约·辅助干员, every summon
const backups = readJson('data/backups.json'); const backups = readJson('data/backups.json');
+1 -1
View File
@@ -93,7 +93,7 @@ test('every chess, 自选 form and stand-in form: the raw keyframes plus every p
for (const [charId, u] of Object.entries(backups.units)) { for (const [charId, u] of Object.entries(backups.units)) {
for (const f of Object.values(u.forms)) { check(`${charId}@${f.status.phase}/${f.status.level}`, charId, f.status, f.stats); forms++; } for (const f of Object.values(u.forms)) { check(`${charId}@${f.status.phase}/${f.status.level}`, charId, f.status, f.stats); forms++; }
} }
assert.equal(forms, 256); assert.equal(forms, 259);
// the 原型干员 stand-ins have no potential ranks: their numbers are the plain keyframes // the 原型干员 stand-ins have no potential ranks: their numbers are the plain keyframes
for (const id of ['char_600_cpione', 'char_602_cdfend', 'char_608_acpion', 'char_617_sharp2']) assert.equal((CT[id].potentialRanks || []).length, 0, id); for (const id of ['char_600_cpione', 'char_602_cdfend', 'char_608_acpion', 'char_617_sharp2']) assert.equal((CT[id].potentialRanks || []).length, 0, id);
}); });
+3 -3
View File
@@ -95,13 +95,13 @@ test('data/i18n/en.json: shape, the 盟约 season of the EN build, coverage and
assert.equal(OVERLAY.version, 1); assert.equal(OVERLAY.version, 1);
assert.equal(OVERLAY.lang, 'en'); assert.equal(OVERLAY.lang, 'en');
assert.equal(OVERLAY.meta.source.season, 'act2autochess', 'built from an EN client that has 卫戍协议:盟约 下半'); assert.equal(OVERLAY.meta.source.season, 'act2autochess', 'built from an EN client that has 卫戍协议:盟约 下半');
// the 自选 data (0.2.0) brings 71 owned 6★, some of them (and some newer modules) not in the EN client yet: their // 0.2.3 adds Clementia before her EN release: official untranslated names/skills/talents remain Chinese.
// names, skills, talents and modules stay Chinese — measured 97.6 % in all, skills 95.9 / talents 95.1 % (2026-10-05) // Measured 2026-10-09: total 97.4%, skills 95.9%, talents 94.9%; other category floors stay unchanged.
assert.ok(OVERLAY.meta.totals.pct >= 97, `coverage ${OVERLAY.meta.totals.pct} %`); assert.ok(OVERLAY.meta.totals.pct >= 97, `coverage ${OVERLAY.meta.totals.pct} %`);
for (const kind of ['operators', 'skills', 'talents', 'modules', 'traits', 'enemies', 'bonds', 'items', 'effects', 'bands', 'garrisons', 'stages', 'tokens', 'choices', 'config']) { for (const kind of ['operators', 'skills', 'talents', 'modules', 'traits', 'enemies', 'bonds', 'items', 'effects', 'bands', 'garrisons', 'stages', 'tokens', 'choices', 'config']) {
const c = OVERLAY.meta.coverage[kind]; const c = OVERLAY.meta.coverage[kind];
assert.ok(c && c.texts > 0, `coverage of ${kind}`); assert.ok(c && c.texts > 0, `coverage of ${kind}`);
assert.ok(c.pct >= (['operators', 'skills', 'talents', 'modules'].includes(kind) ? 95 : 98), `${kind}: ${c.pct} %`); assert.ok(c.pct >= (kind === 'talents' ? 94.9 : ['operators', 'skills', 'modules'].includes(kind) ? 95 : 98), `${kind}: ${c.pct} %`);
} }
assert.deepEqual(Object.keys(OVERLAY.files).filter((f) => !DATA_FILES.includes(f)), []); assert.deepEqual(Object.keys(OVERLAY.files).filter((f) => !DATA_FILES.includes(f)), []);
assert.equal(OVERLAY.names['琳琅诗怀雅'], 'Swire the Elegant Wit'); assert.equal(OVERLAY.names['琳琅诗怀雅'], 'Swire the Elegant Wit');
+1 -1
View File
@@ -102,7 +102,7 @@ test('every chess and 自选 form at every potential: the raw keyframes plus the
forms++; forms++;
} }
} }
assert.equal(forms, 256); assert.equal(forms, 259);
}); });
test('talent candidates at every potential: each talent is the last one unlocked with requiredPotentialRank ≤ the rank', { skip: !HAS_CACHE && 'no .cache/gamedata' }, () => { test('talent candidates at every potential: each talent is the last one unlocked with requiredPotentialRank ≤ the rank', { skip: !HAS_CACHE && 'no .cache/gamedata' }, () => {
+2 -1
View File
@@ -247,7 +247,8 @@ test('#16 塞雷娅 S1 急救 (audit): checked at her attack — an ally of her
fill(u); fill(u);
m.hp = 4000; m.hp = 4000;
h.run(2); h.run(2);
assert.equal(u.skill.activations, 0, 'no enemy, no attack: no cast'); assert.equal(u.skill.activations, 1, 'eligible injured ally supplies the heal attack without an enemy (#406)');
assert.ok(m.hp > 4000, 'the charged attack actually heals');
done(h); done(h);
[h, u, m] = setup(true); [h, u, m] = setup(true);
const e = h.enemies()[0]; const e = h.enemies()[0];