feedback5 WH: 自选 pieces in the sim — shared/diy.js (diyPool, checkDiyPick, diyRecord / diyRecordOf, validateDiyPicks: the legal picks per tier, prototypes skill-locked to their 补位 rows, owned picks any skill / module at the slot's stage, no 特质, derived bonds, one slot per owned operator, distinct picks per tier, unkitted operators refused); simdata getChess(slotId, { diy }) builds the 自选 def (diyFor, charId, loadout.diy, tokenOwner) and getToken resolves its summons from backups.json tokens; Battle, BattleSpec and the loadout view carry the PlayerBattleInput diy pick (a DIY slot without a legal pick fields nothing); kitOf takes KITS[charId] for a 自选 piece as for a stand-in; kits/index.js OPERATOR_KIT_FILES (op-<codename>.js, empty for now), GENERIC_KIT_CHARS, KITTED_CHARS; the harness fields units[].diy

No golden scenario moved (none fields a 自选 piece).
This commit is contained in:
sganggs committed 2026-10-05 19:51:40 +08:00
1 parent c278eafa52
commit 2b72d4deb0
13 files changed
+791 -42

No files matched your search

+148
View File
@@ -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, '甄选干员');
});
+34 -10
View File
@@ -1,15 +1,18 @@
// 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';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
@@ -19,7 +22,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 +42,27 @@ 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 form is authored (a 自选 piece has no default skill: the pick chooses any)
for (const [key, form] of Object.entries(BACKUPS.units[charId].forms)) {
const kit = mod.default[charId](form.skills[0].bb, { ...form, skill: form.skills[0], charId }, {});
assert.deepEqual(Object.keys(kit.skills || {}).sort(), form.skills.map((s) => s.skillId).sort(), `${file}@${key}: 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 +78,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);
});
+163
View File
@@ -0,0 +1,163 @@
// 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.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');
});
+21 -2
View File
@@ -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;