Files
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

232 lines
13 KiB
JavaScript
Raw Permalink 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/record-battle.mjs — run real battles headlessly with the simulation engine (server/sim, docs/SIM.md) and
// record exactly what a watching client receives, for the render demo (public/dev/render-demo.html):
//
// { version: 1, name, title, kind, stageId, modeId, round, tick: 1/30, snapEvery: 3, duration,
// field: <m.field payload>, frames: [ { snap: <b.snap payload>, ev: [ …b.ev tuples… ] } ], result: {…} }
//
// One frame per 3 ticks (= one b.snap, 20 Hz real at the forced 2× speed); `ev` holds the events drained at
// that point (on the wire the match sends that `b.ev` right BEFORE its `b.snap`, both stamped with the snapshot's
// game time `gt`; the demo replays them the same way). Scenarios: a normal round with a real lineup vs the real
// wave template on each of the active stages, a 联防 unite field (2 players, escaped-multi template) and a
// Final Assault boss field (2 players, shared boss pool). Late rounds (`wave: 'official'`) use the match's own wave
// generator (server/match/waves.js setupMatchWaves + buildNormalWave: the official per-round composition and counts,
// research 08 §2) and also store the round's prep preview (`preview` = m.private.nextEnemies, previewOf) for the
// enemy-pen demo (render-demo ?pen=<name>). Also writes public/dev/recordings/index.json.
//
// Usage: node tools/record-battle.mjs [--only name,name] [--out dir] [--seed n] [--max seconds]
// (`--max` caps every scenario; without it a scenario's own `max` — else 90 game s — applies, so a plain run
// reproduces the committed recordings byte for byte.)
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Battle } from '../server/sim/Battle.js';
import { getDefaultSource, spawnsFromTemplate } from '../server/sim/simdata.js';
import { getData } from '../server/data.js';
import { GameData } from '../server/match/gamedata.js';
import { setupMatchWaves, buildNormalWave, previewOf } from '../server/match/waves.js';
import { createRng } from '../server/sim/rng.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2);
const opt = {};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (!a.startsWith('--')) continue;
const k = a.slice(2);
opt[k] = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true;
}
const OUT = path.resolve(ROOT, typeof opt.out === 'string' ? opt.out : 'public/dev/recordings');
const ONLY = typeof opt.only === 'string' ? new Set(opt.only.split(',')) : null;
const SEED = Number(opt.seed ?? 20260927) >>> 0;
const MAX_SECONDS = Number(opt.max ?? 90);
/** Game seconds recorded at most for scenario `sc`: `--max`, else its own `max`, else 90. */
const maxSecondsOf = (sc) => (opt.max != null ? MAX_SECONDS : Number(sc.max ?? MAX_SECONDS));
const quiet = { warn() {}, error() {}, info() {}, debug() {} };
const ds = getDefaultSource();
const data = getData({ log: quiet });
const config = data.config ?? {};
function chessByName(name, golden = false) {
for (const id of ds.chessIds()) {
const r = ds.rawChess(id);
if (r && r.name === name && !r.isGolden && r.visible !== false) return golden ? (r.goldenId || id.replace(/_a$/, '_b')) : id;
}
throw new Error(`unknown chess ${name}`);
}
/**
* Place a lineup on the stage's deployable tiles: melee operators on melee tiles closest to the enemy lane
* (row 9, towards the gate), ranged operators on high ground first, then the remaining tiles.
* `names`: ['name', 'name*' (elite)…] → PlayerBattleInput units (board coordinates).
*/
function lineup(stageId, names, uid0 = 1) {
const st = data.stages?.[stageId] ?? ds.getStage?.(stageId);
const dt = st?.deployTiles?.normal || { melee: [], rangedOnly: [] };
const melee = [...dt.melee].sort((a, b) => (a[0] - b[0]) || (b[1] - a[1]));
const ranged = [...(dt.rangedOnly || [])];
const taken = new Set();
const units = [];
let uid = uid0;
for (const raw of names) {
const golden = raw.endsWith('*');
const name = golden ? raw.slice(0, -1) : raw;
const chessId = chessByName(name, golden);
const rec = ds.rawChess(chessId);
const isMelee = rec?.position === 'MELEE';
const pool = isMelee ? melee : [...ranged, ...melee.slice().reverse()];
const tile = pool.find(([r, c]) => !taken.has(`${r},${c}`));
if (!tile) continue;
taken.add(`${tile[0]},${tile[1]}`);
units.push({ uid: uid++, kind: 'chess', chessId, row: tile[0], col: tile[1] });
}
return units;
}
function modsFor(modeId, round) {
const sc = config.modes?.[modeId]?.enemyScale?.[String(round)];
return { hpMul: sc?.hp ?? 1, atkMul: sc?.atk ?? 1, speedMul: sc?.speed ?? 1 };
}
const SCENARIOS = [
{
name: 'normal-m01', title: '常规作战 · 源石流发生装置 · R5', kind: 'normal', stageId: 'act2autochess_m01', modeId: 'mode_multi_normal', round: 5,
players: () => [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: lineup('act2autochess_m01', ['角峰', '德克萨斯', '艾丝黛尔*', '隐现', '格雷伊', '赫默', '能天使']) }],
},
{
name: 'normal-m02', title: '常规作战 · 沼泽控制 · R8', kind: 'normal', stageId: 'act2autochess_m02', modeId: 'mode_multi_hard', round: 8,
players: () => [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: lineup('act2autochess_m02', ['星熊', '银灰', '斯卡蒂', '莫斯提马', '白面鸮', '寒芒克洛丝', '阿罗玛', '远牙']) }],
},
{
name: 'normal-m03', title: '常规作战 · 排气格栅 · R11', kind: 'normal', stageId: 'act2autochess_m03', modeId: 'mode_multi_hard', round: 11,
players: () => [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: lineup('act2autochess_m03', ['塞雷娅*', '山', '史尔特尔', '夕', '缇缇', '烛煌', '铃兰', '号角*']) }],
},
{
name: 'normal-m04', title: '常规作战 · 涨潮控制 · R6', kind: 'normal', stageId: 'act2autochess_m04', modeId: 'mode_multi_normal', round: 6,
players: () => [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: lineup('act2autochess_m04', ['蛇屠箱', '忍冬', '断崖', '流星', '空弦', '调香师', '初雪']) }],
},
// late rounds with the official composition and counts (perf profiling, research 08 §2.7; 57 / 55 enemies)
{
name: 'late-m03-r12', title: '常规作战 · 排气格栅 · R12(官方波次)', kind: 'normal', stageId: 'act2autochess_m03', modeId: 'mode_multi_hard', round: 12,
wave: 'official', waveSeed: 3,
players: () => [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: lineup('act2autochess_m03', ['塞雷娅*', '星熊*', '山*', '史尔特尔*', '夕*', '莫斯提马*', '铃兰*', '号角*']) }],
},
{
name: 'late-m01-r13', title: '常规作战 · 源石流发生装置 · R13(官方波次)', kind: 'normal', stageId: 'act2autochess_m01', modeId: 'mode_multi_hard', round: 13,
wave: 'official', waveSeed: 5, max: 110, // the whole round (55 real s = 110 game s)
players: () => [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: lineup('act2autochess_m01', ['星熊*', '银灰*', '斯卡蒂*', '莫斯提马*', '白面鸮', '寒芒克洛丝*', '阿罗玛', '远牙*']) }],
},
{
name: 'unite-m01', title: '联防 · 源石流发生装置 · R9', kind: 'unite', stageId: 'act2autochess_m01', modeId: 'mode_multi_normal', round: 9,
wave: 'act1autochess_escaped_multi', timeLimit: 60,
players: () => [
{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: lineup('act2autochess_m01', ['星熊', '德克萨斯', '能天使', '赫默', '格雷伊', '银灰']) },
{ playerId: 'P2', seat: 1, side: 'L', colOffset: 8, units: lineup('act2autochess_m01', ['塞雷娅', '拉普兰德', '莫斯提马', '白面鸮', '远牙', '缇缇'], 101) },
],
},
{
name: 'boss-m02', title: '最终攻势 · 卢西恩,“猩红血钻” · R14', kind: 'boss', stageId: 'act2autochess_m02', modeId: 'mode_multi_normal', round: 14,
wave: 'act2autochess_h07_05', timeLimit: 75, bossId: 'boss_5',
players: () => [
{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: lineup('act2autochess_m02', ['塞雷娅*', '山*', '史尔特尔*', '夕*', '缇缇', '烛煌*', '铃兰', '异客']) },
{ playerId: 'P2', seat: 1, side: 'R', colOffset: 0, units: lineup('act2autochess_m02', ['余*', '耀骑士临光', '锏*', '蕾缪安*', '焰影苇草', '迷迭香*', '流明', '纯烬艾雅法拉'], 101) },
],
},
];
/** The match's own round composition (server/match/waves.js): spawns, routes, overrides, time limit, preview. */
function officialWave(sc) {
const gd = new GameData(data, sc.modeId);
const setup = setupMatchWaves(gd, createRng(sc.waveSeed ?? 1));
const w = buildNormalWave(gd, createRng((sc.waveSeed ?? 1) + sc.round), setup.factions, sc.round);
if (!w.templateId || !w.spawns.length) throw new Error(`no official wave for ${sc.name}`);
return { waveId: w.templateId, conv: { spawns: w.spawns, routes: w.routes, overrides: w.overrides, maxPlayTime: w.timeLimit }, preview: previewOf(w.spawns) };
}
function record(sc) {
const modeId = sc.modeId;
const round = sc.round;
const roundCfg = config.modes?.[modeId]?.rounds?.[String(round)];
let waveId, conv, preview = null;
if (sc.wave === 'official') ({ waveId, conv, preview } = officialWave(sc));
else {
waveId = sc.wave || roundCfg?.template;
const tpl = ds.getWave(waveId);
if (!tpl) throw new Error(`unknown wave ${waveId}`);
conv = spawnsFromTemplate(tpl, { mods: modsFor(modeId, round) });
}
let sharedBoss = null;
if (sc.kind === 'boss') {
const boss = data.bosses?.[sc.bossId];
const hp = Math.round((boss?.bloodPoint?.NORMAL ?? 400000) * 0.5);
sharedBoss = { hp, maxHp: hp, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } };
}
const timeLimit = sc.timeLimit ?? conv.maxPlayTime ?? roundCfg?.combatTimeLimit ?? 60;
const b = new Battle({
seed: SEED ^ hash(sc.name), kind: sc.kind, modeId, round, stageId: sc.stageId,
timeLimit: sc.kind === 'boss' ? Infinity : timeLimit, routes: conv.routes, spawns: conv.spawns,
players: sc.players(), sharedBoss, enemyOverrides: conv.overrides ?? {}, fieldId: sc.kind === 'normal' ? 'n:P1' : sc.kind === 'unite' ? 'u' : 'b1',
flags: { layerGainsEnabled: sc.kind === 'normal', dpInit: 10, dpPerSec: 1, dpMax: 99 }, logger: quiet,
});
const frames = [];
let field = null;
let ticks = 0;
const maxT = Math.min(maxSecondsOf(sc), sc.kind === 'boss' ? timeLimit : timeLimit + 1);
while (!b.finished) {
b.step();
ticks++;
if (!field) field = b.fieldMeta();
if (ticks % 3 === 0) {
frames.push({ snap: b.snapshot(), ev: b.drainEvents() });
}
if (b.time >= maxT) b.forceEnd(sc.kind === 'boss' ? 'forced' : 'timeout');
}
// final frame (end state); when the battle ended on a snapshot tick, only its trailing events are appended
const tail = { snap: b.snapshot(), ev: b.drainEvents() };
const last = frames[frames.length - 1];
if (!last || tail.snap.t > last.snap.t) frames.push(tail);
else last.ev.push(...tail.ev);
const r = b.result();
const counts = {};
for (const f of frames) for (const e of f.ev) counts[e[0]] = (counts[e[0]] || 0) + 1;
return {
version: 1, name: sc.name, title: sc.title, kind: sc.kind, stageId: sc.stageId, modeId, round, wave: waveId,
tick: 1 / 30, snapEvery: 3, duration: Math.round(b.time * 100) / 100, recordedAt: '2026-09-27',
field,
...(preview ? { preview } : {}),
frames,
result: {
reason: r.reason, time: Math.round(r.time * 10) / 10, killed: r.killed, total: r.total, bossHpLeft: r.bossHpLeft ?? null,
perPlayer: Object.fromEntries(Object.entries(r.perPlayer).map(([k, v]) => [k, { killed: v.killed, total: v.total, leaked: v.leaked.length, deaths: v.deaths }])),
events: counts, errors: b.errors?.length ?? 0,
},
};
}
function hash(s) {
let h = 2166136261;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return h >>> 0;
}
fs.mkdirSync(OUT, { recursive: true });
const index = [];
const prev = fs.existsSync(path.join(OUT, 'index.json')) ? JSON.parse(fs.readFileSync(path.join(OUT, 'index.json'), 'utf8')) : [];
for (const sc of SCENARIOS) {
if (ONLY && !ONLY.has(sc.name)) { const p = prev.find((x) => x.name === sc.name); if (p) index.push(p); continue; }
const t0 = performance.now();
const rec = record(sc);
const file = path.join(OUT, `${sc.name}.json`);
const json = JSON.stringify(rec);
fs.writeFileSync(file, json);
const maxUnits = Math.max(...rec.frames.map((f) => f.snap.units.length));
index.push({ name: sc.name, title: sc.title, kind: sc.kind, stageId: sc.stageId, duration: rec.duration, frames: rec.frames.length, maxUnits, file: `${sc.name}.json`, bytes: json.length, result: { reason: rec.result.reason, killed: rec.result.killed, total: rec.result.total } });
console.log(`${sc.name.padEnd(12)} ${rec.kind.padEnd(7)} ${rec.duration.toFixed(1).padStart(5)} s frames ${String(rec.frames.length).padStart(4)} units≤${String(maxUnits).padStart(3)} ${(json.length / 1024).toFixed(0).padStart(5)} KB ${rec.result.reason} ${rec.result.killed}/${rec.result.total} errors ${rec.result.errors} (${(performance.now() - t0).toFixed(0)} ms) events ${JSON.stringify(rec.result.events)}`);
}
// recordings made by other scenario lists (kept files) stay listed
for (const p of prev) if (p && !index.some((x) => x.name === p.name) && fs.existsSync(path.join(OUT, p.file || `${p.name}.json`))) index.push(p);
fs.writeFileSync(path.join(OUT, 'index.json'), JSON.stringify(index, null, 1));
console.log(`wrote ${index.length} recordings → ${path.relative(ROOT, OUT)}`);