feat: implement and deploy blind_chess MVP
- pnpm workspace: shared/server/client packages - Server: Fastify+ws, chess.js, FSM (touch-move + hierarchy), per-player view filter, zod validation, rate limiting, grace-window disconnect handling - Client: Svelte 5 + Vite, click-to-move board, moderator panel, promotion/draw dialogs - Shared: protocol types, ModeratorText enum, geometricMoves helper (provably zero opponent-info leak) - 43 tests pass (21 shared, 22 server incl. 4 real-WS integration) - Deploy: CT 690 on node-241 (192.168.0.245), systemd-managed, Caddy block for chess.sethpc.xyz - Live at https://chess.sethpc.xyz Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { Chess } from 'chess.js';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type {
|
||||
Color, GameId, Mode, PlayerToken,
|
||||
} from '@blind-chess/shared';
|
||||
import { type Game, PRUNE_AFTER_FINISHED_MS, RATE_LIMIT } from './state.js';
|
||||
|
||||
const games = new Map<GameId, Game>();
|
||||
|
||||
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;
|
||||
}): { game: Game; creatorToken: PlayerToken } {
|
||||
const id = newGameId();
|
||||
const creatorToken = newPlayerToken();
|
||||
const now = Date.now();
|
||||
|
||||
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) : null,
|
||||
b: opts.creatorSide === 'b' ? makeSlot(creatorToken, now) : null,
|
||||
},
|
||||
armed: null,
|
||||
drawOffer: null,
|
||||
disconnectAt: {},
|
||||
};
|
||||
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
Reference in New Issue
Block a user