Files
ark-stronghold-protocol/test/helpers/battleHarness.js
T

318 lines
17 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? } | { kind:'token', tokenId, row, col }]
// board coordinates (rows 9–12, cols 2–10) for player 'p1'
// 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]: (bb, chess, def) => Kit } injected kits (take precedence over kits/index.js)
// 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';
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: [],
};
}
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());
}
/** Create a battle + harness. See header for options. */
export function makeBattle(opts = {}) {
const kind = opts.kind ?? 'normal';
const data = opts.data ?? buildData(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', ...u })),
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', 'coins', 'damageDealt', 'bossDamage', 'healingDone', 'deaths']) if (!Number.isFinite(pp[k])) bad(`result ${k}=${pp[k]}`);
}
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);
}