// A live room (docs/games.md §8.3): one five-player identity game that people and agents sit in while it runs. // Lines starting "@shoal " on stdout are the protocol, everything else is log; stdin brings one JSON per line. // out {"event": {"type": "start", "seats": [...], "view": {...}, "seq": n}} public: the table as a spectator sees it // {"event": {"type": "events", "events": [...]}} public: the upstream engine's view events since the last ones // {"event": {"type": "moved" | "control", "seat": "2", ...}} public: a seat answered its ask, changed hands // {"to": "2", "obs": {"view": {...}, "seq": n}} private, only to a person: the seat's own view when they take // {"to": "2", "obs": {"events": [...]}} it, then its view events, so their viewer can draw their hand // {"to": "2", "version": 7, "obs": {...}, "legal": [...]} seat 2 must move: the MCP's snapshot of its view and // its moves (HeadlessGameClient.getAvailableActions); obs.limit is the // seconds it has, obs.refused the moves the engine already refused here. // A broadcast ask (无懈可击) asks every seat that holds an answer at once. // {"end": {"result": ..., "winner": "3", "scores": {"0": 1, ...}, "identities": {...}}} // in {"act": {"seat": "2", "version": 7, "action": 3 | | {skillId, actionType, params}}} // {"control": {"seat": "2", "kind": "human" | "agent" | "auto" | "bot", "name": ..., "dir" | "field": ...}} // {"stop": true} (stdin closing stops the room too) // SS_ROOM: {"seats": {"0": {"kind": "human", "name": "ada"}, "1": {"kind": "bot", "dir": "routes/x"}, // "2": {"kind": "bot", "field": "upstream"}, ...}, "seed": 7, "limit": 30}. Seats left out are auto. // A move with an old version is dropped; a refused one is asked again with a new version, and after three refusals // auto plays the seat's move. A human or agent seat that // does not move within the limit is played by auto (the upstream heuristic AI), and so is a bot that gives no move. // At the end the game is in replays/room.json (the upstream replay format) and replays/matches.json. import { createInterface } from 'node:readline'; import path from 'node:path'; import { eventsForViewer } from '../src/engine/view/events-for-viewer'; import { buildView } from '../src/engine/index'; import { Table, Heuristic, type Ask } from './table'; import { Bot } from './bots'; import { ROOT, Replays, answer, gone, pass } from './play'; type Kind = 'human' | 'agent' | 'bot' | 'auto'; interface Seat { kind: Kind; name?: string; route?: string; dir?: string; field?: string; bot?: Bot; auto: Heuristic; out?: { version: number; key: string; refused: unknown[]; timer: NodeJS.Timeout }; seen: number; } type 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 } }; const say = (m: object) => process.stdout.write(`@shoal ${JSON.stringify(m)}\n`); const log = (s: string) => process.stdout.write(`${s}\n`); /** stdin lines and timers, one at a time. */ class Inbox { private items: Input[] = []; private wake: (() => void) | null = null; closed = false; push(i: Input): void { this.items.push(i); this.wake?.(); } async next(): Promise { while (!this.items.length) { if (this.closed) return null; await new Promise((r) => (this.wake = r)); this.wake = null; } return this.items.shift()!; } } export async function room(): Promise { const cfg = JSON.parse(process.env.SS_ROOM || '{}') as { seats?: Record>; seed?: number; limit?: number }; const seed = cfg.seed ?? Math.floor(Math.random() * 1e9); const limitMs = (cfg.limit ?? 30) * 1000; const seats: Seat[] = [0, 1, 2, 3, 4].map((i) => ({ kind: 'auto' as Kind, ...cfg.seats?.[String(i)], auto: new Heuristic(), seen: 0 })); for (const s of seats) startBot(s); await Promise.all(seats.map((s) => s.bot?.ready)); const names = seats.map((s) => s.name ?? s.route ?? s.field ?? s.kind); const inbox = new Inbox(); const rl = createInterface({ input: process.stdin }); rl.on('line', (line) => { try { inbox.push(JSON.parse(line)); } catch { log(`not JSON: ${line.slice(0, 80)}`); } }); rl.on('close', () => { inbox.closed = true; inbox.push({}); }); const t = new Table(seed); await t.open(names); 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 } }); let publicSeq = t.state.seq; let version = 0; for (const [i, s] of seats.entries()) if (s.kind === 'human') baseline(i); function startBot(s: Seat): void { if (s.kind !== 'bot') return; try { s.bot = new Bot(s.field ? path.join(ROOT, 'field', s.field) : path.resolve(process.env.SS_INPUT ?? '.', s.dir ?? s.route ?? 'routes')); } catch (e) { log(`seat ${s.name}: ${(e as Error).message}; auto plays it`); s.kind = 'auto'; } } /** A person's viewer needs the seat's whole view once, then its events. */ function baseline(i: number): void { say({ to: String(i), obs: { view: buildView(t.state, i), seq: t.state.seq } }); seats[i].seen = t.state.seq; } function publish(): void { const pub = eventsForViewer(t.state, -1, publicSeq); if (pub.length) say({ event: { type: 'events', events: pub } }); publicSeq = t.state.seq; for (const [i, s] of seats.entries()) { if (s.kind !== 'human') continue; const mine = eventsForViewer(t.state, i, s.seen); if (mine.length) say({ to: String(i), obs: { events: mine } }); s.seen = t.state.seq; } } function drop(s: Seat): void { if (s.out) clearTimeout(s.out.timer); s.out = undefined; } function request(a: Ask, refused: unknown[] = []): void { const s = seats[a.seat]; const v = ++version; const timer = setTimeout(() => inbox.push({ timeout: { seat: a.seat, version: v } }), limitMs); s.out = { version: v, key: a.key, refused, timer }; say({ to: String(a.seat), version: v, obs: { ...a.obs, limit: limitMs / 1000, ...(refused.length ? { refused } : {}) }, legal: a.legal }); } async function auto(a: Ask): Promise { const s = seats[a.seat]; if (s.kind === 'bot' && s.bot && !s.bot.dead) return answer(t, a, (obs, legal) => s.bot!.ask({ obs, legal })); return answer(t, a, async (obs, legal) => s.auto.act(obs as Ask['obs'], legal)); } let stopped = false; let stuck = false; for (let moves = 0; !t.over && !stopped && moves < 3000; ) { publish(); const asks = await t.asks(); publish(); if (t.over) break; if (!asks.length) { if (!(await t.idle())) stuck = true; if (stuck) break; continue; } for (const s of seats) if (s.out && !asks.some((a) => seats[a.seat] === s && a.key === s.out!.key)) drop(s); const now = asks.find((a) => seats[a.seat].kind === 'bot' || seats[a.seat].kind === 'auto'); if (now) { if (!(await auto(now))) stuck = true; if (stuck) break; moves++; continue; } for (const a of asks) if (!seats[a.seat].out) request(a); const msg = await inbox.next(); if (!msg) break; if (msg.stop || (inbox.closed && !msg.act && !msg.control)) { stopped = true; } else if (msg.act) { const i = Number(msg.act.seat); const s = seats[i]; const a = asks.find((x) => x.seat === i); if (!s?.out || !a || s.out.version !== msg.act.version) { log(`stale move for seat ${msg.act.seat}: version ${msg.act.version}, now ${s?.out?.version ?? 'none'}`); continue; } const refused = s.out.refused; const v = s.out.version; drop(s); if (await t.act(i, msg.act.action)) { say({ event: { type: 'moved', seat: String(i), version: v, by: s.kind } }); moves++; } else { log(`seat ${i}: the engine refused ${JSON.stringify(msg.act.action).slice(0, 200)}`); if (await gone(t, a)) continue; if (refused.length < 2) request(a, [...refused, msg.act.action]); else if (!(await answer(t, a, async (obs, legal) => s.auto.act(obs as Ask['obs'], legal)))) stuck = true; // three refusals: auto plays it else say({ event: { type: 'moved', seat: String(i), version: v, by: 'auto' } }); } } else if (msg.control) { const i = Number(msg.control.seat); const s = seats[i]; if (!s) continue; s.bot?.close(); Object.assign(s, { kind: msg.control.kind, name: msg.control.name ?? s.name, dir: msg.control.dir, field: msg.control.field, bot: undefined }); startBot(s); await s.bot?.ready; drop(s); // the next turn of the loop asks the seat again, with a new version say({ event: { type: 'control', seat: String(i), kind: s.kind, name: s.name } }); if (s.kind === 'human') baseline(i); } else if (msg.timeout) { const s = seats[msg.timeout.seat]; const a = asks.find((x) => x.seat === msg.timeout!.seat); if (!s?.out || s.out.version !== msg.timeout.version || !a) continue; log(`seat ${msg.timeout.seat}: no move in ${limitMs / 1000}s, auto plays it`); drop(s); say({ event: { type: 'moved', seat: String(msg.timeout.seat), version: msg.timeout.version, by: 'auto' } }); if (!(await answer(t, a, async (obs, legal) => s.auto.act(obs as Ask['obs'], legal))) && !(await pass(t, a))) stuck = true; moves++; } } publish(); for (const s of seats) drop(s), s.bot?.close(); const result = stopped ? 'stopped' : stuck ? 'stuck' : t.result(); const identities = Object.fromEntries(t.state.players.map((p, i) => [String(i), p.identity ?? ''])); const winner = t.over?.winner === undefined ? '无人' : String(t.over.winner); say({ end: { result, winner, scores: t.over ? t.scores() : Object.fromEntries(names.map((_, i) => [String(i), 0])), identities } }); const focus = Math.max(0, seats.findIndex((s) => s.kind === 'human' || s.kind === 'agent')); new Replays().add('room.json', { ...(t.replay('Shoal room') as object), shoal: { seat: focus, names } }, `A room: ${names.join(', ')}`, names, result); rl.close(); process.exit(0); }