Files
ark-stronghold-protocol/tools/matchrun.mjs
T
sganggs 0aba602ecf Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners
- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight
  (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s
- #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks,
  hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped
  at one per frame
- #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates'
  pending LP in the team panel
- #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue);
  temp-row warning and ready-button reason
- #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx
- #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught)
- #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost
- #8 特质 right under the detail card header, with its official type icon
- #9 no spinning busy cursors / spinners next to texts; game data warmed before the match
- docs, recordings and tests updated (2414 unit tests, all browser suites green)
2026-09-29 23:42:55 +08:00

262 lines
16 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.
#!/usr/bin/env node
// tools/matchrun.mjs — run whole bot matches headlessly (virtual time, real simulation) and print a per-round
// balancing summary: LP over time, shop level, deployed units, kills/leaks, 联防, Final Assault outcome.
//
// Usage:
// node tools/matchrun.mjs [--mode solo|coop] [--difficulty FUNNY|NORMAL|HARD|ABYSS|ALL] [--players 1..4] [--seed 1]
// [--seeds N] [--lp N] [--humans N] [--content full|generic|none] [--check] [--errors]
// [--odds] [--json] [--quiet] [--rehearsal N]
// --players co-op seats (default 2; solo is always 1)
// --humans N the first N seats are human seats on "AI 托管" (exercises the human views / m.private paths)
// --seeds N run N consecutive seeds and print an aggregate (rounds survived, LP by round, outcomes)
// --lp N override every player's starting LP (reach later rounds / the Final Assault while balancing)
// --layers N at the boss round's prep, add N layers to every bond of every player (reach the Hidden Core)
// --difficulty ALL sweep all four difficulties
// --content sim content mode (default full)
// --rehearsal N layouts each bot rehearses per prep with the real sim (default: the Match default, 3; 0 = off,
// faster sweeps with the heuristic placement only)
// --check audit every match: engine invariants at every phase change + rule checks (server/match/audit.js:
// income, upgrade price, freeze, shop odds/bans, temp overflow lifetime (no piece outlives the prep it is
// due at), drafts, 联防 helpers, settle LP, final assault pairing/pool, hidden core, titles, deadlines).
// Exit code 1 when anything is violated.
// --errors per-source error summary over all runs: sim/content errors grouped by content module + handler label
// (+ unit) with occurrence and battle counts, meta handler errors by registry key, match engine errors
// --odds shop tier distribution of rolled chess slots per shop level (all runs)
// --json print machine-readable results
// --tuning off play on the research-faithful numbers (data/tuning.json ignored; docs/BALANCE.md)
// --legacy-time read combat limits as game seconds (the reading before docs/BALANCE.md §2.1)
// Examples:
// node tools/matchrun.mjs --mode solo --difficulty FUNNY --seed 3
// node tools/matchrun.mjs --mode coop --difficulty HARD --players 4 --seeds 20
// node tools/matchrun.mjs --mode coop --difficulty ALL --players 4 --humans 2 --seeds 10 --check --errors
import { Match } from '../server/match/Match.js';
import { VirtualScheduler } from '../server/match/scheduler.js';
import { attachAudit } from '../server/match/audit.js';
import { Battle } from '../server/sim/Battle.js';
import { getData } from '../server/data.js';
const argv = process.argv.slice(2);
const opt = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith('--')) continue;
const k = a.slice(2);
const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
opt[k] = v;
}
if (opt.help) {
console.log(Buffer.from(await (await import('node:fs')).promises.readFile(new URL(import.meta.url))).toString().split('\n').filter((l) => l.startsWith('//')).map((l) => l.slice(3)).join('\n'));
process.exit(0);
}
const DIFFS = ['FUNNY', 'NORMAL', 'HARD', 'ABYSS'];
const mode = opt.mode === 'solo' ? 'solo' : 'coop';
const diffArg = String(opt.difficulty || 'NORMAL').toUpperCase();
const difficulties = diffArg === 'ALL' ? DIFFS : [DIFFS.includes(diffArg) ? diffArg : 'NORMAL'];
const players = mode === 'solo' ? 1 : Math.max(1, Math.min(4, Number(opt.players) || 2));
const humans = mode === 'solo' ? (opt.humans != null ? 1 : 0) : Math.max(0, Math.min(players, Number(opt.humans) || 0));
const seed0 = Number(opt.seed) || 1;
const seeds = Math.max(1, Number(opt.seeds) || 1);
const lpOverride = Number(opt.lp) > 0 ? Number(opt.lp) : null;
const layerBoost = Number(opt.layers) > 0 ? Math.floor(Number(opt.layers)) : null;
const quiet = !!opt.quiet || seeds > 1 || difficulties.length > 1;
const rehearsal = opt.rehearsal != null && opt.rehearsal !== true ? Math.max(0, Math.floor(Number(opt.rehearsal) || 0)) : undefined;
let data = getData({ log: { warn() {}, error() {}, info() {} } });
if (opt.tuning === 'off') { const { tuning, ...rest } = data; void tuning; data = Object.freeze(rest); }
if (opt['legacy-time']) data = Object.freeze({ ...data, config: { ...data.config, combatTimeScale: 1 } });
// ---- error collection (--errors) ------------------------------------------------------------------------------
// Battle errors are logged once per unique key inside the sim; to count every occurrence the tool runs a Battle
// subclass that observes the sim's error sink. If that internal hook ever disappears the summary falls back to the
// unique records each match collects in m.simErrorLog.
const simErrors = new Map();
let current = { seed: 0, difficulty: '', battle: 0 };
const moduleOf = (stack) => {
const lines = String(stack || '').split('\n').slice(1);
const pick = (re) => { for (const l of lines) { const mm = re.exec(l); if (mm) return mm[1]; } return null; };
return pick(/server\/sim\/(content\/[^\s):]+:\d+)/) || pick(/server\/(sim\/[^\s):]+:\d+)/) || pick(/server\/(match\/[^\s):]+:\d+)/) || '?';
};
function noteSimError(label, who, message, stack, n = 1, battleKey = null) {
const src = moduleOf(stack);
const key = `${src}|${label}|${who}|${message}`;
let r = simErrors.get(key);
if (!r) { r = { src, label, who, message, count: 0, battles: new Set(), seeds: new Set() }; simErrors.set(key, r); }
r.count += n;
r.battles.add(battleKey ?? `${current.difficulty}:${current.seed}:${current.battle}`);
r.seeds.add(`${current.difficulty[0] || ''}${current.seed}`);
}
const canCount = typeof Battle.prototype._handlerError === 'function';
class CountingBattle extends Battle {
constructor(o) { current.battle++; super(o); }
_handlerError(label, owner, e, internal) {
try {
const who = owner && owner.defId ? owner.defId : (owner && owner.name) || '';
noteSimError(String(label), String(who), String(e && e.message), e && e.stack);
} catch { /* diagnostics only */ }
return super._handlerError(label, owner, e, internal);
}
}
// ---- one match ------------------------------------------------------------------------------------------------
function runOne(seed, difficulty) {
current = { seed, difficulty, battle: 0 };
const sched = new VirtualScheduler();
const errors = [];
const log = { info() {}, debug() {}, warn() {}, error: (...a) => errors.push(a.map(String).join(' ')) };
const seats = [];
for (let i = 0; i < players; i++) {
const human = i < humans;
seats.push({ seat: i, playerId: human ? `p_${i}` : `ai_${i}`, name: human ? `P${i + 1}` : `AI-${i + 1}`, isBot: !human, connected: true });
}
let summary = null;
let frames = 0;
const m = new Match({
roomCode: 'RUN', mode, difficulty, seats, seed, data, log, scheduler: sched,
battleContent: opt.content || 'full',
botRehearsal: rehearsal,
BattleClass: opt.errors && canCount ? CountingBattle : undefined,
send: () => { frames++; return true; }, broadcast: () => {}, onEnd: (s) => { summary = s; },
});
for (const ps of m.players.values()) if (!ps.isBot) ps.autoplay = true;
const audit = opt.check || opt.odds ? attachAudit(m, { invariants: !!opt.check }) : null;
const rounds = [];
let last = '';
const t0 = Date.now();
m.start();
sched.runUntil(() => {
const key = `${m.phase}:${m.round}`;
if (key !== last) {
last = key;
if (lpOverride && m.phase === 'PREP' && m.round === 1) for (const ps of m.players.values()) ps.lp = lpOverride;
if (layerBoost && m.phase === 'PREP' && m.round === m.gd.bossRound) {
for (const ps of m.alivePlayers()) { for (const id of m.gd.bondIds) ps.layers[id] = (ps.layers[id] || 0) + layerBoost; ps.recompute(); }
}
if (m.phase === 'SETTLE' || ((m.phase === 'RESULT') && (m.round === m.gd.bossRound || m.round === m.gd.hiddenRound))) {
const row = { round: m.round, unite: !!m.unitePlan, players: {} };
for (const ps of m.order) {
const r = m.lastResults.get(ps.playerId);
row.players[ps.playerId] = {
lp: ps.lp, alive: ps.alive, level: ps.shop.level, units: ps.deployCount,
killed: r ? r.killed : null, total: r ? r.total : null, leaks: r ? (r.leaked || []).filter((l) => l.counted !== false).length : null,
bonds: Object.entries(ps.bonds).filter(([, b]) => b.active).map(([id, b]) => `${data.bonds[id]?.name ?? id}${b.tier > 1 ? b.tier : ''}`).join(' '),
};
}
if (m.teamLp != null) row.teamLp = m.teamLp;
if (m.bossPool) row.boss = { hp: Math.round(m.bossPool.hp), max: m.bossPool.maxHp };
rounds.push(row);
}
}
return summary != null;
}, { maxSteps: 5e6 });
if (!summary) errors.push(`match did not end (stuck in ${m.phase} R${m.round})`);
if (opt.errors) {
if (!canCount) for (const r of m.simErrorLog.values()) noteSimError(r.label, r.who, r.message, r.stack, r.count, `${difficulty}:${seed}`);
for (const e of m.errors) noteSimError(`match ${e.label}`, '', e.message, e.stack || '', 1, `${difficulty}:${seed}:match`);
for (const [k, r] of m.dispatcher.errorsByKey) {
const i = k.indexOf(': ');
noteSimError(`meta ${i > 0 ? k.slice(0, i) : k}`, '', i > 0 ? k.slice(i + 2) : '', r.stack, r.count, `${difficulty}:${seed}:meta`);
}
}
m.dispose();
return {
seed, difficulty, summary, rounds, errors, matchErrors: m.errorCount, simErrors: m.simErrors, metaErrors: m.dispatcher.errors,
ms: Date.now() - t0, stageId: m.stageId, bossId: m.bossId, frames, audit,
};
}
// ---- run ------------------------------------------------------------------------------------------------------
const results = [];
for (const difficulty of difficulties) {
for (let i = 0; i < seeds; i++) {
const r = runOne(seed0 + i, difficulty);
results.push(r);
if (!quiet && !opt.json) {
const s = r.summary || {};
console.log(`== ${mode} ${difficulty} · ${players} seat(s)${humans ? ` (${humans} human on AI 托管)` : ''} · seed ${r.seed} · stage ${r.stageId} · boss ${r.bossId}`);
for (const row of r.rounds) {
const cells = Object.entries(row.players).map(([id, p]) => `${id.replace(/^(ai|p)_/, 'P')}:${p.alive ? `LP${String(p.lp).padStart(3)}` : ' dead '} L${p.level} u${p.units} ${p.killed != null ? `${p.killed}/${p.total}` : '-'}${p.leaks ? ` −${p.leaks}` : ''}`).join(' | ');
const extra = [row.unite ? '联防' : '', row.teamLp != null ? `teamLP ${row.teamLp}` : '', row.boss ? `boss ${row.boss.hp}/${row.boss.max}` : ''].filter(Boolean).join(' ');
console.log(`R${String(row.round).padStart(2)} ${cells}${extra ? ' · ' + extra : ''}`);
}
console.log(`=> ${s.victory ? 'VICTORY' : 'DEFEAT'} · rounds passed ${s.roundsPassed}${s.hiddenReached ? ` · hidden ${s.hiddenCleared ? 'cleared' : 'failed'}` : ''} · reason ${s.reason} · ${r.ms} ms · match errors ${r.matchErrors} · meta errors ${r.metaErrors} · sim errors ${r.simErrors}`);
for (const p of s.players || []) console.log(` ${p.name}: band ${data.bands[p.bandId]?.name ?? p.bandId} · rounds ${p.roundsPassed} · title ${p.title ? p.title.name : '-'} · dmg ${p.stats.dmgDealt} · kills ${p.stats.kills} · merges ${p.stats.merges} · spent ${p.stats.gold}`);
if (r.errors.length) console.log(' errors:', r.errors.slice(0, 5));
if (r.audit && r.audit.violations.length) console.log(` audit: ${r.audit.violations.length} violation(s)\n ${r.audit.violations.slice(0, 10).join('\n ')}`);
}
}
}
let exitCode = 0;
if (opt.json) {
console.log(JSON.stringify(results.map((r) => ({
seed: r.seed, difficulty: r.difficulty, stageId: r.stageId, bossId: r.bossId, victory: r.summary?.victory, roundsPassed: r.summary?.roundsPassed,
reason: r.summary?.reason, rounds: r.rounds, errors: r.errors.length + r.matchErrors, simErrors: r.simErrors, metaErrors: r.metaErrors,
violations: r.audit ? r.audit.violations : undefined,
})), null, 1));
} else if (quiet) {
for (const difficulty of difficulties) {
const rs = results.filter((r) => r.difficulty === difficulty);
const passed = rs.map((r) => r.summary?.roundsPassed ?? 0);
const wins = rs.filter((r) => r.summary?.victory).length;
const avg = passed.reduce((a, b) => a + b, 0) / passed.length;
const hist = {};
for (const p of passed) hist[p] = (hist[p] || 0) + 1;
console.log(`${mode} ${difficulty} · ${players} seat(s)${humans ? ` (${humans} human)` : ''} · seeds ${seed0}..${seed0 + seeds - 1}`);
console.log(` rounds passed: avg ${avg.toFixed(2)} · min ${Math.min(...passed)} · max ${Math.max(...passed)} · wins ${wins}/${rs.length} · hidden ${rs.filter((r) => r.summary?.hiddenReached).length}`);
console.log(` distribution: ${Object.entries(hist).map(([k, v]) => `${k}:${v}`).join(' ')}`);
const maxR = Math.max(...rs.map((r) => (r.rounds.length ? r.rounds[r.rounds.length - 1].round : 0)));
const line = [];
for (let rr = 1; rr <= maxR; rr++) {
const lps = [];
for (const r of rs) {
const row = r.rounds.find((x) => x.round === rr);
if (!row) continue;
for (const p of Object.values(row.players)) if (p.alive) lps.push(p.lp);
}
if (lps.length) line.push(`R${rr}:${(lps.reduce((a, b) => a + b, 0) / lps.length).toFixed(1)}(${lps.length})`);
}
console.log(` avg LP of alive players after each round: ${line.join(' ')}`);
const errs = rs.reduce((n, r) => n + r.errors.length + r.matchErrors, 0);
console.log(` engine errors: ${errs} · meta errors: ${rs.reduce((n, r) => n + r.metaErrors, 0)} · sim errors: ${rs.reduce((n, r) => n + r.simErrors, 0)} · total ${rs.reduce((n, r) => n + r.ms, 0)} ms`);
}
}
if (opt.check) {
const bad = results.filter((r) => r.audit && r.audit.violations.length);
const stuck = results.filter((r) => !r.summary);
const checks = results.reduce((n, r) => n + (r.audit ? r.audit.checks : 0), 0);
console.log(`\ncheck: ${results.length} match(es), ${checks} rule checks, ${results.reduce((n, r) => n + (r.audit ? r.audit.phases : 0), 0)} invariant passes → ${bad.length ? `${bad.length} match(es) with violations` : 'no violations'}${stuck.length ? `, ${stuck.length} stuck` : ''}`);
for (const r of bad.slice(0, 10)) console.log(` ${r.difficulty} seed ${r.seed}:\n ${r.audit.violations.slice(0, 8).join('\n ')}`);
if (bad.length || stuck.length || results.some((r) => r.matchErrors > 0)) exitCode = 1;
}
if (opt.odds) {
const tot = {};
for (const r of results) for (const [lv, row] of Object.entries(r.audit?.odds || {})) for (const [t, n] of Object.entries(row)) { (tot[lv] ||= {})[t] = (tot[lv][t] || 0) + n; }
console.log('\nshop odds (rolled chess slots per shop level → tier shares):');
for (const lv of Object.keys(tot).sort()) {
const n = Object.values(tot[lv]).reduce((a, b) => a + b, 0);
console.log(` L${lv} (${n} rolls): ${Object.keys(tot[lv]).sort().map((t) => `T${t} ${((tot[lv][t] / n) * 100).toFixed(1)}%`).join(' ')}`);
}
}
if (opt.errors) {
const list = [...simErrors.values()].sort((a, b) => b.count - a.count);
const total = list.reduce((n, r) => n + r.count, 0);
console.log(`\nerrors by source: ${total} occurrence(s), ${list.length} unique${canCount ? '' : ' (unique records only: the sim error sink was not observable)'}`);
if (list.length) {
console.log(' count battles seeds source label (unit) — message');
for (const r of list.slice(0, Number(opt.errors) > 0 ? Number(opt.errors) : 40)) {
const seedsStr = [...r.seeds].slice(0, 4).join(',') + (r.seeds.size > 4 ? '…' : '');
console.log(` ${String(r.count).padStart(6)} ${String(r.battles.size).padStart(7)} ${seedsStr.padEnd(12)} ${r.src.padEnd(34)} ${r.label}${r.who ? ` (${r.who})` : ''} — ${String(r.message).slice(0, 140)}`);
}
const byModule = new Map();
for (const r of list) byModule.set(r.src.replace(/:\d+$/, ''), (byModule.get(r.src.replace(/:\d+$/, '')) || 0) + r.count);
console.log(` by module: ${[...byModule.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k} ${v}`).join(' · ')}`);
console.log(` repro: node tools/matchrun.mjs --mode ${mode} --difficulty <D> --players ${players} --seed <n> --errors (seeds are listed per row, prefixed by the difficulty initial)`);
}
}
process.exitCode = exitCode;