Files
ark-stronghold-protocol/test/helpers/battleHarness.js
T
sganggs 6464e4b46a Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
2026-10-01 19:08:23 +08:00

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/tier*.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);
}