feedback5 WH: 自选 data — data/backups.json gets the 72 owned 6★ picks as units (a form at every DIY slot status: E2 Lv1 rank 4 without a module, E2 Lv60 rank 7 with every module at stage 1 and at stage 3; all three skills, talents, trait, ranges), their summons (backups.json tokens, variants keyed by owner form <charId>@<statusKey>) and each prototype's locked skill / module per tier (diy.locked: its 补位 rows' selection, 预备干员-医疗 S3 assumed); the 6 collab operators (teams rainbow / action4 / mujica / sees / laios) leave the data and the pool, the owner's decision of 2026-10-05

backups.json 171,616 → 1,884,868 bytes (gzip ≈ 142 KB); every other data file is byte-identical (buildTokens' record head moved into summonRecord, shared with the DIY summons). No golden scenario moved (none fields a 自选 piece).
This commit is contained in:
sganggs committed 2026-10-05 19:37:49 +08:00
1 parent a98b02477d
commit c278eafa52
3 files changed
+305 -69

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"units":{"char_600_cpione":{"charId":"char_600_cpione","name":"预备干员-先锋","appellation":"Reserve Operator - Vanguard","rarity":4,"profession":"PIONEER","subProfessionId":"pioneer","subProfessionName":"尖兵","position":"MELEE","nationId":null,"isNotObtainable":true,"assets":{"avatar":"char_600_cpione","avatarGolden":"char_600_cpione","portrait":"char_600_cpione_1","portraitGolden":"char_600_cpione_1","spine":"char_600_cpione","subProfIcon":"sub_pioneer_icon"},"moduleNames":{},"standsIn":["chess_char_5_01_a"],"forms":{"2/1/4/0":{"status":{"phase":2,"level":1,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1225,"atk":320,"def":272,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":4,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":27,"initSp":3,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":4,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":36,"initSp":9,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":4,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":41,"initSp":12,"maxChargeTime":1,"increment":1,"bb":{"cost":12},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":2,"overrideTokenKey":null}],"talents":[{"index":0,"name":"攻击提升","desc":"攻击力+8%","descRaw":"攻击力+8%","bb":{"atk":0.08},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"displayTokens":[],"tokens":[]},"2/60/7/1":{"status":{"phase":2,"level":60,"skillLevel":7,"equipLevel":1},"stats":{"maxHp":1674,"atk":380,"def":322,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":7,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":25,"initSp":6,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":7,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":33,"initSp":10,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":7,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WILine truncated
{"units":{"char_600_cpione":{"charId":"char_600_cpione","name":"预备干员-先锋","appellation":"Reserve Operator - Vanguard","rarity":4,"profession":"PIONEER","subProfessionId":"pioneer","subProfessionName":"尖兵","position":"MELEE","nationId":null,"isNotObtainable":true,"assets":{"avatar":"char_600_cpione","avatarGolden":"char_600_cpione","portrait":"char_600_cpione_1","portraitGolden":"char_600_cpione_1","spine":"char_600_cpione","subProfIcon":"sub_pioneer_icon"},"moduleNames":{},"standsIn":["chess_char_5_01_a"],"forms":{"2/1/4/0":{"status":{"phase":2,"level":1,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1225,"atk":320,"def":272,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":4,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":27,"initSp":3,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":4,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":36,"initSp":9,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":4,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":41,"initSp":12,"maxChargeTime":1,"increment":1,"bb":{"cost":12},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":2,"overrideTokenKey":null}],"talents":[{"index":0,"name":"攻击提升","desc":"攻击力+8%","descRaw":"攻击力+8%","bb":{"atk":0.08},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"displayTokens":[],"tokens":[]},"2/60/7/1":{"status":{"phase":2,"level":60,"skillLevel":7,"equipLevel":1},"stats":{"maxHp":1674,"atk":380,"def":322,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":7,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":25,"initSp":6,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":7,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":33,"initSp":10,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":7,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WILine truncated
+97 -13
View File
@@ -1,7 +1,8 @@
// 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). The gameplay of both features comes later;
// these tests pin the data the gameplay will read: 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 and the faction bonds.
// (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
// 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.
// 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.
// Run: node --test test/backups.test.js
@@ -61,12 +62,16 @@ test('chess: the backup fields are the official shop row, verbatim', { skip: !HA
}
});
const STAT_KEYS = ['maxHp', 'atk', 'def', 'res', 'cost', 'blockCnt', 'bat', 'aspd', 'respawnTime', 'spRecovery', 'moveSpeed'];
test('backups: the 17 stand-in characters, each with a form for every status it fights at', () => {
const ids = Object.keys(backups.units);
const ids = Object.keys(backups.units).filter((id) => backups.units[id].standsIn.length);
assert.deepEqual(ids, [...RESERVES, ...ELITES]);
assert.deepEqual([...new Set(ofType('NORMAL').map((c) => c.backup.charId))].sort(), [...ids].sort(), 'the units are exactly the NORMAL chess backups');
const statKeys = ['maxHp', 'atk', 'def', 'res', 'cost', 'blockCnt', 'bat', 'aspd', 'respawnTime', 'spRecovery', 'moveSpeed'];
for (const [id, u] of Object.entries(backups.units)) {
assert.deepEqual(Object.keys(backups.units), [...RESERVES, ...ELITES, ...backups.diy.ownedPool], 'the stand-ins, then the owned 6★ picks');
assert.deepEqual([...new Set(ofType('NORMAL').map((c) => c.backup.charId))].sort(), [...ids].sort(), 'the stand-ins are exactly the NORMAL chess backups');
const statKeys = STAT_KEYS;
for (const id of ids) {
const u = backups.units[id];
assert.equal(u.charId, id);
assert.equal(u.rarity, ELITES.includes(id) ? 6 : 4, `${id}: rarity`);
assert.equal(u.isNotObtainable, true, `${id}: a prototype cannot be owned`);
@@ -229,10 +234,81 @@ test('DIY: prototype picks — the 9 elites at tiers 5 and 6, six 4★ reserves
assert.equal(diyRecord(chess.chess_char_5_01_a, 'char_608_acpion', backups, { skillIndex: 2 }), null, 'not a DIY slot');
});
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;
assert.equal(ownedPool.length, 72);
let summoners = 0;
for (const id of ownedPool) {
const u = backups.units[id];
assert.ok(u, `${id}: unit`);
assert.deepEqual([u.charId, u.rarity, u.isNotObtainable, u.standsIn], [id, 6, false, []], `${id}: identity`);
assert.ok(u.name && u.profession && u.subProfessionId && u.subProfessionName && u.position, `${id}: identity`);
assert.ok(u.assets.avatar === id && u.assets.spine === id && u.assets.avatarGolden === `${id}_2` && u.assets.portraitGolden === `${id}_2`, `${id}: assets (E2 art on the elite form)`);
assert.deepEqual(Object.keys(u.forms), ['2/1/4/0', '2/60/7/1', '2/60/7/3'], `${id}: forms`);
const advanced = Object.keys(u.moduleNames).filter((m) => u.moduleNames[m].typeName !== 'ORIGINAL');
let summons = false;
for (const [key, f] of Object.entries(u.forms)) {
const label = `${id}@${key}`;
for (const k of STAT_KEYS) assert.ok(isFiniteNum(f.stats[k]), `${label}: stats.${k}`);
assert.ok(f.stats.maxHp > 0 && f.stats.bat > 0 && Array.isArray(f.rangeGrid) && f.rangeGrid.length > 0, `${label}: stats / range`);
assert.ok(typeof f.trait?.desc === 'string' && f.trait.desc.length > 0 && Array.isArray(f.talents), `${label}: trait / talents`);
assert.deepEqual(f.skills.map((s) => [s.index, s.level]), [0, 1, 2].map((i) => [i, f.status.skillLevel]), `${label}: every skill at the slot's skill rank`);
for (const s of f.skills) assert.ok(typeof s.trigger?.rule === 'string' && !('isDefault' in s), `${label} ${s.skillId}: trigger, no selection`);
if (f.status.equipLevel === 0) assert.equal(f.modules, undefined, `${label}: no module phase on the normal form`);
else assert.deepEqual(f.modules.map((m) => [m.uniEquipId, m.level]), advanced.map((m) => [m, f.status.equipLevel]), `${label}: every module at the stage`);
for (const t of f.tokens) {
summons = true;
const rec = backups.tokens[t];
assert.ok(rec && rec.kind === 'summon' && rec.owners.includes(`${id}@${key}`), `${label}: summon ${t}`);
const v = rec.variants[`${id}@${key}`];
assert.ok(v.stats && v.skill !== undefined && Array.isArray(v.sources), `${label}: ${t} variant`);
assert.deepEqual(Object.keys(v.bySkill || {}).map(Number), [1, 2], `${label}: ${t} per other skill`);
if (f.status.equipLevel > 0 && f.modules.length) assert.deepEqual(Object.keys(v.byModule), f.modules.map((m) => m.uniEquipId), `${label}: ${t} per module`);
}
}
if (summons) summoners++;
}
assert.equal(summoners, 27, 'owned picks with summons');
for (const [id, rec] of Object.entries(backups.tokens)) {
assert.deepEqual(Object.keys(rec.variants), rec.owners, `${id}: owners = variant keys`);
for (const o of rec.owners) assert.ok(/^char_\w+@\d+\/\d+\/\d+\/\d+$/.test(o) && ownedPool.includes(o.split('@')[0]), `${id}: owner ${o}`);
}
// spot checks (zh_CN client data): 推进之王 E2 Lv60 = 2046 HP / 484 ATK, SOL-X stage 1 ATK +60 DEF +40 and its trait, stage 3
// the 万兽之王 change; 令 S1 / S2 / S3 summon 清平 / 逍遥 / 弦惊
const siege = backups.units.char_112_siege.forms;
assert.deepEqual([siege['2/60/7/1'].stats.maxHp, siege['2/60/7/1'].stats.atk], [2046, 484]);
const solx1 = siege['2/60/7/1'].modules.find((m) => m.typeName === 'SOL-X');
assert.deepEqual([solx1.attr, solx1.traitOverride.bb, solx1.talentChanges], [{ atk: 60, def: 40 }, { atk: 0.08, def: 0.08 }, []]);
assert.deepEqual(siege['2/60/7/3'].modules.find((m) => m.typeName === 'SOL-X').talentChanges.map((t) => [t.talentIndex, t.bb]), [[0, { atk: 0.08, def: 0.08 }]]);
const ling = backups.units.char_2023_ling.forms['2/60/7/3'];
assert.deepEqual(ling.skills.map((s) => s.overrideTokenKey), ['token_10020_ling_soul1', 'token_10020_ling_soul2', 'token_10020_ling_soul3']);
const soul3 = backups.tokens.token_10020_ling_soul3.variants['char_2023_ling@2/60/7/3'];
assert.deepEqual([soul3.sources, soul3.bySkill['2'].sources], [['display'], ['skill', 'display']], '弦惊: made by S3 only');
});
test('DIY: prototype picks carry the skill / module of their 补位 rows at the slot tier (预备干员-医疗: S3, assumed)', () => {
const { locked, prototypes } = backups.diy;
assert.deepEqual(Object.keys(locked), ['5', '6']);
for (const tier of ['5', '6']) {
assert.deepEqual(Object.keys(locked[tier]), prototypes[tier], `tier ${tier}: one locked selection per prototype`);
for (const [id, l] of Object.entries(locked[tier])) {
const rows = ofType('NORMAL').filter((c) => c.tier === Number(tier) && c.backup.charId === id).map((c) => c.chessId).sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
assert.deepEqual(l.from, rows, `${id}@${tier}: from its 补位 rows`);
if (rows.length) assert.deepEqual([l.skillIndex, l.uniEquipId], [chess[rows[0]].backup.skillIndex, chess[rows[0]].backup.uniEquipId], `${id}@${tier}`);
}
}
// the 6★ elites: S3 with their own module at both tiers; 领主·Sharp: S1; the reserves: S3 without a module
for (const id of ELITES) for (const tier of ['5', '6']) {
assert.deepEqual([locked[tier][id].skillIndex, locked[tier][id].uniEquipId], [id === 'char_617_sharp2' ? 0 : 2, `uniequip_002_${id.replace(/^char_\d+_/, '')}`], `${id}@${tier}`);
}
for (const id of prototypes['5'].filter((x) => RESERVES.includes(x))) assert.deepEqual([locked['5'][id].skillIndex, locked['5'][id].uniEquipId], [2, null], id);
assert.deepEqual(locked['5'].char_605_cmedic, { skillIndex: 2, uniEquipId: null, from: [] }, '预备干员-医疗 has no tier-5 补位 row: S3 [ASSUMED]');
});
test('DIY: the owned-6★ pool and the faction → bond rule (mainPower + every subPower vs powerIdList, else 协防干员)', () => {
const { ownedPool, operators, prototypes } = backups.diy;
const roster = new Set(base.map((c) => c.charId).filter(Boolean));
assert.equal(ownedPool.length, 78);
assert.equal(ownedPool.length, 72);
for (const id of ownedPool) {
assert.ok(!roster.has(id), `${id}: a roster operator is never a pick`);
assert.deepEqual([operators[id].rarity, operators[id].obtainable], [6, true], id);
@@ -246,7 +322,11 @@ test('DIY: the owned-6★ pool and the faction → bond rule (mainPower + every
}
const tally = {};
for (const id of ownedPool) { const k = operators[id].bonds.join('+'); tally[k] = (tally[k] || 0) + 1; }
assert.deepEqual(tally, { emptyShip: 44, yanShip: 14, victoriaShip: 8, sargonShip: 4, siracusaShip: 4, lateranoShip: 2, kazimierzShip: 1, 'yanShip+victoriaShip': 1 });
assert.deepEqual(tally, { emptyShip: 39, yanShip: 14, victoriaShip: 8, sargonShip: 4, siracusaShip: 4, lateranoShip: 1, kazimierzShip: 1, 'yanShip+victoriaShip': 1 });
// 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]]),
['char_456_ash', 'char_1029_yato2', 'char_4123_ela', 'char_4141_marcil', 'char_4182_oblvns', 'char_4217_makoto'].map((id) => [id, undefined, undefined]),
'灰烬, 麒麟R夜刀, 艾拉, 玛露西尔, 丰川祥子, 结城理');
assert.deepEqual(operators.char_017_huang.bonds, ['yanShip', 'victoriaShip'], '煌: 炎 + 维多利亚 from her subPower (mainPower rhodes / elite)');
for (const id of [...prototypes['5'], ...prototypes['6']]) assert.deepEqual(operators[id].bonds, ['emptyShip'], `${id}: no faction`);
});
@@ -255,20 +335,24 @@ test('DIY: powers and the owned pool re-derived from character_table', { skip: !
const CT = raw('character_table');
const act = raw('activity_table').activity.AUTOCHESS_SEASON.act2autochess;
const roster = new Set(Object.values(act.charShopChessDatas).map((r) => r.charId).filter(Boolean));
const pool = Object.entries(CT).filter(([id, c]) => id.startsWith('char_') && c.rarity === 'TIER_6' && !['TOKEN', 'TRAP'].includes(c.profession) && !c.isNotObtainable && !roster.has(id)).map(([id]) => id);
assert.deepEqual([...backups.diy.ownedPool].sort(), pool.sort());
const collab = (c) => [c.mainPower, ...(c.subPower || [])].some((p) => ['rainbow', 'action4', 'mujica', 'sees', 'laios'].includes(p?.teamId));
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.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());
for (const [id, o] of Object.entries(backups.diy.operators)) {
const c = CT[id];
const want = [...new Set([c.mainPower, ...(c.subPower || [])].flatMap((p) => [p?.nationId, p?.groupId, p?.teamId]).filter(Boolean))];
assert.deepEqual(o.powers, want, `${id}: powers`);
}
// nine obtainable 6★ get their core bond from a subPower only (鸿雪, 真言, 假日威龙陈, 弑君者, 予愿安洁莉娜, 结城理, 涤火杰西卡, 薇薇安娜, 煌)
// eight owned picks get their core bond from a subPower only (鸿雪, 真言, 假日威龙陈, 弑君者, 予愿安洁莉娜, 涤火杰西卡, 薇薇安娜, 煌;
// the ninth of the excel, 结城理, is a collab)
const coreOf = (powers) => Object.values(bonds).filter((b) => b.isCore && b.powerIdList.some((p) => powers.includes(p))).map((b) => b.bondId);
const subOnly = backups.diy.ownedPool.filter((id) => {
const m = CT[id].mainPower;
return coreOf([m?.nationId, m?.groupId, m?.teamId].filter(Boolean)).join() !== coreOf(backups.diy.operators[id].powers).join();
});
assert.equal(subOnly.length, 9);
assert.equal(subOnly.length, 8);
});
test('composeUnitRecord: identity from the chess, everything else from the unit (no field of the replaced operator leaks)', () => {
+207 -55
View File
@@ -1071,6 +1071,21 @@ function buildChess(ctx) {
*/
const DIY_EXTRA_PROTOTYPES = Object.freeze({ 5: Object.freeze({ rarity: 4, excludedProfessions: Object.freeze(['PIONEER', 'SPECIAL']) }) });
/**
* 自选 owned picks left out of the data and the pool by the owner's decision of 2026-10-05: the collab operators
* (copyright) — every 6★ whose `mainPower` or a `subPower` names one of these 联动 teams: rainbow (灰烬, 艾拉), action4
* (麒麟R夜刀), mujica (丰川祥子), sees (结城理), laios (玛露西尔). The excel does not exclude them (research 0.2.0 §2.2).
*/
const DIY_EXCLUDED_TEAMS = Object.freeze(['rainbow', 'action4', 'mujica', 'sees', 'laios']);
/**
* The skill a prototype carries in a 自选 slot when no 补位 row of the slot's tier names it (only 预备干员-医疗 at tier 5:
* it stands in at tier 3 only). PRTS 卫戍协议 says the prototypes' "技能携带规则与系统补位时一致"; [ASSUMED] (the owner's
* decision of 2026-10-05) the selection of its 补位 rows at that tier, and for this one S3 — every other 4★ reserve's
* tier-5 row — with no module.
*/
const DIY_PROTOTYPE_FALLBACK_SKILL = 2;
const rarityOf = (char) => Number(String(char?.rarity).replace('TIER_', '')) || null;
const statusCmp = (a, b) => a.phase - b.phase || a.level - b.level || a.skillLevel - b.skillLevel || a.equipLevel - b.equipLevel;
@@ -1173,16 +1188,21 @@ function buildUnitForm(ctx, charId, status, { chessId = null, skillIndex = null
/**
* data/backups.json (DATA.md §18; DESIGN 0.2.0 draft): the data of 补位 and 自选.
* - `units`: every character a NORMAL chess names as `backup.charId` — the 17 原型干员 (预备干员 char_600–607, the 6★
* 罗德岛特派高级资深干员 char_608–615 and 领主·Sharp char_617) — with a form for every status it fights at: the statuses
* of the chess it stands in for (normal and elite) and of the DIY slots it may fill. No unit for a PRESET chess
* (特许: always the real operator; its backup is itself) or a DIY slot (no backup).
* - `units`: every character a 补位 or 自选 piece fields, with a form for every status it fights at. First the 17
* 原型干员 a NORMAL chess names as `backup.charId` (预备干员 char_600–607, the 6★ 罗德岛特派高级资深干员 char_608–615 and
* 领主·Sharp char_617) at the statuses of the chess it stands in for (normal and elite) and of the DIY slots it may fill;
* then every owned-6★ pick (`diy.ownedPool`) at the DIY slot statuses — E2 Lv1 skill rank 4 without a module, E2 Lv60
* rank 7 with every module at stage 1 (tier 5) and at stage 3 (tier 6). No unit for a PRESET chess (特许: always the
* real operator; its backup is itself) or a DIY slot (no backup).
* - `tokens`: the summons of the owned picks (buildDiyTokens: tokens.json records whose `variants` are keyed by owner
* form, `<charId>@<statusKey>`).
* - `diy`: the slots (tier, elite id, the shop level that lists them, the rarity requirement), the prototype picks per
* slot tier (DIY_EXTRA_PROTOTYPES), the owned-6★ pool (obtainable, not a roster operator: no chess names it, hidden
* chess included — "不可甄选加入已在名单中的固定干员") and, for every pick, its faction ids (`mainPower` and every
* `subPower`: "依据其「所属势力」「隐藏势力」等属性决定其盟约") and the core bonds whose `powerIdList` meets them, else
* `constData.fallbackBondId` (协防干员) — PRTS 「卫戍协议」 "甄选加入的干员会根据其实际阵营所属分配核心盟约,若没有可匹配的
* 则改为分配协防干员盟约".
* slot tier (DIY_EXTRA_PROTOTYPES) and the skill / module each carries there (`locked`: its 补位 rows' selection at
* that tier — "技能携带规则与系统补位时一致"), the owned-6★ pool (obtainable, not a roster operator: no chess names it,
* hidden chess included — "不可甄选加入已在名单中的固定干员" — and not a collab, DIY_EXCLUDED_TEAMS: `excluded`) and, for
* every pick, its faction ids (`mainPower` and every `subPower`: "依据其「所属势力」「隐藏势力」等属性决定其盟约") and the
* core bonds whose `powerIdList` meets them, else `constData.fallbackBondId` (协防干员) — PRTS 「卫戍协议」
* "甄选加入的干员会根据其实际阵营所属分配核心盟约,若没有可匹配的则改为分配协防干员盟约".
*/
function buildBackups(ctx, chess) {
const { act, ac, charTable } = ctx;
@@ -1207,12 +1227,15 @@ function buildBackups(ctx, chess) {
for (const d of Object.values(act.shopLevelDisplayDataDict || {})) for (const id of d.charChessDiySlotIdList || []) shopLevelOf[id] = d.shopLevel;
const slots = {};
const prototypes = {};
const locked = {};
const requirements = new Set();
const diyStatuses = new Map(); // statusKey → status of every DIY slot record (both forms)
for (const s of Object.values(chess).filter((c) => c.isDiy && !c.isGolden).sort((x, y) => naturalCmp(x.chessId, y.chessId))) {
const golden = chess[s.goldenId];
if (!golden) continue;
requirements.add(s.diyRequirement);
slots[s.chessId] = { tier: s.tier, goldenId: s.goldenId, shopLevel: shopLevelOf[s.chessId] ?? null, requirement: s.diyRequirement };
for (const rec of [s, golden]) diyStatuses.set(statusKey(rec.status), rec.status);
const extra = DIY_EXTRA_PROTOTYPES[s.tier];
const picks = standInIds.filter((id) => {
const ch = charTable[id];
@@ -1221,27 +1244,48 @@ function buildBackups(ctx, chess) {
if (prototypes[s.tier] && JSON.stringify(prototypes[s.tier]) !== JSON.stringify(picks)) warn(`DIY tier ${s.tier}: slots disagree on the prototype picks`);
prototypes[s.tier] = picks;
for (const id of picks) { addNeed(id, s.status); addNeed(id, golden.status); }
// the skill / module a prototype carries in a slot of this tier: its 补位 rows' (the NORMAL base chess of the tier
// whose backup it is), else DIY_PROTOTYPE_FALLBACK_SKILL without a module [ASSUMED]
locked[s.tier] = {};
for (const id of picks) {
const rows = Object.values(chess).filter((c) => c.chessType === 'NORMAL' && !c.isGolden && c.tier === s.tier && c.backup?.charId === id)
.sort((x, y) => naturalCmp(x.chessId, y.chessId));
const kinds = new Set(rows.map((c) => `${c.backup.skillIndex}|${c.backup.uniEquipId ?? ''}`));
if (kinds.size > 1) warn(`DIY tier ${s.tier} prototype ${id}: its 补位 rows carry different selections (${[...kinds].join(', ')}); the first row's is used`);
locked[s.tier][id] = rows.length
? { skillIndex: rows[0].backup.skillIndex, uniEquipId: rows[0].backup.uniEquipId ?? null, from: rows.map((c) => c.chessId) }
: { skillIndex: DIY_PROTOTYPE_FALLBACK_SKILL, uniEquipId: null, from: [] };
}
}
const roster = new Set(Object.values(act.charShopChessDatas).map((r) => r.charId).filter(Boolean));
const teamsOf = (ch) => [ch.mainPower?.teamId ?? ch.teamId, ...(ch.subPower || []).map((p) => p?.teamId)].filter(Boolean);
const excludedTeams = new Set(DIY_EXCLUDED_TEAMS);
const legal6 = Object.keys(charTable).filter((id) => {
const ch = charTable[id];
return id.startsWith('char_') && requirements.has(ch.rarity) && ch.profession !== 'TOKEN' && ch.profession !== 'TRAP'
&& !ch.isNotObtainable && !roster.has(id);
}).sort(naturalCmp);
const excluded = legal6.filter((id) => teamsOf(charTable[id]).some((t) => excludedTeams.has(t)));
for (const t of DIY_EXCLUDED_TEAMS) if (!excluded.some((id) => teamsOf(charTable[id]).includes(t))) warn(`DIY_EXCLUDED_TEAMS: no owned-6★ pick of team ${t}`);
const ownedPool = legal6.filter((id) => !excluded.includes(id));
for (const id of ownedPool) for (const st of diyStatuses.values()) addNeed(id, st);
const units = {};
for (const id of [...need.keys()].sort(naturalCmp)) {
for (const id of [...standInIds, ...ownedPool]) {
const unit = buildUnitHead(ctx, id);
unit.standsIn = (standsIn.get(id) || []).sort(naturalCmp);
unit.forms = {};
for (const st of [...need.get(id).values()].sort(statusCmp)) {
const form = buildUnitForm(ctx, id, st);
if (form.tokens.length) warn(`unit ${id}@${statusKey(st)}: summons ${form.tokens.join(', ')} have no tokens.json variant for a stand-in owner`);
if (form.tokens.length && unit.standsIn.length) warn(`unit ${id}@${statusKey(st)}: summons ${form.tokens.join(', ')} have no tokens.json variant for a stand-in owner`);
unit.forms[statusKey(st)] = form;
}
units[id] = unit;
}
for (const id of need.keys()) if (!units[id]) warn(`unit ${id}: needed but neither a stand-in nor an owned pick`);
const tokens = buildDiyTokens(ctx, units, ownedPool);
const roster = new Set(Object.values(act.charShopChessDatas).map((r) => r.charId).filter(Boolean));
const ownedPool = Object.keys(charTable).filter((id) => {
const ch = charTable[id];
return id.startsWith('char_') && requirements.has(ch.rarity) && ch.profession !== 'TOKEN' && ch.profession !== 'TRAP'
&& !ch.isNotObtainable && !roster.has(id);
}).sort(naturalCmp);
const coreBonds = Object.values(ac.bondInfoDict || {}).filter((b) => b.isPower && (b.powerIdList || []).length);
const fallback = act.constData.fallbackBondId;
const operators = {};
@@ -1257,7 +1301,7 @@ function buildBackups(ctx, chess) {
obtainable: !ch.isNotObtainable, powers, bonds: hit.length ? hit : [fallback],
};
}
return { units, diy: { slots, prototypes, ownedPool, operators } };
return { units, tokens, diy: { slots, prototypes, locked, ownedPool, excluded, operators } };
}
/**
@@ -1380,14 +1424,130 @@ const TOKEN_ABNORMAL = Object.freeze({
enemy_9012_acloon: ['isolated'], // “炎佑”
});
/**
* The tokens.json record of a summon (buildTokens; the 自选 picks' summons, buildDiyTokens) from its per-owner
* `variants` (key → variant; `owners` = the keys, in order; the defaults are the first owner's variant).
* Hand cards placed during the prep phase (`placeable`) are the MANUALLY DEPLOYABLE summons (PRTS 卫戍协议/帮助
* §战斗部署: "如果部署的干员拥有可手动部署的附属召唤物,则该召唤物会立刻加入手牌区"; user playtest #6): the shop
* state's tokenDisplayType DEFAULT — 赫默's 医疗探机 and 巫恋's 诅咒娃娃 (skill summons) as well as 浊心斯卡蒂's 海嗣,
* 伺夜's 狼群 and 缪尔赛思's 流形 (talent summons) — and a summon the shop state does not list at all: 凯瑟琳's
* 爬行号·防护单元 (talent "携带3个支援装置(最多部署2个)", deployed by hand in the base game; a friend of the user:
* placed by hand officially; confirmed by the user after playtest #6 — DESIGN §20). It is the only pool summon
* missing from shopStateTokenDict (every other one is listed, as are newer tokens such as 10040, 10042, 10043,
* 10055–10058, 10065), so no entry is read as the default display. HIDDEN tokens exist in battle only (e.g.
* 投递坐标 — PRTS: "携带技能【使命必达!】的新约能天使,不会提供所属召唤物"). Only a token its owner actually makes
* (`produced`: a talent or a skill of some loadout, `sources`) is a card; which loadouts make it is per variant
* (`sources`, `bySkill[i].sources`: 赫默 / 巫恋 on S1 get none). In battle a skill's summon deploys once at the start, then
* takes its tile again each time the skill gives one (sim/content/tokens.js dockSkillSummons, shared/constants.js
* SKILL_SUMMON_START_DEPLOY).
* `ownerRange`: the token text "只能部署在召唤者攻击范围内" (the tacticians' 援军 — 伺夜's 狼群, 缪尔赛思's 流形; PRTS 狼群
* 特性): its hand piece may only be placed on a tile of its owner's attack range (server/match/board.js
* ownerRangeKeys, PlayerState._legal; player report #9 after 0.1.0: 伺夜's tactical point could go anywhere).
* `abnormal` = TOKEN_ABNORMAL (PRTS).
*/
function summonRecord(ctx, tokenId, char, variants, produced) {
const displayType = ctx.ac.shopStateTokenDict?.[tokenId]?.tokenDisplayType || null;
const owners = Object.keys(variants);
const first = variants[owners[0]];
const ownerRange = /只能部署在\S*攻击范围内/.test(stripRich(first.trait.desc) || '');
return {
tokenId, kind: 'summon', name: char.name, appellation: char.appellation || null,
desc: stripRich(first.trait.desc), descRaw: first.trait.descRaw,
profession: char.profession, subProfessionId: char.subProfessionId, position: char.position,
displayType, placeable: displayType !== 'HIDDEN' && produced, ownerRange,
owners,
// Defaults = first owner's variant; per-owner data in variants[owner].
stats: first.stats, rangeGrid: first.rangeGrid, dmgType: first.dmgType, attackKind: first.attackKind,
projectile: first.projectile, canHitFly: first.canHitFly,
skill: first.skill ? { skillId: first.skill.skillId, bb: first.skill.bb } : null,
deployLimit: first.stats?.deployLimit ?? 1,
count: first.count,
abnormal: TOKEN_ABNORMAL[tokenId] ? [...TOKEN_ABNORMAL[tokenId]] : [],
variants,
assets: { avatar: tokenId, spine: tokenId },
};
}
/**
* The summons of the 自选 owned picks (data/backups.json `tokens`, DATA.md §18): one summonRecord per token whose
* `variants` are keyed by the owner FORM, `<charId>@<statusKey>` (shared/diy.js diyTokenOwner), never by a chess id — a
* DIY piece is a slot, and two players may fill the same slot with different operators. A variant is the token at the
* owner's status (the owner's phase / level / skill level, as for a chess owner) for the owner's FIRST skill and no
* module; `bySkill[i]` the token skill, count and sources under each other skill, `byModule[id]` the module token
* attributes / trait / talents under each module of the form — the chess variant shape (DESIGN §16), applied with the
* pick as the owner's loadout (simdata getToken, content/tokens.js withLoadout). `count` / `sources` come from the
* owner's no-module talents and the selected skill, as buildChess's `tokenUse`.
* @param {object} units backups `units`
* @param {string[]} owned the owned-6★ picks (their forms are the DIY slot statuses)
*/
function buildDiyTokens(ctx, units, owned) {
const { charTable, battleEquip } = ctx;
const owners = new Map(); // tokenId → [{ key, variant, produced }]
const makes = (list) => list.includes('talent') || list.includes('skill');
for (const charId of owned) {
for (const [key, form] of Object.entries(units[charId]?.forms || {})) {
if (!form.tokens.length) continue;
const { phase, level, skillLevel, equipLevel } = form.status;
/** token id → its sources under `skill` (buildChess tokenUse), and the count a talent / the skill sends */
const tokenUse = (skill) => {
const skTok = skill?.overrideTokenKey || null;
const use = new Map();
const add = (id, src) => { if (!use.has(id)) use.set(id, new Set()); use.get(id).add(src); };
for (const id of form.displayTokens) add(id, 'display');
if (skTok) add(skTok, 'skill');
for (const t of form.talents) if (t.tokenKey) add(t.tokenKey, 'talent');
const count = (id) => {
const tal = form.talents.find((t) => t.tokenKey === id && typeof t.bb.cnt === 'number');
const skillCnt = skTok === id ? skill?.bb?.cnt : undefined;
return tal ? tal.bb.cnt : typeof skillCnt === 'number' ? skillCnt : null;
};
const sources = (id) => ['talent', 'skill', 'display'].filter((x) => use.get(id)?.has(x));
return { count, sources };
};
const [first, ...alts] = form.skills;
for (const tokenId of form.tokens) {
const tchar = charTable[tokenId];
const label = `token ${tokenId}@${charId}@${key}`;
const at = (o) => tokenVariant(ctx, tokenId, tchar, { phase, level, skillIndex: first.index, skillLevel, modulePhase: null, moduleTokenParts: [], label, ...o });
const u0 = tokenUse(first);
const v = { ...at({}), count: u0.count(tokenId), sources: u0.sources(tokenId) };
let produced = makes(v.sources);
if (alts.length) {
v.bySkill = {};
for (const s of alts) {
const u = tokenUse(s);
v.bySkill[s.index] = { skill: at({ skillIndex: s.index }).skill, count: u.count(tokenId), sources: u.sources(tokenId) };
produced = produced || makes(v.bySkill[s.index].sources);
}
}
if (equipLevel > 0 && form.modules?.length) {
v.byModule = {};
for (const m of form.modules) {
const ph = battleEquip[m.uniEquipId]?.phases?.find((p) => p.equipLevel === equipLevel) || null;
const tv = at({ modulePhase: ph, moduleTokenParts: splitModuleParts(ph).token });
v.byModule[m.uniEquipId] = { stats: tv.stats, immunities: tv.immunities, trait: tv.trait, talents: tv.talents };
}
}
if (!owners.has(tokenId)) owners.set(tokenId, []);
owners.get(tokenId).push({ key: `${charId}@${key}`, variant: v, produced });
}
}
}
const out = {};
for (const tokenId of [...owners.keys()].sort(naturalCmp)) {
const list = owners.get(tokenId);
out[tokenId] = summonRecord(ctx, tokenId, charTable[tokenId], Object.fromEntries(list.map((o) => [o.key, o.variant])), list.some((o) => o.produced));
}
return out;
}
/**
* Build data/tokens.json: summons of chess (per-owner variants), bond summons (炎佑) and band map
* characters (band_amedic 预备干员-医疗 / Touch). `abnormal` = TOKEN_ABNORMAL (PRTS).
*/
function buildTokens(ctx, chess, tokenOwners, enemies) {
const { charTable, ac } = ctx;
const { charTable } = ctx;
const out = {};
const displayType = (id) => ac.shopStateTokenDict?.[id]?.tokenDisplayType || null;
for (const tokenId of [...tokenOwners.keys()].sort(naturalCmp)) {
const char = charTable[tokenId];
const owners = tokenOwners.get(tokenId);
@@ -1417,42 +1577,9 @@ function buildTokens(ctx, chess, tokenOwners, enemies) {
}
}
}
const first = variants[owners[0].chessId];
// Hand cards placed during the prep phase (`placeable`) are the MANUALLY DEPLOYABLE summons (PRTS 卫戍协议/帮助
// §战斗部署: "如果部署的干员拥有可手动部署的附属召唤物,则该召唤物会立刻加入手牌区"; user playtest #6): the shop
// state's tokenDisplayType DEFAULT — 赫默's 医疗探机 and 巫恋's 诅咒娃娃 (skill summons) as well as 浊心斯卡蒂's 海嗣,
// 伺夜's 狼群 and 缪尔赛思's 流形 (talent summons) — and a summon the shop state does not list at all: 凯瑟琳's
// 爬行号·防护单元 (talent "携带3个支援装置(最多部署2个)", deployed by hand in the base game; a friend of the user:
// placed by hand officially; confirmed by the user after playtest #6 — DESIGN §20). It is the only pool summon
// missing from shopStateTokenDict (every other one is listed, as are newer tokens such as 10040, 10042, 10043,
// 10055–10058, 10065), so no entry is read as the default display. HIDDEN tokens exist in battle only (e.g.
// 投递坐标 — PRTS: "携带技能【使命必达!】的新约能天使,不会提供所属召唤物"). Only a token its owner actually makes
// (a talent or a skill of some loadout, `sources`) is a card; which loadouts make it is per variant (`sources`,
// `bySkill[i].sources`: 赫默 / 巫恋 on S1 get none). In battle a skill's summon deploys once at the start, then
// takes its tile again each time the skill gives one (sim/content/tokens.js dockSkillSummons, shared/constants.js
// SKILL_SUMMON_START_DEPLOY).
const makes = (list) => (list || []).some((s) => s === 'talent' || s === 'skill');
const produced = owners.some((o) => makes(o.sources) || (o.skillAlts || []).some((a) => makes(a.sources)));
// `ownerRange`: the token text "只能部署在召唤者攻击范围内" (the tacticians' 援军 — 伺夜's 狼群, 缪尔赛思's 流形; PRTS 狼群
// 特性): its hand piece may only be placed on a tile of its owner's attack range (server/match/board.js
// ownerRangeKeys, PlayerState._legal; player report #9 after 0.1.0: 伺夜's tactical point could go anywhere)
const ownerRange = /只能部署在\S*攻击范围内/.test(stripRich(first.trait.desc) || '');
out[tokenId] = {
tokenId, kind: 'summon', name: char.name, appellation: char.appellation || null,
desc: stripRich(first.trait.desc), descRaw: first.trait.descRaw,
profession: char.profession, subProfessionId: char.subProfessionId, position: char.position,
displayType: displayType(tokenId), placeable: displayType(tokenId) !== 'HIDDEN' && produced, ownerRange,
owners: owners.map((o) => o.chessId),
// Defaults = first owner's variant; per-owner data in variants[chessId].
stats: first.stats, rangeGrid: first.rangeGrid, dmgType: first.dmgType, attackKind: first.attackKind,
projectile: first.projectile, canHitFly: first.canHitFly,
skill: first.skill ? { skillId: first.skill.skillId, bb: first.skill.bb } : null,
deployLimit: first.stats?.deployLimit ?? 1,
count: first.count,
abnormal: TOKEN_ABNORMAL[tokenId] ? [...TOKEN_ABNORMAL[tokenId]] : [],
variants,
assets: { avatar: tokenId, spine: tokenId },
};
out[tokenId] = summonRecord(ctx, tokenId, char, variants, produced);
}
// 炎佑 (yanShip 6-member summon) — allied flying unit built from its enemy template.
@@ -3495,9 +3622,32 @@ function validateAll(f) {
const slot = chess[id], golden = chess[s.goldenId];
if (!slot?.isDiy || slot.isGolden || slot.tier !== s.tier || !golden?.isDiy) { err(`DIY slot ${id}: not a DIY chess pair of tier ${s.tier}`); continue; }
if (!backups.diy.prototypes[s.tier]?.length) err(`DIY slot ${id}: no prototype picks`);
for (const p of backups.diy.prototypes[s.tier] || []) for (const rec of [slot, golden]) if (!unitForm(backups, p, rec.status)) err(`DIY slot ${rec.chessId}: prototype ${p} has no form ${statusKey(rec.status)}`);
for (const p of backups.diy.prototypes[s.tier] || []) {
for (const rec of [slot, golden]) if (!unitForm(backups, p, rec.status)) err(`DIY slot ${rec.chessId}: prototype ${p} has no form ${statusKey(rec.status)}`);
// the locked selection exists at both slot forms (the module at the elite's stage)
const lk = backups.diy.locked?.[s.tier]?.[p];
if (!lk) { err(`DIY slot ${id}: prototype ${p} has no locked selection`); continue; }
for (const rec of [slot, golden]) {
const f = unitForm(backups, p, rec.status);
if (f && !f.skills.some((x) => x.index === lk.skillIndex)) err(`DIY slot ${rec.chessId}: prototype ${p} has no skill ${lk.skillIndex} at ${statusKey(rec.status)}`);
if (f && lk.uniEquipId && rec.status.equipLevel > 0 && !f.modules.some((m) => m.uniEquipId === lk.uniEquipId)) err(`DIY slot ${rec.chessId}: prototype ${p} has no module ${lk.uniEquipId} at ${statusKey(rec.status)}`);
}
}
// every owned pick: a form at both slot statuses with every skill, and at the elite every module of the character
for (const p of backups.diy.ownedPool) {
const u = backups.units[p];
for (const rec of [slot, golden]) {
const f = unitForm(backups, p, rec.status);
if (!f) { err(`DIY slot ${rec.chessId}: owned pick ${p} has no form ${statusKey(rec.status)}`); continue; }
if (f.skills.length !== 3) err(`DIY slot ${rec.chessId}: owned pick ${p} has ${f.skills.length} skills at ${statusKey(rec.status)}`);
const ids = Object.keys(u.moduleNames).filter((m) => u.moduleNames[m].typeName !== 'ORIGINAL');
if (rec.status.equipLevel > 0 && JSON.stringify(f.modules.map((m) => m.uniEquipId)) !== JSON.stringify(ids)) err(`DIY slot ${rec.chessId}: owned pick ${p} modules ${f.modules.map((m) => m.uniEquipId)} ≠ ${ids}`);
for (const t of f.tokens) if (!backups.tokens?.[t]?.variants?.[`${p}@${statusKey(rec.status)}`]) err(`DIY pick ${p}@${statusKey(rec.status)}: summon ${t} has no variant in backups.json tokens`);
}
}
}
for (const [id, o] of Object.entries(backups.diy.operators)) for (const b of o.bonds) if (!bonds[b]) err(`DIY pick ${id}: bond ${b} missing`);
for (const id of Object.keys(backups.tokens || {})) if (tokens[id]) err(`backups.json token ${id} is also a tokens.json record`);
// the 重装 stand-ins' deviations (STANDIN_TRIGGER_DEVIATIONS) still override an official TAKE_DAMAGE row, on every form
for (const [charId, skillsOf] of Object.entries(STANDIN_TRIGGER_DEVIATIONS)) {
const forms = Object.values(backups.units[charId]?.forms || {});
@@ -3640,6 +3790,8 @@ async function main() {
tokens: Object.keys(tokens).length, factionEntries: Object.keys(factions.entries).length,
backupUnits: Object.keys(backups.units).length,
backupForms: Object.values(backups.units).reduce((n, u) => n + Object.keys(u.forms).length, 0),
backupTokens: Object.keys(backups.tokens).length,
diyOwnedPicks: backups.diy.ownedPool.length,
},
sizes, totalBytes: total, out: OPTS.out, written: write, warnings, errors,
};