Files
sganggs f3658a0dd4 0.2.3: prepare follow-up fixes and client features (#407, #408, #410, #413, #431, #435, #436, #440, #444)
Add special-target activation without bypassing attack cadence, cooperative
card preferences and emotes, skill sound banks, per-operator voice choices,
text sizing, install prompts, closed-tab recovery and unanimous setup rerolls.
Retain late deployment elapsed time and resolve echo/counter pulses without
recursive hook overflow. Independent final review remains pending.

Golden: roster-016, roster-035, roster-036, roster-045;
boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair,
hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence.
diy-132: Necras extra activation targets.
coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes.
hidden-boss_10-solo: echo pulse FIFO resolution.
See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
2026-10-09 22:55:24 +08:00

103 lines
4.9 KiB
JavaScript

// Protocol fuzz: random VALID-SHAPED intents (they pass shared/protocol.js validateC2S) in random phases, random
// disconnects/reconnects/leaves and random time jumps never throw, always answer { ok } or { error: ERR.* }, never
// report an internal error and never corrupt the invariants (pool accounting, funds, slots, legality, merges).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { validateC2S, C2S } from '../../shared/protocol.js';
import { ERR, EMOTES } from '../../shared/constants.js';
import { createRng } from '../../server/sim/rng.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
const GAME = Object.keys(C2S).filter((t) => t.startsWith('g.') && t !== 'g.leave');
const BANDS = Object.keys(DATA.bands);
function randomIntent(rng, m, ps) {
const t = rng.pick(GAME);
const pieces = [];
if (ps) {
for (const p of [...ps.board.values(), ...ps.hand, ...ps.temp]) {
if (!p) continue;
pieces.push(p.uid);
for (const it of p.items || []) pieces.push(it.uid);
}
}
const uid = () => (pieces.length && rng() < 0.8 ? rng.pick(pieces) : 1 + rng.int(5000));
const to = () => (rng() < 0.6 ? { area: 'board', row: rng() < 0.8 ? 9 + rng.int(4) : rng.int(19), col: rng() < 0.8 ? 2 + rng.int(9) : rng.int(21) } : { area: 'hand', idx: rng.int(10) });
switch (t) {
case 'g.infoReady': return { t, setupRevision: m.setupRevision ?? 0 };
case 'g.rerollVote': return { t, voteId: m.setupVote?.id ?? 1 + rng.int(10), agree: rng() < 0.5 };
case 'g.band': return { t, bandId: rng() < 0.8 ? rng.pick(BANDS) : rng.pick(['constructor', '__proto__', 'band_x', 'a'.repeat(64)]) };
case 'g.buy': return { t, slot: rng.int(16) };
case 'g.sell': case 'g.destroy': return { t, uid: uid() };
case 'g.move': return { t, uid: uid(), to: to() };
case 'g.equip': return { t, itemUid: uid(), targetUid: uid() };
case 'g.art': return { t, itemUid: uid(), row: rng.int(19), col: rng.int(21) };
case 'g.reward': case 'g.choice': return { t, idx: rng.int(6) };
case 'g.ready': return { t, ready: rng() < 0.4 };
case 'g.emote': return { t, id: rng.pick(EMOTES) };
case 'g.watch': return { t, fieldId: rng.pick(['n:p_0', 'n:p_1', 'n:ai_0', 'u', 'b1', 'b2', 'zz', '']) };
case 'g.autoplay': return { t, on: rng() < 0.05 };
case 'g.pause': return { t, on: rng() < 0.5 };
default: return { t };
}
}
function fuzzOne(seed, { fake }) {
const rng = createRng(seed * 7919);
const mode = rng() < 0.3 ? 'solo' : 'coop';
const difficulty = rng.pick(['FUNNY', 'NORMAL', 'HARD', 'ABYSS']);
const humans = mode === 'solo' ? 1 : 1 + rng.int(3);
const bots = mode === 'solo' ? 0 : rng.int(3);
const h = makeMatch({ mode, difficulty, humans, bots, seed, fake, captureFrames: false, checkFrames: true, script: () => ({ duration: 2 + rng.int(6), leaks: {} }) });
const m = h.m;
m.start();
const humanIds = [...m.players.values()].filter((p) => !p.isBot).map((p) => p.playerId);
let intents = 0;
for (let step = 0; step < 2500 && h.ended == null; step++) {
const r = rng();
const pid = rng.pick(humanIds);
const ps = m.players.get(pid);
if (r < 0.82) {
const msg = randomIntent(rng, m, ps);
assert.equal(validateC2S(msg), null, `fuzz produced an invalid message ${JSON.stringify(msg)}`);
const res = m.handle(pid, msg);
intents++;
assert.ok(res && (res.ok === true || (typeof res.error === 'string' && Object.hasOwn(ERR, res.error))), `bad reply ${JSON.stringify(res)} to ${JSON.stringify(msg)}`);
assert.notEqual(res.error, ERR.INTERNAL, `internal error on ${JSON.stringify(msg)}`);
} else if (r < 0.85) {
m.onDisconnect(pid);
} else if (r < 0.88) {
m.onReconnect(pid);
} else if (r < 0.882 && humanIds.filter((id) => !m.players.get(id).left).length > 1) {
m.onLeave(pid);
} else if (r < 0.96) {
h.sched.advance(rng.int(3000));
} else {
for (let k = 0; k < 20; k++) if (!h.sched.runNext()) break;
}
if (step % 20 === 0) checkInvariants(m);
}
// finish the match: everyone left on AI 托管
for (const ps of m.players.values()) if (!ps.isBot && !ps.left) m.handle(ps.playerId, { t: 'g.autoplay', on: true });
h.drive(() => h.ended != null);
assert.ok(h.ended, `seed ${seed}: match did not end (${m.phase} R${m.round})`);
assert.equal(h.endedCount, 1);
assert.equal(m.errorCount, 0, `seed ${seed}: ${JSON.stringify(m.errors.slice(0, 3))}`);
assert.equal(m.dispatcher.errors, 0);
assert.deepEqual(h.sched.errors, []);
assert.deepEqual(h.badFrames, []);
checkInvariants(m);
m.dispose();
return intents;
}
test('fuzz with FakeBattle: 60 matches of random intents / connection churn / time jumps', () => {
let n = 0;
for (let seed = 1; seed <= 60; seed++) n += fuzzOne(seed, { fake: true });
assert.ok(n > 10000, `${n} intents`);
});
test('fuzz with the real simulation: 8 matches', () => {
for (let seed = 101; seed <= 108; seed++) fuzzOne(seed, { fake: false });
});