// Meaningful regression and live-engine checks. No service, account or project data is touched. // node games/sanguosha/src/audit.mjs [--compare] import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { createInterface } from 'node:readline'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Table, Heuristic, UpstreamHeuristic } from '../referee/sgs.mjs'; const GAME = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const player = (index, identity, health = 3) => ({ index, name: `P${index}`, character: '', identity, health, maxHealth: 4, alive: true, handCount: 0, hand: [], equipment: {}, skills: [], marks: [] }); const observation = (role = '忠臣') => ({ game: 'fixture', viewer: 1, currentPlayerIndex: 1, phase: '出牌', turn: { round: 1 }, players: [player(0, '主公'), player(1, role), player(2, '反贼'), player(3, undefined), player(4, undefined)], pending: { target: 1, isBlocking: true, promptTitle: '', requestType: '' }, zones: { deckCount: 100, discardPileCount: 20 }, log: [] }); const action = (skillId, actionType = 'use', params = {}, category = 'play', validTargets = []) => ({ description: skillId, message: { skillId, actionType, ownerId: 1, params }, category, validTargets }); const skip = action('__skip', 'skip', {}, 'skip'); const end = action('回合管理', 'end'); const choose = (obs, legal) => { const a = new Heuristic().act(obs, legal); return typeof a === 'number' ? legal[a]?.message : a; }; let tactical = 0; function check(name, fn) { fn(); tactical++; console.log(`PASS ${name}`); } check('忠臣救主,不救已知反贼', () => { const o = observation(); o.players[2].health = 0; const peach = action('桃', 'respond', { cardId: 'peach' }, 'respond'); assert.equal(choose(o, [peach, skip]).skillId, '__skip'); o.players[2].health = 3; o.players[0].health = 0; assert.equal(choose(o, [peach, skip]).skillId, '桃'); }); check('内奸多人残局不弑主,单挑才攻击主公', () => { const o = observation('内奸'); const slash = action('杀', 'use', {}, 'play', [0]); assert.equal(choose(o, [slash, end]).actionType, 'end'); o.players.slice(2).forEach((p) => { p.alive = false; }); assert.deepEqual(choose(o, [slash, end]).params.targets, [0]); }); check('无懈读取实际锦囊目标,并处理反无懈', () => { const o = observation(); const wuxie = action('无懈可击', 'respond', { cardId: 'wuxie' }, 'respond'); o.context = { cancelTarget: 2, frames: [{ skillId: '决斗', from: 0, params: { targets: [2] } }] }; assert.equal(choose(o, [wuxie, skip]).skillId, '__skip'); o.context.cancelTarget = 0; o.context.frames[0].params.targets = [0]; assert.equal(choose(o, [wuxie, skip]).skillId, '无懈可击'); o.context.frames.push({ skillId: '无懈可击', from: 1, params: {} }); assert.equal(choose(o, [wuxie, skip]).skillId, '__skip'); o.context.frames[0].params.targets = [2]; assert.equal(choose(o, [wuxie, skip]).skillId, '无懈可击'); }); check('无杀不空喝酒,空 targets 填满多目标下限', () => { const o = observation('反贼'); assert.equal(choose(o, [action('酒'), end]).actionType, 'end'); const two = { ...action('离间', 'use', { targets: [] }, 'play', [0, 3, 4]), minTarget: 2, maxTarget: 2 }; assert.equal(choose(o, [two, end]).params.targets.length, 2); }); check('弃牌保留桃和首张闪,盲选不读取牌面', () => { const o = observation(); o.pending.discard = { min: 2, max: 2 }; o.players[1].hand = ['桃', '闪', '闪', '闪电'].map((name, i) => ({ id: `c${i}`, name })); const discard = action('系统规则', 'respond', { cardIds: [] }, 'discard'); assert.deepEqual(new Set(choose(o, [discard]).params.cardIds), new Set(['c2', 'c3'])); o.refused = [0]; // raw template refused; a completed mandatory discard must still be tried assert.deepEqual(new Set(choose(o, [discard]).params.cardIds), new Set(['c2', 'c3'])); o.context = { prompt: { type: 'distribute', mode: 'select', cardIds: ['hidden-a', 'hidden-b'], minTotal: 2, maxTotal: 2 } }; assert.deepEqual(choose(o, [discard]).params.cardIds, ['hidden-a', 'hidden-b']); }); for (const onlyRenegade of [false, true]) { const t = new Table(3); await t.open(['a', 'b', 'c', 'd', 'e']); for (const p of t.state.players) p.alive = p.identity === '内奸' || (!onlyRenegade && p.identity === '忠臣'); await t.settle(); // real upstream victory function through the adapter assert.equal(t.identity(t.over.winner), onlyRenegade ? '内奸' : '反贼'); } console.log('PASS real engine: only the sole surviving renegade wins after lord death'); { const t = new Table(71423); await t.open(['a', 'b', 'c', 'd', 'e']); const ask = await t.ask(0); assert.ok(!ask.obs.game.includes('71423')); assert.deepEqual(t.publicView().players.filter((p) => p.identity).map((p) => p.identity), ['主公']); const slot = t.state.pendingSlots.get(0); t.state.players[1].hand = ['private-face']; t.state.cardMap['private-face'] = { id: 'private-face', name: '桃', suit: '♥', rank: 'A', type: '基本牌' }; slot.atom = { type: '请求回应', requestType: '界镇军/askCards', target: 0, prompt: { type: 'distribute', mode: 'select', cardIds: ['private-face'], minTotal: 1, maxTotal: 1 } }; const mine = t.view(0), publicView = t.publicView(), token = mine.pending.prompt.cardIds[0]; assert.ok(token.startsWith('hidden-')); assert.equal(mine.cardMap[token].name, '暗牌'); assert.ok(!JSON.stringify(publicView.pending).includes('private-face')); const incoming = action('界镇军', 'respond', { cardIds: [token] }).message; assert.deepEqual(t.blind.message(t.state, 0, incoming).params.cardIds, ['private-face']); assert.equal(t.blind.message(t.state, 0, action('界镇军', 'respond', { cardIds: ['private-face'] }).message), null); } console.log('PASS real view: hidden identities, opaque game ID, blind-card prompts and input mapping'); async function run(seed, controllers) { const t = new Table(seed); await t.open(controllers.map((c) => c.constructor.name)); let moves = 0, refusedCount = 0; for (; !t.over && moves < 3000; moves++) { const asks = await t.asks(); if (!asks.length) { assert.ok(await t.idle(), `seed ${seed}: idle deadlock`); continue; } const a = asks[0], refused = []; let accepted = false; for (let tries = 0; tries < 3 && !accepted; tries++) { const m = controllers[a.seat].act({ ...a.obs, refused }, a.legal); accepted = await t.act(a.seat, m) || (await t.ask(a.seat))?.key !== a.key; if (!accepted) { refusedCount++; refused.push(m); } } if (!accepted) { const order = [...a.legal.keys()].sort((x, y) => Number(a.legal[y].category === 'skip' || a.legal[y].message.actionType === 'end') - Number(a.legal[x].category === 'skip' || a.legal[x].message.actionType === 'end')); for (const i of order) if (accepted = await t.act(a.seat, i) || (await t.ask(a.seat))?.key !== a.key) break; } assert.ok(accepted, `seed ${seed}, seat ${a.seat}: no accepted move (${a.obs.pending?.requestType})`); } assert.ok(t.over, `seed ${seed}: exceeded 3000 asks`); return { seed, moves, refused: refusedCount, result: t.result(), roles: t.state.players.map((p) => p.identity), scores: t.scores() }; } const selfPlay = []; for (let seed = 1; seed <= 20; seed++) { const result = await run(seed, Array.from({ length: 5 }, () => new Heuristic())); selfPlay.push(result); console.log(`PASS self-play ${JSON.stringify(result)}`); } // Real room subprocess: a computer is handed to a human while the other computers are active, // followed by a stale act and stop. A temp cwd keeps replays away from user/project data. const temp = mkdtempSync(path.join(tmpdir(), 'shoal-sgs-room-audit-')); try { const child = spawn(process.execPath, [path.join(GAME, 'referee/room.mjs')], { cwd: temp, env: { ...process.env, SS_ROOM: JSON.stringify({ seed: 3, seats: { 0: { kind: 'bot', field: 'strategic' } }, limit: 2 }) }, stdio: ['pipe', 'pipe', 'pipe'] }); const messages = []; let requested = false; const timeout = setTimeout(() => child.kill(), 15000); for await (const line of createInterface({ input: child.stdout })) { if (!line.startsWith('@shoal ')) continue; const m = JSON.parse(line.slice(7)); messages.push(m); if (m.event?.type === 'start') { assert.equal(m.event.seed, undefined); child.stdin.write(`${JSON.stringify({ control: { seat: '0', kind: 'human', name: 'audit-human' } })}\n`); } if (m.to === '0' && m.version !== undefined) { requested = true; child.stdin.write(`${JSON.stringify({ act: { seat: '0', version: m.version - 1, action: 0 } })}\n${JSON.stringify({ stop: true })}\n`); } if (m.end) { assert.equal(m.end.result, 'stopped'); break; } } clearTimeout(timeout); child.stdin.end(); assert.ok(requested, 'all-computer room accepted the handover and issued a human request'); assert.ok(messages.some((m) => m.end?.result === 'stopped'), 'stop was consumed'); assert.ok(!messages.some((m) => m.event?.type === 'moved' && m.event.by === 'human'), 'stale move was not accepted'); console.log('PASS real room: field/strategic, all-computer handover, human request, stale rejection and stop'); } finally { rmSync(temp, { recursive: true, force: true }); } if (process.argv.includes('--compare')) { const newScores = {}, oldScores = {}; const push = (out, role, value) => (out[role] ??= []).push(value > 0 ? 1 : 0); for (let seed = 1; seed <= 10; seed++) { const old = await run(seed, Array.from({ length: 5 }, () => new UpstreamHeuristic())); old.roles.forEach((role, seat) => push(oldScores, role, old.scores[seat])); for (let seat = 0; seat < 5; seat++) { const result = await run(seed, Array.from({ length: 5 }, (_, i) => i === seat ? new Heuristic() : new UpstreamHeuristic())); push(newScores, result.roles[seat], result.scores[seat]); console.log(`PASS comparison seed ${seed}, seat ${seat}: ${result.result}`); } } const rates = (out) => Object.fromEntries(Object.entries(out).map(([r, xs]) => [r, xs.reduce((a, b) => a + b, 0) / xs.length])); const mean = (o) => Object.values(o).reduce((a, b) => a + b, 0) / Object.keys(o).length; const strategic = rates(newScores), upstream = rates(oldScores); console.log(`COMPARISON ${JSON.stringify({ strategic, strategicMean: mean(strategic), upstream, upstreamMean: mean(upstream), seeds: 10, strategicGames: 50, controlGames: 10 })}`); } console.log(`AUDIT PASS ${JSON.stringify({ tacticalGroups: tactical, selfPlayGames: selfPlay.length, moves: selfPlay.reduce((n, r) => n + r.moves, 0), refused: selfPlay.reduce((n, r) => n + r.refused, 0) })}`);