/** * Computer players' driver (docs/06-lobby-rooms.md §12, docs/09-game-engine.md §9). * After every state change the room calls `schedule()`: if the game waits on a bot, one action is planned after a * short human-like pause and played through the room's normal action path (validated, logged as `act`). */ import type { AnyGameDefinition } from '@bg/engine'; import { isBotId } from '@bg/shared'; import { botCandidates, decideBotAction } from '@bg/games/bots'; export interface BotRoom { readonly status: string; readonly game: { state: unknown; players: string[]; finished: boolean } | null; readonly def: AnyGameDefinition; now(): number; /** Runs the action as `actor` through the room's normal path; returns an error message on rejection. */ run(actor: string, action: unknown): string | null; log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record) => void; } /** * Thinking time: a quick beat for forced moves, a little longer for real choices. When people may act in the same * window (e.g. 원카드 외치기, 마작 울기, simultaneous choices) the computer waits longer so it never beats a person * to a reaction just by being fast. */ export function defaultBotDelay(choices: number, withPeople = false, random: () => number = Math.random): number { const base = choices <= 1 ? 400 + Math.floor(random() * 300) : 700 + Math.floor(random() * 800); return withPeople ? base + 1500 : base; } /** Bot move budget (ms); slower decisions are logged. */ export const BOT_SLOW_MS = 50; export class BotDriver { private timer: ReturnType | null = null; /** When the planned action is due (room clock); null when nothing is planned. */ dueAt: number | null = null; /** Bot the planned action is for. */ private planned: string | null = null; constructor( private room: BotRoom, private random: () => number = Math.random, private delay: (choices: number, withPeople: boolean) => number = (n, w) => defaultBotDelay(n, w, random), ) {} /** Plans the next bot action if the game is waiting on a bot. Keeps an already planned one (the bot is "thinking"). */ schedule(): void { if (this.dueAt !== null) return; const next = this.next(); if (!next) return; this.dueAt = this.room.now() + this.delay(next.choices, next.withPeople); this.planned = next.bot; this.arm(); } cancel(): void { if (this.timer) clearTimeout(this.timer); this.timer = null; this.dueAt = null; this.planned = null; } private arm(): void { if (this.timer) clearTimeout(this.timer); this.timer = setTimeout(() => this.fire(), Math.max(0, (this.dueAt ?? 0) - this.room.now())); } /** Bot waited on by the game that has something to do, in seat order. */ private next(): { bot: string; choices: number; withPeople: boolean } | null { const g = this.room.game; if (!g || g.finished || this.room.status !== 'playing' || !g.players.some(isBotId)) return null; const def = this.room.def; const active = def.activePlayers(g.state); const waiting = new Set([...active, ...(def.timeoutPlayers?.(g.state) ?? [])]); const withPeople = active.some((p) => !isBotId(p)); for (const p of g.players) { if (!isBotId(p) || !waiting.has(p)) continue; const choices = botCandidates(def, g.state, p).length; if (choices > 0) return { bot: p, choices, withPeople }; } return null; } /** Plays the planned action when due. Public for tests (with a fake clock). */ fire(): void { if (this.timer) clearTimeout(this.timer); this.timer = null; if (this.dueAt === null) return; if (this.room.now() < this.dueAt) return this.arm(); const planned = this.planned; this.dueAt = null; this.planned = null; const next = this.next(); const g = this.room.game; if (!next || !g) return; // Another bot's turn came up meanwhile: give it its own thinking time. if (next.bot !== planned) return this.schedule(); const def = this.room.def; const t0 = performance.now(); let action: unknown; try { action = decideBotAction(def, g.state, next.bot, { now: this.room.now(), random: this.random }); } catch (err) { this.room.log?.('error', 'bot decision failed', { game: def.id, err: String(err) }); action = null; } const ms = performance.now() - t0; if (ms > BOT_SLOW_MS) this.room.log?.('warn', 'slow bot move', { game: def.id, ms: Math.round(ms) }); let err = action == null ? 'no action' : this.room.run(next.bot, action); if (err) { this.room.log?.('warn', 'bot action rejected', { game: def.id, action, err }); // Last resort before the turn timer: the game's own timeout action. try { if (!this.room.game?.finished) err = this.room.run(next.bot, def.onTimeout(this.room.game!.state, next.bot)); } catch { // keep err } if (err) return; // give up until the next state change; the room's deadline still applies } this.schedule(); } }