import { Chess } from 'chess.js'; 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'; let id = ''; while (true) { const buf = randomBytes(8); id = ''; for (let i = 0; i < 8; i++) id += alphabet[buf[i]! % alphabet.length]; if (!games.has(id)) return id; } } export function newPlayerToken(): PlayerToken { return randomBytes(18).toString('base64url').slice(0, 24).toLowerCase().replace(/[^a-z0-9]/g, 'a'); } export function chooseSide(side: Color | 'random'): Color { if (side === 'random') return Math.random() < 0.5 ? 'w' : 'b'; return side; } 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, highlightingEnabled: opts.highlightingEnabled, status: 'waiting', createdAt: now, chess: new Chess(), moveHistory: [], announcements: [], players: { 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); return { game, creatorToken }; } function makeSlot(token: PlayerToken, now: number) { return { token, socket: null, joinedAt: now, rateBucket: { tokens: RATE_LIMIT.capacity, last: now }, }; } function makeBotSlot(now: number) { // Synthetic slot: occupies the player's color but never connects. The token // is randomized (same shape as a real client token) so a third party can't // hijack the bot's color by guessing a fixed placeholder. return { token: newPlayerToken(), socket: null, joinedAt: now, rateBucket: { tokens: RATE_LIMIT.capacity, last: now }, }; } export function getGame(id: GameId): Game | undefined { return games.get(id); } export function deleteGame(id: GameId): void { games.delete(id); } export function allGames(): IterableIterator { return games.values(); } export function activeGameCount(): number { let n = 0; for (const g of games.values()) if (g.status !== 'finished') n++; return n; } /** Find game where this token is bound to a player slot; returns the slot color. */ export function findTokenInGame(game: Game, token: PlayerToken): Color | null { if (game.players.w?.token === token) return 'w'; if (game.players.b?.token === token) return 'b'; return null; } /** Claim the open slot in a game. Returns the color claimed or null if both filled. */ export function claimSlot( game: Game, joinAs: Color | 'auto', ): { color: Color; token: PlayerToken } | null { const tryClaim = (c: Color): { color: Color; token: PlayerToken } | null => { if (game.players[c]) return null; const token = newPlayerToken(); game.players[c] = makeSlot(token, Date.now()); return { color: c, token }; }; if (joinAs === 'w') return tryClaim('w'); if (joinAs === 'b') return tryClaim('b'); return tryClaim('w') ?? tryClaim('b'); } export function pruneFinished(): number { const now = Date.now(); let removed = 0; 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++; } } return removed; }