Play liveChoose your opponent
Join
games
What shoal-games changes in this game
Branched from main at 1b3bc26 by @shoal shoal · 1 commit ahead, 0 behind main
Continue
Contribute back
223 lines · 11.1 KB Raw Permalink History
1// A live room (docs/games.md §8.3): one five-player identity game that people and agents sit in while it runs.
2// Lines starting "@shoal " on stdout are the protocol, everything else is log; stdin brings one JSON per line.
3// out {"event": {"type": "start", "seats": [...], "view": {...}, "seq": n}} public: the table as a spectator sees it
4// {"event": {"type": "events", "events": [...]}} public: the upstream engine's view events since the last ones
5// {"event": {"type": "moved" | "control", "seat": "2", ...}} public: a seat answered its ask, changed hands
6// {"to": "2", "obs": {"view": {...}, "seq": n}} private, only to a person: the seat's own view when they take
7// {"to": "2", "obs": {"events": [...]}} it, then its view events, so their viewer can draw their hand
8// {"to": "2", "version": 7, "obs": {...}, "legal": [...]} seat 2 must move: the MCP's snapshot of its view and
9// its moves (HeadlessGameClient.getAvailableActions); obs.limit is the
10// seconds it has, obs.refused the moves the engine already refused here.
11// A broadcast ask (无懈可击) asks every seat that holds an answer at once.
12// {"end": {"result": ..., "winner": "3", "scores": {"0": 1, ...}, "identities": {...}}}
13// in {"act": {"seat": "2", "version": 7, "action": 3 | <an entry of legal> | {skillId, actionType, params}}}
14// {"control": {"seat": "2", "kind": "human" | "agent" | "auto" | "bot", "name": ..., "dir" | "field": ...}}
15// {"stop": true} (stdin closing stops the room too)
16// SS_ROOM: {"seats": {"0": {"kind": "human", "name": "ada"}, "1": {"kind": "bot", "dir": "routes/x"},
17// "2": {"kind": "bot", "field": "upstream"}, ...}, "seed": 7, "limit": 30}. Seats left out are auto.
18// A move with an old version is dropped; a refused one is asked again with a new version, and after three refusals
19// auto plays the seat's move. A human or agent seat that
20// does not move within the limit is played by auto (the seat-local strategic player), and so is a bot that gives no move.
21// At the end the game is in replays/room.json (the upstream replay format) and replays/matches.json.
22import { createInterface } from 'node:readline';
23import path from 'node:path';
24import { eventsForViewer } from '../src/engine/view/events-for-viewer';
25import { Table, Heuristic, type Ask } from './table';
26import { Bot } from './bots';
27import { ROOT, Replays, answer, gone, pass } from './play';
28
29type Kind = 'human' | 'agent' | 'bot' | 'auto';
30interface Seat {
31 kind: Kind;
32 name?: string;
33 route?: string;
34 dir?: string;
35 field?: string;
36 bot?: Bot;
37 auto: Heuristic;
38 out?: { version: number; key: string; refused: unknown[]; timer: NodeJS.Timeout };
39 seen: number;
40}
41type Input = { act?: { seat: string; version: number; action: unknown }; control?: { seat: string; kind: Kind; name?: string; dir?: string; field?: string }; stop?: boolean; timeout?: { seat: number; version: number } };
42
43const say = (m: object) => process.stdout.write(`@shoal ${JSON.stringify(m)}\n`);
44const log = (s: string) => process.stdout.write(`${s}\n`);
45
46/** stdin lines and timers, one at a time. */
47class Inbox {
48 private items: Input[] = [];
49 private wake: (() => void) | null = null;
50 closed = false;
51 shift(): Input | undefined { return this.items.shift(); }
52 push(i: Input): void {
53 this.items.push(i);
54 this.wake?.();
55 }
56 async next(): Promise<Input | null> {
57 while (!this.items.length) {
58 if (this.closed) return null;
59 await new Promise<void>((r) => (this.wake = r));
60 this.wake = null;
61 }
62 return this.items.shift()!;
63 }
64}
65
66export async function room(): Promise<void> {
67 const cfg = JSON.parse(process.env.SS_ROOM || '{}') as { seats?: Record<string, Partial<Seat>>; seed?: number; limit?: number };
68 const seed = cfg.seed ?? Math.floor(Math.random() * 1e9);
69 const limitMs = (cfg.limit ?? 30) * 1000;
70 const seats: Seat[] = [0, 1, 2, 3, 4].map((i) => ({ kind: 'auto' as Kind, ...cfg.seats?.[String(i)], auto: new Heuristic(), seen: 0 }));
71 for (const s of seats) startBot(s);
72 await Promise.all(seats.map((s) => s.bot?.ready));
73 const names = seats.map((s) => s.name ?? s.route ?? s.field ?? s.kind);
74 const inbox = new Inbox();
75 const rl = createInterface({ input: process.stdin });
76 rl.on('line', (line) => {
77 try {
78 inbox.push(JSON.parse(line));
79 } catch {
80 log(`not JSON: ${line.slice(0, 80)}`);
81 }
82 });
83 rl.on('close', () => {
84 inbox.closed = true;
85 inbox.push({});
86 });
87
88 const t = new Table(seed);
89 await t.open(names);
90 // The seed reconstructs every hidden identity/hand. Publish it only with the finished result.
91 say({ event: { type: 'start', seats: seats.map((s, i) => ({ seat: String(i), kind: s.kind, name: names[i] })), view: t.publicView(), seq: t.state.seq } });
92 let publicSeq = t.state.seq;
93 let version = 0;
94 for (const [i, s] of seats.entries()) if (s.kind === 'human') baseline(i);
95
96 function startBot(s: Seat): void {
97 if (s.kind !== 'bot') return;
98 try {
99 s.bot = new Bot(s.field ? path.join(ROOT, 'field', s.field) : path.resolve(process.env.SS_INPUT ?? '.', s.dir ?? s.route ?? 'routes'));
100 } catch (e) {
101 log(`seat ${s.name}: ${(e as Error).message}; auto plays it`);
102 s.kind = 'auto';
103 }
104 }
105 /** A person's viewer needs the seat's whole view once, then its events. */
106 function baseline(i: number): void {
107 say({ to: String(i), obs: { view: t.view(i), seq: t.state.seq } });
108 seats[i].seen = t.state.seq;
109 }
110 function publish(): void {
111 const pub = t.blind.events(t.state, -1, eventsForViewer(t.state, -1, publicSeq));
112 if (pub.length) say({ event: { type: 'events', events: pub } });
113 publicSeq = t.state.seq;
114 for (const [i, s] of seats.entries()) {
115 if (s.kind !== 'human') continue;
116 const mine = t.blind.events(t.state, i, eventsForViewer(t.state, i, s.seen));
117 if (mine.length) say({ to: String(i), obs: { events: mine } });
118 s.seen = t.state.seq;
119 }
120 }
121 function drop(s: Seat): void {
122 if (s.out) clearTimeout(s.out.timer);
123 s.out = undefined;
124 }
125 function request(a: Ask, refused: unknown[] = []): void {
126 const s = seats[a.seat];
127 const v = ++version;
128 const timer = setTimeout(() => inbox.push({ timeout: { seat: a.seat, version: v } }), limitMs);
129 s.out = { version: v, key: a.key, refused, timer };
130 say({ to: String(a.seat), version: v, obs: { ...a.obs, limit: limitMs / 1000, ...(refused.length ? { refused } : {}) }, legal: a.legal });
131 }
132 async function auto(a: Ask): Promise<boolean> {
133 const s = seats[a.seat];
134 if (s.kind === 'bot' && s.bot && !s.bot.dead) return answer(t, a, (obs, legal) => s.bot!.ask({ obs, legal }));
135 return answer(t, a, async (obs, legal) => s.auto.act(obs as Ask['obs'], legal));
136 }
137
138 let stopped = false;
139 let stuck = false;
140 for (let moves = 0; !t.over && !stopped && moves < 3000; ) {
141 publish();
142 const asks = await t.asks();
143 publish();
144 if (t.over) break;
145 if (!asks.length) {
146 if (!(await t.idle())) stuck = true;
147 if (stuck) break;
148 continue;
149 }
150 for (const s of seats) if (s.out && !asks.some((a) => seats[a.seat] === s && a.key === s.out!.key)) drop(s);
151 // Read stop/control even in an all-computer game. Previously the room consumed stdin only
152 // while waiting for a human, so its seat handover and stop could be ignored for the whole game.
153 let msg = inbox.shift();
154 const remote = asks.filter((a) => seats[a.seat].kind === 'human' || seats[a.seat].kind === 'agent');
155 for (const a of remote) if (!seats[a.seat].out) request(a);
156 const now = asks.find((a) => seats[a.seat].kind === 'bot' || seats[a.seat].kind === 'auto');
157 // For a broadcast response window, expose the human/agent asks before auto acts. A nullifier
158 // must not disappear before the person even receives their legal responses.
159 if (!msg && now && !remote.length) {
160 if (!(await auto(now))) stuck = true;
161 if (stuck) break;
162 moves++;
163 continue;
164 }
165 msg ??= await inbox.next() ?? undefined;
166 if (!msg) break;
167 if (msg.stop || (inbox.closed && !msg.act && !msg.control)) {
168 stopped = true;
169 } else if (msg.act) {
170 const i = Number(msg.act.seat);
171 const s = seats[i];
172 const a = asks.find((x) => x.seat === i);
173 if (!s?.out || !a || s.out.version !== msg.act.version) {
174 log(`stale move for seat ${msg.act.seat}: version ${msg.act.version}, now ${s?.out?.version ?? 'none'}`);
175 continue;
176 }
177 const refused = s.out.refused;
178 const v = s.out.version;
179 drop(s);
180 if (await t.act(i, msg.act.action)) {
181 say({ event: { type: 'moved', seat: String(i), version: v, by: s.kind } });
182 moves++;
183 } else {
184 log(`seat ${i}: the engine refused ${JSON.stringify(msg.act.action).slice(0, 200)}`);
185 if (await gone(t, a)) continue;
186 if (refused.length < 2) request(a, [...refused, msg.act.action]);
187 else if (!(await answer(t, a, async (obs, legal) => s.auto.act(obs as Ask['obs'], legal)))) stuck = true; // three refusals: auto plays it
188 else say({ event: { type: 'moved', seat: String(i), version: v, by: 'auto' } });
189 }
190 } else if (msg.control) {
191 const i = Number(msg.control.seat);
192 const s = seats[i];
193 if (!s) continue;
194 s.bot?.close();
195 if (!['human', 'agent', 'bot', 'auto'].includes(msg.control.kind)) continue;
196 Object.assign(s, { kind: msg.control.kind, name: msg.control.name ?? s.name, dir: msg.control.dir, field: msg.control.field, bot: undefined });
197 startBot(s);
198 await s.bot?.ready;
199 drop(s); // the next turn of the loop asks the seat again, with a new version
200 say({ event: { type: 'control', seat: String(i), kind: s.kind, name: s.name } });
201 if (s.kind === 'human') baseline(i);
202 } else if (msg.timeout) {
203 const s = seats[msg.timeout.seat];
204 const a = asks.find((x) => x.seat === msg.timeout!.seat);
205 if (!s?.out || s.out.version !== msg.timeout.version || !a) continue;
206 log(`seat ${msg.timeout.seat}: no move in ${limitMs / 1000}s, auto plays it`);
207 drop(s);
208 say({ event: { type: 'moved', seat: String(msg.timeout.seat), version: msg.timeout.version, by: 'auto' } });
209 if (!(await answer(t, a, async (obs, legal) => s.auto.act(obs as Ask['obs'], legal))) && !(await pass(t, a))) stuck = true;
210 moves++;
211 }
212 }
213 publish();
214 for (const s of seats) drop(s), s.bot?.close();
215 const result = stopped ? 'stopped' : stuck ? 'stuck' : t.result();
216 const identities = Object.fromEntries(t.state.players.map((p, i) => [String(i), p.identity ?? '']));
217 const winner = t.over?.winner === undefined ? '无人' : String(t.over.winner);
218 say({ end: { result, winner, seed, scores: t.over ? t.scores() : Object.fromEntries(names.map((_, i) => [String(i), 0])), identities } });
219 const focus = Math.max(0, seats.findIndex((s) => s.kind === 'human' || s.kind === 'agent'));
220 new Replays().add('room.json', { ...(t.replay('Shoal room') as object), shoal: { seat: focus, names } }, `A room: ${names.join(', ')}`, names, result);
221 rl.close();
222 process.exit(0);
223}