|
|
|
@@ -0,0 +1,604 @@
|
|
|
|
|
# blind_chess — Design
|
|
|
|
|
|
|
|
|
|
**Date:** 2026-04-28
|
|
|
|
|
**Status:** approved (brainstorming session, 2026-04-28)
|
|
|
|
|
**Source:** `IDEA.md`, brainstorm session 2026-04-28
|
|
|
|
|
|
|
|
|
|
## Summary
|
|
|
|
|
|
|
|
|
|
Web-based two-player chess where each player sees only their own pieces. The server acts as moderator: it holds the canonical board, validates moves, and announces a small fixed vocabulary of events (illegal move, won't help you, captured, in check, castled, etc.). Two game modes from day one: **vanilla** (full reveal, normal chess) and **blind** (the variation). Modes share one engine; "mode" is a per-player view filter, not a different game.
|
|
|
|
|
|
|
|
|
|
## Background
|
|
|
|
|
|
|
|
|
|
The physical variation: two players play chess battleship-style, separated so neither sees the opponent's board. A human moderator sits between them, syncs both player boards against a master board, and announces a strictly-ordered vocabulary of events ("That piece no longer exists" → "The piece has no legal moves" → "Moving that piece will not help you" → "Illegal move" → success announcements). It's tactically interesting but cumbersome: it requires three people and a lot of physical setup.
|
|
|
|
|
|
|
|
|
|
This project digitizes the moderator. The physical separation becomes WebSocket transport; the master board becomes a server-side `chess.js` instance; the announcement vocabulary becomes a typed enum.
|
|
|
|
|
|
|
|
|
|
## Goals
|
|
|
|
|
|
|
|
|
|
- A working two-player game playable from any device with a browser. Mobile-first.
|
|
|
|
|
- Both vanilla and blind modes shipped together.
|
|
|
|
|
- Faithful implementation of the touch-move rule (once you commit to a piece, you must move it unless the moderator releases you).
|
|
|
|
|
- Moderator vocabulary captured as an enum, decoupled from display strings, ready for i18n or theming later.
|
|
|
|
|
- Hosted at `chess.sethpc.xyz` on the homelab, behind Caddy, in a fresh LXC.
|
|
|
|
|
|
|
|
|
|
## Non-goals (MVP)
|
|
|
|
|
|
|
|
|
|
- No authentication beyond the hashed game link. Anyone with the link can claim the open slot.
|
|
|
|
|
- No spectator mode. The first two arrivals are the players; further requests are rejected.
|
|
|
|
|
- No persistent game history, no replay, no PGN export. Server restart loses active games.
|
|
|
|
|
- No clocks. Untimed correspondence-style play.
|
|
|
|
|
- No matchmaking, ratings, or public lobby.
|
|
|
|
|
- No client-side AI / hint generation. The whole game is human vs. human.
|
|
|
|
|
|
|
|
|
|
## Decisions (from brainstorming)
|
|
|
|
|
|
|
|
|
|
| # | Decision | Why |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| 1 | Both modes shipped day-one; mode = view filter on a shared engine | Single engine, marginal extra UI |
|
|
|
|
|
| 2 | Node 22 + TypeScript, Fastify + `ws`, Svelte + Vite | Single-language stack; Svelte's reactive stores fit board state; Fastify is right-sized |
|
|
|
|
|
| 3 | `chess.js` for rules + custom `geometricMoves` helper for pseudo-legal | chess.js doesn't expose pseudo-legal; ~80 LoC helper, pure function, testable in isolation |
|
|
|
|
|
| 4 | "Moving that piece will not help you" = pseudo-legal moves exist but all leave you in check (pin OR unresolved check) | Hierarchy refinement, deterministic from chess.js outputs |
|
|
|
|
|
| 5 | Touch-move flow: tap arms (reversible, client-only), drag-start or destination-click commits ("touches") | Faithful to physical game; matches lichess/chess.com muscle memory; arming is purely visual so it's safe to be reversible |
|
|
|
|
|
| 6 | Game creation: creator picks side (default random); single-use link; no spectators; link dies with game | Lowest-friction default that matches IDEA.md's intent |
|
|
|
|
|
| 7 | Reconnect via opaque `PlayerToken` in `localStorage`, 5-minute grace window | Generous enough for phone hiccups, short enough that abandoned games end |
|
|
|
|
|
| 8 | Highlighting (when ON, blind mode) shows all geometrically-reachable squares minus off-board minus own-piece. Rays extend through unseen opponent pieces. Zero opponent info leak. | Highlights become a function of `(piece type, position, own-piece set)` — provably no opponent input |
|
|
|
|
|
| 9 | Highlighting (when ON, vanilla mode) shows engine-truth: legal squares with empty/capture distinction | No hiding in vanilla; full reveal is the point |
|
|
|
|
|
| 10 | In-memory only; `Map<gameId, Game>` is the database | Simplest possible; SQLite later if crash recovery becomes desired |
|
|
|
|
|
| 11 | New LXC on node-241 behind Caddy CT 600; systemd-managed Node service on port 3000 | Clean isolation; matches existing patterns |
|
|
|
|
|
| 12 | Auth = the hashed link itself; no Authentik gate | Friction-minimal; appropriate for a casual game |
|
|
|
|
|
| 13 | Pawn promotion via modal (Q/R/B/N); moderator announces the new piece type | Promotion is tactically significant — public info |
|
|
|
|
|
| 14 | Auto-detect all draws (stalemate, insufficient material, threefold, 50-move) | Casual-play friendly; no "claim" UI needed |
|
|
|
|
|
| 15 | Resign + draw-offer/accept flow standard chess UX | Conventional |
|
|
|
|
|
| 16 | Janitor prunes finished games after 30 min idle; active games never expire | Memory hygiene without losing post-game review window |
|
|
|
|
|
|
|
|
|
|
Deferred / rejected:
|
|
|
|
|
|
|
|
|
|
- Tactical-advice interpretation of "won't help you" (engine evaluation deciding moves are bad) — too subjective, anti-fun.
|
|
|
|
|
- Spectator mode for MVP — defer until there's demand.
|
|
|
|
|
- SQLite persistence — defer until in-memory loss becomes painful.
|
|
|
|
|
- E2E browser tests — protocol tests cover the same ground.
|
|
|
|
|
|
|
|
|
|
## Architecture
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
Internet
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
┌─────────────┐
|
|
|
|
|
│ Caddy │ CT 600 (existing)
|
|
|
|
|
│ │ TLS, *.sethpc.xyz
|
|
|
|
|
│ 192.168.0.185│
|
|
|
|
|
└──────┬──────┘
|
|
|
|
|
│ chess.sethpc.xyz → reverse_proxy
|
|
|
|
|
▼
|
|
|
|
|
┌────────────────────────┐
|
|
|
|
|
│ blind_chess CT (new) │ new LXC on node-241
|
|
|
|
|
│ Debian, ~512MB RAM │
|
|
|
|
|
│ │
|
|
|
|
|
│ ┌──────────────────┐ │
|
|
|
|
|
│ │ blind-chess.svc │ │ systemd unit
|
|
|
|
|
│ │ Fastify + ws │ │ Node 22 LTS
|
|
|
|
|
│ │ port 3000 │ │
|
|
|
|
|
│ │ │ │ / → static client
|
|
|
|
|
│ │ │ │ /ws → WebSocket upgrade
|
|
|
|
|
│ │ │ │ /api/games → POST creates game
|
|
|
|
|
│ │ │ │ /api/health → uptime probe
|
|
|
|
|
│ └──────────────────┘ │
|
|
|
|
|
└────────────────────────┘
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
Map<gameId, Game> (memory only)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**Process & port summary:** one Node process, one port (3000), one systemd unit. Caddy already terminates TLS, handles HTTP/3, and upgrades WebSockets — no TLS in our service.
|
|
|
|
|
|
|
|
|
|
**No DB, no Redis.** Restart drops active games. Acceptable for MVP scope.
|
|
|
|
|
|
|
|
|
|
### Repo layout
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
blind_chess/
|
|
|
|
|
├── packages/
|
|
|
|
|
│ ├── server/ # Fastify + ws, chess.js, view filter, FSM
|
|
|
|
|
│ │ ├── src/
|
|
|
|
|
│ │ │ ├── server.ts # bootstrap, routes
|
|
|
|
|
│ │ │ ├── ws.ts # WS upgrade, message dispatch
|
|
|
|
|
│ │ │ ├── games.ts # in-memory registry, gameId generation
|
|
|
|
|
│ │ │ ├── commit.ts # commit handler FSM (§5.1)
|
|
|
|
|
│ │ │ ├── translator.ts # chess.js → ModeratorText enum (§5.2)
|
|
|
|
|
│ │ │ ├── view.ts # buildView, the security boundary
|
|
|
|
|
│ │ │ ├── validation.ts # zod schemas, rate limiting
|
|
|
|
|
│ │ │ └── disconnect.ts # grace-window timers, abandonment
|
|
|
|
|
│ │ ├── test/
|
|
|
|
|
│ │ │ ├── unit/ # geometric, translator, view, validation
|
|
|
|
|
│ │ │ └── integration/ # real WS, scripted games
|
|
|
|
|
│ │ └── package.json
|
|
|
|
|
│ ├── client/ # Svelte + Vite
|
|
|
|
|
│ │ ├── src/
|
|
|
|
|
│ │ │ ├── routes/ # SvelteKit-style or vanilla
|
|
|
|
|
│ │ │ │ ├── +page.svelte # landing (mode selector + create)
|
|
|
|
|
│ │ │ │ ├── g/[gameId]/+page.svelte # game view
|
|
|
|
|
│ │ │ ├── lib/
|
|
|
|
|
│ │ │ │ ├── Board.svelte
|
|
|
|
|
│ │ │ │ ├── ModeratorPanel.svelte
|
|
|
|
|
│ │ │ │ ├── CapturedTray.svelte
|
|
|
|
|
│ │ │ │ ├── DrawDialog.svelte
|
|
|
|
|
│ │ │ │ ├── PromotionDialog.svelte
|
|
|
|
|
│ │ │ │ ├── stores/game.ts # WS connection + state
|
|
|
|
|
│ │ │ │ └── geometric.ts # client-side highlighting (imports from shared)
|
|
|
|
|
│ │ └── package.json
|
|
|
|
|
│ └── shared/ # ProtocolTypes, ModeratorText enum, Square, etc.
|
|
|
|
|
│ ├── src/
|
|
|
|
|
│ │ ├── protocol.ts
|
|
|
|
|
│ │ ├── moderator.ts
|
|
|
|
|
│ │ ├── geometric.ts # the pure helper, used both sides
|
|
|
|
|
│ │ └── types.ts
|
|
|
|
|
│ └── package.json
|
|
|
|
|
├── pnpm-workspace.yaml
|
|
|
|
|
├── tsconfig.base.json
|
|
|
|
|
├── package.json # root scripts
|
|
|
|
|
├── docs/
|
|
|
|
|
│ └── superpowers/specs/
|
|
|
|
|
│ └── 2026-04-28-blind-chess-design.md
|
|
|
|
|
└── deploy/
|
|
|
|
|
├── blind-chess.service # systemd unit
|
|
|
|
|
└── Caddyfile.snippet # additions for CT 600
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Build & deploy (manual for MVP)
|
|
|
|
|
|
|
|
|
|
1. `pnpm -r build` on dev box → `packages/server/dist/` + `packages/client/dist/`
|
|
|
|
|
2. `rsync` server `dist/` and client `dist/` to the new CT
|
|
|
|
|
3. `systemctl restart blind-chess`
|
|
|
|
|
4. Add Caddyfile snippet to CT 600, `caddy reload`
|
|
|
|
|
|
|
|
|
|
CI/automation can come later.
|
|
|
|
|
|
|
|
|
|
## Data model
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
// packages/shared/src/types.ts (excerpt)
|
|
|
|
|
|
|
|
|
|
type Color = 'w' | 'b';
|
|
|
|
|
type Mode = 'blind' | 'vanilla';
|
|
|
|
|
type Square = `${'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'}${'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'}`;
|
|
|
|
|
type PieceType = 'p' | 'n' | 'b' | 'r' | 'q' | 'k';
|
|
|
|
|
type Piece = { color: Color; type: PieceType };
|
|
|
|
|
|
|
|
|
|
type GameStatus = 'waiting' | 'active' | 'finished';
|
|
|
|
|
type EndReason = 'checkmate' | 'stalemate' | 'resign' | 'draw_agreed'
|
|
|
|
|
| 'insufficient' | 'fifty_move' | 'threefold' | 'abandoned';
|
|
|
|
|
|
|
|
|
|
type GameId = string; // ^[a-z0-9]{8}$ — 32 bits, link-bound
|
|
|
|
|
type PlayerToken = string; // ^[a-z0-9]{24}$ — 144 bits, server-issued
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
// packages/server/src/state.ts
|
|
|
|
|
|
|
|
|
|
interface Game {
|
|
|
|
|
id: GameId;
|
|
|
|
|
mode: Mode;
|
|
|
|
|
highlightingEnabled: boolean;
|
|
|
|
|
status: GameStatus;
|
|
|
|
|
createdAt: number;
|
|
|
|
|
finishedAt?: number;
|
|
|
|
|
endReason?: EndReason;
|
|
|
|
|
winner?: Color;
|
|
|
|
|
|
|
|
|
|
chess: ChessJsInstance; // canonical board
|
|
|
|
|
moveHistory: MoveRecord[];
|
|
|
|
|
announcements: Announcement[]; // persistent log; survives until game pruned
|
|
|
|
|
|
|
|
|
|
players: { w: PlayerSlot | null; b: PlayerSlot | null };
|
|
|
|
|
|
|
|
|
|
armed: { color: Color; from: Square } | null; // touch-move state
|
|
|
|
|
drawOffer: { from: Color; at: number } | null;
|
|
|
|
|
disconnectAt: { w?: number; b?: number }; // grace window timestamps
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface PlayerSlot {
|
|
|
|
|
token: PlayerToken;
|
|
|
|
|
socket: WebSocket | null; // null while disconnected
|
|
|
|
|
joinedAt: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MoveRecord {
|
|
|
|
|
ply: number;
|
|
|
|
|
by: Color;
|
|
|
|
|
from: Square; to: Square;
|
|
|
|
|
san: string;
|
|
|
|
|
capturedPieceType?: PieceType;
|
|
|
|
|
promotion?: 'q'|'r'|'b'|'n';
|
|
|
|
|
flags: { castle?: 'k'|'q'; enPassant?: boolean; check?: boolean; mate?: boolean };
|
|
|
|
|
at: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Announcement {
|
|
|
|
|
ply: number;
|
|
|
|
|
text: ModeratorText;
|
|
|
|
|
audience: Color | 'both';
|
|
|
|
|
payload?: { promotedTo?: PieceType }; // only set when text is *_promoted
|
|
|
|
|
at: number;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Moderator vocabulary (enum)
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
type ModeratorText =
|
|
|
|
|
| 'no_such_piece' // "That piece no longer exists"
|
|
|
|
|
| 'no_legal_moves' // pseudo-legal = ∅
|
|
|
|
|
| 'wont_help' // pseudo-legal ≠ ∅, legal = ∅
|
|
|
|
|
| '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';
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Display strings live in `packages/client/src/lib/moderator-strings.ts`. Tests assert enum values, not display.
|
|
|
|
|
|
|
|
|
|
### Per-player view filter
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
interface BoardView {
|
|
|
|
|
pieces: { [square: Square]: Piece };
|
|
|
|
|
toMove: Color;
|
|
|
|
|
inCheck: boolean | null; // null when unknown to this viewer
|
|
|
|
|
// 'legalMoves' is intentionally absent. The server never sends "your candidate
|
|
|
|
|
// squares" — the client computes geometric highlights locally from piece type,
|
|
|
|
|
// which leaks zero opponent info.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildView(game: Game, viewer: Color): BoardView {
|
|
|
|
|
if (game.mode === 'vanilla' || game.status === 'finished') {
|
|
|
|
|
return fullBoardView(game, viewer);
|
|
|
|
|
}
|
|
|
|
|
return ownPiecesOnly(game.chess, viewer);
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
This is the **single security boundary** for opponent information. Any code path that emits board state to a client must go through `buildView`. Snapshot tests assert that blind-mode views for white never contain `'b'` pieces (and vice versa).
|
|
|
|
|
|
|
|
|
|
## WebSocket protocol
|
|
|
|
|
|
|
|
|
|
Two transports:
|
|
|
|
|
|
|
|
|
|
- `POST /api/games` → REST, creates a game, returns `{ gameId, creatorToken, joinUrl }`
|
|
|
|
|
- `wss://chess.sethpc.xyz/ws?game=<gameId>` → WebSocket, all in-game traffic
|
|
|
|
|
|
|
|
|
|
### Client → Server
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
type ClientMessage =
|
|
|
|
|
| { type: 'hello'; gameId: GameId; token?: PlayerToken; joinAs?: Color | 'auto' }
|
|
|
|
|
| { type: 'commit'; from: Square; to?: Square; promotion?: 'q'|'r'|'b'|'n' }
|
|
|
|
|
| { type: 'resign' }
|
|
|
|
|
| { type: 'offer-draw' }
|
|
|
|
|
| { type: 'respond-draw'; accept: boolean }
|
|
|
|
|
| { type: 'pong' };
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
- `hello` reconnects (with `token`) or claims the open slot (with `joinAs`).
|
|
|
|
|
- `commit` is the *only* move-related message. `to: undefined` = drag-start (commit to piece, no destination yet); `to: Square` = drag-drop or destination-click. `promotion` required when the move is a pawn reaching the back rank.
|
|
|
|
|
|
|
|
|
|
### Server → Client
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
type ServerMessage =
|
|
|
|
|
| { type: 'joined'; you: Color | 'spectator-rejected'; token: PlayerToken;
|
|
|
|
|
view: BoardView; announcements: Announcement[];
|
|
|
|
|
gameStatus: GameStatus; mode: Mode; highlightingEnabled: boolean }
|
|
|
|
|
| { type: 'update'; view: BoardView; newAnnouncements: Announcement[];
|
|
|
|
|
gameStatus: GameStatus; touchedPiece?: Square;
|
|
|
|
|
drawOffer?: { from: Color } | null;
|
|
|
|
|
endReason?: EndReason; winner?: Color }
|
|
|
|
|
| { type: 'peer-status'; color: Color; connected: boolean; graceUntil?: number }
|
|
|
|
|
| { type: 'error'; code: ErrorCode; message: string }
|
|
|
|
|
| { type: 'ping' };
|
|
|
|
|
|
|
|
|
|
type ErrorCode =
|
|
|
|
|
| 'game_not_found' | 'slot_taken' | 'spectators_disabled'
|
|
|
|
|
| 'not_your_turn' | 'malformed' | 'promotion_required'
|
|
|
|
|
| 'must_move_touched_piece' | 'rate_limited';
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Protocol invariants
|
|
|
|
|
|
|
|
|
|
1. **Server is the only authority.** Client `commit` is a request. Optimistic animation is allowed but must reconcile to the next `update`.
|
|
|
|
|
2. **`view` filtering is server-side, server-only.** Opponent pieces are absent from blind-mode payloads, not encrypted-but-present.
|
|
|
|
|
3. **Moderator-vocabulary "errors" come through as `Announcement` on `update`, not as `error` messages.** Errors are reserved for protocol-level failures.
|
|
|
|
|
4. **`update` is idempotent.** Replaying the latest `update` produces a correct render.
|
|
|
|
|
5. **Per-token rate limiting on `commit`** (10/s, burst 20).
|
|
|
|
|
|
|
|
|
|
## Game state machine
|
|
|
|
|
|
|
|
|
|
### Commit handler (touch-move FSM)
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
receive commit {from, to?}
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
┌────────── touchedPiece set? ──────────┐
|
|
|
|
|
NO YES
|
|
|
|
|
│ │
|
|
|
|
|
▼ ▼
|
|
|
|
|
Validate piece at `from`: from === touchedPiece?
|
|
|
|
|
• own color piece? NO ───► error: must_move_touched_piece
|
|
|
|
|
no → no_such_piece YES
|
|
|
|
|
• pseudo-legal ≠ ∅? │
|
|
|
|
|
no → no_legal_moves ▼
|
|
|
|
|
• legal moves ≠ ∅? to defined?
|
|
|
|
|
no → wont_help NO ───► no-op (idempotent)
|
|
|
|
|
YES
|
|
|
|
|
All checks passed: │
|
|
|
|
|
set touchedPiece = from ▼
|
|
|
|
|
│ legality check
|
|
|
|
|
▼ LEGAL ───► apply move, clear touched
|
|
|
|
|
to defined? ILLEGAL ──► illegal_move (touched stays)
|
|
|
|
|
NO ───► silent (touched set, awaiting drop)
|
|
|
|
|
YES ───► legality check
|
|
|
|
|
LEGAL ───► apply move, clear touched
|
|
|
|
|
ILLEGAL ──► illegal_move (touched stays)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The `no_legal_moves` and `wont_help` checks fire **only on first commit** with that piece. Once the piece is determined to have at least one legal move, `touchedPiece` is set and subsequent failed commits are `illegal_move`.
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
function handleCommit(game: Game, color: Color, msg: CommitMessage): CommitResult {
|
|
|
|
|
if (game.chess.turn() !== color) return error('not_your_turn');
|
|
|
|
|
|
|
|
|
|
const touched = game.armed?.color === color ? game.armed.from : null;
|
|
|
|
|
|
|
|
|
|
if (touched) {
|
|
|
|
|
if (msg.from !== touched) return error('must_move_touched_piece');
|
|
|
|
|
if (!msg.to) return noop();
|
|
|
|
|
return tryMove(game, color, msg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const piece = game.chess.get(msg.from);
|
|
|
|
|
if (!piece || piece.color !== color) return announce('no_such_piece');
|
|
|
|
|
|
|
|
|
|
const pseudo = geometricMoves(piece.type, msg.from, ownSquares(game, color));
|
|
|
|
|
if (pseudo.length === 0) return announce('no_legal_moves');
|
|
|
|
|
|
|
|
|
|
const legal = chessJsLegalMovesFrom(game.chess, msg.from);
|
|
|
|
|
if (legal.length === 0) return announce('wont_help');
|
|
|
|
|
|
|
|
|
|
game.armed = { color, from: msg.from };
|
|
|
|
|
|
|
|
|
|
if (!msg.to) return silent();
|
|
|
|
|
return tryMove(game, color, msg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function tryMove(game, color, { from, to, promotion }): CommitResult {
|
|
|
|
|
// Pawn reaching back rank requires the promotion field. Missing it is a
|
|
|
|
|
// protocol error (the client must show its modal first), not an illegal
|
|
|
|
|
// move. touchedPiece stays — the player retries with promotion picked.
|
|
|
|
|
if (isPromotionRequired(game, from, to) && !promotion) {
|
|
|
|
|
return error('promotion_required');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const move = game.chess.move({ from, to, promotion });
|
|
|
|
|
if (!move) return announce('illegal_move'); // touchedPiece STAYS
|
|
|
|
|
|
|
|
|
|
game.armed = null;
|
|
|
|
|
return appliedMove(translateMove(game, move));
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Moderator translator
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
function translateMove(game: Game, move: ChessJsMove): Announcement[] {
|
|
|
|
|
const out: Announcement[] = [];
|
|
|
|
|
const mover = move.color;
|
|
|
|
|
const opp = mover === 'w' ? 'b' : 'w';
|
|
|
|
|
const isCap = !!move.captured;
|
|
|
|
|
const isEp = move.flags.includes('e');
|
|
|
|
|
const isCast = move.flags.includes('k') || move.flags.includes('q');
|
|
|
|
|
const isProm = !!move.promotion;
|
|
|
|
|
|
|
|
|
|
// To opponent
|
|
|
|
|
if (isCast) {
|
|
|
|
|
const side = move.flags.includes('k') ? 'kingside' : 'queenside';
|
|
|
|
|
out.push(announce(`${mover}_castled_${side}`, opp));
|
|
|
|
|
} else if (isCap && isEp) {
|
|
|
|
|
out.push(announce(`${mover}_moved_captured_ep`, opp));
|
|
|
|
|
} else if (isCap) {
|
|
|
|
|
out.push(announce(`${mover}_moved_captured`, opp));
|
|
|
|
|
} else {
|
|
|
|
|
out.push(announce(`${mover}_moved`, opp));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isProm) out.push(announce(`${mover}_promoted`, opp));
|
|
|
|
|
|
|
|
|
|
// To both
|
|
|
|
|
if (game.chess.isCheckmate()) out.push(announce(`${mover}_checkmate`, 'both'));
|
|
|
|
|
else if (game.chess.inCheck()) out.push(announce(`${opp}_in_check`, 'both'));
|
|
|
|
|
|
|
|
|
|
if (game.chess.isStalemate()) out.push(announce('stalemate', 'both'));
|
|
|
|
|
if (game.chess.isInsufficientMaterial()) out.push(announce('draw_insufficient', 'both'));
|
|
|
|
|
if (game.chess.isThreefoldRepetition()) out.push(announce('draw_threefold', 'both'));
|
|
|
|
|
if (halfMoveClock(game.chess) >= 100) out.push(announce('draw_fifty', 'both'));
|
|
|
|
|
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The capturing player learns the captured piece type via the `view`/`update` (their canonical board updates with the capture animation and the captured-pieces tray gets populated). The opponent gets only the `*_moved_captured` announcement.
|
|
|
|
|
|
|
|
|
|
### `geometricMoves` helper
|
|
|
|
|
|
|
|
|
|
Pure function. Reads only piece type + from-square + own-piece set. Lives in `packages/shared/src/geometric.ts` so it can run on both server (for `no_legal_moves` check) and client (for highlighting).
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
export function geometricMoves(
|
|
|
|
|
piece: Piece, from: Square, ownSquares: Set<Square>
|
|
|
|
|
): Square[] {
|
|
|
|
|
switch (piece.type) {
|
|
|
|
|
case 'n': return knightJumps(from).filter(s => !ownSquares.has(s));
|
|
|
|
|
case 'k': return kingNeighbors(from).filter(s => !ownSquares.has(s));
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Crucial: rays stop AT own pieces (excluded), but extend THROUGH any
|
|
|
|
|
// unknown squares (which may host opponent pieces).
|
|
|
|
|
function rays(from: Square, dirs: Dir[], own: Set<Square>): Square[] {
|
|
|
|
|
const out: Square[] = [];
|
|
|
|
|
for (const [df, dr] of dirs) {
|
|
|
|
|
let f = fileOf(from), r = rankOf(from);
|
|
|
|
|
while (true) {
|
|
|
|
|
f += df; r += dr;
|
|
|
|
|
if (!onBoard(f, r)) break;
|
|
|
|
|
const sq = square(f, r);
|
|
|
|
|
if (own.has(sq)) break;
|
|
|
|
|
out.push(sq);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Pawn geometry includes forward-1, forward-2 (when from starting rank), and both diagonals — even when the diagonals are empty. Diagonal-to-empty attempts will be rejected as `illegal_move` (which is fine — the player will probe). Castling is handled by `chess.js` as a special king move; not part of `geometricMoves`.
|
|
|
|
|
|
|
|
|
|
**Castling and highlighting:** in **vanilla+ON** mode, the highlight set is chess.js's legal-moves output, which includes castling destinations (g1/c1 or g8/c8) when castling is legal. In **blind+ON** mode, castling is *not* highlighted — `geometricMoves` for a king returns only the 8 neighbors. This is a deliberate choice: castling legality depends on opponent piece positions (path through check, opponent pieces between king and rook), and partial highlighting would either leak that info or mislead. The player can still execute castling by manually committing the king two squares toward a rook; the server applies it normally.
|
|
|
|
|
|
|
|
|
|
### Hierarchy decision table (six golden tests)
|
|
|
|
|
|
|
|
|
|
| # | Scenario | Pseudo-legal | Legal | Expected |
|
|
|
|
|
|---|---|---|---|---|
|
|
|
|
|
| 1 | Empty square / wrong color | — | — | `no_such_piece` |
|
|
|
|
|
| 2 | Knight surrounded by own pawns | ∅ | ∅ | `no_legal_moves` |
|
|
|
|
|
| 3 | Pinned bishop, not in check | ≠∅ | ∅ | `wont_help` |
|
|
|
|
|
| 4 | In check, knight can't block/capture | ≠∅ | ∅ | `wont_help` |
|
|
|
|
|
| 5 | In check, knight can block | ≠∅ | ≠∅ | silent on commit; `illegal_move` on bad destination |
|
|
|
|
|
| 6 | Normal piece with options | ≠∅ | ≠∅ | silent on commit |
|
|
|
|
|
|
|
|
|
|
## Highlighting rules (full table)
|
|
|
|
|
|
|
|
|
|
| Game mode | Toggle | What player sees |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| Blind | OFF | Own pieces only. No overlays. Pure moderator-vocabulary game. |
|
|
|
|
|
| Blind | ON | Own pieces + green dots on every geometrically-reachable square (excluding off-board and own pieces). Rays extend through unseen opponent pieces. Zero opponent info leak. |
|
|
|
|
|
| Vanilla | OFF | Both colors. No overlays. |
|
|
|
|
|
| Vanilla | ON | Both colors + green dots (legal empty) + red rings (legal captures). Engine-truth. |
|
|
|
|
|
|
|
|
|
|
## Lobby flow
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
landing page (chess.sethpc.xyz)
|
|
|
|
|
│
|
|
|
|
|
│ user selects mode (blind | vanilla)
|
|
|
|
|
│ user picks side (white | black | random)
|
|
|
|
|
│ user picks highlighting (on | off)
|
|
|
|
|
│ click "Create game"
|
|
|
|
|
▼
|
|
|
|
|
POST /api/games { mode, side, highlightingEnabled }
|
|
|
|
|
▼
|
|
|
|
|
server returns { gameId, creatorToken, joinUrl: chess.sethpc.xyz/g/<gameId> }
|
|
|
|
|
▼
|
|
|
|
|
creator's browser: localStorage.setItem(`bc:${gameId}`, creatorToken)
|
|
|
|
|
navigate to /g/<gameId>
|
|
|
|
|
WS connects, hello {gameId, token}
|
|
|
|
|
server responds joined { you: <creator's color>, status: 'waiting' }
|
|
|
|
|
UI shows "Waiting for opponent — share this link: <joinUrl>"
|
|
|
|
|
▼
|
|
|
|
|
opponent opens joinUrl
|
|
|
|
|
│
|
|
|
|
|
│ /g/<gameId> page loads
|
|
|
|
|
│ no localStorage token (first visit) → WS hello {gameId, joinAs: 'auto'}
|
|
|
|
|
│ server claims the open color, generates token
|
|
|
|
|
▼
|
|
|
|
|
server responds joined { you: <opposite color>, token: <new>, status: 'active' }
|
|
|
|
|
opponent's browser: localStorage.setItem(`bc:${gameId}`, token)
|
|
|
|
|
│
|
|
|
|
|
│ server flips game to 'active', broadcasts update to creator
|
|
|
|
|
▼
|
|
|
|
|
game on
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Reconnect: navigating to `/g/<gameId>` reads `bc:<gameId>` from localStorage, sends `hello {gameId, token}`. Server matches token to slot, restores socket pointer, replays state.
|
|
|
|
|
|
|
|
|
|
## Error handling, security, testing
|
|
|
|
|
|
|
|
|
|
### Connection lifecycle
|
|
|
|
|
|
|
|
|
|
5-minute grace on disconnect. Reconnect cancels the timer. Grace expiry → `update { gameStatus: 'finished', endReason: 'abandoned', winner: <other> }`. Both-sides simultaneous disconnect: first to expire ends the game, `winner: undefined`.
|
|
|
|
|
|
|
|
|
|
Clean tab-close still applies grace (player may be reopening). Only intentional `resign` ends without grace.
|
|
|
|
|
|
|
|
|
|
**Same-token, second socket.** If a player opens their gameId in a second tab, both tabs read the same token from `localStorage`. The server enforces one socket per slot: a `hello` with a token already bound to a live socket closes the old socket (sends `close` frame with reason `superseded`) and accepts the new one. Last-connect-wins. The displaced tab shows a "this game is now open in another tab" banner.
|
|
|
|
|
|
|
|
|
|
### Security boundaries (each invariant has a test)
|
|
|
|
|
|
|
|
|
|
1. **`buildView` is the only egress for board state.** Snapshot tests assert blind-mode white view contains no `'b'` pieces.
|
|
|
|
|
2. **Server is the only authority on game state.** Malicious-client integration test: `commit` while not your turn → `error: not_your_turn`.
|
|
|
|
|
3. **Touch-move enforcement is server-side.** Bypass-client test: `commit` with different `from` while `touchedPiece` set → `error: must_move_touched_piece`.
|
|
|
|
|
|
|
|
|
|
### Input validation & rate limiting
|
|
|
|
|
|
|
|
|
|
- All inbound messages parsed via `zod` schemas. Mismatch → `error: malformed`.
|
|
|
|
|
- Per-token token-bucket on `commit`: 10/s, burst 20.
|
|
|
|
|
- `gameId` strictly `^[a-z0-9]{8}$`.
|
|
|
|
|
- `Square` strictly validated against `[a-h][1-8]`.
|
|
|
|
|
|
|
|
|
|
### Crash & restart
|
|
|
|
|
|
|
|
|
|
- systemd: `Restart=always`, `RestartSec=2s`.
|
|
|
|
|
- Client: WS reconnect with exponential backoff, ≤5 retries, then "server went away" message.
|
|
|
|
|
- Server restart drops active games (acceptable — see "Non-goals").
|
|
|
|
|
|
|
|
|
|
### Testing strategy
|
|
|
|
|
|
|
|
|
|
**Unit (`vitest`, `packages/server` + `packages/shared`):**
|
|
|
|
|
- `geometricMoves` — table-driven, every piece type, blocked-by-own cases.
|
|
|
|
|
- `translateMove` — canned chess.js move objects + fake instance, assert announcement enums.
|
|
|
|
|
- `buildView` — snapshot per-viewer outputs in both modes.
|
|
|
|
|
- Hierarchy decision table — all six rows.
|
|
|
|
|
- `validateCommit` / zod schemas — fuzz inputs.
|
|
|
|
|
|
|
|
|
|
**Integration (`vitest`, real WS):**
|
|
|
|
|
- Two test WS clients play scripted games end-to-end:
|
|
|
|
|
- Standard opening
|
|
|
|
|
- Castling (kingside, queenside, both colors)
|
|
|
|
|
- En passant
|
|
|
|
|
- Promotion (with and without `promotion` field)
|
|
|
|
|
- Resign / draw offer / accept / decline
|
|
|
|
|
- Checkmate, stalemate, threefold, 50-move
|
|
|
|
|
- Touch-move violation
|
|
|
|
|
- Disconnect within grace → reconnect → state replay
|
|
|
|
|
- Disconnect through grace → opponent wins by abandonment
|
|
|
|
|
|
|
|
|
|
**E2E browser:** out of scope for MVP. Manual testing on phone + desktop.
|
|
|
|
|
|
|
|
|
|
### Logging & observability
|
|
|
|
|
|
|
|
|
|
- **Pino** structured JSON logging via Fastify default. journald captures stdout.
|
|
|
|
|
- `/api/health` → `{ ok: true, activeGames, uptime }`. Wired to Uptime Kuma.
|
|
|
|
|
- No metrics/tracing in MVP; cheap to add later (`/api/metrics`, ~30 LoC).
|
|
|
|
|
|
|
|
|
|
## Glossary
|
|
|
|
|
|
|
|
|
|
- **arm / armed** — client-side state where a piece is tentatively selected; reversible. Visual only.
|
|
|
|
|
- **commit / touched** — irreversible declaration that the player will move this piece. Server-side state. Triggered by drag-start or destination-click.
|
|
|
|
|
- **moderator vocabulary** — fixed enum of announcements the server emits to communicate game events.
|
|
|
|
|
- **pseudo-legal moves** — moves that satisfy a piece's movement geometry, ignoring whether they leave the king in check.
|
|
|
|
|
- **legal moves** — pseudo-legal moves filtered for king safety. What `chess.js` returns from `moves()`.
|
|
|
|
|
- **geometric moves** — pseudo-legal-ish moves computed from piece type + position + own-piece set only (no opponent input). Used for highlighting and the `no_legal_moves` check.
|
|
|
|
|
- **view filter** — server-side function that produces a per-player `BoardView` from the canonical `Game`. The single security boundary for opponent information.
|
|
|
|
|
|
|
|
|
|
## Open items (resolved before implementation)
|
|
|
|
|
|
|
|
|
|
None at spec close. All architectural and gameplay questions resolved during the brainstorming session.
|