Files
ark-stronghold-protocol/test/sim/fuzz.test.js
T
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

118 lines
6.5 KiB
JavaScript

// Fuzz: 200 random battles — random real chess lineups on real stages vs real wave templates — run to completion
// with invariants checked along the way (DESIGN §11).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Battle } from '../../server/sim/Battle.js';
import { createRng } from '../../server/sim/rng.js';
import { getDefaultSource, spawnsFromTemplate } from '../../server/sim/simdata.js';
import { checkInvariants } from '../helpers/battleHarness.js';
const ds = getDefaultSource();
const quiet = { error() {}, warn() {}, info() {} };
function pools() {
const chess = ds.chessIds().filter((id) => {
const r = ds.rawChess(id);
return r && r.stats && !r.isDiy && r.chessType !== 'DIY' && r.visible !== false && !r.isHidden;
});
const stages = ds.stageIds().filter((id) => { const s = ds.getStage(id); return s && s.raw && s.raw.active !== false && s.rows && s.rows.length === 19; });
const waves = ds.waveIds().filter((id) => { const w = ds.getWave(id); return w && (w.kind === 'normal' || (!w.kind && /_0\d$|_h0[1-6]$/.test(id))); });
const bossWaves = ds.waveIds().filter((id) => { const w = ds.getWave(id); return w && (w.kind === 'boss' || w.kind === 'hidden'); });
const escaped = ds.waveIds().filter((id) => { const w = ds.getWave(id); return w && w.kind === 'escaped'; });
return { chess, stages, waves, bossWaves, escaped };
}
function deployTiles(stage, where = 'normal') {
const dt = stage.raw.deployTiles?.[where] ?? (where === 'normal' ? { melee: stage.raw.normalDeployTiles?.melee_or_any, rangedOnly: stage.raw.normalDeployTiles?.rangedOnly } : null);
return { melee: dt?.melee ?? [], ranged: dt?.rangedOnly ?? [] };
}
function lineup(rng, P, stage, n, where = 'normal') {
const { melee, ranged } = deployTiles(stage, where);
const used = new Set();
const units = [];
let uid = 1;
for (let i = 0; i < n * 3 && units.length < n; i++) {
const id = rng.pick(P.chess);
const c = ds.getChess(id);
const isRanged = c.position === 'RANGED';
const tiles = isRanged ? ranged.concat(melee) : melee;
const free = tiles.filter(([r, cc]) => !used.has(r + ',' + cc));
if (!free.length) continue;
const [r, cc] = rng.pick(free);
used.add(r + ',' + cc);
units.push({ uid: uid++, kind: 'chess', chessId: id, row: r, col: cc, abs: true, items: [] });
}
return units;
}
test('fuzz: 200 random battles on real data run to completion with invariants', () => {
const P = pools();
assert.ok(P.chess.length > 100, `chess pool ${P.chess.length}`);
assert.ok(P.stages.length >= 8, `stages ${P.stages.length}`);
assert.ok(P.waves.length >= 10, `waves ${P.waves.length}`);
const N = Number(process.env.SIM_FUZZ_N) || 200; // SIM_FUZZ_N=2000 for a long soak
const CHECK_EVERY = Number(process.env.SIM_FUZZ_CHECK_EVERY) || 97;
const rng = createRng(Number(process.env.SIM_FUZZ_SEED) || 20260927);
const stats = { cleared: 0, timeout: 0, forced: 0, ticks: 0, ms: 0, kinds: {} };
for (let i = 0; i < N; i++) {
const seed = (rng() * 2 ** 32) >>> 0;
const stageId = rng.pick(P.stages);
const stage = ds.getStage(stageId);
const roll = rng();
let kind = 'normal';
let waveId = rng.pick(P.waves);
if (roll < 0.1 && P.bossWaves.length) { waveId = rng.pick(P.bossWaves); kind = ds.getWave(waveId).kind; }
else if (roll < 0.18 && P.escaped.length) { kind = 'unite'; waveId = rng.pick(P.escaped); }
const tpl = ds.getWave(waveId);
const { routes, spawns, maxPlayTime } = spawnsFromTemplate(tpl, { mods: rng() < 0.5 ? { hpMul: 0.8, atkMul: 0.8 } : null });
let players;
let sharedBoss = null;
if (kind === 'boss' || kind === 'hidden') {
players = [{ playerId: 'A', seat: 0, side: 'L', units: lineup(rng, P, stage, 1 + rng.int(8), 'bossLeft') }];
if (!tpl.solo) players.push({ playerId: 'B', seat: 1, side: 'R', units: lineup(rng, P, stage, 1 + rng.int(8), 'bossRight') });
sharedBoss = { hp: 3e5, maxHp: 3e5, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } };
} else if (kind === 'unite') {
const a = lineup(rng, P, stage, 1 + rng.int(8));
const b = lineup(rng, P, stage, 1 + rng.int(8)).map((u) => ({ ...u, col: u.col + 8 }));
players = [{ playerId: 'A', seat: 0, side: 'L', units: a.map((u) => ({ ...u, carryState: { hpPct: 0.2 + rng() * 0.8, sp: rng.int(20), skillActive: rng() < 0.2 } })) }, { playerId: 'B', seat: 1, side: 'L', colOffset: 8, units: b }];
} else {
players = [{ playerId: 'P', seat: 0, side: 'L', units: lineup(rng, P, stage, 1 + rng.int(9)), bonds: {} }];
}
stats.kinds[kind] = (stats.kinds[kind] ?? 0) + 1;
const bossLike = kind === 'boss' || kind === 'hidden';
const timeLimit = bossLike ? Infinity : (maxPlayTime ?? 60);
const b = new Battle({
seed, kind, stageId, routes, spawns, timeLimit, players, sharedBoss, logger: quiet, quiet: true,
recordEvents: i % 4 === 0, modeId: 'mode_multi_normal', round: 1 + rng.int(13),
});
const t0 = performance.now();
let n = 0;
const hardCap = bossLike ? 180 : timeLimit + 1;
while (!b.finished) {
b.step();
n++;
if (n % CHECK_EVERY === 0) checkInvariants(b);
if (i % 4 === 0 && n % 3 === 0) { b.snapshot(); b.drainEvents(); }
if (b.time > hardCap) { if (bossLike) b.forceEnd('forced'); else break; }
}
stats.ms += performance.now() - t0;
stats.ticks += n;
assert.ok(b.finished, `battle ${i} (${kind} ${stageId} ${waveId} seed ${seed}) terminated`);
if (!bossLike) assert.ok(b.time <= timeLimit + 1, `battle ${i} ended in time (${b.time})`);
checkInvariants(b);
assert.equal(b.internalErrorCount ?? 0, 0, `battle ${i} internal errors: ${JSON.stringify(b.errors.slice(0, 2))}`);
assert.equal(b.errors.length, 0, `battle ${i} errors: ${JSON.stringify(b.errors.slice(0, 2))}`);
const r = b.result();
stats[r.reason]++;
for (const [pid, pp] of Object.entries(r.perPlayer)) {
assert.ok(pp.killed <= pp.total || kind === 'unite', `${pid} killed ≤ total`);
for (const k of ['damageDealt', 'healingDone', 'bossDamage']) assert.ok(Number.isFinite(pp[k]) && pp[k] >= 0, `${k} finite`);
if (r.reason === 'cleared' && kind === 'normal') assert.equal(pp.killed + pp.leaked.filter((l) => l.counted).length, pp.total, 'every counted enemy killed or leaked');
}
JSON.stringify(r);
}
// eslint-disable-next-line no-console
console.log(`fuzz: ${JSON.stringify(stats.kinds)} cleared=${stats.cleared} timeout=${stats.timeout} forced=${stats.forced} avg ${(stats.ms / stats.ticks * 1000).toFixed(1)} µs/tick`);
});