卫戍协议:盟约 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).
This commit is contained in:
commit
01a44cd288
408 files changed
+308986
No files matched your search
@@ -0,0 +1,516 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -0,0 +1,516 @@
|
||||
// Combat rules: statuses & immunities, cold→freeze, element bursts, blocking, melee vs FLY, stealth, priorities, healers.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
|
||||
import { ELEMENT } from '../../server/sim/constants.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 sniper = (o = {}) => chessRec({ id: 't_sniper', profession: 'SNIPER', subProfessionId: 'closerange', stats: { atk: 300 }, rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3], [1, 0], [1, 1], [1, 2], [1, 3], [-1, 0], [-1, 1], [-1, 2], [-1, 3]], skill: null, ...o });
|
||||
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0, ...o });
|
||||
const walker = (o = {}) => enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 1, ...o });
|
||||
|
||||
test('stun stops enemy movement and attacks; immunity honoured; statusApplied fires', () => {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_walker: walker({ atk: 100 }), enemy_boss: enemyRec({ key: 'enemy_boss', hp: 1e6, speed: 1, immunities: { stun: true } }) } },
|
||||
enemies: [{ key: 'enemy_walker' }, { key: 'enemy_boss', route: 1 }],
|
||||
content: 'none', autoFinish: false,
|
||||
});
|
||||
h.step();
|
||||
const e = h.enemy('enemy_walker');
|
||||
const boss = h.enemy('enemy_boss');
|
||||
assert.equal(h.b.applyStatus(e, 'stun', { duration: 1 }), true);
|
||||
assert.equal(h.b.applyStatus(boss, 'stun', { duration: 1 }), false, 'immune');
|
||||
const x0 = e.x, bx0 = boss.x;
|
||||
h.run(0.9);
|
||||
approx(e.x, x0);
|
||||
assert.ok(boss.x < bx0, 'boss keeps walking');
|
||||
h.run(0.3);
|
||||
assert.ok(e.x < x0, 'moves again after stun');
|
||||
assert.equal(h.hooksOf('statusApplied').length, 1);
|
||||
assert.ok(h.eventsOf('status').some((ev) => ev[1] === e.id && ev[2] === 'stun' && ev[3] === 1));
|
||||
assert.ok(h.eventsOf('status').some((ev) => ev[1] === e.id && ev[2] === 'stun' && ev[3] === 0));
|
||||
});
|
||||
|
||||
test('stunned operator neither attacks nor gains SP; silence blocks skill activation', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_guard: guard({ skill: { spCost: 5, initSp: 0, duration: 3, bb: { atk: 0.5 } } }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_guard', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [9, 6] }], content: 'generic',
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('t_guard');
|
||||
h.b.applyStatus(u, 'stun', { duration: 2 });
|
||||
const atk0 = u.stats.attacks;
|
||||
h.run(1.9);
|
||||
assert.equal(u.stats.attacks, atk0);
|
||||
assert.ok(u.skill.sp < 0.2);
|
||||
h.b.applyStatus(u, 'silence', { duration: 20 });
|
||||
h.run(8);
|
||||
assert.ok(u.stats.attacks > atk0, 'attacks after stun');
|
||||
assert.equal(u.skill.ready, true);
|
||||
assert.equal(u.skill.activations, 0, 'silenced: no cast');
|
||||
});
|
||||
|
||||
test('cold on cold ⇒ freeze 3 s (res −15), frozen-immune enemies only get cold', () => {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: dummy({ res: 30 }), enemy_icy: enemyRec({ key: 'enemy_icy', speed: 0, hp: 1e6, immunities: { frozen: true } }) } },
|
||||
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_icy', pos: [10, 8] }], content: 'none',
|
||||
});
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy');
|
||||
const icy = h.enemy('enemy_icy');
|
||||
h.b.applyStatus(e, 'cold', { duration: 5 });
|
||||
assert.equal(e.s.aspd, 70);
|
||||
assert.equal(e.s.flags.freeze, undefined);
|
||||
h.b.applyStatus(e, 'cold', { duration: 5 });
|
||||
assert.equal(e.s.flags.freeze, true);
|
||||
assert.equal(e.s.flags.stun, true);
|
||||
assert.equal(e.s.res, 15);
|
||||
h.run(3.1);
|
||||
assert.ok(!e.s.flags.freeze, 'freeze lasts 3 s');
|
||||
h.b.applyStatus(icy, 'cold', { duration: 5 });
|
||||
h.b.applyStatus(icy, 'cold', { duration: 5 });
|
||||
assert.ok(!icy.s.flags.freeze);
|
||||
assert.ok(icy.s.flags.cold);
|
||||
});
|
||||
|
||||
test('sleep: target is untargetable and inactive; fear stops enemy attacks', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sniper: sniper() }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_sniper', row: 11, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [11, 7] }], content: 'none',
|
||||
});
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy');
|
||||
h.b.applyStatus(e, 'sleep', { duration: 2 });
|
||||
const u = h.unit('t_sniper');
|
||||
const a0 = u.stats.attacks;
|
||||
h.run(1.5);
|
||||
assert.equal(u.stats.attacks, a0, 'sleeping enemy not targeted');
|
||||
h.run(1.5);
|
||||
assert.ok(u.stats.attacks > a0);
|
||||
});
|
||||
|
||||
test('element bursts on enemies ("·我方" terms): burn 7000 元素伤害 + RES −20 (10 s lock), neural 6000 + 3 麻痹, leaders hold 2000', () => {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: dummy({ res: 50 }), enemy_lead: enemyRec({ key: 'enemy_lead', hp: 1e6, speed: 0, rank: 'BOSS' }) } },
|
||||
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_lead', pos: [10, 8] }], content: 'none', autoFinish: false,
|
||||
});
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy');
|
||||
const hp0 = e.hp;
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 600 });
|
||||
approx(e.elem.burn, 600);
|
||||
assert.equal(e.hp, hp0, 'element damage fills the gauge only');
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 600 });
|
||||
assert.ok(e.findBuff('burnBurst'));
|
||||
approx(hp0 - e.hp, ELEMENT.burn.enemy.elemDamage, 1e-6, '元素伤害 ignores RES');
|
||||
assert.equal(e.s.res, 50 - ELEMENT.burn.enemy.resDown);
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 500 });
|
||||
assert.equal(e.elem.burn, 1000, 'locked during burst');
|
||||
h.run(10.1);
|
||||
assert.equal(e.elem.burn, 0);
|
||||
assert.equal(e.s.res, 50);
|
||||
// neural: 6000 元素伤害 + 3 麻痹 stacks
|
||||
const hp1 = e.hp;
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'neural', amount: 1000 });
|
||||
approx(hp1 - e.hp, ELEMENT.neural.enemy.elemDamage);
|
||||
assert.equal(e.findBuff('palsy').stacks, ELEMENT.neural.enemy.palsy);
|
||||
assert.ok(!e.s.flags.stun, 'enemies are not stunned by a neural burst');
|
||||
assert.equal(h.hooksOf('elementBurst').length, 2);
|
||||
// element fragility multiplies 元素伤害
|
||||
h.b.addBuff(e, { key: 'test:ef', mods: { elemTakenMul: 1.5 } });
|
||||
const hp2 = e.hp;
|
||||
h.b.dealDamage(null, e, { amount: 100, type: 'elemental', element: 'burn' });
|
||||
approx(hp2 - e.hp, 150);
|
||||
// leaders need 2000
|
||||
const L = h.enemy('enemy_lead');
|
||||
assert.equal(L.gaugeMax, 2000);
|
||||
h.b.dealDamage(null, L, { type: 'element', element: 'burn', amount: 1500 });
|
||||
assert.ok(!L.findBuff('burnBurst'));
|
||||
h.b.dealDamage(null, L, { type: 'element', element: 'burn', amount: 600 });
|
||||
assert.ok(L.findBuff('burnBurst'));
|
||||
});
|
||||
|
||||
test('element bursts on enemies: apoptosis = 800 元素伤害/s + 50 % weaken recovering; erosion = 5000 + permanent DEF −120 (8 s lock)', () => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy({ atk: 1000, def: 500 }) } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false });
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy');
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 1000 });
|
||||
approx(e.s.atk, 500, 1e-6, '50 % weaken at the start');
|
||||
const hp0 = e.hp;
|
||||
h.run(5.02);
|
||||
approx(hp0 - e.hp, 5 * ELEMENT.apoptosis.enemy.elemDps, 1e-6);
|
||||
assert.ok(e.s.atk > 500 && e.s.atk < 1000, `weaken recovers (${e.s.atk})`);
|
||||
h.run(10.1);
|
||||
assert.ok(!e.findBuff('apoptosisBurst'));
|
||||
approx(e.s.atk, 1000);
|
||||
const hp1 = e.hp;
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'erosion', amount: 1000 });
|
||||
approx(hp1 - e.hp, ELEMENT.erosion.enemy.elemDamage);
|
||||
assert.equal(e.s.def, 500 - ELEMENT.erosion.enemy.defDown);
|
||||
assert.equal(e.elem.erosion, 1000, 'locked for 8 s');
|
||||
h.run(8.1);
|
||||
assert.equal(e.elem.erosion, 0);
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'erosion', amount: 1000 });
|
||||
assert.equal(e.s.def, 500 - 2 * ELEMENT.erosion.enemy.defDown, 'stacks, permanent');
|
||||
});
|
||||
|
||||
test('element bursts on operators (enemy damage): burn 1200 arts, neural 1000 true + stun 10 s, apoptosis no skill / −1 SP/s / 100 arts/s, erosion 800 phys + DEF −100', () => {
|
||||
const op = chessRec({ id: 't_op', profession: 'WARRIOR', stats: { atk: 0, maxHp: 1e6, def: 300, res: 0, blockCnt: 0 }, skill: { spCost: 30, initSp: 20, duration: 5, bb: { atk: 0.5 } } });
|
||||
const h = makeBattle({ defs: { chess: { t_op: op } }, units: [{ chessId: 't_op', row: 9, col: 5 }], content: 'generic', autoFinish: false, timeLimit: 120 });
|
||||
h.step();
|
||||
const u = h.unit('t_op');
|
||||
let hp = u.hp;
|
||||
h.b.dealDamage(null, u, { type: 'element', element: 'burn', amount: 1000 });
|
||||
approx(hp - u.hp, ELEMENT.burn.ally.damage);
|
||||
hp = u.hp;
|
||||
h.b.dealDamage(null, u, { type: 'element', element: 'neural', amount: 1000 });
|
||||
approx(hp - u.hp, ELEMENT.neural.ally.damage);
|
||||
assert.ok(u.findBuff('stun'));
|
||||
approx(u.findBuff('stun').timeLeft, ELEMENT.neural.ally.stun);
|
||||
h.run(10.1);
|
||||
const sp0 = u.skill.sp;
|
||||
hp = u.hp;
|
||||
h.b.dealDamage(null, u, { type: 'element', element: 'apoptosis', amount: 1000 });
|
||||
assert.ok(u.s.flags.silence, 'no skill activation');
|
||||
h.run(3.02);
|
||||
approx(hp - u.hp, 3 * ELEMENT.apoptosis.ally.dps);
|
||||
approx(u.skill.sp, sp0 + 3 * u.s.spRecovery - 3 * ELEMENT.apoptosis.ally.spLossPerSec, 0.05);
|
||||
h.run(12.1);
|
||||
assert.ok(!u.s.flags.silence);
|
||||
hp = u.hp;
|
||||
h.b.dealDamage(null, u, { type: 'element', element: 'erosion', amount: 1000 });
|
||||
approx(hp - u.hp, ELEMENT.erosion.ally.damage - (300 - ELEMENT.erosion.ally.defDown), 1e-6, 'DEF cut first, then 800 phys');
|
||||
assert.equal(u.s.def, 300 - ELEMENT.erosion.ally.defDown);
|
||||
assert.equal(u.elem.erosion, 0, 'no lock: accumulates again at once');
|
||||
});
|
||||
|
||||
test('a resolving burst already locks its gauge: an elementBurst handler spreading the element back cannot re-burst (no ping-pong recursion)', () => {
|
||||
// two operators that spread every burst to each other (the 淤困 parasite pattern) — burn has a lock, erosion on
|
||||
// operators has none (duration 0), apoptosis locks with a ticker: each must burst exactly once per fill
|
||||
const op = (id) => chessRec({ id, profession: 'WARRIOR', stats: { atk: 0, maxHp: 1e6, def: 300, res: 0, blockCnt: 0 }, skill: null });
|
||||
for (const el of ['burn', 'erosion', 'apoptosis', 'neural']) {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_a: op('t_a'), t_b: op('t_b') } },
|
||||
units: [{ chessId: 't_a', row: 9, col: 5 }, { chessId: 't_b', row: 9, col: 6 }], content: 'none', autoFinish: false,
|
||||
extraContent: [{ install(battle) {
|
||||
battle.on('elementBurst', (c) => {
|
||||
for (const o of battle.units) if (o.side === 'ally' && o.alive && o.deployed) battle.dealDamage(null, o, { type: 'element', element: c.element, amount: 5000 });
|
||||
});
|
||||
} }],
|
||||
});
|
||||
h.step();
|
||||
const A = h.unit('t_a'), B = h.unit('t_b');
|
||||
const hpA = A.hp, hpB = B.hp;
|
||||
h.b.dealDamage(null, A, { type: 'element', element: el, amount: 1000 });
|
||||
assert.deepEqual(h.b.errors.map((e) => e.label), [], `${el}: no hook-depth errors`);
|
||||
assert.deepEqual(h.hooksOf('elementBurst').map((c) => c.target.defId).sort(), ['t_a', 't_b'], `${el}: one burst per unit`);
|
||||
assert.ok(A.alive && B.alive);
|
||||
if (el === 'burn') { approx(hpA - A.hp, ELEMENT.burn.ally.damage); approx(hpB - B.hp, ELEMENT.burn.ally.damage); }
|
||||
if (el === 'erosion') {
|
||||
for (const u of [A, B]) assert.equal(u.findBuff('erosionDown').stacks, 1, 'one permanent DEF cut');
|
||||
assert.equal(A.elem.erosion, 0, 'no lock once resolved: accumulates again');
|
||||
h.b.dealDamage(null, A, { type: 'element', element: 'erosion', amount: 1000 });
|
||||
assert.equal(A.findBuff('erosionDown').stacks, 2, 'a later fill bursts again');
|
||||
}
|
||||
assert.ok(!A.burstPending?.[el] && !B.burstPending?.[el], 'pending marker cleared');
|
||||
}
|
||||
});
|
||||
|
||||
test('淤困 (enemy_9007_acelem) parasite hosts side by side: one burst spreads once, no ping-pong', () => {
|
||||
const h = makeBattle({
|
||||
seed: 3,
|
||||
units: [{ chessId: 'chess_char_4_17_a', row: 9, col: 5 }, { chessId: 'chess_char_2_08_a', row: 10, col: 5 }],
|
||||
enemies: [{ key: 'enemy_9007_acelem', pos: [9, 7], route: { motion: 'WALK', start: [9, 7], end: [9, 2], checkpoints: [] } },
|
||||
{ key: 'enemy_9007_acelem', pos: [10, 7], route: { motion: 'WALK', start: [10, 7], end: [9, 2], checkpoints: [[10, 3]] } }],
|
||||
hooks: ['elementBurst'],
|
||||
});
|
||||
const A = h.unit('chess_char_4_17_a'), B = h.unit('chess_char_2_08_a');
|
||||
assert.ok(h.runUntil(() => A.findBuff('ab:parasite') && B.findBuff('ab:parasite'), 20), 'both defenders parasitised');
|
||||
const hpA = A.hp, hpB = B.hp;
|
||||
h.eventsOf('dmg'); // drain into h.events
|
||||
const ev0 = h.events.length;
|
||||
h.b.dealDamage(null, A, { type: 'element', element: 'burn', amount: 1000 });
|
||||
assert.deepEqual(h.b.errors.map((e) => e.label), []);
|
||||
assert.equal(h.hooksOf('elementBurst').length, 2, 'A bursts, the spread bursts B, B\'s spread back to A is locked out');
|
||||
assert.ok(A.alive && B.alive, `defenders survive (${Math.round(A.hp)}/${Math.round(B.hp)})`);
|
||||
assert.ok(A.findBuff('burnBurst') && B.findBuff('burnBurst'));
|
||||
h.eventsOf('dmg');
|
||||
const burstHit = (u) => h.events.slice(ev0).filter((e) => e[0] === 'dmg' && e[1] === u.id && e[3] === 'arts').length;
|
||||
assert.equal(burstHit(A), 1, 'one 灼燃 burst hit on A');
|
||||
assert.equal(burstHit(B), 1, 'one 灼燃 burst hit on B');
|
||||
assert.ok(hpA - A.hp < 2 * ELEMENT.burn.ally.damage && hpB - B.hp < 2 * ELEMENT.burn.ally.damage);
|
||||
});
|
||||
|
||||
test('statuses (official terms): tremble only stops attacks while blocked; fear = unblockable + no attack + no advance; stunned blocker lets go', () => {
|
||||
const g = chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 0, blockCnt: 2, maxHp: 1e6 }, skill: null });
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_guard: g }, enemies: { enemy_walker: walker({ atk: 100, bat: 1 }) } },
|
||||
units: [{ chessId: 't_guard', row: 9, col: 6 }], enemies: [{ key: 'enemy_walker' }], content: 'none', autoFinish: false, timeLimit: 120,
|
||||
});
|
||||
const u = h.unit('t_guard');
|
||||
assert.ok(h.runUntil(() => h.enemies()[0]?.blockedBy === u, 20));
|
||||
const e = h.enemies()[0];
|
||||
h.b.applyStatus(e, 'tremble', { duration: 3 });
|
||||
const a0 = e.stats.attacks;
|
||||
h.run(2.5);
|
||||
assert.equal(e.stats.attacks, a0, 'trembling + blocked: no normal attack');
|
||||
h.run(1);
|
||||
assert.ok(e.stats.attacks > a0, 'attacks again');
|
||||
// stun on the blocker: releases the enemy; it is re-blocked once the stun ends
|
||||
h.b.applyStatus(u, 'stun', { duration: 1 });
|
||||
assert.equal(e.blockedBy, null, 'a stunned operator blocks nothing');
|
||||
assert.equal(u.blocking.length, 0);
|
||||
h.run(1.2);
|
||||
// fear: released, no attacks, does not advance
|
||||
if (!e.blockedBy) assert.ok(h.runUntil(() => e.blockedBy === u, 20));
|
||||
h.b.applyStatus(e, 'fear', { duration: 2 });
|
||||
assert.equal(e.blockedBy, null);
|
||||
const x0 = e.x, a1 = e.stats.attacks;
|
||||
h.run(1.9);
|
||||
approx(e.x, x0);
|
||||
assert.equal(e.stats.attacks, a1);
|
||||
h.run(0.5);
|
||||
assert.ok(e.blockedBy === u || e.x < x0, 'walks / is blocked again after the fear');
|
||||
});
|
||||
|
||||
test('statuses: sleep = invulnerable except `hitSleep` attackers; levitate halves on heavy units; frozen RES cut only on enemies', () => {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: dummy(), enemy_heavy: enemyRec({ key: 'enemy_heavy', hp: 1e6, speed: 0, mass: 4 }) } },
|
||||
units: [{ chessId: 'chess_char_1_18_a', row: 9, col: 3 }], enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_heavy', pos: [10, 8] }], content: 'none', autoFinish: false,
|
||||
});
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy');
|
||||
h.b.applyStatus(e, 'sleep', { duration: 5 });
|
||||
assert.equal(h.b.dealDamage(null, e, { amount: 100, type: 'true' }), 0, 'sleepers take no damage');
|
||||
assert.equal(h.b.dealDamage(null, e, { amount: 100, type: 'true', ignoreSleep: true }), 100);
|
||||
const fake = { side: 'ally', s: { dmgDealtMul: 1, physDealtMul: 1, artsDealtMul: 1, defIgnorePct: 0, defIgnoreFlat: 0, resIgnorePct: 0, resIgnoreFlat: 0 }, profile: { hitSleep: true }, stats: { dmg: 0 }, ownerId: 'p1' };
|
||||
assert.equal(h.b.dealDamage(fake, e, { amount: 100, type: 'true' }), 100, 'hitSleep attacker');
|
||||
const heavy = h.enemy('enemy_heavy');
|
||||
h.b.applyStatus(heavy, 'levitate', { duration: 4 });
|
||||
approx(heavy.findBuff('levitate').timeLeft, 2);
|
||||
h.b.applyStatus(e, 'levitate', { duration: 4 });
|
||||
approx(e.findBuff('levitate').timeLeft, 4);
|
||||
const op = h.unit('chess_char_1_18_a');
|
||||
const r0 = op.s.res;
|
||||
h.b.applyStatus(op, 'freeze', { duration: 2 });
|
||||
assert.equal(op.s.res, r0, 'operators keep their RES when frozen');
|
||||
assert.ok(op.s.flags.freeze);
|
||||
});
|
||||
|
||||
test('statuses: "同名效果取最高" — a weaker fragile never overrides a stronger one and resumes after it; palsy cancels attacks', () => {
|
||||
const g = chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 0, blockCnt: 2, maxHp: 1e6 }, skill: null });
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_guard: g }, enemies: { enemy_dummy: dummy({ atk: 100, bat: 1 }) } },
|
||||
units: [{ chessId: 't_guard', row: 11, col: 8 }], enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false,
|
||||
});
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy');
|
||||
h.b.applyStatus(e, 'fragile', { duration: 2, value: 0.3 });
|
||||
h.b.applyStatus(e, 'fragile', { duration: 5, value: 0.1 });
|
||||
approx(e.s.dmgTakenMul, 1.3, 1e-9, 'the weaker one does not override');
|
||||
h.run(2.1);
|
||||
approx(e.s.dmgTakenMul, 1.1, 1e-9, 'the weaker one resumes after the stronger ends');
|
||||
h.run(3.1);
|
||||
approx(e.s.dmgTakenMul, 1);
|
||||
h.b.applyStatus(e, 'fragile', { duration: 5, value: 0.1 });
|
||||
h.b.applyStatus(e, 'fragile', { duration: 1, value: 0.4 });
|
||||
approx(e.s.dmgTakenMul, 1.4, 1e-9, 'a stronger one takes over');
|
||||
h.run(1.1);
|
||||
approx(e.s.dmgTakenMul, 1.1, 1e-9, 'the longer weaker one comes back');
|
||||
// palsy: 2 stacks cancel 2 attacks
|
||||
const u = h.unit('t_guard');
|
||||
assert.ok(h.runUntil(() => e.blockedBy === u, 10));
|
||||
h.b.applyStatus(e, 'palsy', { value: 2 });
|
||||
const a0 = e.stats.attacks;
|
||||
h.run(2.05);
|
||||
assert.equal(e.stats.attacks, a0, 'two attacks interrupted');
|
||||
assert.ok(!e.findBuff('palsy'));
|
||||
h.run(1.1);
|
||||
assert.ok(e.stats.attacks > a0);
|
||||
});
|
||||
|
||||
test('beforeStatus may change the duration / value; NEVER trigger never auto-casts; dodge sources roll independently; no SP while a timed skill runs', () => {
|
||||
const op = chessRec({ id: 't_op', profession: 'WARRIOR', stats: { atk: 100, blockCnt: 1 }, skill: { spCost: 2, initSp: 2, duration: 5, bb: { atk: 0.5 } } });
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_op: op }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_op', row: 11, col: 7 }], enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'generic', autoFinish: false,
|
||||
kits: { t_op: () => ({ skill: { kind: 'duration', trigger: 'NEVER', mods: { atkPct: 0.5 } } }) },
|
||||
setup: (b) => b.on('beforeStatus', (c) => { if (c.status === 'slow') { c.duration *= 2; c.value = 0.9; } }),
|
||||
});
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy');
|
||||
h.b.applyStatus(e, 'slow', { duration: 1.5, value: 0.2 });
|
||||
approx(e.findBuff('slow').timeLeft, 3);
|
||||
approx(e.s.moveSpeed, e.base.moveSpeed * 0.1);
|
||||
const u = h.unit('t_op');
|
||||
h.run(4);
|
||||
assert.ok(u.skill.ready && u.stats.attacks > 0);
|
||||
assert.equal(u.skill.activations, 0, 'NEVER: no auto-cast');
|
||||
assert.equal(u.skill.activate('manual'), true);
|
||||
assert.equal(u.skill.gainSp(5, 'talent'), 0, 'no SP while the timed skill runs');
|
||||
h.b.addBuff(u, { key: 'd1', mods: { dodgePhys: 0.5 } });
|
||||
h.b.addBuff(u, { key: 'd2', mods: { dodgePhys: 0.5 } });
|
||||
approx(u.s.dodgePhys, 0.75, 1e-9, '1 − 0.5 × 0.5');
|
||||
});
|
||||
|
||||
test('blocking: capacity, heavy enemies, release on blocker death', () => {
|
||||
const h = makeBattle({
|
||||
defs: {
|
||||
chess: { t_guard: guard({ stats: { atk: 0, blockCnt: 2, maxHp: 1e6 } }) },
|
||||
enemies: { enemy_walker: walker(), enemy_heavy: enemyRec({ key: 'enemy_heavy', hp: 1e6, speed: 1, blockCnt: 3 }) },
|
||||
},
|
||||
units: [{ chessId: 't_guard', row: 9, col: 6 }],
|
||||
enemies: [{ key: 'enemy_walker', count: 3, interval: 0.5 }, { key: 'enemy_heavy', time: 0.2 }],
|
||||
content: 'none', autoFinish: false,
|
||||
});
|
||||
const g = h.unit('t_guard');
|
||||
h.run(12);
|
||||
assert.equal(g.blocking.length, 2, 'blocks exactly 2');
|
||||
const blocked = h.enemies().filter((e) => e.blockedBy === g);
|
||||
assert.equal(blocked.length, 2);
|
||||
const passed = h.enemies().filter((e) => !e.blockedBy);
|
||||
assert.ok(passed.length === 0 || passed.every((e) => e.x < 6), 'others walked through');
|
||||
assert.ok(h.hooksOf('blocked').length >= 2);
|
||||
const heavy = h.b.units.find((u) => u.defId === 'enemy_heavy');
|
||||
assert.notEqual(heavy.blockedBy, g, 'blockCnt 3 enemy cannot be blocked by capacity 2');
|
||||
h.b.dealDamage(null, g, { amount: 1e9, type: 'true' });
|
||||
assert.equal(g.alive, false);
|
||||
assert.equal(g.blocking.length, 0);
|
||||
for (const e of blocked) assert.equal(e.blockedBy, null);
|
||||
const bx = blocked[0].x;
|
||||
h.run(1);
|
||||
assert.ok(blocked[0].x < bx || !blocked[0].alive, 'released enemy moves on');
|
||||
});
|
||||
|
||||
test('blocked melee enemy attacks its blocker', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_guard: guard({ stats: { atk: 0, maxHp: 1e6 } }) }, enemies: { enemy_walker: walker({ atk: 500 }) } },
|
||||
units: [{ chessId: 't_guard', row: 9, col: 7 }], enemies: [{ key: 'enemy_walker' }], content: 'none', autoFinish: false,
|
||||
});
|
||||
h.run(10);
|
||||
const g = h.unit('t_guard');
|
||||
assert.ok(g.stats.taken > 0);
|
||||
assert.ok(g.hp < g.s.maxHp);
|
||||
});
|
||||
|
||||
test('melee operators cannot hit FLY enemies; ranged ones can', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_guard: guard({ rangeGrid: [[0, 0], [0, 1], [0, 2]] }), t_sniper: sniper() }, enemies: { enemy_fly: enemyRec({ key: 'enemy_fly', hp: 1e6, speed: 0, motion: 'FLY' }) } },
|
||||
units: [{ chessId: 't_guard', row: 9, col: 5 }, { chessId: 't_sniper', row: 10, col: 4 }],
|
||||
enemies: [{ key: 'enemy_fly', pos: [9, 6], route: 2 }], content: 'none',
|
||||
});
|
||||
h.run(3);
|
||||
assert.equal(h.unit('t_guard').stats.attacks, 0);
|
||||
assert.ok(h.unit('t_sniper').stats.attacks > 0);
|
||||
assert.equal(h.enemy('enemy_fly').blockedBy, null, 'FLY never blocked');
|
||||
});
|
||||
|
||||
test('stealth enemies are untargetable unless blocked or revealed', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sniper: sniper() }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_sniper', row: 11, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [11, 7] }], content: 'none',
|
||||
});
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy');
|
||||
h.b.applyStatus(e, 'stealth', { duration: 100 });
|
||||
const u = h.unit('t_sniper');
|
||||
const a0 = u.stats.attacks;
|
||||
h.run(3);
|
||||
assert.equal(u.stats.attacks, a0);
|
||||
h.b.applyStatus(e, 'reveal', { duration: 100 });
|
||||
h.run(2);
|
||||
assert.ok(u.stats.attacks > a0);
|
||||
});
|
||||
|
||||
test('target priority: least remaining distance to goal, then earliest spawned; fly-first profiles', () => {
|
||||
const h = makeBattle({
|
||||
defs: {
|
||||
chess: { t_sniper: sniper({ rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [1, 1], [1, 2], [1, 3], [1, 4], [2, 1], [2, 2], [2, 3], [2, 4], [3, 2], [3, 3]] }) },
|
||||
enemies: { enemy_dummy: dummy(), enemy_fly: enemyRec({ key: 'enemy_fly', hp: 1e6, speed: 0, motion: 'FLY' }) },
|
||||
},
|
||||
units: [{ chessId: 't_sniper', row: 9, col: 3 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [9, 7] }, { key: 'enemy_dummy', pos: [9, 5] }, { key: 'enemy_dummy', pos: [12, 6] }],
|
||||
content: 'none',
|
||||
});
|
||||
h.run(1.2);
|
||||
const [far, near, high] = h.b.units.filter((u) => u.defId === 'enemy_dummy');
|
||||
assert.ok(near.stats.taken > 0, 'closest to goal attacked');
|
||||
assert.equal(far.stats.taken, 0);
|
||||
assert.equal(high.stats.taken, 0);
|
||||
// fly-first
|
||||
const h2 = makeBattle({
|
||||
defs: {
|
||||
chess: { t_fast: sniper({ id: 't_fast', subProfessionId: 'fastshot', targetPriority: 'fly' }) },
|
||||
enemies: { enemy_dummy: dummy(), enemy_fly: enemyRec({ key: 'enemy_fly', hp: 1e6, speed: 0, motion: 'FLY' }) },
|
||||
},
|
||||
units: [{ chessId: 't_fast', row: 9, col: 3 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [9, 4] }, { key: 'enemy_fly', pos: [9, 6], route: 2 }], content: 'none',
|
||||
});
|
||||
h2.run(1.2);
|
||||
assert.ok(h2.enemy('enemy_fly').stats.taken > 0);
|
||||
assert.equal(h2.enemy('enemy_dummy').stats.taken, 0);
|
||||
});
|
||||
|
||||
test('operators prioritise enemies they block', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_guard: guard({ rangeGrid: [[0, 0], [0, 1], [0, 2]], stats: { atk: 100, blockCnt: 1, maxHp: 1e6 } }) }, enemies: { enemy_walker: walker(), enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_guard', row: 9, col: 5 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }, { key: 'enemy_walker', pos: [9, 5.4] }],
|
||||
content: 'none', autoFinish: false,
|
||||
});
|
||||
h.run(0.2);
|
||||
const w = h.enemy('enemy_walker');
|
||||
assert.equal(w.blockedBy, h.unit('t_guard'));
|
||||
h.run(3);
|
||||
assert.ok(w.stats.taken > 0);
|
||||
assert.equal(h.enemy('enemy_dummy').stats.taken, 0);
|
||||
});
|
||||
|
||||
test('healers target the lowest HP% ally in range; idle when everyone is full; noHeal allies skipped', () => {
|
||||
const medic = chessRec({ id: 't_medic', profession: 'MEDIC', subProfessionId: 'physician', dmgType: 'heal', attackKind: 'heal', stats: { atk: 200 }, rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3], [1, 0], [1, 1], [1, 2], [-1, 0], [-1, 1], [-1, 2]], skill: null });
|
||||
const reaper = chessRec({ id: 't_reaper', profession: 'WARRIOR', subProfessionId: 'reaper', skill: null, stats: { maxHp: 5000 } });
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_medic: medic, t_a: guard({ id: 't_a', stats: { maxHp: 5000 } }), t_b: guard({ id: 't_b', stats: { maxHp: 5000 } }), t_reaper: reaper } },
|
||||
units: [{ chessId: 't_medic', row: 10, col: 3 }, { chessId: 't_a', row: 10, col: 4 }, { chessId: 't_b', row: 11, col: 4 }, { chessId: 't_reaper', row: 9, col: 4 }],
|
||||
content: 'none',
|
||||
});
|
||||
h.step();
|
||||
const m = h.unit('t_medic'), a = h.unit('t_a'), b = h.unit('t_b'), r = h.unit('t_reaper');
|
||||
h.run(2);
|
||||
assert.equal(m.stats.attacks, 0, 'no injured ally: no heal');
|
||||
a.hp = 4000; b.hp = 2000; r.hp = 100;
|
||||
h.run(1.05);
|
||||
assert.ok(b.hp > 2000, 'lowest ratio (non-reaper) healed first');
|
||||
assert.equal(a.hp, 4000);
|
||||
assert.equal(r.hp, 100, 'reaper cannot be healed by others');
|
||||
});
|
||||
|
||||
test('ranged enemies attack allies in radius: blocker → taunt → latest deployed, then pause', () => {
|
||||
const mk = (taunt) => makeBattle({
|
||||
defs: {
|
||||
chess: { t_a: guard({ id: 't_a', stats: { maxHp: 1e6, atk: 0 } }), t_b: guard({ id: 't_b', stats: { maxHp: 1e6, atk: 0, tauntLevel: taunt } }) },
|
||||
enemies: { enemy_caster: enemyRec({ key: 'enemy_caster', hp: 1e6, speed: 1, atk: 100, range: 2.5, dmgType: 'arts' }) },
|
||||
},
|
||||
// deploy order: top→bottom, so t_b (row 11) deploys before t_a (row 10) ⇒ t_a is the latest deployed
|
||||
units: [{ chessId: 't_a', row: 10, col: 8 }, { chessId: 't_b', row: 11, col: 8 }],
|
||||
enemies: [{ key: 'enemy_caster', pos: [10, 10] }], content: 'none', autoFinish: false,
|
||||
});
|
||||
const h = mk(1);
|
||||
h.step(1);
|
||||
const e = h.enemy('enemy_caster');
|
||||
assert.ok(e.lastAttackAt >= 0, 'attacked on the first tick');
|
||||
// attack pause: no movement for ATTACK_PAUSE (0.35 s) after the attack
|
||||
const x = e.x;
|
||||
h.step(9);
|
||||
assert.equal(e.x, x);
|
||||
h.run(0.5);
|
||||
assert.ok(e.x < x, 'moves after the pause');
|
||||
assert.ok(h.unit('t_b').stats.taken > 0, 'highest taunt chosen');
|
||||
assert.equal(h.unit('t_a').stats.taken, 0);
|
||||
const h2 = mk(0);
|
||||
h2.run(0.8);
|
||||
assert.ok(h2.unit('t_a').stats.taken > 0, 'latest deployed chosen');
|
||||
assert.equal(h2.unit('t_b').stats.taken, 0);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
// Sim core: rng, grid/pathing, damage formulas, stat aggregation, buffs.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRng } from '../../server/sim/rng.js';
|
||||
import { Grid } from '../../server/sim/grid.js';
|
||||
import { mitigate } from '../../server/sim/damage.js';
|
||||
import { Unit } from '../../server/sim/units.js';
|
||||
import { getDefaultSource, hasGeneratedData } from '../../server/sim/simdata.js';
|
||||
import { makeBattle, flatStage, chessRec, enemyRec } from '../helpers/battleHarness.js';
|
||||
|
||||
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
|
||||
|
||||
test('rng is deterministic and in [0,1)', () => {
|
||||
const a = createRng(123), b = createRng(123), c = createRng(124);
|
||||
const xs = Array.from({ length: 50 }, () => a());
|
||||
const ys = Array.from({ length: 50 }, () => b());
|
||||
assert.deepEqual(xs, ys);
|
||||
assert.notDeepEqual(xs, Array.from({ length: 50 }, () => c()));
|
||||
for (const x of xs) assert.ok(x >= 0 && x < 1);
|
||||
const r = createRng(5);
|
||||
for (let i = 0; i < 200; i++) { const v = r.int(7); assert.ok(Number.isInteger(v) && v >= 0 && v < 7); }
|
||||
});
|
||||
|
||||
test('grid: 8-dir A* without corner cutting matches helper path lengths on real stages', () => {
|
||||
const ds = getDefaultSource();
|
||||
for (const id of ['act2autochess_m01', 'act2autochess_m02', 'act1autochess_m03']) {
|
||||
const st = ds.getStage(id);
|
||||
const g = new Grid(st, { r0: 9, r1: 12, c0: 0, c1: 10 });
|
||||
const helper = st.raw.groundPaths?.['9,10->9,2'] ?? st.raw.groundPathsHelper?.paths?.['9,10->9,2'];
|
||||
const p = g.findPath(9, 10, 9, 2);
|
||||
assert.ok(p, `path on ${id}`);
|
||||
assert.deepEqual(p[0], [9, 10]);
|
||||
assert.deepEqual(p[p.length - 1], [9, 2]);
|
||||
if (helper) approx(Grid.pathLength(p), Grid.pathLength(helper), 1e-6);
|
||||
// no corner cutting: every diagonal step has both orthogonals passable
|
||||
for (let i = 1; i < p.length; i++) {
|
||||
const [r0, c0] = p[i - 1], [r1, c1] = p[i];
|
||||
assert.ok(Math.abs(r1 - r0) <= 1 && Math.abs(c1 - c0) <= 1);
|
||||
if (r1 !== r0 && c1 !== c0) {
|
||||
assert.ok(g.groundPassable(r0, c1) || (r0 === 9 && c1 === 2));
|
||||
assert.ok(g.groundPassable(r1, c0) || (r1 === 9 && c0 === 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('grid: obstacles re-route and bump version; blocked entirely ⇒ null unless ignoreObstacles', () => {
|
||||
const g = new Grid(flatStage(), { r0: 9, r1: 12, c0: 0, c1: 10 });
|
||||
const direct = g.findPath(9, 10, 9, 2);
|
||||
assert.equal(direct.length, 9);
|
||||
const v0 = g.version;
|
||||
g.setObstacle(9, 6, true);
|
||||
assert.ok(g.version > v0);
|
||||
const around = g.findPath(9, 10, 9, 2);
|
||||
assert.ok(!around.some(([r, c]) => r === 9 && c === 6));
|
||||
for (let r = 9; r <= 12; r++) g.setObstacle(r, 6, true);
|
||||
assert.equal(g.findPath(9, 10, 9, 2), null);
|
||||
assert.ok(g.findPath(9, 10, 9, 2, { ignoreObstacles: true }));
|
||||
});
|
||||
|
||||
test('damage formulas: phys/arts/true, ignore, 5% floor', () => {
|
||||
approx(mitigate(1000, 'phys', { def: 300 }), 700);
|
||||
approx(mitigate(1000, 'phys', { def: 2000 }), 50);
|
||||
approx(mitigate(1000, 'phys', { def: 300 }, { defIgnorePct: 0.5, defIgnoreFlat: 50 }), 900);
|
||||
approx(mitigate(1000, 'arts', { res: 30 }), 700);
|
||||
approx(mitigate(1000, 'arts', { res: 100 }), 50);
|
||||
approx(mitigate(1000, 'arts', { res: 40 }, { resIgnorePct: 0.5, resIgnoreFlat: 10 }), 900);
|
||||
approx(mitigate(1000, 'true', { def: 9999, res: 100 }), 1000);
|
||||
});
|
||||
|
||||
test('dealDamage applies multipliers, shields and records stats', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_a: chessRec({ id: 't_a', stats: { atk: 1000 } }) }, enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 100000, def: 200, res: 50, speed: 0 }) } },
|
||||
units: [{ chessId: 't_a', row: 9, col: 3 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }],
|
||||
content: 'none',
|
||||
});
|
||||
h.step();
|
||||
const op = h.unit('t_a');
|
||||
const e = h.enemy('enemy_dummy');
|
||||
op.markDirty();
|
||||
h.b.addBuff(op, { key: 'dd', mods: { dmgDealtMul: 1.5 } });
|
||||
h.b.addBuff(e, { key: 'fr', mods: { dmgTakenMul: 1.2, artsTakenMul: 2 } });
|
||||
const hp0 = e.hp;
|
||||
const d1 = h.b.dealDamage(op, e, { amount: 1000, type: 'phys' });
|
||||
approx(d1, (1000 - 200) * 1.5 * 1.2);
|
||||
const d2 = h.b.dealDamage(op, e, { amount: 1000, type: 'arts' });
|
||||
approx(d2, 1000 * 0.5 * 1.5 * 1.2 * 2);
|
||||
approx(e.hp, hp0 - d1 - d2);
|
||||
// shield absorbs
|
||||
h.b.addBuff(e, { key: 'sh', shield: 500 });
|
||||
const d3 = h.b.dealDamage(null, e, { amount: 300, type: 'true' });
|
||||
assert.equal(d3, 0);
|
||||
const d4 = h.b.dealDamage(null, e, { amount: 300, type: 'true' });
|
||||
approx(d4, 360 - (500 - 360)); // 300×1.2 per hit; the shield absorbed 360 of 500 on the first hit
|
||||
assert.equal(e.findBuff('sh'), null, 'depleted shield removed');
|
||||
// hit-negating barrier
|
||||
h.b.addBuff(e, { key: 'barrier', shieldHits: 1 });
|
||||
assert.equal(h.b.dealDamage(null, e, { amount: 5000, type: 'true' }), 0);
|
||||
assert.ok(h.b.dealDamage(null, e, { amount: 10, type: 'true' }) > 0);
|
||||
assert.ok(op.stats.dmg > 0);
|
||||
assert.ok(h.result().perPlayer.p1.damageDealt >= d1 + d2);
|
||||
});
|
||||
|
||||
test('dodge uses the seeded rng and is deterministic', () => {
|
||||
const run = (seed) => {
|
||||
const h = makeBattle({ seed, defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none' });
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy');
|
||||
h.b.addBuff(e, { key: 'dodge', mods: { dodgePhys: 0.5 } });
|
||||
let hits = 0;
|
||||
for (let i = 0; i < 400; i++) if (h.b.dealDamage(null, e, { amount: 100, type: 'phys' }) > 0) hits++;
|
||||
assert.equal(h.b.dealDamage(null, e, { amount: 100, type: 'phys', canDodge: false }) > 0, true);
|
||||
return hits;
|
||||
};
|
||||
const a = run(9);
|
||||
assert.equal(a, run(9));
|
||||
assert.ok(a > 150 && a < 250, `~50% hits (${a})`);
|
||||
});
|
||||
|
||||
test('stat aggregation: (base+flat)(1+pct)Πmul, res clamp, aspd clamp, interval, HP ratio kept', () => {
|
||||
const u = new Unit({ id: 1, side: 'ally', kind: 'op', base: { maxHp: 1000, atk: 100, def: 50, res: 20, aspd: 100, bat: 1.2 } });
|
||||
u.alive = true;
|
||||
u.hp = 500;
|
||||
u.buffs.push({ key: 'a', stacks: 1, mods: { atkFlat: 50, atkPct: 0.5, atkMul: 1.2, hpPct: 1, resFlat: 200, aspd: 30, batPct: -0.25 } });
|
||||
u.buffs.push({ key: 'b', stacks: 2, mods: { atkPct: 0.1, atkMul: 1.1 } });
|
||||
u.markDirty();
|
||||
const s = u.s;
|
||||
approx(s.atk, (100 + 50) * (1 + 0.5 + 0.2) * 1.2 * 1.1 * 1.1);
|
||||
assert.equal(s.res, 100);
|
||||
assert.equal(s.aspd, 130);
|
||||
approx(s.interval, 1.2 * 0.75 * 100 / 130);
|
||||
approx(s.maxHp, 2000);
|
||||
approx(u.hp, 1000, 1e-6);
|
||||
u.buffs.push({ key: 'c', stacks: 1, mods: { aspd: -500 } });
|
||||
u.markDirty();
|
||||
assert.equal(u.s.aspd, 10);
|
||||
});
|
||||
|
||||
test('buffs: replace / extend / stack / independent / keep, expiry and onExpire', () => {
|
||||
const h = makeBattle({ defs: { chess: { t_a: chessRec({ id: 't_a' }) } }, units: [{ chessId: 't_a', row: 9, col: 3 }], content: 'none' });
|
||||
h.step();
|
||||
const u = h.unit('t_a');
|
||||
const b = h.b;
|
||||
b.addBuff(u, { key: 'r', duration: 2, mods: { atkPct: 0.1 } });
|
||||
b.addBuff(u, { key: 'r', duration: 1, mods: { atkPct: 0.3 } });
|
||||
assert.equal(u.buffs.filter((x) => x.key === 'r').length, 1);
|
||||
approx(u.findBuff('r').timeLeft, 1);
|
||||
b.addBuff(u, { key: 'e', duration: 5, refresh: 'extend', mods: { defPct: 0.1 } });
|
||||
b.addBuff(u, { key: 'e', duration: 2, refresh: 'extend', mods: { defPct: 0.2 } });
|
||||
approx(u.findBuff('e').timeLeft, 5);
|
||||
assert.equal(u.findBuff('e').mods.defPct, 0.2);
|
||||
for (let i = 0; i < 5; i++) b.addBuff(u, { key: 's', duration: 3, refresh: 'stack', maxStacks: 3, mods: { aspd: 10 } });
|
||||
assert.equal(u.findBuff('s').stacks, 3);
|
||||
assert.equal(u.s.aspd, 130);
|
||||
for (let i = 0; i < 4; i++) b.addBuff(u, { key: 'i', duration: 1 + i, refresh: 'independent', maxStacks: 3, mods: { atkFlat: 1 } });
|
||||
assert.equal(u.buffs.filter((x) => x.key === 'i').length, 3);
|
||||
b.addBuff(u, { key: 'k', duration: 1, refresh: 'keep', mods: { atkFlat: 5 } });
|
||||
b.addBuff(u, { key: 'k', duration: 9, refresh: 'keep', mods: { atkFlat: 50 } });
|
||||
assert.equal(u.findBuff('k').mods.atkFlat, 5);
|
||||
let expired = 0;
|
||||
b.addBuff(u, { key: 'x', duration: 0.5, onExpire: () => expired++ });
|
||||
h.run(0.6);
|
||||
assert.equal(expired, 1);
|
||||
assert.equal(u.findBuff('x'), null);
|
||||
assert.ok(u.findBuff('r'), '1 s buff still alive at 0.6 s');
|
||||
h.run(3);
|
||||
assert.equal(u.findBuff('r'), null, '1 s buff expired');
|
||||
assert.equal(u.findBuff('s'), null);
|
||||
assert.ok(u.findBuff('e'));
|
||||
// interval ticks
|
||||
let ticks = 0;
|
||||
b.addBuff(u, { key: 'dot', duration: 3, interval: 1, onTick: () => ticks++ });
|
||||
h.run(3.05);
|
||||
assert.equal(ticks, 3);
|
||||
});
|
||||
|
||||
test('persistent buffs survive death; others are cleared', () => {
|
||||
const h = makeBattle({ defs: { chess: { t_a: chessRec({ id: 't_a', stats: { respawnTime: 1, cost: 0 } }) } }, units: [{ chessId: 't_a', row: 9, col: 3 }], content: 'none' });
|
||||
h.step();
|
||||
const u = h.unit('t_a');
|
||||
h.b.addBuff(u, { key: 'keepme', persist: true, mods: { atkPct: 1 } });
|
||||
h.b.addBuff(u, { key: 'loseme', mods: { atkPct: 1 } });
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
assert.equal(u.alive, false);
|
||||
assert.ok(u.findBuff('keepme'));
|
||||
assert.equal(u.findBuff('loseme'), null);
|
||||
h.run(1.5);
|
||||
assert.equal(u.alive, true, 'redeployed');
|
||||
approx(u.s.atk, 500 * 2);
|
||||
});
|
||||
|
||||
test('simdata normalises real chess, enemy, token, stage and routes', () => {
|
||||
assert.ok(hasGeneratedData(), 'tests run against the generated data/*.json');
|
||||
const ds = getDefaultSource();
|
||||
const c = ds.getChess('chess_char_1_01_a');
|
||||
assert.equal(c.subProf, 'fastshot');
|
||||
assert.ok(c.skill && c.skill.spCost > 0);
|
||||
assert.ok(c.rangeGrid.length > 3);
|
||||
const e = ds.getEnemy('enemy_1427_lrnazg');
|
||||
assert.ok(e.maxHp > 0 && e.rangeRadius > 0);
|
||||
const e2 = ds.getEnemy('1427_lrnazg');
|
||||
assert.equal(e2.key, 'enemy_1427_lrnazg');
|
||||
const st = ds.getStage('act2autochess_m01');
|
||||
assert.equal(st.rows.length, 19);
|
||||
assert.equal(st.rows[9][2], 'E');
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// The worked examples of docs/SIM.md, executed (keeps the documentation honest).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0, ...o });
|
||||
|
||||
test('SIM.md §10: 隐现 S2 fires 14 shots then ends', () => {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 4 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }],
|
||||
hooks: ['ammoUsed', 'skillEnd'],
|
||||
});
|
||||
const u = h.unit('chess_char_1_01_a');
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 60));
|
||||
h.runUntil(() => !u.skill.active, 60);
|
||||
assert.equal(h.hooksOf('ammoUsed').length, 14);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('SIM.md example 1: ammo sniper kit with talent (+2 ammo after 20 s)', () => {
|
||||
const kit = (bb, chess, def) => ({
|
||||
skill: { kind: 'ammo', ammo: bb['attack@trigger_time'], mods: { atkPct: bb.atk, batPct: bb.base_attack_time, taunt: -1 }, targeting: { priority: 'ranged' } },
|
||||
talents: [{ install(battle, unit) {
|
||||
const t = def.talents[0].bb;
|
||||
battle.on('skillStart', ({ unit: u, skill }) => { if (u === unit && battle.time - unit.deployedAt >= t.duration) skill.ammoLeft += t.self_ammo; }, { owner: unit });
|
||||
} }],
|
||||
});
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: dummy() } }, kits: { chess_char_1_01_a: kit },
|
||||
units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 4 }], hooks: ['ammoUsed'],
|
||||
});
|
||||
const u = h.unit('chess_char_1_01_a');
|
||||
h.run(25);
|
||||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.runUntil(() => u.skill.activations === 1, 10);
|
||||
h.runUntil(() => !u.skill.active, 60);
|
||||
assert.equal(h.hooksOf('ammoUsed').length, 14 + 2);
|
||||
assert.equal(u.s.taunt, 0, 'taunt −1 removed after the skill');
|
||||
});
|
||||
|
||||
test('SIM.md example 2: 幽灵鲨 undying during the skill, self-stun afterwards', () => {
|
||||
const kit = (bb) => ({
|
||||
skill: {
|
||||
kind: 'duration', mods: { atkPct: bb.atk },
|
||||
onStart({ battle, unit }) { unit.mem.undying = battle.on('fatal', (c) => { if (c.unit === unit) c.prevented = true; }, { owner: unit }); },
|
||||
onEnd({ battle, unit, reason }) { battle.off(unit.mem.undying); if (reason !== 'death') battle.applyStatus(unit, 'stun', { duration: bb.stun, source: unit }); },
|
||||
},
|
||||
});
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: dummy() } }, kits: { chess_char_2_07_a: kit },
|
||||
units: [{ chessId: 'chess_char_2_07_a', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [9, 6] }],
|
||||
});
|
||||
const u = h.unit('chess_char_2_07_a');
|
||||
h.runUntil(() => u.skill.active, 60);
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
assert.equal(u.alive, true);
|
||||
assert.ok(u.hp >= 1);
|
||||
h.runUntil(() => !u.skill.active, 20);
|
||||
h.step();
|
||||
assert.ok(u.s.flags.stun);
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
assert.equal(u.alive, false, 'undying ended with the skill');
|
||||
});
|
||||
|
||||
test('SIM.md example 5: 赫默 summons a medical drone that expires after 10 s', () => {
|
||||
const kit = () => ({
|
||||
skill: {
|
||||
kind: 'instant', trigger: 'SP_FULL',
|
||||
onStart({ battle, unit }) {
|
||||
for (const k of unit.rangeKeys) {
|
||||
const r = Math.floor(k / 21), c = k % 21;
|
||||
if (!battle.unitAt(r, c) && battle.grid.canStand(r, c, { ranged: true })) { battle.spawnToken(unit, 'token_10000_silent_healrb', r, c, { duration: 10 }); break; }
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
const h = makeBattle({ kits: { chess_char_2_02_a: kit }, units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 4 }] });
|
||||
const u = h.unit('chess_char_2_02_a');
|
||||
h.runUntil(() => u.skill.activations === 1, 60);
|
||||
const drone = h.b.allyUnits.find((x) => x.kind === 'token');
|
||||
assert.ok(drone && drone.alive);
|
||||
assert.equal(drone.profile.dmgType, 'heal');
|
||||
h.run(10.1);
|
||||
assert.equal(drone.alive, false);
|
||||
});
|
||||
|
||||
test('SIM.md example 6: 古米 TAKE_DAMAGE heal on the next attack', () => {
|
||||
const kit = (bb) => ({
|
||||
skill: {
|
||||
kind: 'instant',
|
||||
attack: { onHit({ battle, unit }) {
|
||||
const ally = battle.alliesInRadius(unit.x, unit.y, 1.5, unit.ownerId).sort((a, b) => a.hpRatio - b.hpRatio)[0];
|
||||
if (ally) battle.heal(unit, ally, unit.s.atk * bb.heal_scale);
|
||||
} },
|
||||
},
|
||||
});
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_ally: chessRec({ id: 't_ally', skill: null, stats: { maxHp: 10000, atk: 0 } }) }, enemies: { enemy_dummy: dummy({ atk: 50, bat: 1 }) } },
|
||||
kits: { chess_char_1_10_a: kit },
|
||||
units: [{ chessId: 'chess_char_1_10_a', row: 9, col: 5 }, { chessId: 't_ally', row: 10, col: 5 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [9, 5], time: 8 }], autoFinish: false, // spawns on 古米's tile ⇒ blocked
|
||||
});
|
||||
const g = h.unit('chess_char_1_10_a');
|
||||
h.run(7);
|
||||
h.unit('t_ally').hp = 2000;
|
||||
assert.equal(g.skill.rule, 'TAKE_DAMAGE');
|
||||
assert.equal(g.skill.activations, 0, 'ready but not hit yet');
|
||||
h.run(4);
|
||||
assert.ok(g.skill.activations >= 1);
|
||||
assert.ok(h.unit('t_ally').hp > 2000, 'healed by the empowered attack');
|
||||
});
|
||||
@@ -0,0 +1,507 @@
|
||||
// Engine change requests of the content review (docs/SIM.md): permanent rangeExtend in the initial range, forceAttack
|
||||
// noAmmo, the massFlat mod, the attract (诱导) and resist (抵抗) statuses, extra range keys, content trigger ranges,
|
||||
// skill onEnd order, redeploy { tile, keepSp }, path-first tactical points, statusApplied `entered`, layerGain `tile`.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants, flatRoutes } from '../helpers/battleHarness.js';
|
||||
import { COLS } from '../../server/sim/constants.js';
|
||||
import { RESIST_STATUSES } from '../../server/sim/buffs.js';
|
||||
|
||||
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps, `${msg} ${a} ≈ ${b}`);
|
||||
const K = (r, c) => r * COLS + c;
|
||||
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 AROUND = [[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]];
|
||||
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0, ...o });
|
||||
const sniper = (skill = {}, o = {}) => chessRec({ id: 't_sn', profession: 'SNIPER', subProfessionId: 'closerange', rangeGrid: RANGE3, stats: { atk: 100, bat: 1, ...(o.stats || {}) }, skill: { spCost: 5, initSp: 0, duration: 2, ...skill }, ...o });
|
||||
const guard = (o = {}) => chessRec({ id: 't_gd', profession: 'WARRIOR', rangeGrid: [[0, 0], [0, 1]], stats: { atk: 100, maxHp: 1e6, blockCnt: 3, cost: 10, respawnTime: 5, ...(o.stats || {}) }, skill: { spCost: 10, initSp: 0, duration: 5, ...(o.skill || {}) }, ...o });
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 1) baseRangeKeys = own grid + permanent rangeExtend
|
||||
|
||||
test('baseRangeKeys: the initial range includes permanent rangeExtend (persist, never-expiring) — DEFAULT trigger sees the module tile', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sn: sniper({ spCost: 1, initSp: 1 }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic', autoFinish: false, hooks: ['skillStart'],
|
||||
kits: { t_sn: () => ({ skill: { kind: 'duration', duration: 2 }, install(b, u) { b.addBuff(u, { key: 'module', mods: { rangeExtend: 1 }, persist: true, allowDead: true }); } }) },
|
||||
});
|
||||
const u = h.unit('t_sn');
|
||||
h.step();
|
||||
assert.ok(u.baseRangeKeys.includes(K(10, 8)), 'module tile (col +4) in the initial range');
|
||||
assert.ok(u.rangeKeySet.has(K(10, 8)));
|
||||
h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), 'DEFAULT fires for an enemy on the module tile only');
|
||||
// relocation rebuilds it around the new tile
|
||||
h.b.relocate(u, 11, 3);
|
||||
assert.ok(u.baseRangeKeys.includes(K(11, 7)) && !u.baseRangeKeys.includes(K(11, 8)));
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('baseRangeKeys: temporary / skill rangeExtend never counts; a permanent one added after deployment is picked up', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sn: sniper({ spCost: 100 }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic', autoFinish: false,
|
||||
kits: { t_sn: () => ({ skill: { kind: 'duration', duration: 2, targeting: { rangeExtend: 2 } } }) },
|
||||
});
|
||||
const u = h.unit('t_sn');
|
||||
h.step();
|
||||
h.b.addBuff(u, { key: 'temp', mods: { rangeExtend: 1 }, duration: 5 });
|
||||
h.step();
|
||||
assert.ok(u.rangeKeySet.has(K(10, 8)), 'temporary extend: current range');
|
||||
assert.ok(!u.baseRangeKeys.includes(K(10, 8)), 'not the initial range');
|
||||
u.skill.activate('test', { free: true });
|
||||
assert.ok(u.rangeKeySet.has(K(10, 10)), 'skill extend: current range');
|
||||
assert.ok(!u.baseRangeKeys.includes(K(10, 8)), 'skill range never the initial range');
|
||||
u.skill.end('test');
|
||||
h.b.removeBuff(u, 'temp');
|
||||
h.b.addBuff(u, { key: 'late', mods: { rangeExtend: 1 }, persist: true, allowDead: true });
|
||||
h.step();
|
||||
assert.ok(u.baseRangeKeys.includes(K(10, 8)), 'permanent extend added later: rebuilt on the next tick');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 2) forceAttack noAmmo / onAttack ctx.noAmmo
|
||||
|
||||
test('forceAttack(u, targets, { noAmmo }): an extra attack that spends no bullet and emits no ammoUsed', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sn: sniper({ spCost: 100 }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic', autoFinish: false, hooks: ['ammoUsed', 'attack'], captureNoisy: true,
|
||||
kits: { t_sn: () => ({ skill: { kind: 'ammo', ammo: 5, onAttack(ctx) { if (ctx.unit.mem.freeShot) ctx.noAmmo = true; } } }) },
|
||||
});
|
||||
const u = h.unit('t_sn');
|
||||
h.step();
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.b._buildEnemyIndex();
|
||||
u.skill.activate('test', { free: true });
|
||||
const n0 = u.stats.attacks;
|
||||
assert.equal(h.b.forceAttack(u, [e], { noAmmo: true }), true);
|
||||
assert.equal(u.stats.attacks, n0 + 1, 'it is an attack');
|
||||
assert.equal(u.skill.ammoLeft, 5, 'no bullet spent');
|
||||
assert.equal(h.hooksOf('ammoUsed').length, 0, 'no ammoUsed');
|
||||
h.b.forceAttack(u, [e]);
|
||||
assert.equal(u.skill.ammoLeft, 4, 'a plain forced attack spends one');
|
||||
assert.equal(h.hooksOf('ammoUsed').length, 1);
|
||||
u.mem.freeShot = true; // SkillSpec onAttack: ctx.noAmmo = true
|
||||
h.b.forceAttack(u, [e]);
|
||||
assert.equal(u.skill.ammoLeft, 4, 'onAttack ctx.noAmmo');
|
||||
assert.equal(h.hooksOf('ammoUsed').length, 1);
|
||||
u.mem.freeShot = false;
|
||||
// the last bullets: a noAmmo attack never ends the skill
|
||||
u.skill.ammoLeft = 1;
|
||||
h.b.forceAttack(u, [e], { noAmmo: true });
|
||||
assert.ok(u.skill.active && u.skill.ammoLeft === 1);
|
||||
h.b.forceAttack(u, [e]);
|
||||
assert.ok(!u.skill.active, 'the last real bullet ends it');
|
||||
// no target ⇒ false
|
||||
const far = makeBattle({ defs: { chess: { t_sn: sniper() } }, units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic', autoFinish: false });
|
||||
far.step();
|
||||
assert.equal(far.b.forceAttack(far.unit('t_sn'), null, { noAmmo: true }), false);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 3) massFlat
|
||||
|
||||
test('massFlat: current massLevel (weight) for displacement and 浮空 halving; base untouched; floored at 0', () => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_heavy: enemyRec({ key: 'enemy_heavy', hp: 1e7, speed: 0, mass: 4 }) } }, units: [], content: 'none', autoFinish: false });
|
||||
h.step();
|
||||
const e = h.spawn('enemy_heavy', { pos: [10, 5] });
|
||||
assert.equal(e.s.massLevel, 4);
|
||||
const d1 = h.b.displace(e, { x: 1, y: 0 }, 1, { force: 1 });
|
||||
approx(d1, 0.3, 0.051, 'mass 4 vs force 1: 25 %');
|
||||
h.b.addBuff(e, { key: 'weightless', mods: { massFlat: -1 }, duration: 10 });
|
||||
assert.equal(e.s.massLevel, 3);
|
||||
assert.equal(e.weight, 3);
|
||||
assert.equal(e.base.massLevel, 4, 'base untouched');
|
||||
e.x = 5; e.y = 10;
|
||||
const d2 = h.b.displace(e, { x: 1, y: 0 }, 1, { force: 1 });
|
||||
approx(d2, 0.5, 0.051, 'mass 3: 50 %');
|
||||
h.b.applyStatus(e, 'levitate', { duration: 4 });
|
||||
approx(e.findBuff('levitate').timeLeft, 4, 1e-9, 'weight 3: full 浮空');
|
||||
h.b.removeStatus(e, 'levitate');
|
||||
h.b.removeBuff(e, 'weightless');
|
||||
h.b.applyStatus(e, 'levitate', { duration: 4 });
|
||||
approx(e.findBuff('levitate').timeLeft, 2, 1e-9, 'weight 4: halved');
|
||||
h.b.addBuff(e, { key: 'feather', mods: { massFlat: -10 } });
|
||||
assert.equal(e.s.massLevel, 0, 'never below 0');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 4) attract (诱导)
|
||||
|
||||
test('attract: the enemy is released, walks (own speed) to the point, waits there, then resumes its route', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_gd: guard() }, enemies: { enemy_walker: enemyRec({ key: 'enemy_walker', hp: 1e7, speed: 1 }) } },
|
||||
units: [{ chessId: 't_gd', row: 9, col: 6 }], content: 'generic', autoFinish: false, hooks: ['statusApplied'],
|
||||
enemies: [{ key: 'enemy_walker', route: 0 }],
|
||||
});
|
||||
const g = h.unit('t_gd');
|
||||
const e = () => h.enemy('enemy_walker');
|
||||
assert.ok(h.runUntil(() => e() && e().blockedBy === g, 20), 'blocked first');
|
||||
assert.equal(h.b.applyStatus(e(), 'attract', { duration: 8, source: g, point: [11, 6] }), true);
|
||||
assert.equal(e().blockedBy, null, 'released');
|
||||
assert.ok(e().s.flags.unblockable && e().s.flags.attract);
|
||||
assert.equal(h.hooksOf('statusApplied').at(-1).entered, true);
|
||||
const [x0, y0] = [e().x, e().y];
|
||||
h.run(1);
|
||||
// grid path: back to its tile centre (9,6), then down the column — axis-aligned legs
|
||||
approx(Math.abs(e().x - x0) + Math.abs(e().y - y0), 0.5, 0.02, 'own speed (1 × MOVE_SCALE) along the path');
|
||||
h.run(4.2);
|
||||
approx(e().x, 6, 1e-6); approx(e().y, 11, 1e-6, 'arrived');
|
||||
h.run(1);
|
||||
approx(e().y, 11, 1e-6, 'waits there');
|
||||
assert.ok(h.runUntil(() => !e().findBuff('attract'), 2), 'lasts its duration');
|
||||
const x1 = e().x;
|
||||
h.run(1.5);
|
||||
assert.ok(e().x < x1 - 0.3, 'resumes its route (toward the goal on the left)');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('attract: default point = the source tile; a new application moves the point; stun/bind stop it; flyers fly straight; resisted', () => {
|
||||
const h = makeBattle({
|
||||
defs: {
|
||||
chess: { t_gd: guard() },
|
||||
enemies: { enemy_walker: enemyRec({ key: 'enemy_walker', hp: 1e7, speed: 2 }), enemy_fly: enemyRec({ key: 'enemy_fly', hp: 1e7, speed: 2, motion: 'FLY' }) },
|
||||
},
|
||||
units: [{ chessId: 't_gd', row: 12, col: 3 }], content: 'generic', autoFinish: false,
|
||||
});
|
||||
h.step();
|
||||
const g = h.unit('t_gd');
|
||||
const w = h.spawn('enemy_walker', { pos: [9, 8], routeIndex: 0 });
|
||||
h.b.applyStatus(w, 'attract', { duration: 10, source: g });
|
||||
const A = w.findBuff('attract').data.attract;
|
||||
assert.deepEqual([A.r, A.c], [12, 3], 'default: the source tile');
|
||||
h.b.applyStatus(w, 'stun', { duration: 1 });
|
||||
const p0 = [w.x, w.y];
|
||||
h.run(0.9);
|
||||
assert.deepEqual([w.x, w.y], p0, 'stunned: no movement');
|
||||
h.run(0.5);
|
||||
assert.ok(w.x < p0[0] || w.y > p0[1], 'walks once the stun ended');
|
||||
h.b.applyStatus(w, 'attract', { duration: 10, source: g, point: [9, 9] });
|
||||
assert.deepEqual([w.findBuff('attract').data.attract.r, w.findBuff('attract').data.attract.c], [9, 9], 're-application moves the point');
|
||||
h.run(4);
|
||||
approx(w.x, 9, 1e-6); approx(w.y, 9, 1e-6);
|
||||
// flyer: straight line
|
||||
const f = h.spawn('enemy_fly', { pos: [9, 9], routeIndex: 2 });
|
||||
h.b.applyStatus(f, 'attract', { duration: 5, point: [11, 7] });
|
||||
h.run(0.5);
|
||||
const dx = 9 - f.x, dy = f.y - 9;
|
||||
approx(dx, dy, 1e-6, 'diagonal straight flight');
|
||||
// 抵抗 halves 诱导
|
||||
h.b.applyStatus(w, 'resist', { duration: 30 });
|
||||
h.b.removeStatus(w, 'attract');
|
||||
h.b.applyStatus(w, 'attract', { duration: 4, point: [10, 9] });
|
||||
approx(w.findBuff('attract').timeLeft, 2, 1e-9);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 5) resist (抵抗)
|
||||
|
||||
test('resist: halves control statuses (not stat debuffs), never compounds, keeps a longer running status; statusApplied reports it', () => {
|
||||
const h = makeBattle({ defs: { chess: { t_gd: guard() } }, units: [{ chessId: 't_gd', row: 10, col: 4 }], content: 'generic', autoFinish: false, hooks: ['statusApplied'] });
|
||||
h.step();
|
||||
const u = h.unit('t_gd');
|
||||
assert.ok(RESIST_STATUSES.has('stun') && RESIST_STATUSES.has('attract') && !RESIST_STATUSES.has('weaken'));
|
||||
assert.equal(h.b.resistOf(u), 0);
|
||||
h.b.applyStatus(u, 'resist', { duration: 60 });
|
||||
approx(h.b.resistOf(u), 0.5, 1e-12, 'default value: half');
|
||||
h.b.applyStatus(u, 'stun', { duration: 4 });
|
||||
approx(u.findBuff('stun').timeLeft, 2, 1e-9);
|
||||
assert.equal(h.hooksOf('statusApplied').at(-1).duration, 2, 'statusApplied reports the resisted duration');
|
||||
h.b.applyStatus(u, 'weaken', { duration: 4 });
|
||||
approx(u.findBuff('weaken').timeLeft, 4, 1e-9, 'stat debuffs are not resisted');
|
||||
// several sources: never compound (same-name: strongest applies)
|
||||
h.b.applyStatus(u, 'resist', { duration: 60, value: 0.5 });
|
||||
h.b.addBuff(u, { key: 'module:resist', status: 'resist', data: { value: 0.3 }, persist: true, allowDead: true });
|
||||
approx(h.b.resistOf(u), 0.5, 1e-12);
|
||||
assert.equal(u.buffs.filter((b) => b.key === 'resist').length, 1, 'one resist status');
|
||||
h.b.removeStatus(u, 'stun');
|
||||
h.b.applyStatus(u, 'stun', { duration: 10 });
|
||||
approx(u.findBuff('stun').timeLeft, 5, 1e-9, '×0.5 once');
|
||||
h.run(1);
|
||||
h.b.applyStatus(u, 'stun', { duration: 8 }); // → 4 s < 4 s left… equal/shorter: the running one is kept
|
||||
approx(u.findBuff('stun').timeLeft, 4, 1e-6, 'a shorter resisted re-application never cuts the running one');
|
||||
// the permanent (persist) resist buff survives a knock-out; the status does not
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
approx(h.b.resistOf(u), 0.3, 1e-12);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('resist: a resisting enemy loses one 麻痹 stack every 5 s; the value scales the cut', () => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], content: 'none', autoFinish: false });
|
||||
h.step();
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
|
||||
h.b.applyStatus(e, 'resist', { value: 0.25 });
|
||||
h.b.applyStatus(e, 'palsy', { value: 3 });
|
||||
assert.equal(e.findBuff('palsy').stacks, 3);
|
||||
h.run(5.05);
|
||||
assert.equal(e.findBuff('palsy').stacks, 2);
|
||||
h.run(5);
|
||||
assert.equal(e.findBuff('palsy').stacks, 1);
|
||||
h.run(5);
|
||||
assert.equal(e.findBuff('palsy'), null);
|
||||
h.b.applyStatus(e, 'cold', { duration: 4 });
|
||||
approx(e.findBuff('cold').timeLeft, 3, 1e-9, 'value 0.25 ⇒ ×0.75');
|
||||
h.b.applyStatus(e, 'resist', { value: 0.5, duration: 1 }); // stronger: takes over, the weaker one resumes after
|
||||
approx(h.b.resistOf(e), 0.5, 1e-12);
|
||||
h.run(1.1);
|
||||
approx(h.b.resistOf(e), 0.25, 1e-12, 'weaker resumes');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 6) extra range keys
|
||||
|
||||
test('setExtraRange: extra tiles join the range across every rebuild (skill, relocate), never the initial range', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sn: sniper({ spCost: 100 }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic', autoFinish: false, hooks: ['attack'], captureNoisy: true,
|
||||
kits: { t_sn: () => ({ skill: { kind: 'duration', duration: 1, targeting: { rangeGrid: [[0, 0], [0, 1]] } } }) },
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('t_sn');
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 9] });
|
||||
h.run(1);
|
||||
assert.equal(h.hooksOf('attack').length, 0, 'out of range');
|
||||
assert.equal(h.b.setExtraRange(u, [K(10, 9), K(10, 9), -3, 1.5, 'x']), true);
|
||||
assert.deepEqual(u.extraRangeKeys, [K(10, 9)], 'junk filtered');
|
||||
assert.ok(u.rangeKeySet.has(K(10, 9)) && !u.baseRangeKeys.includes(K(10, 9)));
|
||||
h.run(1);
|
||||
assert.ok(h.hooksOf('attack').some((c) => c.targets.includes(e)), 'attacks the extra target');
|
||||
u.skill.activate('test', { free: true });
|
||||
assert.ok(u.rangeKeySet.has(K(10, 9)) && u.rangeKeySet.has(K(10, 5)) && !u.rangeKeySet.has(K(10, 6)), 'skill range ∪ extra');
|
||||
h.run(1.1);
|
||||
assert.ok(!u.skill.active && u.rangeKeySet.has(K(10, 9)) && u.rangeKeySet.has(K(10, 7)), 'normal range ∪ extra after the skill');
|
||||
h.b.relocate(u, 11, 4);
|
||||
assert.ok(u.rangeKeySet.has(K(10, 9)), 'absolute keys survive a relocation');
|
||||
h.b.setExtraRange(u, null);
|
||||
assert.equal(u.extraRangeKeys, null);
|
||||
assert.ok(!u.rangeKeySet.has(K(10, 9)));
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 7) content trigger ranges
|
||||
|
||||
test('addTriggerRange: an enemy in a content trigger range fires DEFAULT (attackers and non-attackers), not heal skills; unregister', () => {
|
||||
const far = [K(10, 9)];
|
||||
const h = makeBattle({
|
||||
defs: {
|
||||
chess: {
|
||||
t_sn: sniper({ spCost: 1, initSp: 1 }),
|
||||
t_bd: chessRec({ id: 't_bd', profession: 'SUPPORT', subProfessionId: 'bard', rangeGrid: [[0, 0]], skill: { spCost: 1, initSp: 1, duration: 2 } }),
|
||||
t_md: chessRec({ id: 't_md', profession: 'MEDIC', subProfessionId: 'physician', rangeGrid: [[0, 0]], skill: { spCost: 1, initSp: 1, duration: 2 } }),
|
||||
t_gd: guard(),
|
||||
},
|
||||
enemies: { enemy_dummy: dummy() },
|
||||
},
|
||||
units: [{ chessId: 't_sn', row: 10, col: 3 }, { chessId: 't_bd', row: 12, col: 3 }, { chessId: 't_md', row: 11, col: 2 }, { chessId: 't_gd', row: 11, col: 6 }],
|
||||
content: 'generic', autoFinish: false,
|
||||
});
|
||||
h.step();
|
||||
const sn = h.unit('t_sn'), bd = h.unit('t_bd'), md = h.unit('t_md'), gd = h.unit('t_gd');
|
||||
const offSn = sn.skill.addTriggerRange(() => [far]);
|
||||
bd.skill.addTriggerRange(() => [gd]); // a unit entry: its current range counts
|
||||
md.skill.addTriggerRange(() => [far]);
|
||||
h.run(0.5);
|
||||
assert.equal(sn.skill.activations + bd.skill.activations + md.skill.activations, 0, 'nothing in any range yet');
|
||||
h.spawn('enemy_dummy', { pos: [11, 7] }); // in the guard's range only
|
||||
h.run(0.2);
|
||||
assert.equal(bd.skill.activations, 1, 'bard: enemy in the ally unit\'s range');
|
||||
assert.equal(sn.skill.activations, 0);
|
||||
offSn();
|
||||
h.spawn('enemy_dummy', { pos: [10, 9] });
|
||||
h.run(0.2);
|
||||
assert.equal(sn.skill.activations, 0, 'unregistered');
|
||||
sn.skill.addTriggerRange(() => [far]);
|
||||
h.run(0.2);
|
||||
assert.equal(sn.skill.activations, 1, 'attacker: fires although nothing is in its own range');
|
||||
assert.equal(sn.stats.attacks, 0);
|
||||
assert.equal(md.skill.activations, 0, 'heal skills ignore enemy trigger ranges');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 8) onEnd order
|
||||
|
||||
test('skill end: onEnd runs with the skill mods and range still applied, then they are removed; re-activation in onEnd keeps them', () => {
|
||||
const seen = [];
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sn: sniper({ spCost: 100 }) } },
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic', autoFinish: false, hooks: ['skillEnd'],
|
||||
kits: {
|
||||
t_sn: () => ({
|
||||
skill: {
|
||||
kind: 'duration', duration: 1, mods: { atkPct: 1 }, targeting: { rangeGrid: AROUND },
|
||||
onEnd({ unit, skill }) {
|
||||
seen.push({ atk: unit.s.atk, around: unit.rangeKeySet.has(K(unit.tileR, unit.tileC - 1)), active: skill.active });
|
||||
if (unit.mem.again) { unit.mem.again = false; skill.activate('again', { free: true }); }
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('t_sn');
|
||||
u.skill.activate('test', { free: true });
|
||||
approx(u.s.atk, 200);
|
||||
h.run(1.1);
|
||||
assert.deepEqual(seen[0], { atk: 200, around: true, active: false }, 'skill ATK and range during onEnd');
|
||||
approx(u.s.atk, 100, 1e-9, 'removed afterwards');
|
||||
assert.ok(!u.rangeKeySet.has(K(10, 3)), 'normal range restored');
|
||||
assert.equal(h.hooksOf('skillEnd').length, 1);
|
||||
u.mem.again = true;
|
||||
u.skill.activate('test', { free: true });
|
||||
h.run(1.05);
|
||||
assert.equal(seen.length, 2);
|
||||
assert.ok(u.skill.active, 're-activated from onEnd');
|
||||
approx(u.s.atk, 200, 1e-9, 'the new activation keeps its mods');
|
||||
assert.ok(u.rangeKeySet.has(K(10, 3)));
|
||||
h.run(1.1);
|
||||
approx(u.s.atk, 100, 1e-9);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 9) redeploy { free, tile, keepSp }
|
||||
|
||||
test('redeploy: { tile } lands on another tile (home unchanged, later redeploys at home); { keepSp } restores SP before deploy fires', () => {
|
||||
let atDeploy = null;
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_gd: guard({ skill: { spCost: 10, initSp: 2 } }), t_x: guard({ id: 't_x' }) } },
|
||||
units: [{ chessId: 't_gd', row: 10, col: 4 }, { chessId: 't_x', row: 11, col: 6 }], content: 'generic', autoFinish: false, hooks: ['deploy'],
|
||||
setup: (b) => b.on('deploy', (c) => { if (c.unit.defId === 't_gd' && !c.initial) atDeploy = c.unit.skill.sp; }),
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('t_gd');
|
||||
h.run(3);
|
||||
const sp = u.skill.sp;
|
||||
assert.ok(sp > 4.9 && sp < 5.1);
|
||||
h.b.retreat(u);
|
||||
assert.equal(h.b.redeploy(u, { tile: [11, 6] }), false, 'occupied landing tile: refused, no fallback');
|
||||
assert.ok(!u.alive);
|
||||
assert.equal(h.b.redeploy(u, { tile: [3, 3] }), false, 'outside the rect');
|
||||
assert.equal(h.b.redeploy(u, { tile: [10.5, 3] }), false, 'not a tile');
|
||||
assert.equal(h.b.redeploy(u, { tile: [12, 8], keepSp: true }), true);
|
||||
assert.deepEqual([u.tileR, u.tileC, u.homeR, u.homeC], [12, 8, 10, 4], 'landed; home unchanged');
|
||||
assert.equal(h.b.unitAt(12, 8), u);
|
||||
approx(atDeploy, sp, 1e-9, 'SP restored before the deploy hook');
|
||||
approx(u.hp, u.s.maxHp);
|
||||
// plain redeploy: initSp; knocked out later → the auto-redeploy goes home
|
||||
h.b.retreat(u);
|
||||
h.b.redeploy(u, { tile: [12, 8] });
|
||||
approx(u.skill.sp, 2, 1e-9, 'without keepSp: initial SP');
|
||||
const p = h.b.getPlayer('p1');
|
||||
p.dp = 50;
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
assert.ok(h.runUntil(() => u.alive, u.base.respawnTime + 1));
|
||||
assert.deepEqual([u.tileR, u.tileC], [10, 4], 'auto-redeploy on the home tile');
|
||||
// paid redeploy on a tile
|
||||
h.b.retreat(u);
|
||||
const dp = p.dp;
|
||||
assert.equal(h.b.redeploy(u, { free: false, tile: [9, 5] }), true);
|
||||
approx(p.dp, dp - u.base.cost, 1e-9, 'paid');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('redeploy keepSp: charges survive too', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_gd: guard({ skill: { spCost: 2, initSp: 0, maxChargeTime: 3, durationType: 'NONE', duration: 0 } }) } },
|
||||
units: [{ chessId: 't_gd', row: 10, col: 4 }], content: 'generic', autoFinish: false,
|
||||
kits: { t_gd: () => ({ skill: { kind: 'charges', trigger: 'NEVER' } }) },
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('t_gd');
|
||||
h.run(5);
|
||||
assert.equal(u.skill.charges, 2);
|
||||
const sp = u.skill.sp;
|
||||
h.b.retreat(u);
|
||||
h.b.redeploy(u, { keepSp: true });
|
||||
assert.equal(u.skill.charges, 2);
|
||||
approx(u.skill.sp, sp, 1e-9);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 10) tactical point
|
||||
|
||||
test('findTacticalPoint: a free walkable tile of the initial range on an enemy ground path first, then the nearest', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_gd: guard({ rangeGrid: AROUND.concat([[0, 2], [1, 2], [-1, 2]]) }), t_x: guard({ id: 't_x' }) } },
|
||||
units: [{ chessId: 't_gd', row: 10, col: 4 }, { chessId: 't_x', row: 12, col: 9 }], content: 'generic', autoFinish: false,
|
||||
// the row-9 lane only (the upper gate's official smoothed route is a diagonal through (10,4)/(10,5), see grid.js)
|
||||
routes: flatRoutes().filter((r) => r.start[0] === 9),
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('t_gd');
|
||||
const path = h.b.groundPathTiles();
|
||||
assert.ok(path.has(K(9, 4)) && path.has(K(9, 10)) && !path.has(K(10, 4)), 'flat stage route 0 runs along row 9');
|
||||
const tok = (r, c) => assert.ok(h.b.spawnToken(u, 'tok', r, c, { def: { name: 'x', stats: { maxHp: 10 } } }));
|
||||
// (9,3) (9,4) (9,5) are on the path at the same distance: the lowest tile key wins
|
||||
assert.deepEqual(h.b.findTacticalPoint(u), [9, 3], 'on the path (row 9) before the tiles of its own row');
|
||||
tok(9, 3);
|
||||
assert.deepEqual(h.b.findTacticalPoint(u), [9, 4], 'taken tiles are skipped');
|
||||
tok(9, 4); tok(9, 5);
|
||||
assert.deepEqual(h.b.findTacticalPoint(u), [9, 6], 'a farther path tile beats a nearer off-path one');
|
||||
tok(9, 6);
|
||||
assert.deepEqual(h.b.findTacticalPoint(u), [10, 3], 'no path tile left: the nearest (same row first), lowest key');
|
||||
// the home tile of a piece waiting to (re)deploy is reserved
|
||||
const x = h.unit('t_x');
|
||||
h.b.relocate(x, 10, 3);
|
||||
h.b.retreat(x);
|
||||
x.homeR = 10; x.homeC = 3;
|
||||
assert.deepEqual(h.b.findTacticalPoint(u), [10, 5]);
|
||||
// the path cache follows obstacles
|
||||
const v = h.b.grid.version;
|
||||
h.b.setObstacle(9, 7, true);
|
||||
assert.notEqual(h.b.grid.version, v);
|
||||
assert.ok(!h.b.groundPathTiles().has(K(9, 7)), 're-pathed around the obstacle');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 11) statusApplied `entered`, 12) layerGain `tile`
|
||||
|
||||
test('statusApplied ctx.entered: true only when the status newly started (refreshes, weaker valued applications: false)', () => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], content: 'none', autoFinish: false, hooks: ['statusApplied'] });
|
||||
h.step();
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
|
||||
const last = () => h.hooksOf('statusApplied').at(-1).entered;
|
||||
h.b.applyStatus(e, 'freeze', { duration: 1 });
|
||||
assert.equal(last(), true);
|
||||
h.b.applyStatus(e, 'freeze', { duration: 2 });
|
||||
assert.equal(last(), false, 'refresh of a running freeze');
|
||||
h.run(2.1);
|
||||
h.b.applyStatus(e, 'freeze', { duration: 1 });
|
||||
assert.equal(last(), true, 'after it ended');
|
||||
h.b.applyStatus(e, 'slow', { duration: 5, value: 0.5 });
|
||||
assert.equal(last(), true);
|
||||
h.b.applyStatus(e, 'slow', { duration: 9, value: 0.2 });
|
||||
assert.equal(last(), false, 'weaker valued application');
|
||||
h.b.addBuff(e, { key: 'custom:sleep', flags: { sleep: true }, status: 'sleep' });
|
||||
h.b.applyStatus(e, 'sleep', { duration: 1 });
|
||||
assert.equal(last(), false, 'a custom buff carrying the status counts');
|
||||
});
|
||||
|
||||
test('layerGain ctx.tile: the source\'s tile — or where it was knocked out this instant; null afterwards; explicit opts.tile', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_gd: guard() } }, units: [{ chessId: 't_gd', row: 10, col: 4 }], content: 'generic', autoFinish: false, hooks: ['layerGain'],
|
||||
setup: (b) => b.on('death', (c) => { if (c.unit.defId === 't_gd') b.addLayers('p1', 'test', 1, 'garrison', { source: c.unit }); }),
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('t_gd');
|
||||
h.b.relocate(u, 11, 5);
|
||||
h.b.addLayers('p1', 'test', 1, 'garrison', { source: u });
|
||||
assert.deepEqual(h.hooksOf('layerGain').at(-1).tile, [11, 5]);
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
assert.deepEqual(h.hooksOf('layerGain').at(-1).tile, [11, 5], 'knocked out this instant');
|
||||
h.step();
|
||||
h.b.addLayers('p1', 'test', 1, 'garrison', { source: u });
|
||||
assert.equal(h.hooksOf('layerGain').at(-1).tile, null, 'long gone');
|
||||
h.b.addLayers('p1', 'test', 1, 'x', { tile: [9, 9] });
|
||||
assert.deepEqual(h.hooksOf('layerGain').at(-1).tile, [9, 9]);
|
||||
h.b.addLayers('p1', 'test', 1, 'x');
|
||||
assert.equal(h.hooksOf('layerGain').at(-1).tile, null);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// Rotation invariance of the 4-direction facing (DESIGN §3 "Facing (corrected)", research 09 §1.2): on an open field an
|
||||
// operator facing d, with every enemy / helper placed at the same offsets turned by d, must play exactly like the
|
||||
// RIGHT-facing original — same damage on the same facing-RIGHT offsets, same pushes / pulls in its own frame, summons
|
||||
// on the same local tiles. Catches any front / side / "first tile" logic that still assumes RIGHT (tie-breaks by the
|
||||
// absolute tile key, walls along the column, …). Legit exceptions are board-position rules (DESIGN: deployment order
|
||||
// top→bottom then left→right, "最右边" / "更靠左" orders), which the scenario avoids or lists explicitly.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { rotateOffset, toLocal, localOrder, localBefore } from '../../server/sim/dir.js';
|
||||
|
||||
const CHESS = JSON.parse(readFileSync(new URL('../../data/chess.json', import.meta.url), 'utf8'));
|
||||
const LIST = Array.isArray(CHESS) ? CHESS : Object.values(CHESS);
|
||||
|
||||
// an all-road 19×21 field (no gates / paths near the unit: tactical points and summons choose by geometry only)
|
||||
const ROWS = [];
|
||||
for (let r = 0; r < 19; r++) ROWS.push('#' + 'r'.repeat(19) + '#');
|
||||
const OPEN = { id: 'open', name: 'open', rows: ROWS, devices: [] };
|
||||
const RECT = { r0: 0, r1: 18, c0: 0, c1: 20 };
|
||||
const FAR_ROUTE = [{ motion: 'FLY', start: [18, 20], end: [18, 19], checkpoints: [] }];
|
||||
const R0 = 9, C0 = 9;
|
||||
// enemy offsets in the facing-RIGHT frame (a spread of ranges incl. behind / beside / far ahead); every 4th one flies
|
||||
const OFFS = [];
|
||||
for (let dr = -4; dr <= 4; dr++) for (let dc = -4; dc <= 7; dc++) if ((dr || dc) && (dr * dr + dc * dc) % 3 !== 1) OFFS.push([dr, dc]);
|
||||
// injured helpers (distinct max HP and HP ratio: heal / protect choices never fall back to the deployment order)
|
||||
const HELP = [[1, 1], [0, -1], [-1, 0]];
|
||||
const helpers = Object.fromEntries(HELP.map((_, i) => [`t_help${i}`, chessRec({ id: `t_help${i}`, profession: 'SNIPER', rangeGrid: [[0, 0]], skill: null, stats: { maxHp: 4000 + 500 * i, atk: 0, blockCnt: 0 } })]));
|
||||
// 凯瑟琳 hands out her devices front-most operator first by board column — a board position (DESIGN), not a facing
|
||||
const BOARD_POSITION_RULES = new Set(['chess_char_4_11_a', 'chess_char_4_11_b']);
|
||||
const walkRoute = (pos) => ({ motion: 'WALK', start: pos, checkpoints: [{ type: 'WAIT', time: 1e4 }], end: pos });
|
||||
|
||||
function scenario(chessId, dir, secs) {
|
||||
const enemies = OFFS.map(([dr, dc], i) => {
|
||||
const [ar, ac] = rotateOffset(dr, dc, dir);
|
||||
const pos = [R0 + ar, C0 + ac];
|
||||
const fly = i % 4 === 1;
|
||||
return { key: fly ? 'e_fly' : 'e_dummy', pos, route: fly ? { ...walkRoute(pos), motion: 'FLY' } : walkRoute(pos) };
|
||||
});
|
||||
const units = [{ chessId, row: R0, col: C0, dir, abs: true }];
|
||||
HELP.forEach(([dr, dc], i) => { const [ar, ac] = rotateOffset(dr, dc, dir); units.push({ uid: 100 + i, chessId: `t_help${i}`, row: R0 + ar, col: C0 + ac, dir, abs: true }); });
|
||||
const h = makeBattle({
|
||||
stage: OPEN, rect: RECT, seed: 5, routes: FAR_ROUTE, units, enemies, autoFinish: false, timeLimit: secs + 5, hooks: [],
|
||||
defs: {
|
||||
chess: helpers,
|
||||
enemies: {
|
||||
e_dummy: enemyRec({ key: 'e_dummy', hp: 1e6, speed: 0, def: 100, res: 10 }),
|
||||
e_fly: enemyRec({ key: 'e_fly', hp: 1e6, speed: 0, motion: 'FLY', def: 50, res: 30 }),
|
||||
},
|
||||
},
|
||||
setup: (b) => b.on('deploy', ({ unit }) => { if (unit.uid >= 100) unit.hp = unit.s.maxHp * (0.3 + 0.1 * (unit.uid - 100)); }),
|
||||
});
|
||||
h.run(secs);
|
||||
checkInvariants(h.b);
|
||||
const local = (u) => toLocal(u.y - R0, u.x - C0, dir).map((v) => Math.round(v * 10) / 10);
|
||||
return {
|
||||
enemies: h.b.enemies.slice(0, OFFS.length).map((e) => [Math.round(1e6 - e.hp), ...local(e)]),
|
||||
helpers: HELP.map((_, i) => Math.round(h.b.allyUnits.find((u) => u.uid === 100 + i).hp)),
|
||||
tokens: h.b.allyUnits.filter((u) => u.kind === 'token').map((t) => `${t.defId}@${toLocal(t.tileR - R0, t.tileC - C0, dir)}:${t.alive ? 1 : 0}`).sort(),
|
||||
extra: h.b.enemies.length - OFFS.length,
|
||||
};
|
||||
}
|
||||
|
||||
const same = (a, b) => Math.abs(a - b) <= Math.max(2, 0.02 * Math.max(Math.abs(a), Math.abs(b)));
|
||||
|
||||
test('rotation invariance: every chess (normal + elite) facing UP / LEFT / DOWN plays like its RIGHT-facing original', () => {
|
||||
const bad = [];
|
||||
for (const rec of LIST) {
|
||||
if (BOARD_POSITION_RULES.has(rec.chessId)) continue;
|
||||
const base = scenario(rec.chessId, 'RIGHT', 20);
|
||||
for (const d of ['UP', 'LEFT', 'DOWN']) {
|
||||
const o = scenario(rec.chessId, d, 20);
|
||||
const diffs = [];
|
||||
base.enemies.forEach(([dmg, lr, lc], i) => {
|
||||
const [dmg2, lr2, lc2] = o.enemies[i];
|
||||
if (!same(dmg, dmg2) || Math.abs(lr - lr2) > 0.15 || Math.abs(lc - lc2) > 0.15) diffs.push(`enemy ${OFFS[i]}: ${dmg}@${lr},${lc} vs ${dmg2}@${lr2},${lc2}`);
|
||||
});
|
||||
base.helpers.forEach((hp, i) => { if (!same(hp, o.helpers[i])) diffs.push(`helper ${HELP[i]}: hp ${hp} vs ${o.helpers[i]}`); });
|
||||
if (base.tokens.join() !== o.tokens.join()) diffs.push(`summons [${base.tokens}] vs [${o.tokens}]`);
|
||||
if (base.extra !== o.extra) diffs.push(`extra enemies ${base.extra} vs ${o.extra}`);
|
||||
if (diffs.length) bad.push(`${rec.chessId} ${rec.name} ${d}: ${diffs.slice(0, 3).join(' | ')}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(bad, []);
|
||||
});
|
||||
|
||||
test('localOrder / localBefore: the facing-RIGHT frame tie-break equals the tile-key order for RIGHT and turns with the direction', () => {
|
||||
// RIGHT: (row, col) order = tile-key order
|
||||
const offs = [[-1, 0], [0, -1], [0, 1], [1, 0], [1, 1], [-1, -1]];
|
||||
const byKey = offs.slice().sort((a, b) => (a[0] * 21 + a[1]) - (b[0] * 21 + b[1]));
|
||||
const byLocal = offs.slice().sort((a, b) => (localBefore(localOrder(a[0], a[1], 'RIGHT'), localOrder(b[0], b[1], 'RIGHT')) ? -1 : 1));
|
||||
assert.deepEqual(byLocal, byKey);
|
||||
// the first of the 4 neighbours is the right hand (local (−1, 0)) whatever the direction
|
||||
for (const d of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
|
||||
const n4 = [[1, 0], [-1, 0], [0, 1], [0, -1]];
|
||||
let best = null, bo = null;
|
||||
for (const [r, c] of n4) { const o = localOrder(r, c, d); if (localBefore(o, bo)) { bo = o; best = [r, c]; } }
|
||||
assert.deepEqual(best, rotateOffset(-1, 0, d), d);
|
||||
}
|
||||
});
|
||||
|
||||
test('战术家 援军 (伺夜): the tactical point is the tile in FRONT along the direction (not the one beside it)', () => {
|
||||
for (const dir of ['RIGHT', 'UP', 'DOWN', 'LEFT']) {
|
||||
const h = makeBattle({ stage: OPEN, rect: RECT, routes: FAR_ROUTE, units: [{ chessId: 'chess_char_3_19_a', row: R0, col: C0, dir, abs: true }], autoFinish: false, timeLimit: 30 });
|
||||
h.run(2);
|
||||
const wolf = h.b.allyUnits.find((u) => u.kind === 'token' && u.alive);
|
||||
assert.ok(wolf, `${dir}: the pack is summoned`);
|
||||
assert.deepEqual([wolf.tileR - R0, wolf.tileC - C0], rotateOffset(0, 1, dir), `${dir}: in front of 伺夜`);
|
||||
assert.equal(wolf.dir, dir, 'the summon takes the owner direction');
|
||||
}
|
||||
});
|
||||
|
||||
test('突袭 (raidShip): the landing tile beside the enemy turns with the member direction (equal candidates)', () => {
|
||||
// a diagonal-only range: two equally near landing tiles cover the enemy — the tie resolves in the member's frame
|
||||
const rec = chessRec({ id: 'r_diag', bonds: ['raidShip'], profession: 'WARRIOR', skill: null, rangeGrid: [[1, 1], [-1, 1]] });
|
||||
const land = (dir) => {
|
||||
const h = makeBattle({
|
||||
stage: OPEN, rect: RECT, routes: FAR_ROUTE, defs: { chess: { r_diag: rec }, enemies: { e_d: enemyRec({ key: 'e_d', hp: 1e6, speed: 0 }) } },
|
||||
bonds: { raidShip: { count: 1, active: true, tier: 1, layers: 0 } },
|
||||
units: [{ chessId: 'r_diag', row: 15, col: 3, dir, abs: true }],
|
||||
enemies: [{ key: 'e_d', pos: [R0, C0], route: walkRoute([R0, C0]) }], autoFinish: false, timeLimit: 60,
|
||||
});
|
||||
const u = h.unit('r_diag');
|
||||
assert.ok(h.runUntil(() => u.alive && u.deployed && Math.max(Math.abs(u.tileR - R0), Math.abs(u.tileC - C0)) <= 2, 30), `${dir}: jumped next to the enemy`);
|
||||
return toLocal(u.tileR - R0, u.tileC - C0, dir);
|
||||
};
|
||||
const right = land('RIGHT');
|
||||
assert.deepEqual(right, [-1, -1], 'RIGHT: the lower of the two diagonal tiles behind the enemy (tile-key order, as before)');
|
||||
for (const d of ['UP', 'LEFT', 'DOWN']) assert.deepEqual(land(d), right, d);
|
||||
});
|
||||
|
||||
test('findSummonTile: equal candidates resolve in the owner\'s facing-RIGHT frame (RIGHT: the tile-key order, as before)', async () => {
|
||||
const { findSummonTile } = await import('../../server/sim/content/tokens.js');
|
||||
const rec = chessRec({ id: 't_own', profession: 'SUPPORT', rangeGrid: [[0, 0]], skill: null });
|
||||
for (const dir of ['RIGHT', 'UP', 'DOWN', 'LEFT']) {
|
||||
const h = makeBattle({ stage: OPEN, rect: RECT, routes: FAR_ROUTE, defs: { chess: { t_own: rec } }, units: [{ chessId: 't_own', row: R0, col: C0, dir, abs: true }], autoFinish: false, timeLimit: 10 });
|
||||
h.step();
|
||||
const o = h.unit('t_own');
|
||||
// no enemies: the 4 orthogonal neighbours tie on distance — the owner's right hand (local (−1, 0)) comes first
|
||||
const [r, c] = findSummonTile(h.b, o, 'adjacent');
|
||||
assert.deepEqual([r - R0, c - C0], rotateOffset(-1, 0, dir), dir);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
// 4-direction facing in the engine (DESIGN §3 "Facing (corrected)", research 09 §1.2 / §6.1): server/sim/dir.js math,
|
||||
// rotated range grids (+ rangeExtend before rotating), unit dir from the battle input, the Final Assault right-side
|
||||
// mirror (RIGHT ↔ LEFT, UP / DOWN kept), UnitInfo.dir, and every engine-level front / side helper (professions,
|
||||
// skill trigger grids, token placement grids, support geometry, kit helpers).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DIRS, DIR_VEC, normDir, rotateOffset, toLocal, hSign, mirrorDir, oppositeDir, perpendicular, dirFromDelta, frontOf, offsetTile,
|
||||
} from '../../server/sim/dir.js';
|
||||
import { absoluteRangeKeys } from '../../server/sim/targeting.js';
|
||||
import { COLS } from '../../server/sim/constants.js';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { findSummonTile } from '../../server/sim/content/tokens.js';
|
||||
import { frontTile, sideTiles, alliesAround, N4 } from '../../server/sim/content/support/index.js';
|
||||
import { freeTileAround } from '../../server/sim/content/kits/tier1.js';
|
||||
import { DIRS as PROTOCOL_DIRS } from '../../shared/protocol.js';
|
||||
|
||||
const K = (r, c) => r * COLS + c;
|
||||
const tilesOf = (keys) => keys.map((k) => [Math.floor(k / COLS), k % COLS]).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
const sorted = (list) => list.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
const dummy = (key = 'e_d', o = {}) => enemyRec({ key, hp: 1e7, speed: 0, def: 0, res: 0, ...o });
|
||||
|
||||
test('dir.js: the DATA.md rotation RIGHT (dr,dc) · UP (dc,−dr) · LEFT (−dr,−dc) · DOWN (−dc,dr), inverse and helpers', () => {
|
||||
assert.deepEqual([...DIRS], ['UP', 'RIGHT', 'DOWN', 'LEFT']);
|
||||
assert.deepEqual([...PROTOCOL_DIRS], [...DIRS], 'shared/protocol.js lists the same directions');
|
||||
assert.deepEqual(rotateOffset(2, 3, 'RIGHT'), [2, 3]);
|
||||
assert.deepEqual(rotateOffset(2, 3, 'UP'), [3, -2]);
|
||||
assert.deepEqual(rotateOffset(2, 3, 'LEFT'), [-2, -3]);
|
||||
assert.deepEqual(rotateOffset(2, 3, 'DOWN'), [-3, 2]);
|
||||
// the forward cell [0, 1] becomes the direction vector (row 0 = bottom: UP = +row)
|
||||
for (const d of DIRS) assert.deepEqual(rotateOffset(0, 1, d), [...DIR_VEC[d]], d);
|
||||
for (const d of DIRS) for (const [dr, dc] of [[0, 0], [1, 2], [-3, 1], [2, -2]]) {
|
||||
const [ar, ac] = rotateOffset(dr, dc, d);
|
||||
assert.deepEqual(toLocal(ar, ac, d), [dr, dc], `toLocal inverts rotateOffset (${d})`);
|
||||
}
|
||||
assert.equal(normDir('up'), 'UP');
|
||||
assert.equal(normDir(-1), 'LEFT');
|
||||
assert.equal(normDir(1), 'RIGHT');
|
||||
assert.equal(normDir('NORTH'), 'RIGHT');
|
||||
assert.equal(normDir(undefined, 'DOWN'), 'DOWN');
|
||||
assert.deepEqual(DIRS.map(hSign), [1, 1, 1, -1], 'horizontal sign only for sprite flipping');
|
||||
assert.deepEqual(DIRS.map(mirrorDir), ['UP', 'LEFT', 'DOWN', 'RIGHT'], 'FA mirror: RIGHT ↔ LEFT, UP / DOWN kept');
|
||||
assert.deepEqual(DIRS.map(oppositeDir), ['DOWN', 'LEFT', 'UP', 'RIGHT']);
|
||||
assert.ok(perpendicular('UP', 'LEFT') && !perpendicular('UP', 'DOWN'));
|
||||
assert.equal(dirFromDelta(1, 0), 'UP');
|
||||
assert.equal(dirFromDelta(0, -2), 'LEFT');
|
||||
assert.equal(dirFromDelta(-1, 0), 'DOWN');
|
||||
assert.equal(dirFromDelta(0, 0, 'LEFT'), 'LEFT');
|
||||
assert.deepEqual(frontOf(10, 5, 'DOWN', 2), [8, 5]);
|
||||
assert.deepEqual(frontOf(10, 5, 'UP', -1), [9, 5], 'k < 0: behind');
|
||||
assert.deepEqual(offsetTile(10, 5, 1, 0, 'UP'), [10, 4], 'the left hand of an UP unit is the left column');
|
||||
});
|
||||
|
||||
test('absoluteRangeKeys rotates the facing-RIGHT grid; rangeExtend goes along +dCol before rotating', () => {
|
||||
const grid = [[0, 1], [1, 2]];
|
||||
assert.deepEqual(tilesOf(absoluteRangeKeys(grid, 10, 5, 'RIGHT')), sorted([[10, 6], [11, 7]]));
|
||||
assert.deepEqual(tilesOf(absoluteRangeKeys(grid, 10, 5, 'UP')), sorted([[11, 5], [12, 4]]));
|
||||
assert.deepEqual(tilesOf(absoluteRangeKeys(grid, 10, 5, 'LEFT')), sorted([[10, 4], [9, 3]]));
|
||||
assert.deepEqual(tilesOf(absoluteRangeKeys(grid, 10, 5, 'DOWN')), sorted([[9, 5], [8, 6]]));
|
||||
// legacy facing signs still work (−1 = LEFT)
|
||||
assert.deepEqual(absoluteRangeKeys(grid, 10, 5, -1), absoluteRangeKeys(grid, 10, 5, 'LEFT'));
|
||||
// +1 forward tile per row, then rotated: a 2-tile line facing UP / DOWN becomes 3 tiles along the column
|
||||
const line = [[0, 0], [0, 1]];
|
||||
assert.deepEqual(tilesOf(absoluteRangeKeys(line, 10, 5, 'UP', 1)), [[10, 5], [11, 5], [12, 5]]);
|
||||
assert.deepEqual(tilesOf(absoluteRangeKeys(line, 10, 5, 'DOWN', 1)), [[8, 5], [9, 5], [10, 5]]);
|
||||
assert.deepEqual(tilesOf(absoluteRangeKeys(line, 10, 5, 'LEFT', 2)), [[10, 2], [10, 3], [10, 4], [10, 5]]);
|
||||
});
|
||||
|
||||
test('units take `dir` from the battle input (default RIGHT): rotated rangeKeys, derived facing sign, UnitInfo.dir', () => {
|
||||
const rec = chessRec({ id: 't_sn', profession: 'SNIPER', rangeGrid: [[0, 0], [0, 1], [0, 2]], skill: null });
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sn: rec, t_sn2: { ...rec, chessId: 't_sn2', baseId: 't_sn2' }, t_sn3: { ...rec, chessId: 't_sn3', baseId: 't_sn3' }, t_sn4: { ...rec, chessId: 't_sn4', baseId: 't_sn4' } } },
|
||||
units: [
|
||||
{ chessId: 't_sn', row: 10, col: 3, dir: 'UP' }, { chessId: 't_sn2', row: 10, col: 6, dir: 'LEFT' },
|
||||
{ chessId: 't_sn3', row: 12, col: 8, dir: 'DOWN' }, { chessId: 't_sn4', row: 9, col: 8 },
|
||||
],
|
||||
});
|
||||
h.step();
|
||||
const [up, left, down, def] = ['t_sn', 't_sn2', 't_sn3', 't_sn4'].map((id) => h.unit(id));
|
||||
assert.deepEqual([up.dir, left.dir, down.dir, def.dir], ['UP', 'LEFT', 'DOWN', 'RIGHT']);
|
||||
assert.deepEqual([up.facing, left.facing, down.facing, def.facing], [1, -1, 1, 1], 'facing = horizontal sign only');
|
||||
assert.deepEqual(up.fwd, [1, 0]);
|
||||
assert.deepEqual(tilesOf(up.rangeKeys), [[10, 3], [11, 3], [12, 3]]);
|
||||
assert.deepEqual(tilesOf(left.rangeKeys), [[10, 4], [10, 5], [10, 6]]);
|
||||
assert.deepEqual(tilesOf(down.rangeKeys), [[10, 8], [11, 8], [12, 8]]);
|
||||
assert.deepEqual(tilesOf(down.baseRangeKeys), [[10, 8], [11, 8], [12, 8]], 'the initial (trigger) range is rotated too');
|
||||
const info = h.b.fieldMeta().units.find((u) => u.defId === 't_sn');
|
||||
assert.equal(info.dir, 'UP');
|
||||
assert.equal(h.b.fieldMeta().units.find((u) => u.defId === 't_sn2').facing, -1);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('a unit only hits enemies inside its rotated range (DOWN-facing sniper shoots the row below)', () => {
|
||||
const rec = chessRec({ id: 't_sn', profession: 'SNIPER', rangeGrid: [[0, 0], [0, 1]], skill: null, stats: { atk: 100 } });
|
||||
const run = (dir) => {
|
||||
const h = makeBattle({ defs: { chess: { t_sn: rec }, enemies: { e_d: dummy() } }, units: [{ chessId: 't_sn', row: 11, col: 4, dir }], enemies: [{ key: 'e_d', pos: [10, 4] }], autoFinish: false, timeLimit: 10 });
|
||||
h.run(3);
|
||||
return h.hooksOf('attack').length;
|
||||
};
|
||||
assert.ok(run('DOWN') > 0, 'facing DOWN: the enemy below is in range');
|
||||
assert.equal(run('RIGHT'), 0, 'facing RIGHT: out of range');
|
||||
assert.equal(run('UP'), 0, 'facing UP: out of range');
|
||||
});
|
||||
|
||||
test('Final Assault right side: col c → 20 − c with RIGHT ↔ LEFT, UP / DOWN unchanged', () => {
|
||||
const rec = chessRec({ id: 't_a', profession: 'SNIPER', rangeGrid: [[0, 0], [0, 1]], skill: null });
|
||||
const defs = { chess: { t_a: rec, t_b: { ...rec, chessId: 't_b', baseId: 't_b' }, t_c: { ...rec, chessId: 't_c', baseId: 't_c' } } };
|
||||
const units = [{ uid: 1, kind: 'chess', chessId: 't_a', row: 10, col: 4, dir: 'RIGHT' }, { uid: 2, kind: 'chess', chessId: 't_b', row: 11, col: 5, dir: 'UP' }, { uid: 3, kind: 'chess', chessId: 't_c', row: 12, col: 6, dir: 'LEFT' }];
|
||||
const h = makeBattle({ kind: 'boss', defs, players: [
|
||||
{ playerId: 'L', seat: 0, side: 'L', colOffset: 0, units: units.map((u) => ({ ...u })) },
|
||||
{ playerId: 'R', seat: 1, side: 'R', colOffset: 8, units: units.map((u) => ({ ...u, uid: u.uid + 10 })) },
|
||||
] });
|
||||
h.step();
|
||||
const of = (pid, id) => h.b.allyUnits.find((u) => u.ownerId === pid && u.defId === id);
|
||||
assert.deepEqual([of('L', 't_a').tileR, of('L', 't_a').tileC, of('L', 't_a').dir], [3, 4, 'RIGHT']);
|
||||
assert.deepEqual([of('R', 't_a').tileR, of('R', 't_a').tileC, of('R', 't_a').dir], [3, 16, 'LEFT']);
|
||||
assert.deepEqual([of('R', 't_b').tileC, of('R', 't_b').dir], [15, 'UP'], 'UP stays UP');
|
||||
assert.deepEqual([of('R', 't_c').tileC, of('R', 't_c').dir], [14, 'RIGHT'], 'LEFT becomes RIGHT');
|
||||
assert.deepEqual(tilesOf(of('R', 't_a').rangeKeys), [[3, 15], [3, 16]], 'the mirrored range points at the centre');
|
||||
assert.equal(h.b.getPlayer('R').dir, 'LEFT', 'default direction of the mirrored side');
|
||||
assert.equal(h.b.mapDir(h.b.getPlayer('R'), 'DOWN'), 'DOWN');
|
||||
assert.equal(h.b.mapDir(h.b.getPlayer('R'), 'RIGHT', true), 'RIGHT', 'field coordinates are taken as they are');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('professions: 领主 (lord) full damage only on its own tile and the tile in FRONT along its direction', () => {
|
||||
const rec = chessRec({ id: 't_lord', profession: 'WARRIOR', subProfessionId: 'lord', rangeGrid: [[1, 0], [0, 0], [0, 1], [0, 2], [-1, 0]], skill: null });
|
||||
const h = makeBattle({ defs: { chess: { t_lord: rec } }, units: [{ chessId: 't_lord', row: 10, col: 4, dir: 'UP' }] });
|
||||
h.step();
|
||||
const u = h.unit('t_lord');
|
||||
const at = (r, c) => u.profile.dmgMul(h.b, u, { x: c, y: r, blockedBy: null });
|
||||
assert.equal(at(11, 4), 1, 'the tile in front (above) of an UP-facing lord');
|
||||
assert.equal(at(10, 4), 1, 'its own tile');
|
||||
assert.ok(at(10, 5) < 1, 'the tile to its right is not in front');
|
||||
assert.ok(at(12, 4) < 1, 'two tiles ahead: ranged');
|
||||
});
|
||||
|
||||
test('professions: 行刑者 (reaperrange) front bonus follows the direction (own line, at or ahead)', () => {
|
||||
const rec = chessRec({ id: 't_rr', profession: 'SNIPER', subProfessionId: 'reaperrange', rangeGrid: [[0, 0], [0, 1], [0, 2]], skill: null });
|
||||
const h = makeBattle({ defs: { chess: { t_rr: rec } }, units: [{ chessId: 't_rr', row: 11, col: 4, dir: 'DOWN' }] });
|
||||
h.step();
|
||||
const u = h.unit('t_rr');
|
||||
u.profile = { ...u.profile, frontGrid: null };
|
||||
const at = (r, c) => u.profile.dmgMul(h.b, u, { x: c, y: r });
|
||||
assert.ok(at(10, 4) > 1, 'below a DOWN-facing unit: its front line');
|
||||
assert.equal(at(12, 4), 1, 'behind it: no bonus');
|
||||
assert.equal(at(11, 5), 1, 'beside it: no bonus');
|
||||
});
|
||||
|
||||
test('skill CUSTOM_RANGE trigger grids are rotated with the unit', () => {
|
||||
const rec = chessRec({
|
||||
id: 't_trig', profession: 'SNIPER', rangeGrid: [[0, 0]],
|
||||
skill: { kind: 'instant', spCost: 1, initSp: 1, durationType: 'NONE', trigger: { rule: 'CUSTOM_RANGE', grid: [[0, 1], [0, 2]] } },
|
||||
});
|
||||
const run = (dir) => {
|
||||
const h = makeBattle({ defs: { chess: { t_trig: rec }, enemies: { e_d: dummy() } }, units: [{ chessId: 't_trig', row: 9, col: 4, dir }], enemies: [{ key: 'e_d', pos: [11, 4] }], autoFinish: false, timeLimit: 5 });
|
||||
h.run(2);
|
||||
return h.hooksOf('skillStart').length;
|
||||
};
|
||||
assert.ok(run('UP') > 0, 'UP: the enemy two tiles above triggers the skill');
|
||||
assert.equal(run('RIGHT'), 0, 'RIGHT: nothing in the trigger grid');
|
||||
});
|
||||
|
||||
test('token placement grids (findSummonTile) and support geometry follow the owner direction', () => {
|
||||
const rec = chessRec({ id: 't_o', profession: 'SUPPORT', rangeGrid: [[0, 0]], skill: null });
|
||||
const h = makeBattle({ defs: { chess: { t_o: rec, t_n: { ...rec, chessId: 't_n', baseId: 't_n' }, t_s: { ...rec, chessId: 't_s', baseId: 't_s' } } },
|
||||
units: [{ chessId: 't_o', row: 10, col: 4, dir: 'UP' }, { chessId: 't_n', row: 11, col: 4 }, { chessId: 't_s', row: 10, col: 3 }] });
|
||||
h.step();
|
||||
const o = h.unit('t_o');
|
||||
assert.deepEqual(findSummonTile(h.b, o, 'near', { grid: [[0, 2]], at: { x: 4, y: 12 } }), [12, 4], 'grid offset [0,2] = two tiles above');
|
||||
assert.deepEqual(frontTile(o), [11, 4]);
|
||||
assert.deepEqual(frontTile(o, -1), [9, 4]);
|
||||
assert.deepEqual(sideTiles(o), [[10, 3], [10, 5]], 'left hand, right hand');
|
||||
assert.deepEqual(alliesAround(h.b, o, [[0, 1]]).map((a) => a.defId), ['t_n'], 'relative [0,1] = in front');
|
||||
assert.deepEqual(alliesAround(h.b, o, [[1, 0]]).map((a) => a.defId), ['t_s'], 'relative [1,0] = the left hand');
|
||||
assert.equal(alliesAround(h.b, o, N4).length, 2);
|
||||
// tier1 freeTileAround: front first
|
||||
assert.deepEqual(freeTileAround(h.b, o, (r, c) => !(r === 11 && c === 4 && h.b.unitAt(r, c))), [11, 3], 'front first (occupied by t_n)');
|
||||
assert.deepEqual(freeTileAround(h.b, o, (r, c) => !(r === 11 && c === 4)), [11, 3], 'then front-left (its left hand is the lower column)');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('spawnToken inherits the owner direction unless given; spawnDevice takes opts.dir', () => {
|
||||
const rec = chessRec({ id: 't_o', profession: 'SUPPORT', rangeGrid: [[0, 0], [0, 1]], skill: null });
|
||||
const tok = { id: 'tok_t', name: 'tok', position: 'ALL', stats: { maxHp: 100, atk: 0, def: 0, res: 0, aspd: 100, bat: 1, blockCnt: 0, cost: 0, respawnTime: 0 }, rangeGrid: [[0, 0], [0, 1]] };
|
||||
const h = makeBattle({ defs: { chess: { t_o: rec } }, units: [{ chessId: 't_o', row: 10, col: 4, dir: 'DOWN' }] });
|
||||
h.step();
|
||||
const o = h.unit('t_o');
|
||||
const a = h.b.spawnToken(o, 'tok_t', 11, 6, { def: tok });
|
||||
assert.equal(a.dir, 'DOWN');
|
||||
assert.deepEqual(tilesOf(a.rangeKeys), [[10, 6], [11, 6]]);
|
||||
const b = h.b.spawnToken(o, 'tok_t', 12, 6, { def: tok, dir: 'LEFT' });
|
||||
assert.equal(b.dir, 'LEFT');
|
||||
const c = h.b.spawnToken(o, 'tok_t', 12, 7, { def: tok, facing: -1 });
|
||||
assert.equal(c.dir, 'LEFT', 'legacy facing −1 ⇒ LEFT');
|
||||
const d = h.b.spawnDevice('dev_t', 9, 7, { dir: 'UP' });
|
||||
assert.equal(d.dir, 'UP');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('the legacy facing setter maps ±1 to RIGHT / LEFT (content that still assigns facing)', () => {
|
||||
const rec = chessRec({ id: 't_o', rangeGrid: [[0, 0]], skill: null });
|
||||
const h = makeBattle({ defs: { chess: { t_o: rec } }, units: [{ chessId: 't_o', row: 10, col: 4, dir: 'UP' }] });
|
||||
h.step();
|
||||
const u = h.unit('t_o');
|
||||
u.facing = -1;
|
||||
assert.equal(u.dir, 'LEFT');
|
||||
u.facing = 1;
|
||||
assert.equal(u.dir, 'RIGHT');
|
||||
assert.equal(K(10, 4), u.tileR * COLS + u.tileC);
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
// 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');
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
// Fuzz: 200 random battles — random real chess lineups on real stages vs real wave templates — run to completion
|
||||
// with invariants checked along the way (DESIGN §11).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Battle } from '../../server/sim/Battle.js';
|
||||
import { createRng } from '../../server/sim/rng.js';
|
||||
import { getDefaultSource, spawnsFromTemplate } from '../../server/sim/simdata.js';
|
||||
import { checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
const ds = getDefaultSource();
|
||||
const quiet = { error() {}, warn() {}, info() {} };
|
||||
|
||||
function pools() {
|
||||
const chess = ds.chessIds().filter((id) => {
|
||||
const r = ds.rawChess(id);
|
||||
return r && r.stats && !r.isDiy && r.chessType !== 'DIY' && r.visible !== false && !r.isHidden;
|
||||
});
|
||||
const stages = ds.stageIds().filter((id) => { const s = ds.getStage(id); return s && s.raw && s.raw.active !== false && s.rows && s.rows.length === 19; });
|
||||
const waves = ds.waveIds().filter((id) => { const w = ds.getWave(id); return w && (w.kind === 'normal' || (!w.kind && /_0\d$|_h0[1-6]$/.test(id))); });
|
||||
const bossWaves = ds.waveIds().filter((id) => { const w = ds.getWave(id); return w && (w.kind === 'boss' || w.kind === 'hidden'); });
|
||||
const escaped = ds.waveIds().filter((id) => { const w = ds.getWave(id); return w && w.kind === 'escaped'; });
|
||||
return { chess, stages, waves, bossWaves, escaped };
|
||||
}
|
||||
|
||||
function deployTiles(stage, where = 'normal') {
|
||||
const dt = stage.raw.deployTiles?.[where] ?? (where === 'normal' ? { melee: stage.raw.normalDeployTiles?.melee_or_any, rangedOnly: stage.raw.normalDeployTiles?.rangedOnly } : null);
|
||||
return { melee: dt?.melee ?? [], ranged: dt?.rangedOnly ?? [] };
|
||||
}
|
||||
|
||||
function lineup(rng, P, stage, n, where = 'normal') {
|
||||
const { melee, ranged } = deployTiles(stage, where);
|
||||
const used = new Set();
|
||||
const units = [];
|
||||
let uid = 1;
|
||||
for (let i = 0; i < n * 3 && units.length < n; i++) {
|
||||
const id = rng.pick(P.chess);
|
||||
const c = ds.getChess(id);
|
||||
const isRanged = c.position === 'RANGED';
|
||||
const tiles = isRanged ? ranged.concat(melee) : melee;
|
||||
const free = tiles.filter(([r, cc]) => !used.has(r + ',' + cc));
|
||||
if (!free.length) continue;
|
||||
const [r, cc] = rng.pick(free);
|
||||
used.add(r + ',' + cc);
|
||||
units.push({ uid: uid++, kind: 'chess', chessId: id, row: r, col: cc, abs: true, items: [] });
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
test('fuzz: 200 random battles on real data run to completion with invariants', () => {
|
||||
const P = pools();
|
||||
assert.ok(P.chess.length > 100, `chess pool ${P.chess.length}`);
|
||||
assert.ok(P.stages.length >= 8, `stages ${P.stages.length}`);
|
||||
assert.ok(P.waves.length >= 10, `waves ${P.waves.length}`);
|
||||
const N = Number(process.env.SIM_FUZZ_N) || 200; // SIM_FUZZ_N=2000 for a long soak
|
||||
const CHECK_EVERY = Number(process.env.SIM_FUZZ_CHECK_EVERY) || 97;
|
||||
const rng = createRng(Number(process.env.SIM_FUZZ_SEED) || 20260927);
|
||||
const stats = { cleared: 0, timeout: 0, forced: 0, ticks: 0, ms: 0, kinds: {} };
|
||||
for (let i = 0; i < N; i++) {
|
||||
const seed = (rng() * 2 ** 32) >>> 0;
|
||||
const stageId = rng.pick(P.stages);
|
||||
const stage = ds.getStage(stageId);
|
||||
const roll = rng();
|
||||
let kind = 'normal';
|
||||
let waveId = rng.pick(P.waves);
|
||||
if (roll < 0.1 && P.bossWaves.length) { waveId = rng.pick(P.bossWaves); kind = ds.getWave(waveId).kind; }
|
||||
else if (roll < 0.18 && P.escaped.length) { kind = 'unite'; waveId = rng.pick(P.escaped); }
|
||||
const tpl = ds.getWave(waveId);
|
||||
const { routes, spawns, maxPlayTime } = spawnsFromTemplate(tpl, { mods: rng() < 0.5 ? { hpMul: 0.8, atkMul: 0.8 } : null });
|
||||
let players;
|
||||
let sharedBoss = null;
|
||||
if (kind === 'boss' || kind === 'hidden') {
|
||||
players = [{ playerId: 'A', seat: 0, side: 'L', units: lineup(rng, P, stage, 1 + rng.int(8), 'bossLeft') }];
|
||||
if (!tpl.solo) players.push({ playerId: 'B', seat: 1, side: 'R', units: lineup(rng, P, stage, 1 + rng.int(8), 'bossRight') });
|
||||
sharedBoss = { hp: 3e5, maxHp: 3e5, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } };
|
||||
} else if (kind === 'unite') {
|
||||
const a = lineup(rng, P, stage, 1 + rng.int(8));
|
||||
const b = lineup(rng, P, stage, 1 + rng.int(8)).map((u) => ({ ...u, col: u.col + 8 }));
|
||||
players = [{ playerId: 'A', seat: 0, side: 'L', units: a.map((u) => ({ ...u, carryState: { hpPct: 0.2 + rng() * 0.8, sp: rng.int(20), skillActive: rng() < 0.2 } })) }, { playerId: 'B', seat: 1, side: 'L', colOffset: 8, units: b }];
|
||||
} else {
|
||||
players = [{ playerId: 'P', seat: 0, side: 'L', units: lineup(rng, P, stage, 1 + rng.int(9)), bonds: {} }];
|
||||
}
|
||||
stats.kinds[kind] = (stats.kinds[kind] ?? 0) + 1;
|
||||
const bossLike = kind === 'boss' || kind === 'hidden';
|
||||
const timeLimit = bossLike ? Infinity : (maxPlayTime ?? 60);
|
||||
const b = new Battle({
|
||||
seed, kind, stageId, routes, spawns, timeLimit, players, sharedBoss, logger: quiet, quiet: true,
|
||||
recordEvents: i % 4 === 0, modeId: 'mode_multi_normal', round: 1 + rng.int(13),
|
||||
});
|
||||
const t0 = performance.now();
|
||||
let n = 0;
|
||||
const hardCap = bossLike ? 180 : timeLimit + 1;
|
||||
while (!b.finished) {
|
||||
b.step();
|
||||
n++;
|
||||
if (n % CHECK_EVERY === 0) checkInvariants(b);
|
||||
if (i % 4 === 0 && n % 3 === 0) { b.snapshot(); b.drainEvents(); }
|
||||
if (b.time > hardCap) { if (bossLike) b.forceEnd('forced'); else break; }
|
||||
}
|
||||
stats.ms += performance.now() - t0;
|
||||
stats.ticks += n;
|
||||
assert.ok(b.finished, `battle ${i} (${kind} ${stageId} ${waveId} seed ${seed}) terminated`);
|
||||
if (!bossLike) assert.ok(b.time <= timeLimit + 1, `battle ${i} ended in time (${b.time})`);
|
||||
checkInvariants(b);
|
||||
assert.equal(b.internalErrorCount ?? 0, 0, `battle ${i} internal errors: ${JSON.stringify(b.errors.slice(0, 2))}`);
|
||||
assert.equal(b.errors.length, 0, `battle ${i} errors: ${JSON.stringify(b.errors.slice(0, 2))}`);
|
||||
const r = b.result();
|
||||
stats[r.reason]++;
|
||||
for (const [pid, pp] of Object.entries(r.perPlayer)) {
|
||||
assert.ok(pp.killed <= pp.total || kind === 'unite', `${pid} killed ≤ total`);
|
||||
for (const k of ['damageDealt', 'healingDone', 'bossDamage']) assert.ok(Number.isFinite(pp[k]) && pp[k] >= 0, `${k} finite`);
|
||||
if (r.reason === 'cleared' && kind === 'normal') assert.equal(pp.killed + pp.leaked.filter((l) => l.counted).length, pp.total, 'every counted enemy killed or leaked');
|
||||
}
|
||||
JSON.stringify(r);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`fuzz: ${JSON.stringify(stats.kinds)} cleared=${stats.cleared} timeout=${stats.timeout} forced=${stats.forced} avg ${(stats.ms / stats.ticks * 1000).toFixed(1)} µs/tick`);
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
// test/sim/loadout-summons.test.js — summons follow their owner's operator loadout during the battle (DESIGN §16,
|
||||
// DATA.md §14): Battle (_createAllyFromInput passes the unit's loadout, _tokenDef / spawnToken / tokenDef use the
|
||||
// owner's, producesToken refuses summons the selected skill does not make), professions.js (dollkeeper substitute
|
||||
// token) and content/tokens.js (tokenSources / variantOf with the owner's loadout; the skill-summon fallback also runs
|
||||
// when only the selected skill falls back to the generic spec).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DataSource, getDefaultSource, hasGeneratedData } from '../../server/sim/simdata.js';
|
||||
import { Battle } from '../../server/sim/Battle.js';
|
||||
import { buildBattleSpec, createBattleFromSpec, resultDigest, jsonClone } from '../../server/sim/spec.js';
|
||||
import { makeBattle } from '../helpers/battleHarness.js';
|
||||
|
||||
const skip = !hasGeneratedData() && 'no generated data (run node tools/build-data.mjs)';
|
||||
const ds = getDefaultSource();
|
||||
const C = ds.raw.chess;
|
||||
const T = ds.raw.tokens;
|
||||
/** A fresh browser-like DataSource (no research fallback, empty caches). */
|
||||
const freshDs = () => new DataSource(ds.raw, null);
|
||||
|
||||
const MLYSS = 'chess_char_6_11_b', WTRMAN = 'token_10030_mlyss_wtrman'; // 缪尔赛思 精锐: talent summon 流形
|
||||
const CATHY = 'chess_char_4_11_a', CATSLD = 'token_10041_cathy_catsld'; // 凯瑟琳: talent devices (token skill per slot)
|
||||
const HEMO = 'chess_char_2_02_a', DRONE = 'token_10000_silent_healrb'; // 赫默: S2 (default) drone, S1 none
|
||||
const ROSMON = 'chess_char_6_12_a', ROSMON_B = 'chess_char_6_12_b', GEAR = 'token_10012_rosmon_shield'; // 迷迭香: S3 gear
|
||||
const SWIRE = 'chess_char_3_04_b', TRAP = 'token_10031_swire2_gdtrap'; // 琳琅诗怀雅: S2 (default) bombs, S1/S3 none
|
||||
const KAZEMA = 'chess_char_2_11_b', SHADOW = 'token_10022_kazema_shadow'; // 风丸 (dollkeeper)
|
||||
|
||||
const mk = (playerId, seat, colOffset, units) => ({ playerId, seat, side: 'L', colOffset, units, bonds: {}, playerEffects: [] });
|
||||
const uniteSpec = (p1, p2, seed = 11) => buildBattleSpec({
|
||||
battleId: 'ls', fieldId: 'u:p1', kind: 'unite', seed, modeId: 'mode_multi_normal', round: 4, stageId: 'act2autochess_m01', timeLimit: 60,
|
||||
players: [mk('p1', 0, 0, p1), mk('p2', 1, 8, p2)], spawns: [], routes: [],
|
||||
});
|
||||
/** A normal-field spec with one player's units and a real wave (act1autochess_01). */
|
||||
function normalSpec(units, { seed = 4242 } = {}) {
|
||||
const tpl = ds.getWave('act1autochess_01');
|
||||
const spawns = tpl.spawns.filter((s) => !s.action && !s.slot).map((s) => ({ time: s.time, enemyKey: s.key, routeIndex: s.routeIndex, count: s.count, interval: s.interval }));
|
||||
return buildBattleSpec({
|
||||
battleId: 'ls1', fieldId: 'n:p1', kind: 'normal', seed, modeId: 'mode_multi_normal', round: 1, stageId: 'act2autochess_m01',
|
||||
timeLimit: 90, players: [mk('p1', 0, 0, units)], spawns, routes: tpl.routes, flags: {}, waveId: 'act1autochess_01',
|
||||
});
|
||||
}
|
||||
const summonsOf = (b, owner, tokenId) => b.allyUnits.filter((u) => u.kind === 'token' && u.uid == null && u.ownerUnit === owner && u.defId === tokenId);
|
||||
const opOf = (b, pid, uid) => b.allyUnits.find((u) => u.kind === 'op' && u.player.playerId === pid && u.uid === uid);
|
||||
/** Run until `pred` holds (max `sec` s). */
|
||||
function runUntil(b, pred, sec = 10) {
|
||||
for (let i = 0; i < sec * 30 && !pred() && !b.finished; i++) b.step();
|
||||
return pred();
|
||||
}
|
||||
|
||||
test('two players in one field, same chess, different skills / modules: summons spawned mid-battle get each owner\'s variant', { skip }, () => {
|
||||
const spec = uniteSpec(
|
||||
[{ uid: 1, chessId: MLYSS, row: 10, col: 4, dir: 'RIGHT', skillIndex: 0, moduleId: 'uniequip_003_mlyss' }, { uid: 2, chessId: CATHY, row: 11, col: 4, dir: 'RIGHT', skillIndex: 0 }],
|
||||
// p2: MLYSS with another explicit choice, CATHY without loadout fields (= the default, never p1's choice)
|
||||
[{ uid: 1, chessId: MLYSS, row: 10, col: 4, dir: 'RIGHT', skillIndex: 1, moduleId: 'none' }, { uid: 2, chessId: CATHY, row: 11, col: 4, dir: 'RIGHT' }],
|
||||
);
|
||||
const b = createBattleFromSpec(spec, freshDs(), { recordEvents: false, quiet: true });
|
||||
assert.deepEqual([...b.data.loadoutConflicts].sort(), [CATHY, MLYSS].sort(), 'id-only lookups would give p2 p1\'s choices');
|
||||
const m1 = opOf(b, 'p1', 1), m2 = opOf(b, 'p2', 1), c1 = opOf(b, 'p1', 2), c2 = opOf(b, 'p2', 2);
|
||||
// no board pieces: the 流形 (援军) and 凯瑟琳's devices are summoned during the battle
|
||||
assert.ok(runUntil(b, () => [m1, m2, c1, c2].every((o) => summonsOf(b, o, o === m1 || o === m2 ? WTRMAN : CATSLD).length > 0), 5), 'every owner summoned');
|
||||
const d = freshDs();
|
||||
const w1 = summonsOf(b, m1, WTRMAN)[0], w2 = summonsOf(b, m2, WTRMAN)[0];
|
||||
assert.equal(w1.def.skill.id, 'sktok_mlyss_wtrman_1', 'p1 流形: its owner\'s S1');
|
||||
assert.equal(w2.def.skill.id, 'sktok_mlyss_wtrman_2', 'p2 流形: its owner\'s S2, not p1\'s S1');
|
||||
assert.ok(w1.def.talents.some((t) => t.bb.taunt_level === 1), 'p1 流形: module 003 token talent');
|
||||
assert.ok(!w2.def.talents.some((t) => t.bb.taunt_level === 1), 'p2 流形 (no module): no module talent');
|
||||
assert.deepEqual(w2.def.talents, d.getToken(WTRMAN, MLYSS, { skillIndex: 1, moduleId: 'none' }).talents);
|
||||
assert.equal(w2.base.maxHp, d.getToken(WTRMAN, MLYSS, { skillIndex: 1, moduleId: 'none' }).stats.maxHp);
|
||||
for (const t of summonsOf(b, c1, CATSLD)) assert.equal(t.def.skill.id, 'sktok_cathy_catsld_1', 'p1 devices: S1 token skill');
|
||||
for (const t of summonsOf(b, c2, CATSLD)) assert.equal(t.def.skill.id, 'sktok_cathy_catsld_2', 'p2 devices (default loadout): S2 token skill');
|
||||
// Battle.tokenDef / spawnToken (content summons) resolve the owner's loadout as well
|
||||
assert.equal(b.tokenDef(WTRMAN, m1).skill.id, 'sktok_mlyss_wtrman_1');
|
||||
assert.equal(b.tokenDef(WTRMAN, m2).skill.id, 'sktok_mlyss_wtrman_2');
|
||||
assert.equal(b.tokenDef(WTRMAN, MLYSS).skill.id, 'sktok_mlyss_wtrman_1', 'a chess id alone: the view\'s (first) loadout');
|
||||
const free = [[9, 5], [9, 6], [12, 5], [12, 6], [9, 7], [12, 7]].map(([r, c]) => [r, c + 8]).find(([r, c]) => !b.unitAt(r, c) && b.grid.canStand(r, c, { ranged: true }));
|
||||
const extra = b.spawnToken(m2, WTRMAN, free[0], free[1]);
|
||||
assert.ok(extra, 'spawned');
|
||||
assert.equal(extra.def.skill.id, 'sktok_mlyss_wtrman_2');
|
||||
// deterministic whatever the source's cache state (server re-simulation / the browser sim use their own sources)
|
||||
const digest = (src) => resultDigest(createBattleFromSpec(jsonClone(spec), src, { recordEvents: false, quiet: true }).runToEnd(120));
|
||||
assert.equal(digest(freshDs()).hash, digest(ds).hash);
|
||||
});
|
||||
|
||||
test('Battle._createAllyFromInput asks the data for the unit\'s own loadout (no field = the default)', { skip }, () => {
|
||||
const src = freshDs();
|
||||
const calls = [];
|
||||
const spy = Object.create(src);
|
||||
spy.getChess = (id, lo = null) => { calls.push([id, lo]); return src.getChess(id, lo); };
|
||||
const players = [
|
||||
mk('p1', 0, 0, [{ uid: 1, chessId: MLYSS, row: 10, col: 4, skillIndex: 0, moduleId: 'uniequip_003_mlyss' }]),
|
||||
mk('p2', 1, 8, [{ uid: 1, chessId: MLYSS, row: 10, col: 4 }]),
|
||||
];
|
||||
const b = new Battle({ seed: 3, kind: 'unite', stageId: 'act2autochess_m01', timeLimit: 30, players, spawns: [], routes: [], data: spy, recordEvents: false, quiet: true, content: 'none' });
|
||||
const mine = calls.filter(([id]) => id === MLYSS);
|
||||
assert.deepEqual(mine[0], [MLYSS, { skillIndex: 0, moduleId: 'uniequip_003_mlyss' }]);
|
||||
assert.deepEqual(mine[1], [MLYSS, { skillIndex: null, moduleId: null }], 'an entry without fields asks for the default');
|
||||
assert.equal(opOf(b, 'p1', 1).def.loadout.moduleId, 'uniequip_003_mlyss');
|
||||
assert.equal(opOf(b, 'p2', 1).def.loadout.isDefault, true);
|
||||
});
|
||||
|
||||
test('producesToken / spawnToken: a summon the owner\'s selected skill does not make is refused (data `sources`)', { skip }, () => {
|
||||
const run = (skillIndex) => {
|
||||
const units = [{ uid: 1, chessId: HEMO, row: 10, col: 5, ...(skillIndex != null ? { skillIndex } : {}) }];
|
||||
const h = makeBattle({ players: [mk('p1', 0, 0, units)], data: freshDs(), content: 'none' });
|
||||
h.step();
|
||||
return [h.b, h.b.allyUnits.find((u) => u.uid === 1)];
|
||||
};
|
||||
const [b1, s1] = run(0);
|
||||
assert.deepEqual(b1.tokenDef(DRONE, s1).sources, ['display'], '赫默 S1: the drone is only displayed');
|
||||
assert.equal(b1.producesToken(s1, DRONE), false);
|
||||
assert.equal(b1.spawnToken(s1, DRONE, 10, 6), null, 'refused');
|
||||
assert.equal(b1.producesToken(s1, TRAP), true, 'another chess\'s token: the data does not tell ⇒ allowed');
|
||||
assert.ok(b1.spawnToken(s1, DRONE, 10, 6, { anySource: true }), 'opts.anySource overrides');
|
||||
assert.ok(b1.spawnToken(s1, DRONE, 10, 7, { def: { name: 'inline', stats: { maxHp: 10 } } }), 'an inline def is never refused');
|
||||
assert.equal(b1.producesToken(b1.getPlayer('p1'), DRONE), true, 'player-owned summons are not policed');
|
||||
const [b2, s2] = run(null);
|
||||
assert.equal(b2.producesToken(s2, DRONE), true, '赫默 S2 (default) makes the drone');
|
||||
assert.ok(b2.spawnToken(s2, DRONE, 10, 6));
|
||||
// under the DEFAULT skill the kit stays the authority: 迷迭香 S2 does not make 战术装备 (data), content may still
|
||||
const h = makeBattle({ players: [mk('p1', 0, 0, [{ uid: 1, chessId: ROSMON, row: 10, col: 5 }])], data: freshDs(), content: 'none' });
|
||||
h.step();
|
||||
const r = h.b.allyUnits.find((u) => u.uid === 1);
|
||||
assert.equal(h.b.producesToken(r, GEAR), false);
|
||||
assert.ok(h.b.spawnToken(r, GEAR, 10, 6), 'default skill: not policed');
|
||||
});
|
||||
|
||||
test('琳琅诗怀雅 (real content): S1 / S3 summon no 香槟炸弹; S2 (default) does', { skip }, () => {
|
||||
const bombs = (skillIndex) => {
|
||||
const b = createBattleFromSpec(normalSpec([
|
||||
{ uid: 1, chessId: SWIRE, row: 10, col: 6, dir: 'RIGHT', ...(skillIndex != null ? { skillIndex } : {}) },
|
||||
{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 5, dir: 'RIGHT' },
|
||||
], { seed: 5 }), freshDs(), { recordEvents: false, quiet: true });
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < 90 * 30 && !b.finished; i++) { b.step(); for (const u of b.allyUnits) if (u.defId === TRAP) seen.add(u); }
|
||||
return seen.size;
|
||||
};
|
||||
assert.ok(bombs(null) > 0, 'S2 (default): bombs');
|
||||
assert.equal(bombs(0), 0, 'S1: none');
|
||||
assert.equal(bombs(2), 0, 'S3: none');
|
||||
});
|
||||
|
||||
test('spawnToken guard end to end: a kit install hook written for the default skill summons nothing under a skill that does not make the token', { skip }, () => {
|
||||
// an injected kit (independent of the real tier3 kit, which now has its own S1 / S3 specs): its `install` runs under
|
||||
// EVERY selected skill and drops a 香槟炸弹 once she stands, as the old 琳琅诗怀雅 kit's default-skill hooks did
|
||||
const kit = () => ({
|
||||
skill: { kind: 'duration', duration: 5, mods: {} }, talents: [],
|
||||
install(battle, unit) {
|
||||
battle.on('deploy', (c) => { if (c.unit === unit) battle.after(0.1, () => battle.spawnToken(unit, TRAP, unit.tileR, unit.tileC + 1), { owner: unit }); }, { owner: unit });
|
||||
},
|
||||
});
|
||||
const run = (lo) => {
|
||||
const h = makeBattle({ players: [mk('p1', 0, 0, [{ uid: 1, chessId: SWIRE, row: 10, col: 5, ...lo }])], data: freshDs(), kits: { [SWIRE]: kit } });
|
||||
h.run(0.5);
|
||||
const u = h.b.allyUnits.find((x) => x.uid === 1);
|
||||
assert.ok(u.alive && u.deployed);
|
||||
return summonsOf(h.b, u, TRAP).length;
|
||||
};
|
||||
assert.equal(run({}), 1, 'S2 (default): the kit decides');
|
||||
assert.equal(run({ skillIndex: 0 }), 0, 'S1: refused');
|
||||
assert.equal(run({ skillIndex: 2 }), 0, 'S3: refused');
|
||||
});
|
||||
|
||||
/** 迷迭香 alone on a real normal field (a wave, so the range covers path tiles); returns [harness battle, unit]. */
|
||||
function rosmon(chessId, lo, kits) {
|
||||
const spec = normalSpec([{ uid: 1, chessId, row: 10, col: 6, dir: 'RIGHT', ...lo }], { seed: 9 });
|
||||
const b = kits
|
||||
? new Battle({ ...spec, spawns: spec.spawns, data: freshDs(), kits, recordEvents: false, quiet: true })
|
||||
: createBattleFromSpec(spec, freshDs(), { recordEvents: false, quiet: true });
|
||||
b.step();
|
||||
const u = b.allyUnits.find((x) => x.uid === 1);
|
||||
assert.ok(u.alive && u.deployed);
|
||||
return [b, u];
|
||||
}
|
||||
|
||||
/** 迷迭香 S3 cast once: her two 战术装备 stand inside her attack range on melee tiles with the S3 token skill. */
|
||||
function assertRosmonS3Gears(b, u) {
|
||||
assert.equal(u.skill.id, 'skchr_rosmon_3');
|
||||
assert.deepEqual(b.tokenDef(GEAR, u).sources, ['skill', 'display']);
|
||||
assert.equal(summonsOf(b, u, GEAR).length, 0);
|
||||
assert.ok(u.skill.activate('test', { free: true }));
|
||||
b.step();
|
||||
const gears = summonsOf(b, u, GEAR);
|
||||
assert.equal(gears.length, 2, '"部署两个战术装备"');
|
||||
const range = new Set(u.rangeKeys);
|
||||
for (const g of gears) {
|
||||
assert.ok(g.alive && range.has(g.tileR * 21 + g.tileC), 'inside her attack range');
|
||||
assert.ok(b.grid.canStand(g.tileR, g.tileC, { ranged: false }), 'on a melee tile');
|
||||
assert.equal(g.def.skill.bb.stun, 2, 'the S3 token skill (stun 2 s)');
|
||||
}
|
||||
}
|
||||
|
||||
test('迷迭香 S3 (non-default skill that summons): two 战术装备 at skill start — real content, whichever spec runs it', { skip }, () => {
|
||||
// behaviour only: holds while her S3 runs the generic spec + tokens.js fallback AND once a hand-authored S3 lands
|
||||
const [b, u] = rosmon(ROSMON, { skillIndex: 2 });
|
||||
assertRosmonS3Gears(b, u);
|
||||
});
|
||||
|
||||
test('迷迭香 S3 via the tokens.js fallback: a hand-authored kit without an S3 spec (generic spec for the selected skill)', { skip }, () => {
|
||||
// an injected kit, so this path stays covered after the real kit gains `skills.skchr_rosmon_3`
|
||||
const [b, u] = rosmon(ROSMON, { skillIndex: 2 }, { [ROSMON]: () => ({ skill: { kind: 'passive' }, talents: [] }) });
|
||||
assert.equal(u.kit.generic, undefined, 'a hand-authored kit…');
|
||||
assert.equal(u.kit.skillSource, 'generic', '…whose selected skill runs the generic spec');
|
||||
assertRosmonS3Gears(b, u);
|
||||
});
|
||||
|
||||
test('迷迭香 S1 / S2 (default) summon no 战术装备; a kit `skills` entry for S3 takes the summon over', { skip }, () => {
|
||||
for (const lo of [{ skillIndex: 0 }, {}]) {
|
||||
const [b, u] = rosmon(ROSMON, lo);
|
||||
assert.ok(u.skill.activate('test', { free: true }));
|
||||
b.step();
|
||||
assert.equal(summonsOf(b, u, GEAR).length, 0, `S${u.def.loadout.skillIndex + 1}: none`);
|
||||
}
|
||||
const s3 = { kind: 'duration', duration: 5, mods: {} };
|
||||
const [b, u] = rosmon(ROSMON, { skillIndex: 2 }, { [ROSMON]: () => ({ skill: null, skills: { skchr_rosmon_3: s3 }, talents: [] }) });
|
||||
assert.equal(u.kit.skillSource, 'skills');
|
||||
assert.ok(u.skill.activate('test', { free: true }));
|
||||
assert.equal(summonsOf(b, u, GEAR).length, 0, 'the kit\'s own S3 spec: no fallback summon');
|
||||
});
|
||||
|
||||
test('tokens.js variantOf: the deploy limit of a summon follows the owner\'s module (byModule)', { skip }, () => {
|
||||
for (const moduleId of ['none', 'uniequip_003_rosmon']) {
|
||||
const [b, u] = rosmon(ROSMON_B, { skillIndex: 2, moduleId });
|
||||
const v = T[GEAR].variants[ROSMON_B];
|
||||
const limit = (moduleId === 'none' ? v.byModule.none : v.byModule[moduleId] ?? v).stats.deployLimit;
|
||||
assert.ok(u.skill.activate('test', { free: true }));
|
||||
u.skill.end('test');
|
||||
b.step();
|
||||
assert.ok(u.skill.activate('test', { free: true }));
|
||||
const standing = summonsOf(b, u, GEAR).filter((g) => g.alive).length;
|
||||
assert.equal(standing, Math.min(4, limit), `${moduleId}: deployLimit ${limit}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('tokens.js tokenSources: the generic-kit skill-summon fallback reads the owner loadout (赫默 S1 makes no drone)', { skip }, () => {
|
||||
const run = (lo) => {
|
||||
const h = makeBattle({ players: [mk('p1', 0, 0, [{ uid: 1, chessId: HEMO, row: 10, col: 5, ...lo }])], data: freshDs(), kits: { [HEMO]: () => null } });
|
||||
h.step();
|
||||
const u = h.b.allyUnits.find((x) => x.uid === 1);
|
||||
assert.equal(u.kit.generic, true);
|
||||
h.b.producesToken = () => true; // only the fallback's own check decides here
|
||||
assert.ok(u.skill.activate('test', { free: true }));
|
||||
return summonsOf(h.b, u, DRONE).length;
|
||||
};
|
||||
assert.equal(run({}), 1, 'S2 (default): the drone');
|
||||
assert.equal(run({ skillIndex: 0 }), 0, 'S1: none');
|
||||
});
|
||||
|
||||
test('dollkeeper: the substitute token is looked up with the unit\'s own loadout', { skip }, () => {
|
||||
const players = [mk('p1', 0, 0, [{ uid: 1, chessId: KAZEMA, row: 10, col: 5, skillIndex: 0, moduleId: 'none' }])];
|
||||
const h = makeBattle({ players, data: freshDs() });
|
||||
h.step();
|
||||
const u = h.b.allyUnits.find((x) => x.uid === 1);
|
||||
const calls = [];
|
||||
const view = h.b.data;
|
||||
const orig = view.getToken.bind(view);
|
||||
view.getToken = (...a) => { calls.push(a); return orig(...a); };
|
||||
h.b.dealDamage(null, u, { amount: 1e7, type: 'true' });
|
||||
assert.ok(u.alive && u.trait.doll, 'substitute');
|
||||
const call = calls.find(([id]) => id === SHADOW);
|
||||
assert.ok(call, 'the 纸偶 was looked up');
|
||||
assert.equal(call[1], KAZEMA);
|
||||
assert.equal(call[2], u.def.loadout, 'with the owner\'s loadout');
|
||||
assert.equal(call[2].skillIndex, 0);
|
||||
assert.equal(call[2].moduleId, 'none');
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// test/sim/loadout.browser.test.js — operator loadouts (DESIGN §16) in the BROWSER sim: headless Chrome loads the
|
||||
// served /sim/ modules and /data/*.json through the client's own loader (public/js/battle/runner.js loadBrowserSim),
|
||||
// builds a BattleSpec whose units carry skillIndex / moduleId and must resolve the same defs and produce the same result
|
||||
// digest as Node (server re-simulation / verification depend on it).
|
||||
//
|
||||
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/sim/loadout.browser.test.js (or SIM_E2E=1)
|
||||
// Run browser test files one at a time. Chrome path: $CHROME_PATH or the macOS default.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { buildBattleSpec, createBattleFromSpec, resultDigest } from '../../server/sim/spec.js';
|
||||
import { DataSource, getDefaultSource } from '../../server/sim/simdata.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const enabled = (process.env.RENDER_E2E === '1' || process.env.SIM_E2E === '1') && existsSync(CHROME);
|
||||
const skip = enabled ? false : 'set RENDER_E2E=1 or SIM_E2E=1 (needs Chrome)';
|
||||
|
||||
describe('operator loadouts in the browser sim', { skip }, () => {
|
||||
let srv, browser;
|
||||
before(async () => {
|
||||
const require = createRequire(path.join(ROOT, 'package.json'));
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run'] });
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
test('a spec with loadouts resolves the same defs and gives the same digest in Chrome as in Node', async () => {
|
||||
const ds = getDefaultSource();
|
||||
const tpl = ds.getWave('act1autochess_03');
|
||||
const spec = buildBattleSpec({
|
||||
battleId: 'lo1', fieldId: 'n:p1', kind: 'normal', seed: 90210, modeId: 'mode_multi_normal', round: 3, stageId: 'act2autochess_m01',
|
||||
timeLimit: 110, players: [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, playerEffects: [], units: [
|
||||
{ uid: 1, chessId: 'chess_char_1_01_a', row: 10, col: 5, dir: 'RIGHT', skillIndex: 0 },
|
||||
{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 8, dir: 'RIGHT', skillIndex: 0 },
|
||||
{ uid: 3, chessId: 'chess_char_6_11_b', row: 11, col: 4, dir: 'RIGHT', skillIndex: 1, moduleId: 'uniequip_003_mlyss' },
|
||||
{ uid: 4, kind: 'token', tokenId: 'token_10030_mlyss_wtrman', ownerUid: 3, row: 12, col: 6 },
|
||||
] }],
|
||||
spawns: tpl.spawns.filter((s) => !s.action && !s.slot).map((s) => ({ time: s.time, enemyKey: s.key, routeIndex: s.routeIndex, count: s.count, interval: s.interval })),
|
||||
routes: tpl.routes, flags: {}, waveId: 'act1autochess_03',
|
||||
});
|
||||
const nb = createBattleFromSpec(spec, new DataSource(ds.raw, null), { quiet: true, recordEvents: false });
|
||||
const nodeUnits = nb.allyUnits.map((u) => [u.uid, u.def.skill?.id ?? null, u.base.maxHp, u.base.atk]);
|
||||
const nodeHash = resultDigest(nb.runToEnd(4000)).hash;
|
||||
|
||||
const page = await browser.newPage();
|
||||
const problems = [];
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
page.on('response', (r) => { if (r.status() >= 400 && !/favicon/.test(r.url())) problems.push(`HTTP ${r.status()} ${r.url()}`); });
|
||||
await page.goto(`http://127.0.0.1:${srv.port}/sim/spec.js`);
|
||||
const out = await page.evaluate(async (s) => {
|
||||
const { loadBrowserSim } = await import('/js/battle/runner.js');
|
||||
const { spec: S, ds } = await loadBrowserSim();
|
||||
const b = S.createBattleFromSpec(s, ds, { quiet: true, recordEvents: false });
|
||||
const units = b.allyUnits.map((u) => [u.uid, u.def.skill?.id ?? null, u.base.maxHp, u.base.atk]);
|
||||
return { units, hash: S.resultDigest(b.runToEnd(4000)).hash };
|
||||
}, spec);
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
assert.deepEqual(out.units, nodeUnits, 'same loadout defs in the browser');
|
||||
assert.equal(nodeUnits[0][1], 'skchr_inside_1');
|
||||
assert.equal(nodeUnits[2][1], 'skchr_mlyss_2');
|
||||
assert.equal(nodeUnits[3][1], 'sktok_mlyss_wtrman_2');
|
||||
assert.equal(out.hash, nodeHash, 'browser and Node results are identical');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
// test/sim/loadout.test.js — operator loadouts in the sim (DESIGN §16): resolving (chessId, skillIndex, moduleId) defs
|
||||
// (simdata), the per-unit loadout fields of a BattleSpec (spec.js), the kit loader's skill selection (content/index.js)
|
||||
// and the kit coverage report (tools/kit-coverage.mjs).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DataSource, getDefaultSource, resolveLoadout, loadoutRecord, hasGeneratedData, isLoadoutView } from '../../server/sim/simdata.js';
|
||||
import { Battle } from '../../server/sim/Battle.js';
|
||||
import { loadoutOptions, resolveLoadout as resolveSeatLoadout } from '../../shared/protocol.js';
|
||||
import { buildBattleSpec, createBattleFromSpec, resultDigest, withUnitLoadouts, jsonClone } from '../../server/sim/spec.js';
|
||||
import { setupUnitKit, selectSkillSpec, skillSpecSource } from '../../server/sim/content/index.js';
|
||||
import { genericKit } from '../../server/sim/content/generic.js';
|
||||
import { makeBattle } from '../helpers/battleHarness.js';
|
||||
import { kitCoverage } from '../../tools/kit-coverage.mjs';
|
||||
|
||||
const skip = !hasGeneratedData() && 'no generated data (run node tools/build-data.mjs)';
|
||||
const ds = getDefaultSource();
|
||||
const C = ds.raw.chess;
|
||||
/** A fresh browser-like DataSource (no research fallback, empty caches). */
|
||||
const freshDs = () => new DataSource(ds.raw, null);
|
||||
|
||||
const INSIDE = 'chess_char_1_01_a'; // 隐现 (T1, E1: S1–S2, default S2)
|
||||
const MLYSS = 'chess_char_6_11_b'; // 缪尔赛思 精锐 (S1–S3, default S3; modules 002 default / 003)
|
||||
const WTRMAN = 'token_10030_mlyss_wtrman';
|
||||
|
||||
test('resolveLoadout: legal choices resolve, anything else falls back to the default', { skip }, () => {
|
||||
const n = C[INSIDE];
|
||||
assert.deepEqual(resolveLoadout(n, null), { skillIndex: 1, moduleId: null, skillIsDefault: true, moduleIsDefault: true, isDefault: true });
|
||||
assert.equal(resolveLoadout(n, { skillIndex: 0 }).skillIndex, 0);
|
||||
assert.equal(resolveLoadout(n, { skill: 0 }).skillIndex, 0, 'client shape { skill, module } accepted');
|
||||
assert.equal(resolveLoadout(n, { skillIndex: 2 }).skillIndex, 1, 'S3 is not unlocked at E1 → default');
|
||||
for (const bad of [-1, 1.5, '0', null, 9]) assert.equal(resolveLoadout(n, { skillIndex: bad }).skillIndex, 1, `skillIndex ${bad}`);
|
||||
assert.equal(resolveLoadout(n, { moduleId: 'uniequip_002_inside' }).moduleId, null, 'normal chess have no module choice');
|
||||
const g = C[MLYSS];
|
||||
assert.equal(resolveLoadout(g, {}).moduleId, 'uniequip_002_mlyss');
|
||||
assert.equal(resolveLoadout(g, { moduleId: 'none' }).moduleId, 'none');
|
||||
assert.equal(resolveLoadout(g, { module: 'uniequip_003_mlyss' }).moduleId, 'uniequip_003_mlyss');
|
||||
assert.equal(resolveLoadout(g, { moduleId: 'uniequip_002_inside' }).moduleId, 'uniequip_002_mlyss', "another character's module → default");
|
||||
assert.equal(resolveLoadout(g, { moduleId: 'uniequip_001_mlyss' }).moduleId, 'uniequip_002_mlyss', 'INITIAL is not a choice ("none" is)');
|
||||
assert.equal(resolveLoadout(g, { skillIndex: 2, moduleId: 'uniequip_002_mlyss' }).isDefault, true);
|
||||
// research-shaped record (no skills list): always the default
|
||||
assert.equal(resolveLoadout({ skill: { skillId: 'x', index: 0 } }, { skillIndex: 1 }).skillIndex, 0);
|
||||
assert.equal(loadoutRecord(n, resolveLoadout(n, null)), n, 'default loadout = the record itself');
|
||||
});
|
||||
|
||||
test('every choice the lobby accepts (shared loadoutOptions) resolves to itself in the sim; defaults agree', { skip }, () => {
|
||||
const d = freshDs();
|
||||
let n = 0;
|
||||
for (const c of Object.values(C)) {
|
||||
if (c.isGolden || !c.visible) continue;
|
||||
const g = C[c.goldenId];
|
||||
const opt = loadoutOptions(c, g);
|
||||
assert.equal(opt.defaultSkill, c.skill.index, `${c.chessId}: default skill`);
|
||||
assert.equal(opt.defaultModule, resolveLoadout(g, null).moduleId, `${c.chessId}: default module`);
|
||||
for (const skill of opt.skills) {
|
||||
for (const module of opt.modules) {
|
||||
const seat = { [c.chessId]: { skill, module } };
|
||||
for (const rec of [c, g]) {
|
||||
const lo = resolveSeatLoadout(seat, rec, (id) => C[id]); // what PlayerState puts on the unit
|
||||
const def = d.getChess(rec.chessId, lo);
|
||||
assert.equal(def.loadout.skillIndex, skill, `${rec.chessId} S${skill + 1}`);
|
||||
assert.equal(def.skill.id, rec.skills.find((s) => s.index === skill).skillId);
|
||||
if (rec.isGolden) assert.equal(def.loadout.moduleId, module, `${rec.chessId} ${module}`);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(n > 1000, `${n} unit defs resolved`);
|
||||
});
|
||||
|
||||
test('getChess(id, loadout): selected skill (bb, SP, trigger) and module (stats, trait, talents); cached; default = same def', { skip }, () => {
|
||||
const d = freshDs();
|
||||
const def0 = d.getChess(INSIDE);
|
||||
assert.equal(d.getChess(INSIDE, { skillIndex: 1 }), def0, 'explicit default loadout = the default def');
|
||||
assert.equal(d.getChess(INSIDE, { skillIndex: 7 }), def0, 'illegal loadout = the default def');
|
||||
const s1 = d.getChess(INSIDE, { skillIndex: 0 });
|
||||
assert.notEqual(s1, def0);
|
||||
assert.equal(d.getChess(INSIDE, { skillIndex: 0 }), s1, 'variant defs are cached');
|
||||
assert.equal(s1.id, INSIDE, 'def id stays the chess id');
|
||||
assert.equal(s1.skill.id, 'skchr_inside_1');
|
||||
assert.deepEqual(s1.skill.bb, C[INSIDE].skills[0].bb);
|
||||
assert.equal(s1.skill.spCost, C[INSIDE].skills[0].spCost);
|
||||
assert.deepEqual(s1.loadout, { skillIndex: 0, moduleId: null, skillIsDefault: false, moduleIsDefault: true, isDefault: false });
|
||||
assert.deepEqual(s1.stats, def0.stats, 'a skill choice never changes stats');
|
||||
assert.ok(Object.isFrozen(s1) && Object.isFrozen(s1.skill.bb), 'variant defs are frozen like every def');
|
||||
assert.equal(C[INSIDE].skill.skillId, 'skchr_inside_2', 'the raw record is never mutated');
|
||||
// per-index trigger: 角峰 S1 casts when hit (TANK row, skill 1 only); S2 is DEFAULT
|
||||
assert.equal(d.getChess('chess_char_1_02_a', { skillIndex: 0 }).skill.trigger.rule, 'TAKE_DAMAGE');
|
||||
assert.equal(d.getChess('chess_char_1_02_a', { skillIndex: 1 }).skill.trigger.rule, 'DEFAULT');
|
||||
|
||||
// modules on the elite: statsBase + module attr; trait / talents of that module; 'none' = the base
|
||||
const g = C[MLYSS];
|
||||
const dm = d.getChess(MLYSS);
|
||||
const m3 = d.getChess(MLYSS, { moduleId: 'uniequip_003_mlyss' });
|
||||
const none = d.getChess(MLYSS, { moduleId: 'none' });
|
||||
assert.equal(dm.stats.maxHp, 1893);
|
||||
assert.equal(m3.stats.maxHp, g.statsBase.maxHp + 170);
|
||||
assert.equal(m3.stats.atk, g.statsBase.atk + 28);
|
||||
assert.equal(none.stats.maxHp, g.statsBase.maxHp);
|
||||
assert.equal(m3.traitBb.atk_scale, 1.65, 'module trait upgrade');
|
||||
assert.deepEqual(none.traitBb, g.traitBase.bb);
|
||||
assert.equal(m3.raw.module.id, 'uniequip_003_mlyss');
|
||||
assert.equal(m3.raw.module.active, true);
|
||||
assert.equal(none.raw.module.active, false, 'no module ⇒ moduleOn(chess) false for kits');
|
||||
assert.ok(m3.talents.some((t) => t.bb.runtime_cost === -1 && t.bb.cost === -2), 'module talent change applied');
|
||||
assert.equal(m3.skill.id, dm.skill.id, 'a module choice keeps the skill');
|
||||
const both = d.getChess(MLYSS, { skillIndex: 0, moduleId: 'none' });
|
||||
assert.equal(both.skill.id, 'skchr_mlyss_1');
|
||||
assert.equal(both.stats.maxHp, g.statsBase.maxHp);
|
||||
});
|
||||
|
||||
test('getToken(id, owner, ownerLoadout): summons follow the owner\'s skill slot and module', { skip }, () => {
|
||||
const d = freshDs();
|
||||
const t0 = d.getToken(WTRMAN, MLYSS);
|
||||
const t1 = d.getToken(WTRMAN, MLYSS, { skillIndex: 0, moduleId: 'none' });
|
||||
assert.equal(t0.skill.id, 'sktok_mlyss_wtrman_3');
|
||||
assert.equal(t1.skill.id, 'sktok_mlyss_wtrman_1', '流形 uses the skill of the owner\'s selected slot');
|
||||
assert.equal(t0.talents[0].bb.scale, 1);
|
||||
assert.equal(t1.talents[0].bb.scale, 0.9, 'no module ⇒ 90% copy (module isToken part not applied)');
|
||||
assert.equal(d.getToken(WTRMAN, MLYSS, { skillIndex: 2 }), t0, 'default owner loadout = the default token def');
|
||||
assert.equal(d.getToken(WTRMAN, MLYSS, { skillIndex: 0, moduleId: 'none' }), t1, 'cached per owner loadout');
|
||||
// 凛御银灰: the talent's eagle follows the selected skill (S1 → eagle1)
|
||||
const sv = d.getChess('chess_char_5_14_a', { skillIndex: 0 });
|
||||
assert.equal(sv.talents[0].tokenKey, 'token_10057_svash2_eagle1');
|
||||
assert.equal(d.getChess('chess_char_5_14_a').talents[0].tokenKey, 'token_10057_svash2_eagle2');
|
||||
});
|
||||
|
||||
test('kit loader: skills[selected] → kit.skill (default only) → generic; talents / trait / install always from the kit', { skip }, () => {
|
||||
const d = freshDs();
|
||||
const specA = { kind: 'duration', duration: 1, mods: { atkPct: 0.1 } };
|
||||
const specS1 = { kind: 'instant', mods: { atkPct: 0.2 } };
|
||||
const talent = { install() {} };
|
||||
const install = () => {};
|
||||
const kitFn = () => ({ skill: specA, talents: [talent], trait: { priority: 'fly' }, install });
|
||||
const battle = { opts: { kits: { [INSIDE]: kitFn } }, _handlerError(label, u, e) { throw e; } };
|
||||
const unit = (def) => ({ kind: 'op', def });
|
||||
// default skill: kit.skill
|
||||
const k0 = setupUnitKit(battle, unit(d.getChess(INSIDE)), 'full');
|
||||
assert.equal(k0.skill, specA);
|
||||
// non-default, not authored: the generic spec of the SELECTED skill, kit talents / trait kept
|
||||
const defS1 = d.getChess(INSIDE, { skillIndex: 0 });
|
||||
const k1 = setupUnitKit(battle, unit(defS1), 'full');
|
||||
assert.equal(k1.skillSource, 'generic');
|
||||
assert.deepEqual(k1.skill, genericKit(defS1.skill.bb, defS1.raw, defS1).skill);
|
||||
assert.deepEqual([k1.talents[0], k1.trait.priority, k1.install], [talent, 'fly', install]);
|
||||
// authored per skill id
|
||||
battle.opts.kits[INSIDE] = () => ({ skill: specA, skills: { skchr_inside_1: specS1 }, talents: [] });
|
||||
const k2 = setupUnitKit(battle, unit(defS1), 'full');
|
||||
assert.equal(k2.skill, specS1);
|
||||
assert.equal(k2.skillSource, 'skills');
|
||||
assert.equal(setupUnitKit(battle, unit(d.getChess(INSIDE)), 'full').skill, specA, 'skills map without the default id → kit.skill');
|
||||
// an explicit null in skills means "never casts"
|
||||
assert.equal(selectSkillSpec({ skill: specA, skills: { skchr_inside_1: null } }, {}, defS1.raw, defS1).skill, null);
|
||||
// the kit receives the selected skill and its blackboard
|
||||
let seen = null;
|
||||
battle.opts.kits[INSIDE] = (bb, chess) => { seen = [bb, chess.skill.skillId]; return { skill: specA, talents: [] }; };
|
||||
setupUnitKit(battle, unit(defS1), 'full');
|
||||
assert.deepEqual(seen, [C[INSIDE].skills[0].bb, 'skchr_inside_1']);
|
||||
// generic install of the generic skill chains after the kit's own install
|
||||
const order = [];
|
||||
const gi = selectSkillSpec({ skill: specA, talents: [], install: () => order.push('kit') }, {}, defS1.raw, { ...defS1, skill: { ...defS1.skill, bb: {} } });
|
||||
assert.equal(typeof gi.install, 'function');
|
||||
// coverage probe
|
||||
assert.equal(skillSpecSource(d.getChess(INSIDE), { [INSIDE]: () => ({ skill: specA }) }), 'kit');
|
||||
assert.equal(skillSpecSource(defS1, { [INSIDE]: () => ({ skill: specA }) }), 'generic');
|
||||
assert.equal(skillSpecSource(defS1, { [INSIDE]: () => ({ skill: specA, skills: { skchr_inside_1: specS1 } }) }), 'skills');
|
||||
assert.equal(skillSpecSource(defS1, {}), 'none');
|
||||
});
|
||||
|
||||
/** A normal-field spec with one player's units and a real wave (act1autochess_01 on 战场#05). */
|
||||
function normalSpec(units, { seed = 4242 } = {}) {
|
||||
const tpl = ds.getWave('act1autochess_01');
|
||||
const routes = tpl.routes;
|
||||
const spawns = tpl.spawns.filter((s) => !s.action && !s.slot).map((s) => ({ time: s.time, enemyKey: s.key, routeIndex: s.routeIndex, count: s.count, interval: s.interval }));
|
||||
return buildBattleSpec({
|
||||
battleId: 'b1', fieldId: 'n:p1', kind: 'normal', seed, modeId: 'mode_multi_normal', round: 1, stageId: 'act2autochess_m01',
|
||||
timeLimit: 90, players: [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units, bonds: {}, playerEffects: [] }],
|
||||
spawns, routes, flags: {}, waveId: 'act1autochess_01',
|
||||
});
|
||||
}
|
||||
|
||||
test('BattleSpec: units carry skillIndex / moduleId (sanitised); a battle uses the selected skill and module', { skip }, () => {
|
||||
const spec = normalSpec([
|
||||
{ uid: 1, chessId: INSIDE, row: 10, col: 5, dir: 'RIGHT', skillIndex: 0 },
|
||||
{ uid: 2, chessId: MLYSS, row: 11, col: 4, dir: 'RIGHT', skillIndex: 0, moduleId: 'uniequip_003_mlyss' },
|
||||
{ uid: 3, chessId: 'chess_char_1_02_a', row: 10, col: 7, dir: 'RIGHT', skillIndex: 'x', moduleId: '../../etc' },
|
||||
{ uid: 4, kind: 'token', tokenId: WTRMAN, ownerUid: 2, row: 12, col: 6, skillIndex: 1 },
|
||||
]);
|
||||
const u = spec.players[0].units;
|
||||
assert.deepEqual([u[0].skillIndex, u[1].skillIndex, u[1].moduleId], [0, 0, 'uniequip_003_mlyss']);
|
||||
assert.ok(!('skillIndex' in u[2]) && !('moduleId' in u[2]), 'malformed loadout fields dropped');
|
||||
assert.ok(!('skillIndex' in u[3]), 'tokens carry no loadout');
|
||||
assert.equal(JSON.stringify(jsonClone(spec)), JSON.stringify(spec), 'JSON-safe');
|
||||
|
||||
const b = createBattleFromSpec(spec, freshDs(), { recordEvents: false, quiet: true });
|
||||
const inside = b.allyUnits.find((x) => x.uid === 1);
|
||||
assert.equal(inside.def.skill.id, 'skchr_inside_1');
|
||||
assert.equal(inside.skill.id, 'skchr_inside_1', 'the unit\'s skill runtime is the selected skill');
|
||||
assert.deepEqual(inside.skill.bb, C[INSIDE].skills[0].bb, 'with that skill\'s blackboard');
|
||||
assert.equal(inside.skill.baseSpCost, C[INSIDE].skills[0].spCost);
|
||||
const ml = b.allyUnits.find((x) => x.uid === 2);
|
||||
assert.equal(ml.base.maxHp, C[MLYSS].statsBase.maxHp + 170, 'module attr applied to the elite\'s stats');
|
||||
assert.equal(ml.base.atk, C[MLYSS].statsBase.atk + 28);
|
||||
assert.equal(ml.skill.id, 'skchr_mlyss_1');
|
||||
const jf = b.allyUnits.find((x) => x.uid === 3);
|
||||
assert.equal(jf.skill.id, C.chess_char_1_02_a.skill.skillId, 'malformed loadout ⇒ default');
|
||||
const tok = b.allyUnits.find((x) => x.uid === 4);
|
||||
assert.ok(tok, '流形 piece created');
|
||||
assert.equal(tok.def.skill?.id, 'sktok_mlyss_wtrman_1', '流形 piece follows its owner\'s S1');
|
||||
// 落叶四季's isToken part makes the 流形 taunt (+1); the 100% copy belongs to 梳妆流形 only
|
||||
assert.equal(tok.def.talents[0].bb.scale, 0.9);
|
||||
assert.ok(tok.def.talents.some((t) => t.bb.taunt_level === 1), '流形 gets the module 003 token talent');
|
||||
// the Battle still runs to the end
|
||||
b.runToEnd(4000);
|
||||
assert.ok(b.finished);
|
||||
});
|
||||
|
||||
test('determinism: a spec with loadouts gives identical results on independent sources; the loadout matters', { skip }, () => {
|
||||
const units = [
|
||||
{ uid: 1, chessId: INSIDE, row: 10, col: 5, dir: 'RIGHT', skillIndex: 0 },
|
||||
{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 8, dir: 'RIGHT', skillIndex: 0 },
|
||||
{ uid: 3, chessId: MLYSS, row: 11, col: 4, dir: 'RIGHT', skillIndex: 1, moduleId: 'none' },
|
||||
];
|
||||
const spec = normalSpec(units);
|
||||
const run = (s, src) => resultDigest(createBattleFromSpec(s, src, { recordEvents: false, quiet: true }).runToEnd(4000));
|
||||
const a = run(spec, freshDs());
|
||||
const b = run(JSON.parse(JSON.stringify(spec)), freshDs());
|
||||
const c = run(spec, ds); // shared (cached) default source, other construction order
|
||||
assert.equal(a.hash, b.hash);
|
||||
assert.equal(a.hash, c.hash);
|
||||
// the same board with default loadouts builds different units (the selected skills / module are really used)
|
||||
const sp = (s) => createBattleFromSpec(s, freshDs(), { recordEvents: false, quiet: true });
|
||||
const withLo = sp(spec), withoutLo = sp(normalSpec(units.map(({ skillIndex, moduleId, ...x }) => x)));
|
||||
const sig = (bt) => bt.allyUnits.map((x) => [x.skill.id, x.base.maxHp]);
|
||||
assert.deepEqual(sig(withLo), [['skchr_inside_1', C[INSIDE].stats.maxHp], [C.chess_char_1_02_a.skills[0].skillId, C.chess_char_1_02_a.stats.maxHp], ['skchr_mlyss_2', C[MLYSS].statsBase.maxHp]]);
|
||||
assert.deepEqual(sig(withoutLo), [['skchr_inside_2', C[INSIDE].stats.maxHp], [C.chess_char_1_02_a.skill.skillId, C.chess_char_1_02_a.stats.maxHp], ['skchr_mlyss_3', C[MLYSS].stats.maxHp]]);
|
||||
});
|
||||
|
||||
test('withUnitLoadouts: per-battle view; explicit loadouts win; conflicting chess ids are reported', { skip }, () => {
|
||||
const base = freshDs();
|
||||
assert.equal(withUnitLoadouts(base, [{ units: [{ chessId: INSIDE }] }]), base, 'no loadouts ⇒ the source itself');
|
||||
const players = [
|
||||
{ playerId: 'a', units: [{ uid: 1, chessId: INSIDE, skillIndex: 0 }, { uid: 2, chessId: MLYSS, skillIndex: 0 }] },
|
||||
{ playerId: 'b', units: [{ uid: 1, chessId: MLYSS, skillIndex: 1 }] },
|
||||
];
|
||||
const v = withUnitLoadouts(base, players);
|
||||
assert.ok(v instanceof DataSource);
|
||||
assert.equal(v.getChess(INSIDE).skill.id, 'skchr_inside_1');
|
||||
assert.equal(v.getChess(INSIDE, { skillIndex: 1 }).skill.id, 'skchr_inside_2', 'explicit loadout wins');
|
||||
assert.equal(v.getChess(INSIDE), base.getChess(INSIDE, { skillIndex: 0 }), 'caches shared with the source');
|
||||
assert.deepEqual(v.loadoutConflicts, [MLYSS]);
|
||||
assert.equal(v.getToken(WTRMAN, MLYSS).skill.id, 'sktok_mlyss_wtrman_1', 'summons: owner loadout from the view');
|
||||
assert.equal(v.getToken(WTRMAN, MLYSS, { skillIndex: 1 }).skill.id, 'sktok_mlyss_wtrman_2');
|
||||
assert.equal(v.getEnemy('enemy_1422_lrsldr'), base.getEnemy('enemy_1422_lrsldr'), 'everything else delegates');
|
||||
});
|
||||
|
||||
test('multi-player field: the same chess with different loadouts per player — every unit (and summon) gets its own', { skip }, () => {
|
||||
const mk = (playerId, seat, colOffset, units) => ({ playerId, seat, side: 'L', colOffset, units, bonds: {}, playerEffects: [] });
|
||||
const spec = buildBattleSpec({
|
||||
battleId: 'u1', fieldId: 'u:p1', kind: 'unite', seed: 7, modeId: 'mode_multi_normal', round: 4, stageId: 'act2autochess_m01', timeLimit: 60,
|
||||
players: [
|
||||
mk('p1', 0, 0, [{ uid: 1, chessId: MLYSS, row: 10, col: 4, skillIndex: 0, moduleId: 'uniequip_003_mlyss' }, { uid: 2, kind: 'token', tokenId: WTRMAN, ownerUid: 1, row: 11, col: 4 }]),
|
||||
mk('p2', 1, 8, [{ uid: 1, chessId: MLYSS, row: 10, col: 4, skillIndex: 2, moduleId: 'uniequip_002_mlyss' }, { uid: 2, kind: 'token', tokenId: WTRMAN, ownerUid: 1, row: 11, col: 4 }]),
|
||||
],
|
||||
spawns: [], routes: [],
|
||||
});
|
||||
const b = createBattleFromSpec(spec, freshDs(), { quiet: true });
|
||||
const of = (pid, uid) => b.allyUnits.find((u) => u.player.playerId === pid && u.uid === uid);
|
||||
assert.deepEqual([of('p1', 1).skill.id, of('p1', 1).base.maxHp], ['skchr_mlyss_1', C[MLYSS].statsBase.maxHp + 170]);
|
||||
assert.deepEqual([of('p2', 1).skill.id, of('p2', 1).base.maxHp], ['skchr_mlyss_3', C[MLYSS].stats.maxHp]);
|
||||
assert.equal(of('p1', 2).def.skill.id, 'sktok_mlyss_wtrman_1');
|
||||
assert.equal(of('p2', 2).def.skill.id, 'sktok_mlyss_wtrman_3');
|
||||
assert.equal(of('p2', 2).def.talents[0].bb.scale, 1);
|
||||
assert.equal(of('p1', 1).hp, of('p1', 1).s.maxHp, 'HP follows the swapped stats');
|
||||
// determinism of the same multi-player spec
|
||||
const d1 = resultDigest(createBattleFromSpec(spec, freshDs(), { quiet: true, recordEvents: false }).runToEnd(200));
|
||||
const d2 = resultDigest(createBattleFromSpec(jsonClone(spec), ds, { quiet: true, recordEvents: false }).runToEnd(200));
|
||||
assert.equal(d1.hash, d2.hash);
|
||||
});
|
||||
|
||||
test('harness battle with injected kits: non-default skill via the view uses the authored skills entry', { skip }, () => {
|
||||
const specS1 = { kind: 'duration', duration: 5, mods: { atkPct: 1 } };
|
||||
const players = [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, chessId: INSIDE, row: 10, col: 5, skillIndex: 0 }], bonds: {}, playerEffects: [] }];
|
||||
const h = makeBattle({ players, data: withUnitLoadouts(freshDs(), players), kits: { [INSIDE]: () => ({ skill: null, skills: { skchr_inside_1: specS1 }, talents: [] }) } });
|
||||
const u = h.battle.allyUnits[0];
|
||||
assert.equal(u.kit.skill, specS1);
|
||||
assert.equal(u.skill.kind, 'duration');
|
||||
assert.equal(u.skill.id, 'skchr_inside_1');
|
||||
});
|
||||
|
||||
test('tools/kit-coverage: every visible chess and each selectable skill, defaults hand-authored', { skip }, () => {
|
||||
const rep = kitCoverage();
|
||||
assert.equal(rep.summary.chess, 112);
|
||||
assert.equal(rep.chess.length, 112);
|
||||
for (const r of rep.chess) {
|
||||
const ch = loadoutOptions(C[r.chessId], C[C[r.chessId].goldenId]);
|
||||
assert.deepEqual(r.skills.map((s) => s.index), ch.skills, `${r.chessId}: selectable skills listed`);
|
||||
for (const s of r.skills) {
|
||||
assert.ok(['skills', 'kit', 'generic', 'none'].includes(s.normal) && ['skills', 'kit', 'generic', 'none'].includes(s.elite));
|
||||
assert.equal(s.covered, ['skills', 'kit'].includes(s.normal) && ['skills', 'kit'].includes(s.elite));
|
||||
}
|
||||
}
|
||||
assert.equal(rep.summary.skills, rep.chess.reduce((n, r) => n + r.skills.length, 0));
|
||||
// every chess already has a hand-authored kit for its default skill (the coverage baseline kits build on)
|
||||
assert.equal(rep.summary.defaultCovered, rep.summary.defaults);
|
||||
assert.equal(kitCoverage({ tier: 6 }).chess.every((r) => r.tier === 6), true);
|
||||
});
|
||||
|
||||
// ---- adversarial review (data-contract) regressions -------------------------------------------------------------
|
||||
|
||||
test('multi-player field: a unit WITHOUT loadout fields fights with the default even when another player picked a variant', { skip }, () => {
|
||||
const mk = (playerId, seat, colOffset, units) => ({ playerId, seat, side: 'L', colOffset, units, bonds: {}, playerEffects: [] });
|
||||
const spec = buildBattleSpec({
|
||||
battleId: 'u2', fieldId: 'u:p1', kind: 'unite', seed: 9, modeId: 'mode_multi_normal', round: 4, stageId: 'act2autochess_m01', timeLimit: 60,
|
||||
players: [
|
||||
// p1 is listed first: the per-battle view maps MLYSS / INSIDE to p1's choices for id-only lookups
|
||||
mk('p1', 0, 0, [{ uid: 1, chessId: MLYSS, row: 10, col: 4, skillIndex: 0, moduleId: 'none' }, { uid: 3, chessId: INSIDE, row: 9, col: 4, skillIndex: 0 }]),
|
||||
mk('p2', 1, 8, [{ uid: 1, chessId: MLYSS, row: 10, col: 4 }, { uid: 2, kind: 'token', tokenId: WTRMAN, ownerUid: 1, row: 11, col: 4 }, { uid: 3, chessId: INSIDE, row: 9, col: 4 }]),
|
||||
],
|
||||
spawns: [], routes: [],
|
||||
});
|
||||
const b = createBattleFromSpec(spec, freshDs(), { quiet: true });
|
||||
const of = (pid, uid) => b.allyUnits.find((u) => u.player.playerId === pid && u.uid === uid);
|
||||
assert.equal(of('p1', 1).skill.id, 'skchr_mlyss_1');
|
||||
assert.equal(of('p1', 3).skill.id, 'skchr_inside_1');
|
||||
assert.deepEqual([of('p2', 1).skill.id, of('p2', 1).base.maxHp], ['skchr_mlyss_3', C[MLYSS].stats.maxHp], 'no loadout fields ⇒ the default, not p1\'s choice');
|
||||
assert.equal(of('p2', 3).skill.id, 'skchr_inside_2');
|
||||
assert.equal(of('p2', 2).def.skill.id, 'sktok_mlyss_wtrman_3', 'its summon piece follows the default owner');
|
||||
assert.equal(of('p2', 2).def.talents[0].bb.scale, 1);
|
||||
});
|
||||
|
||||
test('an inline token def of a PlayerBattleInput entry is never replaced by the loadout sync', { skip }, () => {
|
||||
for (const lo of [{}, { skillIndex: 0, moduleId: 'uniequip_003_mlyss' }]) {
|
||||
const players = [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, playerEffects: [], units: [
|
||||
{ uid: 1, chessId: MLYSS, row: 10, col: 5, ...lo },
|
||||
{ uid: 2, kind: 'token', tokenId: WTRMAN, ownerUid: 1, row: 11, col: 5, def: { name: 'inline', stats: { maxHp: 77 } } },
|
||||
] }];
|
||||
const h = makeBattle({ players, data: withUnitLoadouts(freshDs(), players) });
|
||||
const t = h.battle.allyUnits.find((u) => u.uid === 2);
|
||||
assert.deepEqual([t.def.name, t.base.maxHp], ['inline', 77], `inline def kept (${JSON.stringify(lo)})`);
|
||||
const op = h.battle.allyUnits.find((u) => u.uid === 1);
|
||||
assert.equal(op.def.loadout.skillIndex, lo.skillIndex ?? 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('getToken: def.sources / def.count follow the owner loadout (which summons a chess really produces)', { skip }, () => {
|
||||
const d = freshDs();
|
||||
const SHADOW = 'token_10022_kazema_shadow', KAZEMA = 'chess_char_2_11_a';
|
||||
assert.deepEqual(d.getToken(SHADOW, KAZEMA).sources, ['skill']);
|
||||
assert.deepEqual(d.getToken(SHADOW, KAZEMA, { skillIndex: 0 }).sources, [], '风丸 S1 makes no 纸偶');
|
||||
const TRAP = 'token_10031_swire2_gdtrap', SWIRE = 'chess_char_3_04_b';
|
||||
assert.deepEqual(d.getToken(TRAP, SWIRE).sources, ['skill'], '琳琅诗怀雅 S2 (default) makes the 香槟炸弹');
|
||||
for (const s of [0, 2]) assert.deepEqual(d.getToken(TRAP, SWIRE, { skillIndex: s }).sources, [], `S${s + 1} makes none`);
|
||||
assert.deepEqual(d.getToken(WTRMAN, MLYSS, { skillIndex: 0, moduleId: 'none' }).sources, ['talent', 'display'], '流形 is a talent summon: every skill');
|
||||
assert.deepEqual([d.getToken('token_10041_cathy_catsld', 'chess_char_4_11_b', { skillIndex: 0 }).count, d.getToken('token_10041_cathy_catsld', 'chess_char_4_11_a').count], [4, 3], '凯瑟琳 devices: count per owner');
|
||||
const HEAL = 'token_10000_silent_healrb';
|
||||
assert.deepEqual(d.getToken(HEAL, 'chess_char_2_02_a', { skillIndex: 0 }).sources, ['display'], '赫默 S1: no drone piece');
|
||||
});
|
||||
|
||||
test('a Battle built WITHOUT the spec path (server-run fields) resolves mid-battle summons with the owner loadout', { skip }, () => {
|
||||
const CATHY = 'chess_char_4_11_a', SHIELD = 'token_10041_cathy_catsld';
|
||||
const run = (skillIndex) => {
|
||||
const players = [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, playerEffects: [],
|
||||
units: [{ uid: 1, kind: 'chess', chessId: CATHY, row: 10, col: 5, dir: 'RIGHT', ...(skillIndex != null ? { skillIndex } : {}) }] }];
|
||||
const b = new Battle({ seed: 3, kind: 'normal', stageId: 'act2autochess_m01', timeLimit: 30, players, spawns: [], routes: [], data: freshDs(), recordEvents: false, quiet: true });
|
||||
for (let i = 0; i < 60 && !b.allyUnits.some((u) => u.defId === SHIELD); i++) b.step();
|
||||
const t = b.allyUnits.find((u) => u.defId === SHIELD);
|
||||
assert.ok(t && t.uid == null, 'a device spawned during the battle');
|
||||
return [b, t];
|
||||
};
|
||||
const [b1, t1] = run(0);
|
||||
assert.ok(isLoadoutView(b1.data), 'installContent gave the battle the loadout view');
|
||||
assert.equal(b1.allyUnits.find((u) => u.uid === 1).skill.id, C[CATHY].skills[0].skillId);
|
||||
assert.equal(t1.def.skill.id, 'sktok_cathy_catsld_1', 'the summon follows the owner\'s S1');
|
||||
const [, t2] = run(1);
|
||||
assert.equal(t2.def.skill.id, 'sktok_cathy_catsld_2', 'default owner ⇒ default token skill');
|
||||
// the spec path is not wrapped twice
|
||||
const spec = normalSpec([{ uid: 1, chessId: INSIDE, row: 10, col: 5, dir: 'RIGHT', skillIndex: 0 }]);
|
||||
const b3 = createBattleFromSpec(spec, freshDs(), { recordEvents: false, quiet: true });
|
||||
assert.ok(isLoadoutView(b3.data) && !isLoadoutView(Object.getPrototypeOf(b3.data)));
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
// Blockable-ground preference of the ground pathing (grid.js header; user playtest: on 战场#01 the lower-gate enemies
|
||||
// walked up the col-9 floor lane, where no operator can stand, instead of the col-8 road). Among equal-length routes the
|
||||
// flow field takes the one with the fewest non-blockable tiles, and smoothing never cuts across floor the grid route
|
||||
// does not walk. Audit: every active stage × gate × field (normal, 联防 both halves, boss both halves incl. the solo
|
||||
// `_s` templates) — the non-blockable tiles an enemy still crosses are listed and proven unavoidable (no route of equal
|
||||
// or near-equal length, up to +2 tiles, crosses fewer).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Grid, bresenhamTiles } from '../../server/sim/grid.js';
|
||||
import { getDefaultSource, hasGeneratedData, normalizeRoute } from '../../server/sim/simdata.js';
|
||||
import { GEO } from '../../shared/constants.js';
|
||||
import { makeBattle, flatStage, chessRec, enemyRec } from '../helpers/battleHarness.js';
|
||||
|
||||
const REAL = { skip: !hasGeneratedData() && 'no generated data' };
|
||||
const COLS = 21;
|
||||
const STAGES = ['act1autochess_m01', 'act1autochess_m02', 'act1autochess_m03', 'act1autochess_m04', 'act2autochess_m01', 'act2autochess_m02', 'act2autochess_m03', 'act2autochess_m04'];
|
||||
const walker = (o = {}) => enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 1, ...o });
|
||||
const guard = chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 0, blockCnt: 3, maxHp: 1e6 }, skill: null });
|
||||
|
||||
/** Grid of a real stage with its match-start devices (crates cost 1000, platforms / mounds blocked). */
|
||||
function stageGrid(id, rect) {
|
||||
const st = getDefaultSource().getStage(id);
|
||||
const g = new Grid(st, rect);
|
||||
for (const d of st.raw.devices) {
|
||||
if (!d.active || !d.pos) continue;
|
||||
if (d.role === 'crate') g.setObstacle(d.pos[0], d.pos[1], true, 'crate');
|
||||
if (d.role === 'platform' || d.role === 'mound') g.setObstacle(d.pos[0], d.pos[1], true);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Every tile the enemy's rounded position can take on the smoothed route: Bresenham tiles + diagonal-step corners. */
|
||||
function covered(wp) {
|
||||
const out = [wp[0]];
|
||||
for (let i = 1; i < wp.length; i++) {
|
||||
const seg = bresenhamTiles(wp[i - 1], wp[i]);
|
||||
for (let j = 1; j < seg.length; j++) {
|
||||
const [r0, c0] = seg[j - 1], [r1, c1] = seg[j];
|
||||
if (r0 !== r1 && c0 !== c1) out.push([r1, c0], [r0, c1]);
|
||||
out.push(seg[j]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Non-blockable tiles crossed from s to e (start and goal excluded), in route order. */
|
||||
function nbCrossed(g, s, e) {
|
||||
const wp = g.waypoints(s[0], s[1], e[0], e[1]);
|
||||
assert.ok(wp, `(${s}) → (${e}) reachable`);
|
||||
const seen = new Set();
|
||||
for (const [r, c] of covered(wp)) {
|
||||
const k = `${r},${c}`;
|
||||
if ((r === s[0] && c === s[1]) || (r === e[0] && c === e[1]) || g.blockable(r, c)) continue;
|
||||
assert.ok(g.walkable(r, c), `(${s}) → (${e}) crosses unwalkable (${k})`);
|
||||
seen.add(k);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fewest non-blockable tiles (start and goal excluded) over all 4-connected crate-free routes s → e of length ≤ the
|
||||
* shortest + `slack`; { best, min } (min = the minimum over those lengths).
|
||||
*/
|
||||
function fewestNb(g, s, e, slack = 2) {
|
||||
const f = g.flowField(e[0], e[1]);
|
||||
const best = f.dist[s[0] * COLS + s[1]];
|
||||
if (best < 0 || best >= 1000) return null;
|
||||
const ek = e[0] * COLS + e[1];
|
||||
const cost = (k) => (k === ek ? 0 : g.unblockable[k]);
|
||||
let cur = new Map([[s[0] * COLS + s[1], 0]]);
|
||||
let min = Infinity;
|
||||
for (let L = 0; L <= best + slack; L++) {
|
||||
if (cur.has(ek)) min = Math.min(min, cur.get(ek));
|
||||
const nx = new Map();
|
||||
for (const [k, p] of cur) {
|
||||
if (k === ek) continue;
|
||||
const r = (k / COLS) | 0, c = k % COLS;
|
||||
for (const [dr, dc] of [[1, 0], [0, 1], [-1, 0], [0, -1]]) {
|
||||
const nr = r + dr, nc = c + dc;
|
||||
if (!g.walkable(nr, nc) || g.isCrate(nr, nc)) continue;
|
||||
const nk = nr * COLS + nc, np = p + cost(nk);
|
||||
if (!nx.has(nk) || nx.get(nk) > np) nx.set(nk, np);
|
||||
}
|
||||
}
|
||||
cur = nx;
|
||||
}
|
||||
return { best, min };
|
||||
}
|
||||
|
||||
/** Tiles an enemy of `route` visits (rounded position per tick) and whether `u` blocked it. */
|
||||
function walk(h) {
|
||||
const seen = [];
|
||||
h.b.on('tick', () => {
|
||||
for (const e of h.b.enemies) {
|
||||
const k = `${Math.round(e.y)},${Math.round(e.x)}`;
|
||||
if (seen[seen.length - 1] !== k) seen.push(k);
|
||||
}
|
||||
});
|
||||
return seen;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
test('equal-length lanes: the road (blockable) lane wins over the floor lane whichever side it is on', () => {
|
||||
// col 0123456789 10
|
||||
// row 12 ##hrrrrrrrh top road, row 12 cols 3..9
|
||||
// row 11 ##hrhhhhXYh two lanes up from (9,9): X = col 8, Y = col 9 (one road, one floor)
|
||||
// row 10 ##hrhhhhXYh
|
||||
// row 9 ##Er####rrS gate (9,10) → (9,9); goal (9,2)
|
||||
const mk = (x, y) => flatStage({ rows: {
|
||||
12: '##hrrrrrrrh##########', 11: `##hrhhhh${x}${y}h##########`, 10: `##hrhhhh${x}${y}h##########`, 9: '##Er####rrS##########',
|
||||
} });
|
||||
for (const [x, y, lane] of [['r', 'f', 8], ['f', 'r', 9]]) {
|
||||
const g = new Grid(mk(x, y), GEO.NORMAL_RECT);
|
||||
const f = g.flowField(9, 2);
|
||||
assert.equal(f.dist[9 * COLS + 10], 14, 'same official length either way');
|
||||
assert.equal(f.pen[9 * COLS + 10], 1, 'only the gate tile is non-blockable on the chosen chain');
|
||||
const path = g.findPath(9, 10, 9, 2).map((p) => p.join());
|
||||
for (const r of [10, 11]) assert.ok(path.includes(`${r},${lane}`), `lane col ${lane}: ${path.join(' ')}`);
|
||||
assert.deepEqual(nbCrossed(g, [9, 10], [9, 2]), [], `no floor crossed (road in col ${lane})`);
|
||||
}
|
||||
});
|
||||
|
||||
test('smoothing never cuts a corner across floor that the grid route does not walk', () => {
|
||||
// open road field with a floor column at col 9 (rows 10–12): the diagonal shortcut (9,9) → (12,8) would pass the
|
||||
// floor of (10,9) / (11,9); the route must go up the road of col 8 instead
|
||||
const rows = { 12: '##hrrrrrrrfS#########', 11: '##hrrrrrrrf##########', 10: '##hrrrrrrrf##########', 9: '##ErrrrrrrrS#########' };
|
||||
const g = new Grid(flatStage({ rows }), GEO.NORMAL_RECT);
|
||||
for (const s of [[9, 9], [9, 10]]) {
|
||||
const wp = g.waypoints(s[0], s[1], 12, 3);
|
||||
for (const [r, c] of covered(wp)) assert.ok(g.blockable(r, c) || (r === s[0] && c === s[1]), `(${s}) → (12,3) crosses (${r},${c}): ${JSON.stringify(wp)}`);
|
||||
}
|
||||
// the straight line stays when it only crosses blockable ground
|
||||
assert.deepEqual(g.waypoints(9, 9, 12, 3), [[9, 9], [12, 3]]);
|
||||
});
|
||||
|
||||
test('the preference is only a tie-break: crates keep cost 1000, route lengths stay the official ones', REAL, () => {
|
||||
// 战场#01 lower gate: a crate on the col-8 road makes the floor lane strictly shorter → it is taken again
|
||||
const g = stageGrid('act1autochess_m01', GEO.NORMAL_RECT);
|
||||
const len0 = g.flowField(9, 2).dist[9 * COLS + 10];
|
||||
g.setObstacle(10, 8, true, 'crate');
|
||||
const f = g.flowField(9, 2);
|
||||
assert.equal(f.dist[9 * COLS + 10], len0, 'the floor lane has the same length');
|
||||
const path = g.findPath(9, 10, 9, 2).map((p) => p.join());
|
||||
assert.ok(!path.includes('10,8'), `detours around the crate: ${path.join(' ')}`);
|
||||
assert.ok(path.includes('10,9') && path.includes('11,9'), `up the floor lane: ${path.join(' ')}`);
|
||||
// wall off the floor lane too: through the crate (1000) rather than nothing
|
||||
g.setObstacle(10, 9, true);
|
||||
assert.ok(g.flowField(9, 2).dist[9 * COLS + 10] > 1000);
|
||||
assert.ok(g.findPath(9, 10, 9, 2).some(([r, c]) => r === 10 && c === 8), 'through the crate');
|
||||
// determinism: an identical grid gives identical fields
|
||||
const a = stageGrid('act2autochess_m03', GEO.UNITE_RECT).flowField(9, 2), b = stageGrid('act2autochess_m03', GEO.UNITE_RECT).flowField(9, 2);
|
||||
assert.deepEqual([...a.next], [...b.next]);
|
||||
assert.deepEqual([...a.pen], [...b.pen]);
|
||||
});
|
||||
|
||||
test('战场#01 (user report): a lower-gate enemy keeps to the col-8 road and an operator on (10,8) blocks it', REAL, () => {
|
||||
const g = stageGrid('act1autochess_m01', GEO.NORMAL_RECT);
|
||||
assert.deepEqual(g.waypoints(9, 10, 9, 2), [[9, 10], [9, 8], [12, 8], [12, 4], [9, 4], [9, 2]]);
|
||||
const route = { motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] };
|
||||
// unblocked: the tiles it walks never include the col-9 floor
|
||||
const h0 = makeBattle({ stageId: 'act1autochess_m01', defs: { enemies: { enemy_walker: walker() } }, enemies: [{ key: 'enemy_walker', route }], content: 'none', autoFinish: false, timeLimit: 120 });
|
||||
const seen = walk(h0);
|
||||
h0.runUntil(() => h0.b.enemies.length === 0 && h0.b.time > 1, 120);
|
||||
assert.equal(h0.result().perPlayer.p1.leaked.length, 1);
|
||||
for (const t of ['10,9', '11,9', '12,9']) assert.ok(!seen.includes(t), `never on floor ${t}: ${seen.join(' ')}`);
|
||||
for (const t of ['9,9', '9,8', '10,8', '11,8', '12,8']) assert.ok(seen.includes(t), `on road ${t}: ${seen.join(' ')}`);
|
||||
// an operator on the col-8 road right above the exit blocks it (also the 联防 field and the checkpoint routes)
|
||||
for (const [kind, rt] of [['normal', route], ['unite', route], ['normal', { motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [[9, 9]] }]]) {
|
||||
for (const tile of [[10, 8], [9, 8]]) {
|
||||
const h = makeBattle({ stageId: 'act1autochess_m01', kind, defs: { chess: { t_guard: guard }, enemies: { enemy_walker: walker() } },
|
||||
units: [{ chessId: 't_guard', row: tile[0], col: tile[1] }], enemies: [{ key: 'enemy_walker', route: rt }], content: 'none', autoFinish: false, timeLimit: 60 });
|
||||
const u = h.unit('t_guard');
|
||||
assert.ok(h.runUntil(() => h.enemies()[0]?.blockedBy === u, 30), `${kind} ${JSON.stringify(rt.checkpoints)}: blocked on (${tile})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The non-blockable tiles each gate's route still crosses (start and goal excluded), per stage and field — every one
|
||||
// proven unavoidable below. normal: lower (9,10) / upper (12,10) gate → (9,2); 联防: the partner half's gates (9,18) /
|
||||
// (12,18) → (9,2) (the own-half gates as in normal); boss: (5,10) / (2,10) → left goal (1,3) and (2,2) | the mirrored
|
||||
// right half → (1,17) and (2,18).
|
||||
const UNAVOIDABLE = {
|
||||
act1autochess_m01: { low: '', up: '12,9', uLow: '9,10', uUp: '12,17 9,10', b5: '5,9|5,11', b2: '|' },
|
||||
act1autochess_m02: { low: '10,9 11,9', up: '12,9', uLow: '10,17 11,17 12,10 12,9', uUp: '12,17 12,10 12,9', b5: '5,9|5,11', b2: '3,9 4,9|3,11 4,11' },
|
||||
act1autochess_m03: { low: '', up: '12,9', uLow: '9,10', uUp: '12,17 9,10', b5: '5,9|5,11', b2: '|' },
|
||||
act1autochess_m04: { low: '', up: '12,9 11,9', uLow: '9,10', uUp: '12,17 11,17 9,10', b5: '5,9 4,9|5,11 4,11', b2: '|' },
|
||||
act2autochess_m01: { low: '', up: '12,9 11,9 10,9', uLow: '9,10', uUp: '12,17 11,17 10,17 9,10', b5: '5,9 4,9 3,9|5,11 4,11 3,11', b2: '|' },
|
||||
act2autochess_m02: { low: '', up: '12,9', uLow: '9,10', uUp: '12,17 9,10', b5: '5,9|5,11', b2: '|' },
|
||||
act2autochess_m03: { low: '10,9', up: '12,9', uLow: '10,17 9,10 10,9', uUp: '12,17 9,10 10,9', b5: '5,9|5,11', b2: '3,9|3,11' },
|
||||
act2autochess_m04: { low: '', up: '12,9', uLow: '9,10', uUp: '12,17 9,10', b5: '5,9|5,11', b2: '|' },
|
||||
};
|
||||
|
||||
test('audit: every active stage × gate × field — the non-blockable tiles crossed are exactly the listed unavoidable ones', REAL, () => {
|
||||
let n = 0;
|
||||
for (const sid of STAGES) {
|
||||
const exp = UNAVOIDABLE[sid];
|
||||
const nrm = stageGrid(sid, GEO.NORMAL_RECT), uni = stageGrid(sid, GEO.UNITE_RECT), bos = stageGrid(sid, GEO.BOSS_RECT);
|
||||
const check = (g, s, e, want, what) => {
|
||||
const got = nbCrossed(g, s, e);
|
||||
assert.equal(got.join(' '), want, `${sid} ${what} (${s}) → (${e}): non-blockable tiles crossed`);
|
||||
const m = fewestNb(g, s, e, 2);
|
||||
assert.ok(m, `${sid} ${what}: reachable without crates`);
|
||||
assert.equal(got.length, m.min, `${sid} ${what} (${s}) → (${e}): a route of length ≤ ${m.best}+2 crosses only ${m.min}`);
|
||||
n++;
|
||||
};
|
||||
check(nrm, [9, 10], [9, 2], exp.low, 'normal lower gate');
|
||||
check(nrm, [12, 10], [9, 2], exp.up, 'normal upper gate');
|
||||
check(uni, [9, 10], [9, 2], exp.low, '联防 own lower gate');
|
||||
check(uni, [12, 10], [9, 2], exp.up, '联防 own upper gate');
|
||||
check(uni, [9, 18], [9, 2], exp.uLow, '联防 partner lower gate');
|
||||
check(uni, [12, 18], [9, 2], exp.uUp, '联防 partner upper gate');
|
||||
const [b5L, b5R] = exp.b5.split('|'), [b2L, b2R] = exp.b2.split('|');
|
||||
for (const e of [[1, 3], [2, 2]]) { check(bos, [5, 10], e, b5L, 'boss left'); check(bos, [2, 10], e, b2L, 'boss left'); }
|
||||
for (const e of [[1, 17], [2, 18]]) { check(bos, [5, 10], e, b5R, 'boss right'); check(bos, [2, 10], e, b2R, 'boss right'); }
|
||||
}
|
||||
assert.equal(n, STAGES.length * 14);
|
||||
});
|
||||
|
||||
test('audit: every WALK leg of every wave template (normal / 联防 / boss / hidden, solo _s too) on every active stage', REAL, () => {
|
||||
const src = getDefaultSource();
|
||||
const rectOf = { normal: GEO.NORMAL_RECT, training: GEO.NORMAL_RECT, escaped: GEO.UNITE_RECT, boss: GEO.BOSS_RECT, hidden: GEO.BOSS_RECT };
|
||||
const legs = new Map();
|
||||
for (const id of src.waveIds()) {
|
||||
const w = src.getWave(id);
|
||||
const rect = rectOf[w && w.kind];
|
||||
if (!rect) continue;
|
||||
for (const raw of [...(w.routes || []), ...(w.extraRoutes || [])]) {
|
||||
const rt = normalizeRoute(raw);
|
||||
if (!rt || rt.motion !== 'WALK' || !rt.start || !rt.end) continue;
|
||||
let from = rt.start;
|
||||
const hop = (to) => {
|
||||
const k = `${w.kind}|${from}|${to}`;
|
||||
if (from.join() !== to.join() && !legs.has(k)) legs.set(k, { rect, from, to, id });
|
||||
from = to;
|
||||
};
|
||||
for (const cp of rt.checkpoints) {
|
||||
if (cp.type === 'MOVE') hop(cp.pos);
|
||||
else if (cp.type === 'APPEAR') from = cp.pos;
|
||||
}
|
||||
hop(rt.end);
|
||||
}
|
||||
}
|
||||
assert.ok(legs.size >= 30, `${legs.size} distinct legs`);
|
||||
// Exceptions to the near-equal (+2 tiles) rule, all accepted: boss / hidden-leader legs whose ends lie in or next to
|
||||
// the boss arena's central floor (rows 3–5 × cols 9–11: the (5,10) / (2,10) teleport exits, boss_8's parts and
|
||||
// patrols); a blockable detour around that floor is ≥ 2 tiles longer, and the official route length is kept.
|
||||
const arena = (t) => { const [r, c] = t.split(',').map(Number); return r >= 3 && r <= 5 && c >= 9 && c <= 11; };
|
||||
let n = 0, extra = 0, accepted = 0;
|
||||
for (const sid of STAGES) {
|
||||
const grids = new Map();
|
||||
for (const { rect, from, to, id } of legs.values()) {
|
||||
const key = JSON.stringify(rect);
|
||||
if (!grids.has(key)) grids.set(key, stageGrid(sid, rect));
|
||||
const g = grids.get(key);
|
||||
if (!g.walkable(from[0], from[1]) || !g.walkable(to[0], to[1])) continue;
|
||||
const eq = fewestNb(g, from, to, 0);
|
||||
if (!eq) continue;
|
||||
const got = nbCrossed(g, from, to);
|
||||
const what = `${sid} ${id} (${from}) → (${to}) crosses ${got.join(' ')}`;
|
||||
assert.equal(got.length, eq.min, `${what}, an equal-length route only ${eq.min}`);
|
||||
const near = fewestNb(g, from, to, 2);
|
||||
if (near.min < got.length) {
|
||||
assert.ok(rect === GEO.BOSS_RECT && got.every(arena), `${what}: a route of length ≤ ${near.best}+2 crosses only ${near.min}`);
|
||||
accepted++;
|
||||
}
|
||||
extra += got.length;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
assert.ok(n > 200, `${n} legs audited (${extra} unavoidable non-blockable crossings)`);
|
||||
assert.equal(accepted, 17, 'accepted boss-arena exceptions (update the count only after reviewing a new one)');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// adversarial review (pathing workstream): end-to-end blocking on every map, map-card crate changes, random layouts,
|
||||
// bounded deviation from the official smoothed route
|
||||
|
||||
/** Legs of the standard gates / exits per field kind (field coordinates). */
|
||||
const GATE_LEGS = [
|
||||
['normal', [9, 10], [9, 2]], ['normal', [12, 10], [9, 2]],
|
||||
['unite', [9, 18], [9, 2]], ['unite', [12, 18], [9, 2]], ['unite', [9, 10], [9, 18]],
|
||||
['boss', [5, 10], [1, 3]], ['boss', [2, 10], [2, 2]], ['boss', [5, 10], [2, 18]], ['boss', [2, 10], [1, 17]],
|
||||
];
|
||||
|
||||
test('every map (user: "检查所有的地图"): an operator on ANY road tile the enemy walks over blocks it — normal gates, 联防 partner gates, boss exits', REAL, () => {
|
||||
const fast = enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 3 });
|
||||
let n = 0;
|
||||
for (const sid of STAGES) {
|
||||
for (const [kind, start, end] of GATE_LEGS) {
|
||||
const route = { motion: 'WALK', start, end, checkpoints: [] };
|
||||
const h0 = makeBattle({ stageId: sid, kind, defs: { enemies: { enemy_walker: fast } }, enemies: [{ key: 'enemy_walker', route }], content: 'none', autoFinish: false, timeLimit: 120 });
|
||||
const seen = new Set();
|
||||
h0.b.on('tick', () => { for (const e of h0.b.enemies) seen.add(`${Math.round(e.y)},${Math.round(e.x)}`); });
|
||||
h0.runUntil(() => h0.b.enemies.length === 0 && h0.b.time > 1, 120);
|
||||
assert.equal(h0.result().perPlayer.p1.leaked.length, 1, `${sid} ${kind} (${start}) → (${end}) leaks unblocked`);
|
||||
const g = h0.b.grid;
|
||||
const road = [...seen].map((k) => k.split(',').map(Number)).filter(([r, c]) => g.blockable(r, c) && !g.isObstacle(r, c));
|
||||
assert.ok(road.length >= 3, `${sid} ${kind} (${start}) → (${end}) walks road: ${[...seen].join(' ')}`);
|
||||
for (const [r, c] of road) {
|
||||
const h = makeBattle({ stageId: sid, kind, defs: { chess: { t_guard: guard }, enemies: { enemy_walker: fast } },
|
||||
units: [{ chessId: 't_guard', row: r, col: c, abs: true }], enemies: [{ key: 'enemy_walker', route }], content: 'none', autoFinish: false, timeLimit: 120 });
|
||||
const u = h.unit('t_guard');
|
||||
assert.ok(u && u.tileR === r && u.tileC === c, `${sid} ${kind}: guard deployed on (${r},${c})`);
|
||||
assert.ok(h.runUntil(() => h.enemies()[0]?.blockedBy === u, 60), `${sid} ${kind} (${start}) → (${end}): a guard on road (${r},${c}) of the route never blocks`);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(n > 600, `${n} guard placements`);
|
||||
});
|
||||
|
||||
test('map cards (移除全部阻隔工事 / 阻隔工事变为射击台): with the crates gone or turned into platforms, no equal-length route crosses fewer non-blockable tiles', REAL, () => {
|
||||
let n = 0;
|
||||
for (const mode of ['removed', 'platform']) {
|
||||
for (const sid of STAGES) {
|
||||
const st = getDefaultSource().getStage(sid);
|
||||
for (const [kind, s, e] of GATE_LEGS) {
|
||||
const g = new Grid(st, kind === 'normal' ? GEO.NORMAL_RECT : kind === 'unite' ? GEO.UNITE_RECT : GEO.BOSS_RECT);
|
||||
for (const d of st.raw.devices) {
|
||||
if (!d.active || !d.pos) continue;
|
||||
if ((d.role === 'crate' && mode === 'platform') || d.role === 'platform' || d.role === 'mound') g.setObstacle(d.pos[0], d.pos[1], true);
|
||||
}
|
||||
const got = nbCrossed(g, s, e);
|
||||
const m = fewestNb(g, s, e, 0);
|
||||
assert.equal(got.length, m.min, `${sid} crates ${mode} ${kind} (${s}) → (${e}) crosses ${got.join(' ')}; an equal-length route only ${m.min}`);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(n, 2 * STAGES.length * GATE_LEGS.length);
|
||||
});
|
||||
|
||||
test('deviation from the official route is bounded: same grid length everywhere, smoothed polyline ≤ 2 tiles longer', REAL, () => {
|
||||
// the official algorithm = the same flow field with every tile counted blockable (no tie-break, no smoothing limit)
|
||||
const pureGrid = (sid, rect) => { const g = stageGrid(sid, rect); g.unblockable.fill(0); return g; };
|
||||
const fields = [[GEO.NORMAL_RECT, [[9, 2]]], [GEO.UNITE_RECT, [[9, 2], [9, 18]]], [GEO.BOSS_RECT, [[1, 3], [2, 2], [1, 17], [2, 18]]]];
|
||||
let n = 0, longer = 0, worst = 0;
|
||||
for (const sid of STAGES) {
|
||||
for (const [rect, dests] of fields) {
|
||||
const ours = stageGrid(sid, rect), pure = pureGrid(sid, rect);
|
||||
for (const [er, ec] of dests) {
|
||||
const fo = ours.flowField(er, ec), fp = pure.flowField(er, ec);
|
||||
assert.deepEqual([...fo.dist], [...fp.dist], `${sid} → (${er},${ec}): official distances`);
|
||||
for (let r = rect.r0; r <= rect.r1; r++) {
|
||||
for (let c = rect.c0; c <= rect.c1; c++) {
|
||||
const k = r * COLS + c;
|
||||
if (fo.dist[k] <= 0 || fo.dist[k] >= 1000) continue;
|
||||
const d = ours.fieldLength(fo, k) - pure.fieldLength(fp, k);
|
||||
assert.ok(d <= 2 + 1e-9, `${sid} (${r},${c}) → (${er},${ec}): ${d.toFixed(2)} tiles longer than official`);
|
||||
if (d > 1e-9) longer++;
|
||||
worst = Math.max(worst, d);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(n > 2000 && longer > 0 && worst > 1, `${n} tiles, ${longer} longer, worst +${worst.toFixed(2)}`);
|
||||
});
|
||||
|
||||
test('random layouts: official lengths, fewest non-blockable tiles among equal-length chains, no off-chain floor / crate / wall on a smoothed segment', () => {
|
||||
let seed = 20260929;
|
||||
const rnd = () => ((seed = (Math.imul(seed, 1103515245) + 12345) >>> 0) / 4294967296);
|
||||
const RECT = GEO.UNITE_RECT;
|
||||
const N = 19 * COLS;
|
||||
for (let t = 0; t < 400; t++) {
|
||||
const rows = Array.from({ length: 19 }, () => '#'.repeat(COLS));
|
||||
for (let r = RECT.r0; r <= RECT.r1; r++) rows[r] = Array.from({ length: COLS }, () => { const x = rnd(); return x < 0.55 ? 'r' : x < 0.85 ? 'f' : '#'; }).join('');
|
||||
const g = new Grid({ rows }, RECT), p = new Grid({ rows }, RECT);
|
||||
p.unblockable.fill(0);
|
||||
for (let i = Math.floor(rnd() * 5); i > 0; i--) { const r = 9 + Math.floor(rnd() * 4), c = Math.floor(rnd() * COLS); g.setObstacle(r, c, true, 'crate'); p.setObstacle(r, c, true, 'crate'); }
|
||||
for (let i = Math.floor(rnd() * 3); i > 0; i--) { const r = 9 + Math.floor(rnd() * 4), c = Math.floor(rnd() * COLS); g.setObstacle(r, c, true); p.setObstacle(r, c, true); }
|
||||
const er = 9 + Math.floor(rnd() * 4), ec = Math.floor(rnd() * COLS), dest = er * COLS + ec;
|
||||
const f = g.flowField(er, ec), fp = p.flowField(er, ec);
|
||||
const what = `layout ${t} → (${er},${ec})\n${rows.slice(9, 13).reverse().join('\n')}`;
|
||||
assert.deepEqual([...f.dist], [...fp.dist], `${what}: official distances`);
|
||||
// the fewest non-blockable tiles over all shortest chains (DP in distance order) = pen
|
||||
const order = [...Array(N).keys()].filter((k) => f.dist[k] >= 0).sort((a, b) => f.dist[a] - f.dist[b]);
|
||||
const best = new Int32Array(N).fill(1e9);
|
||||
for (const k of order) {
|
||||
if (k === dest) { best[k] = 0; continue; }
|
||||
const r = (k / COLS) | 0, c = k % COLS, cost = g.isCrate(r, c) ? 1000 : 1;
|
||||
for (const [dr, dc] of [[1, 0], [0, 1], [-1, 0], [0, -1]]) {
|
||||
const nk = (r + dr) * COLS + c + dc;
|
||||
if (g.inBounds(r + dr, c + dc) && f.dist[nk] >= 0 && f.dist[nk] + cost === f.dist[k]) best[k] = Math.min(best[k], best[nk] + g.unblockable[k]);
|
||||
}
|
||||
assert.equal(f.pen[k], best[k], `${what}: (${r},${c}) pen`);
|
||||
}
|
||||
// smoothed chains: strictly closer each hop; segments cross only walkable, crate-free tiles and on-chain floor
|
||||
for (const k of order) {
|
||||
if (k === dest) continue;
|
||||
const chain = new Set();
|
||||
for (let x = k; x >= 0; x = f.parent[x]) chain.add(x);
|
||||
for (let x = k; x !== dest;) {
|
||||
const y = f.next[x];
|
||||
assert.ok(y >= 0 && f.dist[y] < f.dist[x], `${what}: next of ${x}`);
|
||||
const a = [(x / COLS) | 0, x % COLS], b = [(y / COLS) | 0, y % COLS];
|
||||
const hop = Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) === 1;
|
||||
for (const [r, c] of covered([a, b])) {
|
||||
const kk = r * COLS + c;
|
||||
if (kk === dest || hop) continue;
|
||||
assert.ok(g.walkable(r, c) && !g.isCrate(r, c), `${what}: (${a}) → (${b}) crosses (${r},${c})`);
|
||||
assert.ok(!g.unblockable[kk] || chain.has(kk), `${what}: (${a}) → (${b}) cuts across off-chain floor (${r},${c})`);
|
||||
}
|
||||
x = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
// Adversarial cross-check of grid.js against an independent port of the official client pathing (research 08 §3.4,
|
||||
// `Torappu.Battle.SPFA`: FIFO SPFA from the destination over the WHOLE level map, UP/RIGHT/DOWN/LEFT, crates cost 1000,
|
||||
// strict improvement; row-major in-place Bresenham smoothing with the diagonal corner rule), run on the raw level files
|
||||
// in .cache/gamedata (skipped when absent), extended by our blockable-ground preference (grid.js header: equal-length
|
||||
// ties go to the chain with the fewest non-blockable tiles; a smoothed line crosses non-blockable tiles only on its own
|
||||
// raw chain):
|
||||
// * the 8 active stages with their match-start devices: the smoothed chain from EVERY walkable tile of the normal,
|
||||
// 联防 and boss rects to every goal equals the full-map reference one (the sim's rect limit changes nothing);
|
||||
// * 400 random crate / block layouts: identical chains to the same algorithm limited to the rect;
|
||||
// * against the PURE official algorithm: identical route lengths everywhere, never more non-blockable tiles crossed.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Grid } from '../../server/sim/grid.js';
|
||||
import { getDefaultSource, hasGeneratedData } from '../../server/sim/simdata.js';
|
||||
import { GEO } from '../../shared/constants.js';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const LEVELS = join(ROOT, '.cache', 'gamedata', 'levels', 'activities');
|
||||
const STAGES = ['act1autochess_m01', 'act1autochess_m02', 'act1autochess_m03', 'act1autochess_m04', 'act2autochess_m01', 'act2autochess_m02', 'act2autochess_m03', 'act2autochess_m04'];
|
||||
const file = (sid) => join(LEVELS, sid.slice(0, 13), `level_${sid}.json`);
|
||||
const SKIP = { skip: (!hasGeneratedData() && 'no generated data') || (!STAGES.every((s) => existsSync(file(s))) && 'no .cache/gamedata levels') };
|
||||
const FOUR = [[1, 0], [0, 1], [-1, 0], [0, -1]];
|
||||
|
||||
/**
|
||||
* The official algorithm on a raw level (non-hidden crates cost 1000; platforms / mounds blocked like the sim [ASSUMED]).
|
||||
* `prefer` adds the blockable-ground preference (lexicographic (dist, non-blockable count) relaxation; smoothing lines
|
||||
* cross non-blockable tiles only on the tile's own raw chain between its ends). Returns dest → (start → chain string),
|
||||
* with `.dist(start)` / `.covered(start)` (tiles of the smoothed route incl. diagonal-step corners) on each dest.
|
||||
*/
|
||||
function official(sid, { crates: extraCrates = [], blocks: extraBlocks = [], rect = null, prefer = true } = {}) {
|
||||
const lv = JSON.parse(readFileSync(file(sid), 'utf8'));
|
||||
const md = lv.mapData;
|
||||
const H = md.map.length, W = md.map[0].length;
|
||||
const tile = (r, c) => md.tiles[md.map[H - 1 - r][c]];
|
||||
const crates = new Set(), blocked = new Set();
|
||||
for (const t of lv.predefines?.tokenInsts || []) {
|
||||
if (t.hidden) continue;
|
||||
const k = `${t.position.row},${t.position.col}`;
|
||||
if (t.inst.characterKey === 'trap_1105_accrate') crates.add(k);
|
||||
if (t.inst.characterKey === 'trap_1106_achplat' || t.inst.characterKey === 'trap_032_mound') blocked.add(k);
|
||||
}
|
||||
for (const [r, c] of extraCrates) crates.add(`${r},${c}`);
|
||||
for (const [r, c] of extraBlocks) blocked.add(`${r},${c}`);
|
||||
const inside = (r, c) => (rect ? r >= rect.r0 && r <= rect.r1 && c >= rect.c0 && c <= rect.c1 : r >= 0 && r < H && c >= 0 && c < W);
|
||||
const passable = (r, c) => inside(r, c) && ['ALL', 'WALK_ONLY'].includes(tile(r, c).passableMask) && !blocked.has(`${r},${c}`);
|
||||
// non-blockable walkable terrain: not LOWLAND buildable for melee (floor, gates, goal, teleports)
|
||||
const nb = (r, c) => {
|
||||
const t = tile(r, c);
|
||||
return !(t.heightType === 'LOWLAND' && (t.buildableType === 'ALL' || t.buildableType === 'MELEE'));
|
||||
};
|
||||
const bres = ([r0, c0], [r1, c1], clear) => {
|
||||
const dr = Math.abs(r1 - r0), dc = Math.abs(c1 - c0), sr = r1 > r0 ? 1 : -1, sc = c1 > c0 ? 1 : -1;
|
||||
let err = dc - dr, r = r0, c = c0;
|
||||
const out = [[r, c]];
|
||||
while (r !== r1 || c !== c1) {
|
||||
const e2 = 2 * err;
|
||||
let nr = r, nc = c;
|
||||
if (e2 > -dr) { err -= dr; nc += sc; }
|
||||
if (e2 < dc) { err += dc; nr += sr; }
|
||||
if (nr !== r && nc !== c) out.push([nr, c], [r, nc]);
|
||||
r = nr; c = nc;
|
||||
out.push([r, c]);
|
||||
}
|
||||
return clear ? out.every(([a, b]) => clear(a, b)) : out;
|
||||
};
|
||||
return (dest) => {
|
||||
const D = dest.join();
|
||||
const dist = new Map([[D, 0]]);
|
||||
const pen = new Map([[D, 0]]);
|
||||
const par = new Map();
|
||||
const q = [dest];
|
||||
const inq = new Set([D]);
|
||||
while (q.length) {
|
||||
const cur = q.shift();
|
||||
const ck = cur.join();
|
||||
inq.delete(ck);
|
||||
for (const [dr, dc] of FOUR) {
|
||||
const nb2 = [cur[0] + dr, cur[1] + dc];
|
||||
const nk = nb2.join();
|
||||
if (!passable(nb2[0], nb2[1])) continue;
|
||||
const nd = dist.get(ck) + (crates.has(nk) ? 1000 : 1);
|
||||
const np = pen.get(ck) + (nb(nb2[0], nb2[1]) ? 1 : 0);
|
||||
const better = !dist.has(nk) || nd < dist.get(nk) || (prefer && nd === dist.get(nk) && np < pen.get(nk));
|
||||
if (better) {
|
||||
dist.set(nk, nd);
|
||||
pen.set(nk, np);
|
||||
par.set(nk, cur);
|
||||
if (!inq.has(nk)) { q.push(nb2); inq.add(nk); }
|
||||
}
|
||||
}
|
||||
}
|
||||
const nxt = new Map(par);
|
||||
for (let r = 0; r < H; r++) {
|
||||
for (let c = 0; c < W; c++) {
|
||||
const k = `${r},${c}`;
|
||||
if (!nxt.has(k)) continue;
|
||||
const chain = new Set();
|
||||
for (let x = [r, c]; x; x = par.get(x.join())) chain.add(x.join());
|
||||
const los = (a) => {
|
||||
const lo = dist.get(a.join());
|
||||
return bres([r, c], a, (y, x) => passable(y, x) && !crates.has(`${y},${x}`)
|
||||
&& (!prefer || !nb(y, x) || (chain.has(`${y},${x}`) && dist.get(`${y},${x}`) >= lo)));
|
||||
};
|
||||
let b = nxt.get(k);
|
||||
while (nxt.has(b.join()) && los(nxt.get(b.join()))) b = nxt.get(b.join());
|
||||
nxt.set(k, b);
|
||||
}
|
||||
}
|
||||
const chainOf = (start) => {
|
||||
if (!dist.has(start.join())) return null;
|
||||
const out = [start];
|
||||
let k = start.join();
|
||||
while (k !== D && out.length < 100) { const n = nxt.get(k); out.push(n); k = n.join(); }
|
||||
return out;
|
||||
};
|
||||
const fn = (start) => { const ch = chainOf(start); return ch ? ch.map((p) => `(${p})`).join(' ') : null; };
|
||||
fn.dist = (start) => dist.get(start.join()) ?? -1;
|
||||
fn.coveredNb = (start) => {
|
||||
const ch = chainOf(start);
|
||||
if (!ch) return null;
|
||||
const s = new Set();
|
||||
for (let i = 1; i < ch.length; i++) for (const [y, x] of bres(ch[i - 1], ch[i])) if (nb(y, x)) s.add(`${y},${x}`);
|
||||
if (nb(...ch[0])) s.add(ch[0].join());
|
||||
return s;
|
||||
};
|
||||
return fn;
|
||||
};
|
||||
}
|
||||
|
||||
function simGrid(sid, rect, extra = {}) {
|
||||
const st = getDefaultSource().getStage(sid);
|
||||
const g = new Grid(st, rect);
|
||||
for (const d of st.raw.devices) {
|
||||
if (!d.active || !d.pos) continue;
|
||||
if (d.role === 'crate') g.setObstacle(d.pos[0], d.pos[1], true, 'crate');
|
||||
if (d.role === 'platform' || d.role === 'mound') g.setObstacle(d.pos[0], d.pos[1], true);
|
||||
}
|
||||
for (const [r, c] of extra.crates || []) g.setObstacle(r, c, true, 'crate');
|
||||
for (const [r, c] of extra.blocks || []) g.setObstacle(r, c, true);
|
||||
return g;
|
||||
}
|
||||
|
||||
function compare(sid, rect, dests, extra = {}, officialRect = null) {
|
||||
const off = official(sid, { ...extra, rect: officialRect, prefer: true });
|
||||
const g = simGrid(sid, rect, extra);
|
||||
let n = 0;
|
||||
for (const dest of dests) {
|
||||
const chainOf = off(dest);
|
||||
for (let r = rect.r0; r <= rect.r1; r++) {
|
||||
for (let c = rect.c0; c <= rect.c1; c++) {
|
||||
if (!g.walkable(r, c)) continue;
|
||||
const wp = g.waypoints(r, c, dest[0], dest[1]);
|
||||
assert.equal(wp ? wp.map((p) => `(${p})`).join(' ') : null, chainOf([r, c]), `${sid} (${r},${c}) → (${dest}) ${JSON.stringify(extra)}`);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
test('every walkable tile of the normal / 联防 / boss fields of the 8 stages follows the full-map reference chain', SKIP, () => {
|
||||
let n = 0;
|
||||
for (const sid of STAGES) {
|
||||
n += compare(sid, GEO.NORMAL_RECT, [[9, 2]]);
|
||||
n += compare(sid, GEO.UNITE_RECT, [[9, 2]]);
|
||||
n += compare(sid, GEO.BOSS_RECT, [[1, 3], [1, 17], [2, 2], [2, 18]]);
|
||||
}
|
||||
assert.ok(n > 1500, `${n} chains`);
|
||||
});
|
||||
|
||||
test('random crate / block layouts: identical flow fields and smoothing to the reference algorithm (same rect)', SKIP, () => {
|
||||
let seed = 7;
|
||||
const rnd = () => ((seed = (Math.imul(seed, 1103515245) + 12345) >>> 0) / 4294967296);
|
||||
let n = 0;
|
||||
for (let t = 0; t < 400; t++) {
|
||||
const sid = STAGES[t % STAGES.length];
|
||||
const crates = [], blocks = [];
|
||||
for (let i = 0; i < 6; i++) crates.push([9 + Math.floor(rnd() * 4), Math.floor(rnd() * 11)]);
|
||||
for (let i = 0; i < 2; i++) blocks.push([9 + Math.floor(rnd() * 4), 3 + Math.floor(rnd() * 7)]);
|
||||
n += compare(sid, GEO.NORMAL_RECT, [[9, 2]], { crates, blocks }, GEO.NORMAL_RECT);
|
||||
}
|
||||
assert.ok(n > 5000, `${n} chains`);
|
||||
});
|
||||
|
||||
test('vs the PURE official algorithm: same route length from every tile, never more non-blockable tiles crossed', SKIP, () => {
|
||||
let n = 0, fewer = 0;
|
||||
const fields = [[GEO.NORMAL_RECT, [[9, 2]]], [GEO.UNITE_RECT, [[9, 2]]], [GEO.BOSS_RECT, [[1, 3], [1, 17], [2, 2], [2, 18]]]];
|
||||
for (const sid of STAGES) {
|
||||
const pure = official(sid, { prefer: false });
|
||||
const ours = official(sid, { prefer: true });
|
||||
for (const [rect, dests] of fields) {
|
||||
const g = simGrid(sid, rect);
|
||||
for (const dest of dests) {
|
||||
const p = pure(dest), o = ours(dest);
|
||||
const f = g.flowField(dest[0], dest[1]);
|
||||
for (let r = rect.r0; r <= rect.r1; r++) {
|
||||
for (let c = rect.c0; c <= rect.c1; c++) {
|
||||
if (!g.walkable(r, c) || p.dist([r, c]) < 0) continue;
|
||||
assert.equal(f.dist[r * 21 + c], p.dist([r, c]), `${sid} (${r},${c}) → (${dest}): official length`);
|
||||
const a = p.coveredNb([r, c]).size, b = o.coveredNb([r, c]).size;
|
||||
assert.ok(b <= a, `${sid} (${r},${c}) → (${dest}): ${b} non-blockable tiles vs official ${a}`);
|
||||
if (b < a) fewer++;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(n > 1500 && fewer > 0, `${n} chains, ${fewer} with fewer non-blockable tiles`);
|
||||
// the user's report: 战场#01 lower gate — official climbs the col-9 floor lane, ours keeps to the col-8 road
|
||||
assert.equal(official('act1autochess_m01', { prefer: false })([9, 2])([9, 10]), '(9,10) (9,9) (12,8) (12,4) (9,4) (9,2)');
|
||||
assert.equal(official('act1autochess_m01', { prefer: true })([9, 2])([9, 10]), '(9,10) (9,8) (12,8) (12,4) (9,4) (9,2)');
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
// Official ground pathing (research 08 §3): 4-direction SPFA flow field from the goal (UP, RIGHT, DOWN, LEFT), crates
|
||||
// cost 1000, Bresenham line-of-sight smoothing; enemies walk tile centre to tile centre along next[tile].
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Grid, OBSTACLE_COST, bresenhamTiles } from '../../server/sim/grid.js';
|
||||
import { getDefaultSource, hasGeneratedData } from '../../server/sim/simdata.js';
|
||||
import { makeBattle, flatStage, enemyRec } from '../helpers/battleHarness.js';
|
||||
import { remainingDistance } from '../../server/sim/ai.js';
|
||||
|
||||
const REAL = { skip: !hasGeneratedData() && 'no generated data' };
|
||||
const NORMAL = { r0: 9, r1: 12, c0: 0, c1: 10 };
|
||||
const walker = (o = {}) => enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 1, ...o });
|
||||
const K = (r, c) => r * 21 + c;
|
||||
|
||||
/** Grid of a real stage with its match-start devices (crates cost 1000, platforms / mounds blocked). */
|
||||
function stageGrid(id) {
|
||||
const st = getDefaultSource().getStage(id);
|
||||
const g = new Grid(st, NORMAL);
|
||||
for (const d of st.raw.devices) {
|
||||
if (!d.active || !d.pos) continue;
|
||||
if (d.role === 'crate') g.setObstacle(d.pos[0], d.pos[1], true, 'crate');
|
||||
if (d.role === 'platform' || d.role === 'mound') g.setObstacle(d.pos[0], d.pos[1], true);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
const wp = (g, r, c) => g.waypoints(r, c, 9, 2).map((p) => `(${p})`).join(' ');
|
||||
|
||||
test('flow field: 4 directions only, strict-improvement SPFA with the official UP/RIGHT/DOWN/LEFT tie-break', () => {
|
||||
const g = new Grid(flatStage(), NORMAL);
|
||||
const f = g.flowField(9, 2);
|
||||
assert.equal(f.dist[K(9, 2)], 0);
|
||||
assert.equal(f.dist[K(9, 10)], 8, 'Manhattan distance along the open lane');
|
||||
assert.equal(f.dist[K(12, 10)], 11, 'no diagonal edges: 3 + 8');
|
||||
// every raw parent is a 4-neighbour one step closer
|
||||
for (let k = 0; k < f.dist.length; k++) {
|
||||
if (f.dist[k] <= 0) continue;
|
||||
const p = f.parent[k];
|
||||
assert.equal(Math.abs(((p / 21) | 0) - ((k / 21) | 0)) + Math.abs((p % 21) - (k % 21)), 1);
|
||||
assert.equal(f.dist[p], f.dist[k] - 1);
|
||||
}
|
||||
// equal costs: a tile keeps the first parent that reached it — (10,4) is reached from (10,3) (RIGHT of it) before
|
||||
// (9,4) (UP of it) is expanded, because (10,3) entered the queue first (UP from (9,3))
|
||||
assert.equal(f.parent[K(10, 4)], K(10, 3));
|
||||
// walls are not walkable: the high ground at col 2 of rows 10–12 has no distance
|
||||
assert.equal(f.dist[K(11, 2)], -1);
|
||||
});
|
||||
|
||||
test('smoothing: next[] jumps to the farthest ancestor in Bresenham line of sight; diagonal steps need both orthogonals', () => {
|
||||
const g = new Grid(flatStage(), NORMAL);
|
||||
assert.deepEqual(g.waypoints(9, 10, 9, 2), [[9, 10], [9, 2]], 'straight lane');
|
||||
assert.deepEqual(g.waypoints(12, 10, 9, 2), [[12, 10], [9, 2]], 'open field: one straight line to the goal');
|
||||
// the crossed tiles form an 8-connected chain that never cuts a corner
|
||||
const p = g.findPath(12, 10, 9, 2);
|
||||
assert.deepEqual(p, bresenhamTiles([12, 10], [9, 2]));
|
||||
for (let i = 1; i < p.length; i++) {
|
||||
const [r0, c0] = p[i - 1], [r1, c1] = p[i];
|
||||
assert.ok(Math.abs(r1 - r0) <= 1 && Math.abs(c1 - c0) <= 1);
|
||||
if (r1 !== r0 && c1 !== c0) assert.ok(g.walkable(r0, c1) && g.walkable(r1, c0));
|
||||
}
|
||||
// a wall stub at (10,6)…(12,6): the upper route bends around its end instead of cutting through
|
||||
for (const r of [10, 11, 12]) g.setObstacle(r, 6, true);
|
||||
const bent = g.waypoints(12, 10, 9, 2);
|
||||
assert.ok(bent.length > 2);
|
||||
for (const [r, c] of g.findPath(12, 10, 9, 2)) assert.ok(!(c === 6 && r >= 10), `crosses the wall at (${r},${c})`);
|
||||
// allowDiagonal=false: line of sight only along a row / column
|
||||
const fx = g.flowField(9, 2, { allowDiagonal: false });
|
||||
let k = K(12, 10);
|
||||
while (k !== fx.dest) {
|
||||
const n = fx.next[k];
|
||||
assert.ok(((n / 21) | 0) === ((k / 21) | 0) || n % 21 === k % 21, 'axis-aligned hop');
|
||||
k = n;
|
||||
}
|
||||
});
|
||||
|
||||
test('crates: walkable at cost 1000 (blocks line of sight), blocks are impassable; each kind toggles its own bit', () => {
|
||||
const g = new Grid(flatStage(), NORMAL);
|
||||
const v0 = g.version;
|
||||
g.setObstacle(9, 6, true, 'crate');
|
||||
assert.ok(g.version > v0 && g.isObstacle(9, 6) && g.isCrate(9, 6) && !g.isBlocked(9, 6));
|
||||
assert.equal(g.groundPassable(9, 6), false, 'not a placement / displacement tile');
|
||||
assert.equal(g.walkable(9, 6), true, 'but walkable for the flow field');
|
||||
let f = g.flowField(9, 2);
|
||||
assert.equal(f.dist[K(9, 6)], 3 + OBSTACLE_COST, "(9,5) is 3 from the goal, entering the crate costs 1000");
|
||||
assert.ok(!g.findPath(9, 10, 9, 2).some(([r, c]) => r === 9 && c === 6), 'detours around the crate');
|
||||
// wall off every detour: the crate is the only way — the path goes through it (the enemy will break it)
|
||||
for (const r of [10, 11, 12]) g.setObstacle(r, 6, true);
|
||||
f = g.flowField(9, 2);
|
||||
assert.ok(f.dist[K(9, 10)] > OBSTACLE_COST);
|
||||
assert.ok(g.findPath(9, 10, 9, 2).some(([r, c]) => r === 9 && c === 6), 'through the crate');
|
||||
// a crate turned into a platform on the same tile: both bits, order-independent
|
||||
g.setObstacle(9, 6, true, 'block');
|
||||
g.setObstacle(9, 6, false, 'crate');
|
||||
assert.ok(g.isBlocked(9, 6) && !g.isCrate(9, 6));
|
||||
assert.equal(g.findPath(9, 10, 9, 2), null, 'fully walled');
|
||||
assert.ok(g.findPath(9, 10, 9, 2, { ignoreObstacles: true }));
|
||||
});
|
||||
|
||||
test('flow fields are cached per destination and grid version', () => {
|
||||
const g = new Grid(flatStage(), NORMAL);
|
||||
const a = g.flowField(9, 2);
|
||||
assert.equal(g.flowField(9, 2), a);
|
||||
assert.notEqual(g.flowField(9, 3), a);
|
||||
g.setObstacle(10, 5, true);
|
||||
assert.notEqual(g.flowField(9, 2), a, 'rebuilt after an obstacle change');
|
||||
g.setObstacle(10, 5, true); // no change → no version bump
|
||||
const b = g.flowField(9, 2);
|
||||
assert.equal(g.flowField(9, 2), b);
|
||||
});
|
||||
|
||||
test('real stages: the official lanes of research 08 §3.2 (crates = non-hidden level predefines, platforms blocking) + blockable-ground preference', REAL, () => {
|
||||
// m01 / m04 lower gate: equal-length choice between the col-9 floor lane and the col-8 road — the road (blockable)
|
||||
// wins (official: (9,9) → (12,8) over the floor of (10,9) / (11,9), and (9,10) → (10,7) over (10,9))
|
||||
const lanes = {
|
||||
act1autochess_m01: ['(12,10) (12,4) (9,4) (9,2)', '(9,10) (9,8) (12,8) (12,4) (9,4) (9,2)'],
|
||||
act1autochess_m02: ['(12,10) (12,3) (9,3) (9,2)', '(9,10) (9,9) (11,9) (11,6) (9,6) (9,2)'],
|
||||
act1autochess_m03: ['(12,10) (12,6) (11,6) (9,5) (9,2)', '(9,10) (9,2)'], // 射击台 on (10,3)/(10,4) block [ASSUMED]
|
||||
act1autochess_m04: ['(12,10) (12,9) (11,9) (11,4) (9,4) (9,2)', '(9,10) (9,8) (10,8) (10,4) (9,4) (9,2)'],
|
||||
act2autochess_m01: ['(12,10) (12,9) (9,9) (9,7) (12,7) (12,5) (9,5) (9,2)', '(9,10) (9,7) (12,7) (12,5) (9,5) (9,2)'],
|
||||
act2autochess_m02: ['(12,10) (12,7) (9,7) (9,2)', '(9,10) (9,2)'],
|
||||
act2autochess_m03: ['(12,10) (12,4) (9,4) (9,2)', '(9,10) (9,9) (10,9) (10,6) (9,6) (9,2)'],
|
||||
act2autochess_m04: ['(12,10) (12,4) (9,4) (9,2)', '(9,10) (9,6) (11,6) (11,4) (9,4) (9,2)'],
|
||||
};
|
||||
for (const [id, [up, low]] of Object.entries(lanes)) {
|
||||
const g = stageGrid(id);
|
||||
assert.equal(wp(g, 12, 10), up, `${id} upper gate`);
|
||||
assert.equal(wp(g, 9, 10), low, `${id} lower gate`);
|
||||
}
|
||||
});
|
||||
|
||||
/** Tiles an enemy of `route` visits (rounded position per tick) on a real stage. */
|
||||
function walk(stageId, route) {
|
||||
const h = makeBattle({ stageId, defs: { enemies: { enemy_walker: walker() } }, enemies: [{ key: 'enemy_walker', route }], content: 'none', autoFinish: false, timeLimit: 120 });
|
||||
const seen = [];
|
||||
h.b.on('tick', () => {
|
||||
for (const e of h.b.enemies) {
|
||||
const k = `${Math.round(e.y)},${Math.round(e.x)}`;
|
||||
if (seen[seen.length - 1] !== k) seen.push(k);
|
||||
}
|
||||
});
|
||||
h.runUntil(() => h.b.enemies.length === 0 && h.b.time > 1, 120);
|
||||
return { seen, leaked: h.result().perPlayer.p1.leaked.length };
|
||||
}
|
||||
|
||||
test('act1 m02 (下半, no crates): an upper-gate enemy keeps the top road to col 3 instead of dropping into the lower lane', REAL, () => {
|
||||
const { seen, leaked } = walk('act1autochess_m02', { motion: 'WALK', start: [12, 10], end: [9, 2], checkpoints: [] });
|
||||
assert.equal(leaked, 1);
|
||||
const i3 = seen.indexOf('12,3');
|
||||
assert.ok(i3 > 0, `reaches (12,3): ${seen.join(' ')}`);
|
||||
for (const t of seen.slice(0, i3)) assert.equal(t.split(',')[0], '12', `stays on row 12 before col 3: ${seen.join(' ')}`);
|
||||
assert.ok(!seen.includes('9,6') && !seen.includes('10,6'), 'never takes col 6 down to the lower lane');
|
||||
// the battle spawned no crate on m02 at match start
|
||||
const h = makeBattle({ stageId: 'act1autochess_m02', content: 'none', timeLimit: 1 });
|
||||
h.step();
|
||||
assert.equal(h.b.allyUnits.filter((u) => u.kind === 'device').length, 0);
|
||||
});
|
||||
|
||||
test('act1 m04: an upper-gate enemy stays on row 11 down to col 4 (does not join the lower-gate lane at once)', REAL, () => {
|
||||
const { seen } = walk('act1autochess_m04', { motion: 'WALK', start: [12, 10], end: [9, 2], checkpoints: [] });
|
||||
const i4 = seen.indexOf('11,4');
|
||||
assert.ok(i4 > 0, seen.join(' '));
|
||||
for (const t of seen.slice(seen.indexOf('11,9'), i4 + 1)) assert.equal(t.split(',')[0], '11', `row 11: ${seen.join(' ')}`);
|
||||
const low = walk('act1autochess_m04', { motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] }).seen;
|
||||
assert.ok(low.includes('10,7') && low.includes('10,4') && !low.includes('11,6'), `lower gate on row 10: ${low.join(' ')}`);
|
||||
});
|
||||
|
||||
test('crates in battle: detour when one exists; otherwise the enemy walks into the crate, is blocked and breaks it', () => {
|
||||
// detour available: never enters the crate tile
|
||||
const h = makeBattle({ flat: { crates: [[9, 6]] }, defs: { enemies: { enemy_walker: walker({ atk: 100 }) } }, enemies: [{ key: 'enemy_walker' }], content: 'none', autoFinish: false });
|
||||
let touched = false;
|
||||
h.b.on('tick', () => { for (const u of h.b.enemies) if (Math.round(u.y) === 9 && Math.round(u.x) === 6) touched = true; });
|
||||
h.runUntil(() => h.b.enemies.length === 0 && h.b.time > 1, 40);
|
||||
assert.equal(touched, false);
|
||||
assert.equal(h.result().perPlayer.p1.leaked.length, 1);
|
||||
// only way: through the crate
|
||||
const rows = { 10: '##hrrr#rrrfrrrrrrrf##', 11: '##hrrr#rrrfrrrrrrrf##', 12: '##hrrr#rrrSrrrrrrrS##' };
|
||||
const h2 = makeBattle({ flat: { rows, crates: [[9, 6]] }, defs: { enemies: { enemy_walker: walker({ atk: 500, bat: 1 }) } }, enemies: [{ key: 'enemy_walker' }], content: 'none', autoFinish: false });
|
||||
h2.step();
|
||||
const crate = h2.b.allyUnits.find((u) => u.kind === 'device');
|
||||
assert.ok(crate && h2.b.grid.isCrate(9, 6));
|
||||
const e = h2.enemy('enemy_walker');
|
||||
assert.ok(h2.runUntil(() => e.blockedBy === crate, 20), 'blocked by the crate');
|
||||
assert.ok(h2.runUntil(() => !crate.alive, 10), 'breaks it');
|
||||
assert.equal(h2.b.grid.isObstacle(9, 6), false);
|
||||
h2.runUntil(() => !e.alive, 30);
|
||||
assert.equal(h2.result().perPlayer.p1.leaked.length, 1);
|
||||
});
|
||||
|
||||
test('remaining distance follows the smoothed flow-field route (targeting "closest to the goal")', REAL, () => {
|
||||
const route = { motion: 'WALK', start: [12, 10], end: [9, 2], checkpoints: [] };
|
||||
const h = makeBattle({ defs: { enemies: { enemy_walker: walker() } }, enemies: [{ key: 'enemy_walker', route }], content: 'none', autoFinish: false });
|
||||
h.run(2);
|
||||
const e = h.enemy('enemy_walker');
|
||||
approx(remainingDistance(h.b, e), Math.hypot(e.y - 9, e.x - 2), 'open field: one straight segment');
|
||||
// act1 m02: along the top road — (12,3) then down col 3
|
||||
const h2 = makeBattle({ stageId: 'act1autochess_m02', defs: { enemies: { enemy_walker: walker() } }, enemies: [{ key: 'enemy_walker', route }], content: 'none', autoFinish: false });
|
||||
h2.run(2);
|
||||
const e2 = h2.enemy('enemy_walker');
|
||||
approx(remainingDistance(h2.b, e2), Math.hypot(e2.y - 12, e2.x - 3) + 3 + 1, 'to (12,3), down to (9,3), then (9,2)');
|
||||
});
|
||||
|
||||
function approx(a, b, msg) { assert.ok(Math.abs(a - b) < 1e-6, `${msg}: ${a} vs ${b}`); }
|
||||
|
||||
test('a pushed ground enemy re-plans from its tile centre: never cuts a fence corner or breaks the stage crate on it (act1 m03, tile 11,5)', REAL, () => {
|
||||
// Regression: after Battle.displace() the enemy steered straight from its off-centre position to next[tile], cut
|
||||
// the corner of the FLY-only fence tile (11,5), got blocked by the decorative crate standing there and broke it.
|
||||
const cases = [[12.57, { x: -1, y: 0.2 }], [11, { x: -1, y: -1 }], [13, { x: -1, y: 1 }]];
|
||||
for (const [at, dir] of cases) {
|
||||
const h = makeBattle({
|
||||
stageId: 'act1autochess_m03', routes: [{ motion: 'WALK', start: [12, 10], end: [9, 2], checkpoints: [] }],
|
||||
enemies: [{ key: 'enemy_1267_nhpbr', route: 0 }], units: [], timeLimit: 120,
|
||||
});
|
||||
const b = h.b;
|
||||
h.run(at);
|
||||
const e = b.enemies[0];
|
||||
const crate = b.units.find((u) => u.defId === 'trap_1105_accrate' && u.tileR === 11 && u.tileC === 5);
|
||||
assert.ok(e && crate && crate.alive, 'enemy walking, crate on (11,5)');
|
||||
assert.ok(b.displace(e, dir, 0.5, { force: 3 }) > 0, 'pushed');
|
||||
const ver = b.grid.version;
|
||||
const off = [];
|
||||
while (e.alive && !b.finished) {
|
||||
b.step();
|
||||
const r = Math.round(e.y), c = Math.round(e.x);
|
||||
if (e.alive && !b.grid.walkable(r, c, true)) off.push(`${b.time.toFixed(2)}@${r},${c}`);
|
||||
assert.notEqual(e.blockedBy, crate, `t=${at}: blocked by the fence crate`);
|
||||
}
|
||||
assert.deepEqual(off, [], `t=${at} push ${JSON.stringify(dir)}: stood on a tile it cannot walk`);
|
||||
assert.ok(crate.alive, 'the stage crate survives');
|
||||
assert.equal(b.grid.version, ver, 'no obstacle change');
|
||||
assert.equal(h.result().perPlayer.p1.leaked.length, 1, 'still reaches the goal');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// Performance: a normal battle with ~70 enemies + 10 operators must average < 0.5 ms per tick (DESIGN §11).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Battle } from '../../server/sim/Battle.js';
|
||||
import { getDefaultSource, spawnsFromTemplate } from '../../server/sim/simdata.js';
|
||||
|
||||
const ds = getDefaultSource();
|
||||
const quiet = { error() {}, warn() {} };
|
||||
const LINEUP = [
|
||||
['chess_char_1_02_a', 9, 7], ['chess_char_2_09_a', 9, 4], ['chess_char_4_09_a', 12, 5], ['chess_char_3_08_a', 12, 7],
|
||||
['chess_char_1_01_a', 10, 4], ['chess_char_1_03_a', 11, 4], ['chess_char_2_02_a', 12, 4], ['chess_char_2_14_a', 10, 5],
|
||||
['chess_char_5_12_a', 11, 5], ['chess_char_6_13_a', 9, 8],
|
||||
];
|
||||
|
||||
function build(spawns, routes, timeLimit, seed = 7) {
|
||||
return new Battle({
|
||||
seed, kind: 'normal', stageId: 'act2autochess_m01', routes, spawns, timeLimit, logger: quiet, quiet: true,
|
||||
players: [{ playerId: 'p', units: LINEUP.map(([chessId, row, col], i) => ({ uid: i + 1, kind: 'chess', chessId, row, col, abs: true })) }],
|
||||
});
|
||||
}
|
||||
|
||||
test('benchmark: 70-enemy wave (h05) + 10 operators < 0.5 ms/tick average', () => {
|
||||
const tpl = ds.getWave('act1autochess_h05');
|
||||
const { routes, spawns, maxPlayTime } = spawnsFromTemplate(tpl, { mods: { hpMul: 3 } });
|
||||
// warm-up (JIT)
|
||||
for (let i = 0; i < 2; i++) { const w = build(spawns, routes, maxPlayTime, i + 100); while (!w.finished) w.step(); }
|
||||
const b = build(spawns, routes, maxPlayTime);
|
||||
let n = 0, maxAlive = 0;
|
||||
const t0 = performance.now();
|
||||
while (!b.finished) {
|
||||
b.step();
|
||||
n++;
|
||||
if (n % 3 === 0) { b.snapshot(); b.drainEvents(); }
|
||||
if (b.enemies.length > maxAlive) maxAlive = b.enemies.length;
|
||||
}
|
||||
const avg = (performance.now() - t0) / n;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`bench wave: ${n} ticks, total ${b.total} enemies, peak ${maxAlive} alive, avg ${(avg * 1000).toFixed(1)} µs/tick, reason ${b.reason}`);
|
||||
assert.ok(b.total >= 60);
|
||||
assert.ok(avg < 0.5, `avg ${avg} ms/tick`);
|
||||
});
|
||||
|
||||
test('benchmark: worst case — 70 enemies alive at once + 10 operators < 0.5 ms/tick', () => {
|
||||
const tpl = ds.getWave('act1autochess_h05');
|
||||
const { routes } = spawnsFromTemplate(tpl);
|
||||
const keys = ['enemy_1422_lrsldr', 'enemy_1427_lrnazg', 'enemy_1005_yokai', 'enemy_1042_frostd', 'enemy_1425_lrcmra', 'enemy_1040_bombd'];
|
||||
const spawns = [];
|
||||
for (let i = 0; i < 70; i++) spawns.push({ time: (i % 10) * 0.2, enemyKey: keys[i % keys.length], routeIndex: i % routes.length, mods: { hpMul: 10 } });
|
||||
for (let i = 0; i < 2; i++) { const w = build(spawns, routes, 20, 50 + i); while (!w.finished) w.step(); }
|
||||
const b = build(spawns, routes, 60);
|
||||
let n = 0;
|
||||
const t0 = performance.now();
|
||||
while (!b.finished && b.time < 40) { b.step(); n++; if (n % 3 === 0) { b.snapshot(); b.drainEvents(); } }
|
||||
const avg = (performance.now() - t0) / n;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`bench worst: ${n} ticks, ${b.enemies.length} alive at end, avg ${(avg * 1000).toFixed(1)} µs/tick`);
|
||||
assert.ok(avg < 0.5, `avg ${avg} ms/tick`);
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
// Profession / subprofession default behaviours (server/sim/professions.js).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { SUB, resolveProfile } from '../../server/sim/professions.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
|
||||
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
|
||||
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0, ...o });
|
||||
const R3 = [[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 mk = (sub, profession, o = {}) => chessRec({ id: 't_' + sub, profession, subProfessionId: sub, skill: null, rangeGrid: R3, stats: { atk: 100, bat: 1, ...(o.stats || {}) }, ...o });
|
||||
|
||||
test('every subProfessionId in the visible pool has a dedicated profile entry', () => {
|
||||
const ds = getDefaultSource();
|
||||
const missing = new Set();
|
||||
const seen = new Set();
|
||||
for (const id of ds.chessIds()) {
|
||||
const raw = ds.rawChess(id);
|
||||
if (!raw || raw.isDiy || raw.chessType === 'DIY') continue;
|
||||
const c = ds.getChess(id);
|
||||
if (!c.subProf) continue;
|
||||
seen.add(c.subProf);
|
||||
if (!SUB[c.subProf]) missing.add(c.subProf);
|
||||
}
|
||||
assert.deepEqual([...missing], []);
|
||||
assert.ok(seen.size >= 50, `${seen.size} subprofessions`);
|
||||
});
|
||||
|
||||
test('every visible chess (normal & elite) fights in a real battle without errors', () => {
|
||||
const ds = getDefaultSource();
|
||||
const ids = ds.chessIds().filter((id) => { const r = ds.rawChess(id); return r && !r.isDiy && r.chessType !== 'DIY' && r.stats; });
|
||||
let silent = [];
|
||||
for (const id of ids) {
|
||||
const h = makeBattle({
|
||||
stageId: 'act2autochess_m04', seed: 3,
|
||||
defs: { enemies: { enemy_dummy: dummy({ atk: 300, bat: 2, speed: 0.6, hp: 30000 }) } },
|
||||
units: [{ chessId: id, row: 9, col: 7 }, { chessId: 'chess_char_1_02_a', row: 9, col: 4 }],
|
||||
enemies: [{ key: 'enemy_dummy', count: 4, interval: 2 }, { key: 'enemy_dummy', route: 1, count: 2, interval: 3 }],
|
||||
timeLimit: 40, content: 'full',
|
||||
});
|
||||
h.run(20);
|
||||
checkInvariants(h.b);
|
||||
const u = h.unit(id);
|
||||
assert.equal(h.b.internalErrorCount ?? 0, 0, `${id}: ${JSON.stringify(h.b.errors.slice(0, 2))}`);
|
||||
assert.equal(h.b.errors.length, 0, `${id}: ${JSON.stringify(h.b.errors.slice(0, 2))}`);
|
||||
if (u.stats.attacks === 0 && u.stats.heal === 0 && !(u.skill && u.skill.activations > 0)) silent.push(id + ':' + u.def.subProf);
|
||||
}
|
||||
// units that legitimately never act on their own in this setup (no injured ally / no attack without skill)
|
||||
silent = silent.filter((s) => !/:(physician|ringhealer|chainhealer|healer|wandermedic|bard|phalanx|librator)$/.test(s));
|
||||
assert.deepEqual(silent, []);
|
||||
});
|
||||
|
||||
test('centurion hits every blocked enemy; reaper heals itself per enemy hit (capped by block)', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_centurion: mk('centurion', 'WARRIOR', { rangeGrid: [[0, 0], [0, 1]], stats: { blockCnt: 3, maxHp: 1e6 } }) }, enemies: { enemy_w: enemyRec({ key: 'enemy_w', hp: 1e6, speed: 2 }) } },
|
||||
units: [{ chessId: 't_centurion', row: 9, col: 5 }],
|
||||
enemies: [{ key: 'enemy_w', count: 3, interval: 0.2 }], content: 'none', autoFinish: false,
|
||||
});
|
||||
h.run(8);
|
||||
const ens = h.enemies();
|
||||
assert.equal(ens.filter((e) => e.blockedBy).length, 3);
|
||||
for (const e of ens) assert.ok(e.stats.taken > 0);
|
||||
const h2 = makeBattle({
|
||||
defs: { chess: { t_reaper: mk('reaper', 'WARRIOR', { rangeGrid: [[0, 0], [0, 1]], stats: { blockCnt: 2, maxHp: 10000 } }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_reaper', row: 9, col: 5 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [9, 6], count: 3 }], content: 'none',
|
||||
});
|
||||
h2.step();
|
||||
const r = h2.unit('t_reaper');
|
||||
r.hp = 5000;
|
||||
h2.run(1.02);
|
||||
approx(r.hp, 5000 + 50 * 2 * (r.stats.attacks - 1), 1e-6); // 3 enemies hit, heal capped at blockCnt 2 (first attack was at full HP)
|
||||
});
|
||||
|
||||
test('splash casters hit enemies around the target; chain casters bounce with falloff', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_splashcaster: mk('splashcaster', 'CASTER', { stats: { atk: 1000 } }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_splashcaster', row: 10, col: 4 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [10, 6.8] }, { key: 'enemy_dummy', pos: [12, 9] }], content: 'none',
|
||||
});
|
||||
h.run(1);
|
||||
const [a, b, far] = h.b.units.filter((u) => u.defId === 'enemy_dummy');
|
||||
assert.ok(a.stats.taken > 0 && b.stats.taken > 0);
|
||||
assert.equal(far.stats.taken, 0);
|
||||
const h2 = makeBattle({
|
||||
defs: { chess: { t_chain: chessRec({ id: 't_chain', profession: 'CASTER', subProfessionId: 'chain', skill: null, rangeGrid: R3, stats: { atk: 1000 }, trait: '攻击造成法术伤害,且会在4个敌人间跳跃,每次跳跃伤害降低15%' }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_chain', row: 10, col: 4 }],
|
||||
enemies: [0, 1, 2, 3, 4].map((i) => ({ key: 'enemy_dummy', pos: [10, 6 + i * 0.9] })), content: 'none',
|
||||
});
|
||||
const prof = h2.unit('t_chain').profile;
|
||||
assert.equal(prof.chain.count, 4, 'count parsed from trait text');
|
||||
h2.run(1);
|
||||
const hit = h2.b.units.filter((u) => u.defId === 'enemy_dummy' && u.stats.taken > 0).map((u) => Math.round(u.stats.taken));
|
||||
assert.equal(hit.length, 4);
|
||||
assert.deepEqual(hit.slice().sort((x, y) => y - x), [1000, 850, 723, 614]);
|
||||
});
|
||||
|
||||
test('ring healers heal three allies; bards heal everyone in range every second', () => {
|
||||
const ally = (id) => chessRec({ id, profession: 'TANK', skill: null, stats: { maxHp: 10000, atk: 0 } });
|
||||
const h = makeBattle({
|
||||
defs: {
|
||||
chess: {
|
||||
t_ring: chessRec({ id: 't_ring', profession: 'MEDIC', subProfessionId: 'ringhealer', attackKind: 'heal', dmgType: 'heal', skill: null, rangeGrid: [[0, 0], [0, 1], [1, 0], [1, 1], [-1, 0], [-1, 1], [0, 2]], stats: { atk: 300, bat: 2 } }),
|
||||
a1: ally('a1'), a2: ally('a2'), a3: ally('a3'), a4: ally('a4'),
|
||||
},
|
||||
},
|
||||
units: [{ chessId: 't_ring', row: 10, col: 4 }, { chessId: 'a1', row: 10, col: 5 }, { chessId: 'a2', row: 11, col: 5 }, { chessId: 'a3', row: 9, col: 5 }, { chessId: 'a4', row: 10, col: 6 }],
|
||||
content: 'none',
|
||||
});
|
||||
h.step();
|
||||
for (const id of ['a1', 'a2', 'a3', 'a4']) h.unit(id).hp = 1000 + (id === 'a4' ? 5000 : 0);
|
||||
h.run(0.1);
|
||||
const healed = ['a1', 'a2', 'a3', 'a4'].filter((id) => h.unit(id).hp > (id === 'a4' ? 6000 : 1000));
|
||||
assert.deepEqual(healed, ['a1', 'a2', 'a3']);
|
||||
const h2 = makeBattle({
|
||||
defs: { chess: { t_bard: chessRec({ id: 't_bard', profession: 'SUPPORT', subProfessionId: 'bard', attackKind: 'none', dmgType: 'heal', skill: null, rangeGrid: [[0, 0], [0, 1], [1, 0], [-1, 0]], stats: { atk: 1000 } }), a1: ally('a1'), a2: ally('a2') } },
|
||||
units: [{ chessId: 't_bard', row: 10, col: 4 }, { chessId: 'a1', row: 10, col: 5 }, { chessId: 'a2', row: 11, col: 4 }],
|
||||
content: 'none',
|
||||
});
|
||||
h2.step();
|
||||
h2.unit('a1').hp = 1000; h2.unit('a2').hp = 1000;
|
||||
h2.run(3.05);
|
||||
approx(h2.unit('a1').hp, 1000 + 3 * 100);
|
||||
approx(h2.unit('a2').hp, 1000 + 3 * 100);
|
||||
assert.equal(h2.unit('t_bard').stats.attacks, 0);
|
||||
});
|
||||
|
||||
test('lord deals 80 % at range, 100 % in melee reach; instructor 120 % vs unblocked', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_lord: mk('lord', 'WARRIOR', { attackKind: 'ranged', projectile: 'arrow', canHitFly: true, rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3]], stats: { atk: 1000 } }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_lord', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 7] }], content: 'none', captureNoisy: true, hooks: ['damaged'],
|
||||
});
|
||||
h.run(1);
|
||||
assert.equal(Math.round(h.hooksOf('damaged')[0].amount), 800);
|
||||
const h2 = makeBattle({
|
||||
defs: { chess: { t_ins: mk('instructor', 'WARRIOR', { rangeGrid: [[0, 0], [0, 1], [0, 2]], stats: { atk: 1000, blockCnt: 0 } }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_ins', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], content: 'none', captureNoisy: true, hooks: ['damaged'],
|
||||
});
|
||||
h2.run(0.5);
|
||||
assert.equal(Math.round(h2.hooksOf('damaged')[0].amount), 1200);
|
||||
});
|
||||
|
||||
test('geek loses HP over time; merchant drains DP and retreats when broke; charger gains DP on kills', () => {
|
||||
const h = makeBattle({ defs: { chess: { t_geek: mk('geek', 'SPECIAL', { stats: { maxHp: 1000 } }) } }, units: [{ chessId: 't_geek', row: 9, col: 5 }], content: 'none' });
|
||||
h.run(10.05);
|
||||
approx(h.unit('t_geek').hp, 1000 - 10 * 30, 1e-6);
|
||||
const h2 = makeBattle({ defs: { chess: { t_merchant: mk('merchant', 'SPECIAL', { stats: { respawnTime: 1000 } }) } }, units: [{ chessId: 't_merchant', row: 9, col: 5 }], content: 'none', flags: { dpInit: 10, dpPerSec: 0 } });
|
||||
h2.run(9.05);
|
||||
assert.ok(h2.b.getPlayer('p1').dp < 2);
|
||||
assert.equal(h2.unit('t_merchant').alive, true);
|
||||
h2.run(3.1);
|
||||
assert.equal(h2.unit('t_merchant').alive, false, 'retreated when DP ran out');
|
||||
const h3 = makeBattle({
|
||||
defs: { chess: { t_charger: mk('charger', 'PIONEER', { stats: { atk: 1e5 } }) }, enemies: { enemy_dummy: dummy({ hp: 10 }) } },
|
||||
units: [{ chessId: 't_charger', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [9, 6], count: 3 }], content: 'none', flags: { dpPerSec: 0 },
|
||||
});
|
||||
h3.run(4);
|
||||
approx(h3.b.getPlayer('p1').dp, 13);
|
||||
});
|
||||
|
||||
test('dollkeeper swaps to a substitute on fatal damage and swaps back after 20 s', () => {
|
||||
const h = makeBattle({ defs: { chess: { t_dollkeeper: mk('dollkeeper', 'SPECIAL', { stats: { maxHp: 2000, blockCnt: 2 } }) } }, units: [{ chessId: 't_dollkeeper', row: 9, col: 5 }], content: 'none' });
|
||||
h.step();
|
||||
const u = h.unit('t_dollkeeper');
|
||||
h.b.dealDamage(null, u, { amount: 1e5, type: 'true' });
|
||||
assert.equal(u.alive, true, 'substitute instead of death');
|
||||
assert.equal(u.s.blockCnt, 0);
|
||||
assert.ok(u.trait.doll);
|
||||
h.run(20.1);
|
||||
assert.equal(u.trait.doll, false);
|
||||
approx(u.hp, 2000);
|
||||
assert.equal(u.s.blockCnt, 2);
|
||||
h.b.dealDamage(null, u, { amount: 1e5, type: 'true' });
|
||||
h.b.dealDamage(null, u, { amount: 1e5, type: 'true' });
|
||||
assert.equal(u.alive, false, 'substitute dies ⇒ unit dies');
|
||||
});
|
||||
|
||||
test('phalanx never attacks until its skill; librator ramps ATK while idle and resets on skill end', () => {
|
||||
const ph = chessRec({ id: 't_ph', profession: 'CASTER', subProfessionId: 'phalanx', attackKind: 'none', dmgType: 'arts', rangeGrid: R3, stats: { atk: 100, def: 100 }, skill: { spCost: 5, initSp: 0, duration: 5, trigger: { rule: 'SEARCH' } } });
|
||||
const h = makeBattle({ defs: { chess: { t_ph: ph }, enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 't_ph', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], content: 'generic' });
|
||||
h.run(4.5);
|
||||
const u = h.unit('t_ph');
|
||||
assert.equal(u.stats.attacks, 0);
|
||||
approx(u.s.def, 300);
|
||||
h.run(1);
|
||||
assert.equal(u.skill.active, true);
|
||||
h.run(1);
|
||||
assert.ok(u.stats.attacks > 0);
|
||||
approx(u.s.def, 100);
|
||||
const lib = chessRec({ id: 't_lib', profession: 'WARRIOR', subProfessionId: 'librator', attackKind: 'none', rangeGrid: [[0, 0], [0, 1]], stats: { atk: 100, blockCnt: 1 }, skill: { spCost: 100, initSp: 0, duration: 5 } });
|
||||
const h2 = makeBattle({ defs: { chess: { t_lib: lib } }, units: [{ chessId: 't_lib', row: 9, col: 5 }], content: 'generic' });
|
||||
h2.run(10.05);
|
||||
const l = h2.unit('t_lib');
|
||||
approx(l.s.atk, 100 * 1.5);
|
||||
assert.equal(l.s.blockCnt, 0);
|
||||
h2.run(60);
|
||||
approx(l.s.atk, 300);
|
||||
});
|
||||
|
||||
test('stalker dodges and hits everything in range; hunter consumes and reloads ammo', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_stalker: mk('stalker', 'SPECIAL', { rangeGrid: [[0, 0], [0, 1], [0, 2]] }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_stalker', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [9, 6] }, { key: 'enemy_dummy', pos: [9, 7] }], content: 'none',
|
||||
});
|
||||
h.run(1.1);
|
||||
const u = h.unit('t_stalker');
|
||||
assert.equal(u.s.dodgePhys, 0.5);
|
||||
assert.equal(u.s.taunt, -1);
|
||||
for (const e of h.enemies()) assert.ok(e.stats.taken > 0);
|
||||
const h2 = makeBattle({
|
||||
defs: { chess: { t_hunter: mk('hunter', 'SNIPER', { stats: { atk: 100, bat: 0.5 } }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_hunter', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], content: 'none', captureNoisy: true, hooks: ['damaged'],
|
||||
});
|
||||
h2.run(5);
|
||||
const hu = h2.unit('t_hunter');
|
||||
assert.equal(hu.stats.attacks, 8, '8 bullets then empty');
|
||||
assert.equal(Math.round(h2.hooksOf('damaged')[0].amount), 120);
|
||||
h2.b.enemies[0].x = 20; // out of range → reload
|
||||
h2.b.enemies[0].hidden = true;
|
||||
h2.run(4);
|
||||
assert.ok(hu.trait.ammo >= 2);
|
||||
});
|
||||
|
||||
test('fortress: melee on blocked enemies, ranged splash otherwise; tactician calls a reinforcement', () => {
|
||||
const fort = mk('fortress', 'TANK', { attackKind: 'ranged', projectile: 'arrow', canHitFly: true, rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]], stats: { atk: 500, blockCnt: 3, maxHp: 1e6 } });
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_fort: fort }, enemies: { enemy_dummy: dummy(), enemy_w: enemyRec({ key: 'enemy_w', hp: 1e6, speed: 2 }) } },
|
||||
units: [{ chessId: 't_fort', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [9, 8] }, { key: 'enemy_dummy', pos: [9, 8.6] }], content: 'none',
|
||||
});
|
||||
h.run(1.5);
|
||||
const [a, b] = h.enemies();
|
||||
assert.ok(a.stats.taken > 0 && b.stats.taken > 0, 'splash');
|
||||
const tac = mk('tactician', 'PIONEER', { rangeGrid: [[0, 0], [0, 1], [0, 2], [1, 1], [-1, 1]], stats: { atk: 200 } });
|
||||
const h2 = makeBattle({ defs: { chess: { t_tactician: tac } }, units: [{ chessId: 't_tactician', row: 10, col: 4 }], content: 'none' });
|
||||
h2.step();
|
||||
const tok = h2.b.allyUnits.find((u) => u.kind === 'token');
|
||||
assert.ok(tok, 'reinforcement spawned');
|
||||
assert.equal(tok.s.blockCnt, 1);
|
||||
assert.equal(tok.ownerUnit.id, h2.unit('t_tactician').id);
|
||||
});
|
||||
|
||||
test('skywalker blocks FLY enemies', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_skywalker: mk('skywalker', 'SPECIAL', { canHitFly: true, attackKind: 'melee', rangeGrid: [[0, 0], [0, 1]], stats: { blockCnt: 2, maxHp: 1e6 } }) }, enemies: { enemy_fly: enemyRec({ key: 'enemy_fly', hp: 1e6, speed: 2, motion: 'FLY' }) } },
|
||||
units: [{ chessId: 't_skywalker', row: 9, col: 5 }], enemies: [{ key: 'enemy_fly', route: 2 }], content: 'none', autoFinish: false,
|
||||
});
|
||||
h.run(8);
|
||||
assert.equal(h.enemy('enemy_fly').blockedBy?.id, h.unit('t_skywalker').id);
|
||||
});
|
||||
|
||||
test('incantation medics heal an ally for 50 % of damage dealt; wandermedics clear element gauges', () => {
|
||||
const inc = chessRec({ id: 't_inc', profession: 'MEDIC', subProfessionId: 'incantationmedic', attackKind: 'ranged', dmgType: 'arts', projectile: 'bolt', rangeGrid: R3, skill: null, stats: { atk: 1000 } });
|
||||
const tank = chessRec({ id: 't_tank', profession: 'TANK', skill: null, stats: { maxHp: 1e5, atk: 0 } });
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_inc: inc, t_tank: tank }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_inc', row: 10, col: 4 }, { chessId: 't_tank', row: 10, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [10, 7] }], content: 'none',
|
||||
});
|
||||
h.step();
|
||||
h.unit('t_tank').hp = 1000;
|
||||
h.run(0.5);
|
||||
approx(h.unit('t_tank').hp, 1000 + 500, 1e-6);
|
||||
const wm = chessRec({ id: 't_wm', profession: 'MEDIC', subProfessionId: 'wandermedic', attackKind: 'heal', dmgType: 'heal', rangeGrid: R3, skill: null, stats: { atk: 400 } });
|
||||
const h2 = makeBattle({ defs: { chess: { t_wm: wm, t_tank: tank } }, units: [{ chessId: 't_wm', row: 10, col: 4 }, { chessId: 't_tank', row: 10, col: 5 }], content: 'none' });
|
||||
h2.step();
|
||||
h2.unit('t_tank').elem.neural = 500;
|
||||
h2.run(0.1);
|
||||
approx(h2.unit('t_tank').elem.neural, 300);
|
||||
});
|
||||
|
||||
test('resolveProfile: data fields win over table defaults; kit trait overrides win over data', () => {
|
||||
const ds = getDefaultSource();
|
||||
const c = ds.getChess('chess_char_1_01_a');
|
||||
const p = resolveProfile(c);
|
||||
assert.equal(p.priority, 'fly');
|
||||
assert.equal(p.attack, 'ranged');
|
||||
assert.equal(p.canHitFly, true);
|
||||
const p2 = resolveProfile(c, { priority: 'lowDef', maxTargets: 2 });
|
||||
assert.equal(p2.priority, 'lowDef');
|
||||
assert.equal(p2.maxTargets, 2);
|
||||
const medic = resolveProfile(ds.getChess('chess_char_2_02_a'));
|
||||
assert.equal(medic.dmgType, 'heal');
|
||||
assert.equal(medic.attack, 'ranged');
|
||||
assert.ok(medic.heal);
|
||||
const bard = resolveProfile(ds.getChess('chess_char_4_25_a'));
|
||||
assert.equal(bard.noAttack, true);
|
||||
});
|
||||
@@ -0,0 +1,751 @@
|
||||
// Robustness, determinism & performance regressions (adversarial review of the sim core). Every test here pins a
|
||||
// defect that was reproduced before its fix: re-entrancy from hooks, garbage numbers reaching engine helpers,
|
||||
// runaway content loops, unreachable route legs, listener leaks, and a heavy 2-player boss field.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Battle } from '../../server/sim/Battle.js';
|
||||
import { createRng } from '../../server/sim/rng.js';
|
||||
import { MAX_HOOK_DEPTH, MAX_ALIVE_ENEMIES } from '../../server/sim/constants.js';
|
||||
import { getDefaultSource, spawnsFromTemplate } from '../../server/sim/simdata.js';
|
||||
import { makeBattle, chessRec, enemyRec, flatStage, checkInvariants, hashOf } from '../helpers/battleHarness.js';
|
||||
|
||||
const quiet = { error() {}, warn() {}, info() {} };
|
||||
const guard = (o = {}) => chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 300, blockCnt: 2 }, skill: null, ...o });
|
||||
const sniper = (o = {}) => chessRec({
|
||||
id: 't_sniper', profession: 'SNIPER', skill: null,
|
||||
rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8]], ...o, stats: { atk: 400, blockCnt: 0, ...(o.stats || {}) },
|
||||
});
|
||||
const wideGrid = () => { const g = []; for (let dr = -1; dr <= 2; dr++) for (let dc = -1; dc <= 8; dc++) g.push([dr, dc]); return g; };
|
||||
const walker = (o = {}) => enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 1, ...o });
|
||||
const hookCount = (b) => Object.values(b._hooks).reduce((a, l) => a + l.length, 0);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// lifecycle / re-entrancy
|
||||
|
||||
test('forceEnd from a hook mid-step: the step stops there and the result is never mutated afterwards', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sniper: sniper({ stats: { atk: 5000 } }) }, enemies: { enemy_w: walker({ key: 'enemy_w', hp: 100 }) } },
|
||||
units: [{ chessId: 't_sniper', row: 9, col: 3 }, { chessId: 't_sniper', row: 9, col: 4 }, { chessId: 't_sniper', row: 9, col: 5 }],
|
||||
enemies: [{ key: 'enemy_w', count: 6, pos: [9, 8] }], content: 'none',
|
||||
setup(b) { b.on('kill', () => b.forceEnd('forced'), { priority: 5 }); },
|
||||
});
|
||||
h.runToEnd(30);
|
||||
const r = h.result();
|
||||
assert.equal(r.reason, 'forced');
|
||||
assert.equal(r.killed, r.perPlayer.p1.killed, 'top-level and per-player kill counters agree');
|
||||
assert.equal(r.killed, 1, 'the in-flight kill completes, nothing after it');
|
||||
assert.equal(r.time, h.b.time, 'time does not advance past the forced end');
|
||||
const snap = JSON.stringify(r);
|
||||
h.b.step(); h.b.forceEnd('timeout');
|
||||
assert.equal(JSON.stringify(h.result()), snap, 'finished battles are frozen');
|
||||
});
|
||||
|
||||
test('forceEnd from battleStart / deploy hooks and from a scheduled callback never throws', () => {
|
||||
for (const hook of ['battleStart', 'deploy']) {
|
||||
const h = makeBattle({ defs: { chess: { t_guard: guard() } }, units: [{ chessId: 't_guard', row: 9, col: 5 }], content: 'none',
|
||||
setup(b) { b.on(hook, () => b.forceEnd('timeout')); } });
|
||||
h.step();
|
||||
assert.equal(h.b.finished, true, hook);
|
||||
assert.equal(h.result().reason, 'timeout');
|
||||
}
|
||||
let late = 0;
|
||||
const h = makeBattle({ defs: { enemies: { enemy_walker: walker() } }, enemies: [{ key: 'enemy_walker' }], content: 'none',
|
||||
setup(b) { b.after(1, () => b.forceEnd('forced')); b.after(1.5, () => { late++; }); b.every(0.1, () => { if (b.time > 1.01) late++; }); } });
|
||||
h.run(10);
|
||||
assert.equal(h.result().reason, 'forced');
|
||||
assert.ok(h.b.time <= 1 + 1e-9, `ended at ${h.b.time}`);
|
||||
assert.equal(late, 0, 'no scheduled callback runs after the end');
|
||||
});
|
||||
|
||||
test('step() called re-entrantly from a hook is a no-op', () => {
|
||||
let inner = 0;
|
||||
const h = makeBattle({ content: 'none', defs: { enemies: { enemy_walker: walker() } }, enemies: [{ key: 'enemy_walker' }],
|
||||
setup(b) { b.on('tick', () => { const t = b.tickCount; b.step(); if (b.tickCount !== t) inner++; }); } });
|
||||
h.step(30);
|
||||
assert.equal(inner, 0);
|
||||
assert.equal(h.b.tickCount, 30);
|
||||
});
|
||||
|
||||
test('a flood of engine errors force-ends the battle as a timeout (never throws out of step)', () => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_walker: walker() } }, enemies: [{ key: 'enemy_walker', count: 20 }], content: 'none', timeLimit: 1000 });
|
||||
// sabotage: every enemy update throws (enemies steer by the grid's flow field — grid.js; findPath / waypoints too)
|
||||
for (const fn of ['flowField', 'waypoints', 'findPath']) h.b.grid[fn] = () => { throw new Error('boom'); };
|
||||
assert.doesNotThrow(() => h.run(60));
|
||||
assert.equal(h.b.finished, true);
|
||||
assert.equal(h.result().reason, 'timeout');
|
||||
assert.ok(h.b.internalErrorCount > 200);
|
||||
});
|
||||
|
||||
test('hook recursion is cut at MAX_HOOK_DEPTH deterministically (no stack overflow)', () => {
|
||||
const run = () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_guard: guard() }, enemies: { enemy_walker: walker({ hp: 3000, atk: 100 }) } },
|
||||
units: [{ chessId: 't_guard', row: 9, col: 5 }], enemies: [{ key: 'enemy_walker' }], content: 'none', timeLimit: 30,
|
||||
setup(b) { b.on('damaged', (c) => { b.dealDamage(c.target, c.source ?? c.target, { amount: 0.1, type: 'true' }); }); },
|
||||
});
|
||||
h.runToEnd(40);
|
||||
return h;
|
||||
};
|
||||
const a = run(), b = run();
|
||||
assert.equal(a.b.finished, true);
|
||||
assert.equal(a.b.internalErrorCount ?? 0, 0, 'no internal error (stack overflow) reached the engine');
|
||||
assert.ok(a.b.errors.some((e) => e.label.startsWith('hookDepth:')), 'recursion reported as a content error');
|
||||
assert.equal(hashOf(a.events), hashOf(b.events), 'deterministic');
|
||||
assert.equal(a.b._emitDepth, 0);
|
||||
assert.ok(MAX_HOOK_DEPTH >= 16);
|
||||
});
|
||||
|
||||
// Regression (matchrun solo FUNNY seed 17, "hookDepth: hit → fatal → damaged → kill → death"): 忍冬's 追凶 deals bonus
|
||||
// damage from a `damaged` handler and skips its own 'vulpisHunt'-tagged damage; 频次 (hit-count) enemies used to cancel
|
||||
// every hit and re-create it as a fresh loseHp(1) WITHOUT the original tags, so the talent re-triggered on its own
|
||||
// bonus until the depth guard tripped — and the skipped kill/death handlers lost death spawns / bounties. Hit counting
|
||||
// now happens inside the damage pipeline (flags hitCount / hitCountArts) on the original DamageInfo, and a loss derived
|
||||
// from another damage passes `from` so it inherits its tags.
|
||||
test('hit-count units keep the original DamageInfo: a self-excluding bonus-on-damaged handler never recurses', () => {
|
||||
const bonusHandler = (b, u) => b.on('damaged', (c) => {
|
||||
if (c.source !== u || c.target.side !== 'enemy' || (c.dmg?.tags || []).includes('bonus')) return;
|
||||
b.dealDamage(u, c.target, { amount: 1, type: 'arts', tags: ['bonus'] });
|
||||
}, { owner: u });
|
||||
const run = (mode) => {
|
||||
let deaths = 0;
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_guard: guard({ stats: { atk: 300, blockCnt: 2 } }) }, enemies: { enemy_h: walker({ key: 'enemy_h', hp: 6, speed: 0 }) } },
|
||||
units: [{ chessId: 't_guard', row: 9, col: 5 }], enemies: [{ key: 'enemy_h', pos: [9, 5] }], content: 'none', timeLimit: 30,
|
||||
setup(b) {
|
||||
b.on('battleStart', () => bonusHandler(b, b.allyUnits[0]));
|
||||
b.on('enemySpawn', ({ enemy }) => { if (mode === 'engine') b.addBuff(enemy, { key: 'times', flags: { hitCount: true }, persist: true }); });
|
||||
if (mode !== 'engine') {
|
||||
// the old content conversion: cancel the hit, re-create it as 1 HP loss (with / without `from`)
|
||||
b.on('hit', (c) => {
|
||||
if (c.target.side !== 'enemy') return;
|
||||
c.dmg.cancel = true;
|
||||
b.loseHp(c.target, 1, { source: c.source, from: mode === 'from' ? c.dmg : null });
|
||||
}, { priority: -500 });
|
||||
}
|
||||
b.on('death', ({ unit }) => { if (unit.side === 'enemy') deaths++; });
|
||||
},
|
||||
});
|
||||
h.runToEnd(30);
|
||||
return { h, deaths };
|
||||
};
|
||||
for (const mode of ['engine', 'from']) {
|
||||
const { h, deaths } = run(mode);
|
||||
assert.deepEqual(h.b.errors, [], `${mode}: no hookDepth errors`);
|
||||
assert.equal(h.result().reason, 'cleared', mode);
|
||||
assert.equal(deaths, 1, `${mode}: the death hook of the 频次 unit fired`);
|
||||
assert.equal(h.unit('t_guard').stats.attacks, 3, `${mode}: 6 hits = 3 attacks + 3 bonus instances`);
|
||||
}
|
||||
// the old pattern is exactly the reported cascade — and the guard's message names the looping frames
|
||||
const old = run('fresh');
|
||||
const depth = old.h.b.errors.filter((e) => e.label.startsWith('hookDepth:'));
|
||||
assert.ok(depth.length > 0, 'reproduces the cascade');
|
||||
assert.ok(depth.some((e) => /chain: .*damaged\(t_guard\).*hit\(t_guard→enemy_h\)/.test(e.message)), depth.map((e) => e.message).join('\n'));
|
||||
// engine hit counting: arts-only units ignore physical instances, damage amount is exactly 1
|
||||
const h = makeBattle({ defs: { enemies: { enemy_h: walker({ key: 'enemy_h', hp: 4, speed: 0, def: 0 }) } }, enemies: [{ key: 'enemy_h', pos: [9, 5] }], content: 'none', autoFinish: false });
|
||||
h.step();
|
||||
const e = h.enemy('enemy_h');
|
||||
h.b.addBuff(e, { key: 'times', flags: { hitCountArts: true } });
|
||||
assert.equal(h.b.dealDamage(null, e, { amount: 5000, type: 'phys' }), 0);
|
||||
assert.equal(h.b.dealDamage(null, e, { amount: 5000, type: 'arts' }), 1);
|
||||
assert.equal(h.b.dealDamage(null, e, { amount: 0.5, type: 'true' }), 1);
|
||||
assert.equal(e.hp, 2);
|
||||
});
|
||||
|
||||
test('recursion through content callbacks (skill onEnd → activate → onEnd …) is cut by the same depth guard', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_guard: guard({ skill: { duration: 0, spCost: 5, initSp: 5 } }) } },
|
||||
units: [{ chessId: 't_guard', row: 9, col: 5 }], content: 'generic', timeLimit: 5, hooks: [], // no hooks: recursion only via _safe
|
||||
kits: { t_guard: () => ({ skill: { kind: 'instant', trigger: 'SP_FULL', onEnd: ({ skill }) => { skill.activate('again', { free: true }); } } }) },
|
||||
});
|
||||
assert.doesNotThrow(() => h.run(2));
|
||||
assert.equal(h.b.internalErrorCount ?? 0, 0);
|
||||
assert.ok(h.b.errors.some((e) => e.label.startsWith('hookDepth:')));
|
||||
assert.equal(h.b._emitDepth, 0);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('a leak hook that spawns an instantly-leaking enemy cannot loop inside one tick', () => {
|
||||
const stage = { id: 'noend', rows: Array.from({ length: 19 }, () => 'r'.repeat(21)), devices: [] };
|
||||
const h = makeBattle({ stage, routes: [], defs: { enemies: { enemy_w: walker({ key: 'enemy_w' }) } }, enemies: [{ key: 'enemy_w' }], timeLimit: 5,
|
||||
setup(b) { b.on('enemyLeak', () => { b.spawnEnemy('enemy_w', {}); }); } });
|
||||
h.step(3);
|
||||
assert.ok(h.b.units.length <= 5, `bounded growth: ${h.b.units.length} units after 3 ticks`);
|
||||
h.runToEnd(10);
|
||||
assert.equal(h.b.finished, true);
|
||||
});
|
||||
|
||||
test('runaway spawning is capped at MAX_ALIVE_ENEMIES living enemies (spawnEnemy returns null beyond)', () => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_w: walker({ key: 'enemy_w', speed: 0 }) } }, content: 'none', timeLimit: 5 });
|
||||
h.step();
|
||||
let last = null;
|
||||
for (let i = 0; i < MAX_ALIVE_ENEMIES + 5; i++) last = h.b.spawnEnemy('enemy_w', { pos: [10, 8] });
|
||||
assert.equal(last, null);
|
||||
assert.equal(h.b.aliveEnemies().length, MAX_ALIVE_ENEMIES);
|
||||
// bogus schedule sizes are capped too (Infinity / 1e9 would hang the constructor)
|
||||
const b = new Battle({ stage: flatStage(), kind: 'normal', logger: quiet, quiet: true, timeLimit: 1,
|
||||
spawns: [{ time: 0, enemyKey: 'enemy_1422_lrsldr', count: Infinity }, { time: 0, enemyKey: 'enemy_1422_lrsldr', count: 1e9 }, null, { time: Infinity, enemyKey: 'enemy_1422_lrsldr' }],
|
||||
players: [null, { playerId: 'p', units: [] }] });
|
||||
assert.ok(b._pending.length <= 2 * MAX_ALIVE_ENEMIES);
|
||||
assert.equal(b._pending.filter((p) => p.time === Infinity).length, 0, 'a spawn at time Infinity is never scheduled');
|
||||
});
|
||||
|
||||
test('re-entrant kills/retreats/redeploys inside buff ticks, expiry and death hooks keep state consistent', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_guard: guard({ stats: { respawnTime: 1, cost: 0 } }) }, enemies: { enemy_walker: walker({ hp: 5000, atk: 400 }) } },
|
||||
units: [{ chessId: 't_guard', row: 9, col: 5 }, { chessId: 't_guard', row: 9, col: 6 }],
|
||||
enemies: [{ key: 'enemy_walker', count: 6, interval: 1 }], content: 'none', timeLimit: 40,
|
||||
setup(b) {
|
||||
b.on('deploy', ({ unit }) => {
|
||||
if (unit.side !== 'ally') return;
|
||||
b.addBuff(unit, { key: 'boom', duration: 0.5, onExpire: () => { b.kill(unit); b.removeBuff(unit, 'x'); }, interval: 0.1,
|
||||
onTick: ({ unit: u }) => { if (b.time > 3 && b.rng() < 0.05) b.retreat(u); } });
|
||||
b.addBuff(unit, { key: 'x', duration: 5 });
|
||||
});
|
||||
b.on('death', ({ unit }) => { if (unit.side === 'ally' && b.rng() < 0.5) b.redeploy(unit); });
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < 40 * 30 && !h.b.finished; i++) { h.b.step(); checkInvariants(h.b); }
|
||||
assert.equal(h.b.internalErrorCount ?? 0, 0);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors.slice(0, 2)));
|
||||
for (const u of h.b.allyUnits) if (!u.alive) assert.equal(u.blocking.length, 0);
|
||||
});
|
||||
|
||||
test('spGain handlers that change the SP cost mid-gain never leave sp above the new cost (stale-cost re-entrancy)', () => {
|
||||
// found by the chaos soak: gainSp captured the cost before emitting spGain; a handler set spCostMul = 0 and the
|
||||
// stale cost (25) was then written back as sp → sp 25 / cost 0
|
||||
for (const mul of [0, 0.5, 3]) {
|
||||
const h = makeBattle({ defs: { chess: { t_guard: guard({ skill: { duration: 5, spCost: 25, initSp: 0 } }) } }, units: [{ chessId: 't_guard', row: 9, col: 5 }], content: 'generic',
|
||||
setup(b) { b.on('spGain', ({ skill }) => { if (b.time > 1 && skill.spCostMul !== mul) skill.spCostMul = mul; }); } });
|
||||
for (let i = 0; i < 90; i++) { h.b.step(); checkInvariants(h.b); }
|
||||
const sk = h.unit('t_guard').skill;
|
||||
assert.ok(sk.sp <= sk.spCost + 1e-9, `mul ${mul}: sp ${sk.sp} ≤ cost ${sk.spCost}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('hit handlers see their own changes: invulnerability or fragile granted in `hit` applies to that same hit', () => {
|
||||
const mk = () => {
|
||||
const h = makeBattle({ defs: { chess: { t_guard: guard() } }, units: [{ chessId: 't_guard', row: 9, col: 5 }], content: 'none' });
|
||||
h.step();
|
||||
return h;
|
||||
};
|
||||
const inv = mk();
|
||||
inv.b.on('hit', ({ target }) => { inv.b.addBuff(target, { key: 'shieldwall', duration: 1, flags: { invulnerable: true } }); });
|
||||
assert.equal(inv.b.dealDamage(null, inv.unit('t_guard'), { amount: 500, type: 'true' }), 0, 'invulnerable from the hit hook');
|
||||
const fr = mk();
|
||||
fr.b.on('hit', ({ target }) => { if (!target.findBuff('fragile')) fr.b.applyStatus(target, 'fragile', { duration: 5, value: 1 }); });
|
||||
assert.equal(fr.b.dealDamage(null, fr.unit('t_guard'), { amount: 100, type: 'true' }), 200, 'fragile ×2 applies to the triggering hit');
|
||||
});
|
||||
|
||||
test('a skill onEnd that redeploys its unit during death is refused (no ghost unit outside the occupancy map)', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_guard: guard({ skill: { duration: 30, spCost: 1, initSp: 1 } }) }, enemies: { enemy_walker: walker() } },
|
||||
units: [{ chessId: 't_guard', row: 9, col: 5 }], enemies: [{ key: 'enemy_walker', pos: [9, 6] }], content: 'generic',
|
||||
kits: { t_guard: () => ({ skill: { kind: 'duration', duration: 30, trigger: 'SP_FULL', onEnd: ({ battle, unit, reason }) => { if (reason === 'death') battle.redeploy(unit); } } }) },
|
||||
});
|
||||
h.step(2);
|
||||
const g = h.unit('t_guard');
|
||||
assert.equal(g.skill.active, true);
|
||||
h.b.kill(g);
|
||||
assert.equal(g.alive, false, 'stays dead');
|
||||
assert.equal(h.b.unitAt(9, 5), null);
|
||||
h.b.redeploy(g); // allowed afterwards (e.g. from a death hook)
|
||||
assert.equal(g.alive, true);
|
||||
assert.equal(h.b.unitAt(9, 5), g);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// garbage numbers reaching the engine
|
||||
|
||||
test('DP flags and time limits: undefined/NaN/0 never poison DP or make a normal round endless', () => {
|
||||
const h = makeBattle({ content: 'none', flags: { dpInit: undefined, dpPerSec: NaN, dpMax: 'x' }, timeLimit: 0 });
|
||||
h.step(30);
|
||||
const pl = h.b.getPlayer('p1');
|
||||
assert.ok(Number.isFinite(pl.dp) && Math.abs(pl.dp - 11) < 1e-6, `dp ${pl.dp}`);
|
||||
assert.equal(h.b.timeLimit, 60, 'normal: invalid limit → 60 s');
|
||||
for (const tl of [NaN, -5, 0]) assert.equal(new Battle({ kind: 'normal', stage: flatStage(), timeLimit: tl, logger: quiet }).timeLimit, 60);
|
||||
assert.equal(new Battle({ kind: 'boss', stage: flatStage(), timeLimit: 0, logger: quiet }).timeLimit, Infinity);
|
||||
// malformed field rects fall back to the kind's default
|
||||
assert.deepEqual(new Battle({ kind: 'normal', stage: flatStage(), rect: { r0: 12, r1: 9, c0: 0, c1: 10 }, logger: quiet }).rect, { r0: 9, r1: 12, c0: 0, c1: 10 });
|
||||
assert.deepEqual(new Battle({ kind: 'boss', stage: flatStage(), rect: { r0: 0, r1: 5, c0: 0, c1: 99 }, logger: quiet }).rect, { r0: 0, r1: 5, c0: 0, c1: 20 });
|
||||
});
|
||||
|
||||
test('unit input rows/cols given as strings deploy correctly (no "10"+-7 concatenation); junk tiles are skipped', () => {
|
||||
const h = makeBattle({ kind: 'boss', defs: { chess: { t_guard: guard() } }, content: 'none',
|
||||
players: [{ playerId: 'A', side: 'L', units: [{ uid: 1, chessId: 't_guard', row: '10', col: '5' }, { uid: 2, chessId: 't_guard', row: 'x', col: 5 }] }] });
|
||||
h.step();
|
||||
const g = h.unit(1);
|
||||
assert.equal(g.alive, true);
|
||||
assert.deepEqual([g.tileR, g.tileC], [3, 5]);
|
||||
assert.equal(h.b.allyUnits.length, 1, 'the unit with a junk row was not created');
|
||||
});
|
||||
|
||||
test('kill handlers that write NaN / huge / negative hp: revive clamps to maxHp, otherwise the unit dies with hp 0', () => {
|
||||
for (const v of [NaN, -5, 1e12, 50]) {
|
||||
const h = makeBattle({ defs: { chess: { t_guard: guard() } }, units: [{ chessId: 't_guard', row: 9, col: 5 }], content: 'none',
|
||||
setup(b) { b.on('kill', ({ victim }) => { victim.hp = v; }); } });
|
||||
h.step();
|
||||
const g = h.unit('t_guard');
|
||||
h.b.dealDamage(null, g, { amount: 1e9, type: 'true' });
|
||||
checkInvariants(h.b);
|
||||
if (v > 0) { assert.equal(g.alive, true, `revived with ${v}`); assert.ok(g.hp <= g.s.maxHp); } else { assert.equal(g.alive, false); assert.equal(g.hp, 0); }
|
||||
}
|
||||
});
|
||||
|
||||
test('a nested kill inside kill handlers: a later handler writing hp cannot leave a dead unit with hp > 0', () => {
|
||||
// found by the chaos soak: kill(A) → handler X kills A again (nested, completes the removal) → handler Y then wrote
|
||||
// hp = 154 on the now-dead A; the outer kill() saw !alive and returned, leaving "killed with hp 154"
|
||||
let nested = false;
|
||||
const h = makeBattle({ defs: { chess: { t_guard: guard() } }, units: [{ chessId: 't_guard', row: 9, col: 5 }], content: 'none',
|
||||
setup(b) {
|
||||
b.on('kill', ({ victim }) => { if (!nested) { nested = true; b.kill(victim); } }, { priority: 10 });
|
||||
b.on('kill', ({ victim }) => { if (!victim.alive) victim.hp = 154; }, { priority: 0 });
|
||||
} });
|
||||
h.step();
|
||||
const g = h.unit('t_guard');
|
||||
h.b.kill(g);
|
||||
assert.equal(g.alive, false);
|
||||
assert.equal(g.hp, 0);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('a heal hook that kills its target: the heal is dropped (no dead unit with hp > 0)', () => {
|
||||
const h = makeBattle({ defs: { chess: { t_guard: guard() } }, units: [{ chessId: 't_guard', row: 9, col: 5 }], content: 'none',
|
||||
setup(b) { b.on('heal', ({ target }) => { b.kill(target); }); } });
|
||||
h.step();
|
||||
const g = h.unit('t_guard');
|
||||
h.b.loseHp(g, 500);
|
||||
assert.equal(h.b.heal(null, g, 300), 0);
|
||||
assert.equal(g.alive, false);
|
||||
assert.equal(g.hp, 0);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('helpers validate their inputs: spawnDevice / relocate / spawnToken / spawnEnemy / projectiles / skills', () => {
|
||||
const h = makeBattle({ defs: { chess: { t_guard: guard({ skill: { duration: 5, spCost: 10, initSp: 10 } }) }, enemies: { enemy_walker: walker() } },
|
||||
units: [{ chessId: 't_guard', row: 9, col: 5 }], content: 'generic', timeLimit: 60 });
|
||||
h.step();
|
||||
const b = h.b;
|
||||
const g = h.unit('t_guard');
|
||||
// spawnDevice: outside the rect / on a living unit / junk hp
|
||||
assert.equal(b.spawnDevice('trap_1105_accrate', 3, 5, { obstacle: true }), null, 'outside the normal rect');
|
||||
assert.equal(b.spawnDevice('trap_1105_accrate', 9, 5, { obstacle: true }), null, 'on the guard');
|
||||
assert.equal(b.spawnDevice('trap_1105_accrate', 10.5, 5), null, 'non-integer tile');
|
||||
const d = b.spawnDevice('trap_1105_accrate', 11, 6, { hp: Infinity, obstacle: true });
|
||||
assert.equal(d.hp, 100);
|
||||
// relocate: dead units, junk tiles and tiles outside the rect are refused; a move refreshes the initial range
|
||||
assert.equal(b.relocate(g, 16, 5), false);
|
||||
assert.equal(b.relocate(g, NaN, 5), false);
|
||||
assert.equal(b.relocate(g, 10, 6), true);
|
||||
assert.ok(g.baseRangeKeys.includes(10 * 21 + 6), 'initial (DEFAULT trigger) range follows the unit');
|
||||
b.kill(g);
|
||||
assert.equal(b.relocate(g, 12, 6), false, 'dead units stay put');
|
||||
b.redeploy(g);
|
||||
// spawnToken on a busy tile (even with force) → null, no half-built token left behind
|
||||
const aliveBefore = b.allyUnits.filter((u) => u.alive).length;
|
||||
const hooks = hookCount(b);
|
||||
assert.equal(g.alive, true);
|
||||
assert.equal(b.spawnToken('p1', 'x_token', 9, 5, { def: { name: 't', stats: { maxHp: 100 } }, force: true }), null);
|
||||
assert.equal(b.allyUnits.filter((u) => u.alive).length, aliveBefore, 'no half-built token');
|
||||
assert.ok(hookCount(b) <= hooks);
|
||||
const t = b.spawnToken('p1', 'x_token', 12, 8, { def: { name: 't', stats: { maxHp: 100 } }, hp: NaN, stats: { atk: NaN, def: 50 } });
|
||||
assert.equal(t.hp, 100);
|
||||
assert.equal(t.base.def, 50);
|
||||
assert.ok(Number.isFinite(t.base.atk));
|
||||
// spawnEnemy: junk multipliers and positions outside the field
|
||||
const e = b.spawnEnemy('enemy_walker', { mods: { hpMul: NaN, speedMul: -1, atkMul: Infinity }, pos: [3, 40] });
|
||||
assert.ok(Number.isFinite(e.hp) && e.hp > 0 && Number.isFinite(e.s.moveSpeed) && Number.isFinite(e.s.atk));
|
||||
assert.ok(e.x <= b.rect.c1 + 0.5 && e.y >= b.rect.r0 - 0.5, `spawned inside the field (${e.x},${e.y})`);
|
||||
// projectiles aimed at NaN never linger
|
||||
b.addProjectile({ from: g, to: { x: NaN, y: NaN }, onHit() {} });
|
||||
b.addProjectile({ from: { x: NaN, y: 3 }, target: e, maxAge: NaN, onHit() {} });
|
||||
for (const p of b.projectiles.list) assert.ok([p.x, p.y, p.tx, p.ty, p.maxAge].every(Number.isFinite));
|
||||
h.step(45);
|
||||
assert.equal(b.projectiles.list.length, 0);
|
||||
// skills: NaN cost multipliers, NaN extensions and ammo never freeze the skill
|
||||
const sk = h.unit('t_guard').skill;
|
||||
sk.spCostMul = NaN;
|
||||
assert.equal(sk.spCostMul, 1);
|
||||
sk.spCostMul = 0;
|
||||
assert.ok(sk.sp <= sk.spCost + 1e-9, 'sp ≤ cost immediately after a cost change');
|
||||
sk.spCostMul = 1;
|
||||
sk.activate('test', { free: true });
|
||||
sk.extend(NaN); sk.addAmmo(NaN); sk.addCharge(NaN);
|
||||
h.run(6);
|
||||
assert.equal(sk.active, false, 'duration skill still ends');
|
||||
checkInvariants(b);
|
||||
});
|
||||
|
||||
test('malformed kits (junk range grids, mods, talents, trait) never raise engine errors', () => {
|
||||
const junkKit = () => ({
|
||||
skill: { kind: 'duration', duration: 3, trigger: 'SP_FULL', mods: { atkPct: 'x', aspd: NaN, blockCnt: Infinity },
|
||||
targeting: { rangeGrid: 5, rangeExtend: NaN, maxTargets: 'many' }, attack: { atkScale: NaN, splashRadius: 'big', hits: Infinity } },
|
||||
talents: [null, { install: 'x' }, { install() { throw new Error('talent boom'); } }],
|
||||
trait: 'garbage',
|
||||
});
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_guard: guard({ skill: { duration: 3, spCost: 1, initSp: 1 }, rangeGrid: [[0, 0], 'x', [0, 1.5], [0, 1]] }) }, enemies: { enemy_walker: walker({ hp: 5000, atk: 50 }) } },
|
||||
units: [{ chessId: 't_guard', row: 9, col: 5 }], enemies: [{ key: 'enemy_walker', count: 3, interval: 2 }], content: 'generic', timeLimit: 20,
|
||||
kits: { t_guard: junkKit },
|
||||
});
|
||||
for (let i = 0; i < 20 * 30 && !h.b.finished; i++) { h.b.step(); if (i % 5 === 0) checkInvariants(h.b); }
|
||||
assert.equal(h.b.internalErrorCount ?? 0, 0, JSON.stringify(h.b.errors.filter((e) => e.label.startsWith('internal')).slice(0, 2)));
|
||||
assert.ok(h.unit('t_guard').skill.activations > 0);
|
||||
});
|
||||
|
||||
test('stat aggregation never produces non-finite stats (overflowing stacked multipliers fall back to base)', () => {
|
||||
const h = makeBattle({ defs: { chess: { t_guard: guard() } }, units: [{ chessId: 't_guard', row: 9, col: 5 }], content: 'none' });
|
||||
h.step();
|
||||
const g = h.unit('t_guard');
|
||||
const buff = h.b.addBuff(g, { key: 'huge', refresh: 'stack', maxStacks: 1000, mods: { hpMul: 1e9, atkMul: 1e9, moveMul: 1e9, spRecoveryMul: 1e300 } });
|
||||
for (let i = 0; i < 50; i++) h.b.addBuff(g, { key: 'huge', refresh: 'stack', maxStacks: 1000, mods: buff.mods });
|
||||
for (const k of ['maxHp', 'atk', 'def', 'interval', 'moveSpeed', 'spRecovery', 'hpRegen']) assert.ok(Number.isFinite(g.s[k]), `${k}=${g.s[k]}`);
|
||||
assert.ok(Number.isFinite(g.hp) && g.hp <= g.s.maxHp);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('zero / junk attack intervals from data never yield NaN or 0-length cooldowns', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_guard: guard({ stats: { bat: 0, aspd: 0 } }) }, enemies: { enemy_walker: walker({ bat: 0, aspd: -50, hp: 1e9 }) } },
|
||||
units: [{ chessId: 't_guard', row: 9, col: 5 }], enemies: [{ key: 'enemy_walker', pos: [9, 5] }], content: 'none', timeLimit: 5,
|
||||
});
|
||||
h.run(3);
|
||||
const g = h.unit('t_guard'), e = h.enemies()[0];
|
||||
for (const u of [g, e]) assert.ok(Number.isFinite(u.s.interval) && u.s.interval > 0, `${u.kind} interval ${u.s.interval}`);
|
||||
assert.ok(g.stats.attacks <= 3 / g.s.interval + 2, 'attack count bounded by the interval');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('broken shared boss pool (NaN hp) never leaks NaN into the boss unit', () => {
|
||||
const pool = { hp: 1000, maxHp: 1000, damage() { this.hp = NaN; } };
|
||||
const h = makeBattle({ kind: 'boss', sharedBoss: pool, content: 'none',
|
||||
defs: { chess: { t_sniper: sniper() }, enemies: { enemy_boss: enemyRec({ key: 'enemy_boss', hp: 1e6, speed: 0 }) } },
|
||||
players: [{ playerId: 'A', side: 'L', units: [{ uid: 1, chessId: 't_sniper', row: 10, col: 3 }] }],
|
||||
enemies: [{ key: 'enemy_boss', pos: [3, 8], tag: 'boss' }] });
|
||||
h.run(5);
|
||||
const boss = h.enemies()[0];
|
||||
assert.ok(Number.isFinite(boss.hp));
|
||||
assert.ok(Number.isFinite(h.result().perPlayer.A.bossDamage));
|
||||
assert.ok(Number.isFinite(h.unit(1).stats.dmg));
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// movement: unreachable goals, blocked paths, empty routes
|
||||
|
||||
test('route legs outside the field rect are clamped: a flyer finishes its route instead of hovering at the border', () => {
|
||||
// mirrors act1autochess_h07_01 extraRoutes[1] (FLY start (6,10) → (6,17): row 6 is outside the boss rect)
|
||||
const route = { motion: 'FLY', start: [6, 10], end: [1, 3], checkpoints: [[6, 17]] };
|
||||
const h = makeBattle({ kind: 'boss', content: 'none', defs: { enemies: { enemy_f: enemyRec({ key: 'enemy_f', motion: 'FLY', speed: 3 }) } },
|
||||
players: [{ playerId: 'A', side: 'L', units: [] }], enemies: [{ key: 'enemy_f', route }], autoFinish: true });
|
||||
h.step();
|
||||
assert.equal(h.enemies().length, 1);
|
||||
h.runUntil(() => h.enemies().length === 0, 60);
|
||||
assert.equal(h.enemies().length, 0, 'reached its goal');
|
||||
assert.equal(h.result().perPlayer.A.leaked.length, 1);
|
||||
// and the real template still converts cleanly
|
||||
const tpl = getDefaultSource().getWave('act1autochess_h07_01');
|
||||
if (tpl) assert.ok(spawnsFromTemplate(tpl).extraRoutes.length > 0);
|
||||
});
|
||||
|
||||
test('crates blocking every path: attackers break through, harmless walkers time out (battle always terminates)', () => {
|
||||
const crates = [[9, 6], [10, 6], [11, 6], [12, 6]];
|
||||
const brk = makeBattle({ flat: { crates }, content: 'none', timeLimit: 60, defs: { enemies: { enemy_walker: walker({ atk: 300, speed: 2 }) } }, enemies: [{ key: 'enemy_walker', count: 3, interval: 1 }] });
|
||||
brk.runToEnd(70);
|
||||
assert.equal(brk.result().perPlayer.p1.leaked.length, 3, 'crate destroyed, everyone walked through');
|
||||
assert.ok(brk.b.allyUnits.some((u) => u.kind === 'device' && !u.alive));
|
||||
const stuck = makeBattle({ flat: { crates }, content: 'none', timeLimit: 20, defs: { enemies: { enemy_walker: walker({ atk: 0, speed: 2 }) } }, enemies: [{ key: 'enemy_walker' }] });
|
||||
stuck.runToEnd(30);
|
||||
assert.equal(stuck.result().reason, 'timeout');
|
||||
assert.ok(stuck.b.time <= 20 + 1e-6);
|
||||
checkInvariants(stuck.b);
|
||||
});
|
||||
|
||||
test('empty routes / no goal tile: enemies leak immediately instead of wandering; spawns on an occupied tile get blocked', () => {
|
||||
const stage = { id: 'noend', rows: Array.from({ length: 19 }, () => 'r'.repeat(21)), devices: [] };
|
||||
const h = makeBattle({ stage, routes: [], content: 'none', defs: { enemies: { enemy_walker: walker() } }, enemies: [{ key: 'enemy_walker', count: 3 }] });
|
||||
h.runToEnd(5);
|
||||
assert.equal(h.b.finished, true);
|
||||
assert.equal(h.result().perPlayer.p1.leaked.length, 3);
|
||||
const o = makeBattle({ defs: { chess: { t_guard: guard() }, enemies: { enemy_walker: walker() } }, units: [{ chessId: 't_guard', row: 10, col: 6 }], content: 'none',
|
||||
enemies: [{ key: 'enemy_walker', pos: [10, 6] }] });
|
||||
o.step(2);
|
||||
assert.equal(o.enemies()[0].blockedBy, o.unit('t_guard'));
|
||||
});
|
||||
|
||||
test('an enemy that turns unblockable through a plain buff walks on', () => {
|
||||
const h = makeBattle({ defs: { chess: { t_guard: guard({ stats: { atk: 0 } }) }, enemies: { enemy_walker: walker({ speed: 2 }) } },
|
||||
units: [{ chessId: 't_guard', row: 9, col: 5 }], enemies: [{ key: 'enemy_walker', route: 0 }], content: 'none', timeLimit: 60 });
|
||||
h.runUntil(() => h.enemies()[0]?.blockedBy, 20);
|
||||
const e = h.enemies()[0];
|
||||
assert.ok(e.blockedBy);
|
||||
h.b.addBuff(e, { key: 'phase', flags: { unblockable: true } });
|
||||
h.step(2);
|
||||
assert.equal(e.blockedBy, null);
|
||||
assert.equal(h.unit('t_guard').blocking.length, 0);
|
||||
h.runToEnd(60);
|
||||
assert.equal(h.result().perPlayer.p1.leaked.length, 1);
|
||||
});
|
||||
|
||||
test('a single-target projectile whose target dies mid-flight fizzles without errors', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sniper: sniper({ stats: { atk: 100 } }) }, enemies: { enemy_walker: walker({ hp: 1e6, speed: 0 }) } },
|
||||
units: [{ chessId: 't_sniper', row: 9, col: 3 }], enemies: [{ key: 'enemy_walker', pos: [9, 9] }, { key: 'enemy_walker', pos: [12, 9] }], content: 'none', timeLimit: 10,
|
||||
});
|
||||
h.runUntil(() => h.b.projectiles.list.length > 0, 5);
|
||||
const e = h.enemies()[0];
|
||||
h.b.kill(e);
|
||||
const dmgBefore = h.eventsOf('dmg').length;
|
||||
h.step(30);
|
||||
assert.equal(h.b.finished, false);
|
||||
assert.equal(h.eventsOf('dmg').length, dmgBefore, 'no damage to a dead target');
|
||||
assert.equal(h.b.projectiles.list.length, 0);
|
||||
assert.equal(h.b.errors.length, 0);
|
||||
});
|
||||
|
||||
test('an enemy shot in flight at an operator that dies and is redeployed at once fizzles (new life, new target)', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_guard: guard({ stats: { respawnTime: 0, cost: 0 } }) }, enemies: { enemy_gun: enemyRec({ key: 'enemy_gun', hp: 1e9, atk: 500, range: 6, bat: 20, speed: 0 }) } },
|
||||
units: [{ chessId: 't_guard', row: 9, col: 3 }], enemies: [{ key: 'enemy_gun', pos: [9, 8] }], content: 'none', timeLimit: 30,
|
||||
});
|
||||
h.runUntil(() => h.b.projectiles.list.length > 0, 5);
|
||||
const g = h.unit('t_guard');
|
||||
assert.equal(h.b.projectiles.list.length, 1);
|
||||
h.b.kill(g);
|
||||
assert.ok(h.b.redeploy(g));
|
||||
const hp = g.hp;
|
||||
h.step(60);
|
||||
assert.equal(h.b.projectiles.list.length, 0);
|
||||
assert.equal(g.hp, hp, 'the redeployed operator was not hit by the old shot');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// listener / timer leaks
|
||||
|
||||
test('hooks and periodic timers owned by removed enemies/tokens are released at step end; their own death/leak handlers still fire', () => {
|
||||
let deaths = 0, leaks = 0, afterRan = 0, everyRuns = 0;
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sniper: sniper({ stats: { atk: 3000 }, rangeGrid: wideGrid() }) }, enemies: { enemy_walker: walker({ hp: 500, speed: 3 }) } },
|
||||
units: [{ chessId: 't_sniper', row: 10, col: 3 }], enemies: [{ key: 'enemy_walker', count: 30, interval: 0.3 }], content: 'none', timeLimit: 60,
|
||||
setup(b) {
|
||||
b.on('enemySpawn', ({ enemy }) => {
|
||||
b.on('damaged', () => {}, { owner: enemy });
|
||||
b.on('death', ({ unit }) => { if (unit === enemy) { deaths++; b.after(0.5, () => { afterRan++; }, { owner: enemy }); } }, { owner: enemy });
|
||||
b.on('enemyLeak', (c) => { if (c.enemy === enemy) leaks++; }, { owner: enemy });
|
||||
b.every(0.2, () => { everyRuns++; }, { owner: enemy });
|
||||
});
|
||||
},
|
||||
});
|
||||
h.step();
|
||||
const base = hookCount(h.b);
|
||||
h.runToEnd(70);
|
||||
assert.equal(deaths, 30, 'own death handler ran for every enemy (killed or leaked)');
|
||||
assert.equal(leaks, h.result().perPlayer.p1.leaked.length, 'own leak handler ran');
|
||||
const dead = h.b.units.filter((u) => u.side === 'enemy');
|
||||
const due = dead.filter((u) => u.deathAt + 0.5 < h.b.time - 1e-6).length;
|
||||
assert.ok(afterRan >= due && afterRan <= deaths, `one-shot after() owned by a dead enemy still runs (${afterRan}, due ${due})`);
|
||||
assert.ok(h.result().perPlayer.p1.killed > 0 && leaks > 0, 'both kill and leak paths exercised');
|
||||
assert.ok(hookCount(h.b) <= base, `hooks released (${hookCount(h.b)} vs ${base})`);
|
||||
assert.equal(h.b._sched.filter((s) => s.interval > 0 && !s.cancelled).length, 0, 'periodic timers of dead enemies cancelled');
|
||||
assert.ok(everyRuns > 0);
|
||||
});
|
||||
|
||||
test('client event buffer stays bounded when nobody drains', () => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_walker: walker({ speed: 0 }) } }, content: 'none', recordEvents: true, timeLimit: 1e5 });
|
||||
h.step();
|
||||
for (let i = 0; i < 70000; i++) h.b.fx('spam', { x: 1, y: 1 });
|
||||
assert.ok(h.b._evq.length <= 50000);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// chaos fuzz, determinism, performance (real data)
|
||||
|
||||
const ds = getDefaultSource();
|
||||
const WEIRD = [0, -1, NaN, Infinity, -Infinity, 1e9, 0.5, 3, undefined, null];
|
||||
const BUFFKEYS = ['stun', 'freeze', 'cold', 'sleep', 'slow', 'bind', 'fragile', 'silence', 'fear', 'levitate', 'stealth', 'invulnerable', 'taunt', 'bogus', 'attract', 'resist', 'palsy'];
|
||||
|
||||
/** Content module that randomly abuses every public helper from inside hooks (seeded → deterministic). */
|
||||
function chaosContent(cr, P, pools) {
|
||||
return {
|
||||
install(b) {
|
||||
const w = () => cr.pick(WEIRD);
|
||||
const any = () => cr.pick(b.units);
|
||||
const act = () => {
|
||||
if (!cr.chance(P)) return;
|
||||
const u = any();
|
||||
if (!u) return;
|
||||
switch (cr.int(24)) {
|
||||
case 0: b.kill(u, any()); break;
|
||||
case 1: b.retreat(u, { permanent: cr.chance(0.3) }); break;
|
||||
case 2: b.redeploy(u, cr.chance(0.5) ? {} : { free: cr.chance(0.5), keepSp: cr.chance(0.5), tile: cr.pick([null, [cr.int(19), cr.int(21)], [w(), w()], 'x', [u.tileR, u.tileC]]) }); break;
|
||||
case 3: b.relocate(u, cr.int(21), cr.int(21)); break;
|
||||
case 4: b.displace(u, { x: w() ?? 1, y: w() ?? 0 }, w(), { force: w() }); break;
|
||||
case 5: b.applyStatus(u, cr.pick(BUFFKEYS), { duration: w(), value: w(), source: any(), point: cr.pick([undefined, [w(), w()], [cr.int(19), cr.int(21)], { x: w(), y: w() }, 'x']) }); break;
|
||||
case 6: b.addBuff(u, { key: 'c' + cr.int(3), duration: w(), refresh: cr.pick(['replace', 'extend', 'stack', 'independent', 'keep']), maxStacks: w(),
|
||||
mods: { atkPct: w(), aspd: w(), hpMul: w(), moveMul: w(), blockCnt: w(), batPct: w(), shield: w(), massFlat: w(), rangeExtend: w() }, persist: cr.chance(0.2), interval: w(),
|
||||
onTick: cr.chance(0.3) ? () => { if (cr.chance(0.2)) b.kill(u); } : null, onExpire: cr.chance(0.3) ? () => b.kill(any()) : null }); break;
|
||||
case 7: b.spawnEnemy(cr.pick(pools.enemies), { routeIndex: cr.int(30) - 2, pos: cr.chance(0.3) ? [cr.int(19), cr.int(21)] : undefined, mods: { hpMul: w(), speedMul: w() }, tag: cr.pick([null, 'boss', 'part']) }); break;
|
||||
case 8: b.spawnToken(cr.chance(0.5) ? u : u.ownerId, cr.pick(pools.tokens), cr.int(19), cr.int(21), { duration: w(), hp: w(), force: cr.chance(0.3) }); break;
|
||||
case 9: b.spawnDevice('trap_1105_accrate', cr.int(19), cr.int(21), { hp: w(), obstacle: true }); break;
|
||||
case 10: b.setObstacle(cr.int(19), cr.int(21), cr.chance(0.5)); break;
|
||||
case 11: b.loseHp(u, w()); break;
|
||||
case 12: b.heal(any(), u, w(), { overheal: cr.chance(0.5) }); break;
|
||||
case 13: b.dealDamage(any(), u, { amount: w(), type: cr.pick(['phys', 'arts', 'true', 'element']), element: cr.pick(['burn', 'neural', 'necrosis', 'apoptosis']), mul: w() }); break;
|
||||
case 14: b.addDp(u.ownerId, w()); b.addLayers(u.ownerId, 'bond_x', w()); b.addCoins(u.ownerId, w()); break;
|
||||
case 15: if (u.skill) { const m = cr.int(5); if (m === 0) u.skill.gainSp(w()); else if (m === 1) u.skill.activate('chaos', { free: cr.chance(0.5) }); else if (m === 2) u.skill.end('chaos'); else if (m === 3) u.skill.extend(w()); else u.skill.spCostMul = w(); } break;
|
||||
case 16: b.after(w(), () => { const v = any(); if (v) b.kill(v); }, { owner: cr.chance(0.5) ? u : null }); break;
|
||||
case 17: b.every(w(), () => { const v = any(); if (v && cr.chance(0.05)) b.retreat(v); }, { owner: u }); break;
|
||||
case 18: if (cr.chance(0.002)) b.forceEnd(cr.pick(['forced', 'timeout'])); break;
|
||||
case 19: b.forceAttack(u, cr.chance(0.8) ? null : [any()], { noAmmo: cr.chance(0.5) }); break;
|
||||
case 20: b.addProjectile({ from: u, target: any(), to: cr.chance(0.3) ? { x: w(), y: w() } : undefined, speed: w(), onHit: (c) => { if (c.target) b.kill(c.target); }, hitDead: cr.chance(0.5) }); break;
|
||||
case 21: b.setExtraRange(u, cr.pick([null, [], [w(), cr.int(399)], 'x', [cr.int(399), cr.int(399)]])); break;
|
||||
case 22: if (u.skill && cr.chance(0.1)) u.skill.addTriggerRange(cr.pick([() => [any()], () => [[cr.int(399)]], () => null, () => { throw new Error('chaos trigger'); }, () => 5])); break;
|
||||
default: if (u.side === 'enemy') b.leak(u); break;
|
||||
}
|
||||
};
|
||||
for (const name of ['damaged', 'kill', 'death', 'attack', 'beforeAttack', 'skillStart', 'skillEnd', 'deploy', 'blocked', 'enemySpawn', 'enemyLeak', 'tick', 'heal', 'statusApplied', 'hit', 'fatal', 'spGain']) {
|
||||
b.on(name, act, { priority: cr.int(5) - 2 });
|
||||
}
|
||||
b.on('kill', (c) => { if (cr.chance(P) && c.victim.side === 'ally') c.victim.hp = cr.pick([1, NaN, -5, 1e12]); });
|
||||
b.on('fatal', (c) => { if (cr.chance(P)) c.prevented = true; });
|
||||
b.on('beforeAttack', (c) => { if (cr.chance(P)) c.targets = cr.pick([null, [], [any()], [null, undefined]]); });
|
||||
b.on('hit', (c) => { if (cr.chance(P)) c.dmg.amount = w(); });
|
||||
b.on('heal', (c) => { if (cr.chance(P)) c.amount = w(); });
|
||||
b.on('spGain', (c) => { if (cr.chance(P)) c.amount = w(); });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function realPools() {
|
||||
const chess = ds.chessIds().filter((id) => { const r = ds.rawChess(id); return r && r.stats && r.visible !== false && !r.isHidden && r.chessType !== 'DIY'; });
|
||||
const stages = ds.stageIds().filter((id) => { const s = ds.getStage(id); return s && s.raw && s.raw.active !== false && s.raw.deployTiles; });
|
||||
const waves = ds.waveIds().filter((id) => ['normal', 'boss', 'hidden', 'escaped'].includes(ds.getWave(id)?.kind));
|
||||
return { chess, stages, waves, enemies: Object.keys(ds.raw.enemies), tokens: Object.keys(ds.raw.tokens) };
|
||||
}
|
||||
|
||||
function realBattle(P, seed, { chaos = 0, stageId = null, waveId = null, extra = null, units = null } = {}) {
|
||||
const r = createRng(seed);
|
||||
stageId = stageId ?? r.pick(P.stages);
|
||||
waveId = waveId ?? r.pick(P.waves);
|
||||
const stage = ds.getStage(stageId), tpl = ds.getWave(waveId);
|
||||
const kind = tpl.kind === 'escaped' ? 'unite' : tpl.kind;
|
||||
const { routes, spawns, maxPlayTime } = spawnsFromTemplate(tpl, { mods: { hpMul: 0.7 } });
|
||||
const lineup = (where, n, off = 0) => {
|
||||
const dt = stage.raw.deployTiles[where];
|
||||
const tiles = [...dt.melee, ...dt.rangedOnly];
|
||||
r.shuffle(tiles);
|
||||
return tiles.slice(0, n).map(([a, c], j) => ({ uid: j + 1, kind: 'chess', chessId: r.pick(P.chess), row: a, col: c + off, abs: true }));
|
||||
};
|
||||
let players, sharedBoss = null;
|
||||
if (kind === 'boss' || kind === 'hidden') {
|
||||
players = [{ playerId: 'A', side: 'L', units: lineup('bossLeft', units ?? 1 + r.int(9)) }, { playerId: 'B', side: 'R', units: lineup('bossRight', units ?? 1 + r.int(9)) }];
|
||||
sharedBoss = { hp: 1e5, maxHp: 1e5, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } };
|
||||
} else if (kind === 'unite') {
|
||||
players = [{ playerId: 'A', side: 'L', units: lineup('normal', 1 + r.int(8)) }, { playerId: 'B', side: 'L', colOffset: 8, units: lineup('normal', 1 + r.int(8), 8) }];
|
||||
} else players = [{ playerId: 'P', side: 'L', units: lineup('normal', 1 + r.int(9)) }];
|
||||
const bossLike = kind === 'boss' || kind === 'hidden';
|
||||
const b = new Battle({
|
||||
seed, kind, stageId, routes, spawns: spawns.concat(extra ? extra(routes) : []), players, sharedBoss, logger: quiet, quiet: true,
|
||||
timeLimit: bossLike ? Infinity : (kind === 'unite' ? 60 : maxPlayTime ?? 60), enemyOverrides: tpl.overrides ?? {},
|
||||
extraContent: chaos ? [chaosContent(createRng(seed ^ 0xc4a05), chaos, P)] : [],
|
||||
});
|
||||
return { b, kind, stageId, waveId, bossLike };
|
||||
}
|
||||
|
||||
test('chaos fuzz: content abusing every helper from every hook never breaks invariants or the engine (real data)', () => {
|
||||
const P = realPools();
|
||||
const N = Number(process.env.SIM_CHAOS_N) || 12; // SIM_CHAOS_N=500 for a soak
|
||||
const rng = createRng(Number(process.env.SIM_CHAOS_SEED) || 424242);
|
||||
const stats = { simSeconds: 0, reasons: {}, contentErrors: 0 };
|
||||
for (let i = 0; i < N; i++) {
|
||||
const seed = (rng() * 2 ** 32) >>> 0;
|
||||
const { b, kind, stageId, waveId, bossLike } = realBattle(P, seed, { chaos: 0.04 });
|
||||
const cap = bossLike ? 40 : b.timeLimit + 1;
|
||||
const tag = `#${i} ${kind} ${stageId} ${waveId} seed=${seed}`;
|
||||
let n = 0;
|
||||
try {
|
||||
while (!b.finished && b.time < cap) {
|
||||
b.step();
|
||||
if (++n % 4 === 0) checkInvariants(b);
|
||||
if (n % 3 === 0) { b.snapshot(); b.drainEvents(); }
|
||||
}
|
||||
} catch (e) { e.message = `${tag}: ${e.message}`; throw e; }
|
||||
if (!bossLike) assert.ok(b.finished, `${tag} terminated`);
|
||||
checkInvariants(b);
|
||||
assert.equal(b.internalErrorCount ?? 0, 0, `${tag}: ${JSON.stringify(b.errors.filter((e) => e.label.startsWith('internal')).slice(0, 2))}`);
|
||||
JSON.stringify(b.result());
|
||||
stats.simSeconds += b.time;
|
||||
stats.reasons[b.reason ?? 'running'] = (stats.reasons[b.reason ?? 'running'] ?? 0) + 1;
|
||||
stats.contentErrors += b.errorCount;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`chaos: ${N} battles, ${stats.simSeconds.toFixed(0)} s simulated, ${JSON.stringify(stats.reasons)}, ${stats.contentErrors} content errors`);
|
||||
assert.ok(stats.simSeconds >= N * 10, 'battles ran long enough to matter');
|
||||
});
|
||||
|
||||
test('determinism: identical event streams regardless of battle order / interleaving (real data, with chaos content)', () => {
|
||||
const P = realPools();
|
||||
const cases = [[11, 'act2autochess_m01', 'act1autochess_h05'], [12, 'act2autochess_m01', 'act1autochess_h07_01'], [13, 'act1autochess_m01', 'act1autochess_escaped_multi'], [14, 'act2autochess_m03', 'act1autochess_h08_01']]
|
||||
.filter(([, s, w]) => ds.getStage(s) && ds.getWave(w));
|
||||
const runAll = (order, interleave) => {
|
||||
const bs = order.map((i) => realBattle(P, cases[i][0], { chaos: 0.01, stageId: cases[i][1], waveId: cases[i][2] }).b);
|
||||
const logs = bs.map(() => []);
|
||||
for (let t = 0, active = true; active; t++) {
|
||||
active = false;
|
||||
bs.forEach((b, k) => {
|
||||
const steps = interleave ? 1 + ((t + k) % 3) : 1e9;
|
||||
for (let s = 0; s < steps && !b.finished && b.time < 45; s++) { b.step(); if (b.tickCount % 3 === 0) logs[k].push(hashOf(b.snapshot()), hashOf(b.drainEvents())); }
|
||||
if (!b.finished && b.time < 45) active = true;
|
||||
});
|
||||
}
|
||||
const out = {};
|
||||
order.forEach((i, k) => { out[i] = hashOf(logs[k]) + hashOf(bs[k].result()); });
|
||||
return out;
|
||||
};
|
||||
const seq = runAll(cases.map((_, i) => i), false);
|
||||
const inter = runAll(cases.map((_, i) => i).reverse(), true);
|
||||
assert.deepEqual(inter, seq);
|
||||
});
|
||||
|
||||
test('determinism: shared defs are frozen — a kit that mutates its blackboard / range cannot leak into later battles', () => {
|
||||
const chessId = 'chess_char_1_02_a';
|
||||
const def = ds.getChess(chessId);
|
||||
if (!def) return;
|
||||
assert.ok(Object.isFrozen(def) && Object.isFrozen(def.stats) && Object.isFrozen(def.rangeGrid));
|
||||
if (def.skill) assert.ok(Object.isFrozen(def.skill.bb));
|
||||
const evil = (bb, chess, d) => { bb.atk = 99; d.rangeGrid.push([0, 9]); d.stats.atk *= 10; return null; };
|
||||
const run = () => {
|
||||
const h = makeBattle({ data: ds, stageId: 'act2autochess_m01', units: [{ chessId, row: 9, col: 7 }], kits: { [def.baseId]: evil },
|
||||
enemies: [{ key: 'enemy_1422_lrsldr', count: 4, interval: 2 }], timeLimit: 40 });
|
||||
h.runToEnd(45);
|
||||
return { hash: hashOf(h.result()), errs: h.b.errors.filter((e) => e.label.startsWith('kit:')).length };
|
||||
};
|
||||
const a = run(), b = run();
|
||||
assert.equal(a.hash, b.hash, 'second battle unaffected by the first');
|
||||
assert.ok(a.errs >= 1, 'the mutation attempt surfaced as a kit error');
|
||||
assert.ok(def.stats.atk < 5000 && !def.rangeGrid.some(([r, c]) => r === 0 && c === 9));
|
||||
});
|
||||
|
||||
test('performance: heavy 2-player boss field (18 ops, ~130 enemies alive) averages < 0.5 ms/tick', () => {
|
||||
const P = realPools();
|
||||
if (!ds.getStage('act2autochess_m01') || !ds.getWave('act1autochess_h07_01')) return;
|
||||
const keys = ['enemy_1422_lrsldr', 'enemy_1427_lrnazg', 'enemy_1005_yokai', 'enemy_1042_frostd', 'enemy_1425_lrcmra', 'enemy_1040_bombd'].filter((k) => ds.getEnemy(k));
|
||||
const extra = (routes) => Array.from({ length: 120 }, (_, i) => ({ time: (i % 20) * 0.25, enemyKey: keys[i % keys.length], routeIndex: i % routes.length, mods: { hpMul: 20 } }));
|
||||
const mk = (seed) => realBattle(P, seed, { stageId: 'act2autochess_m01', waveId: 'act1autochess_h07_01', extra, units: 9 }).b;
|
||||
for (let w = 0; w < 2; w++) { const b = mk(100 + w); while (b.time < 15) b.step(); }
|
||||
const b = mk(7);
|
||||
let n = 0, peak = 0;
|
||||
const t0 = performance.now();
|
||||
while (!b.finished && b.time < 60) {
|
||||
b.step();
|
||||
if (++n % 3 === 0) { b.snapshot(); b.drainEvents(); }
|
||||
peak = Math.max(peak, b.enemies.length);
|
||||
}
|
||||
const avg = (performance.now() - t0) / n;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`bench boss field: ${n} ticks, peak ${peak} alive, ${b.allyUnits.length} allies, avg ${(avg * 1000).toFixed(1)} µs/tick`);
|
||||
assert.ok(peak >= 100, `peak ${peak}`);
|
||||
assert.equal(b.allyUnits.filter((u) => u.kind === 'op').length, 18);
|
||||
assert.ok(avg < 0.5, `avg ${avg} ms/tick`);
|
||||
assert.equal(b.errors.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
// Skills runtime: SP types, trigger rules, kinds (duration/ammo/instant/charges/passive/toggle), SkillSpec, generic kits.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
|
||||
import { genericSkillSpec, genericKind } from '../../server/sim/content/generic.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.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 });
|
||||
|
||||
function sniperWith(skill, o = {}) {
|
||||
return chessRec({ id: 't_sn', profession: 'SNIPER', subProfessionId: 'closerange', stats: { atk: 100, bat: 1, ...(o.stats || {}) }, rangeGrid: RANGE3, skill, ...o });
|
||||
}
|
||||
|
||||
test('SP: time regen, starts at initSp, capped at cost; DEFAULT waits for an enemy in the initial range', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sn: sniperWith({ spCost: 5, initSp: 2, duration: 3, bb: { atk: 1 } }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic',
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('t_sn');
|
||||
approx(u.skill.sp, 2 + 1 / 30, 1e-6);
|
||||
h.run(5);
|
||||
assert.equal(u.skill.sp, 5);
|
||||
assert.equal(u.skill.ready, true);
|
||||
assert.equal(u.skill.activations, 0, 'no enemy in range: DEFAULT waits');
|
||||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.run(0.1);
|
||||
assert.equal(u.skill.activations, 1);
|
||||
assert.equal(u.skill.active, true);
|
||||
approx(u.s.atk, 200);
|
||||
const sp = u.skill.sp;
|
||||
h.run(1);
|
||||
assert.equal(u.skill.sp, sp, 'no SP gain during a duration skill');
|
||||
h.run(2.1);
|
||||
assert.equal(u.skill.active, false);
|
||||
approx(u.s.atk, 100);
|
||||
assert.equal(h.hooksOf('skillStart').length, 1);
|
||||
assert.equal(h.hooksOf('skillEnd').length, 1);
|
||||
assert.deepEqual(h.eventsOf('skill').map((e) => e[2]), [1, 0]);
|
||||
});
|
||||
|
||||
test('SP types: attack (+1 per attack) and hurt (+1 per hit taken)', () => {
|
||||
const h = makeBattle({
|
||||
defs: {
|
||||
chess: {
|
||||
t_sn: sniperWith({ spType: 'INCREASE_WHEN_ATTACK', spCost: 100, initSp: 0, duration: 3 }),
|
||||
t_tank: chessRec({ id: 't_tank', profession: 'TANK', stats: { atk: 0, maxHp: 1e6, blockCnt: 3 }, skill: { spType: 'INCREASE_WHEN_TAKEN_DAMAGE', spCost: 100, initSp: 0, duration: 3 } }),
|
||||
},
|
||||
enemies: { enemy_dummy: dummy({ atk: 10, bat: 1 }) },
|
||||
},
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4 }, { chessId: 't_tank', row: 9, col: 6 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }], content: 'generic',
|
||||
});
|
||||
h.run(0.2);
|
||||
const e = h.enemy('enemy_dummy');
|
||||
assert.equal(e.blockedBy, h.unit('t_tank'));
|
||||
h.run(4.9);
|
||||
const sn = h.unit('t_sn'), tank = h.unit('t_tank');
|
||||
assert.equal(sn.skill.sp, sn.stats.attacks);
|
||||
assert.ok(tank.skill.sp >= 4 && tank.skill.sp === Math.round(tank.skill.sp), `hurt sp ${tank.skill.sp}`);
|
||||
});
|
||||
|
||||
test('trigger TAKE_DAMAGE fires on the first hit once ready; SP_FULL fires immediately; SEARCH needs an enemy in the initial range', () => {
|
||||
const h = makeBattle({
|
||||
defs: {
|
||||
chess: {
|
||||
t_tank: chessRec({ id: 't_tank', profession: 'TANK', stats: { atk: 0, maxHp: 1e6, blockCnt: 3 }, skill: { spCost: 1, initSp: 1, duration: 2, trigger: { rule: 'TAKE_DAMAGE' } } }),
|
||||
t_bard: chessRec({ id: 't_bard', profession: 'SUPPORT', subProfessionId: 'bard', skill: { spCost: 1, initSp: 1, duration: 2, trigger: { rule: 'ALWAYS' } } }),
|
||||
t_ph: chessRec({ id: 't_ph', profession: 'CASTER', subProfessionId: 'phalanx', attackKind: 'none', rangeGrid: [[0, 0], [0, 1], [0, 2]], skill: { spCost: 2, initSp: 0, duration: 2, trigger: { rule: 'SEARCH' } } }),
|
||||
},
|
||||
enemies: { enemy_dummy: dummy({ atk: 10, bat: 1 }) },
|
||||
},
|
||||
units: [{ chessId: 't_tank', row: 9, col: 6 }, { chessId: 't_bard', row: 12, col: 3 }, { chessId: 't_ph', row: 11, col: 3 }],
|
||||
content: 'generic', autoFinish: false,
|
||||
});
|
||||
h.run(0.1);
|
||||
assert.equal(h.unit('t_bard').skill.activations, 1, 'SP_FULL');
|
||||
assert.equal(h.unit('t_tank').skill.activations, 0);
|
||||
h.run(3);
|
||||
assert.equal(h.unit('t_ph').skill.activations, 0, 'SEARCH: no enemy on field');
|
||||
h.spawn('enemy_dummy', { pos: [9, 6] });
|
||||
h.run(1.5);
|
||||
assert.equal(h.unit('t_tank').skill.activations, 1, 'TAKE_DAMAGE');
|
||||
// research 03 Addendum C1 (BWIKI): "解放者/阵法术师子职业干员 技能1,初始攻击范围内出现敌人后自动释放" — an enemy
|
||||
// elsewhere on the field is not enough (integration review: the old rule burnt the skill on enemies at the gate)
|
||||
assert.equal(h.unit('t_ph').skill.activations, 0, 'SEARCH: an enemy outside the initial range does not trigger');
|
||||
h.spawn('enemy_dummy', { pos: [11, 5] });
|
||||
h.run(0.2);
|
||||
assert.equal(h.unit('t_ph').skill.activations, 1, 'SEARCH: an enemy inside the initial range, without an attack');
|
||||
});
|
||||
|
||||
test('trigger CUSTOM_RANGE uses the custom grid; unknown rules fall back to DEFAULT', () => {
|
||||
const h = makeBattle({
|
||||
defs: {
|
||||
chess: {
|
||||
t_cr: sniperWith({ spCost: 1, initSp: 1, duration: 2, trigger: { rule: 'CUSTOM_RANGE', customRangeGrid: [[0, 0], [0, 1]] } }, { id: 't_cr' }),
|
||||
t_unk: sniperWith({ spCost: 1, initSp: 1, duration: 2, trigger: { rule: 'MLYSS_WTRMAN' } }, { id: 't_unk' }),
|
||||
},
|
||||
enemies: { enemy_dummy: dummy() },
|
||||
},
|
||||
units: [{ chessId: 't_cr', row: 10, col: 4 }, { chessId: 't_unk', row: 12, col: 4 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 7] }], content: 'generic',
|
||||
});
|
||||
h.run(1);
|
||||
assert.equal(h.unit('t_cr').skill.activations, 0, 'enemy in attack range but outside the custom grid');
|
||||
assert.ok(h.unit('t_cr').stats.attacks > 0);
|
||||
h.b.units.find((u) => u.defId === 'enemy_dummy').x = 5;
|
||||
h.run(0.2);
|
||||
assert.equal(h.unit('t_cr').skill.activations, 1);
|
||||
h.spawn('enemy_dummy', { pos: [12, 6] });
|
||||
h.run(0.2);
|
||||
assert.equal(h.unit('t_unk').skill.activations, 1, 'unknown rule behaves like DEFAULT');
|
||||
});
|
||||
|
||||
test('ammo skills end when ammo runs out; ammoUsed fires per shot; no SP gain while active', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sn: sniperWith({ durationType: 'AMMO', duration: 0, spCost: 5, initSp: 5, bb: { atk: 0.5, 'attack@trigger_time': 4 } }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], content: 'generic',
|
||||
hooks: ['ammoUsed', 'skillStart', 'skillEnd'],
|
||||
});
|
||||
h.run(0.1);
|
||||
const u = h.unit('t_sn');
|
||||
assert.equal(u.skill.kind, 'ammo');
|
||||
assert.equal(u.skill.active, true);
|
||||
h.run(3.1);
|
||||
assert.equal(u.skill.active, false);
|
||||
assert.deepEqual(h.hooksOf('ammoUsed').map((c) => c.left), [3, 2, 1, 0]);
|
||||
assert.equal(h.hooksOf('skillEnd').length, 1);
|
||||
assert.equal(h.hooksOf('skillEnd')[0].reason, 'ammo');
|
||||
});
|
||||
|
||||
test('charges accumulate up to maxCharges and are spent one per cast', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sn: sniperWith({ duration: 0, spCost: 2, initSp: 0, maxChargeTime: 3, bb: { atk_scale: 3 } }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic', autoFinish: false,
|
||||
});
|
||||
h.run(7);
|
||||
const u = h.unit('t_sn');
|
||||
assert.equal(u.skill.kind, 'charges');
|
||||
assert.equal(u.skill.charges, 3);
|
||||
assert.equal(u.skill.sp, 2);
|
||||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.step(1);
|
||||
assert.equal(u.skill.charges, 2);
|
||||
assert.equal(u.skill.sp, 0, 'SP bar restarts after using a full stack');
|
||||
h.run(1.05);
|
||||
assert.ok(u.skill.activations >= 2);
|
||||
});
|
||||
|
||||
test('instant skills with an attack override apply to exactly one attack', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sn: sniperWith({ duration: 0, spCost: 100, initSp: 100, bb: { atk_scale: 5 } }, { stats: { atk: 100 } }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], content: 'generic',
|
||||
captureNoisy: true, hooks: ['damaged', 'skillStart', 'skillEnd'],
|
||||
});
|
||||
h.run(2.2);
|
||||
const dmgs = h.hooksOf('damaged').map((c) => Math.round(c.amount));
|
||||
assert.deepEqual(dmgs.slice(0, 3), [500, 100, 100]);
|
||||
assert.equal(h.hooksOf('skillEnd').length, 1);
|
||||
});
|
||||
|
||||
test('SkillSpec from a kit: mods, targeting override, attack override, onStart/onEnd/onHit', () => {
|
||||
const log = [];
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sn: sniperWith({ spCost: 1, initSp: 1, duration: 2 }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [11, 6] }, { key: 'enemy_dummy', pos: [9, 6] }],
|
||||
kits: {
|
||||
t_sn: (bb) => ({
|
||||
skill: {
|
||||
kind: 'duration', duration: 2,
|
||||
mods: { aspd: 100 },
|
||||
targeting: { maxTargets: 3 },
|
||||
attack: { atkScale: 2, dmgType: 'arts', onHit: (c) => log.push(['hit', c.target.id]) },
|
||||
onStart: (c) => log.push(['start', c.skill.timeLeft]),
|
||||
onEnd: (c) => log.push(['end', c.reason]),
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
h.run(0.6); // arrows are in flight for ~0.15 s
|
||||
const u = h.unit('t_sn');
|
||||
assert.equal(u.s.aspd, 200);
|
||||
const hits = log.filter((x) => x[0] === 'hit');
|
||||
assert.equal(new Set(hits.map((x) => x[1])).size, 3, 'hits 3 targets while active');
|
||||
h.run(2);
|
||||
assert.deepEqual(log.find((x) => x[0] === 'start'), ['start', 2]);
|
||||
assert.deepEqual(log.find((x) => x[0] === 'end'), ['end', 'duration']);
|
||||
assert.equal(u.s.aspd, 100);
|
||||
});
|
||||
|
||||
test('passive and toggle kinds; carryState restores hp/sp/skill', () => {
|
||||
const h = makeBattle({
|
||||
defs: {
|
||||
chess: {
|
||||
t_p: sniperWith({ skillType: 'PASSIVE', durationType: 'NONE', duration: -1, spCost: 0, bb: { atk: 1 } }, { id: 't_p' }),
|
||||
t_t: sniperWith({ duration: -1, spCost: 3, initSp: 3, bb: { attack_speed: 50 }, desc: '攻击速度+50;持续时间无限' }, { id: 't_t' }),
|
||||
t_c: sniperWith({ duration: 10, spCost: 10, initSp: 0, bb: { atk: 1 } }, { id: 't_c' }),
|
||||
},
|
||||
enemies: { enemy_dummy: dummy() },
|
||||
},
|
||||
units: [{ chessId: 't_p', row: 10, col: 4 }, { chessId: 't_t', row: 11, col: 4 }, { chessId: 't_c', row: 12, col: 4, carryState: { hpPct: 0.5, sp: 10, skillActive: true } }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [11, 6] }], content: 'generic',
|
||||
});
|
||||
h.run(0.1);
|
||||
assert.equal(h.unit('t_p').skill.kind, 'passive');
|
||||
approx(h.unit('t_p').s.atk, 200);
|
||||
const t = h.unit('t_t');
|
||||
assert.equal(t.skill.kind, 'toggle');
|
||||
h.run(30);
|
||||
assert.equal(t.skill.active, true, 'toggle stays on');
|
||||
const c = h.unit('t_c');
|
||||
assert.ok(c.skill.activations >= 1);
|
||||
const h2 = makeBattle({
|
||||
defs: { chess: { t_c: sniperWith({ duration: 10, spCost: 10, initSp: 0, bb: { atk: 1 } }, { id: 't_c' }) } },
|
||||
units: [{ chessId: 't_c', row: 12, col: 4, carryState: { hpPct: 0.5, sp: 10, skillActive: true } }], content: 'generic',
|
||||
});
|
||||
h2.step();
|
||||
const c2 = h2.unit('t_c');
|
||||
approx(c2.hpRatio, 0.5, 1e-9);
|
||||
assert.equal(c2.skill.active, true);
|
||||
});
|
||||
|
||||
test('generic kit maps blackboards of every real chess to a sane SkillSpec', () => {
|
||||
const ds = getDefaultSource();
|
||||
const kinds = {};
|
||||
for (const id of ds.chessIds()) {
|
||||
const c = ds.getChess(id);
|
||||
if (!c || !c.skill) continue;
|
||||
const spec = genericSkillSpec(c.skill, c.skill.bb);
|
||||
assert.ok(spec, id);
|
||||
assert.ok(['duration', 'ammo', 'instant', 'charges', 'passive', 'toggle'].includes(spec.kind), `${id} kind ${spec.kind}`);
|
||||
if (spec.kind === 'ammo') assert.ok(spec.ammo >= 1, `${id} ammo`);
|
||||
if (spec.kind === 'duration') assert.ok(spec.duration > 0, `${id} duration`);
|
||||
for (const [k, v] of Object.entries(spec.mods || {})) assert.ok(Number.isFinite(v), `${id} mod ${k}=${v}`);
|
||||
kinds[spec.kind] = (kinds[spec.kind] || 0) + 1;
|
||||
}
|
||||
assert.ok(kinds.duration > 50 && kinds.ammo > 5, JSON.stringify(kinds));
|
||||
// explicit mapping check
|
||||
const spec = genericSkillSpec({ id: 's', skillType: 'MANUAL', durationType: 'AMMO', duration: 0, spCost: 20, maxCharges: 1, bb: {} }, { atk: 0.8, base_attack_time: -0.3, 'attack@trigger_time': 14 });
|
||||
assert.equal(spec.kind, 'ammo');
|
||||
assert.equal(spec.ammo, 14);
|
||||
assert.deepEqual(spec.mods, { atkPct: 0.8, batPct: -0.3 });
|
||||
assert.equal(genericKind({ skillType: 'MANUAL', durationType: 'NONE', duration: 0, spCost: 5, maxCharges: 2 }), 'charges');
|
||||
assert.equal(genericKind({ skillType: 'AUTO', durationType: 'NONE', duration: 0, spCost: 0, maxCharges: 1 }), 'passive', 'free skill = always on');
|
||||
});
|
||||
|
||||
test('generic kit heuristics: self-stun after skill, on-hit statuses, displacement direction', () => {
|
||||
const ds = getDefaultSource();
|
||||
const ghost = ds.getChess('chess_char_2_07_a'); // 幽灵鲨: "技能结束后干员晕眩10秒"
|
||||
const g = genericSkillSpec(ghost.skill, ghost.skill.bb, ghost);
|
||||
assert.equal(g.attack?.onHit, undefined, 'no stun on targets');
|
||||
assert.equal(typeof g.onEnd, 'function', 'self stun on end');
|
||||
const texas = ds.getChess('chess_char_1_08_a'); // 德克萨斯: instant, stun targets 2 s
|
||||
const t = genericSkillSpec(texas.skill, texas.skill.bb, texas);
|
||||
assert.equal(t.kind, 'instant');
|
||||
assert.equal(typeof t.attack.onHit, 'function');
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 'chess_char_2_07_a', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [9, 6] }], content: 'generic',
|
||||
});
|
||||
const u = h.unit('chess_char_2_07_a');
|
||||
h.runUntil(() => u.skill.activations > 0, 60);
|
||||
assert.ok(u.skill.active);
|
||||
h.runUntil(() => !u.skill.active, 20);
|
||||
h.step();
|
||||
assert.ok(u.s.flags.stun, 'stunned after the skill ended');
|
||||
});
|
||||
|
||||
test('spCostMul changes (绝技-style) keep SP within bounds and convert overflow into a charge', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sn: sniperWith({ spCost: 10, initSp: 8, duration: 3 }) } },
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic',
|
||||
setup: (b) => b.on('battleStart', () => { for (const u of b.allyUnits) if (u.skill) u.skill.spCostMul = 0.7; }),
|
||||
});
|
||||
h.step(2);
|
||||
const u = h.unit('t_sn');
|
||||
assert.equal(u.skill.spCost, 7);
|
||||
assert.equal(u.skill.charges, 1);
|
||||
assert.ok(u.skill.sp <= u.skill.spCost);
|
||||
});
|
||||
Reference in new issue
Block a user