实时对局选你的对手
加入
32fe633
215 行 · 10.3 KB 原始文件 永久链接 历史
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 upstream heuristic AI), 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 { buildView } from '../src/engine/index';
26import { Table, Heuristic, type Ask } from './table';
27import { Bot } from './bots';
28import { ROOT, Replays, answer, gone, pass } from './play';
29
30type Kind = 'human' | 'agent' | 'bot' | 'auto';
31interface Seat {
32 kind: Kind;
33 name?: string;
34 route?: string;
35 dir?: string;
36 field?: string;
37 bot?: Bot;
38 auto: Heuristic;
39 out?: { version: number; key: string; refused: unknown[]; timer: NodeJS.Timeout };
40 seen: number;
41}
42type 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 } };
43
44const say = (m: object) => process.stdout.write(`@shoal ${JSON.stringify(m)}\n`);
45const log = (s: string) => process.stdout.write(`${s}\n`);
46
47/** stdin lines and timers, one at a time. */
48class Inbox {
49 private items: Input[] = [];
50 private wake: (() => void) | null = null;
51 closed = false;
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 say({ event: { type: 'start', seed, seats: seats.map((s, i) => ({ seat: String(i), kind: s.kind, name: names[i] })), view: t.publicView(), seq: t.state.seq } });
91 let publicSeq = t.state.seq;
92 let version = 0;
93 for (const [i, s] of seats.entries()) if (s.kind === 'human') baseline(i);
94
95 function startBot(s: Seat): void {
96 if (s.kind !== 'bot') return;
97 try {
98 s.bot = new Bot(s.field ? path.join(ROOT, 'field', s.field) : path.resolve(process.env.SS_INPUT ?? '.', s.dir ?? s.route ?? 'routes'));
99 } catch (e) {
100 log(`seat ${s.name}: ${(e as Error).message}; auto plays it`);
101 s.kind = 'auto';
102 }
103 }
104 /** A person's viewer needs the seat's whole view once, then its events. */
105 function baseline(i: number): void {
106 say({ to: String(i), obs: { view: buildView(t.state, i), seq: t.state.seq } });
107 seats[i].seen = t.state.seq;
108 }
109 function publish(): void {
110 const pub = eventsForViewer(t.state, -1, publicSeq);
111 if (pub.length) say({ event: { type: 'events', events: pub } });
112 publicSeq = t.state.seq;
113 for (const [i, s] of seats.entries()) {
114 if (s.kind !== 'human') continue;
115 const mine = eventsForViewer(t.state, i, s.seen);
116 if (mine.length) say({ to: String(i), obs: { events: mine } });
117 s.seen = t.state.seq;
118 }
119 }
120 function drop(s: Seat): void {
121 if (s.out) clearTimeout(s.out.timer);
122 s.out = undefined;
123 }
124 function request(a: Ask, refused: unknown[] = []): void {
125 const s = seats[a.seat];
126 const v = ++version;
127 const timer = setTimeout(() => inbox.push({ timeout: { seat: a.seat, version: v } }), limitMs);
128 s.out = { version: v, key: a.key, refused, timer };
129 say({ to: String(a.seat), version: v, obs: { ...a.obs, limit: limitMs / 1000, ...(refused.length ? { refused } : {}) }, legal: a.legal });
130 }
131 async function auto(a: Ask): Promise<boolean> {
132 const s = seats[a.seat];
133 if (s.kind === 'bot' && s.bot && !s.bot.dead) return answer(t, a, (obs, legal) => s.bot!.ask({ obs, legal }));
134 return answer(t, a, async (obs, legal) => s.auto.act(obs as Ask['obs'], legal));
135 }
136
137 let stopped = false;
138 let stuck = false;
139 for (let moves = 0; !t.over && !stopped && moves < 3000; ) {
140 publish();
141 const asks = await t.asks();
142 publish();
143 if (t.over) break;
144 if (!asks.length) {
145 if (!(await t.idle())) stuck = true;
146 if (stuck) break;
147 continue;
148 }
149 for (const s of seats) if (s.out && !asks.some((a) => seats[a.seat] === s && a.key === s.out!.key)) drop(s);
150 const now = asks.find((a) => seats[a.seat].kind === 'bot' || seats[a.seat].kind === 'auto');
151 if (now) {
152 if (!(await auto(now))) stuck = true;
153 if (stuck) break;
154 moves++;
155 continue;
156 }
157 for (const a of asks) if (!seats[a.seat].out) request(a);
158 const msg = await inbox.next();
159 if (!msg) break;
160 if (msg.stop || (inbox.closed && !msg.act && !msg.control)) {
161 stopped = true;
162 } else if (msg.act) {
163 const i = Number(msg.act.seat);
164 const s = seats[i];
165 const a = asks.find((x) => x.seat === i);
166 if (!s?.out || !a || s.out.version !== msg.act.version) {
167 log(`stale move for seat ${msg.act.seat}: version ${msg.act.version}, now ${s?.out?.version ?? 'none'}`);
168 continue;
169 }
170 const refused = s.out.refused;
171 const v = s.out.version;
172 drop(s);
173 if (await t.act(i, msg.act.action)) {
174 say({ event: { type: 'moved', seat: String(i), version: v, by: s.kind } });
175 moves++;
176 } else {
177 log(`seat ${i}: the engine refused ${JSON.stringify(msg.act.action).slice(0, 200)}`);
178 if (await gone(t, a)) continue;
179 if (refused.length < 2) request(a, [...refused, msg.act.action]);
180 else if (!(await answer(t, a, async (obs, legal) => s.auto.act(obs as Ask['obs'], legal)))) stuck = true; // three refusals: auto plays it
181 else say({ event: { type: 'moved', seat: String(i), version: v, by: 'auto' } });
182 }
183 } else if (msg.control) {
184 const i = Number(msg.control.seat);
185 const s = seats[i];
186 if (!s) continue;
187 s.bot?.close();
188 Object.assign(s, { kind: msg.control.kind, name: msg.control.name ?? s.name, dir: msg.control.dir, field: msg.control.field, bot: undefined });
189 startBot(s);
190 await s.bot?.ready;
191 drop(s); // the next turn of the loop asks the seat again, with a new version
192 say({ event: { type: 'control', seat: String(i), kind: s.kind, name: s.name } });
193 if (s.kind === 'human') baseline(i);
194 } else if (msg.timeout) {
195 const s = seats[msg.timeout.seat];
196 const a = asks.find((x) => x.seat === msg.timeout!.seat);
197 if (!s?.out || s.out.version !== msg.timeout.version || !a) continue;
198 log(`seat ${msg.timeout.seat}: no move in ${limitMs / 1000}s, auto plays it`);
199 drop(s);
200 say({ event: { type: 'moved', seat: String(msg.timeout.seat), version: msg.timeout.version, by: 'auto' } });
201 if (!(await answer(t, a, async (obs, legal) => s.auto.act(obs as Ask['obs'], legal))) && !(await pass(t, a))) stuck = true;
202 moves++;
203 }
204 }
205 publish();
206 for (const s of seats) drop(s), s.bot?.close();
207 const result = stopped ? 'stopped' : stuck ? 'stuck' : t.result();
208 const identities = Object.fromEntries(t.state.players.map((p, i) => [String(i), p.identity ?? '']));
209 const winner = t.over?.winner === undefined ? '无人' : String(t.over.winner);
210 say({ end: { result, winner, scores: t.over ? t.scores() : Object.fromEntries(names.map((_, i) => [String(i), 0])), identities } });
211 const focus = Math.max(0, seats.findIndex((s) => s.kind === 'human' || s.kind === 'agent'));
212 new Replays().add('room.json', { ...(t.replay('Shoal room') as object), shoal: { seat: focus, names } }, `A room: ${names.join(', ')}`, names, result);
213 rl.close();
214 process.exit(0);
215}