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
+102
View File
@@ -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);
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from './types.js';
export * from './moderator.js';
export * from './protocol.js';
export * from './geometric.js';
+28
View File
@@ -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;
}
+62
View File
@@ -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;
}
+63
View File
@@ -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;
}