Files
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

186 lines
9.9 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/simrun.mjs — run one battle headlessly and print a balancing summary (+ optional ASCII frames).
//
// Usage:
// node tools/simrun.mjs scenario.json [--ascii 5] [--json]
// node tools/simrun.mjs --mode mode_multi_normal --round 5 [--stage act2autochess_m01] [--boss boss_1]
// --lineup "chess_char_1_02_a@9,7 隐现@10,4 角峰*@9,5" [--seed 1] [--content full|generic|none]
// [--hpMul 1 --atkMul 1 --speedMul 1] [--wave act1autochess_05] [--time 60] [--ascii 5] [--json] [--events]
//
// Lineup entries: `<chessId|name>[*]@row,col` in board coordinates (rows 9–12, cols 2–10); `*` = elite (精锐 _b).
// --mode/--round pick the wave template, time limit and enemy multipliers from data/config.json (placeholder
// template enemies are NOT replaced by factions — that is the match's job; pass --wave to force a template).
// Scenario JSON: { stageId, kind, seed, wave|waveTemplate, round, modeId, timeLimit, mods, content, units:[…]|players:[…],
// enemies:[{ key, time, route, count, interval, mods, pos }], sharedBossHp }
// Output: reason, time, kills/total, leaks, per-unit damage / DPS / kills / heals / skill casts / deaths.
import fs from 'node:fs';
import { Battle } from '../server/sim/Battle.js';
import { getDefaultSource, spawnsFromTemplate } from '../server/sim/simdata.js';
import { getData } from '../server/data.js';
import { COLS } from '../server/sim/constants.js';
const argv = process.argv.slice(2);
const opt = {};
const pos = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) {
const k = a.slice(2);
const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
opt[k] = v;
} else pos.push(a);
}
if (opt.help || (!pos.length && !opt.mode && !opt.wave && !opt.lineup)) {
console.log(fs.readFileSync(new URL(import.meta.url)).toString().split('\n').filter((l) => l.startsWith('//')).map((l) => l.slice(3)).join('\n'));
process.exit(0);
}
const ds = getDefaultSource();
const data = getData({ log: { warn() {}, error() {}, info() {} } });
const config = data.config ?? {};
function findChess(q) {
let golden = false;
if (q.endsWith('*')) { golden = true; q = q.slice(0, -1); }
let id = null;
if (ds.rawChess(q)) id = q;
else {
for (const cid of ds.chessIds()) {
const r = ds.rawChess(cid);
if (r && r.name === q && !r.isGolden && r.visible !== false && !r.isHidden) { id = cid; break; }
}
if (!id) for (const cid of ds.chessIds()) { const r = ds.rawChess(cid); if (r && r.name === q && !r.isGolden) { id = cid; break; } }
}
if (!id) throw new Error(`unknown chess ${q}`);
if (golden) id = id.replace(/_a$/, '_b');
return id;
}
function parseLineup(s) {
return String(s).trim().split(/\s+/).filter(Boolean).map((tok, i) => {
const m = tok.match(/^(.+)@(\d+),(\d+)$/);
if (!m) throw new Error(`bad lineup entry ${tok} (want id@row,col)`);
return { uid: i + 1, kind: 'chess', chessId: findChess(m[1]), row: +m[2], col: +m[3] };
});
}
// ---- build scenario
let sc = {};
if (pos[0]) sc = JSON.parse(fs.readFileSync(pos[0], 'utf8'));
const modeId = opt.mode ?? sc.modeId ?? 'mode_multi_normal';
const round = Number(opt.round ?? sc.round ?? 1);
const mode = config.modes?.[modeId];
const roundCfg = mode?.rounds?.[String(round)];
let kind = opt.kind ?? sc.kind ?? (roundCfg?.isBoss ? 'boss' : roundCfg?.isHidden ? 'hidden' : 'normal');
let waveId = opt.wave ?? sc.wave ?? sc.waveTemplate ?? roundCfg?.template ?? null;
if (!waveId && roundCfg?.bossTemplates) {
const bossId = opt.boss ?? Object.keys(roundCfg.bossTemplates)[0];
waveId = roundCfg.bossTemplates[bossId];
if (/_s$/.test(waveId) === false && modeId.includes('single')) waveId = waveId + '_s';
}
const stageId = opt.stage ?? sc.stageId ?? mode?.stages?.[0] ?? 'act2autochess_m01';
const scale = mode?.enemyScale?.[String(round)] ?? null;
const mods = sc.mods ?? {
hpMul: Number(opt.hpMul ?? scale?.hp ?? 1), atkMul: Number(opt.atkMul ?? scale?.atk ?? 1), speedMul: Number(opt.speedMul ?? scale?.speed ?? 1),
};
let routes = [], spawns = [], timeLimit = null, overrides = null;
if (waveId) {
const tpl = ds.getWave(waveId);
if (!tpl) throw new Error(`unknown wave ${waveId}`);
const conv = spawnsFromTemplate(tpl, { mods });
routes = conv.routes; spawns = conv.spawns; timeLimit = conv.maxPlayTime; overrides = conv.overrides;
if (tpl.kind === 'boss' || tpl.kind === 'hidden') kind = tpl.kind;
}
for (const e of sc.enemies ?? []) {
spawns.push({ time: e.time ?? 0, enemyKey: e.key, routeIndex: typeof e.route === 'number' ? e.route : 0, route: typeof e.route === 'object' ? e.route : null, count: e.count ?? 1, interval: e.interval ?? 0, mods: e.mods ?? mods, pos: e.pos ?? null, tag: e.tag ?? null });
}
if (sc.routes) routes = sc.routes;
if (opt.time) timeLimit = Number(opt.time);
else if (sc.timeLimit != null) timeLimit = sc.timeLimit;
if (kind === 'boss' || kind === 'hidden') timeLimit = opt.time ? Number(opt.time) : (sc.timeLimit ?? 300);
const players = sc.players ?? [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: sc.units ?? parseLineup(opt.lineup ?? '') }];
const bossHp = Number(opt.bossHp ?? sc.sharedBossHp ?? 0);
const sharedBoss = bossHp > 0 ? { hp: bossHp, maxHp: bossHp, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } } : null;
const seed = Number(opt.seed ?? sc.seed ?? 1);
const b = new Battle({
seed, kind, modeId, round, stageId, timeLimit: timeLimit ?? 60, routes, spawns, players, sharedBoss, enemyOverrides: overrides ?? {},
content: opt.content ?? sc.content ?? 'full', recordEvents: !!opt.events, verbose: !!opt.verbose, fieldId: 'sim',
});
// ---- ASCII frames
const asciiEvery = opt.ascii ? Number(opt.ascii === true ? 5 : opt.ascii) : 0;
const legend = new Map();
function frame() {
const R = b.rect;
const lines = [];
const occ = new Map();
for (const u of b.units) {
if (!u.alive || !u.deployed || u.hidden) continue;
const k = Math.round(u.y) * COLS + Math.round(u.x);
if (!occ.has(k)) occ.set(k, []);
occ.get(k).push(u);
}
for (let r = R.r1; r >= R.r0; r--) {
let line = String(r).padStart(2) + ' ';
for (let c = R.c0; c <= R.c1; c++) {
const us = occ.get(r * COLS + c) || [];
const ally = us.find((u) => u.side === 'ally');
const ens = us.filter((u) => u.side === 'enemy');
if (ally && ally.kind === 'device') line += ens.length ? '!' : '■';
else if (ally) {
if (!legend.has(ally.id)) legend.set(ally.id, String.fromCharCode(65 + legend.size % 26));
line += ens.length ? legend.get(ally.id).toLowerCase() : legend.get(ally.id);
} else if (ens.length) line += ens.length > 9 ? '+' : ens.some((e) => e.isFlying) ? '^' : String(ens.length);
else {
const t = b.grid.tile(r, c);
line += t.special === 'end' ? 'E' : t.special === 'start' ? 'S' : t.pass === 'ALL' ? '.' : t.height === 'HIGH' ? '#' : ',';
}
}
lines.push(line);
}
const alive = b.enemies.filter((e) => e.alive);
console.log(`--- t=${b.time.toFixed(1)}s enemies alive ${alive.length} killed ${b.killed}/${b.total} dp ${Math.floor(b.players[0]?.dp ?? 0)}`);
console.log(lines.join('\n'));
const ops = b.allyUnits.filter((u) => u.kind !== 'device' && legend.has(u.id));
console.log(ops.map((u) => `${legend.get(u.id)}=${u.name}${u.def.golden ? '*' : ''} ${u.alive ? Math.round(u.hpRatio * 100) + '%' : 'dead'}${u.skill?.active ? ' [S]' : ''}`).join(' '));
}
const t0 = performance.now();
let n = 0;
let nextFrame = 0;
while (!b.finished) {
if (asciiEvery && b.time + 1e-9 >= nextFrame) { frame(); nextFrame += asciiEvery; }
b.step();
n++;
if (opt.events && n % 3 === 0) for (const ev of b.drainEvents()) if (ev[0] !== 'dmg' && ev[0] !== 'atk' && ev[0] !== 'heal') console.log(b.time.toFixed(2), JSON.stringify(ev));
if ((kind === 'boss' || kind === 'hidden') && b.time >= timeLimit) b.forceEnd('forced');
}
const ms = performance.now() - t0;
if (asciiEvery) frame();
const r = b.result();
if (opt.json) {
console.log(JSON.stringify(r, null, 2));
process.exit(0);
}
console.log(`\n== ${kind} battle · stage ${stageId} · wave ${waveId ?? '(custom)'} · ${modeId} R${round} · seed ${seed}`);
console.log(` enemy mods: hp×${mods.hpMul ?? 1} atk×${mods.atkMul ?? 1} speed×${mods.speedMul ?? 1}`);
console.log(` result: ${r.reason} at ${r.time.toFixed(1)} s · killed ${r.killed}/${r.total} · ${n} ticks in ${ms.toFixed(1)} ms (${(ms / n * 1000).toFixed(1)} µs/tick)${b.errorCount ? ' · ERRORS ' + b.errorCount : ''}`);
if (r.bossHpLeft != null) console.log(` boss pool left: ${Math.round(r.bossHpLeft)}`);
for (const [pid, pp] of Object.entries(r.perPlayer)) {
const leaks = {};
for (const l of pp.leaked) leaks[l.enemyKey] = (leaks[l.enemyKey] ?? 0) + 1;
console.log(`\n player ${pid}: killed ${pp.killed}/${pp.total} · leaked ${pp.leaked.length}${pp.leaked.length ? ' ' + JSON.stringify(leaks) : ''} · perfect ${pp.perfect} · deaths ${pp.deaths} · coins ${pp.coins}`);
console.log(` damage ${Math.round(pp.damageDealt)} · healing ${Math.round(pp.healingDone)} · boss ${Math.round(pp.bossDamage)}${Object.keys(pp.layerGains).length ? ' · layers ' + JSON.stringify(pp.layerGains) : ''}`);
const rows = pp.unitStats.filter((u) => u.kind === 'op' || u.dmg > 0 || u.heal > 0);
console.log(' ' + ['unit'.padEnd(18), 'dmg'.padStart(8), 'dps'.padStart(7), 'kills'.padStart(6), 'heal'.padStart(7), 'taken'.padStart(7), 'atks'.padStart(5), 'casts'.padStart(6)].join(' '));
for (const u of rows) {
const unit = b.unitById(u.id);
const casts = unit?.skill?.activations ?? 0;
console.log(' ' + [(u.name + (unit?.def?.golden ? '*' : '')).slice(0, 16).padEnd(18), String(u.dmg).padStart(8), (u.dmg / Math.max(1, r.time)).toFixed(0).padStart(7), String(u.kills).padStart(6), String(u.heal).padStart(7), String(u.taken).padStart(7), String(u.attacks).padStart(5), String(casts).padStart(6)].join(' '));
}
}
if (b.errors.length) { console.log('\n errors:'); for (const e of b.errors.slice(0, 10)) console.log(' - ' + e.label + ' ' + e.who + ': ' + e.message); }