// 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 {sp: 0, skillActive: true} (what unitsEnd reports) resumes the timed skill without a charge', () => { const h = 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: 0, skillActive: true } }], content: 'generic', }); h.step(); const u = h.unit('t_sn'); approx(u.hpRatio, 0.6, 1e-9); assert.equal(u.skill.active, true); assert.equal(u.skill.charges, 0); assert.ok(u.skill.sp < 0.01); approx(u.s.atk, 200); // and a real end-of-battle report round-trips: unitsEnd of a running skill carries sp 0 + skillActive h.run(1); const end = h.result().perPlayer.p1.unitsEnd[0]; assert.equal(end.skillActive, true); assert.equal(end.sp, 0); }); // --------------------------------------------------------------------------------------------------------------- // 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 (tier6.js has 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 (归溟幽灵鲨) uses half its own 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')); approx(u.s.maxHp, full * 0.5, 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; assert.equal(p.killed + p.leaked.filter((l) => l.counted).length, p.total, 'every counted enemy is killed or leaked'); }); 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'); });