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(),
});
@@ -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 };
});
@@ -23,6 +23,7 @@ function makeGame(mode: 'blind' | 'vanilla', fen?: string): Game {
armed: null,
drawOffer: null,
disconnectAt: {},
lastBroadcastIdx: { w: 0, b: 0 },
};
}
@@ -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' },
};
}
@@ -24,6 +24,7 @@ function makeGame(fen?: string): Game {
armed: null,
drawOffer: null,
disconnectAt: {},
lastBroadcastIdx: { w: 0, b: 0 },
};
}
+1
View File
@@ -21,6 +21,7 @@ function makeGame(mode: 'blind' | 'vanilla', fen?: string, status: 'active' | 'f
armed: null,
drawOffer: null,
disconnectAt: {},
lastBroadcastIdx: { w: 0, b: 0 },
};
}
+4 -1
View File
@@ -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;
}