feedback5 WD: the sim fields a 补位 stand-in — getChess(id, { standIn: true }) over data/backups.json, PlayerBattleInput standIn: true, kits by the stand-in's charId (never the replaced operator's), kits/ops/standin-<codename>.js registry, harness standIn

- simdata: DataSource keeps `backups` (rawBackups follows the fallback chain); getStandIn = shared/standIn.js
  standInRecord normalised (the chess's ids / bonds / 特质 / tier, the stand-in's body, def.standInFor); a PRESET / DIY
  chess or a source without backups fields itself; the per-battle view maps id-only lookups.
- spec.js keeps `standIn` only when exactly true (never on a token); Battle passes it to getChess; syncUnitLoadout
  carries it; UnitInfo `standInFor`.
- content/index.js kitOf: a stand-in's kit is KITS[def.charId] only; no default skill (a stand-in kit writes `skills`);
  without a kit the generic kit plus genericTalents. kits/index.js STANDIN_KIT_FILES / STANDIN_KITS, merged after the
  tier groups (old keys keep their order; golden unchanged).
- server/data.js and tools/setup.mjs expect backups.json.
- test/content/standin.test.js: the plumbing and the eight 预备干员 on all 28 chess records they replace.
This commit is contained in:
sganggs committed 2026-10-05 17:44:58 +08:00
1 parent 286a34064f
commit ed2b679f83
14 files changed
+656 -58

No files matched your search

+26 -9
View File
@@ -1,27 +1,44 @@
// The operator kit layout (server/sim/content/kits/README.md): every file of kits/ops/ is listed once in kits/index.js
// 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; the registry is the tier groups in order, 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 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/).
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 } from '../../server/sim/content/kits/index.js';
import { KIT_FILES, TIER_KITS, KITS, STANDIN_KIT_FILES, STANDIN_KITS } 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)), '../..');
const KIT_DIR = join(ROOT, 'server/sim/content/kits');
const CHESS = JSON.parse(readFileSync(join(ROOT, 'data/chess.json'), 'utf8'));
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();
const listed = [...KIT_FILES.flat(), ...STANDIN_KIT_FILES];
assert.equal(new Set(listed).size, listed.length, 'a file listed twice');
assert.deepEqual([...listed].sort(), jsIn('ops'));
});
test('a stand-in kit file standin-<codename>.js registers the kit of that stand-in charId (data/backups.json units)', async () => {
const units = Object.keys(BACKUPS.units);
for (const file of STANDIN_KIT_FILES) {
const m = /^standin-([a-z0-9]+)\.js$/.exec(file);
assert.ok(m, `${file}: name`);
const charId = units.find((id) => id.replace(/^char_\d+_/, '') === m[1]);
assert.ok(charId, `${file}: no stand-in 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(STANDIN_KITS[charId], mod.default[charId], `${file}: registered`);
assert.equal(KITS[charId], mod.default[charId], `${file}: in the merged registry`);
}
});
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);
@@ -37,9 +54,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, one key per file; content/index.js serves the same object', () => {
assert.deepEqual(Object.keys(KITS), TIER_KITS.flatMap((t) => Object.keys(t)));
assert.equal(Object.keys(KITS).length, KIT_FILES.flat().length, 'one key per file, none twice');
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');
assert.equal(CONTENT_KITS, KITS);
});
+357
View File
@@ -0,0 +1,357 @@
// test/content/standin.test.js — 补位 stand-ins in the battle simulation (DATA.md §18; kits/README.md "Stand-in kits").
//
// The data layer: simdata getChess(id, { standIn: true }) / getStandIn — the chess's identity (ids, bonds, 特质, tier)
// with the stand-in's body, skill and module (shared/standIn.js standInRecord over data/backups.json) —, the per-battle
// view, the BattleSpec field `standIn`; the kit lookup by the stand-in's charId, never the replaced operator's kit
// (content/index.js kitOf); the harness option `units[].standIn`; and the eight 预备干员 (char_600–607) on the generic
// path, on every chess they replace (normal + elite): the stand-in body, the skill firing with its blackboard numbers,
// the unconditional stat talent (generic.js genericTalents), 一击即退 lasting its 10 s from every deployment (PRTS
// 预备干员-特种: 被动, 持续 10), 冲锋号令's DP at full SP (as 德克萨斯's kit casts the same skill) and 预备干员-重装 casting
// with an enemy in range (the owner's 重装 exception for the stand-ins, 2026-10-05; tools/build-data.mjs
// STANDIN_TRIGGER_DEVIATIONS).
// Run: node --test test/content/standin.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, standInRec, checkInvariants } from '../helpers/battleHarness.js';
import { DataSource, getDefaultSource, withUnitLoadouts } from '../../server/sim/simdata.js';
import { buildBattleSpec, createBattleFromSpec, sanitizeUnitLoadout } from '../../server/sim/spec.js';
import { KITS, kitOf, isStandInDef, skillSpecSource } from '../../server/sim/content/index.js';
import { statTalentMods } from '../../server/sim/content/generic.js';
import { unitInfo } from '../../server/sim/snapshot.js';
import { standInRecord, unitForm } from '../../shared/standIn.js';
import { bodyInKeys } from '../../server/sim/body.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 ds = getDefaultSource();
const RESERVES = ['char_600_cpione', 'char_601_cguard', 'char_602_cdfend', 'char_603_csnipe', 'char_604_ccast', 'char_605_cmedic', 'char_606_csuppo', 'char_607_cspec'];
/** Every chess record (normal + elite) a 预备干员 stands in for. */
const RESERVE_RECORDS = Object.values(CHESS).filter((c) => c.chessType === 'NORMAL' && RESERVES.includes(c.backup.charId))
.sort((a, b) => a.chessId.localeCompare(b.chessId));
const DUMMY = { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, atk: 0, speed: 1 }) };
const close = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-6, `${msg}: ${a} vs ${b}`);
// ---------------------------------------------------------------------------------------------------------------
// data layer
test('data: every NORMAL chess record resolves to its stand-in def — the chess identity, the stand-in body and backup selection — cached and frozen; PRESET / DIY keep their own def', () => {
let n = 0;
for (const c of Object.values(CHESS)) {
const d = ds.getChess(c.chessId, { standIn: true });
if (c.chessType !== 'NORMAL') {
assert.equal(d, ds.getChess(c.chessId), `${c.chessId}: a ${c.chessType} chess fights as itself`);
assert.equal(d?.standInFor, undefined);
continue;
}
const r = standInRecord(c, BACKUPS);
const where = `${c.chessId} → ${c.backup.charId}`;
assert.deepEqual([d.id, d.baseId, d.golden, d.tier, d.charId, d.standInFor, d.name], [c.chessId, c.baseId, !!c.isGolden, c.tier, c.backup.charId, c.charId, r.name], where);
assert.deepEqual([d.bonds, d.raw.garrisonIds], [c.bonds, c.garrisonIds], `${where}: bonds / 特质 of the chess`);
for (const k of ['maxHp', 'atk', 'def', 'res', 'blockCnt', 'cost', 'respawnTime']) assert.equal(d.stats[k], r.stats[k], `${where}: ${k} of the stand-in`);
assert.deepEqual([d.profession, d.subProf, d.rangeGrid], [r.profession, r.subProfessionId, r.rangeGrid], `${where}: class and range of the stand-in`);
assert.deepEqual([d.skill.id, d.skill.index, d.skill.bb], [r.skill.skillId, c.backup.skillIndex, r.skill.bb], `${where}: the chess's backup skill`);
assert.equal(d.loadout.standIn, true);
assert.equal(d.loadout.moduleId ?? null, c.status.equipLevel > 0 ? (c.backup.uniEquipId ?? 'none') : null, `${where}: the backup module`);
assert.equal(!!d.raw.module?.active, !!(c.backup.uniEquipId && c.status.equipLevel > 0), `${where}: module active`);
assert.ok(Object.isFrozen(d) && Object.isFrozen(d.skill.bb), `${where}: frozen`);
assert.equal(ds.getChess(c.chessId, { standIn: true, skillIndex: 0, moduleId: 'none' }), d, `${where}: a stand-in's skill / module cannot be changed`);
const own = ds.getChess(c.chessId);
assert.ok(own !== d && own.charId === c.charId && own.standInFor === undefined, `${where}: the chess's own def is untouched`);
assert.ok(isStandInDef(d) && !isStandInDef(own));
n++;
}
assert.equal(n, 110);
});
test('data: a source without backups.json fields the chess itself (a browser that did not fetch it); with it, the stand-in', () => {
const rec = CHESS.chess_char_4_04_a;
const bare = new DataSource({ chess: { [rec.chessId]: rec } }, null);
assert.equal(bare.rawBackups(), null);
const d = bare.getChess(rec.chessId, { standIn: true });
assert.deepEqual([d.charId, d.standInFor], [rec.charId, undefined]);
const full = new DataSource({ chess: { [rec.chessId]: rec }, backups: BACKUPS }, null);
assert.deepEqual([full.getChess(rec.chessId, { standIn: true }).charId, full.getChess(rec.chessId, { standIn: true }).standInFor], ['char_607_cspec', rec.charId]);
// a fallback chain finds the default source's backups (test DataSources over getDefaultSource)
assert.equal(new DataSource({}, ds).rawBackups(), ds.rawBackups());
});
test('spec and view: buildBattleSpec keeps standIn only when exactly true (never on a token); createBattleFromSpec fields the stand-in; the view maps id-only lookups', () => {
assert.deepEqual(sanitizeUnitLoadout({ chessId: 'x', standIn: true }), { chessId: 'x', standIn: true });
assert.deepEqual(sanitizeUnitLoadout({ chessId: 'x', standIn: 'true' }), { chessId: 'x' });
assert.deepEqual(sanitizeUnitLoadout({ chessId: 'x', standIn: 1 }), { chessId: 'x' });
assert.deepEqual(sanitizeUnitLoadout({ kind: 'token', tokenId: 't', standIn: true }), { kind: 'token', tokenId: 't' });
const spec = buildBattleSpec({
battleId: 'standin', fieldId: 'f', kind: 'normal', seed: 9, modeId: 'mode_multi_normal', round: 3, stageId: 'act2autochess_m01', timeLimit: 30,
players: [{
playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, bandId: null, playerEffects: [],
units: [
{ uid: 1, kind: 'chess', chessId: 'chess_char_4_04_a', row: 10, col: 4, standIn: true },
{ uid: 2, kind: 'chess', chessId: 'chess_char_4_07_a', row: 11, col: 4, standIn: 'yes' },
],
}],
spawns: [], routes: [],
});
assert.deepEqual(spec.players[0].units.map((u) => u.standIn), [true, undefined]);
const b = createBattleFromSpec(spec, ds);
b.step();
const [u1, u2] = [1, 2].map((uid) => b.allyUnits.find((u) => u.uid === uid));
assert.deepEqual([u1.def.charId, u1.def.standInFor, u1.defId], ['char_607_cspec', CHESS.chess_char_4_04_a.charId, 'chess_char_4_04_a']);
assert.deepEqual([u2.def.charId, u2.def.standInFor], [CHESS.chess_char_4_07_a.charId, undefined]);
assert.equal(b.data.getChess('chess_char_4_04_a'), u1.def, 'the per-battle view: an id-only lookup is the stand-in');
assert.deepEqual(b.data.loadoutConflicts, []);
assert.equal(b.errors.length, 0);
// two players of one field: one fields the chess as its stand-in, the other as itself — the first wins id-only lookups
const view = withUnitLoadouts(ds, [
{ units: [{ chessId: 'chess_char_4_04_a', standIn: true }] },
{ units: [{ chessId: 'chess_char_4_04_a' }] },
]);
assert.deepEqual(view.loadoutConflicts, ['chess_char_4_04_a']);
assert.equal(view.getChess('chess_char_4_04_a').charId, 'char_607_cspec');
assert.equal(view.getChess('chess_char_4_04_a', {}).charId, CHESS.chess_char_4_04_a.charId, 'an explicit loadout is exact');
});
// ---------------------------------------------------------------------------------------------------------------
// kit lookup
test('kits: a stand-in finds its kit by its charId only — never the kit of the operator it replaces (kitOf, skillSpecSource)', () => {
const sd = ds.getChess('chess_char_4_04_a', { standIn: true });
const od = ds.getChess('chess_char_4_04_a');
const fChess = () => ({}), fStand = () => ({});
assert.equal(typeof KITS.chess_char_4_04_a, 'function', '伊内丝 has a kit of her own');
assert.equal(kitOf(sd, { chess_char_4_04_a: fChess, chess_char_4_04: fChess }), undefined);
assert.equal(kitOf(sd, { chess_char_4_04_a: fChess, char_607_cspec: fStand }), fStand);
assert.equal(kitOf(od, { chess_char_4_04_a: fChess, char_607_cspec: fStand }), fChess);
assert.equal(kitOf(ds.getChess('chess_char_4_04_b'), { chess_char_4_04_a: fChess }), fChess, 'the elite finds the base kit as before');
assert.equal(kitOf(sd, KITS), undefined, 'no stand-in kit is registered for 预备干员-特种');
assert.equal(skillSpecSource(sd), 'none');
assert.equal(skillSpecSource(od), 'kit');
assert.equal(skillSpecSource(sd, { char_607_cspec: () => ({ skills: { 'skcom_born_up[2]': { kind: 'passive' } } }) }), 'skills');
assert.equal(skillSpecSource(sd, { char_607_cspec: () => ({ skill: { kind: 'passive' } }) }), 'generic', 'a stand-in has no default skill');
});
test('kits in battle: injected kits keyed by the chess id reach the operator only, keyed by the charId the stand-in only; without one the stand-in runs the generic kit', () => {
const calls = [];
const spy = (tag) => (bb, chess, def) => { calls.push([tag, def.charId]); return { skill: null, talents: [] }; };
const h = makeBattle({
units: [{ chessId: 'chess_char_4_04_a', row: 10, col: 4, standIn: true }, { chessId: 'chess_char_4_04_b', row: 11, col: 4 }],
kits: { chess_char_4_04_a: spy('chess'), char_607_cspec: spy('standIn') },
});
h.step();
assert.deepEqual(calls.sort(), [['chess', CHESS.chess_char_4_04_b.charId], ['standIn', 'char_607_cspec']]);
checkInvariants(h.b);
const g = makeBattle({ units: [{ chessId: 'chess_char_4_04_a', row: 10, col: 4, standIn: true }, { chessId: 'chess_char_4_04_b', row: 11, col: 4 }] });
g.step();
const [s, o] = [g.unit(1), g.unit(2)];
assert.equal(s.kit.generic, true, 'the stand-in: generic kit');
assert.equal(s.kit.talents.length, 1, 'plus its one unconditional stat talent');
assert.ok(!o.kit.generic, 'the operator: her own kit');
assert.equal(g.b.errors.length, 0);
});
test('kits: a stand-in kit has no default skill — `skills[skillId]` is its spec, `skill` is never used (the generic spec instead)', () => {
const run = (kit) => {
const h = makeBattle({ units: [{ chessId: 'chess_char_4_07_a', row: 10, col: 4, standIn: true }], kits: { char_601_cguard: () => kit } });
h.step();
return h.unit(1).kit;
};
const k1 = run({ skill: { kind: 'duration', mods: { atkPct: 9 } }, talents: [] });
assert.equal(k1.skillSource, 'generic');
assert.deepEqual(k1.skill.mods, { atkPct: 0.35 }, '攻击力强化·β型 at Lv4: atk 0.35 from the blackboard');
const k2 = run({ skills: { 'skcom_atk_up[2]': { kind: 'duration', mods: { atkPct: 0.123 } } }, talents: [] });
assert.equal(k2.skillSource, 'skills');
assert.deepEqual(k2.skill.mods, { atkPct: 0.123 });
});
test('loadout sync keeps a stand-in whose entry also names a skill / module; UnitInfo carries standInFor for the client', () => {
const h = makeBattle({ units: [
{ chessId: 'chess_char_4_07_b', row: 10, col: 4, standIn: true, skillIndex: 0, moduleId: 'none' },
{ chessId: 'chess_char_4_04_a', row: 11, col: 4 },
] });
h.step();
const [s, o] = [h.unit(1), h.unit(2)];
assert.deepEqual([s.def.charId, s.skill.id], ['char_601_cguard', 'skcom_atk_up[2]']);
assert.equal(unitInfo(s).standInFor, CHESS.chess_char_4_07_b.charId);
assert.equal(unitInfo(s).spine, 'char_601_cguard', 'the stand-in model');
assert.equal(unitInfo(o).standInFor, undefined);
assert.equal(h.b.errors.length, 0);
});
test('harness: standIn: { skillIndex, moduleId } fields another skill / module of the stand-in (standInRec); a chess without a stand-in throws', () => {
const r = standInRec('chess_char_5_11_b', { skillIndex: 0 });
assert.deepEqual([r.charId, r.skill.skillId, r.module.id, r.module.active, r.standInFor], ['char_613_acmedc', 'skchr_acmedc_1', 'uniequip_002_acmedc', true, CHESS.chess_char_5_11_b.charId]);
assert.deepEqual(standInRec('chess_char_5_11_b'), standInRecord(CHESS.chess_char_5_11_b, BACKUPS), 'no selection: the chess backup');
const h = makeBattle({ units: [{ chessId: 'chess_char_5_11_b', row: 10, col: 4, standIn: { skillIndex: 0, moduleId: 'none' } }] });
h.step();
const u = h.unit(1);
assert.deepEqual([u.def.charId, u.skill.id, !!u.def.raw.module?.active, u.def.standInFor], ['char_613_acmedc', 'skchr_acmedc_1', false, CHESS.chess_char_5_11_b.charId]);
assert.equal(u.base.atk, unitForm(BACKUPS, 'char_613_acmedc', CHESS.chess_char_5_11_b.status).stats.atk, 'no module: the form ATK');
assert.throws(() => standInRec('chess_char_1_01_a'), /no 补位 stand-in/);
assert.throws(() => standInRec('chess_char_5_11_b', { skillIndex: 7 }), /has no skill/);
});
// ---------------------------------------------------------------------------------------------------------------
// the eight 预备干员 on the generic path
/** The skill buff mods the generic spec should give for a stat skill's blackboard (generic.js key map). */
function statMods(bb) {
const m = {};
if (bb.atk) m.atkPct = bb.atk;
if (bb.def) m.defPct = bb.def;
if (bb.attack_speed) m.aspd = bb.attack_speed;
return m;
}
test('the eight 预备干员: every chess record they replace fields the stand-in body; the skill fires with its blackboard numbers; the stat talent applies', () => {
const seen = new Set();
for (const c of RESERVE_RECORDS) {
const charId = c.backup.charId;
const form = unitForm(BACKUPS, charId, c.status);
const sk = form.skills.find((s) => s.index === c.backup.skillIndex);
const where = `${c.chessId} ${c.name} → ${BACKUPS.units[charId].name} ${sk.name}`;
const medic = charId === 'char_605_cmedic';
const units = [{ chessId: c.chessId, row: 9, col: 5, dir: 'RIGHT', standIn: true, carryState: { sp: 999 } }];
if (medic) units.push({ chessId: 'chess_char_1_02_a', row: 10, col: 6 });
const h = makeBattle({
defs: { enemies: DUMMY }, units, timeLimit: 60, autoFinish: false, flags: { dpPerSec: 0 },
enemies: medic ? [] : [{ key: 'enemy_dummy', route: 0, time: 0 }], hooks: ['skillStart'],
});
const u = h.unit(1);
if (medic) { h.step(); const a = h.unit(2); h.b.loseHp(a, a.hp * 0.6); }
// the stand-in body and the generic kit, never the replaced operator's
assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id, u.kit.generic], [charId, c.charId, sk.skillId, true], where);
for (const k of ['maxHp', 'atk', 'def']) assert.equal(u.base[k], form.stats[k], `${where}: base ${k}`);
// the unconditional stat talent (攻击力+8% / 防御力+10% / 攻击速度+9), as a persistent talent buff
const talent = form.talents[0];
const tMods = statTalentMods({ description: talent.desc, bb: talent.bb });
assert.ok(tMods, `${where}: ${talent.desc} is a plain stat talent`);
assert.deepEqual(u.findBuff('talent:generic:0')?.mods, tMods, `${where}: talent ${talent.name}`);
const dp0 = h.b.players[0].dp;
if (sk.skillType === 'PASSIVE') {
h.step();
assert.deepEqual(u.findBuff(`generic:passive:${sk.skillId}`)?.mods, statMods(sk.bb), `${where}: deploy buff`);
} else {
assert.ok(h.runUntil(() => u.skill.activations > 0, 30), `${where}: the skill fires`);
h.step();
if (sk.prefabId === 'skcom_charge_cost') {
assert.equal(u.skill.rule, 'SP_FULL', `${where}: AUTO, nothing to wait for`);
assert.equal(h.b.players[0].dp, dp0 + sk.bb.cost, `${where}: +${sk.bb.cost} DP`);
} else {
const buff = u.findBuff(`skill:${u.id}`);
assert.deepEqual(buff?.mods, statMods(sk.bb), `${where}: skill buff from the blackboard`);
assert.equal(u.skill.kind, 'duration', where);
assert.equal(u.skill.duration, sk.duration, `${where}: ${sk.duration} s`);
// the stat the skill raises, exactly: base × (1 + talent + skill) or base + talent + skill
if (sk.bb.atk) close(u.s.atk, form.stats.atk * (1 + (tMods.atkPct ?? 0) + sk.bb.atk), `${where}: ATK`);
if (sk.bb.def) close(u.s.def, form.stats.def * (1 + (tMods.defPct ?? 0) + sk.bb.def), `${where}: DEF`);
if (sk.bb.attack_speed) assert.equal(u.s.aspd, form.stats.aspd + (tMods.aspd ?? 0) + sk.bb.attack_speed, `${where}: ASPD`);
}
}
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, `${where}: ${JSON.stringify(h.b.errors[0])}`);
seen.add(charId);
}
assert.equal(RESERVE_RECORDS.length, 28);
assert.deepEqual([...seen].sort(), RESERVES);
});
test('一击即退 (预备干员-特种 for 伊内丝): ATK / DEF up for exactly its 10 s from every deployment, normal and elite', () => {
for (const id of ['chess_char_4_04_a', 'chess_char_4_04_b']) {
const c = CHESS[id];
const form = unitForm(BACKUPS, 'char_607_cspec', c.status);
const sk = form.skills.find((s) => s.index === c.backup.skillIndex);
assert.deepEqual([sk.skillId, sk.skillType, sk.duration], ['skcom_born_up[2]', 'PASSIVE', 10]);
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 4, standIn: true }], timeLimit: 200, autoFinish: false });
const u = h.unit(1);
const on = () => { close(u.s.atk, form.stats.atk * (1.08 + sk.bb.atk), `${id} ATK on`); close(u.s.def, form.stats.def * (1 + sk.bb.def), `${id} DEF on`); };
const off = () => { close(u.s.atk, form.stats.atk * 1.08, `${id} ATK off`); close(u.s.def, form.stats.def, `${id} DEF off`); };
h.run(9.9);
on();
h.run(0.2);
off();
// knocked out and redeployed (再部署时间大幅度减少): the buff starts again with the new deployment
h.b.kill(u);
assert.ok(h.runUntil(() => u.alive && u.deployed, 60), `${id}: redeployed`);
const back = h.b.time;
h.step();
on();
h.runUntil(() => h.b.time >= back + 9.9, 20);
on();
h.run(0.2);
off();
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
}
});
test('冲锋号令·γ型 (预备干员-先锋 for 圣约送葬人): +12 DP as soon as the SP is full — no enemy needed — then again after each refill', () => {
for (const id of ['chess_char_5_01_a', 'chess_char_5_01_b']) {
const c = CHESS[id];
const sk = unitForm(BACKUPS, 'char_600_cpione', c.status).skills.find((s) => s.index === c.backup.skillIndex);
assert.deepEqual([sk.skillId, sk.skillType, sk.bb.cost], ['skcom_charge_cost[3]', 'AUTO', 12]);
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 4, standIn: true }], timeLimit: 200, autoFinish: false, flags: { dpPerSec: 0 }, hooks: ['skillStart'] });
const u = h.unit(1);
const dp = () => h.b.players[0].dp;
h.step();
const dp0 = dp();
// initial SP initSp, then +1/s: the first cast when the SP reaches the cost (41 − 12 s normal, 38 − 14 s elite)
assert.ok(h.runUntil(() => u.skill.activations === 1, 60));
close(Math.round(h.b.time), sk.spCost - sk.initSp, `${id}: first cast at full SP`);
h.step();
assert.equal(dp(), dp0 + 12, `${id}: +12 DP`);
assert.ok(h.runUntil(() => u.skill.activations === 2, 60));
h.step();
assert.equal(dp(), dp0 + 24, `${id}: +12 DP again`);
assert.equal(h.hooksOf('skillStart').length, 2);
assert.equal(h.b.enemies.length, 0, 'no enemy at all');
checkInvariants(h.b);
}
});
/**
* A real battle (the 重装 trigger check of test/sim/feedback1-tank-triggers.test.js): the stand-in of `chessId` on
* (9,5) of 战场#01(下半) facing the gate with its SP full; one 萨卡兹枯朽前锋 (melee) walks the lane from 3 s. Returns the
* first cast (time, reason, an enemy on its range or blocked then) and the time the unit first took damage.
*/
function firstCast(chessId) {
const h = makeBattle({
stageId: 'act2autochess_m01', seed: 3, timeLimit: 120, autoFinish: false,
units: [{ chessId, row: 9, col: 5, dir: 'RIGHT', standIn: true, carryState: { sp: 999 } }],
enemies: [{ key: 'enemy_1422_lrsldr', route: 0, time: 3 }],
});
const u = h.unit(chessId);
const out = { rule: u.skill.rule, cast: null, reason: null, enemyInRange: false, blocking: false, hurt: null };
h.b.on('damaged', (ctx) => { if (ctx.target === u && out.hurt == null) out.hurt = h.b.time; }, { priority: 1000 });
h.b.on('skillStart', (ctx) => {
if (ctx.unit !== u || out.cast != null) return;
out.cast = h.b.time;
out.reason = ctx.reason;
const keys = new Set(u.baseRangeKeys || u.rangeKeys);
out.enemyInRange = h.b.enemies.some((e) => e.alive && e.deployed && bodyInKeys(e, keys));
out.blocking = u.blocking.length > 0;
}, { priority: 1000 });
h.runUntil(() => out.cast != null && out.hurt != null, 40);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
return { ...out, charId: u.def.charId, skill: u.skill.id, trigger: u.def.skill.trigger };
}
test('预备干员-重装 (for 泥岩 S2, 乌尔比安 S3): casts with an enemy in range before anything hits it — the owner\'s 重装 exception, rawRule TAKE_DAMAGE', () => {
for (const id of ['chess_char_4_18_a', 'chess_char_4_18_b', 'chess_char_5_05_a', 'chess_char_5_05_b']) {
const r = firstCast(id);
const where = `${id}: ${JSON.stringify(r)}`;
assert.equal(r.charId, 'char_602_cdfend', where);
assert.deepEqual(r.trigger, { rule: 'DEFAULT', grid: null }, where);
assert.equal(r.rule, 'DEFAULT', where);
assert.ok(r.cast != null && r.cast >= 3, `cast once the enemy is on the field — ${where}`);
assert.equal(r.reason, 'DEFAULT', where);
assert.ok(r.enemyInRange || r.blocking, `an enemy on its tile at the cast — ${where}`);
assert.ok(r.hurt == null || r.hurt > r.cast, `cast before the first hit — ${where}`);
}
// the data keeps the official row as rawRule, on every skill of both 重装 stand-ins (Mechanist's kit reads it as is)
for (const charId of ['char_602_cdfend', 'char_610_acfend']) {
for (const f of Object.values(BACKUPS.units[charId].forms)) for (const s of f.skills) assert.deepEqual([s.trigger.rule, s.trigger.rawRule], ['DEFAULT', 'TAKE_DAMAGE'], `${charId} ${s.skillId}`);
}
});
+49 -5
View File
@@ -11,15 +11,21 @@
// stage explicit stage object (overrides stageId)
// kind 'normal' | 'unite' | 'boss' | 'hidden' (default 'normal')
// seed uint32 (default 1)
// units [{ chessId, row, col, items?, uid?, carryState?, abs? } | { kind:'token', tokenId, row, col }]
// board coordinates (rows 9–12, cols 2–10) for player 'p1'
// units [{ chessId, row, col, items?, uid?, carryState?, abs?, standIn? } | { kind:'token', tokenId, row, col }]
// board coordinates (rows 9–12, cols 2–10) for player 'p1'. `standIn: true` fields a NORMAL chess as its
// 补位 stand-in (data/backups.json through simdata getChess(id, { standIn: true }) — the production
// 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
// 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
// routes RouteSpec[] (default: template routes, or flat-stage routes 0..3 = walk low, walk high, fly low, fly high)
// timeLimit seconds (default: template maxPlayTime, else 60; boss/hidden: Infinity)
// content 'full' | 'generic' | 'none' (default 'full')
// kits { [baseChessId]: (bb, chess, def) => Kit } injected kits (take precedence over kits/index.js)
// kits { [baseChessId | standInCharId]: (bb, chess, def) => Kit } injected kits (take precedence over
// kits/index.js; a stand-in's kit is keyed by its charId only)
// extraContent [{ install(battle) }] extra content modules installed after the domain modules (content tests)
// defs { chess: {id: record}, enemies: {key: record}, tokens: {id: record} } extra/override data records
// flags, sharedBoss, modeId, round, setup(battle), recordEvents (default true). The harness sets
@@ -36,6 +42,7 @@
import { Battle } from '../../server/sim/Battle.js';
import { DataSource, getDefaultSource, spawnsFromTemplate } from '../../server/sim/simdata.js';
import { TICK } from '../../server/sim/constants.js';
import { standInRecord, composeUnitRecord, unitForm } from '../../shared/standIn.js';
export const ALL_HOOKS = Object.freeze([
'battleStart', 'deploy', 'tick', 'beforeAttack', 'attack', 'hit', 'damaged', 'heal', 'kill', 'death',
@@ -141,15 +148,52 @@ export function enemyRec(o = {}) {
};
}
/**
* The 补位 record of a NORMAL chess (data/chess.json `_a` normal / `_b` elite) fielded as its stand-in — shared/standIn.js
* standInRecord: the chess's backup skill and module — or, with `skillIndex` / `moduleId` (null / 'none' = no module),
* another selection of the same stand-in at the chess's status. Throws when the chess has no stand-in (PRESET, DIY) or
* the stand-in lacks that skill / module.
* @param {string} chessId
* @param {{ skillIndex?: number, moduleId?: string|null }} [sel]
* @param {object} [data] DataSource (default: the sim's default source)
*/
export function standInRec(chessId, sel = {}, data = getDefaultSource()) {
const chess = data.rawChess(chessId);
const backups = typeof data.rawBackups === 'function' ? data.rawBackups() : null;
const b = chess?.backup;
if (!b || !backups || chess.chessType !== 'NORMAL') throw new Error(`${chessId}: no 补位 stand-in (PRESET / DIY chess, or no backups.json)`);
const custom = sel && (sel.skillIndex != null || sel.moduleId !== undefined);
const rec = !custom ? standInRecord(chess, backups) : composeUnitRecord(chess, backups.units?.[b.charId], unitForm(backups, b.charId, chess.status), {
skillIndex: sel.skillIndex ?? b.skillIndex,
moduleId: sel.moduleId === undefined ? b.uniEquipId ?? null : (sel.moduleId === 'none' ? null : sel.moduleId),
standInFor: chess.charId,
});
if (!rec || !rec.skill) throw new Error(`${chessId}: stand-in ${b.charId} has no skill ${sel?.skillIndex ?? b.skillIndex}`);
const mod = sel?.moduleId;
if (custom && mod && mod !== 'none' && !rec.module?.active) throw new Error(`${chessId}: stand-in ${b.charId} has no module ${mod} at ${chessId}`);
return rec;
}
function buildData(defs) {
if (!defs) return getDefaultSource();
return new DataSource({ chess: defs.chess ?? {}, enemies: defs.enemies ?? {}, tokens: defs.tokens ?? {}, stages: defs.stages ?? {}, waves: defs.waves ?? {} }, getDefaultSource());
}
/** `units[].standIn` objects → the composed records for `defs.chess` (null when there are none). */
function standInDefs(units) {
let out = null;
for (const u of units ?? []) {
if (!u || !u.standIn || typeof u.standIn !== 'object') continue;
(out ??= {})[u.chessId] = standInRec(u.chessId, u.standIn);
}
return out;
}
/** Create a battle + harness. See header for options. */
export function makeBattle(opts = {}) {
const kind = opts.kind ?? 'normal';
const data = opts.data ?? buildData(opts.defs);
const custom = opts.data ? null : standInDefs(opts.units);
const data = opts.data ?? buildData(custom ? { ...opts.defs, chess: { ...(opts.defs?.chess ?? {}), ...custom } } : opts.defs);
let stage = opts.stage ?? null;
if (!stage) stage = !opts.stageId || opts.stageId === 'flat' ? flatStage(opts.flat || {}) : data.getStage(opts.stageId);
if (!stage) throw new Error(`unknown stage ${opts.stageId}`);
@@ -181,7 +225,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 })),
units: (opts.units ?? []).map((u, i) => ({ uid: u.uid ?? i + 1, kind: u.kind ?? 'chess', ...u, ...(u.standIn ? { standIn: true } : null) })),
bonds: opts.bonds ?? {}, bandId: opts.bandId ?? null, playerEffects: opts.playerEffects ?? [],
}];
const hookNames = opts.hooks ?? ALL_HOOKS;