- Conflicts (the branch started atae22335c): spec.js / protocol.js b.progress (resolved from fb7-ui-backlog + leaksBy), gameLogic.js / game.js exports (device tips + normalizePersonalChoice), docs/PLAYING.md / design/network.md lines merged against the base, META.md (画卷 note + 教鞭), test/docs-consistency.test.js pin (both fields). - Grok review: MERGE AFTER FIXES — the HIGH (a forged boss result paid the cards) fixed on the branch (66a228c6), the META wording (1ab12763). - Golden: matches 10 (the 杜宾 bot picks and the guard placement); union: coop3-ABYSS-5 differs from HEAD only in ai_2's dmgDealt (1238011 → 1186269), no outcome change — HEAD's side had moved it, the new placement applies on that path. - Broad node --test 5623 (5621 pass, 0 fail, 2 skipped); lint 0 errors; typecheck; i18n strict clean.
277 lines
15 KiB
JavaScript
277 lines
15 KiB
JavaScript
// test/match/harness.js — helpers for the match-engine tests (test/match/*.test.js).
|
|
//
|
|
// const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 7, fake: true });
|
|
// h.start(); h.runToPhase('PREP'); const ps = h.ps('p_0'); …; h.invariants();
|
|
//
|
|
// Options: mode, difficulty, humans (count) | seats (explicit), bots, spectators (spectator seat ids, opts.spectators),
|
|
// seed, matchNo (the room's match number: part of the battleId prefix), data (default: real data/*.json),
|
|
// fake (true → test/match/fakeBattle.js as BattleClass), script (FakeBattle.script), registry, instant (virtual
|
|
// scheduler runs battles synchronously; default true), timerScale, battleContent, botRehearsal (default 0),
|
|
// botSliceMs (bot rehearsal slice budget; default: unbounded in virtual time), aiPicksLast (the room option
|
|
// AI 队友最后选择: the drafts order every human seat before every AI seat).
|
|
// Combat mode: clientCombat (default false here: the legacy server-run mode most suites were written for; production
|
|
// defaults to client-side combat, DESIGN §14). With clientCombat: true every human gets a scripted browser
|
|
// (test/match/simClient.js SimClient: h.clients) unless clients: false; pace 'instant' | 'paced', perPlayer
|
|
// { [playerId]: SimClient options } (tamper / mute / stall …), verify ('off' | 'sample' | 'all'), headlessSliceMs
|
|
// (server-run fields in wall-clock slices even in virtual time).
|
|
// Hooks: h.onSend [(playerId, msg) => void], h.onBroadcast [(msg) => void].
|
|
// Captures: h.sent [[playerId, msg]], h.bc [msg], h.ended (summary), h.logs { error: [], warn: [] }.
|
|
|
|
import assert from 'node:assert/strict';
|
|
import { Match } from '../../server/match/Match.js';
|
|
import { VirtualScheduler } from '../../server/match/scheduler.js';
|
|
import { getData } from '../../server/data.js';
|
|
import { FIELD, canPlace, placeClass, positionClass, tileKey } from '../../server/match/board.js';
|
|
import { FakeBattle } from './fakeBattle.js';
|
|
import { GEO } from '../../shared/constants.js';
|
|
import { collectViolations } from '../../server/match/invariants.js';
|
|
import { attachSimClients } from './simClient.js';
|
|
|
|
export const DATA = getData({ log: { warn() {}, error() {}, info() {} } });
|
|
|
|
export function makeMatch(o = {}) {
|
|
const mode = o.mode ?? 'coop';
|
|
const difficulty = o.difficulty ?? 'NORMAL';
|
|
let seats = o.seats;
|
|
if (!seats) {
|
|
seats = [];
|
|
const humans = o.humans ?? 1;
|
|
const bots = mode === 'solo' ? 0 : (o.bots ?? 0);
|
|
for (let i = 0; i < humans; i++) seats.push({ seat: seats.length, playerId: `p_${i}`, name: `P${i}`, isBot: false, connected: true });
|
|
for (let i = 0; i < bots; i++) seats.push({ seat: seats.length, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true });
|
|
}
|
|
const sched = new VirtualScheduler({ instantCombat: o.instant !== false });
|
|
const logs = { error: [], warn: [] };
|
|
const log = { info() {}, debug() {}, warn: (...a) => logs.warn.push(a.map(String).join(' ')), error: (...a) => logs.error.push(a.map(String).join(' ')) };
|
|
const h = { sent: [], bc: [], ended: null, endedCount: 0, logs, sched, frames: 0, badFrames: [], onSend: [], onBroadcast: [], clients: null };
|
|
const captureFrames = o.captureFrames !== false;
|
|
const checkFrame = (msg) => {
|
|
// every frame must be JSON-safe: no NaN/Infinity, no undefined-only structures, serializable
|
|
if (!o.checkFrames) return;
|
|
const s = JSON.stringify(msg);
|
|
if (s == null || /NaN|Infinity/.test(s.replace(/"[^"]*"/g, ''))) h.badFrames.push(msg.t);
|
|
};
|
|
if (o.fake) {
|
|
FakeBattle.reset();
|
|
if (o.script) FakeBattle.script = o.script;
|
|
}
|
|
h.m = new Match({
|
|
roomCode: 'TEST', mode, difficulty, seats, spectators: o.spectators, seed: o.seed ?? 1, matchNo: o.matchNo, data: o.data ?? DATA, log,
|
|
send: (id, msg) => {
|
|
for (const fn of h.onSend) fn(id, msg);
|
|
if (msg.t === 'b.snap' || msg.t === 'b.ev') { h.frames++; if (!captureFrames) return true; }
|
|
checkFrame(msg);
|
|
h.sent.push([id, msg]);
|
|
return true;
|
|
},
|
|
broadcast: (msg) => { for (const fn of h.onBroadcast) fn(msg); checkFrame(msg); h.bc.push(msg); },
|
|
onEnd: (s) => { h.ended = s; h.endedCount++; },
|
|
scheduler: sched,
|
|
registry: o.registry,
|
|
BattleClass: o.fake ? FakeBattle : undefined,
|
|
battleContent: o.battleContent,
|
|
timerScale: o.timerScale,
|
|
// bot layout rehearsal (extra simulated battles per bot prep) is off unless a test asks for it: it multiplies the
|
|
// simulation work of full-match suites; test/match/bot.test.js covers it
|
|
botRehearsal: o.botRehearsal ?? 0,
|
|
botSliceMs: o.botSliceMs,
|
|
clientCombat: o.clientCombat ?? false,
|
|
verify: o.verify ?? 'off',
|
|
headlessSliceMs: o.headlessSliceMs,
|
|
aiPicksLast: o.aiPicksLast,
|
|
});
|
|
const m = h.m;
|
|
if (m.clientCombat && o.clients !== false) {
|
|
h.clients = attachSimClients(h, { BattleClass: o.fake ? FakeBattle : undefined, pace: o.pace, perPlayer: o.perPlayer });
|
|
}
|
|
h.start = () => { m.start(); return h; };
|
|
h.ps = (id) => m.players.get(id);
|
|
h.run = (pred, opts = {}) => sched.runUntil(pred, { maxSteps: 3e6, ...opts });
|
|
/** run until the phase (and round) is reached */
|
|
h.runToPhase = (phase, round = null, opts = {}) => {
|
|
const ok = h.run(() => h.ended != null || (m.phase === phase && (round == null || m.round === round)), opts);
|
|
assert.ok(ok && m.phase === phase && (round == null || m.round === round), `expected ${phase}${round != null ? ' R' + round : ''}, got ${m.phase} R${m.round}${h.ended ? ' (ended)' : ''}`);
|
|
return h;
|
|
};
|
|
h.runToEnd = (opts = {}) => { h.run(() => h.ended != null, opts); assert.ok(h.ended, `match did not end (phase ${m.phase} R${m.round})`); return h.ended; };
|
|
/** last message of a type sent to a player (unicast) */
|
|
h.lastTo = (id, type) => { for (let i = h.sent.length - 1; i >= 0; i--) if (h.sent[i][0] === id && h.sent[i][1].t === type) return h.sent[i][1]; return null; };
|
|
h.lastBc = (type) => { for (let i = h.bc.length - 1; i >= 0; i--) if (h.bc[i].t === type) return h.bc[i]; return null; };
|
|
h.allTo = (id, type) => h.sent.filter(([pid, msg]) => pid === id && msg.t === type).map(([, msg]) => msg);
|
|
h.flushAll = () => { sched.advance(200); m.flush(true); };
|
|
/** give the human every draft decision automatically (humans otherwise wait for timers) */
|
|
h.autoHumans = () => { for (const ps of m.players.values()) if (!ps.isBot) ps.autoplay = true; return h; };
|
|
h.invariants = () => checkInvariants(m);
|
|
/** force the stage (tests need known geometry) */
|
|
h.setStage = (stageId) => {
|
|
m.stageId = stageId;
|
|
m.stage = m.gd.stage(stageId);
|
|
m._botPath = null;
|
|
for (const ps of m.players.values()) ps.invalidateDeployMap();
|
|
return h;
|
|
};
|
|
/** drive human decisions (info, band, 机变, ready) until `pred` holds */
|
|
h.drive = (pred, { ready = true, band = 'band_bldsk', maxSteps = 3e6 } = {}) => {
|
|
for (let i = 0; i < maxSteps; i++) {
|
|
if (pred()) return true;
|
|
if (h.ended) return !!pred();
|
|
for (const ps of m.players.values()) {
|
|
if (ps.isBot || ps.left) continue;
|
|
if (m.phase === 'INFO_CHECK' && !ps.infoReady) m.handle(ps.playerId, { t: 'g.infoReady' });
|
|
if (m.phase === 'BAND_DRAFT' && m.draftTurn() === ps.playerId) m.handle(ps.playerId, { t: 'g.band', bandId: band });
|
|
if (m.phase === 'SP_DRAFT' && m.spTurn() === ps.playerId) {
|
|
const idx = m.sp.cards.map((c) => c.idx).find((k) => m.sp.taken[k] == null);
|
|
if (idx != null) m.handle(ps.playerId, { t: 'g.choice', idx });
|
|
}
|
|
if (ready && m.phase === 'PREP' && ps.alive && !ps.ready) {
|
|
if (ps.personalChoice) m.handle(ps.playerId, { t: 'g.choice', idx: 0, choiceId: ps.personalChoice.id });
|
|
if (!ps.tempEmpty) ps.resolveTemp();
|
|
m.handle(ps.playerId, { t: 'g.ready', ready: true });
|
|
}
|
|
}
|
|
if (pred()) return true;
|
|
if (!sched.runNext()) return !!pred();
|
|
}
|
|
return !!pred();
|
|
};
|
|
/** reach PREP of round r (humans confirm/draft/ready automatically) */
|
|
h.toPrep = (r = 1, opts = {}) => {
|
|
const ok = h.drive(() => m.phase === 'PREP' && m.round === r, { ...opts, ready: true, stopAtPrep: r });
|
|
assert.ok(ok, `could not reach PREP R${r} (phase ${m.phase} R${m.round}${h.ended ? ', ended' : ''})`);
|
|
return h;
|
|
};
|
|
return h;
|
|
}
|
|
|
|
/** Engine invariants (DESIGN §11). Throws an AssertionError listing every violation (server/match/invariants.js). */
|
|
export function checkInvariants(m) {
|
|
const violations = collectViolations(m);
|
|
assert.deepEqual(violations, [], `invariants violated (${m.phase} R${m.round}):\n ${violations.join('\n ')}`);
|
|
return legacyInvariants(m);
|
|
}
|
|
|
|
/** The original harness checks (kept alongside collectViolations; they must agree). */
|
|
function legacyInvariants(m) {
|
|
const pool = m.pool;
|
|
const held = new Map();
|
|
const uids = new Set();
|
|
const note = (p) => {
|
|
assert.ok(Number.isInteger(p.uid) && p.uid > 0, `bad uid ${p.uid}`);
|
|
assert.ok(!uids.has(p.uid), `duplicate uid ${p.uid}`);
|
|
uids.add(p.uid);
|
|
};
|
|
for (const ps of m.players.values()) {
|
|
assert.ok(Number.isInteger(ps.funds) && ps.funds >= 0, `${ps.playerId} funds ${ps.funds}`);
|
|
assert.ok(Number.isInteger(ps.pendingFunds) && ps.pendingFunds >= 0, `${ps.playerId} pending ${ps.pendingFunds}`);
|
|
assert.equal(ps.hand.length, GEO.HAND_SIZE);
|
|
assert.equal(ps.temp.length, GEO.TEMP_SIZE);
|
|
assert.ok(ps.shop.level >= 1 && ps.shop.level <= m.gd.maxShopLevel);
|
|
assert.ok(ps.shop.upgradePrice >= 0);
|
|
assert.ok(ps.shop.freeRefreshes >= 0);
|
|
if (!ps.alive) {
|
|
assert.equal(ps.board.size, 0, 'eliminated player keeps no board');
|
|
assert.ok(ps.hand.every((x) => x == null) && ps.temp.every((x) => x == null), 'eliminated player keeps nothing');
|
|
}
|
|
const chessUids = new Set();
|
|
const diyHeld = new Map();
|
|
const all = [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)];
|
|
for (const p of all) if (p.kind === 'chess') chessUids.add(p.uid);
|
|
let deployed = 0;
|
|
const dmap = m.deployMapFor(ps); // a pure read (as server/match/invariants.js): no PlayerState cache touched
|
|
for (const [k, p] of ps.board) {
|
|
const [r, c] = k.split(',').map(Number);
|
|
assert.ok(r >= FIELD.r0 && r <= FIELD.r1 && c >= FIELD.c0 && c <= FIELD.c1, `piece outside the board ${k}`);
|
|
const pgd = ps.gd || m.gd; // the player's data view (0.2.0 自选: its summons are data/backups.json tokens)
|
|
const rec = p.kind === 'token' ? pgd.token(p.id) : pgd.chess(p.id);
|
|
assert.ok(rec, `unknown board piece ${p.id}`);
|
|
const cls = p.kind === 'chess' ? placeClass(ps, rec) : positionClass(rec);
|
|
assert.ok(canPlace(dmap, cls, r, c), `illegal tile ${p.id} @ ${k}`);
|
|
assert.notEqual(p.kind, 'item', 'items never stand on the board');
|
|
if (p.kind === 'chess') deployed++;
|
|
if (p.kind === 'token') assert.ok(chessUids.has(p.ownerUid), `orphan token ${p.uid}`);
|
|
}
|
|
assert.ok(deployed <= ps.deployCap, `${ps.playerId} deployCount ${deployed} > cap ${ps.deployCap}`);
|
|
for (const p of all) {
|
|
note(p);
|
|
if (p.kind === 'chess') {
|
|
const rec = m.gd.chess(p.id);
|
|
assert.ok(rec, `unknown chess ${p.id}`);
|
|
assert.ok(Array.isArray(p.items) && p.items.length <= m.gd.equipPerChess, `${p.id} carries ${p.items && p.items.length} items`);
|
|
for (const it of p.items) { note(it); assert.equal(it.kind, 'item'); assert.ok(m.gd.item(it.id), `unknown item ${it.id}`); }
|
|
assert.ok(Number.isInteger(p.poolCopies) && p.poolCopies >= 0);
|
|
const base = m.gd.baseIdOf(p.id);
|
|
// a 自选 piece's copies are its player's own stock (0.2.0, server/match/player/diy.js)
|
|
const tally = ps.diyStock && ps.diyStock.has(base) ? diyHeld : held;
|
|
tally.set(base, (tally.get(base) || 0) + p.poolCopies);
|
|
} else if (p.kind === 'item') {
|
|
assert.ok(m.gd.item(p.id), `unknown item ${p.id}`);
|
|
} else if (p.kind === 'token') {
|
|
assert.ok(p.count >= 1, 'token stack count');
|
|
assert.ok(chessUids.has(p.ownerUid), `orphan token ${p.uid}`);
|
|
}
|
|
}
|
|
// merges are immediate: never 3 normal copies (风丸 2) of one chess owned at once
|
|
const counts = new Map();
|
|
for (const p of all) if (p.kind === 'chess' && !m.gd.isGolden(p.id)) { const b = m.gd.baseIdOf(p.id); counts.set(b, (counts.get(b) || 0) + 1); }
|
|
for (const [b, n] of counts) {
|
|
const need = m.gd.mergeCount(b);
|
|
if (need > 1 && m.gd.goldenIdOf(b)) assert.ok(n < need, `${ps.playerId} owns ${n} copies of ${b} (merge ${need})`);
|
|
}
|
|
for (const [base, e] of ps.diyStock ? ps.diyStock.entries : []) {
|
|
assert.equal(e.left + (diyHeld.get(base) || 0), e.cap, `${ps.playerId} 自选 stock ${base}: left ${e.left} + held ${diyHeld.get(base) || 0} != cap ${e.cap}`);
|
|
}
|
|
}
|
|
for (const [base, e] of pool.entries) {
|
|
assert.ok(e.left >= 0 && e.left <= e.cap, `pool ${base} left ${e.left} cap ${e.cap}`);
|
|
assert.equal(e.left + (held.get(base) || 0), e.cap, `pool accounting ${base}: left ${e.left} + held ${held.get(base) || 0} != cap ${e.cap}`);
|
|
}
|
|
for (const [base, n] of held) if (!pool.has(base)) assert.equal(n, 0, `non-pool chess ${base} holds copies`);
|
|
return true;
|
|
}
|
|
|
|
/** Put a chess piece straight into a player's hand/board for scenario setup (takes pool copies like a buy). */
|
|
export function give(m, ps, chessId, where = 'hand', at = null) {
|
|
const rec = m.gd.chess(chessId);
|
|
assert.ok(rec, `unknown chess ${chessId}`);
|
|
const base = m.gd.baseIdOf(chessId);
|
|
const taken = m.pool.take(base, rec.isGolden ? m.gd.goldenCopies : 1);
|
|
const piece = ps.newPiece('chess', chessId, { poolCopies: taken });
|
|
if (where === 'board') {
|
|
ps.board.set(tileKey(at[0], at[1]), piece);
|
|
ps.grantTokensFor(piece);
|
|
} else if (where === 'temp') {
|
|
const i = at ?? ps.temp.findIndex((x) => x == null);
|
|
ps.temp[i] = piece;
|
|
} else {
|
|
const i = at ?? ps.hand.findIndex((x) => x == null);
|
|
ps.hand[i] = piece;
|
|
}
|
|
ps.recompute();
|
|
return piece;
|
|
}
|
|
|
|
export function giveItem(m, ps, itemId, where = 'hand', at = null) {
|
|
assert.ok(m.gd.item(itemId), `unknown item ${itemId}`);
|
|
const piece = ps.newPiece('item', itemId);
|
|
if (where === 'temp') ps.temp[at ?? ps.temp.findIndex((x) => x == null)] = piece;
|
|
else ps.hand[at ?? ps.hand.findIndex((x) => x == null)] = piece;
|
|
ps.recompute();
|
|
return piece;
|
|
}
|
|
|
|
/** Visible chess ids of a tier (sorted), optionally filtered. */
|
|
export function chessOfTier(tier, filter = () => true) {
|
|
return Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === tier && filter(c)).map((c) => c.chessId).sort();
|
|
}
|
|
|
|
/** First legal board tile for a chess (reading order). */
|
|
export function legalTileFor(m, ps, chessId, skip = new Set()) {
|
|
const pos = placeClass(ps, m.gd.chess(chessId));
|
|
const map = ps.deployMap();
|
|
for (let r = FIELD.r1; r >= FIELD.r0; r--) for (let c = FIELD.c0; c <= FIELD.c1; c++) {
|
|
if (skip.has(tileKey(r, c)) || ps.board.has(tileKey(r, c))) continue;
|
|
if (canPlace(map, pos, r, c)) return [r, c];
|
|
}
|
|
return null;
|
|
}
|