88bc23b0d0
- 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
161 lines
4.4 KiB
TypeScript
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;
|
|
}
|