M9: 컴퓨터 상대 — 게임 22종 컴퓨터 전략, 서버 구동(BotDriver), 연습 방
- packages/shared/src/bots.ts: 컴퓨터 id(bot:<n>:<ulid>)·프로필(컴퓨터 n)·BOT_GAMES
(도블·할리갈리·그림 맞히기·마피아 제외)
- packages/games/src/bots: 전략 계약(자기 view + 합법 수만), decideBotAction(validate 재검사),
게임별 <게임>/bot.ts 22개 + 행동 테스트, 전 게임 판 끝까지 돌리는 공통 테스트
- 서버: Room.addBot/removeBot(방장·대기실만, 방은 친구만), RoomManager.createPractice
(POST /api/rooms {practice:true}), BotDriver(사람 같은 지연, 사람과 같은 창이면 더 천천히,
재시작 후 다시 예약), 연습 게임은 전적 제외(result_json.practice), 사람이 모두 나가면 무효
- 프로토콜 addBot/removeBot, 문서 03·06 §12·08·09 §8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
122
apps/server/src/rooms/bots.ts
Normal file
122
apps/server/src/rooms/bots.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Computer players' driver (docs/06-lobby-rooms.md §12, docs/09-game-engine.md §8).
|
||||
* 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<string, unknown>) => 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<typeof setTimeout> | 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user