Files
ark-stronghold-protocol/test/sim/fuzz.test.js
T
xcdoge b4bc89ad4e feedback7 UIBACK: the top-bar capsule counts like the official — resolved = killed + leaked among the enemies the round scheduled (opening 0/3, a leak 1/3, a kill 2/3, the splitter 3/3); a split child, a summon and a boss summon are in neither number but still cost LP and break 完美作战 (PR #308 by @xcdoge, the capsule half of PR #157 — authored by them: the sim / protocol / HUD hunks applied as reviewed): inTotal / killedInTotal / leakedInTotal / resolved, b.snap / BattleResult / b.progress (optional field) / m.public progress carry resolved, killed may exceed total (validation bounds it by the content-spawn allowance); dropped the PR's stray design-doc line — it had replaced the S→C b.end bullet of docs/design/network.md, which is back; the 联防 live-counter change mark follows the enemy list now that a spawn no longer moves total (uniteLeftMark + test); golden: only total / players.*.total / snaps move, outcomes bit-identical — roster 23 (roster-002 003 007 008 009 010 011 012 013 015 017 018 020 023 024 025 026 027 037 045 047 048 049), bonds 2 (bond-maniShip-low, bond-soloShip-high), fields 11 (boss-boss_1 3 5 pair+solo, boss-boss_6-solo, hidden-boss_8 10 pair+solo), diy 11 (diy-036 040 056 062 068 073 083 091 095 114 115), matches 12 (solo-FUNNY-1 NORMAL-1 HARD-1 ABYSS-1 ABYSS-2, coop2-NORMAL-3 coop2-HARD-4 coop3-ABYSS-5 coop4-ABYSS-7 coop2-NORMAL-8-serverrun solo-HARD-9-boosted coop2-NORMAL-70-diy: the total column of 36 round rows), standins unchanged
2026-10-08 19:30:36 +08:00

130 lines
7.6 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)) {
// the capsule's own pair: `total` counts only the enemies this field scheduled (`inTotal`), and its numerator
// never exceeds it. `killed` deliberately keeps the `counted` reading — a runtime split child / summon knocked
// down counts there — so `killed > total` is legal since PR #157's capsule semantics (a 4-磨砻 round killed 4
// plus a child reads 5/4 on nothing but `killed/total`; the capsule reads resolved/total = 4/4)
assert.ok(pp.killedInTotal <= pp.total, `${pid} killedInTotal ≤ total`);
assert.ok(pp.resolved <= pp.total, `${pid} resolved ≤ total`);
for (const k of ['damageDealt', 'healingDone', 'bossDamage']) assert.ok(Number.isFinite(pp[k]) && pp[k] >= 0, `${k} finite`);
// the capsule identity: every one of the field's own enemies is 已解决 — knocked down (killedInTotal) or leaked
// (leakedInTotal). It replaces the old `killed + counted leaks = total`, which mixed the counted reading with the
// scheduled denominator and no longer holds (killed counts runtime children, total does not)
if (r.reason === 'cleared' && kind === 'normal') {
assert.ok(pp.killed + pp.leaked.filter((l) => l.counted).length >= pp.total, 'every counted enemy killed or leaked');
assert.equal(pp.killedInTotal + pp.leakedInTotal, pp.total, 'the capsule is full: killedInTotal + leakedInTotal = total');
assert.equal(pp.resolved, pp.total, 'a cleared normal field resolves its own list');
}
}
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`);
});