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:
claude (blind_chess)
2026-04-28 11:20:18 -04:00
parent 9a5ad55f30
commit a6de43edc1
53 changed files with 11970 additions and 5 deletions
+132
View File
@@ -0,0 +1,132 @@
import type { Move } from 'chess.js';
import {
geometricMoves,
type Announcement,
type Color,
type Piece,
type PromotionType,
type Square,
} from '@blind-chess/shared';
import type { Game, MoveRecord } from './state.js';
import { announce, translateMove } from './translator.js';
import { ownSquares } from './view.js';
export type CommitResult =
| { kind: 'error'; code: 'not_your_turn' | 'must_move_touched_piece' | 'promotion_required' }
| { kind: 'announce'; announcements: Announcement[] }
| { kind: 'silent' }
| { kind: 'applied'; announcements: Announcement[]; moveRecord: MoveRecord };
export interface CommitInput {
from: Square;
to?: Square;
promotion?: PromotionType;
}
export function handleCommit(game: Game, color: Color, msg: CommitInput): CommitResult {
if (game.status !== 'active') return { kind: 'error', code: 'not_your_turn' };
if (game.chess.turn() !== color) return { kind: 'error', code: 'not_your_turn' };
const touched = game.armed?.color === color ? game.armed.from : null;
if (touched) {
if (msg.from !== touched) return { kind: 'error', code: 'must_move_touched_piece' };
if (!msg.to) return { kind: 'silent' };
return tryMove(game, color, { from: msg.from, to: msg.to, promotion: msg.promotion });
}
const piece = game.chess.get(msg.from) as { color: Color; type: Piece['type'] } | false;
if (!piece || piece.color !== color) {
return announceWith(game, 'no_such_piece', color);
}
const pseudo = geometricMoves(
{ color: piece.color, type: piece.type },
msg.from,
ownSquares(game, color),
);
if (pseudo.length === 0) {
return announceWith(game, 'no_legal_moves', color);
}
const legal = chessJsLegalFrom(game, msg.from);
if (legal.length === 0) {
return announceWith(game, 'wont_help', color);
}
game.armed = { color, from: msg.from };
if (!msg.to) return { kind: 'silent' };
return tryMove(game, color, { from: msg.from, to: msg.to, promotion: msg.promotion });
}
function tryMove(
game: Game,
color: Color,
msg: { from: Square; to: Square; promotion?: PromotionType },
): CommitResult {
if (isPromotionRequired(game, msg.from, msg.to) && !msg.promotion) {
return { kind: 'error', code: 'promotion_required' };
}
let move: Move | null = null;
try {
move = game.chess.move({ from: msg.from, to: msg.to, promotion: msg.promotion });
} catch {
move = null;
}
if (!move) {
return announceWith(game, 'illegal_move', color);
}
game.armed = null;
const ply = game.chess.history().length;
const moveRecord: MoveRecord = {
ply,
by: color,
from: msg.from,
to: msg.to,
san: move.san,
capturedPieceType: move.captured,
promotion: move.promotion as PromotionType | undefined,
flags: {
castle: move.isKingsideCastle() ? 'k' : move.isQueensideCastle() ? 'q' : undefined,
enPassant: move.isEnPassant() || undefined,
check: game.chess.inCheck() || undefined,
mate: game.chess.isCheckmate() || undefined,
},
at: Date.now(),
};
game.moveHistory.push(moveRecord);
const announcements = translateMove(game, move);
game.announcements.push(...announcements);
return { kind: 'applied', announcements, moveRecord };
}
function announceWith(
game: Game,
text: 'no_such_piece' | 'no_legal_moves' | 'wont_help' | 'illegal_move',
color: Color,
): CommitResult {
const ply = game.chess.history().length;
const a = announce(text, color, ply);
game.announcements.push(a);
return { kind: 'announce', announcements: [a] };
}
function chessJsLegalFrom(game: Game, from: Square): string[] {
return game.chess.moves({ square: from as never, verbose: false } as never) as string[];
}
function isPromotionRequired(game: Game, from: Square, to: Square): boolean {
const piece = game.chess.get(from);
if (!piece || piece.type !== 'p') return false;
const toRank = to[1];
if (piece.color === 'w' && toRank === '8') return true;
if (piece.color === 'b' && toRank === '1') return true;
return false;
}
+122
View File
@@ -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;
}
+18
View File
@@ -0,0 +1,18 @@
import { RATE_LIMIT, type Game } from './state.js';
import type { Color } from '@blind-chess/shared';
/** Token-bucket rate limiter on `commit`. Returns true if allowed. */
export function consumeCommitToken(game: Game, color: Color): boolean {
const slot = game.players[color];
if (!slot) return false;
const now = Date.now();
const elapsed = (now - slot.rateBucket.last) / 1000;
slot.rateBucket.tokens = Math.min(
RATE_LIMIT.capacity,
slot.rateBucket.tokens + elapsed * RATE_LIMIT.refillPerSec,
);
slot.rateBucket.last = now;
if (slot.rateBucket.tokens < 1) return false;
slot.rateBucket.tokens -= 1;
return true;
}
+105
View File
@@ -0,0 +1,105 @@
import Fastify from 'fastify';
import websocketPlugin from '@fastify/websocket';
import staticPlugin from '@fastify/static';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
activeGameCount,
chooseSide,
createGame,
pruneFinished,
} from './games.js';
import { attachSocket } from './ws.js';
import { createGameSchema } from './validation.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.PORT ?? '3000', 10);
const HOST = process.env.HOST ?? '0.0.0.0';
const STATIC_DIR = process.env.STATIC_DIR ?? path.resolve(__dirname, '../../client/dist');
const PUBLIC_BASE = process.env.PUBLIC_BASE ?? '';
const startedAt = Date.now();
const fastify = Fastify({
logger: {
level: process.env.LOG_LEVEL ?? 'info',
transport: process.env.NODE_ENV === 'production' ? undefined : {
target: 'pino-pretty',
options: { colorize: true, translateTime: 'HH:MM:ss' },
},
},
trustProxy: true,
});
await fastify.register(websocketPlugin);
fastify.get('/api/health', async () => ({
ok: true,
activeGames: activeGameCount(),
uptime: Math.floor((Date.now() - startedAt) / 1000),
}));
fastify.post('/api/games', async (req, reply) => {
const parsed = createGameSchema.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'malformed', detail: parsed.error.issues };
}
const { mode, side, highlightingEnabled } = parsed.data;
const creatorSide = chooseSide(side);
const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled });
const publicBase = PUBLIC_BASE
|| (req.headers.host ? `${req.protocol}://${req.headers.host}` : '');
const joinUrl = `${publicBase}/g/${game.id}`;
return { gameId: game.id, creatorToken, creatorColor: creatorSide, joinUrl };
});
fastify.get('/ws', { websocket: true }, (socket) => {
// fastify-websocket v11 passes the raw ws socket directly.
const raw = (socket as unknown as { socket?: unknown }).socket ?? socket;
attachSocket(raw as never);
});
// Static client assets — serve dist/ if present, gracefully degrade if not.
import('node:fs').then((fs) => {
if (fs.existsSync(STATIC_DIR)) {
fastify.register(staticPlugin, {
root: STATIC_DIR,
prefix: '/',
decorateReply: true,
});
// SPA fallback: serve index.html for /g/<id> etc.
fastify.setNotFoundHandler((req, reply) => {
const accept = String(req.headers.accept ?? '');
if (accept.includes('text/html')) {
return (reply as unknown as { sendFile: (n: string) => unknown }).sendFile('index.html');
}
reply.code(404).send({ error: 'not_found' });
});
} else {
fastify.log.warn({ STATIC_DIR }, 'static client dist not found; serving API only');
}
});
// Janitor: prune finished games every 5 min.
const janitor = setInterval(() => {
const removed = pruneFinished();
if (removed > 0) fastify.log.info({ removed }, 'pruned finished games');
}, 5 * 60 * 1000);
janitor.unref();
const ready = fastify.listen({ port: PORT, host: HOST });
ready.then(() => {
fastify.log.info(`blind_chess listening on ${HOST}:${PORT}`);
}).catch((err) => {
fastify.log.error(err);
process.exit(1);
});
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
process.on(sig, () => {
fastify.log.info({ sig }, 'shutting down');
fastify.close().then(() => process.exit(0));
});
}
+58
View File
@@ -0,0 +1,58 @@
import type { Chess } from 'chess.js';
import type { WebSocket } from 'ws';
import type {
Announcement,
Color,
EndReason,
GameId,
GameStatus,
Mode,
PieceType,
PlayerToken,
PromotionType,
Square,
} from '@blind-chess/shared';
export interface MoveRecord {
ply: number;
by: Color;
from: Square;
to: Square;
san: string;
capturedPieceType?: PieceType;
promotion?: PromotionType;
flags: { castle?: 'k' | 'q'; enPassant?: boolean; check?: boolean; mate?: boolean };
at: number;
}
export interface PlayerSlot {
token: PlayerToken;
socket: WebSocket | null;
joinedAt: number;
rateBucket: { tokens: number; last: number };
}
export interface Game {
id: GameId;
mode: Mode;
highlightingEnabled: boolean;
status: GameStatus;
createdAt: number;
finishedAt?: number;
endReason?: EndReason;
winner?: Color | null;
chess: Chess;
moveHistory: MoveRecord[];
announcements: Announcement[];
players: { w: PlayerSlot | null; b: PlayerSlot | null };
armed: { color: Color; from: Square } | null;
drawOffer: { from: Color; at: number } | null;
disconnectAt: { w?: number; b?: number };
}
export const RATE_LIMIT = { capacity: 20, refillPerSec: 10 };
export const GRACE_MS = 5 * 60 * 1000;
export const PRUNE_AFTER_FINISHED_MS = 30 * 60 * 1000;
+73
View File
@@ -0,0 +1,73 @@
import type { Move } from 'chess.js';
import type { Announcement, Audience, Color, ModeratorText } from '@blind-chess/shared';
import type { Game } from './state.js';
export function announce(
text: ModeratorText,
audience: Audience,
ply: number,
payload?: Announcement['payload'],
): Announcement {
return { text, audience, ply, at: Date.now(), payload };
}
/**
* Translate an applied chess.js Move into the moderator vocabulary.
*
* Capturing player learns the captured piece type via their `view` update
* (their canonical board reflects the capture; the captured-pieces tray is
* populated from move history). The opponent gets only the `*_moved_captured`
* announcement.
*/
export function translateMove(game: Game, move: Move): Announcement[] {
const out: Announcement[] = [];
const ply = game.chess.history().length;
const mover = move.color as Color;
const opp: Color = mover === 'w' ? 'b' : 'w';
const moverWord = mover === 'w' ? 'white' : 'black';
const oppWord = opp === 'w' ? 'white' : 'black';
const isEp = move.isEnPassant();
const isCap = move.isCapture();
const isKingsideCastle = move.isKingsideCastle();
const isQueensideCastle = move.isQueensideCastle();
const isProm = !!move.promotion;
// To opponent: the move event itself.
if (isKingsideCastle) {
out.push(announce(`${moverWord}_castled_kingside` as ModeratorText, opp, ply));
} else if (isQueensideCastle) {
out.push(announce(`${moverWord}_castled_queenside` as ModeratorText, opp, ply));
} else if (isCap && isEp) {
out.push(announce(`${moverWord}_moved_captured_ep` as ModeratorText, opp, ply));
} else if (isCap) {
out.push(announce(`${moverWord}_moved_captured` as ModeratorText, opp, ply));
} else {
out.push(announce(`${moverWord}_moved` as ModeratorText, opp, ply));
}
if (isProm) {
out.push(announce(`${moverWord}_promoted` as ModeratorText, opp, ply, { promotedTo: move.promotion }));
}
// To both: state changes.
if (game.chess.isCheckmate()) {
out.push(announce(`${moverWord}_checkmate` as ModeratorText, 'both', ply));
} else if (game.chess.inCheck()) {
out.push(announce(`${oppWord}_in_check` as ModeratorText, 'both', ply));
}
if (game.chess.isStalemate()) out.push(announce('stalemate', 'both', ply));
if (game.chess.isInsufficientMaterial()) out.push(announce('draw_insufficient', 'both', ply));
if (game.chess.isThreefoldRepetition()) out.push(announce('draw_threefold', 'both', ply));
if (halfMoveClock(game) >= 100) out.push(announce('draw_fifty', 'both', ply));
return out;
}
/** Half-move clock (50-move rule). chess.js exposes it via FEN. */
function halfMoveClock(game: Game): number {
const fen = game.chess.fen();
const parts = fen.split(' ');
return parseInt(parts[4] ?? '0', 10) || 0;
}
+44
View File
@@ -0,0 +1,44 @@
import { z } from 'zod';
const colorSchema = z.union([z.literal('w'), z.literal('b')]);
const squareSchema = z.string().regex(/^[a-h][1-8]$/);
const promotionSchema = z.union([z.literal('q'), z.literal('r'), z.literal('b'), z.literal('n')]);
const gameIdSchema = z.string().regex(/^[a-z0-9]{8}$/);
const tokenSchema = z.string().regex(/^[a-z0-9]{24}$/);
export const helloSchema = z.object({
type: z.literal('hello'),
gameId: gameIdSchema,
token: tokenSchema.optional(),
joinAs: z.union([colorSchema, z.literal('auto')]).optional(),
});
export const commitSchema = z.object({
type: z.literal('commit'),
from: squareSchema,
to: squareSchema.optional(),
promotion: promotionSchema.optional(),
});
export const resignSchema = z.object({ type: z.literal('resign') });
export const offerDrawSchema = z.object({ type: z.literal('offer-draw') });
export const respondDrawSchema = z.object({
type: z.literal('respond-draw'),
accept: z.boolean(),
});
export const pongSchema = z.object({ type: z.literal('pong') });
export const clientMessageSchema = z.discriminatedUnion('type', [
helloSchema,
commitSchema,
resignSchema,
offerDrawSchema,
respondDrawSchema,
pongSchema,
]);
export const createGameSchema = z.object({
mode: z.union([z.literal('blind'), z.literal('vanilla')]),
side: z.union([colorSchema, z.literal('random')]),
highlightingEnabled: z.boolean(),
});
+48
View File
@@ -0,0 +1,48 @@
import type { Square as ChessSquare } from 'chess.js';
import type { BoardView, Color, Piece, Square } from '@blind-chess/shared';
import type { Game } from './state.js';
/**
* The single security boundary for opponent information.
* In blind mode (active games only), opponent pieces are ABSENT from the
* payload — not encrypted-but-present. The wire literally cannot leak what
* the wire never carries.
*/
export function buildView(game: Game, viewer: Color): BoardView {
const pieces: Partial<Record<Square, Piece>> = {};
const board = game.chess.board();
const reveal = game.mode === 'vanilla' || game.status === 'finished';
for (let r = 0; r < 8; r++) {
const row = board[r];
if (!row) continue;
for (let f = 0; f < 8; f++) {
const cell = row[f];
if (!cell) continue;
if (!reveal && cell.color !== viewer) continue;
pieces[cell.square as Square] = { color: cell.color, type: cell.type };
}
}
return {
pieces,
toMove: game.chess.turn() as Color,
inCheck: reveal ? game.chess.inCheck() : (viewer === game.chess.turn() ? game.chess.inCheck() : null),
};
}
/** Compute the set of own-occupied squares for a player. Used by the FSM. */
export function ownSquares(game: Game, color: Color): Set<Square> {
const out = new Set<Square>();
const board = game.chess.board();
for (const row of board) {
if (!row) continue;
for (const cell of row) {
if (!cell) continue;
if (cell.color === color) out.add(cell.square as Square);
}
}
return out;
}
export type { ChessSquare };
+300
View File
@@ -0,0 +1,300 @@
import type { WebSocket } from 'ws';
import {
type ClientMessage,
type Color,
type ErrorCode,
type ServerMessage,
} from '@blind-chess/shared';
import { clientMessageSchema } from './validation.js';
import {
claimSlot,
findTokenInGame,
getGame,
} from './games.js';
import type { Game } from './state.js';
import { GRACE_MS } from './state.js';
import { handleCommit } from './commit.js';
import { announce } from './translator.js';
import { buildView } from './view.js';
import { consumeCommitToken } from './ratelimit.js';
interface SocketCtx {
socket: WebSocket;
game: Game | null;
color: Color | null;
}
const sockets = new WeakMap<WebSocket, SocketCtx>();
export function attachSocket(socket: WebSocket): void {
const ctx: SocketCtx = { socket, game: null, color: null };
sockets.set(socket, ctx);
socket.on('message', (data) => onMessage(ctx, data));
socket.on('close', () => onClose(ctx));
socket.on('error', () => {/* logged via fastify */});
}
function send(socket: WebSocket, msg: ServerMessage): void {
if (socket.readyState === socket.OPEN) {
socket.send(JSON.stringify(msg));
}
}
function sendError(socket: WebSocket, code: ErrorCode, message?: string): void {
send(socket, { type: 'error', code, message: message ?? code });
}
function onMessage(ctx: SocketCtx, data: unknown): void {
let parsed: ClientMessage;
try {
const raw = typeof data === 'string' ? data : data instanceof Buffer ? data.toString('utf8') : String(data);
parsed = JSON.parse(raw);
} catch {
return sendError(ctx.socket, 'malformed', 'invalid JSON');
}
const result = clientMessageSchema.safeParse(parsed);
if (!result.success) {
return sendError(ctx.socket, 'malformed', result.error.message);
}
const msg = result.data as ClientMessage;
if (msg.type === 'hello') return onHello(ctx, msg);
if (msg.type === 'pong') return;
if (!ctx.game || !ctx.color) {
return sendError(ctx.socket, 'malformed', 'send hello first');
}
switch (msg.type) {
case 'commit': return onCommit(ctx, msg);
case 'resign': return onResign(ctx);
case 'offer-draw': return onOfferDraw(ctx);
case 'respond-draw': return onRespondDraw(ctx, msg.accept);
}
}
function onHello(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'hello' }>): void {
const game = getGame(msg.gameId);
if (!game) return sendError(ctx.socket, 'game_not_found');
let color: Color | null = null;
if (msg.token) {
color = findTokenInGame(game, msg.token);
if (!color) return sendError(ctx.socket, 'invalid_token');
} else {
const claim = claimSlot(game, msg.joinAs ?? 'auto');
if (!claim) {
return sendError(ctx.socket, 'spectators_disabled', 'both player slots are filled');
}
color = claim.color;
}
// Same-token, second socket: close old socket with reason "superseded".
const slot = game.players[color]!;
if (slot.socket && slot.socket !== ctx.socket && slot.socket.readyState === slot.socket.OPEN) {
try { slot.socket.close(4000, 'superseded'); } catch {/* ignore */}
}
slot.socket = ctx.socket;
delete game.disconnectAt[color];
ctx.game = game;
ctx.color = color;
// Activate game once both slots are filled and connected.
if (game.status === 'waiting' && game.players.w && game.players.b) {
game.status = 'active';
}
const view = buildView(game, color);
const audienceFiltered = game.announcements.filter(
(a) => a.audience === 'both' || a.audience === color,
);
send(ctx.socket, {
type: 'joined',
you: color,
token: slot.token,
view,
announcements: audienceFiltered,
gameStatus: game.status,
mode: game.mode,
highlightingEnabled: game.highlightingEnabled,
opponentConnected: !!game.players[color === 'w' ? 'b' : 'w']?.socket,
});
// Notify peer that we're connected.
notifyPeer(game, color, true);
// If activation just happened, push update to both.
if (game.status === 'active') {
broadcastUpdate(game);
}
}
function onCommit(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'commit' }>): void {
const game = ctx.game!;
const color = ctx.color!;
if (!consumeCommitToken(game, color)) {
return sendError(ctx.socket, 'rate_limited');
}
const result = handleCommit(game, color, msg);
switch (result.kind) {
case 'error':
sendError(ctx.socket, result.code);
return;
case 'silent':
// Re-send updated view to actor only with touchedPiece set.
sendUpdateTo(game, color, [], { touchedPiece: msg.from });
return;
case 'announce':
// Announcement to actor; opponent is unaffected unless audience=both.
broadcastNewAnnouncements(game, result.announcements);
return;
case 'applied':
// Move applied. Check end conditions.
finalizeIfEnded(game, result.announcements);
broadcastNewAnnouncements(game, result.announcements);
return;
}
}
function onResign(ctx: SocketCtx): void {
const game = ctx.game!;
const color = ctx.color!;
if (game.status !== 'active') return;
const ply = game.chess.history().length;
const a = announce(color === 'w' ? 'white_resigned' : 'black_resigned', 'both', ply);
game.announcements.push(a);
endGame(game, 'resign', color === 'w' ? 'b' : 'w');
broadcastNewAnnouncements(game, [a]);
}
function onOfferDraw(ctx: SocketCtx): void {
const game = ctx.game!;
const color = ctx.color!;
if (game.status !== 'active') return;
game.drawOffer = { from: color, at: Date.now() };
// Push update to both so opponent sees the drawOffer field.
broadcastUpdate(game);
}
function onRespondDraw(ctx: SocketCtx, accept: boolean): void {
const game = ctx.game!;
const color = ctx.color!;
if (!game.drawOffer || game.drawOffer.from === color) return;
if (accept) {
const ply = game.chess.history().length;
const a = announce('draw_agreed', 'both', ply);
game.announcements.push(a);
game.drawOffer = null;
endGame(game, 'draw_agreed', null);
broadcastNewAnnouncements(game, [a]);
} else {
game.drawOffer = null;
broadcastUpdate(game);
}
}
function onClose(ctx: SocketCtx): void {
const { game, color } = ctx;
if (!game || !color) return;
const slot = game.players[color];
if (!slot) return;
if (slot.socket === ctx.socket) {
slot.socket = null;
if (game.status === 'active') {
game.disconnectAt[color] = Date.now();
// Schedule grace timer.
setTimeout(() => maybeAbandon(game, color), GRACE_MS + 100);
}
notifyPeer(game, color, false, Date.now() + GRACE_MS);
}
}
function maybeAbandon(game: Game, color: Color): void {
if (game.status !== 'active') return;
const slot = game.players[color];
if (!slot) return;
if (slot.socket?.readyState === slot.socket?.OPEN) return; // reconnected
// Still disconnected. Game is abandoned.
const ply = game.chess.history().length;
const a = announce('game_abandoned', 'both', ply);
game.announcements.push(a);
const winner = game.players[color === 'w' ? 'b' : 'w']?.socket ? (color === 'w' ? 'b' : 'w') : null;
endGame(game, 'abandoned', winner);
broadcastNewAnnouncements(game, [a]);
}
function endGame(game: Game, reason: Game['endReason'], winner: Color | null): void {
game.status = 'finished';
game.endReason = reason;
game.winner = winner;
game.finishedAt = Date.now();
}
function finalizeIfEnded(game: Game, announcements: ReadonlyArray<{ text: string }>): void {
// Detect terminal moderator announcements.
const lastTexts = new Set(announcements.map((a) => a.text));
if (lastTexts.has('white_checkmate')) endGame(game, 'checkmate', 'w');
else if (lastTexts.has('black_checkmate')) endGame(game, 'checkmate', 'b');
else if (lastTexts.has('stalemate')) endGame(game, 'stalemate', null);
else if (lastTexts.has('draw_insufficient')) endGame(game, 'insufficient', null);
else if (lastTexts.has('draw_threefold')) endGame(game, 'threefold', null);
else if (lastTexts.has('draw_fifty')) endGame(game, 'fifty_move', null);
}
function broadcastNewAnnouncements(
game: Game,
newAnnouncements: ReadonlyArray<import('@blind-chess/shared').Announcement>,
): void {
for (const c of ['w', 'b'] as const) {
const filtered = newAnnouncements.filter((a) => a.audience === 'both' || a.audience === c);
sendUpdateTo(game, c, filtered);
}
}
function broadcastUpdate(game: Game): void {
for (const c of ['w', 'b'] as const) {
sendUpdateTo(game, c, []);
}
}
function sendUpdateTo(
game: Game,
color: Color,
newAnnouncements: ReadonlyArray<import('@blind-chess/shared').Announcement>,
extra?: { touchedPiece?: string },
): void {
const slot = game.players[color];
if (!slot?.socket) return;
const view = buildView(game, color);
const drawOffer = game.drawOffer ? { from: game.drawOffer.from } : null;
send(slot.socket, {
type: 'update',
view,
newAnnouncements: [...newAnnouncements],
gameStatus: game.status,
touchedPiece: game.armed?.color === color ? game.armed.from : extra?.touchedPiece as never,
drawOffer,
endReason: game.endReason,
winner: game.winner ?? null,
});
}
function notifyPeer(game: Game, source: Color, connected: boolean, graceUntil?: number): void {
const peer = source === 'w' ? 'b' : 'w';
const slot = game.players[peer];
if (!slot?.socket) return;
send(slot.socket, {
type: 'peer-status',
color: source,
connected,
graceUntil,
});
}