Files
ark-stronghold-protocol/test/helpers/battleHarness.js
T
xcdoge b4bc89ad4e feedback7 UIBACK: the top-bar capsule counts like the official — resolved = killed + leaked among the enemies the round scheduled (opening 0/3, a leak 1/3, a kill 2/3, the splitter 3/3); a split child, a summon and a boss summon are in neither number but still cost LP and break 完美作战 (PR #308 by @xcdoge, the capsule half of PR #157 — authored by them: the sim / protocol / HUD hunks applied as reviewed): inTotal / killedInTotal / leakedInTotal / resolved, b.snap / BattleResult / b.progress (optional field) / m.public progress carry resolved, killed may exceed total (validation bounds it by the content-spawn allowance); dropped the PR's stray design-doc line — it had replaced the S→C b.end bullet of docs/design/network.md, which is back; the 联防 live-counter change mark follows the enemy list now that a spawn no longer moves total (uniteLeftMark + test); golden: only total / players.*.total / snaps move, outcomes bit-identical — roster 23 (roster-002 003 007 008 009 010 011 012 013 015 017 018 020 023 024 025 026 027 037 045 047 048 049), bonds 2 (bond-maniShip-low, bond-soloShip-high), fields 11 (boss-boss_1 3 5 pair+solo, boss-boss_6-solo, hidden-boss_8 10 pair+solo), diy 11 (diy-036 040 056 062 068 073 083 091 095 114 115), matches 12 (solo-FUNNY-1 NORMAL-1 HARD-1 ABYSS-1 ABYSS-2, coop2-NORMAL-3 coop2-HARD-4 coop3-ABYSS-5 coop4-ABYSS-7 coop2-NORMAL-8-serverrun solo-HARD-9-boosted coop2-NORMAL-70-diy: the total column of 36 round rows), standins unchanged
2026-10-08 19:30:36 +08:00

388 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// test/helpers/battleHarness.js — convenience harness for sim-core and content tests (see docs/SIM.md §Testing).
//
// import { makeBattle, flatStage, chessRec, enemyRec } from '../helpers/battleHarness.js';
// const h = makeBattle({ units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 5 }],
// enemies: [{ key: 'enemy_1422_lrsldr', time: 0, route: 0 }], seed: 7 });
// h.runUntil(() => h.enemies().length === 0, 60);
// assert.equal(h.result().reason, 'cleared');
//
// makeBattle(opts):
// stageId real stage id (data/stages.json) or 'flat' (default: synthetic open lane stage, see flatStage())
// stage explicit stage object (overrides stageId)
// kind 'normal' | 'unite' | 'boss' | 'hidden' (default 'normal')
// seed uint32 (default 1)
// units [{ chessId, row, col, items?, uid?, carryState?, abs?, standIn? } | { kind:'token', tokenId, row, col }]
// board coordinates (rows 9–12, cols 2–10) for player 'p1'. `standIn: true` fields a NORMAL chess as its
// 补位 stand-in (data/backups.json through simdata getChess(id, { standIn: true }) — the production
// path: the chess's ids / bonds / 特质 / tier, the stand-in's body, its kit by charId); the `_a` id is the
// normal form, `_b` the elite, skill / module are the chess's backup selection. `standIn: { skillIndex?,
// moduleId? }` fields it with another of the stand-in's skills / modules (a kit test of a skill no chess
// names): the composed record (standInRec) replaces that chess id's record for the whole battle.
// `{ diy: { slot, charId, skillIndex?, uniEquipId? }, elite?, row, col }` fields a 自选 piece — the
// production path (simdata getChess(slotId, { diy }), shared/diy.js): `slot` = a DIY slot's base id
// (`chess_char_5_diy1_a`) or its tier (5 / 6 ⇒ that tier's first slot), `elite` = its `_b` form (E2
// Lv60, module stage 1 at tier 5 / 3 at tier 6); the operator's kit is KITS[charId]; a prototype may
// omit skillIndex / uniEquipId (its locked ones)
// players full PlayerBattleInput[] (overrides `units`)
// enemies [{ key, time=0, route=0 | RouteSpec, pos?, count?, interval?, mods?, tag?, bounty?, sourcePlayerId? }]
// waveTemplate wave id (data/waves.json) or template object → routes + spawns + timeLimit
// routes RouteSpec[] (default: template routes, or flat-stage routes 0..3 = walk low, walk high, fly low, fly high)
// timeLimit seconds (default: template maxPlayTime, else 60; boss/hidden: Infinity)
// content 'full' | 'generic' | 'none' (default 'full')
// kits { [baseChessId | standInCharId]: (bb, chess, def) => Kit } injected kits (take precedence over
// kits/index.js; a stand-in's kit is keyed by its charId only)
// extraContent [{ install(battle) }] extra content modules installed after the domain modules (content tests)
// defs { chess: {id: record}, enemies: {key: record}, tokens: {id: record} } extra/override data records
// flags, sharedBoss, modeId, round, setup(battle), recordEvents (default true). The harness sets
// flags.startOpCooldown = 0 unless given: the official 3 s operation cooldown of the battle-start
// deployment (Battle default AUTO_OP_COOLDOWN) would hold back every test that fills a skill's SP at
// t ≈ 0 to stand for a skill that became ready mid-fight; tests of that rule pass it explicitly
// autoFinish end when all enemies are dead (default: true when any spawn is scheduled, else false so a
// battle without enemies keeps running until its time limit)
// hooks list of hook names to capture (default: all DESIGN events)
// Returned harness: { battle, b, step(n), run(seconds), runUntil(pred|seconds, maxSeconds), runToEnd(maxSeconds),
// unit(idOrChessIdOrUid), allies(), enemies(), enemy(key), spawn(key, opts), events, eventsOf(kind),
// hooks, hooksOf(name), invariants(), result(), snapshot() }
import { Battle } from '../../server/sim/Battle.js';
import { DataSource, getDefaultSource, spawnsFromTemplate } from '../../server/sim/simdata.js';
import { TICK } from '../../server/sim/constants.js';
import { standInRecord, composeUnitRecord, unitForm } from '../../shared/standIn.js';
export const ALL_HOOKS = Object.freeze([
'battleStart', 'deploy', 'tick', 'beforeAttack', 'attack', 'hit', 'damaged', 'heal', 'kill', 'death',
'skillStart', 'skillEnd', 'ammoUsed', 'spGain', 'statusApplied', 'blocked', 'enemySpawn', 'enemyLeak', 'battleEnd',
'fatal', 'layerGain', 'elementBurst', 'dodge',
]);
const NOISY = new Set(['tick', 'spGain', 'hit', 'damaged', 'beforeAttack', 'attack', 'heal']);
/**
* Synthetic 19×21 stage: open lanes on the normal field (rows 9–12) and the boss field (rows 1–5).
* Normal: gates S (9,10) & (12,10), goal E (9,2); partner half mirrors with gates at col 18.
* `opts.crates` = [[r,c]…] adds crate devices; `opts.rows` overrides specific rows ({ [row]: string }).
*/
export function flatStage(opts = {}) {
const rows = [];
for (let r = 0; r < 19; r++) rows.push('#'.repeat(21));
rows[0] = 'aaaaaaaaaa#aaaaaaaaaa';
rows[1] = '###IAAAAA###AAAAAI###';
rows[2] = '##ErrrrrrrOrrrrrrrE##';
rows[3] = '##hrrrrrrrfrrrrrrrh##';
rows[4] = '##hrrrrrrrfrrrrrrrh##';
rows[5] = '##hrrrrrrrOrrrrrrrh##';
rows[6] = 'X'.repeat(21);
rows[7] = 'aaaaaaaaaa###########';
rows[8] = '####AAAAA##I#########';
rows[9] = '##ErrrrrrrSrrrrrrrS##';
rows[10] = '##hrrrrrrrfrrrrrrrf##';
rows[11] = '##hrrrrrrrfrrrrrrrf##';
rows[12] = '##hrrrrrrrSrrrrrrrS##';
rows[13] = 'X'.repeat(21);
for (const [k, v] of Object.entries(opts.rows || {})) rows[+k] = v;
const devices = (opts.crates || []).map(([r, c], i) => ({ key: 'trap_1105_accrate', name: '阻隔工事', pos: [r, c], hidden: false, role: 'crate', active: true, alias: `crate#${i}` }));
return { id: opts.id ?? 'flat', name: 'flat', rows, devices };
}
/** Default routes for the flat stage (and any stage with the standard gates). */
export function flatRoutes(kind = 'normal') {
if (kind === 'boss' || kind === 'hidden') {
return [
{ motion: 'WALK', start: [2, 10], end: [2, 2], checkpoints: [] },
{ motion: 'WALK', start: [5, 10], end: [2, 2], checkpoints: [] },
{ motion: 'FLY', start: [2, 10], end: [2, 2], checkpoints: [] },
{ motion: 'WALK', start: [2, 10], end: [2, 18], checkpoints: [] },
{ motion: 'WALK', start: [5, 10], end: [2, 18], checkpoints: [] },
];
}
if (kind === 'unite') {
return [
{ motion: 'WALK', start: [9, 18], end: [9, 2], checkpoints: [] },
{ motion: 'WALK', start: [12, 18], end: [9, 2], checkpoints: [] },
{ motion: 'FLY', start: [9, 18], end: [9, 2], checkpoints: [] },
{ motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] },
];
}
return [
{ motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] },
{ motion: 'WALK', start: [12, 10], end: [9, 2], checkpoints: [] },
{ motion: 'FLY', start: [9, 10], end: [9, 2], checkpoints: [] },
{ motion: 'FLY', start: [12, 10], end: [9, 2], checkpoints: [] },
];
}
/**
* Build a chess record in data/chess.json shape (for synthetic test units).
* Minimal: chessRec({ id: 'test_guard', profession: 'WARRIOR', stats: { atk: 500 } })
*/
export function chessRec(o = {}) {
const id = o.id ?? o.chessId ?? 'test_chess_a';
const stats = {
maxHp: 2000, atk: 500, def: 200, res: 0, cost: 10, blockCnt: 2, bat: 1, aspd: 100, respawnTime: 20,
spRecovery: 1, hpRecoveryPerSec: 0, moveSpeed: 1, tauntLevel: 0, massLevel: 0, ...(o.stats || {}),
};
const rec = {
chessId: id, baseId: o.baseId ?? id, isGolden: !!o.golden, tier: o.tier ?? 1, name: o.name ?? id, charId: o.charId ?? id,
profession: o.profession ?? 'WARRIOR', subProfessionId: o.subProfessionId ?? o.subProf ?? null,
position: o.position ?? (['SNIPER', 'CASTER', 'MEDIC', 'SUPPORT'].includes(o.profession) ? 'RANGED' : 'MELEE'),
bonds: o.bonds ?? [], stats, rangeGrid: o.rangeGrid ?? [[0, 0], [0, 1]],
dmgType: o.dmgType ?? null, attackKind: o.attackKind ?? null, projectile: o.projectile ?? null,
canHitFly: o.canHitFly ?? null, targetPriority: o.targetPriority ?? null,
trait: { desc: o.trait ?? '', bb: {} }, talents: o.talents ?? [], tokens: o.tokens ?? [],
immunities: o.immunities ?? {},
skill: o.skill === null ? null : {
skillId: 'sk_test', name: 'test skill', skillType: 'MANUAL', durationType: 'NONE', duration: 10,
spType: 'INCREASE_WITH_TIME', spCost: 10, initSp: 0, maxChargeTime: 1, bb: {}, trigger: { rule: 'DEFAULT' }, index: 1,
...(o.skill || {}),
},
};
return rec;
}
/** Build an enemy record in data/enemies.json shape. */
export function enemyRec(o = {}) {
const key = o.key ?? 'enemy_test';
return {
key, name: o.name ?? key, rank: o.rank ?? 'NORMAL', applyWay: o.applyWay ?? ((o.range ?? 0) > 0 ? 'RANGED' : 'MELEE'),
stats: {
maxHp: o.hp ?? 5000, atk: o.atk ?? 0, def: o.def ?? 0, res: o.res ?? 0, moveSpeed: o.speed ?? 1, bat: o.bat ?? 2,
aspd: o.aspd ?? 100, rangeRadius: o.range ?? 0, blockCnt: o.blockCnt ?? 1, massLevel: o.mass ?? 1, lpr: o.lpr ?? 1,
hpRecoveryPerSec: 0, elementRes: 0, elementDmgRes: 0, dmgType: o.dmgType ?? 'phys', motion: o.motion ?? 'WALK',
immunities: o.immunities ?? {}, otherImmunities: o.otherImmunities ?? [], tauntLevel: o.taunt ?? 0,
},
notCountInTotal: !!o.notCountInTotal, tags: [], abilities: [], talents: { bb: {} }, skills: [],
};
}
/**
* The 补位 record of a NORMAL chess (data/chess.json `_a` normal / `_b` elite) fielded as its stand-in — shared/standIn.js
* standInRecord: the chess's backup skill and module — or, with `skillIndex` / `moduleId` (null / 'none' = no module),
* another selection of the same stand-in at the chess's status. Throws when the chess has no stand-in (PRESET, DIY) or
* the stand-in lacks that skill / module.
* @param {string} chessId
* @param {{ skillIndex?: number, moduleId?: string|null }} [sel]
* @param {object} [data] DataSource (default: the sim's default source)
*/
export function standInRec(chessId, sel = {}, data = getDefaultSource()) {
const chess = data.rawChess(chessId);
const backups = typeof data.rawBackups === 'function' ? data.rawBackups() : null;
const b = chess?.backup;
if (!b || !backups || chess.chessType !== 'NORMAL') throw new Error(`${chessId}: no 补位 stand-in (PRESET / DIY chess, or no backups.json)`);
const custom = sel && (sel.skillIndex != null || sel.moduleId !== undefined);
const rec = !custom ? standInRecord(chess, backups) : composeUnitRecord(chess, backups.units?.[b.charId], unitForm(backups, b.charId, chess.status), {
skillIndex: sel.skillIndex ?? b.skillIndex,
moduleId: sel.moduleId === undefined ? b.uniEquipId ?? null : (sel.moduleId === 'none' ? null : sel.moduleId),
standInFor: chess.charId,
});
if (!rec || !rec.skill) throw new Error(`${chessId}: stand-in ${b.charId} has no skill ${sel?.skillIndex ?? b.skillIndex}`);
const mod = sel?.moduleId;
if (custom && mod && mod !== 'none' && !rec.module?.active) throw new Error(`${chessId}: stand-in ${b.charId} has no module ${mod} at ${chessId}`);
return rec;
}
function buildData(defs) {
if (!defs) return getDefaultSource();
return new DataSource({ chess: defs.chess ?? {}, enemies: defs.enemies ?? {}, tokens: defs.tokens ?? {}, stages: defs.stages ?? {}, waves: defs.waves ?? {} }, getDefaultSource());
}
/**
* A harness `units[]` entry with `diy` → its PlayerBattleInput fields: `chessId` = the slot's normal or elite id, `diy` =
* the pick ({ charId, skillIndex, uniEquipId } as given). Other entries unchanged.
*/
function diyEntry(u, data = getDefaultSource()) {
if (!u || !u.diy || typeof u.diy !== 'object') return u;
const slots = data.rawBackups?.()?.diy?.slots ?? {};
const { slot, ...pick } = u.diy;
const base = typeof slot === 'number' ? Object.keys(slots).find((id) => slots[id].tier === slot) : slot;
if (!base || !slots[base]) throw new Error(`diy: unknown slot ${slot}`);
const { elite, ...rest } = u;
return { ...rest, chessId: elite ? slots[base].goldenId : base, diy: pick };
}
/** `units[].standIn` objects → the composed records for `defs.chess` (null when there are none). */
function standInDefs(units) {
let out = null;
for (const u of units ?? []) {
if (!u || !u.standIn || typeof u.standIn !== 'object') continue;
(out ??= {})[u.chessId] = standInRec(u.chessId, u.standIn);
}
return out;
}
/** Create a battle + harness. See header for options. */
export function makeBattle(opts = {}) {
const kind = opts.kind ?? 'normal';
const custom = opts.data ? null : standInDefs(opts.units);
const data = opts.data ?? buildData(custom ? { ...opts.defs, chess: { ...(opts.defs?.chess ?? {}), ...custom } } : opts.defs);
let stage = opts.stage ?? null;
if (!stage) stage = !opts.stageId || opts.stageId === 'flat' ? flatStage(opts.flat || {}) : data.getStage(opts.stageId);
if (!stage) throw new Error(`unknown stage ${opts.stageId}`);
let routes = opts.routes ?? null;
let spawns = [];
let timeLimit = opts.timeLimit;
if (opts.waveTemplate) {
const tpl = typeof opts.waveTemplate === 'string' ? data.getWave(opts.waveTemplate) : opts.waveTemplate;
if (!tpl) throw new Error(`unknown wave template ${opts.waveTemplate}`);
const conv = spawnsFromTemplate(tpl, { mods: opts.mods ?? null });
routes = routes ?? conv.routes;
spawns = conv.spawns;
if (timeLimit === undefined && conv.maxPlayTime && kind !== 'boss' && kind !== 'hidden') timeLimit = conv.maxPlayTime;
}
routes = routes ?? flatRoutes(kind);
const extraRoutes = [];
for (const e of opts.enemies ?? []) {
let routeIndex = 0;
let route = null;
if (typeof e.route === 'number') routeIndex = e.route;
else if (e.route && typeof e.route === 'object') route = e.route;
else if (e.routeIndex != null) routeIndex = e.routeIndex;
spawns.push({
time: e.time ?? 0, enemyKey: e.key, routeIndex, route, count: e.count ?? 1, interval: e.interval ?? 0,
mods: e.mods ?? null, tag: e.tag ?? null, bounty: e.bounty ?? null, sourcePlayerId: e.sourcePlayerId ?? null,
ownerPlayerId: e.ownerPlayerId ?? null, pos: e.pos ?? null, countInTotal: e.countInTotal,
});
}
if (extraRoutes.length) routes = routes.concat(extraRoutes);
const players = opts.players ?? [{
playerId: 'p1', seat: 0, side: 'L', colOffset: 0,
units: (opts.units ?? []).map((u, i) => ({ uid: u.uid ?? i + 1, kind: u.kind ?? 'chess', ...diyEntry(u), ...(u.standIn ? { standIn: true } : null) })),
bonds: opts.bonds ?? {}, bandId: opts.bandId ?? null, playerEffects: opts.playerEffects ?? [],
}];
const hookNames = opts.hooks ?? ALL_HOOKS;
const captured = Object.create(null);
const userSetup = opts.setup;
const battle = new Battle({
seed: opts.seed ?? 1, kind, modeId: opts.modeId ?? 'mode_multi_normal', round: opts.round ?? 1, stage, rect: opts.rect,
timeLimit: timeLimit ?? (kind === 'boss' || kind === 'hidden' ? Infinity : 60), players, spawns, routes,
sharedBoss: opts.sharedBoss ?? null, flags: { startOpCooldown: 0, ...opts.flags }, fieldId: opts.fieldId ?? 'test', data,
content: opts.content ?? 'full', recordEvents: opts.recordEvents !== false, logger: opts.logger ?? quietLogger(opts.verbose),
quiet: opts.verbose ? false : true, devices: opts.devices,
autoFinish: opts.autoFinish ?? spawns.length > 0, kits: opts.kits, extraContent: opts.extraContent,
setup: (b) => {
for (const name of hookNames) {
captured[name] = [];
b.on(name, (ctx) => {
if (NOISY.has(name) && !opts.captureNoisy) { captured[name].length++; return; }
captured[name].push({ ...ctx, t: b.time });
}, { priority: -1000 });
}
if (typeof userSetup === 'function') userSetup(b);
},
});
const events = [];
const drain = () => { for (const e of battle.drainEvents()) events.push(e); };
const h = {
battle,
b: battle,
events,
hooks: captured,
step(n = 1) { for (let i = 0; i < n && !battle.finished; i++) battle.step(); drain(); return h; },
run(seconds) {
const target = battle.time + seconds - 1e-9;
while (!battle.finished && battle.time < target) battle.step();
drain();
return h;
},
/** Step until pred() is true (or `pred` seconds elapsed when a number). Returns true if pred became true. */
runUntil(pred, maxSeconds = 300) {
if (typeof pred === 'number') { h.run(pred); return true; }
const limit = battle.time + maxSeconds;
while (!battle.finished && battle.time < limit) {
if (pred(battle)) { drain(); return true; }
battle.step();
}
drain();
return !!pred(battle);
},
runToEnd(maxSeconds = 4000) {
const limit = battle.time + maxSeconds;
while (!battle.finished && battle.time < limit) battle.step();
drain();
return battle.result();
},
/** Ally lookup: board uid first, then chessId / baseId, then engine id. */
unit(q) {
const L = battle.allyUnits;
return L.find((u) => u.uid != null && u.uid === q) ?? L.find((u) => u.defId === q || u.def?.baseId === q) ?? L.find((u) => u.id === q) ?? null;
},
allies() { return battle.allyUnits.filter((u) => u.alive && u.deployed && u.kind !== 'device'); },
enemies() { return battle.enemies.filter((e) => e.alive); },
enemy(key) { return battle.units.find((u) => u.side === 'enemy' && (key == null || u.defId === key)) ?? null; },
spawn(key, o = {}) { const e = battle.spawnEnemy(key, o); drain(); return e; },
eventsOf(kind) { drain(); return events.filter((e) => e[0] === kind); },
hooksOf(name) { return captured[name] ?? []; },
result() { return battle.result(); },
snapshot() { return battle.snapshot(); },
invariants() { return checkInvariants(battle); },
TICK,
};
return h;
}
function quietLogger(verbose) {
if (verbose) return console;
return { warn() {}, error() {}, info() {}, log() {} };
}
/** Throws on any invariant violation (no NaN/Infinity, hp ∈ [0,maxHp], positions inside rect, DP bounds). */
export function checkInvariants(b) {
const R = b.rect;
const bad = (msg, u) => { throw new Error(`invariant: ${msg} ${u ? `${u.kind}#${u.id} ${u.defId}` : ''} t=${b.time.toFixed(3)}`); };
for (const u of b.units) {
for (const k of ['x', 'y', 'hp', 'atkCd']) {
const v = u[k];
if (typeof v !== 'number' || Number.isNaN(v) || (!Number.isFinite(v) && k !== 'atkCd')) bad(`${k}=${v}`, u);
}
const s = u.s;
for (const k of ['maxHp', 'atk', 'def', 'res', 'aspd', 'interval', 'moveSpeed']) {
if (!Number.isFinite(s[k])) bad(`stat ${k}=${s[k]}`, u);
}
if (u.alive) {
if (u.hp < 0 || u.hp > s.maxHp + 1e-6) bad(`hp ${u.hp} / ${s.maxHp}`, u);
if (u.deployed && !u.hidden) {
if (u.x < R.c0 - 0.5 - 1e-6 || u.x > R.c1 + 0.5 + 1e-6 || u.y < R.r0 - 0.5 - 1e-6 || u.y > R.r1 + 0.5 + 1e-6) bad(`pos ${u.x},${u.y} outside rect`, u);
}
if (u.skill && !u.skill.noSkill) {
const sk = u.skill;
if (!Number.isFinite(sk.sp) || sk.sp < -1e-9 || sk.sp > sk.spCost + 1e-6) bad(`sp ${sk.sp}/${sk.spCost}`, u);
if (!(sk.charges >= 0 && sk.charges <= sk.maxCharges)) bad(`charges ${sk.charges}/${sk.maxCharges}`, u);
if (sk.active && sk.kind === 'duration' && !Number.isFinite(sk.timeLeft)) bad(`skill timeLeft ${sk.timeLeft}`, u);
if (sk.active && sk.kind === 'ammo' && !Number.isFinite(sk.ammoLeft)) bad(`skill ammoLeft ${sk.ammoLeft}`, u);
}
// occupancy map consistency: a living deployed ally is what its tile says it is
if (u.side === 'ally' && u.deployed && b._occ[u.tileR * 21 + u.tileC] !== u) bad(`occupancy map out of sync at ${u.tileR},${u.tileC}`, u);
} else if (u.removeReason === 'killed' && u.hp !== 0 && !u.bossPool) bad(`killed with hp ${u.hp}`, u);
else if (u.hp < 0) bad(`negative hp ${u.hp}`, u);
if (u.side === 'enemy' && u.blockedBy && !u.blockedBy.blocking.includes(u)) bad('block link broken', u);
}
for (const a of b.allyUnits) {
if (!a.alive && a.blocking.length) bad('dead blocker still blocking', a);
let used = 0;
for (const e of a.blocking) used += e.blockWeight ?? 1;
if (a.alive && used > Math.max(a.s.blockCnt, 0) && !a.s.flags.blockFly) {
// capacity may drop below current load only transiently (buff removal) — engine releases on next check
}
}
for (const p of b.players) if (!(p.dp >= 0 && p.dp <= b.flags.dpMax + 1e-9) || Number.isNaN(p.dp)) bad(`dp ${p.dp}`);
for (const pr of b.projectiles.list) if (![pr.x, pr.y, pr.tx, pr.ty].every(Number.isFinite)) bad(`projectile at ${pr.x},${pr.y} → ${pr.tx},${pr.ty}`);
if (b._emitDepth) bad(`hook emit depth ${b._emitDepth} between steps`);
for (const pp of Object.values(b._perPlayer ?? {})) {
for (const k of ['killed', 'total', 'killedInTotal', 'leakedInTotal', 'coins', 'damageDealt', 'bossDamage', 'healingDone', 'deaths']) if (!Number.isFinite(pp[k])) bad(`result ${k}=${pp[k]}`);
// The capsule's counters are attributed differently on purpose: a knock-out goes to the enemy's owner (as its `total`
// did), while a leak goes to the player whose half the enemy reached (`_recordLeak`) — in a 联防 field an enemy
// spawned on one half and leaked on the other bills the leaker, so per player only `killedInTotal ≤ total` holds
// (BattleResult's per-player `resolved` clamps the sum to `total`, and the battle-level sum is exact)
if (pp.killedInTotal > pp.total) bad(`capsule ${pp.killedInTotal} > ${pp.total}`);
if (pp.total < 0 || pp.killedInTotal < 0 || pp.leakedInTotal < 0) bad('negative capsule counter');
}
if (b.killedInTotal + b.leakedInTotal > b.total) bad(`battle capsule ${b.killedInTotal}+${b.leakedInTotal} > ${b.total}`);
return true;
}
/** Stable hash of a value (for determinism tests). */
export function hashOf(v) {
const s = JSON.stringify(v);
let h = 2166136261;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return (h >>> 0).toString(16);
}