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).
517 lines
26 KiB
JavaScript
517 lines
26 KiB
JavaScript
// 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);
|
||
});
|