Files
ark-stronghold-protocol/test/sim/battle.test.js
T
Cartethyia 54cdad2602 Operator battle voices: the official combat lines play by voice slot during battles, one at a time (#73)
Deploy, skill and result lines come from the operators' official voice slots; a newer line takes the channel over (stale callbacks of the replaced line no longer release it), and the result line is spoken by an operator of the player's own battle. Contributed by @Convey123.
2026-10-06 01:44:46 +08:00

559 lines
29 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, BOND_LAYER_CAP } 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: by column from the left, top to bottom within a column (PRTS 从上到下>从左到右 as a scan); the right boss side from its own left', () => {
// PRTS 卫戍协议/帮助 §作战阶段 "按从上到下>从左到右的顺序部署" (since 2026-03-14; act 1: "从左到右>从下到上") — down each column,
// the columns from the left: the order PRTS gives the 阿戈尔 devour ("更靠左和靠上"). Until 0.1.3: the top row first
const h = makeBattle({
defs: { chess: { a: guard({ id: 'a' }), b: guard({ id: 'b' }), c: guard({ id: 'c' }), d: guard({ id: 'd' }), e: guard({ id: 'e' }) } },
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 }, { chessId: 'e', row: 12, col: 5 }],
content: 'none',
});
h.step();
assert.deepEqual(h.hooksOf('deploy').filter((c) => c.initial).map((c) => c.unit.defId), ['c', 'd', 'e', 'a', 'b']);
assert.equal(h.hooksOf('battleStart').length, 1);
const hb = makeBattle({
kind: 'boss',
defs: { chess: { a: guard({ id: 'a' }), b: guard({ id: 'b' }), c: guard({ id: 'c' }) } },
players: [{ playerId: 'R1', side: 'R', colOffset: 8, units: [{ uid: 1, chessId: 'a', row: 10, col: 3 }, { uid: 2, chessId: 'b', row: 10, col: 6 }, { uid: 3, chessId: 'c', row: 12, 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 rows 10 / 12 → boss rows 3 / 5; mirrored cols: 3 → 17, 6 → 14; its own left first = the highest field column
assert.deepEqual(order, [['a', 3, 17, -1], ['c', 5, 14, -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 leaked at ~180 s
// and the field used to finish 'cleared' at once, ending the Final Assault as a defeat with team LP left.
// Officially he is 自缚 (PRTS 天赋; content/bosses.js SELF_BOUND, user playtest #5) and never walks it — the engine
// rule is still checked by lifting the 自缚 buff.
const pool = new LocalBossPool(708750);
const opts = { kind: 'boss', stageId: 'act2autochess_m02', rect: { r0: 0, r1: 5, c0: 0, c1: 20 }, waveTemplate: 'act1autochess_h07_04', sharedBoss: pool, units: [] };
const bound = makeBattle({ ...opts, sharedBoss: new LocalBossPool(708750) });
bound.run(400);
assert.ok(!bound.hooks.enemyLeak.some((x) => x.enemy.defId === 'enemy_1521_dslily'), '自缚: he stays');
assert.equal(bound.b.finished, false);
const h = makeBattle(opts);
assert.ok(h.runUntil(() => h.b.enemies.some((e) => e.defId === 'enemy_1521_dslily'), 5));
for (const e of h.b.enemies) if (e.defId === 'enemy_1521_dslily') h.b.removeBuff(e, 'boss:selfBound');
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('one ENGAGE per unit, on its first attack that hits an enemy (the client\'s 行动开始 voice)', () => {
const h = makeBattle({
defs: { chess: { t_guard: guard({ stats: { atk: 100, maxHp: 5000 } }) }, enemies: { enemy_walker: walker({ atk: 100, hp: 3000 }) } },
units: [{ chessId: 't_guard', row: 9, col: 5 }],
enemies: [{ key: 'enemy_walker' }], content: 'generic', fieldId: 'n:p1',
});
h.run(60); // the walker (0.5 tiles/s) reaches the guard at ~9 s and is attacked from then on
const id = h.unit('t_guard').id;
const engage = h.eventsOf(EV.ENGAGE);
assert.equal(engage.length, 1, 'one event for the whole battle');
assert.deepEqual(engage[0], ['engage', id]);
// it is emitted at the unit's first attack on an enemy — an earlier 命中的攻击 is impossible
const first = h.events.findIndex((e) => e[0] === 'engage');
const earlierAtk = h.events.slice(0, first).findIndex((e) => e[0] === 'atk' && e[1] === id);
assert.equal(earlierAtk, -1, 'no attack of that unit precedes it');
assert.ok(h.events.slice(first).some((e) => e[0] === 'atk' && e[1] === id), 'the attack it belongs to');
});
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('layers: an IN_BATTLE gain stops at BOND_LAYER_CAP (999, MAX_GARRISON_STACK) — the battle\'s count and the reported gain', () => {
assert.equal(BOND_LAYER_CAP, 999);
const h = makeBattle({ content: 'none', bonds: { yanShip: { count: 3, active: true, tier: 1, layers: 995 } } });
h.step();
assert.equal(h.b.addLayers('p1', 'yanShip', 3, 'test'), 3);
assert.equal(h.b.addLayers('p1', 'yanShip', 5, 'test'), 1, 'only the room left under the cap');
assert.equal(h.b.addLayers('p1', 'yanShip', 5, 'test'), 0, 'at the cap: nothing');
assert.equal(h.b.getPlayer('p1').bonds.yanShip.layers, 999);
h.b.forceEnd('forced');
assert.deepEqual(h.result().perPlayer.p1.layerGains, { yanShip: 4 });
assert.deepEqual(h.eventsOf('layer').map((e) => e[3]), [3, 1], 'the client sees what was added');
});
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);
});