Files
xcdoge b4bc89ad4e feedback7 UIBACK: the top-bar capsule counts like the official — resolved = killed + leaked among the enemies the round scheduled (opening 0/3, a leak 1/3, a kill 2/3, the splitter 3/3); a split child, a summon and a boss summon are in neither number but still cost LP and break 完美作战 (PR #308 by @xcdoge, the capsule half of PR #157 — authored by them: the sim / protocol / HUD hunks applied as reviewed): inTotal / killedInTotal / leakedInTotal / resolved, b.snap / BattleResult / b.progress (optional field) / m.public progress carry resolved, killed may exceed total (validation bounds it by the content-spawn allowance); dropped the PR's stray design-doc line — it had replaced the S→C b.end bullet of docs/design/network.md, which is back; the 联防 live-counter change mark follows the enemy list now that a spawn no longer moves total (uniteLeftMark + test); golden: only total / players.*.total / snaps move, outcomes bit-identical — roster 23 (roster-002 003 007 008 009 010 011 012 013 015 017 018 020 023 024 025 026 027 037 045 047 048 049), bonds 2 (bond-maniShip-low, bond-soloShip-high), fields 11 (boss-boss_1 3 5 pair+solo, boss-boss_6-solo, hidden-boss_8 10 pair+solo), diy 11 (diy-036 040 056 062 068 073 083 091 095 114 115), matches 12 (solo-FUNNY-1 NORMAL-1 HARD-1 ABYSS-1 ABYSS-2, coop2-NORMAL-3 coop2-HARD-4 coop3-ABYSS-5 coop4-ABYSS-7 coop2-NORMAL-8-serverrun solo-HARD-9-boosted coop2-NORMAL-70-diy: the total column of 36 round rows), standins unchanged
2026-10-08 19:30:36 +08:00

378 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Fidelity review (correctness vs Arknights rules / research / real data): SP-on-attack cycles, melee enemies,
// unite carry state, stage devices from data/stages.json, generic-kit heuristics on real chess, profession details.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource, hasGeneratedData, spawnsFromTemplate } from '../../server/sim/simdata.js';
import { genericSkillSpec, genericKit } from '../../server/sim/content/generic.js';
import { getData } from '../../server/data.js';
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
const RANGE3 = [[0, 0], [0, 1], [0, 2], [0, 3], [1, 0], [1, 1], [1, 2], [1, 3], [-1, 0], [-1, 1], [-1, 2], [-1, 3]];
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0, ...o });
const sniper = (skill, o = {}) => chessRec({ id: 't_sn', profession: 'SNIPER', subProfessionId: 'closerange', stats: { atk: 100, bat: 1, ...(o.stats || {}) }, rangeGrid: RANGE3, skill, ...o });
const ds = getDefaultSource();
const specOf = (id) => { const d = ds.getChess(id); return genericSkillSpec(d.skill, d.skill.bb, d); };
// ---------------------------------------------------------------------------------------------------------------
// skills
test('attack-SP instant skill fires every spCost+1 attacks: the skill attack itself recovers no SP', () => {
const h = makeBattle({
defs: { chess: { t_sn: sniper({ spType: 'INCREASE_WHEN_ATTACK', spCost: 3, initSp: 0, duration: 0, bb: { atk_scale: 2 } }) }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_sn', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }],
content: 'generic', hooks: ['attack'], captureNoisy: true,
});
h.run(16.5);
const seq = h.hooksOf('attack').map((a) => (a.isSkill ? 'S' : '.')).join('');
assert.ok(seq.startsWith('...S...S...S'), seq);
assert.ok(!seq.includes('S..S'), `cycle of 4 attacks: ${seq}`);
});
test('attack-SP ammo skill: no SP from any ammo shot, including the last one', () => {
const h = makeBattle({
defs: { chess: { t_sn: sniper({ spType: 'INCREASE_WHEN_ATTACK', spCost: 2, initSp: 0, duration: 0, durationType: 'AMMO', bb: { 'attack@trigger_time': 3 } }) }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_sn', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], content: 'generic',
});
const u = h.unit('t_sn');
assert.ok(h.runUntil(() => u.skill.activations === 1 && !u.skill.active, 30));
assert.equal(u.stats.attacks, 5, '2 charging attacks + 3 ammo shots');
assert.equal(u.skill.sp, 0, 'the last ammo shot recovered nothing');
h.runUntil(() => u.stats.attacks === 6, 5);
assert.equal(u.skill.sp, 1);
});
test('unite carryState: a skill running at the end of the own combat is not carried — unitsEnd reports sp 0 and the skill enters 联防 off', () => {
// community report #34 / GitHub #82: the carried skillActive used to restart the skill for free (bar empty, skill on)
const h = makeBattle({
defs: { chess: { t_sn: sniper({ duration: 10, spCost: 10, initSp: 10, bb: { atk: 1 } }) }, enemies: { e_dummy: enemyRec({ key: 'e_dummy', hp: 1e9, speed: 0 }) } },
units: [{ chessId: 't_sn', row: 10, col: 4 }], enemies: [{ key: 'e_dummy', pos: [10, 6] }], content: 'generic', autoFinish: false,
});
h.runUntil(() => h.unit('t_sn').skill.active, 10);
h.run(1);
const end = h.result().perPlayer.p1.unitsEnd[0];
assert.equal(end.skillActive, true, 'reported');
assert.equal(end.sp, 0, 'the SP was spent at the activation (PRTS 技能 "触发技能后…消耗相应的技力")');
const u2 = makeBattle({
defs: { chess: { t_sn: sniper({ duration: 10, spCost: 10, initSp: 0, bb: { atk: 1 } }) } },
units: [{ chessId: 't_sn', row: 10, col: 4, carryState: { hpPct: 0.6, sp: end.sp, skillActive: end.skillActive } }], content: 'generic',
});
u2.step();
const u = u2.unit('t_sn');
approx(u.hpRatio, 0.6, 1e-9);
assert.equal(u.skill.active, false);
assert.equal(u.skill.charges, 0);
assert.ok(u.skill.sp < 0.2);
approx(u.s.atk, 100);
});
// ---------------------------------------------------------------------------------------------------------------
// enemies
test('MELEE enemies only hit their blocker even with a data radius; RANGED ones shoot unblocking operators', () => {
const medic = chessRec({ id: 't_med', profession: 'MEDIC', subProfessionId: 'physician', stats: { atk: 0, maxHp: 1e5 }, rangeGrid: [[0, 0]], skill: null });
const run = (applyWay) => {
const h = makeBattle({
defs: { chess: { t_med: medic }, enemies: { enemy_x: enemyRec({ key: 'enemy_x', applyWay, range: 2.5, atk: 100, speed: 1, hp: 1e6 }) } },
units: [{ chessId: 't_med', row: 10, col: 2 }], // high ground next to the goal: never blocks
enemies: [{ key: 'enemy_x', route: 0 }], content: 'none', timeLimit: 30,
});
h.runToEnd(31);
return h.unit('t_med').stats.taken;
};
assert.equal(run('MELEE'), 0, 'melee enemy walked past without attacking');
assert.ok(run('RANGED') > 0, 'ranged enemy shot the medic');
});
test('real data: MELEE enemies with an official radius (宿主士兵) leave high-ground operators alone', () => {
const h = makeBattle({ units: [{ chessId: 'chess_char_2_14_a', row: 10, col: 2 }], enemies: [{ key: 'enemy_1043_zomsbr', route: 0 }], timeLimit: 40, content: 'none' });
h.runToEnd(41);
assert.equal(h.unit('chess_char_2_14_a').stats.taken, 0);
});
// ---------------------------------------------------------------------------------------------------------------
// stage devices (data/stages.json)
test('stage devices: act1 m02 crates are hidden and inactive in 下半 (research 08 §3.2) ⇒ none; act1 m04 active crates ⇒ obstacles', { skip: !hasGeneratedData() }, () => {
const inRect = (id) => ds.getStage(id).devices.filter((d) => d.role === 'crate' && d.row >= 9 && d.row <= 12 && d.col >= 0 && d.col <= 10);
const m02 = inRect('act1autochess_m02');
assert.ok(m02.length > 0 && m02.every((d) => d.hidden), 'fixture: hidden crates on the m02 normal field');
const h2 = makeBattle({ stageId: 'act1autochess_m02', content: 'none', timeLimit: 5 });
h2.step();
assert.equal(h2.b.allyUnits.filter((u) => u.kind === 'device').length, 0, '下半 m02 starts without crates');
for (const d of m02) assert.ok(!h2.b.grid.isObstacle(d.row, d.col), `no obstacle at (${d.row},${d.col})`);
const m04 = inRect('act1autochess_m04');
assert.ok(m04.length > 0 && m04.every((d) => !d.hidden), 'fixture: active crates on the m04 normal field');
const h4 = makeBattle({ stageId: 'act1autochess_m04', content: 'none', timeLimit: 5 });
h4.step();
const crates = h4.b.allyUnits.filter((u) => u.kind === 'device');
assert.equal(crates.length, m04.length);
for (const c of crates) assert.ok(h4.b.grid.isObstacle(c.tileR, c.tileC));
});
test('stage devices: active platforms (act1 m03) block ground paths and elevate operators standing on them', { skip: !hasGeneratedData() }, () => {
const stage = ds.getStage('act1autochess_m03');
const plats = stage.devices.filter((d) => d.role === 'platform' && d.raw.active && d.row >= 9 && d.row <= 12 && d.col <= 10);
assert.ok(plats.length > 0);
const p = plats[0];
const h = makeBattle({ stageId: 'act1autochess_m03', units: [{ chessId: 'chess_char_1_01_a', row: p.row, col: p.col }], content: 'none', timeLimit: 5 });
h.step();
assert.ok(h.b.grid.isObstacle(p.row, p.col));
const u = h.unit('chess_char_1_01_a');
assert.ok(u.deployed);
assert.equal(u.ground, false, 'an operator on a platform never blocks');
// hidden+inactive platforms (m02) change nothing
const h2 = makeBattle({ stageId: 'act1autochess_m02', content: 'none', timeLimit: 5 });
h2.step();
const p2 = ds.getStage('act1autochess_m02').devices.find((d) => d.role === 'platform' && d.row >= 9 && d.row <= 12 && d.col <= 10 && !h2.b.allyUnits.some((x) => x.tileR === d.row && x.tileC === d.col));
if (p2) assert.equal(h2.b.grid.isObstacle(p2.row, p2.col), false);
});
// ---------------------------------------------------------------------------------------------------------------
// generic kit on real chess (skill metadata/blackboards from data/chess.json)
test('generic kind: duration −1 is instant/charges unless the skill is explicitly endless', () => {
assert.equal(specOf('chess_char_6_09_a').kind, 'charges', '塑心 可充能2次');
assert.equal(specOf('chess_char_5_22_a').kind, 'instant', '妮芙');
assert.equal(specOf('chess_char_5_12_a').kind, 'charges', '夕 下一次攻击');
assert.equal(specOf('chess_char_3_17_a').kind, 'instant', '流星 立即');
assert.equal(specOf('chess_char_4_14_a').kind, 'charges', '莱恩哈特');
const surtr = specOf('chess_char_5_07_a');
assert.equal(surtr.kind, 'toggle', '史尔特尔 持续时间无限');
assert.equal(surtr.mods.hpFlat, 5000, '生命上限+5000 is flat');
assert.equal(surtr.mods.hpPct, undefined);
});
test('generic scales: attack@atk_scale wins for hits; burst/counter/bonus scales never become the attack scale', () => {
const lg = specOf('chess_char_3_08_a'); // 薄绿: 110 % per hit, 210 % at skill end
assert.equal(lg.attack.atkScale, 1.1);
assert.equal(typeof lg.onEnd, 'function');
assert.equal(specOf('chess_char_5_19_a').attack.atkScale, 1.25, '玛恩纳 125 % ×5');
assert.equal(specOf('chess_char_5_03_a').attack?.atkScale, undefined, '烛煌: 额外造成攻击力60% is bonus damage');
assert.equal(specOf('chess_char_1_06_a').attack?.atkScale, undefined, '刺玫: 20 % belongs to the protected ally counter');
const ub = specOf('chess_char_5_05_a'); // 乌尔比安: 立即 anchor 135 % + stun 6 once
assert.equal(ub.attack?.atkScale, undefined);
assert.equal(ub.attack?.onHit, undefined, 'no 6 s stun on every attack');
assert.equal(typeof ub.onStart, 'function');
assert.deepEqual(specOf('chess_char_1_02_a').mods, { defPct: 0.1, hpPct: 0.3, resMul: 1.6 }, '角峰 法术抗性+60%');
});
test('generic passives only buff stats: 星熊 keeps 100 % attacks and counters attackers for 65 % ATK', () => {
const spec = specOf('chess_char_4_17_a');
assert.equal(spec.kind, 'passive');
assert.equal(spec.attack, undefined);
assert.equal(spec.targeting, undefined);
assert.equal(specOf('chess_char_4_16_a').targeting, undefined, '缄默德克萨斯 keeps her own range');
const h = makeBattle({
defs: { enemies: { enemy_x: enemyRec({ key: 'enemy_x', hp: 1e7, atk: 50, def: 0, speed: 1, bat: 1 }) } },
units: [{ chessId: 'chess_char_4_17_a', row: 9, col: 6 }], enemies: [{ key: 'enemy_x', route: 0 }],
hooks: ['damaged'], captureNoisy: true, timeLimit: 20,
});
const u = h.unit('chess_char_4_17_a');
h.run(12);
const counters = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags?.includes('counter'));
assert.ok(counters.length > 0, 'countered');
approx(counters[0].amount, u.s.atk * 0.65, 1e-6);
checkInvariants(h.b);
});
test('generic target debuffs: 初雪 shreds enemies in range (not herself); 流星 marks its targets for 5 s', () => {
const s = specOf('chess_char_3_14_a');
assert.equal(s.mods, undefined, 'no self debuff');
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy({ def: 500, res: 50 }) } },
units: [{ chessId: 'chess_char_3_14_a', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], timeLimit: 120,
});
const u = h.unit('chess_char_3_14_a');
assert.ok(h.runUntil(() => u.skill.active, 90));
h.step(2);
const e = h.enemy('enemy_dummy');
approx(e.s.def, 300);
approx(e.s.res, 50 * 0.77, 1e-6);
approx(u.s.def, u.base.def);
h.runUntil(() => !u.skill.active, 30);
h.run(1);
approx(e.s.def, 500);
const m = specOf('chess_char_3_17_a');
assert.equal(m.mods, undefined);
assert.equal(typeof m.attack.onHit, 'function');
});
test('generic text rules: 泡泡 stops attacking; 小满 sleeps ≤3 enemies once; 宴 +65 % ATK only for 14 s after losing half HP; 砾 shield', () => {
assert.equal(specOf('chess_char_2_08_a').attack.noAttack, true);
const xm = specOf('chess_char_2_04_a');
assert.equal(xm.attack?.onHit, undefined, 'no sleep on every attack');
assert.equal(typeof xm.onStart, 'function');
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 'chess_char_1_18_a', row: 9, col: 5 }, { chessId: 'chess_char_2_12_a', row: 11, col: 5 }], content: 'full', autoFinish: false, timeLimit: 60 });
h.step();
const yan = h.unit('chess_char_1_18_a');
approx(yan.hpRatio, 0.5, 1e-6);
approx(yan.s.atk, yan.base.atk * 1.65, 1e-6);
h.run(14.1);
approx(yan.s.atk, yan.base.atk, 1e-6);
const gravel = h.unit('chess_char_2_12_a');
assert.equal(gravel.s.shield, 0, 'the 10 s decaying barrier is gone');
const h2 = makeBattle({ units: [{ chessId: 'chess_char_2_12_a', row: 11, col: 5 }], autoFinish: false, timeLimit: 60 });
h2.step();
const g2 = h2.unit('chess_char_2_12_a');
assert.ok(g2.s.shield > g2.s.maxHp * 1.3, `barrier ${g2.s.shield}`);
h2.run(5);
assert.ok(g2.s.shield < g2.s.maxHp * 0.8 && g2.s.shield > 0, 'decays');
});
test('generic element: 塑心 charges add 85 % ATK of 凋亡 (apoptosis) damage to the gauge', () => {
// the generic kit's numbers (kits/ops/chess_char_6_09-cello.js is a hand-written 塑心 kit whose talent amplifies apoptosis in range)
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 'chess_char_6_09_a', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], timeLimit: 60, content: 'generic' });
const u = h.unit('chess_char_6_09_a');
assert.ok(h.runUntil(() => u.skill.activations >= 1 && h.enemy('enemy_dummy').elem.apoptosis > 0, 40));
const e = h.enemy('enemy_dummy');
approx(e.elem.apoptosis, u.s.atk * 0.85 * u.skill.activations, 1e-3);
});
// ---------------------------------------------------------------------------------------------------------------
// professions / engine details
test('taunt as a buff flag (DESIGN §5.3) raises aggro: ranged enemies pick the taunting operator', () => {
const tank = (id) => chessRec({ id, profession: 'TANK', stats: { atk: 0, maxHp: 1e6, blockCnt: 0 }, rangeGrid: [[0, 0]], skill: null });
const h = makeBattle({
defs: { chess: { t_a: tank('t_a'), t_b: tank('t_b') }, enemies: { enemy_r: enemyRec({ key: 'enemy_r', applyWay: 'RANGED', range: 5, atk: 10, speed: 0, hp: 1e6 }) } },
units: [{ chessId: 't_a', row: 10, col: 5 }, { chessId: 't_b', row: 11, col: 5 }], enemies: [{ key: 'enemy_r', pos: [10, 8] }], content: 'none', autoFinish: false, timeLimit: 30,
});
h.step();
const a = h.unit('t_a');
h.b.addBuff(a, { key: 'test:taunt', flags: { taunt: true } });
assert.equal(a.s.taunt, 1);
h.run(5);
assert.ok(a.stats.taken > 0);
assert.equal(h.unit('t_b').stats.taken, 0, 'latest-deployed rule overridden by taunt');
});
test('librator module (玛恩纳 elite init_atk): +100 % ATK right after deployment', { skip: !hasGeneratedData() }, () => {
const h = makeBattle({ units: [{ chessId: 'chess_char_5_19_b', row: 9, col: 5 }], autoFinish: false, timeLimit: 10 });
h.step();
const u = h.unit('chess_char_5_19_b');
approx(u.s.atk, u.base.atk * 2, 1e-6);
const n = makeBattle({ units: [{ chessId: 'chess_char_5_19_a', row: 9, col: 5 }], autoFinish: false, timeLimit: 10 });
n.step();
approx(n.unit('chess_char_5_19_a').s.atk, n.unit('chess_char_5_19_a').base.atk, 1e-6);
});
test('dollkeeper without a substitute token (归溟幽灵鲨) keeps its own max HP, not 风丸\'s 纸偶', { skip: !hasGeneratedData() }, () => {
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 'chess_char_5_13_a', row: 9, col: 5 }], content: 'none', autoFinish: false, timeLimit: 30 });
h.step();
const u = h.unit('chess_char_5_13_a');
const full = u.s.maxHp;
h.b.dealDamage(null, u, { amount: 1e7, type: 'true' });
assert.ok(u.alive && u.hasBuff('trait:substitute'));
// PRTS 分支特性信息 傀儡师 "重设自身生命至最大值"; the trait's 替身 HP bonus (bb max_hp) is 0 and 风丸's 纸偶 has her own HP
approx(u.s.maxHp, full, 1e-6);
approx(u.hp, full, 1e-6);
});
// ---------------------------------------------------------------------------------------------------------------
// end-to-end on real data
test('real data: R5 template on act1 m04 (crates) with a real lineup runs to the limit with invariants', { skip: !hasGeneratedData() }, () => {
const data = getData({ log: { warn() {}, error() {}, info() {} } });
const mode = data.config.modes.mode_multi_normal;
const rc = mode.rounds['5'];
const tpl = ds.getWave(rc.template);
const sc = mode.enemyScale?.['5'] ?? {};
const mods = { hpMul: (sc.hp ?? 1) ** (sc.kHp ?? 0), atkMul: (sc.atk ?? 1) ** (sc.kAtk ?? 0), speedMul: sc.speed ?? 1 };
const conv = spawnsFromTemplate(tpl, { mods });
const h = makeBattle({
// act1 m04: active 阻隔工事 at (12,3) (12,4) (12,8) (act1 m02 has none in 下半, research 08 §3.2)
stageId: 'act1autochess_m04', waveTemplate: tpl, mods, timeLimit: rc.combatTimeLimit, flags: tpl.dp ? { dpInit: tpl.dp.init, dpPerSec: tpl.dp.perSec, dpMax: tpl.dp.max } : undefined,
units: [
{ chessId: 'chess_char_1_02_a', row: 9, col: 3 }, { chessId: 'chess_char_1_12_b', row: 9, col: 4 }, { chessId: 'chess_char_1_01_a', row: 10, col: 3 },
{ chessId: 'chess_char_2_14_a', row: 11, col: 3 }, { chessId: 'chess_char_1_03_a', row: 11, col: 4 }, { chessId: 'chess_char_3_01_a', row: 10, col: 5 },
],
});
assert.equal(h.b.total, conv.spawns.reduce((n, s) => n + (s.countInTotal === false ? 0 : s.count), 0));
let t = 0;
while (!h.b.finished && t < rc.combatTimeLimit + 2) { h.run(1); t += 1; checkInvariants(h.b); }
assert.ok(h.b.finished);
assert.ok(h.b.time <= rc.combatTimeLimit + 1e-6);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
assert.ok(h.b.allyUnits.some((u) => u.kind === 'device'), 'm04 crates present');
const r = h.result();
const p = r.perPlayer.p1;
// the capsule identity (PR #157): this round's own scheduled enemies are all 已解决 — knocked down or leaked. It
// replaces `killed + counted leaks = total`, which mixed the counted reading (runtime splits / summons included) with
// the scheduled denominator: `killed` may now exceed `total`, so only `≥` holds for it, while the capsule pair is exact
assert.ok(p.killed + p.leaked.filter((l) => l.counted).length >= p.total, 'every counted enemy is killed or leaked');
assert.equal(p.killedInTotal + p.leakedInTotal, p.total, 'the capsule is full: killedInTotal + leakedInTotal = total');
assert.equal(p.resolved, p.total);
});
test('generic kit never throws and yields finite numbers for every real chess (normal & elite)', () => {
for (const id of ds.chessIds()) {
const d = ds.getChess(id);
if (!d || !d.skill) continue;
const kit = genericKit(d.skill.bb, d.raw, d);
const s = kit.skill;
for (const [k, v] of Object.entries(s.mods || {})) assert.ok(Number.isFinite(v), `${id} ${k}`);
if (s.attack?.atkScale !== undefined) assert.ok(s.attack.atkScale > 0 && s.attack.atkScale < 10, `${id} atkScale ${s.attack.atkScale}`);
if (s.kind === 'toggle') assert.ok(/无限/.test(d.skill.description), `${id} toggle only when endless`);
}
});
// ---------------------------------------------------------------------------------------------------------------
// DESIGN §5.1 / §5.4 contract: API surface and the documented ctx keys of every hook
test('DESIGN §5.1/§5.4 contract: Battle API + helpers exist and every hook fires with its documented ctx keys', () => {
const HOOK_KEYS = {
battleStart: [], deploy: ['unit', 'initial'], tick: ['dt'], beforeAttack: ['attacker', 'targets'], attack: ['attacker', 'targets', 'isSkill'],
hit: ['source', 'target', 'dmg'], damaged: ['source', 'target', 'amount', 'type', 'dmg'], heal: ['source', 'target', 'amount'],
kill: ['killer', 'victim'], death: ['unit'], skillStart: ['unit', 'skill'], skillEnd: ['unit', 'skill'], ammoUsed: ['unit', 'left'],
spGain: ['unit', 'amount'], statusApplied: ['source', 'target', 'status', 'duration'], blocked: ['blocker', 'enemy'],
enemySpawn: ['enemy'], enemyLeak: ['enemy'], battleEnd: ['result'],
};
const DMG_KEYS = ['amount', 'type', 'element', 'atkScale', 'defIgnoreFlat', 'defIgnorePct', 'resIgnoreFlat', 'resIgnorePct', 'mul', 'canDodge', 'isSkill', 'isSplash', 'tags'];
const seen = {};
const h = makeBattle({
defs: {
chess: {
t_sn: sniper({ spCost: 2, initSp: 2, duration: 0, durationType: 'AMMO', bb: { 'attack@trigger_time': 3 } }, { stats: { atk: 400 } }),
t_g: chessRec({ id: 't_g', profession: 'WARRIOR', stats: { atk: 2000, blockCnt: 2, maxHp: 1e5 }, skill: null }),
t_med: chessRec({ id: 't_med', profession: 'MEDIC', subProfessionId: 'physician', stats: { atk: 100 }, rangeGrid: RANGE3, skill: null }),
},
enemies: { enemy_w: enemyRec({ key: 'enemy_w', hp: 3000, atk: 200, speed: 2 }), enemy_f: enemyRec({ key: 'enemy_f', hp: 1e6, speed: 4, motion: 'FLY' }) },
},
units: [{ chessId: 't_g', row: 9, col: 5 }, { chessId: 't_sn', row: 10, col: 3 }, { chessId: 't_med', row: 10, col: 4 }],
enemies: [{ key: 'enemy_w', route: 0, count: 2, interval: 1 }, { key: 'enemy_f', route: 2 }],
content: 'generic', hooks: [], timeLimit: 30,
setup: (b) => {
for (const name of Object.keys(HOOK_KEYS)) b.on(name, (ctx) => { if (!seen[name]) seen[name] = { ...ctx }; }, { priority: -5000 });
b.on('battleStart', () => { const e = b.allyUnits[0]; b.applyStatus(e, 'cold', { duration: 1, source: null }); });
},
});
h.runToEnd(40);
for (const [name, keys] of Object.entries(HOOK_KEYS)) {
assert.ok(seen[name], `hook ${name} fired`);
for (const k of keys) assert.ok(k in seen[name], `${name}.${k}`);
}
for (const k of DMG_KEYS) assert.ok(k in seen.hit.dmg, `DamageInfo.${k}`);
const b = h.b;
for (const m of ['step', 'forceEnd', 'result', 'snapshot', 'drainEvents', 'on', 'off', 'dealDamage', 'heal', 'applyStatus', 'addBuff', 'removeBuff',
'spawnToken', 'spawnEnemy', 'addProjectile', 'unitsInGrid', 'enemiesInRadius', 'alliesInRadius', 'addLayers', 'addCoins', 'fx', 'rng', 'getPlayer',
'redeploy', 'after', 'every']) assert.equal(typeof b[m], 'function', `battle.${m}`);
assert.equal(typeof b.finished, 'boolean');
assert.equal(typeof b.time, 'number');
const r = b.result();
for (const k of ['time', 'reason', 'perPlayer']) assert.ok(k in r, `BattleResult.${k}`);
for (const k of ['killed', 'total', 'leaked', 'perfect', 'layerGains', 'coins', 'damageDealt', 'bossDamage', 'healingDone', 'deaths', 'unitsEnd']) assert.ok(k in r.perPlayer.p1, `perPlayer.${k}`);
for (const k of ['enemyKey', 'mods', 'lpr', 'sourcePlayerId']) assert.ok(k in r.perPlayer.p1.leaked[0], `leaked.${k}`);
for (const k of ['uid', 'hpPct', 'sp', 'alive']) assert.ok(k in r.perPlayer.p1.unitsEnd[0], `unitsEnd.${k}`);
});
test('kits keyed like the DESIGN §5.6 example (suffix-less `chess_char_1_01`) are found for normal and elite chess', () => {
const calls = [];
const kits = { chess_char_1_01: (bb, chess, def) => { calls.push(def.id); return { skill: { kind: 'duration', duration: 3, mods: { atkPct: 1 } }, talents: [] }; } };
const h = makeBattle({ units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 3 }, { chessId: 'chess_char_1_01_b', row: 10, col: 4 }], kits, autoFinish: false, timeLimit: 5 });
h.step();
assert.deepEqual(calls.sort(), ['chess_char_1_01_a', 'chess_char_1_01_b']);
assert.equal(h.unit('chess_char_1_01_a').kit.generic, undefined, 'hand-authored kit, not the generic one');
});