- co-op leaders died within seconds: leader HP is the official data (40 values pinned); bond / strategy / item / 机变 / 特质 "+X%" bonuses now add up (PRTS 直接乘算) instead of compounding; a same-named enemy debuff keeps one (strongest) instance per target (奥术 across a pair field); leader parts pass damage 1:1; fixed co-op pool (the user's call) - official limits read from the client: each bond stops at 999 layers (MAX_GARRISON_STACK); a leader hit of 300000+ in boss / hidden battles is cancelled (MAX_BATTLE_DAMAGE, 限伤) — shared/constants.js switches, research 11 - a merge that consumes a deployed copy puts the elite on that tile and facing (user-confirmed); gold target highlight, promotion effect - 假想敌:胄 per PRTS: one shell, shells within range, any drone death costs 2 % max HP, 剑/锤 sorties and 瘫痪 - DESIGN §20.10–§20.14; 2842 unit tests, all 28 browser suites green; Chrome = Node on boss fields
264 lines
17 KiB
JavaScript
264 lines
17 KiB
JavaScript
#!/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); a raw
|
||
// write (no onLayers milestones) that stops at BOND_LAYER_CAP (999, shared/constants.js) like every gain
|
||
// --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';
|
||
import { layerGainRoom } from '../shared/constants.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) { const before = ps.layers[id] || 0; ps.layers[id] = before + layerGainRoom(before, 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;
|