实时对局选你的对手
加入
games
What shoal-games changes in this game
由 @shoal shoal 从 main 的 1b3bc26 分出 · 领先 1 个提交,比 main 落后 0 个
接续
贡献回去
161 行 · 10.6 KB 原始文件 永久链接 历史
1// Meaningful regression and live-engine checks. No service, account or project data is touched.
2// node games/sanguosha/src/audit.mjs [--compare]
3import assert from 'node:assert/strict';
4import { spawn } from 'node:child_process';
5import { createInterface } from 'node:readline';
6import { mkdtempSync, rmSync } from 'node:fs';
7import { tmpdir } from 'node:os';
8import path from 'node:path';
9import { fileURLToPath } from 'node:url';
10import { Table, Heuristic, UpstreamHeuristic } from '../referee/sgs.mjs';
11
12const GAME = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
13const player = (index, identity, health = 3) => ({ index, name: `P${index}`, character: '', identity, health, maxHealth: 4, alive: true, handCount: 0, hand: [], equipment: {}, skills: [], marks: [] });
14const 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: [] });
15const action = (skillId, actionType = 'use', params = {}, category = 'play', validTargets = []) => ({ description: skillId, message: { skillId, actionType, ownerId: 1, params }, category, validTargets });
16const skip = action('__skip', 'skip', {}, 'skip');
17const end = action('回合管理', 'end');
18const choose = (obs, legal) => { const a = new Heuristic().act(obs, legal); return typeof a === 'number' ? legal[a]?.message : a; };
19let tactical = 0;
20function check(name, fn) { fn(); tactical++; console.log(`PASS ${name}`); }
21
22check('忠臣救主,不救已知反贼', () => {
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});
29check('内奸多人残局不弑主,单挑才攻击主公', () => {
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});
35check('无懈读取实际锦囊目标,并处理反无懈', () => {
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});
46check('无杀不空喝酒,空 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});
52check('弃牌保留桃和首张闪,盲选不读取牌面', () => {
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]; // raw template refused; a completed mandatory discard must still be tried
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
63for (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(); // real upstream victory function through the adapter
67 assert.equal(t.identity(t.over.winner), onlyRenegade ? '内奸' : '反贼');
68}
69console.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}
87console.log('PASS real view: hidden identities, opaque game ID, blind-card prompts and input mapping');
88
89async 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
111const selfPlay = [];
112for (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// Real room subprocess: a computer is handed to a human while the other computers are active,
118// followed by a stale act and stop. A temp cwd keeps replays away from user/project data.
119const temp = mkdtempSync(path.join(tmpdir(), 'shoal-sgs-room-audit-'));
120try {
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
144if (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}
161console.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) })}`);