From 9a837ec319de931505c42341385c42d4f5f14c9b Mon Sep 17 00:00:00 2001 From: "claude (blind_chess)" Date: Tue, 28 Apr 2026 14:07:01 -0400 Subject: [PATCH] feat(bot): vsAi/aiOpponent protocol fields and bot-driver registry --- packages/server/src/games.ts | 41 ++++++++++++++++++- packages/server/src/state.ts | 1 + packages/server/src/validation.ts | 3 ++ .../test/integration/scripted-game.test.ts | 1 + .../server/test/unit/bot/candidates.test.ts | 1 + packages/server/test/unit/bot/driver.test.ts | 1 + packages/server/test/unit/commit-fsm.test.ts | 1 + packages/server/test/unit/view.test.ts | 1 + packages/shared/src/protocol.ts | 5 ++- 9 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/server/src/games.ts b/packages/server/src/games.ts index bb15e14..db06877 100644 --- a/packages/server/src/games.ts +++ b/packages/server/src/games.ts @@ -3,9 +3,23 @@ import { randomBytes } from 'node:crypto'; import type { Color, GameId, Mode, PlayerToken, } from '@blind-chess/shared'; +import type { BotDriver } from './bot/driver.js'; import { type Game, PRUNE_AFTER_FINISHED_MS, RATE_LIMIT } from './state.js'; const games = new Map(); +const botDrivers = new Map(); + +export function attachBotDriver(id: GameId, driver: BotDriver): void { + botDrivers.set(id, driver); +} + +export function getBotDriver(id: GameId): BotDriver | undefined { + return botDrivers.get(id); +} + +export function disposeBotDriver(id: GameId): void { + botDrivers.delete(id); +} export function newGameId(): GameId { const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; @@ -31,11 +45,16 @@ export function createGame(opts: { mode: Mode; creatorSide: Color; highlightingEnabled: boolean; + vsAi?: { brain: 'casual' | 'recon' }; }): { game: Game; creatorToken: PlayerToken } { const id = newGameId(); const creatorToken = newPlayerToken(); const now = Date.now(); + const botColor: Color | null = opts.vsAi + ? (opts.creatorSide === 'w' ? 'b' : 'w') + : null; + const game: Game = { id, mode: opts.mode, @@ -46,12 +65,18 @@ export function createGame(opts: { moveHistory: [], announcements: [], players: { - w: opts.creatorSide === 'w' ? makeSlot(creatorToken, now) : null, - b: opts.creatorSide === 'b' ? makeSlot(creatorToken, now) : null, + w: opts.creatorSide === 'w' ? makeSlot(creatorToken, now) + : (botColor === 'w' ? makeBotSlot(now) : null), + b: opts.creatorSide === 'b' ? makeSlot(creatorToken, now) + : (botColor === 'b' ? makeBotSlot(now) : null), }, armed: null, drawOffer: null, disconnectAt: {}, + lastBroadcastIdx: { w: 0, b: 0 }, + aiOpponent: opts.vsAi && botColor + ? { color: botColor, brain: opts.vsAi.brain } + : undefined, }; games.set(id, game); @@ -67,6 +92,17 @@ function makeSlot(token: PlayerToken, now: number) { }; } +function makeBotSlot(now: number) { + // Synthetic slot: occupies the player's color but never connects. + // Token is a 24-char placeholder; never matches a real client. + return { + token: 'bot' + 'x'.repeat(21), + socket: null, + joinedAt: now, + rateBucket: { tokens: RATE_LIMIT.capacity, last: now }, + }; +} + export function getGame(id: GameId): Game | undefined { return games.get(id); } @@ -115,6 +151,7 @@ export function pruneFinished(): number { for (const [id, g] of games) { if (g.status === 'finished' && g.finishedAt && now - g.finishedAt > PRUNE_AFTER_FINISHED_MS) { games.delete(id); + botDrivers.delete(id); removed++; } } diff --git a/packages/server/src/state.ts b/packages/server/src/state.ts index 93c4b48..4459430 100644 --- a/packages/server/src/state.ts +++ b/packages/server/src/state.ts @@ -51,6 +51,7 @@ export interface Game { armed: { color: Color; from: Square } | null; drawOffer: { from: Color; at: number } | null; disconnectAt: { w?: number; b?: number }; + lastBroadcastIdx: { w: number; b: number }; aiOpponent?: { color: Color; brain: 'casual' | 'recon' }; } diff --git a/packages/server/src/validation.ts b/packages/server/src/validation.ts index 1cc1473..7a583d4 100644 --- a/packages/server/src/validation.ts +++ b/packages/server/src/validation.ts @@ -41,4 +41,7 @@ export const createGameSchema = z.object({ mode: z.union([z.literal('blind'), z.literal('vanilla')]), side: z.union([colorSchema, z.literal('random')]), highlightingEnabled: z.boolean(), + vsAi: z.object({ + brain: z.union([z.literal('casual'), z.literal('recon')]), + }).optional(), }); diff --git a/packages/server/test/integration/scripted-game.test.ts b/packages/server/test/integration/scripted-game.test.ts index 9d122ee..81c965a 100644 --- a/packages/server/test/integration/scripted-game.test.ts +++ b/packages/server/test/integration/scripted-game.test.ts @@ -26,6 +26,7 @@ beforeAll(async () => { mode: parsed.data.mode, creatorSide, highlightingEnabled: parsed.data.highlightingEnabled, + vsAi: parsed.data.vsAi, }); return { gameId: game.id, creatorToken, creatorColor: creatorSide }; }); diff --git a/packages/server/test/unit/bot/candidates.test.ts b/packages/server/test/unit/bot/candidates.test.ts index 1a85e4f..58e1340 100644 --- a/packages/server/test/unit/bot/candidates.test.ts +++ b/packages/server/test/unit/bot/candidates.test.ts @@ -23,6 +23,7 @@ function makeGame(mode: 'blind' | 'vanilla', fen?: string): Game { armed: null, drawOffer: null, disconnectAt: {}, + lastBroadcastIdx: { w: 0, b: 0 }, }; } diff --git a/packages/server/test/unit/bot/driver.test.ts b/packages/server/test/unit/bot/driver.test.ts index 63c31f8..2742728 100644 --- a/packages/server/test/unit/bot/driver.test.ts +++ b/packages/server/test/unit/bot/driver.test.ts @@ -24,6 +24,7 @@ function makeGame(opts: { mode?: 'blind' | 'vanilla'; fen?: string; status?: Gam armed: null, drawOffer: null, disconnectAt: {}, + lastBroadcastIdx: { w: 0, b: 0 }, aiOpponent: { color: 'b', brain: 'casual' }, }; } diff --git a/packages/server/test/unit/commit-fsm.test.ts b/packages/server/test/unit/commit-fsm.test.ts index f62ebfe..d8a9421 100644 --- a/packages/server/test/unit/commit-fsm.test.ts +++ b/packages/server/test/unit/commit-fsm.test.ts @@ -24,6 +24,7 @@ function makeGame(fen?: string): Game { armed: null, drawOffer: null, disconnectAt: {}, + lastBroadcastIdx: { w: 0, b: 0 }, }; } diff --git a/packages/server/test/unit/view.test.ts b/packages/server/test/unit/view.test.ts index 2046542..db8b8cd 100644 --- a/packages/server/test/unit/view.test.ts +++ b/packages/server/test/unit/view.test.ts @@ -21,6 +21,7 @@ function makeGame(mode: 'blind' | 'vanilla', fen?: string, status: 'active' | 'f armed: null, drawOffer: null, disconnectAt: {}, + lastBroadcastIdx: { w: 0, b: 0 }, }; } diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 8e0ea0f..e4c05d2 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -34,6 +34,7 @@ export type ServerMessage = mode: Mode; highlightingEnabled: boolean; opponentConnected: boolean; + aiOpponent?: { color: Color; brain: 'casual' | 'recon' }; } | { type: 'update'; @@ -44,6 +45,7 @@ export type ServerMessage = drawOffer?: { from: Color } | null; endReason?: EndReason; winner?: Color | null; + aiOpponent?: { color: Color; brain: 'casual' | 'recon' }; } | { type: 'peer-status'; color: Color; connected: boolean; graceUntil?: number } | { type: 'error'; code: ErrorCode; message: string } @@ -53,10 +55,11 @@ export interface CreateGameRequest { mode: Mode; side: Color | 'random'; highlightingEnabled: boolean; + vsAi?: { brain: 'casual' | 'recon' }; } export interface CreateGameResponse { gameId: GameId; creatorToken: PlayerToken; - joinUrl: string; + joinUrl: string | null; }