| 1 | |
| 2 | |
| 3 | import assert from 'node:assert/strict'; |
| 4 | import { spawn } from 'node:child_process'; |
| 5 | import { createInterface } from 'node:readline'; |
| 6 | import { mkdtempSync, rmSync } from 'node:fs'; |
| 7 | import { tmpdir } from 'node:os'; |
| 8 | import path from 'node:path'; |
| 9 | import { fileURLToPath } from 'node:url'; |
| 10 | import { Table, Heuristic, UpstreamHeuristic } from '../referee/sgs.mjs'; |
| 11 | |
| 12 | const GAME = path.dirname(path.dirname(fileURLToPath(import.meta.url))); |
| 13 | const player = (index, identity, health = 3) => ({ index, name: `P${index}`, character: '', identity, health, maxHealth: 4, alive: true, handCount: 0, hand: [], equipment: {}, skills: [], marks: [] }); |
| 14 | 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: [] }); |
| 15 | const action = (skillId, actionType = 'use', params = {}, category = 'play', validTargets = []) => ({ description: skillId, message: { skillId, actionType, ownerId: 1, params }, category, validTargets }); |
| 16 | const skip = action('__skip', 'skip', {}, 'skip'); |
| 17 | const end = action('回合管理', 'end'); |
| 18 | const choose = (obs, legal) => { const a = new Heuristic().act(obs, legal); return typeof a === 'number' ? legal[a]?.message : a; }; |
| 19 | let tactical = 0; |
| 20 | function check(name, fn) { fn(); tactical++; console.log(`PASS ${name}`); } |
| 21 | |
| 22 | check('忠臣救主,不救已知反贼', () => { |
| 23 | const o = observation(); o.players[2].health = 0; |
| 24 | const peach = action('桃', 'respond', { cardId: 'peach' }, 'respond'); |
| 25 | assert.equal(choose(o, [peach, skip]).skillId, '__skip'); |
| 26 | o.players[2].health = 3; o.players[0].health = 0; |
| 27 | assert.equal(choose(o, [peach, skip]).skillId, '桃'); |
| 28 | }); |
| 29 | check('内奸多人残局不弑主,单挑才攻击主公', () => { |
| 30 | const o = observation('内奸'); const slash = action('杀', 'use', {}, 'play', [0]); |
| 31 | assert.equal(choose(o, [slash, end]).actionType, 'end'); |
| 32 | o.players.slice(2).forEach((p) => { p.alive = false; }); |
| 33 | assert.deepEqual(choose(o, [slash, end]).params.targets, [0]); |
| 34 | }); |
| 35 | check('无懈读取实际锦囊目标,并处理反无懈', () => { |
| 36 | const o = observation(); const wuxie = action('无懈可击', 'respond', { cardId: 'wuxie' }, 'respond'); |
| 37 | o.context = { cancelTarget: 2, frames: [{ skillId: '决斗', from: 0, params: { targets: [2] } }] }; |
| 38 | assert.equal(choose(o, [wuxie, skip]).skillId, '__skip'); |
| 39 | o.context.cancelTarget = 0; o.context.frames[0].params.targets = [0]; |
| 40 | assert.equal(choose(o, [wuxie, skip]).skillId, '无懈可击'); |
| 41 | o.context.frames.push({ skillId: '无懈可击', from: 1, params: {} }); |
| 42 | assert.equal(choose(o, [wuxie, skip]).skillId, '__skip'); |
| 43 | o.context.frames[0].params.targets = [2]; |
| 44 | assert.equal(choose(o, [wuxie, skip]).skillId, '无懈可击'); |
| 45 | }); |
| 46 | check('无杀不空喝酒,空 targets 填满多目标下限', () => { |
| 47 | const o = observation('反贼'); |
| 48 | assert.equal(choose(o, [action('酒'), end]).actionType, 'end'); |
| 49 | const two = { ...action('离间', 'use', { targets: [] }, 'play', [0, 3, 4]), minTarget: 2, maxTarget: 2 }; |
| 50 | assert.equal(choose(o, [two, end]).params.targets.length, 2); |
| 51 | }); |
| 52 | check('弃牌保留桃和首张闪,盲选不读取牌面', () => { |
| 53 | const o = observation(); o.pending.discard = { min: 2, max: 2 }; |
| 54 | o.players[1].hand = ['桃', '闪', '闪', '闪电'].map((name, i) => ({ id: `c${i}`, name })); |
| 55 | const discard = action('系统规则', 'respond', { cardIds: [] }, 'discard'); |
| 56 | assert.deepEqual(new Set(choose(o, [discard]).params.cardIds), new Set(['c2', 'c3'])); |
| 57 | o.refused = [0]; |
| 58 | assert.deepEqual(new Set(choose(o, [discard]).params.cardIds), new Set(['c2', 'c3'])); |
| 59 | o.context = { prompt: { type: 'distribute', mode: 'select', cardIds: ['hidden-a', 'hidden-b'], minTotal: 2, maxTotal: 2 } }; |
| 60 | assert.deepEqual(choose(o, [discard]).params.cardIds, ['hidden-a', 'hidden-b']); |
| 61 | }); |
| 62 | |
| 63 | for (const onlyRenegade of [false, true]) { |
| 64 | const t = new Table(3); await t.open(['a', 'b', 'c', 'd', 'e']); |
| 65 | for (const p of t.state.players) p.alive = p.identity === '内奸' || (!onlyRenegade && p.identity === '忠臣'); |
| 66 | await t.settle(); |
| 67 | assert.equal(t.identity(t.over.winner), onlyRenegade ? '内奸' : '反贼'); |
| 68 | } |
| 69 | console.log('PASS real engine: only the sole surviving renegade wins after lord death'); |
| 70 | |
| 71 | { |
| 72 | const t = new Table(71423); await t.open(['a', 'b', 'c', 'd', 'e']); |
| 73 | const ask = await t.ask(0); assert.ok(!ask.obs.game.includes('71423')); |
| 74 | assert.deepEqual(t.publicView().players.filter((p) => p.identity).map((p) => p.identity), ['主公']); |
| 75 | const slot = t.state.pendingSlots.get(0); |
| 76 | t.state.players[1].hand = ['private-face']; |
| 77 | t.state.cardMap['private-face'] = { id: 'private-face', name: '桃', suit: '♥', rank: 'A', type: '基本牌' }; |
| 78 | slot.atom = { type: '请求回应', requestType: '界镇军/askCards', target: 0, prompt: { type: 'distribute', mode: 'select', cardIds: ['private-face'], minTotal: 1, maxTotal: 1 } }; |
| 79 | const mine = t.view(0), publicView = t.publicView(), token = mine.pending.prompt.cardIds[0]; |
| 80 | assert.ok(token.startsWith('hidden-')); |
| 81 | assert.equal(mine.cardMap[token].name, '暗牌'); |
| 82 | assert.ok(!JSON.stringify(publicView.pending).includes('private-face')); |
| 83 | const incoming = action('界镇军', 'respond', { cardIds: [token] }).message; |
| 84 | assert.deepEqual(t.blind.message(t.state, 0, incoming).params.cardIds, ['private-face']); |
| 85 | assert.equal(t.blind.message(t.state, 0, action('界镇军', 'respond', { cardIds: ['private-face'] }).message), null); |
| 86 | } |
| 87 | console.log('PASS real view: hidden identities, opaque game ID, blind-card prompts and input mapping'); |
| 88 | |
| 89 | async function run(seed, controllers) { |
| 90 | const t = new Table(seed); await t.open(controllers.map((c) => c.constructor.name)); |
| 91 | let moves = 0, refusedCount = 0; |
| 92 | for (; !t.over && moves < 3000; moves++) { |
| 93 | const asks = await t.asks(); |
| 94 | if (!asks.length) { assert.ok(await t.idle(), `seed ${seed}: idle deadlock`); continue; } |
| 95 | const a = asks[0], refused = []; let accepted = false; |
| 96 | for (let tries = 0; tries < 3 && !accepted; tries++) { |
| 97 | const m = controllers[a.seat].act({ ...a.obs, refused }, a.legal); |
| 98 | accepted = await t.act(a.seat, m) || (await t.ask(a.seat))?.key !== a.key; |
| 99 | if (!accepted) { refusedCount++; refused.push(m); } |
| 100 | } |
| 101 | if (!accepted) { |
| 102 | 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')); |
| 103 | for (const i of order) if (accepted = await t.act(a.seat, i) || (await t.ask(a.seat))?.key !== a.key) break; |
| 104 | } |
| 105 | assert.ok(accepted, `seed ${seed}, seat ${a.seat}: no accepted move (${a.obs.pending?.requestType})`); |
| 106 | } |
| 107 | assert.ok(t.over, `seed ${seed}: exceeded 3000 asks`); |
| 108 | return { seed, moves, refused: refusedCount, result: t.result(), roles: t.state.players.map((p) => p.identity), scores: t.scores() }; |
| 109 | } |
| 110 | |
| 111 | const selfPlay = []; |
| 112 | for (let seed = 1; seed <= 20; seed++) { |
| 113 | const result = await run(seed, Array.from({ length: 5 }, () => new Heuristic())); |
| 114 | selfPlay.push(result); console.log(`PASS self-play ${JSON.stringify(result)}`); |
| 115 | } |
| 116 | |
| 117 | |
| 118 | |
| 119 | const temp = mkdtempSync(path.join(tmpdir(), 'shoal-sgs-room-audit-')); |
| 120 | try { |
| 121 | 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'] }); |
| 122 | const messages = []; let requested = false; |
| 123 | const timeout = setTimeout(() => child.kill(), 15000); |
| 124 | for await (const line of createInterface({ input: child.stdout })) { |
| 125 | if (!line.startsWith('@shoal ')) continue; |
| 126 | const m = JSON.parse(line.slice(7)); messages.push(m); |
| 127 | if (m.event?.type === 'start') { |
| 128 | assert.equal(m.event.seed, undefined); |
| 129 | child.stdin.write(`${JSON.stringify({ control: { seat: '0', kind: 'human', name: 'audit-human' } })}\n`); |
| 130 | } |
| 131 | if (m.to === '0' && m.version !== undefined) { |
| 132 | requested = true; |
| 133 | child.stdin.write(`${JSON.stringify({ act: { seat: '0', version: m.version - 1, action: 0 } })}\n${JSON.stringify({ stop: true })}\n`); |
| 134 | } |
| 135 | if (m.end) { assert.equal(m.end.result, 'stopped'); break; } |
| 136 | } |
| 137 | clearTimeout(timeout); child.stdin.end(); |
| 138 | assert.ok(requested, 'all-computer room accepted the handover and issued a human request'); |
| 139 | assert.ok(messages.some((m) => m.end?.result === 'stopped'), 'stop was consumed'); |
| 140 | assert.ok(!messages.some((m) => m.event?.type === 'moved' && m.event.by === 'human'), 'stale move was not accepted'); |
| 141 | console.log('PASS real room: field/strategic, all-computer handover, human request, stale rejection and stop'); |
| 142 | } finally { rmSync(temp, { recursive: true, force: true }); } |
| 143 | |
| 144 | if (process.argv.includes('--compare')) { |
| 145 | const newScores = {}, oldScores = {}; |
| 146 | const push = (out, role, value) => (out[role] ??= []).push(value > 0 ? 1 : 0); |
| 147 | for (let seed = 1; seed <= 10; seed++) { |
| 148 | const old = await run(seed, Array.from({ length: 5 }, () => new UpstreamHeuristic())); |
| 149 | old.roles.forEach((role, seat) => push(oldScores, role, old.scores[seat])); |
| 150 | for (let seat = 0; seat < 5; seat++) { |
| 151 | const result = await run(seed, Array.from({ length: 5 }, (_, i) => i === seat ? new Heuristic() : new UpstreamHeuristic())); |
| 152 | push(newScores, result.roles[seat], result.scores[seat]); |
| 153 | console.log(`PASS comparison seed ${seed}, seat ${seat}: ${result.result}`); |
| 154 | } |
| 155 | } |
| 156 | const rates = (out) => Object.fromEntries(Object.entries(out).map(([r, xs]) => [r, xs.reduce((a, b) => a + b, 0) / xs.length])); |
| 157 | const mean = (o) => Object.values(o).reduce((a, b) => a + b, 0) / Object.keys(o).length; |
| 158 | const strategic = rates(newScores), upstream = rates(oldScores); |
| 159 | console.log(`COMPARISON ${JSON.stringify({ strategic, strategicMean: mean(strategic), upstream, upstreamMean: mean(upstream), seeds: 10, strategicGames: 50, controlGames: 10 })}`); |
| 160 | } |
| 161 | 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) })}`); |