feedback5: merge W5H — 自选 data (72 owned 6★ without the 6 collab operators, every DIY form, summons, prototypes' locked skills), shared/diy.js, the sim fields DIY picks, OPERATOR_KIT_FILES with 推进之王 as the first operator kit, golden family diy, the art plan (--add-only)
Conflicts: tools/golden.mjs / test/golden/README.md take both new families (standins from W5G, diy from W5H); DATA.md / SIM.md keep both texts. data/ equals an offline build of the merged tools/build-data.mjs. Golden regenerated on the merged code: against feedback5 nothing moved, diy.json is new; against fb5-diy the W5G / W5J / W5L scenarios moved, and diy-004 / diy-006 (the prototype battles with Touch, changed by W5L) — diy-001/002/003/005 identical.
This commit is contained in:
commit
1006254beb
32 files changed
+2229
-174
No files matched your search
@@ -0,0 +1,93 @@
|
||||
// test/assets-diy.test.js — the art of the 自选 picks in the asset pipeline (tools/fetch-assets.mjs dataExtras,
|
||||
// tools/assets/plan.mjs patternOperator / buildPlan `extraOperators` / `moduleTypes`; docs/ASSETS.md): the owned-6★
|
||||
// units of data/backups.json are planned from research 07's URL patterns (avatar, portrait — E2 too —, battle Spine
|
||||
// Front / Back, every skill icon), their summons as tokens, and every module's type icon as manifest `modules`; the
|
||||
// committed data/assets.json lists them (checked against public/assets when present); --add-only never rewrites a file.
|
||||
// Run: node --test test/assets-diy.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dataExtras, parseArgs } from '../tools/fetch-assets.mjs';
|
||||
import { patternOperator, buildPlan } from '../tools/assets/plan.mjs';
|
||||
import { indexAudio } from '../tools/assets/audio.mjs';
|
||||
import { Downloader } from '../tools/assets/downloader.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const load = (f) => JSON.parse(readFileSync(join(ROOT, 'data', `${f}.json`), 'utf8'));
|
||||
const BACKUPS = load('backups');
|
||||
const CHESS = load('chess');
|
||||
const YUAN = 'https://raw.githubusercontent.com/yuanyan3060/ArknightsGameResource/main/';
|
||||
const FEXLI = 'https://raw.githubusercontent.com/fexli/ArknightsResource/main/';
|
||||
|
||||
test('dataExtras: every backups.json unit (skills by index, once), the 自选 summons, the type icon of every module of chess.json and backups.json', () => {
|
||||
const x = dataExtras(BACKUPS, CHESS);
|
||||
assert.deepEqual(Object.keys(x.extraOperators), Object.keys(BACKUPS.units));
|
||||
assert.deepEqual(x.extraOperators.char_112_siege, {
|
||||
name: '推进之王', subProfessionId: 'pioneer', nationId: 'victoria',
|
||||
skills: [{ index: 0, skillId: 'skcom_charge_cost[3]', iconId: 'skcom_charge_cost[3]' }, { index: 1, skillId: 'skchr_siege_2', iconId: 'skchr_siege_2' }, { index: 2, skillId: 'skchr_siege_3', iconId: 'skchr_siege_3' }],
|
||||
});
|
||||
assert.deepEqual(x.tokenIds, Object.keys(BACKUPS.tokens).sort());
|
||||
const types = new Set();
|
||||
for (const c of Object.values(CHESS)) for (const m of c.modules || []) types.add(m.typeIcon);
|
||||
for (const u of Object.values(BACKUPS.units)) for (const f of Object.values(u.forms)) for (const m of f.modules || []) types.add(m.typeIcon);
|
||||
assert.deepEqual(x.moduleTypes, [...types].sort());
|
||||
assert.ok(x.moduleTypes.includes('sol-x') && x.moduleTypes.includes('sol-y'));
|
||||
assert.deepEqual(dataExtras(null, null), { extraOperators: {}, tokenIds: [], moduleTypes: [] });
|
||||
});
|
||||
|
||||
test('patternOperator / buildPlan: an operator research 07 lacks gets the 07 URL patterns; known operators keep their research record; module type icons become `modules`', () => {
|
||||
const o = patternOperator('char_112_siege', { subProfessionId: 'pioneer', skills: [{ index: 0, skillId: 'skcom_charge_cost[3]', iconId: 'skcom_charge_cost[3]' }] });
|
||||
assert.deepEqual([o.avatar.e0e1.url, o.avatar.e2.url, o.portrait.e0e1.url, o.portrait.e2.url],
|
||||
[`${YUAN}avatar/char_112_siege.png`, `${YUAN}avatar/char_112_siege_2.png`, `${YUAN}portrait/char_112_siege_1.png`, `${YUAN}portrait/char_112_siege_2.png`]);
|
||||
assert.equal(o.battleSpine.front.skel, `${FEXLI}spine/char_112_siege/char_112_siege/Front/char_112_siege.skel`);
|
||||
assert.equal(o.battleSpine.back.png, `${FEXLI}spine/char_112_siege/char_112_siege/Back/char_112_siege.png`);
|
||||
assert.equal(o.skills[0].icon.url, `${YUAN}skill/skill_icon_skcom_charge_cost%5B3%5D.png`, 'brackets percent-encoded');
|
||||
assert.match(o.subProfessionIcon, /arts\/ui\/subprofessionicon\/sub_pioneer_icon\.png$/);
|
||||
const known = { name: 'x', avatar: { e0e1: { url: 'https://example.invalid/known.png' } }, skills: [] };
|
||||
const t = buildPlan({
|
||||
assets07: { operators: { char_609_acguad: known } }, ops03: {}, enemies05: {}, maps05: {}, audio: indexAudio({}), modelsData: {},
|
||||
extraOperators: { char_112_siege: dataExtras(BACKUPS, CHESS).extraOperators.char_112_siege, char_609_acguad: { skills: [] }, 'bad id': {} },
|
||||
moduleTypes: ['sol-x', 'sol-x', 'bad/type'],
|
||||
});
|
||||
const c = t.template.chars.char_112_siege;
|
||||
assert.deepEqual([c.avatar.alts[0].rel, c.avatarE2.alts[0].rel, c.portrait.alts[0].rel, c.portraitE2.alts[0].rel],
|
||||
['char/avatar/char_112_siege.png', 'char/avatar/char_112_siege_2.png', 'char/portrait/char_112_siege_1.png', 'char/portrait/char_112_siege_2.png']);
|
||||
assert.deepEqual(c.spine, { front: { model: 'op:char_112_siege:front' }, back: { model: 'op:char_112_siege:back' } });
|
||||
assert.deepEqual(t.models.get('op:char_112_siege:front').skillIndices, [0, 1, 2], 'a Spine clip per skill index');
|
||||
for (const id of ['skcom_charge_cost[3]', 'skchr_siege_2', 'skchr_siege_3']) assert.ok(t.template.skills[id] && t.template.skillsById[id] === id, id);
|
||||
assert.equal(t.template.chars.char_609_acguad.avatar.alts[0].urls[0], 'https://example.invalid/known.png', 'research 07 wins for an operator it lists');
|
||||
assert.equal(t.template.chars['bad id'], undefined);
|
||||
assert.deepEqual(Object.keys(t.template.modules), ['sol-x']);
|
||||
assert.match(t.template.modules['sol-x'].alts[0].urls[0], /arts\/ui\/uniequiptype\/sol-x\.png$/);
|
||||
assert.equal(t.template.prof.sub.pioneer.alts[0].rel, 'prof/sub/pioneer.png');
|
||||
});
|
||||
|
||||
test('--add-only: parsed, refused with --prune / --force; its downloader keeps every existing file and records what it wrote', async () => {
|
||||
assert.equal(parseArgs(['--add-only']).addOnly, true);
|
||||
assert.equal(parseArgs([]).addOnly, false);
|
||||
assert.throws(() => parseArgs(['--add-only', '--prune']), /never deletes or rewrites/);
|
||||
assert.throws(() => parseArgs(['--add-only', '--force']), /never deletes or rewrites/);
|
||||
const dl = new Downloader({ root: join(ROOT, 'data'), ledgerPath: join(ROOT, '.cache', 'test-never-written.json'), keepExisting: true, force: true, log() {} });
|
||||
assert.equal(dl.force, false, '--force never applies');
|
||||
assert.equal(await dl.existingSize({ rel: 'chess.json', urls: [], kind: 'png', bytes: 1 }), readFileSync(join(ROOT, 'data', 'chess.json')).length, 'an existing file is kept whatever its size / kind');
|
||||
assert.equal(await dl.existingSize({ rel: 'no-such-file.png', urls: [], kind: 'png' }), -1);
|
||||
assert.deepEqual([...dl.written], []);
|
||||
});
|
||||
|
||||
test('the committed data/assets.json lists every 自选 operator\'s avatar, portrait, Front Spine and skill icons, and a type icon per module (files on disk when public/assets is here)', () => {
|
||||
const m = load('assets');
|
||||
const disk = existsSync(join(ROOT, 'public', 'assets', 'char'));
|
||||
const onDisk = (u) => !disk || existsSync(join(ROOT, 'public', u));
|
||||
for (const id of BACKUPS.diy.ownedPool) {
|
||||
const c = m.chars[id];
|
||||
assert.ok(c && c.avatar && c.portrait && c.spine?.front?.skel, `${id}: art`);
|
||||
for (const u of [c.avatar, c.portrait, c.spine.front.skel, c.spine.front.atlas, ...c.spine.front.textures]) assert.ok(onDisk(u), `${id}: ${u} on disk`);
|
||||
assert.deepEqual(Object.keys(c.spine.front.anims.skills || {}).sort(), ['0', '1', '2'], `${id}: a clip per skill index`);
|
||||
for (const s of BACKUPS.units[id].forms['2/1/4/0'].skills) assert.ok(m.skills[m.skillsById[s.skillId]] || m.skills[s.iconId], `${id}: skill icon ${s.skillId}`);
|
||||
}
|
||||
for (const t of dataExtras(BACKUPS, CHESS).moduleTypes) assert.ok(m.modules[t] && onDisk(m.modules[t]), `module type icon ${t}`);
|
||||
assert.equal(m.stats.modules, Object.keys(m.modules).length);
|
||||
});
|
||||
+97
-13
@@ -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)', () => {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// test/content/diy.test.js — 自选 pieces in battle (DATA.md §18, docs/SIM.md §12): simdata getChess(slotId, { diy }) /
|
||||
// getDiy, the PlayerBattleInput `diy` field through Battle, the BattleSpec path and the per-battle loadout view, the
|
||||
// kit lookup by charId (an owned 6★'s operator kit, a prototype's stand-in kit, a 预备干员's generic kit, the generic kit
|
||||
// for an operator without one), the summons of an owned pick (data/backups.json `tokens`, getToken with the pick as the
|
||||
// owner's loadout) and the wire info. The 推进之王 kit itself: test/content/op_siege.test.js.
|
||||
// Run: node --test test/content/diy.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { makeBattle, enemyRec, checkInvariants, flatStage, flatRoutes } from '../helpers/battleHarness.js';
|
||||
import { getDefaultSource, DataSource } from '../../server/sim/simdata.js';
|
||||
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
|
||||
import { unitInfo } from '../../server/sim/snapshot.js';
|
||||
import { isDiyDef, kitOf } from '../../server/sim/content/index.js';
|
||||
|
||||
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
|
||||
const BACKUPS = load('backups');
|
||||
const SIEGE = 'char_112_siege';
|
||||
const T5 = 'chess_char_5_diy1_a', T6 = 'chess_char_6_diy1_a';
|
||||
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
|
||||
const ENEMIES = { enemy_dummy: dummy('enemy_dummy') };
|
||||
|
||||
function battle(units, o = {}) {
|
||||
return makeBattle({ defs: { enemies: ENEMIES }, units, timeLimit: 300, autoFinish: false, seed: o.seed ?? 7, flags: { dpPerSec: 0 }, ...o });
|
||||
}
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
|
||||
test('getChess(slotId, { diy }): the 自选 def — the slot\'s ids / tier, the pick\'s body, skill, module and bonds; diyFor, charId, loadout.diy, tokenOwner; cached; null for an illegal pick', () => {
|
||||
const ds = getDefaultSource();
|
||||
const pick = { charId: SIEGE, skillIndex: 0, uniEquipId: 'uniequip_003_siege' };
|
||||
const d = ds.getChess('chess_char_6_diy1_b', { diy: pick });
|
||||
assert.deepEqual([d.id, d.baseId, d.golden, d.tier, d.charId, d.diyFor, d.name, d.profession], ['chess_char_6_diy1_b', T6, true, 6, SIEGE, T6, '推进之王', 'PIONEER']);
|
||||
assert.deepEqual([d.bonds, d.raw.garrisonIds, d.skill.id, d.raw.module.id, d.raw.module.level, d.raw.module.active], [['victoriaShip'], [], 'skcom_charge_cost[3]', 'uniequip_003_siege', 3, true]);
|
||||
assert.deepEqual(d.loadout.diy, pick);
|
||||
assert.equal(d.tokenOwner, 'char_112_siege@2/60/7/3');
|
||||
assert.ok(isDiyDef(d) && Object.isFrozen(d));
|
||||
assert.equal(ds.getChess('chess_char_6_diy1_b', { diy: { ...pick } }), d, 'cached per (id, pick)');
|
||||
assert.equal(ds.getChess(T6, { diy: { charId: 'char_601_cguard' } }), null, 'a reserve is no tier-6 pick');
|
||||
assert.equal(ds.getChess(T5, { diy: { charId: 'char_609_acguad', skillIndex: 0 } }), null, 'a prototype off its locked skill');
|
||||
assert.equal(ds.getChess('chess_char_5_01_a', { diy: pick }), null, 'not a slot');
|
||||
const proto = ds.getChess(T5, { diy: { charId: 'char_617_sharp2' } });
|
||||
assert.deepEqual([proto.skill.id, proto.loadout.diy], ['skchr_sharp2_1', { charId: 'char_617_sharp2', skillIndex: 0, uniEquipId: 'uniequip_002_sharp2' }], '领主·Sharp: S1, its module (inactive on the normal form)');
|
||||
assert.equal(proto.raw.module.active, false);
|
||||
});
|
||||
|
||||
test('a battle fields 自选 pieces by their kit: an owned 6★\'s operator kit (keyed by its charId), a prototype\'s stand-in kit, a 预备干员\'s generic kit, the generic kit (+ its exact talents) without a kit', () => {
|
||||
// an injected kit keyed by the operator's charId — what kits/ops/op-<codename>.js registers (KITS[charId])
|
||||
const siegeKit = () => ({ skills: { skchr_siege_2: { kind: 'charges' } }, talents: [], diyProbe: true });
|
||||
const h = battle([
|
||||
{ diy: { slot: 5, charId: SIEGE, skillIndex: 1 }, row: 10, col: 3 },
|
||||
{ diy: { slot: 'chess_char_5_diy2_a', charId: 'char_609_acguad' }, elite: true, row: 10, col: 5 },
|
||||
{ diy: { slot: T6, charId: 'char_617_sharp2' }, elite: true, row: 10, col: 7 },
|
||||
{ diy: { slot: 'chess_char_6_diy2_a', charId: 'char_017_huang', skillIndex: 2 }, row: 11, col: 3 },
|
||||
{ diy: { slot: 'chess_char_6_diy2_a', charId: 'char_601_cguard' }, row: 11, col: 5 }, // illegal at tier 6: never fielded
|
||||
], { kits: { [SIEGE]: siegeKit, chess_char_5_diy1_a: () => { throw new Error('a 自选 piece never runs a kit of its slot id'); } } });
|
||||
h.step();
|
||||
const [siege, sharp, sharp2, huang] = [1, 2, 3, 4].map((uid) => h.unit(uid));
|
||||
assert.equal(h.unit(5), null, 'an illegal pick fields nothing');
|
||||
assert.deepEqual([siege.defId, siege.def.charId, siege.skill.id, siege.kit.diyProbe, siege.kit.skillSource], [T5, SIEGE, 'skchr_siege_2', true, 'skills']);
|
||||
assert.equal(kitOf(siege.def, { [T5]: 'slot', chess_char_5_diy1: 'bare', [SIEGE]: 'mine' }), 'mine', 'kitOf: by charId only');
|
||||
assert.equal(kitOf(siege.def, { [T5]: 'slot' }), undefined);
|
||||
assert.deepEqual([sharp.defId, sharp.def.charId, sharp.skill.id, !!sharp.kit.generic, sharp.kit.skillSource, sharp.def.raw.module.level], ['chess_char_5_diy2_b', 'char_609_acguad', 'skchr_acguad_3', false, 'skills', 1]);
|
||||
assert.deepEqual([sharp2.skill.id, !!sharp2.kit.generic, sharp2.def.raw.module.level, sharp2.def.raw.module.active], ['skchr_sharp2_1', false, 3, true], '领主·Sharp at tier 6: S1 + LOR-X stage 3');
|
||||
assert.deepEqual([huang.def.charId, huang.skill.id, !!huang.kit.generic, huang.def.bonds], ['char_017_huang', 'skchr_huang_3', true, ['yanShip', 'victoriaShip']], '煌 has no kit yet: the generic kit');
|
||||
// the elite reserve uses the generic kit with its exact stat talents (as a 补位 stand-in does)
|
||||
const h2 = battle([{ diy: { slot: 5, charId: 'char_601_cguard' }, elite: true, row: 10, col: 3 }]);
|
||||
h2.step();
|
||||
const g = h2.unit(1);
|
||||
assert.deepEqual([g.def.charId, g.skill.id, !!g.kit.generic], ['char_601_cguard', 'skcom_atk_up[3]', true], '预备干员-近卫 at tier 5: S3 攻击力强化·γ型, its 补位 row');
|
||||
assert.ok(Math.abs(g.s.atk - g.base.atk * 1.08) < 1e-6, 'its talent 攻击提升 (攻击力+8%) through genericTalents');
|
||||
done(h);
|
||||
done(h2);
|
||||
});
|
||||
|
||||
test('BattleSpec path: buildBattleSpec keeps a well-formed `diy` (and drops a malformed one); the spec battle fields the 自选 def, with the per-unit pick exact when two players fill one slot id differently', () => {
|
||||
const players = [
|
||||
{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, units: [{ uid: 1, kind: 'chess', chessId: 'chess_char_5_diy1_b', diy: { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege', extra: 1 }, row: 10, col: 4 }] },
|
||||
{ playerId: 'p2', seat: 1, side: 'L', colOffset: 0, bonds: {}, units: [
|
||||
{ uid: 1, kind: 'chess', chessId: 'chess_char_5_diy1_b', diy: { charId: 'char_611_acnipe' }, row: 11, col: 4 },
|
||||
{ uid: 2, kind: 'chess', chessId: 'chess_char_5_diy2_a', diy: { charId: 'not ok!' }, row: 12, col: 4 },
|
||||
] },
|
||||
];
|
||||
const spec = buildBattleSpec({ battleId: 'diy', kind: 'normal', seed: 3, stageId: null, players, spawns: [], routes: flatRoutes(), timeLimit: 30 });
|
||||
assert.deepEqual(spec.players[0].units[0].diy, { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }, 'reduced to the pick');
|
||||
assert.deepEqual(spec.players[1].units[0].diy, { charId: 'char_611_acnipe' });
|
||||
assert.equal('diy' in spec.players[1].units[1], false, 'a malformed pick is dropped');
|
||||
// the same players through the harness (a flat stage; the spec carries no stage object): each unit its own pick
|
||||
const h = makeBattle({ defs: { enemies: ENEMIES }, players: spec.players, timeLimit: 30, autoFinish: false, stage: flatStage(), flags: { dpPerSec: 0 } });
|
||||
h.step();
|
||||
const mine = h.b.allyUnits.find((u) => u.ownerId === 'p1' && u.uid === 1);
|
||||
const theirs = h.b.allyUnits.find((u) => u.ownerId === 'p2' && u.uid === 1);
|
||||
assert.deepEqual([mine.def.charId, mine.skill.id, theirs.def.charId, theirs.skill.id], [SIEGE, 'skchr_siege_3', 'char_611_acnipe', 'skchr_acnipe_3']);
|
||||
assert.ok(h.b.data.loadoutConflicts.includes('chess_char_5_diy1_b'), 'the id-only view notes the conflict; each unit still has its own def');
|
||||
assert.equal(h.b.allyUnits.some((u) => u.ownerId === 'p2' && u.uid === 2), false, 'a DIY slot without a pick fields nothing');
|
||||
done(h);
|
||||
// and createBattleFromSpec on a real stage builds the same 自选 def
|
||||
const stageId = Object.keys(load('stages')).find((id) => load('stages')[id].kind !== 'unite');
|
||||
const b = createBattleFromSpec({ ...spec, stageId, routes: [] }, getDefaultSource(), { quiet: true });
|
||||
b.step();
|
||||
assert.deepEqual(b.allyUnits.filter((u) => u.kind === 'op').map((u) => [u.ownerId, u.def.charId, u.def.loadout.diy.skillIndex]), [['p1', SIEGE, 2], ['p2', 'char_611_acnipe', 2]]);
|
||||
});
|
||||
|
||||
test('summons of an owned pick: battle.tokenDef resolves the variant of its form and pick (令 at tier 6 elite: S3 makes 弦惊, not 清平)', () => {
|
||||
const h = battle([{ diy: { slot: 6, charId: 'char_2023_ling', skillIndex: 2, uniEquipId: 'uniequip_002_ling' }, elite: true, row: 10, col: 4 }]);
|
||||
h.step();
|
||||
const u = h.unit(1);
|
||||
assert.equal(u.def.tokenOwner, 'char_2023_ling@2/60/7/3');
|
||||
const rec3 = BACKUPS.tokens.token_10020_ling_soul3;
|
||||
const v = rec3.variants['char_2023_ling@2/60/7/3'];
|
||||
const t3 = h.b.tokenDef('token_10020_ling_soul3', u);
|
||||
assert.deepEqual([t3.id, t3.sources, t3.skill?.id], ['token_10020_ling_soul3', ['skill', 'display'], v.bySkill['2'].skill.skillId]);
|
||||
const stats = { ...v.stats, ...(v.byModule?.uniequip_002_ling?.stats ?? {}) };
|
||||
assert.deepEqual([t3.stats.maxHp, t3.stats.atk, t3.stats.def], [stats.maxHp, stats.atk, stats.def], 'the form\'s stats with the module\'s token attributes');
|
||||
assert.equal(h.b.producesToken(u, 'token_10020_ling_soul3'), true);
|
||||
assert.equal(h.b.producesToken(u, 'token_10020_ling_soul1'), false, 'S3 does not make 清平');
|
||||
const t1 = h.b.tokenDef('token_10020_ling_soul1', u);
|
||||
assert.deepEqual(t1.sources, ['display']);
|
||||
// the same token of the normal form (tier 5, no module) is the form's own variant
|
||||
const h2 = battle([{ diy: { slot: 5, charId: 'char_2023_ling', skillIndex: 0 }, row: 10, col: 4 }]);
|
||||
h2.step();
|
||||
const n1 = h2.b.tokenDef('token_10020_ling_soul1', h2.unit(1));
|
||||
assert.deepEqual([n1.sources, n1.stats.atk], [['skill', 'display'], BACKUPS.tokens.token_10020_ling_soul1.variants['char_2023_ling@2/1/4/0'].stats.atk]);
|
||||
done(h);
|
||||
done(h2);
|
||||
});
|
||||
|
||||
test('wire info: a 自选 piece carries its pick (defId = the slot; spine / avatar = the operator\'s; no 补位 mark)', () => {
|
||||
const h = battle([{ diy: { slot: 5, charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }, elite: true, row: 10, col: 4 }]);
|
||||
h.step();
|
||||
const info = unitInfo(h.unit(1));
|
||||
assert.deepEqual([info.defId, info.spine, info.avatar, info.tier, info.golden, info.skillIndex, info.moduleId, info.standInFor],
|
||||
['chess_char_5_diy1_b', SIEGE, `${SIEGE}_2`, 5, true, 2, 'uniequip_002_siege', undefined]);
|
||||
assert.deepEqual(info.diy, { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' });
|
||||
const plain = battle([{ chessId: 'chess_char_1_02_a', row: 10, col: 4 }]);
|
||||
plain.step();
|
||||
assert.equal(unitInfo(plain.unit(1)).diy, undefined);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('a DataSource without backups.json fields no 自选 piece (null def), and its plain DIY slot def is unchanged', () => {
|
||||
const ds = new DataSource({ chess: { [T5]: load('chess')[T5] } }, null);
|
||||
assert.equal(ds.getChess(T5, { diy: { charId: SIEGE, skillIndex: 0 } }), null);
|
||||
assert.equal(ds.getChess(T5).name, '甄选干员');
|
||||
});
|
||||
@@ -1,16 +1,21 @@
|
||||
// The operator kit layout (server/sim/content/kits/README.md): every file of kits/ops/ is listed once in kits/index.js
|
||||
// KIT_FILES or STANDIN_KIT_FILES and nothing else is; a file `<chessId>-<codename>.js` registers exactly the kit of
|
||||
// `<chessId>_a`, whose data record has that charId code name; a 补位 file `standin-<codename>.js` registers exactly the
|
||||
// kit of the stand-in character whose charId has that code name (data/backups.json `units`); the registry is the tier
|
||||
// groups in order, then the stand-ins, and content/index.js serves that object; no kit file imports a Node-only or
|
||||
// server-only module or reads the clock / Math.random (the sim is deterministic and also runs in the browser, /sim/).
|
||||
// KIT_FILES, STANDIN_KIT_FILES or OPERATOR_KIT_FILES and nothing else is; a file `<chessId>-<codename>.js` registers
|
||||
// exactly the kit of `<chessId>_a`, whose data record has that charId code name; a 补位 file `standin-<codename>.js`
|
||||
// registers exactly the kit of the stand-in character whose charId has that code name (data/backups.json `units`); a
|
||||
// 自选 file `op-<codename>.js` registers exactly the kit of the owned-6★ pick with that code name (data/backups.json
|
||||
// `diy.ownedPool`) and writes every skill of the operator under `skills`; the registry is the tier groups in order, then
|
||||
// the stand-ins, then the 自选 operators, and content/index.js serves that object; KITTED_CHARS = the 预备干员 + every
|
||||
// registered stand-in / operator charId; no kit file imports a Node-only or server-only module or reads the clock /
|
||||
// Math.random (the sim is deterministic and also runs in the browser, /sim/).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { KIT_FILES, TIER_KITS, KITS, STANDIN_KIT_FILES, STANDIN_KITS } from '../../server/sim/content/kits/index.js';
|
||||
import { KIT_FILES, TIER_KITS, KITS, STANDIN_KIT_FILES, STANDIN_KITS, OPERATOR_KIT_FILES, OPERATOR_KITS, GENERIC_KIT_CHARS, KITTED_CHARS } from '../../server/sim/content/kits/index.js';
|
||||
import { KITS as CONTENT_KITS } from '../../server/sim/content/index.js';
|
||||
import { diyRecord } from '../../shared/diy.js';
|
||||
import { normalizeChess } from '../../server/sim/simdata.js';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const KIT_DIR = join(ROOT, 'server/sim/content/kits');
|
||||
@@ -19,7 +24,7 @@ const BACKUPS = JSON.parse(readFileSync(join(ROOT, 'data/backups.json'), 'utf8')
|
||||
const jsIn = (dir) => readdirSync(join(KIT_DIR, dir)).filter((f) => f.endsWith('.js')).sort();
|
||||
|
||||
test('kits/index.js lists every kit file of kits/ops/ exactly once, and only those', () => {
|
||||
const listed = [...KIT_FILES.flat(), ...STANDIN_KIT_FILES];
|
||||
const listed = [...KIT_FILES.flat(), ...STANDIN_KIT_FILES, ...OPERATOR_KIT_FILES];
|
||||
assert.equal(new Set(listed).size, listed.length, 'a file listed twice');
|
||||
assert.deepEqual([...listed].sort(), jsIn('ops'));
|
||||
});
|
||||
@@ -39,6 +44,32 @@ test('a stand-in kit file standin-<codename>.js registers the kit of that stand-
|
||||
}
|
||||
});
|
||||
|
||||
test('a 自选 kit file op-<codename>.js registers the kit of that owned-6★ pick (data/backups.json diy.ownedPool), every skill under `skills`', async () => {
|
||||
const pool = BACKUPS.diy.ownedPool;
|
||||
for (const file of OPERATOR_KIT_FILES) {
|
||||
const m = /^op-([a-z0-9]+)\.js$/.exec(file);
|
||||
assert.ok(m, `${file}: name`);
|
||||
const charId = pool.find((id) => id.replace(/^char_\d+_/, '') === m[1]);
|
||||
assert.ok(charId, `${file}: no owned-6★ pick with the code name ${m[1]}`);
|
||||
const mod = await import(`../../server/sim/content/kits/ops/${file}`);
|
||||
assert.deepEqual(Object.keys(mod.default), [charId], `${file}: default export`);
|
||||
assert.equal(typeof mod.default[charId], 'function', `${file}: kit builder`);
|
||||
assert.equal(OPERATOR_KITS[charId], mod.default[charId], `${file}: registered`);
|
||||
assert.equal(KITS[charId], mod.default[charId], `${file}: in the merged registry`);
|
||||
// every skill of every slot form is authored (a 自选 piece has no default skill: the pick chooses any), built from the
|
||||
// composed record and its def as the sim does (shared/diy.js diyRecord, simdata normalizeChess)
|
||||
for (const slot of ['chess_char_5_diy1_a', 'chess_char_6_diy1_a']) {
|
||||
for (const elite of [false, true]) {
|
||||
const rec = diyRecord(slot, { charId, skillIndex: 0 }, { elite, data: { chess: CHESS, backups: BACKUPS } });
|
||||
assert.ok(rec, `${file}: a legal pick of ${slot}`);
|
||||
const kit = mod.default[charId](rec.skill.bb, rec, normalizeChess(rec));
|
||||
assert.deepEqual(Object.keys(kit.skills || {}).sort(), rec.skills.map((s) => s.skillId).sort(), `${file}@${slot}${elite ? ' elite' : ''}: skills`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual([...KITTED_CHARS].sort(), [...new Set([...GENERIC_KIT_CHARS, ...Object.keys(STANDIN_KITS), ...Object.keys(OPERATOR_KITS)])].sort());
|
||||
});
|
||||
|
||||
test('a kit file <chessId>-<codename>.js registers the kit of <chessId>_a (codename: its charId without char_<n>_)', async () => {
|
||||
for (const file of KIT_FILES.flat()) {
|
||||
const m = /^(chess_char_\w+?)-([a-z0-9]+)\.js$/.exec(file);
|
||||
@@ -54,9 +85,9 @@ test('a kit file <chessId>-<codename>.js registers the kit of <chessId>_a (coden
|
||||
}
|
||||
});
|
||||
|
||||
test('the registry is the tier groups in KIT_FILES order, then the stand-ins, one key per file; content/index.js serves the same object', () => {
|
||||
assert.deepEqual(Object.keys(KITS), [...TIER_KITS.flatMap((t) => Object.keys(t)), ...Object.keys(STANDIN_KITS)]);
|
||||
assert.equal(Object.keys(KITS).length, KIT_FILES.flat().length + STANDIN_KIT_FILES.length, 'one key per file, none twice');
|
||||
test('the registry is the tier groups in KIT_FILES order, then the stand-ins, then the 自选 operators, one key per file; content/index.js serves the same object', () => {
|
||||
assert.deepEqual(Object.keys(KITS), [...TIER_KITS.flatMap((t) => Object.keys(t)), ...Object.keys(STANDIN_KITS), ...Object.keys(OPERATOR_KITS)]);
|
||||
assert.equal(Object.keys(KITS).length, KIT_FILES.flat().length + STANDIN_KIT_FILES.length + OPERATOR_KIT_FILES.length, 'one key per file, none twice');
|
||||
assert.equal(CONTENT_KITS, KITS);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// test/content/op_siege.test.js — the 自选 operator kit of 推进之王 (char_112_siege, 6★ 尖兵; kit
|
||||
// server/sim/content/kits/ops/op-siege.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SOL-X
|
||||
// “糖果盒” or SOL-Y “初诺” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of
|
||||
// that slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_siege.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
|
||||
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
|
||||
|
||||
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
|
||||
const CHESS = load('chess');
|
||||
const BACKUPS = load('backups');
|
||||
const SIEGE = 'char_112_siege';
|
||||
const FORMS = BACKUPS.units[SIEGE].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const SOLX = 'uniequip_002_siege', SOLY = 'uniequip_003_siege';
|
||||
const S1 = 'skcom_charge_cost[3]', S2 = 'skchr_siege_2', S3 = 'skchr_siege_3';
|
||||
/** The unit form of a slot (tier, normal / elite). */
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
|
||||
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
|
||||
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
|
||||
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
|
||||
const ENEMIES = { enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }) };
|
||||
/** Every 自选 form: [tier, elite, module]. */
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SOLX, SOLY].map((m) => [t, true, m]))];
|
||||
|
||||
/** A battle with 推进之王 as uid 1 at (row, col) facing RIGHT, plus `others`. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5 } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 600, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: SIEGE, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
test('推进之王 in every 自选 form: its operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 2, melee ground-only, 维多利亚, no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[SIEGE], KITS[SIEGE]);
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
for (const skill of [0, 1, 2]) {
|
||||
const { h, u } = field({ tier, elite, mod, skill });
|
||||
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
|
||||
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [SIEGE, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.base.bat], [2, 'melee', false, 1.05], `${label(f)}: 尖兵`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['victoriaShip'], []], `${label(f)}: bonds / 特质`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// the numbers of the forms (zh_CN): E2 Lv1 1643 / 422 / 307, E2 Lv60 2046 / 484 / 358; SOL-X +60 / +40 → +75 / +50 ATK / DEF,
|
||||
// SOL-Y +200 / +65 → +260 / +82 HP / ATK
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1643, 422, 2046, 484]);
|
||||
assert.deepEqual([modOf(5, SOLX).attr, modOf(6, SOLX).attr, modOf(5, SOLY).attr, modOf(6, SOLY).attr], [{ atk: 60, def: 40 }, { atk: 75, def: 50 }, { maxHp: 200, atk: 65 }, { maxHp: 260, atk: 82 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 推进之王 is offered at tiers 5 and 6 (he has a kit) and a roster with him passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(SIEGE));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(SIEGE), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: SIEGE, skillIndex: 1, uniEquipId: SOLY } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[6]]: { charId: SIEGE, skillIndex: 1, uniEquipId: SOLY } } });
|
||||
});
|
||||
|
||||
test('S1 冲锋号令·γ型 (AUTO): +12 DP as soon as its SP is full — no enemy needed (the kit\'s SP_FULL, as 德克萨斯 casts it); 41 / 38 SP from 12 / 14', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u } = field({ tier, elite, mod: elite ? SOLX : null, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spCost, sk.initSp, sk.bb.cost], ['SP_FULL', elite ? 38 : 41, elite ? 14 : 12, 12], `T${tier}`);
|
||||
h.b.players[0].dp = 0;
|
||||
const wait = sk.spCost - sk.initSp;
|
||||
h.run(wait - 0.5);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: not before its SP is full`);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `T${tier}: cast at full SP (${wait} s), no enemy on the field`);
|
||||
approx(h.b.players[0].dp, 12, `T${tier}: +12 DP`);
|
||||
h.run(sk.spCost + 0.1);
|
||||
assert.equal(u.skill.activations, 2, `T${tier}: again ${sk.spCost} s later`);
|
||||
approx(h.b.players[0].dp, 24, `T${tier}: +12 DP each cast`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 跃空锤 (AUTO, 2 / 3 charges): the next attack hits every ground enemy on her x-5 for 250 % / 280 % ATK physical and gives 3 DP; one charge per attack; outside x-5 and flyers untouched', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const { h, u } = field({ tier, elite, mod: elite ? SOLY : null, skill: 1 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.maxCharges, sk.bb.atk_scale, sk.bb.cost, sk.rangeId], ['DEFAULT', elite ? 3 : 2, elite ? 2.8 : 2.5, 3, 'x-5'], `T${tier}`);
|
||||
const at = (pos, key = 'enemy_dummy') => h.spawn(key, { pos });
|
||||
const front = at([10, 6]), back = at([10, 4]), upE = at([9, 5]);
|
||||
const fly = at([11, 5], 'enemy_fly');
|
||||
const diag = at([9, 6]), far = at([10, 7]);
|
||||
u.skill.gainSp(999);
|
||||
assert.equal(u.skill.charges, u.skill.maxCharges, `T${tier}: charges stored`);
|
||||
h.b.players[0].dp = 0;
|
||||
const n0 = atkHits(h, u).length;
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1 && atkHits(h, u).length > n0, 3), `T${tier}: cast on her next attack`);
|
||||
const first = atkHits(h, u).slice(n0);
|
||||
const id = first[0].dmg.attackId;
|
||||
const hit = first.filter((c) => c.dmg.attackId === id);
|
||||
assert.deepEqual(hit.map((c) => c.target).sort((a, b) => a.id - b.id), [front, back, upE].sort((a, b) => a.id - b.id), `T${tier}: her x-5 ground enemies`);
|
||||
for (const c of hit) {
|
||||
approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
|
||||
assert.deepEqual([c.type, c.dmg.isSkill], ['phys', true], `T${tier}: physical skill attack`);
|
||||
}
|
||||
for (const e of [fly, diag, far]) assert.ok(!hit.some((c) => c.target === e), `T${tier}: ${e.defId} at ${e.tileR},${e.tileC} untouched`);
|
||||
approx(h.b.players[0].dp, 3, `T${tier}: +3 DP`);
|
||||
assert.equal(u.skill.charges, u.skill.maxCharges - 1, `T${tier}: one charge spent`);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 1-1 after the attack`);
|
||||
// the stored charges go on her next attacks, one each
|
||||
h.runUntil(() => u.skill.activations === u.skill.maxCharges, 10);
|
||||
assert.equal(u.skill.charges, 0);
|
||||
approx(h.b.players[0].dp, 3 * u.skill.maxCharges, `T${tier}: +3 DP per charge`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 碎颅击 (MANUAL, data DEFAULT): 18 / 21 s, attack interval 1.05 + 1.0 s, every attack 290 % / 320 % ATK with 40 % to stun 0.8 / 1.1 s; all back after', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u } = field({ tier, elite, skill: 2, seed: 11 });
|
||||
assert.deepEqual([u.skill.rule, sk.duration, sk.bb['attack@atk_scale'], sk.bb['attack@buff_prob'], sk.bb['attack@stun'], sk.bb.base_attack_time],
|
||||
['DEFAULT', elite ? 21 : 18, elite ? 3.2 : 2.9, 0.4, elite ? 1.1 : 0.8, 1], `T${tier}`);
|
||||
approx(u.s.interval, 1.05, `T${tier}: own interval`);
|
||||
u.skill.gainSp(999);
|
||||
h.run(2);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in her range`);
|
||||
approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.01);
|
||||
approx(u.s.interval, 2.05, `T${tier}: +1.0 s`);
|
||||
const n0 = atkHits(h, u).length;
|
||||
u.skill.extend(400); // ≈ 200 attacks
|
||||
h.runUntil(() => !u.skill.active, 500);
|
||||
const hits = atkHits(h, u).slice(n0).filter((c) => c.dmg.isSkill);
|
||||
assert.ok(hits.length > 190, `T${tier}: ${hits.length} skill attacks`);
|
||||
for (const c of hits.slice(0, 5)) approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} % ATK`);
|
||||
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun' && c.target === e);
|
||||
assert.ok(stuns.length / hits.length > 0.32 && stuns.length / hits.length < 0.48, `T${tier}: ${stuns.length} / ${hits.length} stuns ≈ 40 %`);
|
||||
for (const c of stuns) approx(c.duration, sk.bb['attack@stun'], `T${tier}: stun ${sk.bb['attack@stun']} s`);
|
||||
approx(u.s.interval, 1.05, `T${tier}: interval back`);
|
||||
const n1 = atkHits(h, u).length;
|
||||
h.run(10);
|
||||
assert.ok(atkHits(h, u).slice(n1).every((c) => !c.dmg.isSkill && Math.abs(c.amount - u.s.atk) < 1e-6), `T${tier}: plain attacks once it ends`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T1 万兽之王: every 【先锋】 operator of the field ATK / DEF +8 % while she is deployed (not other professions); SOL-X stage 3 adds her own +8 % / +8 %', () => {
|
||||
const others = [{ uid: 2, chessId: 'chess_char_1_08_a', row: 11, col: 3 }, { uid: 3, chessId: 'chess_char_1_02_a', row: 11, col: 7 }];
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2, others });
|
||||
h.run(0.6);
|
||||
const texas = h.unit(2), yak = h.unit(3);
|
||||
const t0 = u.def.raw.talents.find((t) => t.index === 0);
|
||||
const extra = elite && mod === SOLX && tier === 6 ? 0.08 : 0;
|
||||
assert.deepEqual(texas.findBuff('talent:siege:pioneers')?.mods, { atkPct: 0.08, defPct: 0.08 }, `${label(f)}: 德克萨斯 (先锋)`);
|
||||
assert.equal(yak.findBuff('talent:siege:pioneers'), null, `${label(f)}: 角峰 (重装) gets nothing`);
|
||||
assert.deepEqual(u.findBuff('talent:siege:pioneers')?.mods, { atkPct: 0.08, defPct: 0.08 }, `${label(f)}: herself`);
|
||||
approx(u.s.atk, u.base.atk * (1 + 0.08 + extra), `${label(f)}: her ATK`);
|
||||
approx(u.s.def, u.base.def * (1 + 0.08 + extra), `${label(f)}: her DEF`);
|
||||
assert.equal(!!u.findBuff('talent:siege:self'), extra > 0, `${label(f)}: the SOL-X self extra`);
|
||||
if (extra) assert.match(t0.desc, /自身攻击力和防御力额外\+8%/, 'the module text');
|
||||
h.b.retreat(u);
|
||||
h.run(0.7);
|
||||
assert.equal(texas.findBuff('talent:siege:pioneers'), null, `${label(f)}: gone once she leaves the field`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 粉碎: an enemy falling on her x-5 gives 1 SP (flyers too, under her own 阻回 too, never during a timed skill); SOL-Y stage 3: 3 SP and 1 SP to another 【先锋】 operator', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const others = [{ uid: 2, chessId: 'chess_char_1_08_a', row: 12, col: 9 }];
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2, others });
|
||||
const texas = h.unit(2);
|
||||
const y3 = elite && mod === SOLY && tier === 6;
|
||||
const sp = y3 ? 3 : 1;
|
||||
const kill = (pos, key = 'enemy_dummy') => {
|
||||
const e = h.spawn(key, { pos });
|
||||
const [a, b] = [u.skill.sp, texas.skill.sp];
|
||||
h.b.kill(e, null);
|
||||
const r6 = (v) => Math.round(v * 1e6) / 1e6;
|
||||
return [r6(u.skill.sp - a), r6(texas.skill.sp - b)];
|
||||
};
|
||||
assert.deepEqual(kill([10, 4]), [sp, y3 ? 1 : 0], `${label(f)}: behind her (x-5)`);
|
||||
assert.deepEqual(kill([9, 5], 'enemy_fly'), [sp, y3 ? 1 : 0], `${label(f)}: a flyer above her`);
|
||||
assert.deepEqual(kill([9, 6]), [0, 0], `${label(f)}: diagonal — outside x-5`);
|
||||
assert.deepEqual(kill([10, 7]), [0, 0], `${label(f)}: two tiles ahead`);
|
||||
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: 5 });
|
||||
assert.equal(kill([10, 6])[0], sp, `${label(f)}: 无视自身的阻回`);
|
||||
h.b.removeBuff(u, 'test:noSp');
|
||||
u.skill.gainSp(999);
|
||||
u.skill.activate('test');
|
||||
assert.ok(u.skill.active);
|
||||
assert.equal(kill([11, 5])[0], 0, `${label(f)}: no SP while 碎颅击 runs`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('SOL-X “糖果盒” trait: ATK / DEF +8 % while she blocks an enemy (stages 1 and 3); none without it; SOL-Y\'s 首次部署时部署费用-4 changes nothing in battle', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2, row: 9, col: 5 });
|
||||
const solx = elite && mod === SOLX;
|
||||
if (solx) assert.deepEqual(u.def.raw.trait.bb, { atk: 0.08, def: 0.08 }, label(f));
|
||||
h.run(1);
|
||||
assert.equal(u.findBuff('trait:siege:block'), null, `${label(f)}: nothing blocked`);
|
||||
h.spawn('enemy_walk', { routeIndex: 0 });
|
||||
assert.ok(h.runUntil(() => u.blocking.length > 0, 30), `${label(f)}: the walker reaches her`);
|
||||
h.step();
|
||||
assert.equal(!!u.findBuff('trait:siege:block'), solx, `${label(f)}: blocking`);
|
||||
if (solx) assert.deepEqual(u.findBuff('trait:siege:block').mods, { atkPct: 0.08, defPct: 0.08 });
|
||||
if (elite && mod === SOLY) {
|
||||
assert.deepEqual(u.def.raw.talents.filter((t) => t.index === -1).map((t) => t.bb.runtime_cost).filter((v) => v != null), [-4], `${label(f)}: the data carries it`);
|
||||
assert.equal(u.base.cost, formOf(tier, true).stats.cost, `${label(f)}: no cost change`);
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
// test/diy.test.js — the 自选 rules of shared/diy.js over the real data (data/chess.json, data/backups.json; DATA.md §18):
|
||||
// the legal picks per tier (diyPool, with and without the kit registry), one pick against a slot (checkDiyPick: the
|
||||
// prototypes' locked skill / module, an owned operator's choice of skill and module at the slot's stage), the composed
|
||||
// record (diyRecord: the slot's identity, no 特质, derived bonds, the operator's body at the slot form) and a whole roster
|
||||
// (validateDiyPicks). Research 0.2.0 §2; the owner's decisions of 2026-10-05.
|
||||
// Run: node --test test/diy.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { diyPool, diySlot, diySlotIds, checkDiyPick, diyRecord, diyRecordOf, validateDiyPicks, lockedSelection, diyTokenOwner, DIY_TIERS } from '../shared/diy.js';
|
||||
import { unitForm, composeUnitRecord } from '../shared/standIn.js';
|
||||
import { composeStats, composeTalents } from '../shared/loadoutRecord.js';
|
||||
import { normalizeChess, resolveLoadout, loadoutRecord } from '../server/sim/simdata.js';
|
||||
import { KITTED_CHARS, GENERIC_KIT_CHARS, STANDIN_KITS, OPERATOR_KITS } from '../server/sim/content/kits/index.js';
|
||||
|
||||
const load = (f) => JSON.parse(readFileSync(new URL(`../data/${f}.json`, import.meta.url), 'utf8'));
|
||||
const chess = load('chess');
|
||||
const backups = load('backups');
|
||||
const DATA = { chess, backups };
|
||||
const { ownedPool, prototypes } = backups.diy;
|
||||
const ELITES = ['char_608_acpion', 'char_609_acguad', 'char_610_acfend', 'char_611_acnipe', 'char_612_accast', 'char_613_acmedc', 'char_614_acsupo', 'char_615_acspec', 'char_617_sharp2'];
|
||||
const RESERVES5 = ['char_601_cguard', 'char_602_cdfend', 'char_603_csnipe', 'char_604_ccast', 'char_605_cmedic', 'char_606_csuppo'];
|
||||
const COLLAB = ['char_456_ash', 'char_1029_yato2', 'char_4123_ela', 'char_4141_marcil', 'char_4182_oblvns', 'char_4217_makoto'];
|
||||
const SIEGE = 'char_112_siege';
|
||||
const T5 = 'chess_char_5_diy1_a', T5B = 'chess_char_5_diy2_a', T6 = 'chess_char_6_diy1_a', T6B = 'chess_char_6_diy2_a';
|
||||
|
||||
test('slots: two per tier (5, 6), each with its elite twin', () => {
|
||||
assert.deepEqual(DIY_TIERS, [5, 6]);
|
||||
assert.deepEqual(diySlotIds(DATA), [T5, T5B, T6, T6B]);
|
||||
assert.deepEqual(diySlot('chess_char_6_diy2_b', DATA) && { ...diySlot('chess_char_6_diy2_b', DATA), normal: undefined, golden: undefined },
|
||||
{ baseId: T6B, goldenId: 'chess_char_6_diy2_b', tier: 6, elite: true, normal: undefined, golden: undefined });
|
||||
assert.equal(diySlot('chess_char_5_01_a', DATA), null, 'not a slot');
|
||||
assert.equal(diyTokenOwner(SIEGE, chess.chess_char_6_diy1_b.status), 'char_112_siege@2/60/7/3');
|
||||
});
|
||||
|
||||
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 });
|
||||
assert.deepEqual(p5, [...RESERVES5, ...ELITES, ...ownedPool]);
|
||||
assert.deepEqual(p6, [...ELITES, ...ownedPool]);
|
||||
assert.deepEqual([p5.length, p6.length], [87, 81]);
|
||||
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);
|
||||
assert.deepEqual(diyPool(4, { data: DATA }), [], 'no 自选 slot at tier 4');
|
||||
// the kit registry: the 预备干员 run the generic kit (exact for their skills), the nine elites their stand-in kits, the
|
||||
// owned 6★ only with an operator kit file (kits/index.js OPERATOR_KIT_FILES), in pool order
|
||||
assert.deepEqual(GENERIC_KIT_CHARS.filter((id) => prototypes['5'].includes(id)), RESERVES5);
|
||||
for (const id of ELITES) assert.equal(typeof STANDIN_KITS[id], 'function', `${id}: stand-in kit`);
|
||||
const ops = ownedPool.filter((id) => typeof OPERATOR_KITS[id] === 'function');
|
||||
assert.deepEqual([...ops].sort(), Object.keys(OPERATOR_KITS).sort(), 'every operator kit is an owned-6★ pick');
|
||||
assert.deepEqual(diyPool(5, { data: DATA, kitted: KITTED_CHARS }), [...RESERVES5, ...ELITES, ...ops]);
|
||||
assert.deepEqual(diyPool(6, { data: DATA, kitted: KITTED_CHARS }), [...ELITES, ...ops]);
|
||||
assert.deepEqual(diyPool(6, { data: DATA, kitted: (id) => id === SIEGE }), [SIEGE], 'a predicate works too');
|
||||
assert.deepEqual(diyPool(5, { data: DATA, kitted: [] }), [], 'nothing kitted, nothing offered');
|
||||
});
|
||||
|
||||
test('checkDiyPick: a prototype carries its locked skill / module (与系统补位时一致) — omitted ⇒ filled in, another ⇒ refused', () => {
|
||||
for (const [slot, tier] of [[T5, 5], [T6, 6]]) {
|
||||
for (const id of prototypes[tier]) {
|
||||
const lk = lockedSelection(DATA, tier, id);
|
||||
assert.deepEqual(checkDiyPick(slot, { charId: id }, DATA), { ok: true, pick: { charId: id, ...lk } }, `${id}@${tier}`);
|
||||
assert.deepEqual(checkDiyPick(slot, { charId: id, skillIndex: lk.skillIndex, uniEquipId: lk.uniEquipId }, DATA).ok, true);
|
||||
const other = lk.skillIndex === 0 ? 1 : 0;
|
||||
if (unitForm(backups, id, chess[slot].status).skills.some((s) => s.index === other)) {
|
||||
assert.match(checkDiyPick(slot, { charId: id, skillIndex: other }, DATA).error, /prototype carries skill/, `${id}@${tier}: skill-locked`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(lockedSelection(DATA, 5, 'char_609_acguad'), { skillIndex: 2, uniEquipId: 'uniequip_002_acguad' });
|
||||
assert.deepEqual(lockedSelection(DATA, 6, 'char_617_sharp2'), { skillIndex: 0, uniEquipId: 'uniequip_002_sharp2' });
|
||||
assert.deepEqual(lockedSelection(DATA, 5, 'char_605_cmedic'), { skillIndex: 2, uniEquipId: null });
|
||||
assert.match(checkDiyPick(T5, { charId: 'char_609_acguad', uniEquipId: 'none' }, DATA).error, /prototype carries module/);
|
||||
assert.match(checkDiyPick(T5, { charId: 'char_609_acguad', uniEquipId: 'uniequip_001_acguad' }, DATA).error, /prototype carries module/);
|
||||
assert.deepEqual(checkDiyPick(T5, { charId: 'char_609_acguad', skillIndex: null, uniEquipId: null }, DATA).pick, { charId: 'char_609_acguad', skillIndex: 2, uniEquipId: 'uniequip_002_acguad' }, 'null = the locked selection');
|
||||
assert.equal(lockedSelection(DATA, 5, SIEGE), null, 'an owned operator has no lock');
|
||||
});
|
||||
|
||||
test('checkDiyPick: an owned 6★ chooses any of its 3 skills and any module of its elite form at the slot\'s stage (or none); illegal picks are refused', () => {
|
||||
for (const slot of [T5, T6]) {
|
||||
for (const s of [0, 1, 2]) {
|
||||
for (const m of [undefined, null, 'none', 'uniequip_002_siege', 'uniequip_003_siege']) {
|
||||
const c = checkDiyPick(slot, { charId: SIEGE, skillIndex: s, uniEquipId: m }, DATA);
|
||||
assert.deepEqual(c, { ok: true, pick: { charId: SIEGE, skillIndex: s, uniEquipId: m && m !== 'none' ? m : null } }, `${slot} S${s + 1} ${m}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const bad = [
|
||||
[T5, { charId: SIEGE }, /needs a skill index/],
|
||||
[T5, { charId: SIEGE, skillIndex: 3 }, /no skill 3/],
|
||||
[T5, { charId: SIEGE, skillIndex: 0, uniEquipId: 'uniequip_001_siege' }, /no module uniequip_001_siege/],
|
||||
[T5, { charId: SIEGE, skillIndex: 0, uniEquipId: 'uniequip_002_acguad' }, /no module/],
|
||||
[T6, { charId: 'char_601_cguard' }, /not a tier-6 自选 pick/],
|
||||
[T5, { charId: 'char_600_cpione' }, /not a tier-5/],
|
||||
[T5, { charId: 'char_607_cspec' }, /not a tier-5/],
|
||||
[T5, { charId: 'char_102_texas', skillIndex: 0 }, /not a tier-5/],
|
||||
[T6, { charId: 'char_1012_skadi2', skillIndex: 0 }, /not a tier-6/],
|
||||
[T5, { charId: 'char_456_ash', skillIndex: 0 }, /not a tier-5/],
|
||||
[T5, null, /bad pick/],
|
||||
[T5, { charId: 42 }, /bad pick/],
|
||||
['chess_char_5_01_a', { charId: SIEGE, skillIndex: 0 }, /not a 自选 slot/],
|
||||
];
|
||||
for (const [slot, pick, re] of bad) assert.match(checkDiyPick(slot, pick, DATA).error, re, JSON.stringify(pick));
|
||||
});
|
||||
|
||||
test('diyRecord: the slot\'s identity (tier, price, merge, status), no 特质, derived bonds, the operator\'s body at the slot form; module active on the elite only, stage 1 / 3', () => {
|
||||
const cases = [[T5, false, '2/1/4/0', 0], [T5, true, '2/60/7/1', 1], [T6, false, '2/1/4/0', 0], [T6, true, '2/60/7/3', 3]];
|
||||
for (const [slotId, elite, key, stage] of cases) {
|
||||
for (const mod of [null, 'uniequip_002_siege', 'uniequip_003_siege']) {
|
||||
const r = diyRecord(slotId, { charId: SIEGE, skillIndex: 1, uniEquipId: mod }, { elite, data: DATA });
|
||||
const slot = chess[elite ? backups.diy.slots[slotId].goldenId : slotId];
|
||||
const form = backups.units[SIEGE].forms[key];
|
||||
const label = `${slot.chessId} ${mod}`;
|
||||
assert.deepEqual([r.chessId, r.baseId, r.isGolden, r.tier, r.price, r.sellPrice, r.upgradeNum, r.chessType, r.isDiy], [slot.chessId, slotId, elite, slot.tier, 4, 1, elite ? 0 : 3, 'DIY', true], label);
|
||||
assert.deepEqual([r.charId, r.diyFor, r.name, r.profession, r.rarity], [SIEGE, slotId, '推进之王', 'PIONEER', 6], label);
|
||||
assert.deepEqual([r.garrisonIds, r.bonds, r.standInFor], [[], ['victoriaShip'], undefined], `${label}: no 特质, 维多利亚 from 维多利亚 / 格拉斯哥帮`);
|
||||
assert.deepEqual(r.status, slot.status, label);
|
||||
assert.deepEqual([r.skill.skillId, r.skill.level], ['skchr_siege_2', elite ? 7 : 4], `${label}: S2 at the slot's rank`);
|
||||
assert.deepEqual(r.skills.filter((s) => s.isDefault).map((s) => s.index), [1]);
|
||||
const m = mod && elite ? form.modules.find((x) => x.uniEquipId === mod) : null;
|
||||
assert.deepEqual(r.stats, composeStats(form.stats, m?.attr), `${label}: stats`);
|
||||
assert.deepEqual(r.talents, composeTalents(form.talents, m?.talentChanges), `${label}: talents`);
|
||||
assert.deepEqual(r.trait, m?.traitOverride ?? form.trait, `${label}: trait`);
|
||||
assert.equal(!!r.module?.active, !!m, `${label}: module active`);
|
||||
if (m) assert.equal(r.module.level, stage, `${label}: stage ${stage}`);
|
||||
if (elite) assert.deepEqual(r.modules.map((x) => [x.uniEquipId, x.level, x.isDefault]), form.modules.map((x) => [x.uniEquipId, stage, x.uniEquipId === mod]), label);
|
||||
assert.equal(r.assets.avatar, elite ? `${SIEGE}_2` : SIEGE, `${label}: E2 art on the elite`);
|
||||
// chess-shaped: the loadout machinery and the sim read it unchanged
|
||||
assert.equal(loadoutRecord(r, resolveLoadout(r, null)), r);
|
||||
const def = normalizeChess(r);
|
||||
assert.deepEqual([def.id, def.baseId, def.charId, def.diyFor, def.golden, def.tier, def.skill.id], [slot.chessId, slotId, SIEGE, slotId, elite, slot.tier, 'skchr_siege_2'], label);
|
||||
}
|
||||
}
|
||||
// a prototype's derived bond is 协防; 煌 gets 炎 + 维多利亚 (subPower only); the composed record equals composeUnitRecord's
|
||||
const sharp = diyRecord(T6, { charId: 'char_609_acguad' }, { elite: true, data: DATA });
|
||||
assert.deepEqual([sharp.bonds, sharp.skill.skillId, sharp.module.id, sharp.module.level, sharp.garrisonIds], [['emptyShip'], 'skchr_acguad_3', 'uniequip_002_acguad', 3, []]);
|
||||
assert.deepEqual(diyRecord(T5, { charId: 'char_017_huang', skillIndex: 0 }, { data: DATA }).bonds, ['yanShip', 'victoriaShip']);
|
||||
const slot = chess.chess_char_5_diy2_b;
|
||||
const { diyFor, ...rest } = diyRecordOf(slot, { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_003_siege' }, DATA);
|
||||
assert.equal(diyFor, T5B);
|
||||
assert.deepEqual(rest, composeUnitRecord(slot, backups.units[SIEGE], unitForm(backups, SIEGE, slot.status), { skillIndex: 2, moduleId: 'uniequip_003_siege', bonds: ['victoriaShip'] }));
|
||||
assert.equal(diyRecord(T6, { charId: 'char_601_cguard' }, { data: DATA }), null, 'illegal ⇒ null');
|
||||
assert.equal(diyRecordOf(chess.chess_char_5_01_a, { charId: SIEGE, skillIndex: 0 }, DATA), null, 'not a slot record');
|
||||
});
|
||||
|
||||
test('validateDiyPicks: a prototype may fill a tier-5 and a tier-6 slot, an owned operator one slot, the picks of a tier differ; unkitted operators are refused', () => {
|
||||
const sharp = { charId: 'char_609_acguad' };
|
||||
const ok = validateDiyPicks({ [T5]: sharp, [T5B]: { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }, [T6]: sharp, [T6B]: null }, { data: DATA, kitted: [...KITTED_CHARS, SIEGE] });
|
||||
assert.deepEqual(ok, { ok: true, picks: {
|
||||
[T5]: { charId: 'char_609_acguad', skillIndex: 2, uniEquipId: 'uniequip_002_acguad' },
|
||||
[T5B]: { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' },
|
||||
[T6]: { charId: 'char_609_acguad', skillIndex: 2, uniEquipId: 'uniequip_002_acguad' },
|
||||
} });
|
||||
assert.deepEqual(validateDiyPicks({}, { data: DATA }), { ok: true, picks: {} }, 'empty slots are fine');
|
||||
const err = (picks, opts = {}) => validateDiyPicks(picks, { data: DATA, ...opts });
|
||||
assert.match(err({ [T5]: sharp, [T5B]: sharp }).detail, /fills two tier-5 slots/);
|
||||
assert.match(err({ [T5]: { charId: SIEGE, skillIndex: 0 }, [T6]: { charId: SIEGE, skillIndex: 1 } }).detail, /an owned operator fills one slot/);
|
||||
assert.match(err({ [T5]: { charId: 'char_017_huang', skillIndex: 0 } }, { kitted: KITTED_CHARS.filter((id) => id !== 'char_017_huang') }).detail, /has no kit yet/);
|
||||
assert.match(err({ [T5]: sharp }, { kitted: (id) => id !== 'char_609_acguad' }).detail, /char_609_acguad has no kit yet/);
|
||||
assert.deepEqual(err({ [T5]: { charId: 'char_017_huang', skillIndex: 0 } }).ok, true, 'without the registry every legal pick passes');
|
||||
assert.match(err({ chess_char_5_01_a: sharp }).detail, /not a 自选 slot/);
|
||||
assert.match(err({ [T6]: { charId: 'char_602_cdfend' } }).detail, /not a tier-6/);
|
||||
assert.match(err({ [T5]: { charId: 'char_609_acguad', skillIndex: 0 } }).detail, /prototype carries skill 2/);
|
||||
assert.deepEqual(err('x'), { error: 'BAD_MSG', detail: 'bad 自选 picks' });
|
||||
assert.equal(err([sharp]).error, 'BAD_MSG');
|
||||
});
|
||||
@@ -7,7 +7,7 @@ through.
|
||||
|
||||
- `tools/golden.mjs` builds the corpus from `data/*.json` (fixed order, fixed seeds) and reduces every scenario to a
|
||||
digest. Its header documents the families and the digest fields.
|
||||
- `test/golden/<family>.json` hold the digests (`roster`, `bonds`, `fields`, `matches`, `standins`).
|
||||
- `test/golden/<family>.json` hold the digests (`roster`, `bonds`, `fields`, `matches`, `standins`, `diy`).
|
||||
- `test/golden.test.js` recomputes them and compares. On a mismatch it prints the scenario, the field and old → new.
|
||||
|
||||
## The corpus
|
||||
@@ -19,6 +19,7 @@ through.
|
||||
| `fields` | 22 battles: every Final Assault / Hidden Core leader on a pair and a solo template (shared pool, 200 s cap) and the 联防 field with 1 and 2 helpers on its own map (carried HP / SP, a knocked-out operator, two leakers' enemies) |
|
||||
| `matches` | 17 matches to the end in virtual time: 16 bot-only (solo 标准 / 险境 / 绝境 / 终极 × 2 seeds, co-op 2 / 3 / 4, one server-run combat match, two runs boosted to the Hidden Core) and one co-op match whose human seat (AI 托管, offline) does not own 7 NORMAL chess — they fight as their 补位 stand-ins (its digest lists them per round, `standIns`) |
|
||||
| `standins` | 10 battles: every NORMAL chess record (normal and elite, 110) fielded as its 补位 stand-in (`standIn: true`: the stand-in's body, backup skill / module and kit — all 17 stand-ins, every skill a chess names for them), 12 per battle by strength band, laid out by the stand-in's position on a real stage, against the round's real wave three times over plus 8 ground enemy kinds (melee stand-ins always meet an enemy), an item each, bonds from the board |
|
||||
| `diy` | 6 battles of 自选 pieces (a DIY slot with its `diy` pick, `shared/diy.js`): every owned 6★ with an operator kit (`kits/index.js OPERATOR_KITS` — 推进之王 so far) in each form of tiers 5 and 6 (normal; elite with no module and with each module) under each skill, then every prototype pick with a kit at its locked selection, normal and elite, 12 per battle against the round's real wave; a new operator kit adds its scenarios |
|
||||
|
||||
Battles go through the production BattleSpec path (`server/sim/spec.js`, as browsers and the server's headless fields
|
||||
run them); matches construct `Match` directly with a `VirtualScheduler`. Every option is explicit, so a change to a
|
||||
@@ -31,7 +32,7 @@ to 2 decimals.
|
||||
|
||||
`npm test` runs the fast subset (the scenarios marked `"fast": true`: every chess record with its default loadout,
|
||||
every stage and every non-leader enemy kind, every bond at its top tier, six fields, six matches, the five stand-in
|
||||
battles of the normal records — 59 of the 144). `GOLDEN_FULL=1` checks everything.
|
||||
battles of the normal records, the first 自选 battle of the operator kits and of the prototypes — 61 of the 150). `GOLDEN_FULL=1` checks everything.
|
||||
|
||||
## Workflow
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
{
|
||||
"family": "diy",
|
||||
"about": "自选 pieces: every kitted owned 6★ × form (tiers 5 / 6, normal / elite × module) × skill, every kitted prototype at its locked selection",
|
||||
"note": "Generated by `npm run golden:update` (tools/golden.mjs). A refactor must never change this file; see test/golden/README.md.",
|
||||
"columns": {
|
||||
"units": ["player","ref","kind","def","skill","module","hp","alive","deploys","kills","dmg","taken","heal","attacks","casts"],
|
||||
"enemies": ["spawned","killed","leaked","dmg","taken"],
|
||||
"hooks": ["battleStart","deploy","tick","beforeAttack","attack","hit","damaged","heal","kill","death","skillStart","skillEnd","ammoUsed","spGain","statusApplied","blocked","enemySpawn","enemyLeak","battleEnd","fatal","layerGain","elementBurst","dodge"]
|
||||
},
|
||||
"scenarios": {
|
||||
"diy-001": {
|
||||
"about": "5_diy1_a=siege/S1 5_diy2_a=siege/S2 5_diy1_a=siege/S3 5_diy2_b=siege/S1 5_diy1_b=siege/S2 5_diy2_b=siege/S3 5_diy1_b=siege/S1/002_siege 5_diy2_b=siege/S2/002_siege 5_diy1_b=siege/S3/002_siege 5_diy2_b=siege/S1/003_siege 5_diy1_b=siege/S2/003_siege 5_diy2_b=siege/S3/003_siege",
|
||||
"fast": true,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m01",
|
||||
"modeId": "mode_multi_normal",
|
||||
"round": 12,
|
||||
"seed": 2851314323,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 230,
|
||||
"ticks": 6900,
|
||||
"reason": "timeout",
|
||||
"killed": 0,
|
||||
"total": 93,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 3,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":0,"total":93,"perfect":false,"leaks":93,"leaksUncounted":0,"coins":0,"dmg":76088,"bossDmg":0,"heal":0,"deaths":28,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,122,0,923,4929,0,7,1],
|
||||
["p1",2,"op","chess_char_5_diy2_a",1,null,0,0,123,0,7443,4929,0,19,3],
|
||||
["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,124,0,7649,4929,0,23,0],
|
||||
["p1",4,"op","chess_char_5_diy2_b",0,"none",0,0,117,0,4072,6138,0,11,0],
|
||||
["p1",5,"op","chess_char_5_diy1_b",1,"none",2046,1,125,0,9788,4092,0,14,2],
|
||||
["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,82,0,3152,4092,0,4,1],
|
||||
["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_siege",0,0,90,0,2443,4092,0,7,1],
|
||||
["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_siege",0,0,99,0,19095,4092,0,8,3],
|
||||
["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_siege",0,0,111,0,3317,4092,0,5,1],
|
||||
["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_siege",0,0,71,0,1958,4492,0,6,0],
|
||||
["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_siege",0,0,68,0,13962,4492,0,7,2],
|
||||
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_siege",0,0,63,0,2285,4492,0,10,0],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {"enemy_1267_nhpbr_2":[36,0,35,9319,36291],"enemy_1270_nhstlk_2":[27,0,27,22708,32133],"enemy_9006_actoxi":[30,0,30,22834,7664]},
|
||||
"hooks": [1,125,6900,243,243,280,280,0,28,120,14,14,0,10303,0,52,93,92,1,28,0,0,0],
|
||||
"events": {"atk":260,"deploy":29,"die":28,"dmg":280,"fx":22,"leak":92,"skill":28,"spawn":108,"status":26},
|
||||
"fx": {"aoe":10,"dp":12},
|
||||
"snaps": "11fd58d9 8c9b2fa6 4326c48e 207bf323 f5892cfd eb207b8b 716efaa3 73d27b32 157261e1 b639e83b 8c6960a9 594efc22 ede97e52 8c0b4015 9a8cdd7e 6bb87f5f 4bf762d2 9cd529d0 40b20b8e c21a56fc 4f34951d b94bd6bf 54fb0c18"
|
||||
},
|
||||
"diy-002": {
|
||||
"about": "6_diy1_a=siege/S1 6_diy2_a=siege/S2 6_diy1_a=siege/S3 6_diy2_b=siege/S1 6_diy1_b=siege/S2 6_diy2_b=siege/S3 6_diy1_b=siege/S1/002_siege 6_diy2_b=siege/S2/002_siege 6_diy1_b=siege/S3/002_siege 6_diy2_b=siege/S1/003_siege 6_diy1_b=siege/S2/003_siege 6_diy2_b=siege/S3/003_siege",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m02",
|
||||
"modeId": "mode_multi_hard",
|
||||
"round": 13,
|
||||
"seed": 197068430,
|
||||
"waveId": "act1autochess_h06",
|
||||
"time": 223.3,
|
||||
"ticks": 6699,
|
||||
"reason": "cleared",
|
||||
"killed": 2,
|
||||
"total": 123,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 5,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":2,"total":123,"perfect":false,"leaks":121,"leaksUncounted":0,"coins":0,"dmg":66245,"bossDmg":0,"heal":0,"deaths":26,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,154,0,8115,4929,0,23,1],
|
||||
["p1",2,"op","chess_char_6_diy2_a",1,null,1643,1,144,0,365,3286,0,8,2],
|
||||
["p1",3,"op","chess_char_6_diy1_a",2,null,1643,1,151,0,246,3286,0,7,1],
|
||||
["p1",4,"op","chess_char_6_diy2_b",0,"none",2046,1,142,0,233,4092,0,9,2],
|
||||
["p1",5,"op","chess_char_6_diy1_b",1,"none",2046,1,155,0,18574,4092,0,24,6],
|
||||
["p1",6,"op","chess_char_6_diy2_b",2,"none",2046,1,153,0,1079,4092,0,11,0],
|
||||
["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_siege",2046,1,132,0,203,4092,0,6,2],
|
||||
["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_siege",2046,1,156,0,1316,4092,0,4,2],
|
||||
["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_siege",0,0,94,0,677,4092,0,4,1],
|
||||
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_siege",0,0,119,1,9041,4612,0,19,1],
|
||||
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_siege",0,0,79,1,24153,4612,0,21,4],
|
||||
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_siege",0,0,134,0,2242,6918,0,12,0]
|
||||
],
|
||||
"enemies": {"enemy_1006_shield_3":[21,0,21,20551,4442],"enemy_1043_zomsbr":[66,2,64,10572,58576],"enemy_1402_tgshd_2":[36,0,36,21072,3227]},
|
||||
"hooks": [1,156,6699,282,282,308,308,7543,28,149,22,22,0,17563,2,43,123,121,1,28,0,0,0],
|
||||
"events": {"atk":308,"deploy":33,"die":28,"dmg":308,"fx":70,"leak":121,"skill":44,"spawn":135,"status":28},
|
||||
"fx": {"aoe":14,"dp":20,"expose":36},
|
||||
"snaps": "d7b739d1 f5e170c4 bc463f9c 6ebd80ed 93f8e1ae ea1947a7 9541a28f e8dbac5b b0b3c196 14ae4a64 46640828 01c55ea9 0f261678 f447848b 0aad3542 db5850a3 631cc5ca 455c1dbc 68ad7134 24c71ebb 34a3ee93 37b05130 7a58a87e"
|
||||
},
|
||||
"diy-003": {
|
||||
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo",
|
||||
"fast": true,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m03",
|
||||
"modeId": "mode_single_normal",
|
||||
"round": 12,
|
||||
"seed": 3865246633,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 196.5,
|
||||
"ticks": 5895,
|
||||
"reason": "cleared",
|
||||
"killed": 8,
|
||||
"total": 93,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 0,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":8,"total":93,"perfect":false,"leaks":85,"leaksUncounted":0,"coins":0,"dmg":306215,"bossDmg":0,"heal":22436,"deaths":11,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,114,0,690,6384,0,13,0],
|
||||
["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,98,1,7246,10191,0,56,1],
|
||||
["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,116,1,16020,8139,0,28,1],
|
||||
["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,74,3,34176,12589,0,37,2],
|
||||
["p1",5,"op","chess_char_5_diy1_a",2,null,871,1,8,1,22261,400,0,129,3],
|
||||
["p1",6,"op","chess_char_5_diy2_b",2,"none",1510,1,12,0,32187,200,0,108,3],
|
||||
["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,14,0,30588,200,0,77,2],
|
||||
["p1",8,"op","chess_char_5_diy2_b",2,"none",1160,1,4,1,71148,200,0,154,3],
|
||||
["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,6,0,0,600,18186,48,3],
|
||||
["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,115,0,0,8077,4251,12,2],
|
||||
["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,13,1,40672,2000,0,123,3],
|
||||
["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,15,0,51226,800,0,132,3],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {"enemy_1267_nhpbr_2":[36,8,28,17650,131613],"enemy_1270_nhstlk_2":[27,0,27,15235,63417],"enemy_9006_actoxi":[30,0,30,16896,111185]},
|
||||
"hooks": [1,116,5895,1089,1089,1215,1215,60,19,104,26,24,0,28385,254,28,93,85,1,19,0,0,0],
|
||||
"events": {"atk":1089,"deploy":20,"die":19,"dmg":1215,"fx":8,"heal":60,"leak":85,"skill":50,"spawn":108,"status":504},
|
||||
"fx": {"zone":8},
|
||||
"snaps": "b1b2dda9 6ac577d6 84811e80 eb6305ea 52a34c95 c2fd4731 45525f0b dbcc3ddb ddfcbc11 0874e859 206cd671 4c83f44b f4f5331a 194da7d5 1fe13dbf 6b7331b4 4cf19629 7bae5e13 5b7078fc 65a8a1f5"
|
||||
},
|
||||
"diy-004": {
|
||||
"about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m04",
|
||||
"modeId": "mode_multi_abyss",
|
||||
"round": 12,
|
||||
"seed": 1210847140,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 224.77,
|
||||
"ticks": 6743,
|
||||
"reason": "cleared",
|
||||
"killed": 21,
|
||||
"total": 117,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 350,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":21,"total":117,"perfect":false,"leaks":96,"leaksUncounted":0,"coins":0,"dmg":374057,"bossDmg":0,"heal":25487,"deaths":23,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,116,0,0,5208,0,0,0],
|
||||
["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",100.37,1,143,0,0,6642,0,0,0],
|
||||
["p1",3,"op","chess_char_5_diy1_a",2,null,1060.28,1,145,0,4912,3527,0,0,1],
|
||||
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,98,5,85077,5533,0,0,3],
|
||||
["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,104,0,0,8356,0,0,0],
|
||||
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,100,0,0,20296,0,0,0],
|
||||
["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,132,0,16051,2544,0,16,1],
|
||||
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,135,5,91286,3174,0,37,1],
|
||||
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,77,0,6653,2744,0,3,0],
|
||||
["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,7,11,170077,1749,0,21,1],
|
||||
["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,144,0,0,5333,10172,24,1],
|
||||
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,94,0,0,8375,15315,21,1],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {"enemy_1005_yokai":[51,16,35,0,83756],"enemy_1005_yokai_2":[54,5,49,24107,111017],"enemy_1005_yokai_3":[12,0,12,42273,179284]},
|
||||
"hooks": [1,145,6743,501,501,975,936,86,44,140,9,9,0,14181,0,0,117,96,1,44,0,0,39],
|
||||
"events": {"atk":594,"deploy":25,"die":44,"dmg":936,"fx":87,"heal":78,"leak":96,"skill":18,"spawn":132,"status":8},
|
||||
"fx": {"aoe":1,"dodge":39,"dp":9,"slash":32,"spGain":6},
|
||||
"snaps": "6b632c98 e087bd9f f5b9da13 f7038fad bb4d7811 5178d072 0c0de684 872f1892 81773c41 3d296a6a 66f1329a 3a99c8b1 aa011a13 319c8ec5 26231cc7 f808b9be 73dd7977 75857859 817f18b6 d1cdd533 cd927f27 64106a51 89f62784"
|
||||
},
|
||||
"diy-005": {
|
||||
"about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m05",
|
||||
"modeId": "mode_multi_funny",
|
||||
"round": 13,
|
||||
"seed": 823748262,
|
||||
"waveId": "act1autochess_h06",
|
||||
"time": 230,
|
||||
"ticks": 6900,
|
||||
"reason": "timeout",
|
||||
"killed": 63,
|
||||
"total": 123,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 117,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":63,"total":123,"perfect":false,"leaks":60,"leaksUncounted":0,"coins":0,"dmg":274732,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_diy1_a",2,null,1834,1,132,5,19446,3374,0,80,2],
|
||||
["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",2142.36,1,141,5,29488,4720,0,101,2],
|
||||
["p1",3,"op","chess_char_5_diy1_a",0,null,1865,1,154,3,27639,3730,0,42,1],
|
||||
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",2374,1,152,11,43106,4748,0,39,1],
|
||||
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,135,3,18986,4587,0,18,1],
|
||||
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1808.52,1,143,18,92036,3903,0,40,4],
|
||||
["p1",7,"op","chess_char_5_diy1_a",2,null,117.2,1,130,12,39744,6807,0,115,0],
|
||||
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,1,0,0,0,0,0,0],
|
||||
["p1",9,"op","chess_char_6_diy1_a",2,null,2638,1,3,0,0,0,0,0,0],
|
||||
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",3531,1,5,0,0,0,0,0,0],
|
||||
["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,153,0,1743,2320,0,7,0],
|
||||
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1120.4,1,145,0,2545,3170,0,7,1]
|
||||
],
|
||||
"enemies": {"enemy_1019_jshoot_2":[57,37,20,9953,112024],"enemy_1299_ymkilr_2":[27,6,20,25821,123649],"enemy_1422_lrsldr_2":[39,20,19,1584,123193]},
|
||||
"hooks": [1,154,6900,835,835,3335,3375,0,83,142,12,15,0,16427,38,83,123,59,1,83,0,0,0],
|
||||
"events": {"atk":851,"deploy":31,"die":83,"dmg":3367,"fx":150,"leak":59,"skill":22,"spawn":135,"status":47},
|
||||
"fx": {"aoe":83,"displace":3,"dp":26,"slash":38},
|
||||
"snaps": "aa4b712f 51a2360b 59868653 c815c7eb 7dd47d06 3f3f03f4 89e883c3 60e1e7f6 2355716a 5d734980 75cde05f c0dd657c 4e5bd257 0f8a040d b1aafe05 c04cbb36 e6d37890 8457998a a19143e5 d709bc2b 4283543b 0d9edf54 6ef9963d"
|
||||
},
|
||||
"diy-006": {
|
||||
"about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m06",
|
||||
"modeId": "mode_single_hard",
|
||||
"round": 13,
|
||||
"seed": 2464539579,
|
||||
"waveId": "act1autochess_h06",
|
||||
"time": 210.23,
|
||||
"ticks": 6307,
|
||||
"reason": "cleared",
|
||||
"killed": 5,
|
||||
"total": 81,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 123,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":5,"total":81,"perfect":false,"leaks":76,"leaksUncounted":0,"coins":0,"dmg":185202,"bossDmg":0,"heal":4048,"deaths":30,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,3,0,11325,1865,0,7,1],
|
||||
["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,6,3,52957,2494,0,16,1],
|
||||
["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,112,0,15444,8178,0,22,0],
|
||||
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,109,2,51553,12663,0,57,0],
|
||||
["p1",5,"op","chess_char_6_diy1_a",2,null,1272,1,113,0,4256,2544,0,15,0],
|
||||
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,105,0,10180,3286,0,19,0],
|
||||
["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,5,0,3060,1372,0,2,0],
|
||||
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,2,0,30589,1819,0,4,1],
|
||||
["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,8,0,0,2028,2464,7,1],
|
||||
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,61,0,0,3890,1584,6,1],
|
||||
["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,58,0,2490,3291,0,7,1],
|
||||
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,60,0,3347,3320,0,8,0]
|
||||
],
|
||||
"enemies": {"enemy_1267_nhpbr_2":[27,5,22,7765,89733],"enemy_1270_nhstlk_2":[36,0,36,14278,68347],"enemy_1272_nhtank_2":[18,0,18,24706,27121]},
|
||||
"hooks": [1,113,6307,262,262,449,476,16,35,111,6,21,0,5828,86,24,81,76,1,35,0,0,0],
|
||||
"events": {"atk":283,"deploy":32,"die":35,"dmg":441,"fx":58,"heal":15,"leak":76,"skill":12,"spawn":93,"status":142},
|
||||
"fx": {"aoe":7,"displace":21,"spGain":6,"zone":24},
|
||||
"snaps": "1dcf4706 8bd55683 d93e4d02 ab832ad5 132699ab f37b039d f12175ca fe30225b 6b2b77c0 2caefa3a 1018f9e1 a60a2364 f81df3c0 d0f2780e 0e940f53 372c90ef 46af3a81 a6b2adf1 63234976 3b8b7c59 b62aa8d4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,12 @@
|
||||
// path: the chess's ids / bonds / 特质 / tier, the stand-in's body, its kit by charId); the `_a` id is the
|
||||
// normal form, `_b` the elite, skill / module are the chess's backup selection. `standIn: { skillIndex?,
|
||||
// moduleId? }` fields it with another of the stand-in's skills / modules (a kit test of a skill no chess
|
||||
// names): the composed record (standInRec) replaces that chess id's record for the whole battle
|
||||
// names): the composed record (standInRec) replaces that chess id's record for the whole battle.
|
||||
// `{ diy: { slot, charId, skillIndex?, uniEquipId? }, elite?, row, col }` fields a 自选 piece — the
|
||||
// production path (simdata getChess(slotId, { diy }), shared/diy.js): `slot` = a DIY slot's base id
|
||||
// (`chess_char_5_diy1_a`) or its tier (5 / 6 ⇒ that tier's first slot), `elite` = its `_b` form (E2
|
||||
// Lv60, module stage 1 at tier 5 / 3 at tier 6); the operator's kit is KITS[charId]; a prototype may
|
||||
// omit skillIndex / uniEquipId (its locked ones)
|
||||
// players full PlayerBattleInput[] (overrides `units`)
|
||||
// enemies [{ key, time=0, route=0 | RouteSpec, pos?, count?, interval?, mods?, tag?, bounty?, sourcePlayerId? }]
|
||||
// waveTemplate wave id (data/waves.json) or template object → routes + spawns + timeLimit
|
||||
@@ -179,6 +184,20 @@ function buildData(defs) {
|
||||
return new DataSource({ chess: defs.chess ?? {}, enemies: defs.enemies ?? {}, tokens: defs.tokens ?? {}, stages: defs.stages ?? {}, waves: defs.waves ?? {} }, getDefaultSource());
|
||||
}
|
||||
|
||||
/**
|
||||
* A harness `units[]` entry with `diy` → its PlayerBattleInput fields: `chessId` = the slot's normal or elite id, `diy` =
|
||||
* the pick ({ charId, skillIndex, uniEquipId } as given). Other entries unchanged.
|
||||
*/
|
||||
function diyEntry(u, data = getDefaultSource()) {
|
||||
if (!u || !u.diy || typeof u.diy !== 'object') return u;
|
||||
const slots = data.rawBackups?.()?.diy?.slots ?? {};
|
||||
const { slot, ...pick } = u.diy;
|
||||
const base = typeof slot === 'number' ? Object.keys(slots).find((id) => slots[id].tier === slot) : slot;
|
||||
if (!base || !slots[base]) throw new Error(`diy: unknown slot ${slot}`);
|
||||
const { elite, ...rest } = u;
|
||||
return { ...rest, chessId: elite ? slots[base].goldenId : base, diy: pick };
|
||||
}
|
||||
|
||||
/** `units[].standIn` objects → the composed records for `defs.chess` (null when there are none). */
|
||||
function standInDefs(units) {
|
||||
let out = null;
|
||||
@@ -225,7 +244,7 @@ export function makeBattle(opts = {}) {
|
||||
if (extraRoutes.length) routes = routes.concat(extraRoutes);
|
||||
const players = opts.players ?? [{
|
||||
playerId: 'p1', seat: 0, side: 'L', colOffset: 0,
|
||||
units: (opts.units ?? []).map((u, i) => ({ uid: u.uid ?? i + 1, kind: u.kind ?? 'chess', ...u, ...(u.standIn ? { standIn: true } : null) })),
|
||||
units: (opts.units ?? []).map((u, i) => ({ uid: u.uid ?? i + 1, kind: u.kind ?? 'chess', ...diyEntry(u), ...(u.standIn ? { standIn: true } : null) })),
|
||||
bonds: opts.bonds ?? {}, bandId: opts.bandId ?? null, playerEffects: opts.playerEffects ?? [],
|
||||
}];
|
||||
const hookNames = opts.hooks ?? ALL_HOOKS;
|
||||
|
||||
Reference in new issue
Block a user