Play liveChoose your opponent
Join
429632f
130 lines · 6.6 KB Raw Permalink History
1// The project's referee: five-player identity games (主公, 忠臣, two 反贼, 内奸) on the upstream engine.
2// check: on ten seeds the route's bot takes every seat in turn while one kind of field bot fills the other four;
3// the score is the mean over the four roles of the share of games the route's side won (each role a
4// subtask), over the whole field.
5// practice: one seed against each field bot, the route as 主公 and as a 反贼, a replay of each in replays/.
6// Same seeds and same bots, same games: the engine runs on a virtual clock and its shuffles are seeded.
7import { mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
8import path from 'node:path';
9import { Table, Heuristic, passIndex, type Ask } from './table';
10import { Bot, REFEREE } from './bots';
11
12export const ROOT = path.dirname(REFEREE);
13export const SEEDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
14const MAX_MOVES = 3000;
15const ROLES = ['主公', '忠臣', '反贼', '内奸'];
16
17/** One ask to its end: the chooser gets up to three tries (each refused move is shown back to it in obs.refused),
18 * then the seat passes. True when the engine took a move or the ask is gone anyway. */
19export async function answer(t: Table, a: Ask, choose: (obs: object, legal: Ask['legal']) => Promise<unknown>): Promise<boolean> {
20 const refused: unknown[] = [];
21 for (let k = 0; k < 3; k++) {
22 const move = await choose(refused.length ? { ...a.obs, refused } : a.obs, a.legal);
23 if (move === undefined || move === null) break;
24 if ((await t.act(a.seat, move)) || (await gone(t, a))) return true;
25 refused.push(move);
26 }
27 return pass(t, a);
28}
29
30/** A refused move can still end the ask: a failed transform (解围 as 无懈可击) clears the engine's open asks. */
31export async function gone(t: Table, a: Ask): Promise<boolean> {
32 return (await t.ask(a.seat))?.key !== a.key;
33}
34
35/** A move the engine takes for a seat that gave none: a pass, else what the upstream AI would play, else any. */
36export async function pass(t: Table, a: Ask): Promise<boolean> {
37 const tries: unknown[] = [passIndex(a.legal)];
38 const h = new Heuristic();
39 for (let k = 0; k < 4; k++) tries.push(h.act({ ...a.obs, refused: tries }, a.legal));
40 tries.push(...a.legal.keys());
41 for (const m of tries) if ((await t.act(a.seat, m)) || (await gone(t, a))) return true;
42 return false;
43}
44
45/** A whole game with every seat a bot process. */
46/** A whole game with every seat a bot process; why it stopped when it did not end. */
47async function game(t: Table, bots: Bot[]): Promise<string> {
48 for (let moves = 0; !t.over; moves++) {
49 if (moves >= MAX_MOVES) return `${MAX_MOVES} moves`;
50 const asks = await t.asks();
51 if (!asks.length) {
52 if (!(await t.idle())) return 'stuck: nobody to move';
53 continue;
54 }
55 const a = asks[0];
56 if (!(await answer(t, a, (obs, legal) => bots[a.seat].ask({ obs, legal })))) return `stuck: seat ${a.seat} has no move the engine takes (${a.obs.pending?.requestType})`;
57 }
58 return '';
59}
60
61export function routeName(): string {
62 const d = (process.env.SS_ROUTE_DIR ?? 'routes').replace(/\/+$/, '');
63 return d === 'routes' ? 'main' : d.split('/').slice(1).join('/');
64}
65
66export function field(): string[] {
67 const dir = path.join(ROOT, 'field');
68 return readdirSync(dir).filter((d) => !d.startsWith('.') && statSync(path.join(dir, d)).isDirectory()).sort();
69}
70
71/** replays/<file> per game, and replays/matches.json saying who sat where and how it ended (as kit/league.py). */
72export class Replays {
73 private index: Array<{ file: string; title: string; seats: string[]; result: string }> = [];
74 constructor(private root = 'replays') {
75 mkdirSync(root, { recursive: true });
76 }
77 add(file: string, data: unknown, title: string, seats: string[], result: string): void {
78 writeFileSync(path.join(this.root, file), JSON.stringify(data));
79 this.index.push({ file, title, seats, result });
80 writeFileSync(path.join(this.root, 'matches.json'), JSON.stringify(this.index, null, 2));
81 }
82}
83
84export function writeMetrics(score: number, subtasks: Record<string, number>, file = 'metrics.json'): void {
85 const r = (x: number) => Math.round(x * 1e6) / 1e6;
86 const out = { score: r(score), subtasks: Object.fromEntries(Object.entries(subtasks).map(([k, v]) => [k, r(v)])) };
87 writeFileSync(file, JSON.stringify(out, null, 2));
88 console.log(`score ${out.score} over ${Object.keys(subtasks).length}: ${Object.entries(out.subtasks).map(([k, v]) => `${k} ${v}`).join(', ')}`);
89}
90
91export async function play(mode: string, route: string): Promise<void> {
92 if (mode !== 'check' && mode !== 'practice') throw new Error('usage: node referee/play.mjs check|practice <route folder>');
93 const name = routeName();
94 const me = new Bot(route);
95 const reps = mode === 'practice' ? new Replays() : null;
96 const won: Record<string, number[]> = Object.fromEntries(ROLES.map((r) => [r, []]));
97 for (const opp of field()) {
98 const others = [0, 1, 2, 3].map(() => new Bot(path.join(ROOT, 'field', opp)));
99 await Promise.all([me.ready, ...others.map((b) => b.ready)]);
100 for (const seed of mode === 'check' ? SEEDS : SEEDS.slice(0, 1)) {
101 const roles = await identities(seed);
102 const mine = mode === 'check' ? [0, 1, 2, 3, 4] : [0, roles.indexOf('反贼')];
103 for (const seat of mine) {
104 const bots = [...others.slice(0, seat), me, ...others.slice(seat)];
105 const names = bots.map((b) => (b === me ? name : opp));
106 const t = new Table(seed);
107 await t.open(names);
108 const why = await game(t, bots);
109 const role = t.identity(seat);
110 const win = t.scores()[String(seat)] > 0;
111 won[role].push(win ? 1 : 0);
112 console.log(`seed ${seed}, ${name} as ${role} (seat ${seat}) vs ${opp}: ${t.result()}${why ? ` (${why})` : ''}`);
113 reps?.add(`${opp}-${seed}-${seat}.json`, { ...(t.replay(`${name} vs ${opp}`) as object), shoal: { seat, names } },
114 `${name} as ${role}, four ${opp} bots, seed ${seed}`, names, `${t.result()}: ${name} ${win ? 'won' : 'lost'}`);
115 }
116 }
117 others.forEach((b) => b.close());
118 }
119 me.close();
120 const rate = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
121 const subtasks = Object.fromEntries(ROLES.filter((r) => won[r].length).map((r) => [r, rate(won[r])]));
122 if (mode === 'check') writeMetrics(rate(Object.values(subtasks)), subtasks);
123}
124
125/** Who holds which identity on a seed (the lord is always seat 0; the rest follow the seeded shuffle). */
126async function identities(seed: number): Promise<string[]> {
127 const t = new Table(seed);
128 await t.open(['', '', '', '', '']);
129 return t.state.players.map((p) => p.identity ?? '');
130}