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,32 @@
|
||||
{
|
||||
"name": "@blind-chess/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blind-chess/shared": "workspace:*",
|
||||
"@fastify/static": "^8.0.0",
|
||||
"@fastify/websocket": "^11.0.0",
|
||||
"chess.js": "^1.4.0",
|
||||
"fastify": "^5.2.0",
|
||||
"pino": "^9.5.0",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/ws": "^8.5.13",
|
||||
"pino-pretty": "^11.3.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { WebSocket } from 'ws';
|
||||
import Fastify from 'fastify';
|
||||
import websocketPlugin from '@fastify/websocket';
|
||||
import {
|
||||
activeGameCount,
|
||||
chooseSide,
|
||||
createGame,
|
||||
} from '../../src/games.js';
|
||||
import { attachSocket } from '../../src/ws.js';
|
||||
import { createGameSchema } from '../../src/validation.js';
|
||||
import type { ServerMessage } from '@blind-chess/shared';
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let baseUrl = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(websocketPlugin);
|
||||
app.get('/api/health', async () => ({ ok: true, activeGames: activeGameCount() }));
|
||||
app.post('/api/games', async (req, reply) => {
|
||||
const parsed = createGameSchema.safeParse(req.body);
|
||||
if (!parsed.success) { reply.code(400); return { error: 'malformed' }; }
|
||||
const creatorSide = chooseSide(parsed.data.side);
|
||||
const { game, creatorToken } = createGame({
|
||||
mode: parsed.data.mode,
|
||||
creatorSide,
|
||||
highlightingEnabled: parsed.data.highlightingEnabled,
|
||||
});
|
||||
return { gameId: game.id, creatorToken, creatorColor: creatorSide };
|
||||
});
|
||||
app.get('/ws', { websocket: true }, (socket) => {
|
||||
const raw = (socket as unknown as { socket?: unknown }).socket ?? socket;
|
||||
attachSocket(raw as never);
|
||||
});
|
||||
await app.listen({ port: 0, host: '127.0.0.1' });
|
||||
const addr = app.server.address();
|
||||
if (typeof addr !== 'object' || !addr) throw new Error('no address');
|
||||
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
interface Client {
|
||||
ws: WebSocket;
|
||||
msgs: ServerMessage[];
|
||||
waitFor: (pred: (m: ServerMessage) => boolean, timeoutMs?: number) => Promise<ServerMessage>;
|
||||
send: (m: unknown) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
function makeClient(gameId: string): Promise<Client> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(baseUrl.replace('http', 'ws') + `/ws?game=${gameId}`);
|
||||
const msgs: ServerMessage[] = [];
|
||||
const waiters: Array<{ pred: (m: ServerMessage) => boolean; resolve: (m: ServerMessage) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }> = [];
|
||||
ws.on('message', (data) => {
|
||||
const m = JSON.parse(data.toString()) as ServerMessage;
|
||||
msgs.push(m);
|
||||
for (const w of [...waiters]) {
|
||||
if (w.pred(m)) {
|
||||
clearTimeout(w.timer);
|
||||
waiters.splice(waiters.indexOf(w), 1);
|
||||
w.resolve(m);
|
||||
}
|
||||
}
|
||||
});
|
||||
ws.on('open', () => resolve({
|
||||
ws, msgs,
|
||||
waitFor: (pred, timeoutMs = 1500) => new Promise<ServerMessage>((res, rej) => {
|
||||
const existing = msgs.find(pred);
|
||||
if (existing) return res(existing);
|
||||
const timer = setTimeout(() => rej(new Error('waitFor timeout')), timeoutMs);
|
||||
waiters.push({ pred, resolve: res, reject: rej, timer });
|
||||
}),
|
||||
send: (m) => ws.send(JSON.stringify(m)),
|
||||
close: () => ws.close(),
|
||||
}));
|
||||
ws.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function createTestGame(mode: 'blind' | 'vanilla' = 'blind'): Promise<{ gameId: string; creatorToken: string; creatorColor: 'w' | 'b' }> {
|
||||
const res = await fetch(`${baseUrl}/api/games`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ mode, side: 'w', highlightingEnabled: false }),
|
||||
});
|
||||
return await res.json() as { gameId: string; creatorToken: string; creatorColor: 'w' | 'b' };
|
||||
}
|
||||
|
||||
describe('scripted game end-to-end', () => {
|
||||
it('two clients connect, opening exchange, blind view filtering', async () => {
|
||||
const { gameId, creatorToken } = await createTestGame('blind');
|
||||
const w = await makeClient(gameId);
|
||||
const b = await makeClient(gameId);
|
||||
|
||||
// White connects with token, black auto-claims.
|
||||
w.send({ type: 'hello', gameId, token: creatorToken });
|
||||
b.send({ type: 'hello', gameId, joinAs: 'auto' });
|
||||
|
||||
const wJoined = await w.waitFor((m) => m.type === 'joined');
|
||||
const bJoined = await b.waitFor((m) => m.type === 'joined');
|
||||
expect(wJoined.type === 'joined' && wJoined.you).toBe('w');
|
||||
expect(bJoined.type === 'joined' && bJoined.you).toBe('b');
|
||||
|
||||
// Blind view: white sees only its 16 pieces.
|
||||
if (wJoined.type !== 'joined') throw new Error('expected joined');
|
||||
expect(Object.keys(wJoined.view.pieces).length).toBe(16);
|
||||
for (const piece of Object.values(wJoined.view.pieces)) {
|
||||
expect(piece?.color).toBe('w');
|
||||
}
|
||||
|
||||
// White plays e2e4 in two messages: arm + commit.
|
||||
w.send({ type: 'commit', from: 'e2' });
|
||||
await w.waitFor((m) => m.type === 'update' && m.touchedPiece === 'e2');
|
||||
w.send({ type: 'commit', from: 'e2', to: 'e4' });
|
||||
|
||||
// Black should see a moderator announcement white_moved.
|
||||
const bMoved = await b.waitFor((m) =>
|
||||
m.type === 'update' && m.newAnnouncements.some((a) => a.text === 'white_moved'),
|
||||
);
|
||||
expect(bMoved.type).toBe('update');
|
||||
|
||||
w.close();
|
||||
b.close();
|
||||
});
|
||||
|
||||
it('not_your_turn error when black tries to move first', async () => {
|
||||
const { gameId, creatorToken } = await createTestGame('vanilla');
|
||||
const w = await makeClient(gameId);
|
||||
const b = await makeClient(gameId);
|
||||
w.send({ type: 'hello', gameId, token: creatorToken });
|
||||
b.send({ type: 'hello', gameId, joinAs: 'auto' });
|
||||
await b.waitFor((m) => m.type === 'joined');
|
||||
b.send({ type: 'commit', from: 'e7', to: 'e5' });
|
||||
const err = await b.waitFor((m) => m.type === 'error');
|
||||
expect(err.type === 'error' && err.code).toBe('not_your_turn');
|
||||
w.close();
|
||||
b.close();
|
||||
});
|
||||
|
||||
it('rejects malformed messages', async () => {
|
||||
const { gameId, creatorToken } = await createTestGame('blind');
|
||||
const w = await makeClient(gameId);
|
||||
w.send({ type: 'hello', gameId, token: creatorToken });
|
||||
await w.waitFor((m) => m.type === 'joined');
|
||||
w.send({ type: 'commit', from: 'zz', to: 'e4' });
|
||||
const err = await w.waitFor((m) => m.type === 'error');
|
||||
expect(err.type === 'error' && err.code).toBe('malformed');
|
||||
w.close();
|
||||
});
|
||||
|
||||
it('resign: opponent gets game_finished update', async () => {
|
||||
const { gameId, creatorToken } = await createTestGame('vanilla');
|
||||
const w = await makeClient(gameId);
|
||||
const b = await makeClient(gameId);
|
||||
w.send({ type: 'hello', gameId, token: creatorToken });
|
||||
b.send({ type: 'hello', gameId, joinAs: 'auto' });
|
||||
await b.waitFor((m) => m.type === 'joined');
|
||||
w.send({ type: 'resign' });
|
||||
const upd = await b.waitFor((m) =>
|
||||
m.type === 'update' && m.gameStatus === 'finished',
|
||||
);
|
||||
expect(upd.type === 'update' && upd.endReason).toBe('resign');
|
||||
expect(upd.type === 'update' && upd.winner).toBe('b');
|
||||
w.close();
|
||||
b.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { Chess } from 'chess.js';
|
||||
import { handleCommit } from '../../src/commit.js';
|
||||
import type { Game } from '../../src/state.js';
|
||||
import { RATE_LIMIT } from '../../src/state.js';
|
||||
|
||||
function makeGame(fen?: string): Game {
|
||||
const chess = fen ? new Chess(fen) : new Chess();
|
||||
return {
|
||||
id: 'testtest',
|
||||
mode: 'blind',
|
||||
highlightingEnabled: false,
|
||||
status: 'active',
|
||||
createdAt: Date.now(),
|
||||
chess,
|
||||
moveHistory: [],
|
||||
announcements: [],
|
||||
players: {
|
||||
w: { token: 'w'.repeat(24), socket: null, joinedAt: 0,
|
||||
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
|
||||
b: { token: 'b'.repeat(24), socket: null, joinedAt: 0,
|
||||
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
|
||||
},
|
||||
armed: null,
|
||||
drawOffer: null,
|
||||
disconnectAt: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('hierarchy decision table', () => {
|
||||
let game: Game;
|
||||
beforeEach(() => { game = makeGame(); });
|
||||
|
||||
it('row 1: no_such_piece — empty square', () => {
|
||||
const r = handleCommit(game, 'w', { from: 'e4' });
|
||||
expect(r.kind).toBe('announce');
|
||||
if (r.kind === 'announce') expect(r.announcements[0]!.text).toBe('no_such_piece');
|
||||
});
|
||||
|
||||
it('row 1b: no_such_piece — opponent piece', () => {
|
||||
const r = handleCommit(game, 'w', { from: 'e7' }); // black pawn
|
||||
expect(r.kind).toBe('announce');
|
||||
if (r.kind === 'announce') expect(r.announcements[0]!.text).toBe('no_such_piece');
|
||||
});
|
||||
|
||||
it('row 2: no_legal_moves — knight in starting position is OK; use a contrived fen', () => {
|
||||
// White knight surrounded by own pieces. Place a knight at b1 with own
|
||||
// pieces blocking a3, c3, d2.
|
||||
const g = makeGame('4k3/8/8/8/8/P1P5/3P4/1N2K3 w - - 0 1');
|
||||
// Knight at b1: jumps a3, c3, d2. All blocked by own pawns.
|
||||
const r = handleCommit(g, 'w', { from: 'b1' });
|
||||
expect(r.kind).toBe('announce');
|
||||
if (r.kind === 'announce') expect(r.announcements[0]!.text).toBe('no_legal_moves');
|
||||
});
|
||||
|
||||
it('row 3: wont_help — pinned bishop, not in check', () => {
|
||||
// White king on e1, white bishop on e2 pinned by black rook on e8.
|
||||
const g = makeGame('4r2k/8/8/8/8/8/4B3/4K3 w - - 0 1');
|
||||
const r = handleCommit(g, 'w', { from: 'e2' });
|
||||
expect(r.kind).toBe('announce');
|
||||
if (r.kind === 'announce') expect(r.announcements[0]!.text).toBe('wont_help');
|
||||
});
|
||||
|
||||
it('row 5: silent on first commit when piece has legal moves', () => {
|
||||
const r = handleCommit(game, 'w', { from: 'e2' });
|
||||
expect(r.kind).toBe('silent');
|
||||
expect(game.armed).toEqual({ color: 'w', from: 'e2' });
|
||||
});
|
||||
|
||||
it('row 6: applied move when legal commit completes', () => {
|
||||
const r = handleCommit(game, 'w', { from: 'e2', to: 'e4' });
|
||||
expect(r.kind).toBe('applied');
|
||||
expect(game.armed).toBeNull();
|
||||
expect(game.chess.history()).toContain('e4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('touch-move enforcement', () => {
|
||||
it('after silent arm, commit with different from yields must_move_touched_piece', () => {
|
||||
const game = makeGame();
|
||||
handleCommit(game, 'w', { from: 'e2' }); // arm
|
||||
const r = handleCommit(game, 'w', { from: 'd2', to: 'd4' });
|
||||
expect(r.kind).toBe('error');
|
||||
if (r.kind === 'error') expect(r.code).toBe('must_move_touched_piece');
|
||||
});
|
||||
|
||||
it('after silent arm, illegal destination returns illegal_move and KEEPS touch', () => {
|
||||
const game = makeGame();
|
||||
handleCommit(game, 'w', { from: 'e2' });
|
||||
const r = handleCommit(game, 'w', { from: 'e2', to: 'e5' }); // pawn can't go e2→e5
|
||||
expect(r.kind).toBe('announce');
|
||||
if (r.kind === 'announce') expect(r.announcements[0]!.text).toBe('illegal_move');
|
||||
expect(game.armed).toEqual({ color: 'w', from: 'e2' });
|
||||
});
|
||||
|
||||
it('not your turn returns error', () => {
|
||||
const game = makeGame();
|
||||
const r = handleCommit(game, 'b', { from: 'e7' });
|
||||
expect(r.kind).toBe('error');
|
||||
if (r.kind === 'error') expect(r.code).toBe('not_your_turn');
|
||||
});
|
||||
});
|
||||
|
||||
describe('promotion', () => {
|
||||
it('promotion required: pawn on 7th to 8th without promotion field', () => {
|
||||
const g = makeGame('7k/4P3/8/8/8/8/8/4K3 w - - 0 1');
|
||||
const r = handleCommit(g, 'w', { from: 'e7', to: 'e8' });
|
||||
expect(r.kind).toBe('error');
|
||||
if (r.kind === 'error') expect(r.code).toBe('promotion_required');
|
||||
});
|
||||
|
||||
it('promotion succeeds with field', () => {
|
||||
const g = makeGame('7k/4P3/8/8/8/8/8/4K3 w - - 0 1');
|
||||
const r = handleCommit(g, 'w', { from: 'e7', to: 'e8', promotion: 'q' });
|
||||
expect(r.kind).toBe('applied');
|
||||
if (r.kind === 'applied') {
|
||||
const promo = r.announcements.find((a) => a.text === 'white_promoted');
|
||||
expect(promo).toBeDefined();
|
||||
expect(promo?.payload?.promotedTo).toBe('q');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Chess } from 'chess.js';
|
||||
import { buildView, ownSquares } from '../../src/view.js';
|
||||
import type { Game } from '../../src/state.js';
|
||||
import { RATE_LIMIT } from '../../src/state.js';
|
||||
|
||||
function makeGame(mode: 'blind' | 'vanilla', fen?: string, status: 'active' | 'finished' = 'active'): Game {
|
||||
return {
|
||||
id: 'testtest',
|
||||
mode,
|
||||
highlightingEnabled: false,
|
||||
status,
|
||||
createdAt: Date.now(),
|
||||
chess: fen ? new Chess(fen) : new Chess(),
|
||||
moveHistory: [],
|
||||
announcements: [],
|
||||
players: {
|
||||
w: { token: 'w'.repeat(24), socket: null, joinedAt: 0, rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
|
||||
b: { token: 'b'.repeat(24), socket: null, joinedAt: 0, rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
|
||||
},
|
||||
armed: null,
|
||||
drawOffer: null,
|
||||
disconnectAt: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildView: security boundary', () => {
|
||||
it('blind/active white view contains zero black pieces', () => {
|
||||
const g = makeGame('blind');
|
||||
const view = buildView(g, 'w');
|
||||
for (const piece of Object.values(view.pieces)) {
|
||||
expect(piece?.color).toBe('w');
|
||||
}
|
||||
expect(Object.keys(view.pieces).length).toBe(16); // all 16 white pieces
|
||||
});
|
||||
|
||||
it('blind/active black view contains zero white pieces', () => {
|
||||
const g = makeGame('blind');
|
||||
const view = buildView(g, 'b');
|
||||
for (const piece of Object.values(view.pieces)) {
|
||||
expect(piece?.color).toBe('b');
|
||||
}
|
||||
expect(Object.keys(view.pieces).length).toBe(16);
|
||||
});
|
||||
|
||||
it('vanilla/active shows both colors', () => {
|
||||
const g = makeGame('vanilla');
|
||||
const view = buildView(g, 'w');
|
||||
expect(Object.keys(view.pieces).length).toBe(32);
|
||||
});
|
||||
|
||||
it('blind/finished reveals both colors (post-game review)', () => {
|
||||
const g = makeGame('blind', undefined, 'finished');
|
||||
const view = buildView(g, 'w');
|
||||
expect(Object.keys(view.pieces).length).toBe(32);
|
||||
});
|
||||
|
||||
it('blind: inCheck is null for non-actor (info leak prevention)', () => {
|
||||
// Black to move and is in check. White's view says null (it's not white's turn,
|
||||
// and revealing inCheck-status of opponent leaks info).
|
||||
const g = makeGame('blind', 'rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3');
|
||||
const view = buildView(g, 'b');
|
||||
// It's white's turn here. Black viewer is not the to-move side.
|
||||
expect(view.inCheck).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ownSquares', () => {
|
||||
it('starting position returns 16 own squares', () => {
|
||||
const g = makeGame('blind');
|
||||
expect(ownSquares(g, 'w').size).toBe(16);
|
||||
expect(ownSquares(g, 'b').size).toBe(16);
|
||||
});
|
||||
|
||||
it('contains only own-color squares', () => {
|
||||
const g = makeGame('blind');
|
||||
const wSet = ownSquares(g, 'w');
|
||||
expect(wSet.has('e2')).toBe(true);
|
||||
expect(wSet.has('e7')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"references": [{ "path": "../shared" }]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/commit.ts","./src/games.ts","./src/ratelimit.ts","./src/server.ts","./src/state.ts","./src/translator.ts","./src/validation.ts","./src/view.ts","./src/ws.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user