Files
blind_chess/packages/server/src/games.ts
T
claude (blind_chess) 88bc23b0d0 fix(bot): harden ws.ts integration seam
- maybeAbandon Promise no longer floats from setTimeout
- broadcastSinceLast loses dead extra parameter
- bot-slot token is randomized so a third party can't hijack the
  bot's color by guessing a fixed placeholder
2026-04-28 14:17:46 -04:00

161 lines
4.4 KiB
TypeScript

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<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';
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<Game> {
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;
}