| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
| 14 | |
| 15 | |
| 16 | |
| 17 | |
| 18 | |
| 19 | |
| 20 | |
| 21 | |
| 22 | import { createInterface } from 'node:readline'; |
| 23 | import path from 'node:path'; |
| 24 | import { eventsForViewer } from '../src/engine/view/events-for-viewer'; |
| 25 | import { buildView } from '../src/engine/index'; |
| 26 | import { Table, Heuristic, type Ask } from './table'; |
| 27 | import { Bot } from './bots'; |
| 28 | import { ROOT, Replays, answer, gone, pass } from './play'; |
| 29 | |
| 30 | type Kind = 'human' | 'agent' | 'bot' | 'auto'; |
| 31 | interface 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 | } |
| 42 | 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 } }; |
| 43 | |
| 44 | const say = (m: object) => process.stdout.write(`@shoal ${JSON.stringify(m)}\n`); |
| 45 | const log = (s: string) => process.stdout.write(`${s}\n`); |
| 46 | |
| 47 | |
| 48 | class 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 | |
| 66 | export 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 | |
| 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; |
| 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); |
| 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 | } |