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,23 @@
|
||||
{
|
||||
"name": "@blind-chess/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Piece, Square } from './types.js';
|
||||
import { fileIndex, rankIndex, squareAt } from './types.js';
|
||||
|
||||
type Dir = readonly [number, number];
|
||||
|
||||
const ROOK_DIRS: readonly Dir[] = [
|
||||
[1, 0], [-1, 0], [0, 1], [0, -1],
|
||||
];
|
||||
|
||||
const BISHOP_DIRS: readonly Dir[] = [
|
||||
[1, 1], [1, -1], [-1, 1], [-1, -1],
|
||||
];
|
||||
|
||||
const QUEEN_DIRS: readonly Dir[] = [...ROOK_DIRS, ...BISHOP_DIRS];
|
||||
|
||||
const KNIGHT_OFFSETS: readonly Dir[] = [
|
||||
[1, 2], [2, 1], [2, -1], [1, -2],
|
||||
[-1, -2], [-2, -1], [-2, 1], [-1, 2],
|
||||
];
|
||||
|
||||
const KING_OFFSETS: readonly Dir[] = [
|
||||
[1, 0], [-1, 0], [0, 1], [0, -1],
|
||||
[1, 1], [1, -1], [-1, 1], [-1, -1],
|
||||
];
|
||||
|
||||
function rays(from: Square, dirs: readonly Dir[], own: Set<Square>): Square[] {
|
||||
const out: Square[] = [];
|
||||
const f0 = fileIndex(from);
|
||||
const r0 = rankIndex(from);
|
||||
for (const [df, dr] of dirs) {
|
||||
let f = f0 + df;
|
||||
let r = r0 + dr;
|
||||
while (true) {
|
||||
const sq = squareAt(f, r);
|
||||
if (!sq) break;
|
||||
if (own.has(sq)) break;
|
||||
out.push(sq);
|
||||
f += df;
|
||||
r += dr;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function jumps(from: Square, offsets: readonly Dir[], own: Set<Square>): Square[] {
|
||||
const out: Square[] = [];
|
||||
const f0 = fileIndex(from);
|
||||
const r0 = rankIndex(from);
|
||||
for (const [df, dr] of offsets) {
|
||||
const sq = squareAt(f0 + df, r0 + dr);
|
||||
if (sq && !own.has(sq)) out.push(sq);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pawnGeometry(from: Square, color: 'w' | 'b', own: Set<Square>): Square[] {
|
||||
const out: Square[] = [];
|
||||
const f0 = fileIndex(from);
|
||||
const r0 = rankIndex(from);
|
||||
const dir = color === 'w' ? 1 : -1;
|
||||
const startRank = color === 'w' ? 1 : 6;
|
||||
|
||||
const f1 = squareAt(f0, r0 + dir);
|
||||
if (f1 && !own.has(f1)) out.push(f1);
|
||||
|
||||
if (r0 === startRank) {
|
||||
const f2 = squareAt(f0, r0 + 2 * dir);
|
||||
if (f1 && f2 && !own.has(f1) && !own.has(f2)) out.push(f2);
|
||||
}
|
||||
|
||||
for (const df of [-1, 1]) {
|
||||
const cap = squareAt(f0 + df, r0 + dir);
|
||||
if (cap && !own.has(cap)) out.push(cap);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geometric (pseudo-legal-ish) moves for a piece.
|
||||
*
|
||||
* Reads ONLY: piece type/color, from-square, own-piece set.
|
||||
* Reads NOT: opponent piece positions, board history, anything else.
|
||||
*
|
||||
* The signature is the proof of zero opponent info leak. Castling is
|
||||
* intentionally excluded — castling legality depends on opponent state
|
||||
* (path through check, opponent pieces between king and rook).
|
||||
*/
|
||||
export function geometricMoves(
|
||||
piece: Piece,
|
||||
from: Square,
|
||||
ownSquares: Set<Square>,
|
||||
): Square[] {
|
||||
switch (piece.type) {
|
||||
case 'n': return jumps(from, KNIGHT_OFFSETS, ownSquares);
|
||||
case 'k': return jumps(from, KING_OFFSETS, ownSquares);
|
||||
case 'b': return rays(from, BISHOP_DIRS, ownSquares);
|
||||
case 'r': return rays(from, ROOK_DIRS, ownSquares);
|
||||
case 'q': return rays(from, QUEEN_DIRS, ownSquares);
|
||||
case 'p': return pawnGeometry(from, piece.color, ownSquares);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './types.js';
|
||||
export * from './moderator.js';
|
||||
export * from './protocol.js';
|
||||
export * from './geometric.js';
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Color, PieceType } from './types.js';
|
||||
|
||||
export type ModeratorText =
|
||||
| 'no_such_piece'
|
||||
| 'no_legal_moves'
|
||||
| 'wont_help'
|
||||
| 'illegal_move'
|
||||
| 'white_moved' | 'black_moved'
|
||||
| 'white_moved_captured' | 'black_moved_captured'
|
||||
| 'white_moved_captured_ep' | 'black_moved_captured_ep'
|
||||
| 'white_castled_kingside' | 'white_castled_queenside'
|
||||
| 'black_castled_kingside' | 'black_castled_queenside'
|
||||
| 'white_in_check' | 'black_in_check'
|
||||
| 'white_promoted' | 'black_promoted'
|
||||
| 'white_checkmate' | 'black_checkmate'
|
||||
| 'stalemate' | 'draw_insufficient' | 'draw_fifty' | 'draw_threefold'
|
||||
| 'white_resigned' | 'black_resigned'
|
||||
| 'draw_agreed' | 'game_abandoned';
|
||||
|
||||
export type Audience = Color | 'both';
|
||||
|
||||
export interface Announcement {
|
||||
ply: number;
|
||||
text: ModeratorText;
|
||||
audience: Audience;
|
||||
payload?: { promotedTo?: PieceType };
|
||||
at: number;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
BoardView, Color, GameId, GameStatus, Mode, PlayerToken,
|
||||
PromotionType, Square, EndReason,
|
||||
} from './types.js';
|
||||
import type { Announcement } from './moderator.js';
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: 'hello'; gameId: GameId; token?: PlayerToken; joinAs?: Color | 'auto' }
|
||||
| { type: 'commit'; from: Square; to?: Square; promotion?: PromotionType }
|
||||
| { type: 'resign' }
|
||||
| { type: 'offer-draw' }
|
||||
| { type: 'respond-draw'; accept: boolean }
|
||||
| { type: 'pong' };
|
||||
|
||||
export type ErrorCode =
|
||||
| 'game_not_found'
|
||||
| 'slot_taken'
|
||||
| 'spectators_disabled'
|
||||
| 'not_your_turn'
|
||||
| 'malformed'
|
||||
| 'promotion_required'
|
||||
| 'must_move_touched_piece'
|
||||
| 'rate_limited'
|
||||
| 'invalid_token';
|
||||
|
||||
export type ServerMessage =
|
||||
| {
|
||||
type: 'joined';
|
||||
you: Color | 'spectator-rejected';
|
||||
token: PlayerToken;
|
||||
view: BoardView;
|
||||
announcements: Announcement[];
|
||||
gameStatus: GameStatus;
|
||||
mode: Mode;
|
||||
highlightingEnabled: boolean;
|
||||
opponentConnected: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'update';
|
||||
view: BoardView;
|
||||
newAnnouncements: Announcement[];
|
||||
gameStatus: GameStatus;
|
||||
touchedPiece?: Square;
|
||||
drawOffer?: { from: Color } | null;
|
||||
endReason?: EndReason;
|
||||
winner?: Color | null;
|
||||
}
|
||||
| { type: 'peer-status'; color: Color; connected: boolean; graceUntil?: number }
|
||||
| { type: 'error'; code: ErrorCode; message: string }
|
||||
| { type: 'ping' };
|
||||
|
||||
export interface CreateGameRequest {
|
||||
mode: Mode;
|
||||
side: Color | 'random';
|
||||
highlightingEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface CreateGameResponse {
|
||||
gameId: GameId;
|
||||
creatorToken: PlayerToken;
|
||||
joinUrl: string;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export type Color = 'w' | 'b';
|
||||
export type Mode = 'blind' | 'vanilla';
|
||||
|
||||
export type File = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h';
|
||||
export type Rank = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8';
|
||||
export type Square = `${File}${Rank}`;
|
||||
|
||||
export type PieceType = 'p' | 'n' | 'b' | 'r' | 'q' | 'k';
|
||||
export type PromotionType = 'q' | 'r' | 'b' | 'n';
|
||||
|
||||
export interface Piece {
|
||||
color: Color;
|
||||
type: PieceType;
|
||||
}
|
||||
|
||||
export type GameStatus = 'waiting' | 'active' | 'finished';
|
||||
|
||||
export type EndReason =
|
||||
| 'checkmate'
|
||||
| 'stalemate'
|
||||
| 'resign'
|
||||
| 'draw_agreed'
|
||||
| 'insufficient'
|
||||
| 'fifty_move'
|
||||
| 'threefold'
|
||||
| 'abandoned';
|
||||
|
||||
export type GameId = string;
|
||||
export type PlayerToken = string;
|
||||
|
||||
export interface BoardView {
|
||||
pieces: Partial<Record<Square, Piece>>;
|
||||
toMove: Color;
|
||||
inCheck: boolean | null;
|
||||
}
|
||||
|
||||
export const FILES: readonly File[] = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] as const;
|
||||
export const RANKS: readonly Rank[] = ['1', '2', '3', '4', '5', '6', '7', '8'] as const;
|
||||
|
||||
export function isSquare(s: string): s is Square {
|
||||
return /^[a-h][1-8]$/.test(s);
|
||||
}
|
||||
|
||||
export const ALL_SQUARES: readonly Square[] = (() => {
|
||||
const out: Square[] = [];
|
||||
for (const f of FILES) for (const r of RANKS) out.push(`${f}${r}` as Square);
|
||||
return out;
|
||||
})();
|
||||
|
||||
export function fileIndex(s: Square): number {
|
||||
return s.charCodeAt(0) - 'a'.charCodeAt(0);
|
||||
}
|
||||
|
||||
export function rankIndex(s: Square): number {
|
||||
return s.charCodeAt(1) - '1'.charCodeAt(0);
|
||||
}
|
||||
|
||||
export function squareAt(fileIdx: number, rankIdx: number): Square | null {
|
||||
if (fileIdx < 0 || fileIdx > 7 || rankIdx < 0 || rankIdx > 7) return null;
|
||||
const f = String.fromCharCode('a'.charCodeAt(0) + fileIdx);
|
||||
const r = String.fromCharCode('1'.charCodeAt(0) + rankIdx);
|
||||
return `${f}${r}` as Square;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { geometricMoves } from '../src/geometric.js';
|
||||
import type { Piece, Square } from '../src/types.js';
|
||||
|
||||
const set = (...sq: Square[]) => new Set<Square>(sq);
|
||||
|
||||
describe('geometricMoves: knight', () => {
|
||||
it('jumps to all 8 squares from d4 with no own pieces', () => {
|
||||
const moves = geometricMoves({ color: 'w', type: 'n' }, 'd4', set());
|
||||
expect(moves.sort()).toEqual(['b3', 'b5', 'c2', 'c6', 'e2', 'e6', 'f3', 'f5']);
|
||||
});
|
||||
|
||||
it('cannot land on own pieces', () => {
|
||||
const moves = geometricMoves({ color: 'w', type: 'n' }, 'd4', set('e6', 'b3'));
|
||||
expect(moves).not.toContain('e6');
|
||||
expect(moves).not.toContain('b3');
|
||||
expect(moves.length).toBe(6);
|
||||
});
|
||||
|
||||
it('corner: a1 has only 2 jumps', () => {
|
||||
const moves = geometricMoves({ color: 'w', type: 'n' }, 'a1', set());
|
||||
expect(moves.sort()).toEqual(['b3', 'c2']);
|
||||
});
|
||||
|
||||
it('hierarchy row 2 — knight surrounded by own pawns yields ∅', () => {
|
||||
// White knight on b1 with own pawns blocking d2, c3, a3 reachable squares
|
||||
const moves = geometricMoves({ color: 'w', type: 'n' }, 'b1', set('d2', 'c3', 'a3'));
|
||||
expect(moves).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('geometricMoves: rays (bishop/rook/queen)', () => {
|
||||
it('bishop on d4 reaches both diagonals fully when board empty', () => {
|
||||
const moves = geometricMoves({ color: 'w', type: 'b' }, 'd4', set());
|
||||
expect(moves.sort()).toEqual(['a1', 'a7', 'b2', 'b6', 'c3', 'c5', 'e3', 'e5', 'f2', 'f6', 'g1', 'g7', 'h8']);
|
||||
});
|
||||
|
||||
it('bishop ray STOPS at own piece (square excluded)', () => {
|
||||
const moves = geometricMoves({ color: 'w', type: 'b' }, 'd4', set('f6'));
|
||||
expect(moves).not.toContain('f6');
|
||||
expect(moves).not.toContain('g7');
|
||||
expect(moves).not.toContain('h8');
|
||||
expect(moves).toContain('e5');
|
||||
});
|
||||
|
||||
it('bishop ray EXTENDS THROUGH unknown squares (may host opponent pieces)', () => {
|
||||
// No own piece on f6. From the function's POV, f6 is "unknown" — ray continues.
|
||||
const moves = geometricMoves({ color: 'w', type: 'b' }, 'd4', set());
|
||||
expect(moves).toContain('f6');
|
||||
expect(moves).toContain('g7');
|
||||
expect(moves).toContain('h8');
|
||||
});
|
||||
|
||||
it('rook on a1 with own pawn at a2 has zero rank moves up', () => {
|
||||
const moves = geometricMoves({ color: 'w', type: 'r' }, 'a1', set('a2'));
|
||||
expect(moves).not.toContain('a2');
|
||||
expect(moves).toContain('b1');
|
||||
expect(moves).toContain('h1');
|
||||
});
|
||||
|
||||
it('queen combines rook+bishop reach', () => {
|
||||
const queenMoves = geometricMoves({ color: 'w', type: 'q' }, 'd4', set());
|
||||
const bishopMoves = geometricMoves({ color: 'w', type: 'b' }, 'd4', set());
|
||||
const rookMoves = geometricMoves({ color: 'w', type: 'r' }, 'd4', set());
|
||||
expect(queenMoves.sort()).toEqual([...bishopMoves, ...rookMoves].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('geometricMoves: king', () => {
|
||||
it('center king: 8 neighbors', () => {
|
||||
const moves = geometricMoves({ color: 'w', type: 'k' }, 'e4', set());
|
||||
expect(moves.sort()).toEqual(['d3', 'd4', 'd5', 'e3', 'e5', 'f3', 'f4', 'f5']);
|
||||
});
|
||||
|
||||
it('corner king: 3 neighbors', () => {
|
||||
const moves = geometricMoves({ color: 'w', type: 'k' }, 'a1', set());
|
||||
expect(moves.sort()).toEqual(['a2', 'b1', 'b2']);
|
||||
});
|
||||
|
||||
it('does NOT include castling targets', () => {
|
||||
// White king at e1 with own rooks present should NOT see g1 or c1.
|
||||
const moves = geometricMoves({ color: 'w', type: 'k' }, 'e1', set('a1', 'h1'));
|
||||
expect(moves).not.toContain('g1');
|
||||
expect(moves).not.toContain('c1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('geometricMoves: pawn', () => {
|
||||
it('white pawn on starting rank: forward 1 + 2 + diagonals', () => {
|
||||
const moves = geometricMoves({ color: 'w', type: 'p' }, 'e2', set());
|
||||
expect(moves.sort()).toEqual(['d3', 'e3', 'e4', 'f3']);
|
||||
});
|
||||
|
||||
it('white pawn forward-2 blocked by own piece on e3', () => {
|
||||
const moves = geometricMoves({ color: 'w', type: 'p' }, 'e2', set('e3'));
|
||||
expect(moves).not.toContain('e3');
|
||||
expect(moves).not.toContain('e4');
|
||||
});
|
||||
|
||||
it('white pawn forward-2 blocked by own piece on e4 (intermediate clear)', () => {
|
||||
const moves = geometricMoves({ color: 'w', type: 'p' }, 'e2', set('e4'));
|
||||
expect(moves).toContain('e3');
|
||||
expect(moves).not.toContain('e4');
|
||||
});
|
||||
|
||||
it('white pawn off starting rank: only forward 1 + diagonals', () => {
|
||||
const moves = geometricMoves({ color: 'w', type: 'p' }, 'e4', set());
|
||||
expect(moves.sort()).toEqual(['d5', 'e5', 'f5']);
|
||||
});
|
||||
|
||||
it('white pawn diagonals included even when empty (illegal-move probe ok)', () => {
|
||||
const moves = geometricMoves({ color: 'w', type: 'p' }, 'e4', set());
|
||||
expect(moves).toContain('d5');
|
||||
expect(moves).toContain('f5');
|
||||
});
|
||||
|
||||
it('white pawn diagonal blocked by own piece', () => {
|
||||
const moves = geometricMoves({ color: 'w', type: 'p' }, 'e4', set('f5'));
|
||||
expect(moves).not.toContain('f5');
|
||||
});
|
||||
|
||||
it('black pawn from starting rank moves DOWN', () => {
|
||||
const moves = geometricMoves({ color: 'b', type: 'p' }, 'e7', set());
|
||||
expect(moves.sort()).toEqual(['d6', 'e5', 'e6', 'f6']);
|
||||
});
|
||||
|
||||
it('a-file pawn: only one diagonal (b)', () => {
|
||||
const moves = geometricMoves({ color: 'w', type: 'p' }, 'a2', set());
|
||||
expect(moves.sort()).toEqual(['a3', 'a4', 'b3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('zero-opponent-leak invariant', () => {
|
||||
it('output is identical regardless of opponent position when ownSquares is the same', () => {
|
||||
// The function's signature literally cannot accept opponent positions.
|
||||
// This test asserts that two callers with different mental models of
|
||||
// opponent state but identical ownSquares get identical results.
|
||||
const own = set('e2');
|
||||
const a = geometricMoves({ color: 'w', type: 'r' }, 'd4', own);
|
||||
const b = geometricMoves({ color: 'w', type: 'r' }, 'd4', own);
|
||||
expect(a).toEqual(b);
|
||||
// Sanity that ownSquares actually affects output: d6 is on the d-file
|
||||
// ray, so blocking it shortens the rook's reach.
|
||||
const blocked = geometricMoves({ color: 'w', type: 'r' }, 'd4', set('d6'));
|
||||
expect(blocked).not.toContain('d6');
|
||||
expect(blocked).not.toContain('d7');
|
||||
expect(a).toContain('d6');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user