feat(bot): vsAi/aiOpponent protocol fields and bot-driver registry

This commit is contained in:
claude (blind_chess)
2026-04-28 14:07:01 -04:00
parent 4407110147
commit 9a837ec319
9 changed files with 52 additions and 3 deletions
+39 -2
View File
@@ -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<GameId, Game>();
const botDrivers = new Map<GameId, BotDriver>();
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++;
}
}
+1
View File
@@ -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' };
}
+3
View File
@@ -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(),
});