Files
ark-stronghold-protocol/test/sim/battle.test.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

517 lines
26 KiB
JavaScript
Raw 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.
// Battle-level rules: DP & redeploy, pathing around crates, FLY checkpoints, leaks/results, time limit, unite & boss
// fields, shared boss pool, wire format, determinism, robustness, deployment order.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Battle } from '../../server/sim/Battle.js';
import { makeBattle, chessRec, enemyRec, flatStage, hashOf, checkInvariants } from '../helpers/battleHarness.js';
import { UF, ANIM } from '../../shared/constants.js';
import { EV } from '../../shared/protocol.js';
import { getDefaultSource, spawnsFromTemplate, hasGeneratedData } from '../../server/sim/simdata.js';
import { LocalBossPool } from '../../server/sim/spec.js';
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
const guard = (o = {}) => chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 300, blockCnt: 2 }, skill: null, ...o });
const walker = (o = {}) => enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 1, ...o });
test('initial deployment order: top→bottom then left→right; right boss side right→left', () => {
const h = makeBattle({
defs: { chess: { a: guard({ id: 'a' }), b: guard({ id: 'b' }), c: guard({ id: 'c' }), d: guard({ id: 'd' }) } },
units: [{ chessId: 'a', row: 9, col: 5 }, { chessId: 'b', row: 12, col: 7 }, { chessId: 'c', row: 12, col: 3 }, { chessId: 'd', row: 10, col: 4 }],
content: 'none',
});
h.step();
assert.deepEqual(h.hooksOf('deploy').filter((c) => c.initial).map((c) => c.unit.defId), ['c', 'b', 'd', 'a']);
assert.equal(h.hooksOf('battleStart').length, 1);
const hb = makeBattle({
kind: 'boss',
defs: { chess: { a: guard({ id: 'a' }), b: guard({ id: 'b' }) } },
players: [{ playerId: 'R1', side: 'R', colOffset: 8, units: [{ uid: 1, chessId: 'a', row: 10, col: 3 }, { uid: 2, chessId: 'b', row: 10, col: 6 }] }],
content: 'none',
});
hb.step();
const order = hb.hooksOf('deploy').filter((c) => c.initial).map((c) => [c.unit.defId, c.unit.tileR, c.unit.tileC, c.unit.facing]);
// board row 10 → boss row 3; mirrored cols: 3 → 17, 6 → 14; right→left in field coords
assert.deepEqual(order, [['a', 3, 17, -1], ['b', 3, 14, -1]]);
});
test('DP: starts at 10, +1/s, cap 99; dead operator redeploys after respawnTime when DP ≥ cost', () => {
const h = makeBattle({
defs: { chess: { t_guard: guard({ stats: { respawnTime: 5, cost: 20 } }) } },
units: [{ chessId: 't_guard', row: 9, col: 5 }], content: 'none', timeLimit: 200,
});
h.step();
const pl = h.b.getPlayer('p1');
approx(pl.dp, 10 + 1 / 30);
const g = h.unit('t_guard');
h.b.dealDamage(null, g, { amount: 1e9, type: 'true' });
assert.equal(g.alive, false);
assert.equal(h.result().perPlayer.p1.deaths, 1);
h.run(5.2);
assert.equal(g.alive, false, 'respawn time elapsed but DP (≈15) < cost 20');
h.run(5);
assert.equal(g.alive, true, 'redeployed once DP reached 20');
assert.ok(pl.dp < 2, `dp spent (${pl.dp})`);
approx(g.hp, g.s.maxHp);
h.run(150);
assert.equal(pl.dp, 99);
assert.equal(h.eventsOf('deploy').length, 2);
});
test('battle.redeploy(unit) is instant and free; retreat() keeps respawn timers', () => {
const h = makeBattle({ defs: { chess: { t_guard: guard({ stats: { respawnTime: 50, cost: 50 } }) } }, units: [{ chessId: 't_guard', row: 9, col: 5 }], content: 'none' });
h.step();
const g = h.unit('t_guard');
h.b.retreat(g);
assert.equal(g.alive, false);
assert.equal(h.result().perPlayer.p1.deaths, 0, 'retreat is not a death');
assert.equal(h.b.redeploy(g), true);
assert.equal(g.alive, true);
});
test('pathing: enemies walk around crates and re-path when a crate is destroyed', () => {
// wall off rows 10–12 at col 6 except via a crate on (9,6): the only route goes through the crate
const rows = { 10: '##hrrr#rrrfrrrrrrrf##', 11: '##hrrr#rrrfrrrrrrrf##', 12: '##hrrr#rrrSrrrrrrrS##' };
const h = makeBattle({
flat: { rows, crates: [[9, 6]] },
defs: { enemies: { enemy_walker: walker({ atk: 500, bat: 1 }) } },
enemies: [{ key: 'enemy_walker' }], content: 'none', autoFinish: false,
});
h.step();
const crate = h.b.allyUnits.find((u) => u.kind === 'device');
assert.ok(crate && crate.obstacle, 'crate spawned from stage devices');
assert.ok(h.b.grid.isObstacle(9, 6));
const e = h.enemy('enemy_walker');
h.runUntil(() => e.blockedBy === crate, 20);
assert.equal(e.blockedBy, crate, 'no way around: blocked by the crate');
h.runUntil(() => !crate.alive, 10);
assert.equal(crate.alive, false, 'enemy destroyed the crate');
assert.equal(h.b.grid.isObstacle(9, 6), false);
h.runUntil(() => !e.alive, 30);
assert.equal(h.result().perPlayer.p1.leaked.length, 1);
// with a detour available the crate is avoided entirely
const h2 = makeBattle({ flat: { crates: [[9, 6]] }, defs: { enemies: { enemy_walker: walker() } }, enemies: [{ key: 'enemy_walker' }], content: 'none', autoFinish: false });
const e2 = h2.b.units;
let touched = false;
h2.b.on('tick', () => { for (const u of h2.b.enemies) if (Math.round(u.y) === 9 && Math.round(u.x) === 6) touched = true; });
h2.runUntil(() => h2.b.enemies.length === 0 && h2.b.time > 1, 40);
assert.equal(touched, false, 'detoured around the crate');
assert.ok(e2.find((u) => u.kind === 'device').alive);
// re-path when a new obstacle appears mid-walk
const h3 = makeBattle({ defs: { enemies: { enemy_walker: walker() } }, enemies: [{ key: 'enemy_walker' }], content: 'none', autoFinish: false });
h3.run(1);
const e3 = h3.enemy('enemy_walker');
h3.b.setObstacle(9, 5, true);
let crossed = false;
h3.b.on('tick', () => { if (e3.alive && Math.round(e3.y) === 9 && Math.round(e3.x) === 5) crossed = true; });
h3.runUntil(() => !e3.alive, 40);
assert.equal(crossed, false);
});
test('FLY enemies follow their checkpoint route and ignore ground obstacles', () => {
const route = { motion: 'FLY', start: [9, 10], end: [9, 2], checkpoints: [[12, 9], [12, 4]] };
const h = makeBattle({
defs: { enemies: { enemy_fly: enemyRec({ key: 'enemy_fly', hp: 1e6, speed: 2, motion: 'FLY' }) } },
enemies: [{ key: 'enemy_fly', route }], content: 'none', autoFinish: false,
});
const pts = [];
h.b.on('tick', () => { const e = h.b.enemies[0]; if (e) pts.push([e.y, e.x]); });
h.runUntil(() => h.b.enemies.length === 0 && h.b.time > 0.5, 60);
assert.ok(pts.some(([y, x]) => Math.abs(y - 12) < 0.05 && Math.abs(x - 9) < 0.05), 'passed checkpoint (12,9)');
assert.ok(pts.some(([y, x]) => Math.abs(y - 12) < 0.05 && Math.abs(x - 4) < 0.05), 'passed checkpoint (12,4)');
const maxY = Math.max(...pts.map((p) => p[0]));
assert.ok(maxY <= 12 + 1e-9);
assert.equal(h.result().perPlayer.p1.leaked.length, 1);
});
test('route steps: disappear / wait / appear (teleport) are honoured', () => {
const route = { motion: 'WALK', start: [9, 10], end: [9, 2], steps: [{ t: 'move', p: [9, 8] }, { t: 'disappear' }, { t: 'wait', s: 2 }, { t: 'appear', p: [9, 4] }] };
const h = makeBattle({ defs: { enemies: { enemy_walker: walker({ speed: 2 }) } }, enemies: [{ key: 'enemy_walker', route }], content: 'none', autoFinish: false });
h.run(2.1);
const e = h.enemy('enemy_walker');
assert.equal(e.hidden, true);
assert.ok(!h.snapshot().units.some((t) => t[0] === e.id), 'hidden enemies are not in snapshots');
h.run(2);
assert.equal(e.hidden, false);
assert.ok(e.x <= 4 + 1e-9);
assert.ok(h.eventsOf('fx').some((f) => f[1] === 'appear'));
});
test('leaks: recorded per player with enemyKey/mods/lpr/sourcePlayerId; perfect=false; cleared when all gone', () => {
const h = makeBattle({
defs: { enemies: { enemy_walker: walker({ speed: 3, lpr: 2 }) } },
enemies: [{ key: 'enemy_walker', mods: { hpMul: 2 }, sourcePlayerId: 'pX' }], content: 'none',
});
const r = h.runToEnd(60);
assert.equal(r.reason, 'cleared');
const pp = r.perPlayer.p1;
assert.equal(pp.leaked.length, 1);
assert.deepEqual({ ...pp.leaked[0] }, { enemyKey: 'enemy_walker', mods: { hpMul: 2 }, lpr: 2, sourcePlayerId: 'pX', tag: null, counted: true, boss: undefined, spawned: true });
assert.equal(pp.perfect, false);
assert.equal(pp.killed, 0);
assert.equal(pp.total, 1);
assert.equal(h.eventsOf('leak').length, 1);
assert.equal(h.hooksOf('enemyLeak').length, 1);
assert.equal(h.hooksOf('battleEnd').length, 1);
});
test('kills: killed/total counters, bounty coins to the killer\'s owner, perfect clear', () => {
const h = makeBattle({
defs: { chess: { t_guard: guard({ stats: { atk: 5000 } }) }, enemies: { enemy_walker: walker({ hp: 1000 }) } },
units: [{ chessId: 't_guard', row: 9, col: 6 }],
enemies: [{ key: 'enemy_walker', count: 3, interval: 1, bounty: { coins: 2 } }], content: 'none',
});
const r = h.runToEnd(60);
assert.equal(r.reason, 'cleared');
assert.equal(r.perPlayer.p1.killed, 3);
assert.equal(r.perPlayer.p1.total, 3);
assert.equal(r.perPlayer.p1.perfect, true);
assert.equal(r.perPlayer.p1.coins, 6);
assert.equal(h.eventsOf('bounty').length, 3);
assert.equal(h.b.killed, 3);
assert.equal(h.snapshot().killed, 3);
assert.equal(h.unit('t_guard').stats.kills, 3);
});
test('time limit: battle ends at timeLimit, remaining enemies count as leaked, unspawned ones are dropped', () => {
const h = makeBattle({
defs: { enemies: { enemy_walker: walker({ speed: 0.01 }) } },
enemies: [{ key: 'enemy_walker', count: 2 }, { key: 'enemy_walker', time: 100 }], timeLimit: 10, content: 'none',
});
const r = h.runToEnd(30);
assert.equal(r.reason, 'timeout');
approx(r.time, 10, 1 / 30 + 1e-9);
assert.equal(r.perPlayer.p1.leaked.length, 2);
assert.equal(r.unspawned.length, 1);
assert.equal(r.total, 2);
assert.equal(r.perPlayer.p1.total, 2);
assert.equal(r.perPlayer.p1.perfect, false);
});
test('forceEnd: forced ends immediately, timeout counts leaks; step after finish is a no-op', () => {
const h = makeBattle({ defs: { enemies: { enemy_walker: walker() } }, enemies: [{ key: 'enemy_walker' }], content: 'none' });
h.run(1);
h.b.forceEnd('forced');
assert.equal(h.b.finished, true);
assert.equal(h.result().reason, 'forced');
const t = h.b.time;
h.step(5);
assert.equal(h.b.time, t);
const h2 = makeBattle({ defs: { enemies: { enemy_walker: walker() } }, enemies: [{ key: 'enemy_walker' }], content: 'none' });
h2.run(1);
h2.b.forceEnd('timeout');
assert.equal(h2.result().reason, 'timeout');
assert.equal(h2.result().perPlayer.p1.leaked.length, 1);
});
test('unite field: two helpers (colOffset 0 / +8), carryState, per-half attribution, no layer gains', () => {
const h = makeBattle({
kind: 'unite',
defs: { chess: { t_guard: guard({ stats: { atk: 1e5 } }), t_b: guard({ id: 't_b', stats: { atk: 0, maxHp: 1e6, blockCnt: 0 } }) }, enemies: { enemy_walker: walker({ hp: 100, speed: 2 }) } },
players: [
{ playerId: 'A', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 11, chessId: 't_guard', row: 9, col: 4, carryState: { hpPct: 0.4, sp: 0 } }] },
{ playerId: 'B', seat: 1, side: 'L', colOffset: 8, units: [{ uid: 21, chessId: 't_b', row: 9, col: 7 }] },
],
enemies: [{ key: 'enemy_walker', route: 0, sourcePlayerId: 'A' }, { key: 'enemy_walker', route: 0, time: 0.5, sourcePlayerId: 'B' }],
content: 'none',
});
h.step();
const a = h.b.allyUnits.find((u) => u.uid === 11), b = h.b.allyUnits.find((u) => u.uid === 21);
assert.deepEqual([a.tileR, a.tileC, a.facing], [9, 4, 1]);
assert.deepEqual([b.tileR, b.tileC, b.facing], [9, 15, 1]);
approx(a.hpRatio, 0.4, 1e-9);
assert.equal(h.b.flags.layerGainsEnabled, false);
assert.equal(h.b.addLayers('A', 'yanShip', 5), 0, 'no IN_BATTLE gains in unite');
const r = h.runToEnd(60);
assert.equal(r.reason, 'cleared');
// enemies spawn on the right half (col 18) ⇒ attributed to B's half
assert.equal(r.perPlayer.B.total, 2);
assert.equal(r.perPlayer.B.killed, 2);
assert.equal(r.perPlayer.A.total, 0);
assert.ok(r.perPlayer.A.damageDealt > 0, 'A dealt the damage');
assert.equal(r.perPlayer.A.unitsEnd[0].uid, 11);
});
test('boss field: side R is mirrored and faces left; range grid mirrored', () => {
const sn = chessRec({ id: 't_sn', profession: 'SNIPER', subProfessionId: 'closerange', stats: { atk: 100 }, rangeGrid: [[0, 0], [0, 1], [0, 2]], skill: null });
const h = makeBattle({
kind: 'boss',
defs: { chess: { t_sn: sn }, enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0 }) } },
players: [
{ playerId: 'L1', side: 'L', colOffset: 0, units: [{ uid: 1, chessId: 't_sn', row: 11, col: 5 }] },
{ playerId: 'R1', side: 'R', colOffset: 8, units: [{ uid: 2, chessId: 't_sn', row: 11, col: 5 }] },
],
enemies: [{ key: 'enemy_dummy', pos: [4, 13] }, { key: 'enemy_dummy', pos: [4, 7] }],
content: 'none', autoFinish: false,
});
h.run(2);
const l = h.b.allyUnits.find((u) => u.uid === 1), r = h.b.allyUnits.find((u) => u.uid === 2);
assert.deepEqual([l.tileR, l.tileC, l.facing], [4, 5, 1]);
assert.deepEqual([r.tileR, r.tileC, r.facing], [4, 15, -1]);
const [e1, e2] = h.b.units.filter((u) => u.defId === 'enemy_dummy');
assert.ok(e1.stats.taken > 0, 'right player hits (4,13) to its left');
assert.ok(e2.stats.taken > 0, 'left player hits (4,7) to its right');
assert.equal(e1.ownerId, 'R1');
assert.equal(e2.ownerId, 'L1');
});
test('shared boss pool: damage routed to the pool from two battles; victory when it reaches 0', () => {
const pool = {
hp: 20000, maxHp: 20000, byPlayer: {},
damage(pid, amount) { const d = Math.min(this.hp, amount); this.hp -= d; this.byPlayer[pid] = (this.byPlayer[pid] ?? 0) + d; },
};
const mk = (pid) => makeBattle({
kind: 'boss', sharedBoss: pool,
defs: { chess: { t_guard: guard({ stats: { atk: 2000, maxHp: 1e6 } }) }, enemies: { enemy_boss: enemyRec({ key: 'enemy_boss', rank: 'BOSS', hp: 999, speed: 0 }) } },
players: [{ playerId: pid, side: 'L', units: [{ uid: 1, chessId: 't_guard', row: 10, col: 4 }] }],
enemies: [{ key: 'enemy_boss', pos: [3, 5], tag: 'boss' }], content: 'none',
});
const h1 = mk('P1'), h2 = mk('P2');
h1.step(); h2.step();
const boss1 = h1.enemy('enemy_boss');
assert.equal(boss1.isBoss, true);
assert.equal(Math.round(boss1.s.maxHp), 20000);
assert.equal(h1.b.total, 0, 'boss not counted in total');
while (!h1.b.finished || !h2.b.finished) { h1.b.step(); h2.b.step(); if (h1.b.time > 60) break; }
assert.equal(pool.hp, 0);
const r1 = h1.result(), r2 = h2.result();
assert.equal(r1.reason, 'cleared');
assert.equal(r2.reason, 'cleared');
assert.equal(r1.bossHpLeft, 0);
approx(r1.perPlayer.P1.bossDamage + r2.perPlayer.P2.bossDamage, 20000, 1e-6);
assert.ok(pool.byPlayer.P1 > 0 && pool.byPlayer.P2 > 0);
assert.ok(h1.snapshot().boss);
});
test('boss field with a shared pool: an emptied field (leader leaked) keeps running until the pool is 0 or the match ends it', () => {
// DESIGN §5.5: boss battles end by the pool or the match — never 'cleared' just because the field emptied.
const mk = (pool) => makeBattle({
kind: 'boss', sharedBoss: pool,
defs: { enemies: { enemy_boss: enemyRec({ key: 'enemy_boss', rank: 'BOSS', hp: 999, speed: 4, lpr: 2 }) } },
players: [{ playerId: 'P', side: 'L', units: [] }],
enemies: [{ key: 'enemy_boss', route: 0, tag: 'boss' }], content: 'none',
});
const pool = new LocalBossPool(50000);
const h = mk(pool);
assert.ok(h.runUntil(() => h.hooks.enemyLeak.length > 0, 60), 'the leader walks into the objective');
h.run(300);
assert.equal(h.b.finished, false, 'no "cleared" while the pool is above 0');
assert.equal(h.b.enemies.length, 0);
pool.sync(0, 0); // another field emptied the shared pool
h.step();
assert.equal(h.result().reason, 'cleared');
const h2 = mk(new LocalBossPool(50000));
h2.run(60);
h2.b.forceEnd('forced'); // team LP 0 (overtime drain)
const r = h2.result();
assert.equal(r.reason, 'forced');
assert.deepEqual(r.perPlayer.P.leaked.map((l) => [l.enemyKey, l.lpr, l.boss]), [['enemy_boss', 2, true]]);
// without a shared pool (sandboxes, tools) a boss-kind battle still ends when it empties
const h3 = mk(null);
assert.equal(h3.runToEnd(120).reason, 'cleared');
});
test('Final Assault h07_04 (pair): 昆图斯 walking into the objective does not end the field while the pool is full', { skip: !hasGeneratedData() && 'no generated data' }, () => {
// Regression: the official pair route of the leader has no wait step; with nobody blocking him he leaks at ~180 s
// and the field used to finish 'cleared' at once, ending the Final Assault as a defeat with team LP left.
const pool = new LocalBossPool(708750);
const h = makeBattle({ kind: 'boss', stageId: 'act2autochess_m02', rect: { r0: 0, r1: 5, c0: 0, c1: 20 }, waveTemplate: 'act1autochess_h07_04', sharedBoss: pool, units: [] });
assert.ok(h.runUntil(() => h.hooks.enemyLeak.some((x) => x.enemy.defId === 'enemy_1521_dslily'), 400), 'the leader leaks');
h.run(200);
assert.equal(h.b.finished, false);
assert.equal(pool.hp, 708750);
h.b.forceEnd('forced');
assert.equal(h.result().reason, 'forced');
});
test('boss battles: infinite time limit, leaks carry lpr, match ends via forceEnd', () => {
const h = makeBattle({
kind: 'boss',
defs: { enemies: { enemy_walker: walker({ speed: 4, lpr: 3 }), enemy_boss: enemyRec({ key: 'enemy_boss', hp: 1e9, speed: 0 }) } },
players: [{ playerId: 'P', side: 'L', units: [] }],
enemies: [{ key: 'enemy_walker', route: 0 }, { key: 'enemy_boss', pos: [3, 9], tag: 'boss' }], content: 'none',
});
h.run(200);
assert.equal(h.b.finished, false);
assert.equal(h.b.timeLimit, Infinity);
h.b.forceEnd('forced');
const r = h.result();
assert.equal(r.reason, 'forced');
assert.equal(r.perPlayer.P.leaked[0].lpr, 3);
});
test('snapshot / event wire format (DESIGN §8.2)', () => {
const h = makeBattle({
defs: { chess: { t_guard: guard({ stats: { atk: 100, maxHp: 5000 } }) }, enemies: { enemy_walker: walker({ atk: 100, hp: 3000 }), enemy_fly: enemyRec({ key: 'enemy_fly', hp: 500, speed: 3, motion: 'FLY' }) } },
units: [{ chessId: 't_guard', row: 9, col: 5 }],
enemies: [{ key: 'enemy_walker' }, { key: 'enemy_fly', route: 2 }], content: 'generic', fieldId: 'n:p1',
});
h.run(10.5); // walker (0.5 tiles/s) reaches the guard at ~9 s
const snap = h.snapshot();
assert.deepEqual(Object.keys(snap).slice(0, 6), ['fieldId', 't', 'units', 'dp', 'killed', 'total']);
assert.equal(snap.fieldId, 'n:p1');
for (const u of snap.units) {
assert.equal(u.length, 9);
const [id, x, y, hp, maxHp, sp, spMax, flags, anim] = u;
assert.ok(Number.isInteger(id) && Number.isInteger(hp) && Number.isInteger(maxHp) && Number.isInteger(flags) && Number.isInteger(anim));
for (const v of [x, y, sp, spMax]) assert.ok(Number.isFinite(v));
assert.ok(Object.values(ANIM).includes(anim));
assert.ok(hp <= maxHp);
}
const g = snap.units.find((u) => u[0] === h.unit('t_guard').id);
assert.ok(g[7] & UF.BLOCKED, 'guard is blocking');
const allEv = h.events;
const kinds = new Set(Object.values(EV));
for (const ev of allEv) assert.ok(kinds.has(ev[0]), `known event ${ev[0]}`);
const spawn = allEv.find((e) => e[0] === 'spawn');
assert.deepEqual(Object.keys(spawn[1]).filter((k) => spawn[1][k] !== undefined).slice(0, 15).sort(),
['avatar', 'defId', 'dir', 'facing', 'golden', 'id', 'kind', 'maxHp', 'name', 'ownerId', 'side', 'spine', 'tier', 'x', 'y'].sort());
assert.ok(['UP', 'RIGHT', 'DOWN', 'LEFT'].includes(spawn[1].dir), 'UnitInfo.dir is a direction');
const atk = allEv.find((e) => e[0] === 'atk');
assert.equal(atk.length, 4);
const dmg = allEv.find((e) => e[0] === 'dmg');
assert.equal(dmg.length, 4);
assert.ok(['phys', 'arts', 'true', 'burn', 'neural', 'necrosis', 'apoptosis'].includes(dmg[3]));
const flyInSnap = h.b.units.find((u) => u.defId === 'enemy_fly');
if (flyInSnap.alive) assert.ok(snap.units.find((u) => u[0] === flyInSnap.id)[7] & UF.FLYING);
assert.deepEqual(h.b.drainEvents(), [], 'drained');
const meta = h.b.fieldMeta();
assert.equal(meta.kind, 'normal');
assert.ok(Array.isArray(meta.units));
JSON.stringify(snap); // serialisable
});
test('determinism: same seed ⇒ identical result and event stream; different seed may differ', () => {
const ds = getDefaultSource();
const tpl = ds.getWave('act1autochess_05');
const run = (seed) => {
const { routes, spawns, maxPlayTime } = spawnsFromTemplate(tpl);
const b = new Battle({
seed, kind: 'normal', stageId: 'act2autochess_m03', timeLimit: maxPlayTime, routes, spawns, logger: { error() {}, warn() {} },
players: [{ playerId: 'p', units: [
{ uid: 1, chessId: 'chess_char_1_02_a', row: 9, col: 7 }, { uid: 2, chessId: 'chess_char_1_01_a', row: 10, col: 5 },
{ uid: 3, chessId: 'chess_char_4_09_a', row: 12, col: 6 }, { uid: 4, chessId: 'chess_char_2_02_a', row: 11, col: 5 },
{ uid: 5, chessId: 'chess_char_3_08_a', row: 10, col: 6 },
] }],
});
const ev = [];
while (!b.finished) { b.step(); if (b.tickCount % 3 === 0) ev.push(b.snapshot()); ev.push(b.drainEvents()); }
return { res: hashOf(b.result()), ev: hashOf(ev) };
};
const a = run(1234), b = run(1234);
assert.deepEqual(a, b);
});
test('robustness: throwing content handlers / kits never crash the battle', () => {
const h = makeBattle({
defs: { chess: { t_guard: guard({ stats: { atk: 2000 } }) }, enemies: { enemy_walker: walker({ hp: 2000 }) } },
units: [{ chessId: 't_guard', row: 9, col: 5 }],
enemies: [{ key: 'enemy_walker', count: 3, interval: 1 }],
kits: { t_guard: () => { throw new Error('bad kit'); } },
setup: (b) => {
b.on('hit', () => { throw new Error('bad hit handler'); });
b.on('tick', () => { throw new Error('bad tick handler'); });
b.after(1, () => { throw new Error('bad timer'); });
b.addBuff(b.allyUnits[0], { key: 'x', onTick: () => { throw new Error('bad buff'); } });
},
});
const r = h.runToEnd(60);
assert.equal(r.reason, 'cleared');
assert.ok(h.b.errorCount > 0);
assert.ok(h.b.errors.some((e) => /bad kit/.test(e.message)));
assert.ok(h.b.errors.length < 20, 'errors are logged once per key');
checkInvariants(h.b);
});
test('hook bus: priority order, owner removal, once, off by handle', () => {
const h = makeBattle({ content: 'none' });
const order = [];
const owner = {};
h.b.on('custom', () => order.push('p0'));
h.b.on('custom', () => order.push('p10'), { priority: 10 });
h.b.on('custom', () => order.push('p0b'));
h.b.on('custom', () => order.push('owned'), { owner });
const once = h.b.on('custom', () => order.push('once'), { once: true, priority: 5 });
h.b.emit('custom', {});
assert.deepEqual(order, ['p10', 'once', 'p0', 'p0b', 'owned']);
order.length = 0;
h.b.offOwner(owner);
h.b.emit('custom', {});
assert.deepEqual(order, ['p10', 'p0', 'p0b']);
assert.ok(once.removed);
});
test('scheduling: after / every with cancel', () => {
const h = makeBattle({ content: 'none' });
let a = 0, e = 0;
h.b.after(1, () => a++);
const ev = h.b.every(0.5, () => e++);
h.run(2.01);
assert.equal(a, 1);
assert.equal(e, 4);
ev.cancel();
h.run(2);
assert.equal(e, 4);
});
test('layers & coins: addLayers records gains (normal field) and emits client events', () => {
const h = makeBattle({ content: 'none', bonds: { yanShip: { count: 3, active: true, tier: 1, layers: 10 } } });
h.step();
assert.equal(h.b.addLayers('p1', 'yanShip', 3, 'test'), 3);
assert.equal(h.b.getPlayer('p1').bonds.yanShip.layers, 13);
h.b.addCoins('p1', 2);
h.b.forceEnd('forced');
const r = h.result();
assert.deepEqual(r.perPlayer.p1.layerGains, { yanShip: 3 });
assert.equal(r.perPlayer.p1.coins, 2);
assert.ok(h.eventsOf('layer').some((e) => e[1] === 'p1' && e[2] === 'yanShip' && e[3] === 3));
const h2 = makeBattle({ content: 'none', flags: { layerGainsEnabled: false } });
assert.equal(h2.b.addLayers('p1', 'yanShip', 3), 0);
});
test('tokens: spawnToken deploys a token next to its owner; manual token pieces deploy from input', () => {
const ds = getDefaultSource();
const h = makeBattle({
units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 4, uid: 7 }, { kind: 'token', tokenId: 'token_10000_silent_healrb', ownerUid: 7, row: 10, col: 5, uid: 8 }],
content: 'generic',
});
h.step();
const tok = h.b.allyUnits.find((u) => u.kind === 'token');
assert.ok(tok && tok.alive);
assert.ok(tok.s.flags.untargetable, '医疗探机 cannot be attacked');
assert.equal(tok.profile.dmgType, 'heal');
const t2 = h.b.spawnToken(h.unit(7), 'token_10000_silent_healrb', 11, 4, { duration: 1 });
assert.ok(t2 && t2.alive);
h.run(1.1);
assert.equal(t2.alive, false, 'expired');
assert.ok(ds.getToken('token_10000_silent_healrb', 'chess_char_2_02_b').stats.atk > ds.getToken('token_10000_silent_healrb', 'chess_char_2_02_a').stats.atk);
});
test('displacement helper moves enemies along passable tiles and re-paths', () => {
const h = makeBattle({ defs: { enemies: { enemy_walker: walker({ speed: 0.01 }) } }, enemies: [{ key: 'enemy_walker', pos: [10, 6] }], content: 'none', autoFinish: false });
h.step();
const e = h.enemy('enemy_walker');
const moved = h.b.displace(e, { x: 1, y: 0 }, 2, { force: 1 });
assert.ok(moved > 1.8);
approx(Math.round(e.x), 8);
const blocked = h.b.displace(e, { x: 0, y: 1 }, 5);
assert.ok(e.y <= 12.5);
assert.ok(blocked <= 3);
});
test('content modules: a throwing install() is logged and skipped; units outside the rect are not deployed', () => {
let ran = false;
const h = makeBattle({
defs: { chess: { t_guard: guard() }, enemies: { enemy_walker: walker({ hp: 100 }) } },
units: [{ chessId: 't_guard', row: 9, col: 5 }, { chessId: 't_guard', row: 3, col: 5, abs: true }],
enemies: [{ key: 'enemy_walker' }],
extraContent: [{ install() { throw new Error('broken module'); } }, { install(b) { b.on('battleStart', () => { ran = true; }); } }],
});
const r = h.runToEnd(60);
assert.equal(ran, true, 'later modules still installed');
assert.ok(h.b.errors.some((e) => /broken module/.test(e.message)));
assert.equal(h.b.allyUnits.filter((u) => u.deployed || u.alive).length, 1);
assert.equal(r.reason, 'cleared');
checkInvariants(h.b);
});