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,180 @@
|
||||
# Handoff: Spec approved, ready for implementation plan
|
||||
|
||||
## Session Metadata
|
||||
|
||||
- Created: 2026-04-28 10:43:44
|
||||
- Project: /home/claude/bin/blind_chess
|
||||
- Branch: not a git repo (yet — see Pending Work)
|
||||
- Session duration: ~90 minutes (single brainstorming session)
|
||||
- Recent commits: none — repo not initialized yet.
|
||||
|
||||
## Handoff Chain
|
||||
|
||||
- **Continues from**: [2026-04-28-kickoff.md](./2026-04-28-kickoff.md)
|
||||
- Previous title: blind_chess Kickoff (2026-04-28)
|
||||
- **Supersedes**: None
|
||||
|
||||
> Review the previous handoff for full context before filling this one.
|
||||
|
||||
## Current State Summary
|
||||
|
||||
A 90-minute brainstorming session walked the project from "IDEA.md is populated" through every major architectural and gameplay decision and produced a complete design spec. Seth reviewed and approved each of the five design sections (architecture, data model, protocol, state machine, error handling/security/testing). The spec is on disk at `docs/superpowers/specs/2026-04-28-blind-chess-design.md`. Self-review applied four small fixes inline. **Seth ended the session before reviewing the written spec or invoking the `writing-plans` skill** — those are the next steps for the resuming agent.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Designed (not yet implemented):
|
||||
|
||||
- **Language/stack:** Node 22 + TypeScript, Fastify + `ws`, Svelte + Vite, `chess.js`.
|
||||
- **Repo shape:** pnpm workspace, three packages — `packages/server`, `packages/client`, `packages/shared`. Shared types are the load-bearing decision: the WS protocol drift surface is high-risk.
|
||||
- **State:** in-memory only (`Map<gameId, Game>`). No DB. Server restart drops active games — acceptable for MVP.
|
||||
- **Engine:** `chess.js` for rules + a custom ~80-LoC `geometricMoves` helper for pseudo-legal moves (chess.js doesn't expose those). The helper is a pure function of `(piece, from, ownSquares)` — provably no opponent input — so it can run on both server (for the `no_legal_moves` check) and client (for highlighting). It lives in `packages/shared`.
|
||||
- **Deploy:** new LXC on node-241, Caddy CT 600 routes `chess.sethpc.xyz` → port 3000, systemd-managed Node service. Single-port everything (HTTP + WS + static).
|
||||
- **Security boundary:** `buildView(game, viewer)` is the *only* function that emits board state to clients. Blind-mode views literally omit opponent pieces from the wire — they are absent, not encrypted-but-present.
|
||||
|
||||
## Critical Files
|
||||
|
||||
| File | Purpose | Relevance |
|
||||
|------|---------|-----------|
|
||||
| IDEA.md | Original project brief from Seth | Source of truth for "what is this game"; read first |
|
||||
| docs/superpowers/specs/2026-04-28-blind-chess-design.md | Full design spec | The canonical artifact — everything from this session is here |
|
||||
| CLAUDE.md | Project identity + tagline + current state | Updated this session; loads on every session |
|
||||
| DECISIONS.md | 16 architecture/implementation decisions + 12 deferred/rejected | Updated this session |
|
||||
| .claude/handoffs/2026-04-28-kickoff.md | Previous handoff (project scaffold) | Read second |
|
||||
| .gitignore | Excludes `.superpowers/` and `.backup/` | Created this session |
|
||||
| .superpowers/brainstorm/2734300-1777384084/content/ | Visual companion artifacts (3 mockup HTMLs) | Browse historically; not part of project |
|
||||
|
||||
## Key Patterns Discovered
|
||||
|
||||
- **Mode = view filter, not different game.** Vanilla and blind share one `chess.js` instance; the difference is what `buildView()` returns to each player.
|
||||
- **Moderator vocabulary is an enum, never a string.** Display text lives client-side. Tests assert against `ModeratorText` values like `'wont_help'`.
|
||||
- **Moderator-vocabulary "errors" come through as `Announcement` on the `update` message, NOT as protocol `error`.** They're game events. `error` is reserved for protocol failures (malformed, rate-limited, slot taken).
|
||||
- **Highlights in blind+ON are purely geometric.** Function of `(piece type, position, own-piece set)`. Rays extend through unseen opponents. Stop at own pieces. Off-board excluded. **Zero opponent info leak.**
|
||||
- **Touch-move FSM is server-side.** `game.armed: { color, from }` is the entire state. `no_legal_moves` and `wont_help` checks fire only on first commit with a piece; once committed, all subsequent failed attempts are `illegal_move` with the touch staying.
|
||||
- **Single `commit { from, to? }` message** handles both drag-start (no `to`) and drag-drop / destination-click (with `to`). Uniform server FSM through one handler.
|
||||
|
||||
## Tasks Finished
|
||||
|
||||
- Read kickoff handoff and IDEA.md
|
||||
- Offered visual companion, Seth accepted (URL was http://steel141.local:50816 during session — server now stale)
|
||||
- Q1 scope: locked vanilla+blind day-one, shared engine
|
||||
- Q2 stack: locked Node + TypeScript + chess.js
|
||||
- Q3 "wont help you" semantics: locked check-resolution interpretation
|
||||
- Q4 touch-move FSM: locked tap-arms-reversible, drag/destination-click commits
|
||||
- Q5 lobby flow (5 sub-defaults): all locked
|
||||
- Q6 highlighting: locked geometric-only rule for blind+ON (rays extend through unseen pieces)
|
||||
- Q7 final cleanup batch (6 items: persistence, time controls, deploy target, promotion, mobile, endgame): all confirmed
|
||||
- Architecture: 3 approaches proposed (SvelteKit monolith / pnpm workspace / framework-free), Seth picked B
|
||||
- 5 design sections presented in terminal, each individually approved
|
||||
- Spec written: `docs/superpowers/specs/2026-04-28-blind-chess-design.md`
|
||||
- Spec self-review: 4 fixes applied inline (Announcement payload for promotion, geometricMoves signature, castling+highlighting note, promotion-required-as-protocol-error)
|
||||
- CLAUDE.md updated (tagline, Project Identity, current state, key files, conventions)
|
||||
- DECISIONS.md updated (16 settled decisions, 12 deferred/rejected)
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Changes | Rationale |
|
||||
|------|---------|-----------|
|
||||
| CLAUDE.md | Replaced placeholder tagline + Project Identity; added current state, key files, conventions | Was a stub before brainstorming; now reflects approved spec |
|
||||
| DECISIONS.md | Filled in 16 architecture/implementation decisions and 12 deferred/rejected items | Captures everything Seth confirmed |
|
||||
| docs/superpowers/specs/2026-04-28-blind-chess-design.md | New file — full design spec | Output of brainstorming session per skill workflow |
|
||||
| .gitignore | New file — excludes `.superpowers/` and `.backup/` | `.superpowers/` is the visual-companion working directory |
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See the full list in `DECISIONS.md` (16 architecture + implementation, 12 deferred/rejected). Highlights:
|
||||
|
||||
| Decision | Options Considered | Rationale |
|
||||
|----------|-------------------|-----------|
|
||||
| Stack: Node + TS + Fastify + Svelte + chess.js | SvelteKit monolith / framework-free / Python+FastAPI / Go | Single-language top-to-bottom; shared types catch protocol drift; Svelte fits reactive board state |
|
||||
| Highlighting (blind+ON) is purely geometric | "Safe" (legal-empty only) / Full / None | Provably zero opponent info leak; rule is `f(piece, position, ownSquares)` only |
|
||||
| Moderator hierarchy refined to 4 tiers | 2-tier / 3-tier / 4-tier with pseudo-legal | Pseudo-legal vs. legal distinction lets `wont_help` reveal "king is the problem" without revealing why |
|
||||
| `Announcement` is enum, not string | Free-form strings / structured enum | Tests assert on enum, i18n trivial via translation table |
|
||||
| In-memory state only | SQLite from day one / in-memory + later SQLite | Hobby-scale; restart drops games is acceptable; SQLite is a 1-day add later |
|
||||
| Single `commit` message for arm + move | Two messages (arm + move) / three / one | Uniform server FSM; one handler covers all transitions |
|
||||
| Castling NOT highlighted in blind+ON | Highlight always / partial highlight / never | Castling legality depends on opponent state; partial reveal would leak; player can still execute manually |
|
||||
|
||||
## Immediate Next Steps
|
||||
|
||||
1. **Have Seth review the written spec.** It's at `docs/superpowers/specs/2026-04-28-blind-chess-design.md`. This is the gate the brainstorming skill required before invoking `writing-plans`, but the session ended before Seth read the written form. Ask: "I've got the spec written at `<path>`. Please review and let me know if anything needs to change before I draft the implementation plan." If Seth requests changes, edit inline and re-ask.
|
||||
|
||||
2. **Propose Gitea repo creation, with Seth's OK.** The kickoff handoff's deferred step 5: `git init` + `gitea create blind_chess` + `gitea remote blind_chess` + `gitea push`. The session ended before this happened. **Do not init the repo without Seth's explicit confirmation** — the kickoff said "propose creating a Gitea repo … Once direction is clear", and Seth ended the session without saying go. Use the `gitea` CLI from `~/bin/gitea`.
|
||||
|
||||
3. **Invoke the `superpowers:writing-plans` skill.** This is the terminal state of the brainstorming process. The plan should decompose the spec into concrete implementation tasks. Skill location: per CLAUDE.md plugin list.
|
||||
|
||||
## Blockers/Open Questions
|
||||
|
||||
- **Spec review by Seth not yet done.** He approved each of the 5 sections in conversation but did not look at the written spec. Self-review found 4 fixes that he hasn't seen — most notably the `Announcement.payload` field for promotions and the castling+highlighting clarification.
|
||||
- **No Gitea repo yet.** Project is on disk only. If the resuming agent loses this directory or it gets clobbered, the spec + decisions are gone. Backup: `.backup/` is gitignored but currently empty (no edits to backup).
|
||||
|
||||
## Deferred Items
|
||||
|
||||
All deferred/rejected items are in `DECISIONS.md` "Deferred / Rejected" section. The implementation plan should NOT plan for:
|
||||
|
||||
- Spectator mode, time controls, SQLite persistence, E2E browser tests, Authentik gate, public lobby/matchmaking/ratings, client-side AI hints, PGN export, CI/CD automation.
|
||||
- Stretch goal (deferred, not rejected): pre-deploy "server restarting" warning to active players.
|
||||
|
||||
## Important Context
|
||||
|
||||
**The whole project is currently a design spec on disk — no code exists yet.** The next session's job is to (1) get spec sign-off, (2) create the repo, (3) invoke `writing-plans` to produce an implementation plan. Do not jump to writing code; the brainstorming skill's terminal state is `writing-plans`, and writing-plans then becomes the input to a future implementation session.
|
||||
|
||||
**Seth approved each of the 5 design sections in conversation, but did NOT review the assembled written spec.** The brainstorming skill explicitly requires user review of the written artifact before transitioning to writing-plans. The four self-review fixes (Announcement payload, geometricMoves signature, castling-highlighting note, promotion-required handling) were applied without Seth seeing them — they're small and not contentious, but he should glance at them.
|
||||
|
||||
**The visual companion server (port 50816, `http://steel141.local:50816`) is no longer running.** It was started during the session and would have been auto-killed after 30 minutes of inactivity. Three mockup HTMLs are persisted in `.superpowers/brainstorm/2734300-1777384084/content/`: `welcome.html`, `highlighting.html` (v1, superseded), `highlighting-v2.html` (the corrected geometric rule, the one Seth approved), `waiting.html`. Don't restart the server unless the next session does more visual work.
|
||||
|
||||
**`gameId` format `^[a-z0-9]{8}$` is small but adequate** — only 32 bits of entropy, but the link IS the auth, lives a single game's worth of time, and we have rate limiting. If at any point the link becomes long-lived (e.g., post-game replay URLs), bump to 12+ chars.
|
||||
|
||||
**The `geometricMoves` helper is the most-tested piece of code in this design.** Six golden-path test cases cover the moderator hierarchy decision table. Make sure its test file is one of the FIRST things implemented — everything else depends on it being right.
|
||||
|
||||
## Assumptions Made
|
||||
|
||||
- Seth wants Svelte not React (he didn't push back; my recommendation stood).
|
||||
- Seth wants Fastify not Express (same — accepted by silence).
|
||||
- Seth's homelab has node-241 with capacity for one more LXC (true at the time of CLAUDE.md last-update; verify with `pct list` on node-241 if uncertain).
|
||||
- "Moving that piece will not help you" is purely a king-safety announcement, not a tactical-evaluation one. Seth confirmed explicitly during the session.
|
||||
- 8-character `gameId` is unique enough at this scale (~tens of concurrent games max). Birthday-collision math: ~60K games active before 1% chance of any pair colliding. Way more than this project will see.
|
||||
|
||||
## Potential Gotchas
|
||||
|
||||
- **chess.js does not expose pseudo-legal moves directly.** Don't try to compute the `no_legal_moves` / `wont_help` distinction by calling chess.js's `moves()` alone — that returns *legal* moves only. You need both the geometric set and the legal set. The spec's `geometricMoves` helper is the missing piece.
|
||||
- **chess.js `inCheck()` checks the side-to-move.** After a move is applied, the side-to-move is the opponent. So `inCheck()` true after white's move means *black* is in check (which is what `${opp}_in_check` already says — but easy to invert by accident).
|
||||
- **`Announcement.payload.promotedTo` was added in self-review.** If the implementation skips it, the `white_promoted` announcement carries no piece-type info and the opponent has to guess. Don't drop it.
|
||||
- **The same-token-second-tab rule (last-connect-wins) is a security AND UX feature.** Without it, a malicious client could open many sockets per token and cause server-side resource leaks. Don't be tempted to "be more permissive" here.
|
||||
- **Castling is NOT in `geometricMoves`.** This is intentional. If a future contributor adds castling targets to the king's geometric set "for completeness", they'll either leak opponent info (by checking castling-legality) or mislead users (by suggesting illegal castling). Comment in the helper explaining this.
|
||||
- **The visual companion's `.superpowers/` dir is gitignored.** If you want to share mockups (e.g., commit them for posterity), copy them to `docs/` first.
|
||||
- **Files referenced in this handoff that don't exist yet:** `deploy/Caddyfile.snippet` and the test files (e.g., `packages/server/test/unit/geometric.test.ts`) are forward references — they'll be created during implementation. Don't be alarmed when the validator flags them as missing.
|
||||
|
||||
## Environment State
|
||||
|
||||
### Tools/Services Used
|
||||
|
||||
- **chess.js**: not yet installed; will be a dependency in `packages/server` (and possibly `packages/client` for vanilla mode legal-moves computation). Pin via `package.json` once init'd.
|
||||
- **gitea CLI**: `~/bin/gitea`. Used for repo create + push. Token at `~/.config/gitea/token`. Documented in `~/bin/CLAUDE.md`.
|
||||
- **pnpm**: required for workspace. Version 9+ recommended.
|
||||
- **Node 22 LTS**: deployment target. Local dev should match.
|
||||
- **Caddy CT 600** (192.168.0.185): existing. Add a `chess.sethpc.xyz` block to the Caddyfile when deploying. Spec includes a `deploy/Caddyfile.snippet` to be created during implementation.
|
||||
|
||||
### Active Processes
|
||||
|
||||
- **None.** The visual companion server self-terminated after 30 minutes of inactivity. No long-running tasks were started.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `HOMELAB_PASSWORD`: set globally, used for SSH/SMB/etc. Not needed for blind_chess work directly.
|
||||
- (No project-specific env vars yet.)
|
||||
|
||||
## Related Resources
|
||||
|
||||
- Spec: `docs/superpowers/specs/2026-04-28-blind-chess-design.md`
|
||||
- Project identity: `CLAUDE.md`
|
||||
- Decisions: `DECISIONS.md`
|
||||
- Original brief: `IDEA.md`
|
||||
- Kickoff handoff: `.claude/handoffs/2026-04-28-kickoff.md`
|
||||
- Visual companion mockups (gitignored): `.superpowers/brainstorm/2734300-1777384084/content/`
|
||||
- Global homelab context: `~/bin/CLAUDE.md`
|
||||
- Project scaffold recipe: `~/bin/CREATE_PROJECT.md`
|
||||
- Gitea CLI: `~/bin/gitea`
|
||||
- chess.js (intended dep): https://github.com/jhlywa/chess.js
|
||||
|
||||
---
|
||||
|
||||
**Security Reminder**: Before finalizing, run `validate_handoff.py` to check for accidental secret exposure.
|
||||
@@ -0,0 +1,181 @@
|
||||
# Handoff: MVP deployed and live
|
||||
|
||||
## Session Metadata
|
||||
|
||||
- Created: 2026-04-28 ~15:20 UTC (terminal session, single user-driven workflow run)
|
||||
- Project: /home/claude/bin/blind_chess
|
||||
- Branch: `main`
|
||||
- Repo: `git.sethpc.xyz/Seth/blind_chess`
|
||||
- Recent commits: see `git log` after the wrap-up commit on this session.
|
||||
- Live URL: **https://chess.sethpc.xyz**
|
||||
|
||||
## Handoff Chain
|
||||
|
||||
- **Continues from**: [2026-04-28-104344-spec-approved-ready-for-plan.md](./2026-04-28-104344-spec-approved-ready-for-plan.md)
|
||||
- **Supersedes**: None
|
||||
|
||||
## Current State Summary
|
||||
|
||||
Seth invoked the workflow `handoff -> implementation -> deployment -> update context -> create handoff -> git commit -> close session`. In one session: created the Gitea repo, scaffolded the pnpm workspace, implemented `packages/{shared,server,client}`, wrote 43 passing tests, deployed an LXC on node-241, configured Caddy + systemd, and verified the live URL handles game creation, the SPA fallback, WebSocket upgrade, and the per-player blind-mode view filter end-to-end. The previous handoff's deferred steps (spec sign-off, Gitea creation, writing-plans) were skipped per Seth's explicit workflow direction; he chose direct execution from the spec rather than another planning round.
|
||||
|
||||
The application works. Black gets `white_moved` announcements while seeing only black's 16 pieces — the spec's central security property is verified on the live URL.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Implemented (matches the design spec exactly with two intentional deviations noted below):
|
||||
|
||||
- **pnpm workspace**, three packages, all on Node 22 + TS 5.9.
|
||||
- `packages/shared/` exports types, the `ModeratorText` enum, the WS `ClientMessage`/`ServerMessage` types, and `geometricMoves()`. Built to `dist/`; `package.json` `main`/`exports` point at the compiled JS.
|
||||
- `packages/server/` runs Fastify + `@fastify/websocket` + `@fastify/static`. Single port 3000 serves `/api/games` (REST), `/api/health`, `/ws` (WS upgrade), and the static client. SPA fallback serves `index.html` for any 404 with `accept: text/html`.
|
||||
- `packages/client/` is Svelte 5 + Vite. Hash routing with a pathname fallback so both `/g/<id>` (share URL) and `/#/g/<id>` (post-create URL) render the game.
|
||||
- **Deploy:** CT 690 on node-241 (192.168.0.245), Debian 12, Node 22.22.2, systemd unit `blind-chess.service` with hardening (`NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome`, restricted user `blindchess`). Caddy CT 600 reverse-proxies `chess.sethpc.xyz` → `192.168.0.245:3000`. DNS rides the existing `*.sethpc.xyz` wildcard.
|
||||
|
||||
**Intentional deviations from spec:**
|
||||
|
||||
1. **Click-to-move only** — drag-and-drop deferred. Tap-arm + tap-destination implements the touch-move FSM correctly and is identical on phone and desktop. (The FSM doesn't care which input mode produces the `commit` message.)
|
||||
2. **No CapturedTray UI yet.** The spec's captured-pieces tray would derive from `moveHistory[].capturedPieceType`. Implementation deferred — moderator panel is the primary opponent-event channel.
|
||||
|
||||
## Critical Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `packages/shared/src/geometric.ts` | `geometricMoves(piece, from, ownSquares)`. Signature is the proof of zero opponent leak. 21 tests in `packages/shared/test/geometric.test.ts`. |
|
||||
| `packages/shared/src/protocol.ts` | `ClientMessage`, `ServerMessage`, `ErrorCode` — single source of truth, no drift. |
|
||||
| `packages/shared/src/moderator.ts` | `ModeratorText` enum, `Announcement` type. |
|
||||
| `packages/server/src/view.ts` | `buildView()` — the security boundary. Test: `packages/server/test/unit/view.test.ts` (snapshot per-viewer, blind/active white view contains zero black pieces). |
|
||||
| `packages/server/src/commit.ts` | Touch-move FSM. Test: `packages/server/test/unit/commit-fsm.test.ts` (hierarchy table rows 1, 1b, 2, 3, 5, 6 plus touch-move enforcement and promotion). |
|
||||
| `packages/server/src/translator.ts` | chess.js `Move` → `ModeratorText[]`. Half-move clock is read from FEN field 4. |
|
||||
| `packages/server/src/ws.ts` | The WS dispatch + broadcast logic. Largest file. Includes the same-token-second-tab supersede behavior. |
|
||||
| `packages/server/src/server.ts` | Fastify bootstrap, routes, SPA fallback, janitor interval. |
|
||||
| `packages/client/src/lib/Board.svelte` | The 8×8 grid, click-to-arm/click-to-commit. Imports `geometricMoves` from shared for highlights. |
|
||||
| `packages/client/src/lib/Game.svelte` | The game page (board + moderator panel + actions + dialogs). Owns the WS connection lifecycle. |
|
||||
| `packages/client/src/lib/stores/game.ts` | Reactive game state via Svelte 5 `$state`. Handles WS reconnect, token persistence, message dispatch. |
|
||||
| `deploy/blind-chess.service` | systemd unit (canonical at `/etc/systemd/system/blind-chess.service` on CT). |
|
||||
| `deploy/Caddyfile.snippet` | Reverse-proxy block already merged into `/etc/caddy/Caddyfile` on CT 600. |
|
||||
|
||||
## Tasks Finished
|
||||
|
||||
- Read prior handoff and the design spec
|
||||
- Created Gitea repo `Seth/blind_chess` and pushed initial scaffold
|
||||
- Set up pnpm workspace, root `package.json`, `tsconfig.base.json`, `pnpm-workspace.yaml`
|
||||
- Implemented `packages/shared`: types, protocol, moderator enum, geometric helper. **21 tests passing.**
|
||||
- Implemented `packages/server`: state, games registry, view filter, translator, FSM, validation (zod), rate limiter, WS dispatch, Fastify bootstrap. **22 tests passing** (including 4 real-WS integration tests).
|
||||
- Implemented `packages/client`: Svelte 5 + Vite, Landing/Game/Board/ModeratorPanel/PromotionDialog, hash+pathname routing, dark theme with Sethflix orange (#D35400) accents. Built bundle: 57 KB JS / 8 KB CSS gzipped to ~22 KB total.
|
||||
- Provisioned LXC CT 690 on node-241, installed Node 22 from NodeSource, created `blindchess` user, deployed artifacts via rsync.
|
||||
- Created systemd unit, enabled + started service.
|
||||
- Added Caddy block for `chess.sethpc.xyz` → 192.168.0.245:3000, validated and reloaded.
|
||||
- Verified live URL handles `/api/health`, `/api/games`, `/`, `/g/<id>` SPA fallback, `/assets/*`, and `wss:///ws` end-to-end including the per-player view filter.
|
||||
- Updated `CLAUDE.md` (project state, ops notes), `DECISIONS.md` (implementation outcomes), `~/bin/CLAUDE.md` (project listing).
|
||||
|
||||
## Files Modified / Added
|
||||
|
||||
(Refer to `git log` and `git diff` for exhaustive lists.)
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| (new) `package.json`, `pnpm-workspace.yaml`, `tsconfig.base.json`, `.npmrc` | Workspace root |
|
||||
| (new) `packages/shared/**` | 4 source files, 1 test file, 21 tests |
|
||||
| (new) `packages/server/**` | 9 source files, 3 test files, 22 tests |
|
||||
| (new) `packages/client/**` | 8 source/component files, Vite/Svelte config |
|
||||
| (new) `deploy/blind-chess.service`, `deploy/Caddyfile.snippet` | Deployment artifacts |
|
||||
| `CLAUDE.md` | "Current State" updated to "deployed and live", added "Operations" section |
|
||||
| `DECISIONS.md` | Added "Implementation outcomes" section with 9 entries |
|
||||
| `.gitignore` | Added node_modules, dist, .deploy-server, etc. |
|
||||
| `~/bin/CLAUDE.md` | blind_chess project entry updated to reflect deployed state |
|
||||
|
||||
## Decisions Made
|
||||
|
||||
All implementation decisions are recorded in `DECISIONS.md` "Implementation outcomes" (2026-04-28). Highlights:
|
||||
|
||||
- chess.js v1.4.0; use `Move.isEnPassant()` etc., NOT the deprecated `flags` string.
|
||||
- Half-move clock comes from FEN field 4 — chess.js doesn't expose it.
|
||||
- Shared package's `package.json` exports point at `dist/`, never `src/`. Always build shared before server (project refs handle this on `pnpm -r build`).
|
||||
- Click-to-move (no drag-and-drop) is the only input mode for now.
|
||||
- Hash routing in the SPA, with a pathname fallback for share URLs.
|
||||
|
||||
## Immediate Next Steps
|
||||
|
||||
The MVP is functional. Order of likely follow-ups, not committed:
|
||||
|
||||
1. **Manual phone + desktop play-test.** Send the URL to a friend and play a real game in both modes. Check edge cases the integration tests don't cover: castling kingside and queenside (both colors), en passant, threefold repetition, draw offer/decline cycle, simultaneous tabs (last-connect-wins).
|
||||
2. **Drag-and-drop input.** Useful for desktop. Adds nontrivial complexity to `Board.svelte` (need to handle `pointermove`, ghost element, snapping). Probably 200–300 LoC.
|
||||
3. **CapturedTray.svelte.** Derive from `moveHistory[].capturedPieceType` (server already supplies this in `MoveRecord`). Need to wire the move history through `update` messages — currently it's only computed server-side.
|
||||
4. **More integration tests.** Specifically: castling end-to-end, en passant, both-sides simultaneous disconnect, 5-minute grace expiry. Each is ~30 LoC of test plus existing scaffolding.
|
||||
5. **Uptime Kuma.** Add a probe for `https://chess.sethpc.xyz/api/health` returning `{"ok":true}`.
|
||||
6. **Stretch:** SQLite persistence for crash recovery (1-day add: serialize Map on `ExecStop`, deserialize on `ExecStart`).
|
||||
|
||||
## Blockers / Open Questions
|
||||
|
||||
- **Spec was never reviewed in written form by Seth.** The previous handoff said this was a gate before implementing. Seth waived it implicitly by directing me into the implementation workflow. If he reviews `docs/superpowers/specs/2026-04-28-blind-chess-design.md` and finds something he doesn't like, the implementation may need surgery. The four self-review fixes in the spec (Announcement payload for promotion, geometricMoves signature, castling+highlighting note, promotion-required-as-protocol-error) were all faithfully implemented.
|
||||
- **Public-internet exposure.** `chess.sethpc.xyz` is reachable from the public internet. The link IS the auth, but anyone who guesses an 8-char gameId could try to connect. With `^[a-z0-9]{8}$`, that's 36^8 ≈ 2.8 trillion possibilities; rate limiting on commits (10/s, burst 20) makes mass scanning impractical. **No rate limiting on `hello` messages or `POST /api/games`** — if abuse becomes a concern, add a per-IP token bucket on those endpoints.
|
||||
- **Server restart drops active games.** Acceptable for MVP per spec, but be mindful when deploying updates during active play.
|
||||
|
||||
## Deferred Items
|
||||
|
||||
See `DECISIONS.md` "Deferred / Rejected" — unchanged from prior handoff. Implementation didn't add new deferred items beyond the two MVP-scope reductions: drag-and-drop and CapturedTray (both deferred, not rejected).
|
||||
|
||||
## Important Context
|
||||
|
||||
- **The deploy bundle directory `.deploy-server/`** is created by `pnpm --filter @blind-chess/server deploy --prod --legacy .deploy-server` and is gitignored. Don't commit it. It contains ~93 production deps including `@blind-chess/shared` resolved as a real package (via legacy symlink).
|
||||
- **Re-deploying** is the same flow: `pnpm -r build` → `pnpm --filter @blind-chess/server deploy --prod --legacy .deploy-server` → `rsync -a --delete .deploy-server/ root@192.168.0.245:/opt/blind-chess/server/` → `rsync -a --delete packages/client/dist/ root@192.168.0.245:/opt/blind-chess/client/dist/` → `ssh root@192.168.0.245 'chown -R blindchess:blindchess /opt/blind-chess && systemctl restart blind-chess'`.
|
||||
- **CT root access** is via the `claude` user's ed25519 pubkey (pushed via `pct exec` then `cat > authorized_keys`). A throwaway password was set during `pct create` and **rotated** to something not stored anywhere; recover via `pct exec 690 -- passwd root` from any cluster node if needed. The CT is a stateless game server — losing root access only means re-provisioning, which takes ~3 minutes.
|
||||
- **The integration tests open ephemeral ports** via `listen({ port: 0 })`. They don't depend on 3000 being free. Do NOT change to fixed ports without thinking — that breaks parallel `vitest`.
|
||||
- **Svelte 5 runes mode** is what's in use (`$state`, `$derived`, `$effect`). Don't try to mix in Svelte 3/4 reactive `$:` syntax.
|
||||
- **The visual-companion mockups** from the brainstorm session (`.superpowers/brainstorm/2734300-1777384084/content/`) are still on disk and gitignored. They're stale relative to the implementation (the actual UI uses different colors, spacing, layouts) — don't use them as a reference. The implementation IS the reference now.
|
||||
- **chess.js's `Move.flags` field is deprecated.** The implementation uses the `is...()` methods. Don't use `.flags` if extending the translator.
|
||||
- **Same-token-second-tab is implemented.** Opening a second browser tab on the same gameId closes the first tab's socket with reason `superseded`. The displaced tab does NOT yet show a "now open in another tab" banner; it just disconnects. This is a UX gap, not a correctness gap.
|
||||
|
||||
## Assumptions Made
|
||||
|
||||
- Seth's workflow shorthand is binding direction. The previous handoff's "do not init repo without explicit OK" was overridden by the explicit `implementation->deployment` step. (If Seth disagrees, only the Gitea repo is reversible: `gitea delete blind_chess` undoes it cleanly.)
|
||||
- 192.168.0.245 was free at the time of LXC creation; verified by ICMP. Conflict is unlikely on a homelab where IPs are hand-assigned.
|
||||
- CT 690 was free; verified via `pct list`. The hostname `blind-chess` is unique on the cluster.
|
||||
- Node 22 LTS via NodeSource is acceptable. (Debian 12's apt Node is way too old.)
|
||||
|
||||
## Potential Gotchas
|
||||
|
||||
- **`pnpm install` after pulling on a fresh machine** will warn `Ignored build scripts: esbuild`. That's fine — `auto-install-peers=true` in `.npmrc` prevents prompts but esbuild prompts to opt in to running its postinstall. Vite + Svelte work without it; the warning is benign.
|
||||
- **Pino in production mode** does NOT use `pino-pretty` (only in dev). The systemd unit sets `NODE_ENV=production`; logs in journald are JSON. To pretty-print: `journalctl -u blind-chess -o cat | pnpx pino-pretty`.
|
||||
- **Caddy auto-issues a TLS cert** on first hit of `chess.sethpc.xyz`. The first hit (during smoke testing) took a few hundred ms longer than subsequent hits while the cert was being provisioned; subsequent hits were ~30 ms.
|
||||
- **Test FENs need a black king.** Two unit tests had to be re-FEN'd because chess.js rejects a FEN with no black king. Always include both kings in test positions.
|
||||
- **Pawn-promotion test position** must give the pawn a legal move. `4k3/4P3/8/8/8/8/8/4K3` looks fine but the black king blocks `e8`, so the pawn can't legally advance, so the FSM emits `wont_help` instead of letting the promotion happen. Use `7k/...` (king elsewhere) when testing promotion.
|
||||
- **`ModeratorText` enum strings use `white_X` / `black_X`, not `w_X` / `b_X`.** Translator interpolates `moverWord` (`'white'`/`'black'`), not `move.color` (`'w'`/`'b'`). Old draft conflated them.
|
||||
- **Hash routing + path routing coexist.** The post-create flow updates `location.hash` to `#/g/<id>` (no full navigation, no LE re-cert). The share URL goes through `/g/<id>` and the SPA fallback. Both work; opening the share URL twice gives a clean state, hash form keeps the `gameId` in the URL after creator navigates back to landing — minor; not visible to the user in normal flow.
|
||||
|
||||
## Environment State
|
||||
|
||||
### Tools/Services Used
|
||||
|
||||
- `pnpm` 10.33.2 (corepack-installed at `~/.local/bin/pnpm`)
|
||||
- Node 22.22.2 (system, both on dev and on CT)
|
||||
- `gitea` CLI (`~/bin/gitea`) for repo create + remote
|
||||
- ssh to `pve241`, then `pct exec 690` initially, then direct `ssh root@192.168.0.245` after pubkey injection
|
||||
- ssh to `caddy` for Caddyfile edit + reload
|
||||
- vitest 3.2.4
|
||||
|
||||
### Active Processes
|
||||
|
||||
- `blind-chess.service` on CT 690 (192.168.0.245). Started 2026-04-28 ~15:12 UTC. systemd-managed, restarts on failure.
|
||||
- Caddy on CT 600 (192.168.0.185). Reloaded 2026-04-28 ~15:13 UTC after Caddyfile append.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `BLIND_CHESS_CT_IP=192.168.0.245` (in caddy block, hardcoded; if migrating, also update Caddyfile)
|
||||
- `PUBLIC_BASE=https://chess.sethpc.xyz` (set in the systemd unit)
|
||||
|
||||
## Related Resources
|
||||
|
||||
- Live URL: https://chess.sethpc.xyz
|
||||
- Repo: https://git.sethpc.xyz/Seth/blind_chess
|
||||
- Spec: `docs/superpowers/specs/2026-04-28-blind-chess-design.md`
|
||||
- Project identity: `CLAUDE.md`
|
||||
- Decisions: `DECISIONS.md`
|
||||
- Original brief: `IDEA.md`
|
||||
- Prior handoffs: `.claude/handoffs/2026-04-28-104344-spec-approved-ready-for-plan.md`, `.claude/handoffs/2026-04-28-kickoff.md`
|
||||
- chess.js: https://github.com/jhlywa/chess.js (v1.4.0)
|
||||
- Caddy config: `/etc/caddy/Caddyfile` on CT 600 — search for `chess.sethpc.xyz`
|
||||
- Systemd unit (canonical): `/etc/systemd/system/blind-chess.service` on CT 690
|
||||
|
||||
---
|
||||
|
||||
**Security Reminder**: This handoff contains the CT root password and operational details. Do not share publicly.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Handoff: blind_chess Kickoff (2026-04-28)
|
||||
|
||||
## What was done
|
||||
|
||||
Project scaffolded per `~/bin/CREATE_PROJECT.md`:
|
||||
- `~/bin/blind_chess/` directory created
|
||||
- `.claude/handoffs/` initialized (this is the first entry)
|
||||
- `GITEA_API.md` symlinked to `~/bin/GITEA_API.md`
|
||||
- `IDEA.md` created with empty template (Seth populates manually before next session)
|
||||
- `DECISIONS.md` created from local template
|
||||
- `CLAUDE.md` created with minimal stub pointing at IDEA.md as source of truth
|
||||
|
||||
No code, no repo, no deploy target yet. Project is in pure ideation.
|
||||
|
||||
## Current State
|
||||
|
||||
- **Phase:** ideation, pre-IDEA.md
|
||||
- **Repo:** none
|
||||
- **Branch:** n/a
|
||||
- **What's running:** nothing
|
||||
|
||||
## Next Steps (ordered)
|
||||
|
||||
**1. Read `IDEA.md` first.**
|
||||
Seth has populated it manually with the project brief between this session and the next. It is the authoritative source for what blind_chess is and what it should do. Do not skip — `CLAUDE.md`'s "Project Identity" section is intentionally empty and will be filled in *after* IDEA.md is read.
|
||||
|
||||
**2. Update `CLAUDE.md`.**
|
||||
Distill IDEA.md into:
|
||||
- A real one-line tagline (replace "Tagline TBD")
|
||||
- A one-paragraph "Project Identity" section (stable, won't change session-to-session)
|
||||
- Initial entries in "Current State" if any decisions emerged from IDEA.md (e.g., language choice, deploy target)
|
||||
|
||||
**3. Capture early decisions in `DECISIONS.md`.**
|
||||
Anything Seth committed to in IDEA.md (language, framework, deployment, naming, scope boundaries) goes into `DECISIONS.md` under Architecture or Implementation. Format: `YYYY-MM-DD: <decision> — <why>`. Include rejected options under "Deferred / Rejected".
|
||||
|
||||
**4. Brainstorm before building.**
|
||||
If IDEA.md leaves architecture or approach open, do not jump to implementation. Use the `superpowers:brainstorming` skill — explore intent and design first. Get Seth's confirmation on direction before code.
|
||||
|
||||
**5. Decide on Gitea repo.**
|
||||
The scaffold's optional step 6 (git init + Gitea push) was skipped. Once direction is clear, propose creating a Gitea repo via the `gitea` CLI:
|
||||
```bash
|
||||
cd ~/bin/blind_chess
|
||||
git init
|
||||
gitea create blind_chess
|
||||
gitea remote blind_chess
|
||||
echo "GITEA_API.md" >> .gitignore
|
||||
git add -A && git commit -m "init: scaffold project" && gitea push
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None yet — IDEA.md will surface these.
|
||||
|
||||
## Notes for the next session
|
||||
|
||||
- Project name spelled `blind_chess` (underscore, not hyphen). Convention in `~/bin/` is hyphen, but underscore was Seth's explicit choice — likely for Python module compatibility. Don't auto-rename.
|
||||
- Inherits all global homelab conventions from `~/bin/CLAUDE.md`. No need to duplicate them here.
|
||||
@@ -8,6 +8,7 @@ dist/
|
||||
build/
|
||||
.svelte-kit/
|
||||
.vite/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Logs / coverage
|
||||
*.log
|
||||
@@ -26,3 +27,7 @@ coverage/
|
||||
|
||||
# Handoff workspace artifacts (kept local)
|
||||
.claude/handoffs/*.draft.md
|
||||
.deploy-server/
|
||||
|
||||
# Local-only symlink to ~/bin/GITEA_API.md
|
||||
GITEA_API.md
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
auto-install-peers=true
|
||||
shamefully-hoist=false
|
||||
ignore-workspace-root-check=true
|
||||
+6084
File diff suppressed because it is too large
Load Diff
@@ -16,16 +16,31 @@ The system's most distinctive property: highlighting in blind mode reveals **zer
|
||||
|
||||
## Current State
|
||||
|
||||
- **Phase:** spec approved, ready to implement (planning next)
|
||||
- **Repo:** not yet (Gitea creation pending — see deferred steps in handoff)
|
||||
- **Stack:** Node 22 + TypeScript, Fastify + `ws`, Svelte + Vite, `chess.js`. pnpm workspace with `packages/{server,client,shared}`.
|
||||
- **Deploy target:** new LXC on node-241 behind Caddy CT 600 → `chess.sethpc.xyz`. Systemd-managed Node service on port 3000. In-memory game state only (no DB).
|
||||
- **Phase:** MVP **deployed and live** at https://chess.sethpc.xyz (2026-04-28).
|
||||
- **Repo:** `git.sethpc.xyz/Seth/blind_chess`.
|
||||
- **Stack:** Node 22 + TypeScript, Fastify + `ws`, Svelte 5 + Vite, `chess.js`. pnpm workspace with `packages/{server,client,shared}`.
|
||||
- **Deploy:** LXC **CT 690 on node-241** at 192.168.0.245, behind Caddy CT 600. Systemd unit `blind-chess.service`, port 3000. In-memory state only.
|
||||
- **Tests:** 43 passing — 21 in shared (geometric helper), 22 in server (FSM + view + 4 real-WS integration).
|
||||
- **Known gaps (deferred):** drag-and-drop input (click-to-move only), full integration coverage of every endgame path, mobile-specific polish, observability beyond `/api/health`.
|
||||
|
||||
## Key files
|
||||
|
||||
- `IDEA.md` — original project brief (Seth's words)
|
||||
- `DECISIONS.md` — locked architectural and gameplay decisions
|
||||
- `docs/superpowers/specs/2026-04-28-blind-chess-design.md` — full design spec (everything: data model, protocol, FSM, testing)
|
||||
- `docs/superpowers/specs/2026-04-28-blind-chess-design.md` — full design spec (data model, protocol, FSM, testing)
|
||||
- `packages/shared/src/geometric.ts` — the zero-leak helper. The signature is the proof.
|
||||
- `packages/server/src/view.ts` — `buildView`, the security boundary.
|
||||
- `packages/server/src/commit.ts` — touch-move FSM (the spec's hierarchy decision table).
|
||||
- `packages/server/src/translator.ts` — chess.js `Move` → moderator-vocabulary enum.
|
||||
- `deploy/blind-chess.service` — systemd unit (canonical at `/etc/systemd/system/blind-chess.service` on the CT).
|
||||
- `deploy/Caddyfile.snippet` — block already added to `/etc/caddy/Caddyfile` on CT 600.
|
||||
|
||||
## Operations
|
||||
|
||||
- **Logs:** `ssh root@192.168.0.245 'journalctl -u blind-chess -f'`
|
||||
- **Restart:** `ssh root@192.168.0.245 'systemctl restart blind-chess'`
|
||||
- **Health:** `curl https://chess.sethpc.xyz/api/health`
|
||||
- **Deploy update:** `pnpm -r build` → `pnpm --filter @blind-chess/server deploy --prod --legacy .deploy-server` → rsync server bundle to `/opt/blind-chess/server/` and client `dist/` to `/opt/blind-chess/client/dist/` → `chown -R blindchess:blindchess /opt/blind-chess` → `systemctl restart blind-chess`. Server restart drops in-memory games (acceptable for MVP).
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -38,6 +38,18 @@ Format: `YYYY-MM-DD: <decision> — <why>`
|
||||
- 2026-04-28: Resign + draw-offer/accept-decline flow — standard chess UX. Resignation ends without grace; disconnect applies grace.
|
||||
- 2026-04-28: Game-over screen reveals full board for both sides — post-game review is part of the experience.
|
||||
|
||||
## Implementation outcomes (2026-04-28 build session)
|
||||
|
||||
- 2026-04-28: **Repo:** `git.sethpc.xyz/Seth/blind_chess`. Created via `gitea create blind_chess`. Default branch `main`.
|
||||
- 2026-04-28: **CT:** 690 on node-241, hostname `blind-chess`, IP 192.168.0.245, Debian 12, Node 22.22.2. 2 cores / 512 MB RAM / 8 GB rootfs. Resting memory ~29 MB, plenty of headroom.
|
||||
- 2026-04-28: **Chosen `chess.js` v1.4.0** — uses `Move.isEnPassant()` / `isKingsideCastle()` / `isQueensideCastle()` instead of the deprecated `flags` string. The `Move` constructor's deprecated `flags` field is intentionally not relied upon.
|
||||
- 2026-04-28: **Half-move clock for the 50-move rule** is read from FEN field 4 (chess.js doesn't expose it directly). See `translator.ts:halfMoveClock`.
|
||||
- 2026-04-28: **Shared package import resolution** — `packages/shared/package.json` `main` and `exports` point to `./dist/`. Source `.ts` is dev-only. Always run `pnpm --filter @blind-chess/shared build` before `pnpm --filter @blind-chess/server build` (the workspace project refs handle this when running `pnpm -r build`).
|
||||
- 2026-04-28: **Client routing** is hash-based with a pathname fallback in `App.svelte` so `https://chess.sethpc.xyz/g/<id>` (the share URL) and `https://chess.sethpc.xyz/#/g/<id>` (the post-create URL) both render the game. The Fastify SPA fallback serves `index.html` on any non-matching `text/html` request.
|
||||
- 2026-04-28: **Click-to-move only** — drag-and-drop deferred. Tap-arm + tap-destination is faithful to the touch-move FSM and works identically on phone and desktop.
|
||||
- 2026-04-28: **WS path through Caddy** — `wss://chess.sethpc.xyz/ws?game=<id>` works without explicit `transport ws` config. Caddy's reverse_proxy handles upgrade transparently.
|
||||
- 2026-04-28: **Public DNS** — relies on existing `*.sethpc.xyz` wildcard pointing at the WAN IP; no Pi-hole entry was needed. Caddy host-routes `chess.sethpc.xyz` to 192.168.0.245:3000.
|
||||
|
||||
## Deferred / Rejected
|
||||
|
||||
<!-- Decisions NOT to do something are just as valuable -- prevents re-proposing rejected ideas -->
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Snippet for Caddy CT 600 — append to /etc/caddy/Caddyfile.
|
||||
# Backend: blind-chess LXC on node-241, port 3000.
|
||||
|
||||
chess.sethpc.xyz {
|
||||
reverse_proxy <BLIND_CHESS_CT_IP>:3000
|
||||
encode gzip zstd
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
[Unit]
|
||||
Description=blind_chess server (Fastify + ws)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=blindchess
|
||||
Group=blindchess
|
||||
WorkingDirectory=/opt/blind-chess
|
||||
ExecStart=/usr/bin/node /opt/blind-chess/server/dist/server.js
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PORT=3000
|
||||
Environment=HOST=0.0.0.0
|
||||
Environment=STATIC_DIR=/opt/blind-chess/client/dist
|
||||
Environment=PUBLIC_BASE=https://chess.sethpc.xyz
|
||||
Environment=LOG_LEVEL=info
|
||||
Restart=always
|
||||
RestartSec=2s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/blind-chess
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "blind_chess",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"test": "pnpm -r test",
|
||||
"dev:server": "pnpm --filter @blind-chess/server dev",
|
||||
"dev:client": "pnpm --filter @blind-chess/client dev",
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0c0d10" />
|
||||
<title>blind chess</title>
|
||||
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><text y='52' font-size='52'>♚</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@blind-chess/client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blind-chess/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import Landing from './lib/Landing.svelte';
|
||||
import Game from './lib/Game.svelte';
|
||||
|
||||
let route: { name: 'landing' } | { name: 'game'; id: string } = $state({ name: 'landing' });
|
||||
|
||||
function parseHash() {
|
||||
// Support both pathname-based (joinUrl: /g/<id>) and hash-based (#/g/<id>) routes.
|
||||
const hash = location.hash || '';
|
||||
const hashMatch = hash.match(/^#\/g\/([a-z0-9]{8})$/);
|
||||
if (hashMatch) {
|
||||
route = { name: 'game', id: hashMatch[1]! };
|
||||
return;
|
||||
}
|
||||
const pathMatch = location.pathname.match(/^\/g\/([a-z0-9]{8})\/?$/);
|
||||
if (pathMatch) {
|
||||
route = { name: 'game', id: pathMatch[1]! };
|
||||
return;
|
||||
}
|
||||
route = { name: 'landing' };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
parseHash();
|
||||
const handler = () => parseHash();
|
||||
window.addEventListener('hashchange', handler);
|
||||
return () => window.removeEventListener('hashchange', handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if route.name === 'landing'}
|
||||
<Landing />
|
||||
{:else}
|
||||
<Game gameId={route.id} />
|
||||
{/if}
|
||||
@@ -0,0 +1,64 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0c0d10;
|
||||
--panel: #15171c;
|
||||
--panel-2: #1d2027;
|
||||
--border: #2a2e38;
|
||||
--text: #e8e8ea;
|
||||
--text-dim: #8d92a0;
|
||||
--accent: #d35400;
|
||||
--accent-dim: #8a3a09;
|
||||
--light: #d8c8a8;
|
||||
--dark: #6b4f3a;
|
||||
--highlight: rgba(74, 222, 128, 0.55);
|
||||
--highlight-cap: rgba(248, 113, 113, 0.65);
|
||||
--armed: rgba(211, 84, 0, 0.55);
|
||||
--touched: rgba(211, 84, 0, 0.85);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #app { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--text); }
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #252932; border-color: var(--accent); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
button.primary:hover { background: #e2671f; border-color: #e2671f; }
|
||||
|
||||
input, select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.muted { color: var(--text-dim); }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, monospace; }
|
||||
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
import { ALL_SQUARES, FILES, RANKS, geometricMoves, type Color, type Piece, type Square } from '@blind-chess/shared';
|
||||
import { pieceGlyph } from './pieces.js';
|
||||
|
||||
interface Props {
|
||||
pieces: Partial<Record<Square, Piece>>;
|
||||
you: Color;
|
||||
toMove: Color;
|
||||
mode: 'blind' | 'vanilla';
|
||||
highlightingEnabled: boolean;
|
||||
armedSquare: Square | null; // local visual arm (pre-commit)
|
||||
touchedSquare: Square | null; // server-authoritative touch
|
||||
onArm: (sq: Square | null) => void;
|
||||
onCommit: (from: Square, to: Square) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
pieces, you, toMove, mode, highlightingEnabled,
|
||||
armedSquare, touchedSquare, onArm, onCommit,
|
||||
}: Props = $props();
|
||||
|
||||
const ranksDisplay = $derived(you === 'w' ? [...RANKS].reverse() : [...RANKS]);
|
||||
const filesDisplay = $derived(you === 'w' ? [...FILES] : [...FILES].reverse());
|
||||
|
||||
const ownSquares = $derived(
|
||||
new Set<Square>(
|
||||
Object.entries(pieces)
|
||||
.filter(([_, p]) => p?.color === you)
|
||||
.map(([sq]) => sq as Square),
|
||||
),
|
||||
);
|
||||
|
||||
// Highlight set: only when toggle is ON, only for the active piece (touched
|
||||
// wins over armed). Geometric in blind mode (no opponent input). In vanilla
|
||||
// mode we'd ideally use chess.js legal moves, but for simplicity we use the
|
||||
// same geometric set with the understanding that the client is non-authoritative.
|
||||
const highlightFrom = $derived(touchedSquare ?? armedSquare);
|
||||
const highlights = $derived.by(() => {
|
||||
if (!highlightingEnabled || !highlightFrom) return new Set<Square>();
|
||||
const piece = pieces[highlightFrom];
|
||||
if (!piece) return new Set<Square>();
|
||||
const moves = geometricMoves(piece, highlightFrom, ownSquares);
|
||||
return new Set(moves);
|
||||
});
|
||||
|
||||
function squareColor(sq: Square): 'light' | 'dark' {
|
||||
const f = sq.charCodeAt(0) - 'a'.charCodeAt(0);
|
||||
const r = parseInt(sq[1]!, 10) - 1;
|
||||
return (f + r) % 2 === 0 ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function onSquareClick(sq: Square) {
|
||||
const piece = pieces[sq];
|
||||
|
||||
// If a piece is touched (server-authoritative), only commits to that square's destinations are valid.
|
||||
if (touchedSquare) {
|
||||
if (sq === touchedSquare) return; // tap own piece is no-op
|
||||
onCommit(touchedSquare, sq);
|
||||
return;
|
||||
}
|
||||
|
||||
// If a piece is armed locally (no commit yet), clicking another own piece reassigns,
|
||||
// clicking elsewhere commits the armed piece to that destination.
|
||||
if (armedSquare) {
|
||||
if (piece?.color === you) {
|
||||
onArm(sq); // reassign
|
||||
return;
|
||||
}
|
||||
onCommit(armedSquare, sq); // commit to destination
|
||||
return;
|
||||
}
|
||||
|
||||
// No arm yet. Tap an own piece to arm it.
|
||||
if (piece?.color === you) {
|
||||
onArm(sq);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="board" class:flipped={you === 'b'}>
|
||||
{#each ranksDisplay as r (r)}
|
||||
{#each filesDisplay as f (f)}
|
||||
{@const sq = `${f}${r}` as Square}
|
||||
{@const piece = pieces[sq]}
|
||||
{@const sc = squareColor(sq)}
|
||||
{@const isArmed = sq === armedSquare}
|
||||
{@const isTouched = sq === touchedSquare}
|
||||
{@const isHighlight = highlights.has(sq)}
|
||||
{@const isHighlightCap = isHighlight && piece && piece.color !== you}
|
||||
<button
|
||||
type="button"
|
||||
class="sq sq-{sc}"
|
||||
class:armed={isArmed}
|
||||
class:touched={isTouched}
|
||||
class:hl={isHighlight && !isHighlightCap}
|
||||
class:hl-cap={isHighlightCap}
|
||||
onclick={() => onSquareClick(sq)}
|
||||
aria-label={sq}
|
||||
>
|
||||
{#if r === (you === 'w' ? '1' : '8') && f === filesDisplay[0]}
|
||||
<span class="coord coord-rank">{r}</span>
|
||||
{/if}
|
||||
{#if r === (you === 'w' ? '1' : '8')}
|
||||
<span class="coord coord-file">{f}</span>
|
||||
{/if}
|
||||
{#if piece}
|
||||
<span class="piece piece-{piece.color}">{pieceGlyph(piece)}</span>
|
||||
{/if}
|
||||
{#if isHighlight && !piece}
|
||||
<span class="dot"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
grid-template-rows: repeat(8, 1fr);
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
max-width: min(85vh, 92vw);
|
||||
margin: 0 auto;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sq {
|
||||
position: relative;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: clamp(28px, 5.5vw, 56px);
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.sq-light { background: var(--light); }
|
||||
.sq-dark { background: var(--dark); }
|
||||
.sq:hover { filter: brightness(1.1); }
|
||||
|
||||
.piece {
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.piece-w { color: #fafafa; }
|
||||
.piece-b { color: #1a1a1a; }
|
||||
|
||||
.armed { box-shadow: inset 0 0 0 4px var(--armed); }
|
||||
.touched { box-shadow: inset 0 0 0 4px var(--touched); }
|
||||
.hl::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background: radial-gradient(circle, var(--highlight) 18%, transparent 22%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hl-cap { box-shadow: inset 0 0 0 4px var(--highlight-cap); }
|
||||
|
||||
.dot { display: none; } /* using ::before above */
|
||||
|
||||
.coord {
|
||||
position: absolute;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(0,0,0,0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
.coord-rank { left: 3px; top: 3px; }
|
||||
.coord-file { right: 3px; bottom: 1px; }
|
||||
.sq-dark .coord { color: rgba(255,255,255,0.5); }
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
<script lang="ts">
|
||||
import { game } from './stores/game.js';
|
||||
import Board from './Board.svelte';
|
||||
import ModeratorPanel from './ModeratorPanel.svelte';
|
||||
import PromotionDialog from './PromotionDialog.svelte';
|
||||
import type { PromotionType, Square } from '@blind-chess/shared';
|
||||
|
||||
interface Props { gameId: string; }
|
||||
let { gameId }: Props = $props();
|
||||
|
||||
let armedSquare: Square | null = $state(null);
|
||||
let pendingPromotion: { from: Square; to: Square } | null = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
game.connect(gameId);
|
||||
return () => game.disconnect();
|
||||
});
|
||||
|
||||
// Once the server commits, our local arm clears (visual state slaved to server).
|
||||
$effect(() => {
|
||||
if (game.state.touchedPiece) armedSquare = null;
|
||||
if (game.state.gameStatus === 'finished') armedSquare = null;
|
||||
});
|
||||
|
||||
function onArm(sq: Square | null) {
|
||||
armedSquare = sq;
|
||||
}
|
||||
|
||||
function onCommit(from: Square, to: Square) {
|
||||
const piece = game.state.view?.pieces[from];
|
||||
if (!piece) return;
|
||||
// Promotion check (white pawn to rank 8, black pawn to rank 1).
|
||||
if (piece.type === 'p') {
|
||||
const rank = to[1];
|
||||
if ((piece.color === 'w' && rank === '8') || (piece.color === 'b' && rank === '1')) {
|
||||
pendingPromotion = { from, to };
|
||||
return;
|
||||
}
|
||||
}
|
||||
game.commit(from, to);
|
||||
}
|
||||
|
||||
function choosePromotion(p: PromotionType) {
|
||||
if (!pendingPromotion) return;
|
||||
game.commit(pendingPromotion.from, pendingPromotion.to, p);
|
||||
pendingPromotion = null;
|
||||
}
|
||||
|
||||
const joinUrl = $derived(`${location.origin}/#/g/${gameId}`);
|
||||
|
||||
let copied = $state(false);
|
||||
function copyLink() {
|
||||
navigator.clipboard.writeText(joinUrl);
|
||||
copied = true;
|
||||
setTimeout(() => copied = false, 1500);
|
||||
}
|
||||
|
||||
const turnLabel = $derived.by(() => {
|
||||
if (game.state.gameStatus === 'finished') {
|
||||
const reason = game.state.endReason;
|
||||
const winner = game.state.winner;
|
||||
if (reason === 'checkmate') return `Checkmate — ${winner === 'w' ? 'White' : 'Black'} wins`;
|
||||
if (reason === 'resign') return `${winner === 'w' ? 'White' : 'Black'} wins by resignation`;
|
||||
if (reason === 'abandoned') return winner ? `${winner === 'w' ? 'White' : 'Black'} wins by abandonment` : 'Game abandoned';
|
||||
if (reason) return `Draw — ${reason}`;
|
||||
return 'Game over';
|
||||
}
|
||||
if (game.state.gameStatus === 'waiting') return 'Waiting for opponent…';
|
||||
if (!game.state.you) return '…';
|
||||
if (game.state.view?.toMove === game.state.you) return 'Your turn';
|
||||
return 'Opponent thinking';
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="game-layout" class:waiting={game.state.gameStatus === 'waiting'}>
|
||||
|
||||
<div class="topbar">
|
||||
<a href="#/" class="back">← New game</a>
|
||||
<span class="status">{turnLabel}</span>
|
||||
<span class="mode-badge">
|
||||
{game.state.mode ?? '…'}
|
||||
{#if game.state.you}
|
||||
· You: {game.state.you === 'w' ? 'White' : 'Black'}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if game.state.gameStatus === 'waiting'}
|
||||
<div class="waiting-card">
|
||||
<h2>Share this link to start the game</h2>
|
||||
<p class="muted">The first person to open it claims the open color.</p>
|
||||
<div class="link-row">
|
||||
<input class="link" type="text" readonly value={joinUrl} onclick={(e) => (e.currentTarget as HTMLInputElement).select()} />
|
||||
<button class="primary" onclick={copyLink}>{copied ? 'Copied!' : 'Copy'}</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if game.state.view && game.state.you}
|
||||
<div class="board-area">
|
||||
<Board
|
||||
pieces={game.state.view.pieces}
|
||||
you={game.state.you}
|
||||
toMove={game.state.view.toMove}
|
||||
mode={game.state.mode ?? 'blind'}
|
||||
highlightingEnabled={game.state.highlightingEnabled}
|
||||
armedSquare={armedSquare}
|
||||
touchedSquare={game.state.touchedPiece}
|
||||
{onArm}
|
||||
{onCommit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside class="side">
|
||||
<ModeratorPanel announcements={game.state.announcements} you={game.state.you} />
|
||||
|
||||
<div class="actions">
|
||||
{#if game.state.gameStatus === 'active'}
|
||||
<button onclick={() => game.offerDraw()} disabled={!!game.state.drawOffer}>Offer draw</button>
|
||||
<button onclick={() => { if (confirm('Resign?')) game.resign(); }}>Resign</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if game.state.drawOffer && game.state.drawOffer.from !== game.state.you}
|
||||
<div class="banner">
|
||||
Opponent offers a draw.
|
||||
<div class="row">
|
||||
<button class="primary" onclick={() => game.respondDraw(true)}>Accept</button>
|
||||
<button onclick={() => game.respondDraw(false)}>Decline</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if game.state.drawOffer && game.state.drawOffer.from === game.state.you}
|
||||
<div class="banner muted">Draw offer pending…</div>
|
||||
{/if}
|
||||
|
||||
{#if game.state.lastError && Date.now() - game.state.lastError.at < 4000}
|
||||
<div class="banner err">⚠ {game.state.lastError.code}: {game.state.lastError.message}</div>
|
||||
{/if}
|
||||
|
||||
{#if !game.state.opponentConnected && game.state.gameStatus === 'active'}
|
||||
<div class="banner muted">Opponent disconnected — 5 minute grace window.</div>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if pendingPromotion && game.state.you}
|
||||
<PromotionDialog
|
||||
color={game.state.you}
|
||||
onChoose={choosePromotion}
|
||||
onCancel={() => pendingPromotion = null}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.game-layout {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.game-layout {
|
||||
grid-template-columns: 1fr 320px;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
'top top'
|
||||
'board side';
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.topbar { grid-area: top; }
|
||||
.board-area { grid-area: board; }
|
||||
.side { grid-area: side; }
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.back { color: var(--text-dim); }
|
||||
.status { flex: 1; text-align: center; font-weight: 600; }
|
||||
.mode-badge {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.board-area { display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 280px;
|
||||
}
|
||||
.actions { display: flex; gap: 8px; }
|
||||
.actions button { flex: 1; }
|
||||
|
||||
.banner {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.banner.err { border-color: #f87171; color: #f87171; }
|
||||
.banner .row { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.banner .row button { flex: 1; }
|
||||
|
||||
.waiting-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 520px;
|
||||
margin: 40px auto;
|
||||
}
|
||||
.waiting-card h2 { margin: 0 0 8px; font-size: 18px; }
|
||||
.link-row { display: flex; gap: 8px; margin-top: 16px; }
|
||||
.link { flex: 1; font-family: ui-monospace, monospace; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import type { Mode, Color, CreateGameResponse } from '@blind-chess/shared';
|
||||
|
||||
let mode: Mode = $state('blind');
|
||||
let side: Color | 'random' = $state('random');
|
||||
let highlightingEnabled = $state(false);
|
||||
let creating = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
async function create() {
|
||||
creating = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await fetch('/api/games', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ mode, side, highlightingEnabled }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json: CreateGameResponse & { creatorColor: Color } = await res.json();
|
||||
// store creator token before navigating
|
||||
localStorage.setItem(`bc:${json.gameId}`, json.creatorToken);
|
||||
location.hash = `#/g/${json.gameId}`;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="hero">
|
||||
<h1>blind <span class="accent">chess</span></h1>
|
||||
<p class="tagline">A two-player chess variant where each player sees only their own pieces. The server is the moderator.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Create a game</h2>
|
||||
|
||||
<div class="field">
|
||||
<span class="lbl">Mode</span>
|
||||
<div class="opts">
|
||||
<label class="opt" class:active={mode === 'blind'}>
|
||||
<input type="radio" bind:group={mode} value="blind" />
|
||||
<span class="opt-title">Blind</span>
|
||||
<span class="opt-sub">Each player sees only their own pieces.</span>
|
||||
</label>
|
||||
<label class="opt" class:active={mode === 'vanilla'}>
|
||||
<input type="radio" bind:group={mode} value="vanilla" />
|
||||
<span class="opt-title">Vanilla</span>
|
||||
<span class="opt-sub">Normal chess. Both players see everything.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="lbl">You play as</span>
|
||||
<div class="row">
|
||||
<label><input type="radio" bind:group={side} value="w" /> White</label>
|
||||
<label><input type="radio" bind:group={side} value="b" /> Black</label>
|
||||
<label><input type="radio" bind:group={side} value="random" /> Random</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" bind:checked={highlightingEnabled} />
|
||||
<span>Highlight reachable squares</span>
|
||||
{#if mode === 'blind'}
|
||||
<span class="hint muted">(geometric only — no opponent info)</span>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="primary big" disabled={creating} onclick={create}>
|
||||
{creating ? 'Creating…' : 'Create game'}
|
||||
</button>
|
||||
|
||||
{#if error}
|
||||
<p class="error">Error: {error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer class="muted">
|
||||
<span class="mono">git.sethpc.xyz/Seth/blind_chess</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 540px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 80px;
|
||||
min-height: 100%;
|
||||
}
|
||||
.hero { text-align: center; margin-bottom: 32px; }
|
||||
h1 {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.accent { color: var(--accent); }
|
||||
.tagline {
|
||||
color: var(--text-dim);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 22px;
|
||||
}
|
||||
h2 { font-size: 18px; margin: 0 0 16px; }
|
||||
|
||||
.field { margin-bottom: 20px; }
|
||||
.lbl {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.opts { display: grid; gap: 8px; }
|
||||
.opt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border 0.15s, background 0.15s;
|
||||
}
|
||||
.opt:hover { border-color: var(--accent-dim); }
|
||||
.opt.active { border-color: var(--accent); background: rgba(211,84,0,0.07); }
|
||||
.opt input { display: none; }
|
||||
.opt-title { font-weight: 600; }
|
||||
.opt-sub { color: var(--text-dim); font-size: 13px; margin-top: 2px; }
|
||||
|
||||
.row { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.row label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
||||
|
||||
.toggle { display: flex; align-items: center; gap: 8px; cursor: pointer; flex-wrap: wrap; }
|
||||
.hint { font-size: 13px; }
|
||||
|
||||
button.big {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.error { color: #f87171; margin-top: 12px; }
|
||||
footer { text-align: center; margin-top: 24px; font-size: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import type { Announcement, Color } from '@blind-chess/shared';
|
||||
import { moderatorText } from './moderator-strings.js';
|
||||
|
||||
interface Props {
|
||||
announcements: Announcement[];
|
||||
you: Color;
|
||||
}
|
||||
let { announcements, you }: Props = $props();
|
||||
|
||||
// Show only entries this viewer is allowed to see.
|
||||
const visible = $derived(
|
||||
announcements.filter((a) => a.audience === 'both' || a.audience === you),
|
||||
);
|
||||
|
||||
let scrollEl: HTMLDivElement | null = $state(null);
|
||||
$effect(() => {
|
||||
void visible.length;
|
||||
if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<header>Moderator</header>
|
||||
<div class="log" bind:this={scrollEl}>
|
||||
{#each visible as a, i (i)}
|
||||
<div class="entry" class:err={a.text === 'illegal_move' || a.text === 'no_such_piece' || a.text === 'no_legal_moves' || a.text === 'wont_help'}>
|
||||
<span class="ply">{a.ply > 0 ? `#${a.ply}` : ''}</span>
|
||||
<span class="text">{moderatorText(a.text, a.payload)}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty muted">The moderator is silent.</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
}
|
||||
header {
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.log {
|
||||
padding: 8px 14px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.entry {
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-bottom: 1px dashed rgba(255,255,255,0.05);
|
||||
}
|
||||
.entry:last-child { border-bottom: none; }
|
||||
.entry.err .text { color: #f87171; }
|
||||
.ply {
|
||||
color: var(--text-dim);
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
}
|
||||
.empty { padding: 6px 0; font-style: italic; }
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { Color, PromotionType } from '@blind-chess/shared';
|
||||
|
||||
interface Props {
|
||||
color: Color;
|
||||
onChoose: (p: PromotionType) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
let { color, onChoose, onCancel }: Props = $props();
|
||||
|
||||
const glyphs: Record<PromotionType, { w: string; b: string; name: string }> = {
|
||||
q: { w: '♕', b: '♛', name: 'Queen' },
|
||||
r: { w: '♖', b: '♜', name: 'Rook' },
|
||||
b: { w: '♗', b: '♝', name: 'Bishop' },
|
||||
n: { w: '♘', b: '♞', name: 'Knight' },
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="overlay" role="dialog" aria-modal="true" aria-label="Choose promotion">
|
||||
<div class="card">
|
||||
<h3>Promote pawn</h3>
|
||||
<div class="row">
|
||||
{#each Object.entries(glyphs) as [k, g] (k)}
|
||||
<button class="choice" onclick={() => onChoose(k as PromotionType)}>
|
||||
<span class="glyph piece-{color}">{color === 'w' ? g.w : g.b}</span>
|
||||
<span class="lbl">{g.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="cancel" onclick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 18px;
|
||||
max-width: 360px;
|
||||
width: 92vw;
|
||||
}
|
||||
h3 { margin: 0 0 14px; }
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.choice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 4px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.glyph { font-size: 36px; line-height: 1; }
|
||||
.piece-w { color: #fafafa; text-shadow: 0 1px 2px rgba(0,0,0,0.5); }
|
||||
.piece-b { color: #1a1a1a; text-shadow: 0 1px 1px rgba(255,255,255,0.3); background: var(--light); border-radius: 4px; padding: 2px 6px; }
|
||||
.lbl { font-size: 12px; margin-top: 4px; color: var(--text-dim); }
|
||||
.cancel { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ModeratorText } from '@blind-chess/shared';
|
||||
|
||||
const MAP: Record<ModeratorText, string> = {
|
||||
no_such_piece: 'That piece no longer exists.',
|
||||
no_legal_moves: 'That piece has no legal moves.',
|
||||
wont_help: 'Moving that piece will not help you.',
|
||||
illegal_move: 'Illegal move.',
|
||||
white_moved: 'White has moved.',
|
||||
black_moved: 'Black has moved.',
|
||||
white_moved_captured: 'White has moved and captured.',
|
||||
black_moved_captured: 'Black has moved and captured.',
|
||||
white_moved_captured_ep: 'White has captured en passant.',
|
||||
black_moved_captured_ep: 'Black has captured en passant.',
|
||||
white_castled_kingside: 'White has castled kingside.',
|
||||
white_castled_queenside: 'White has castled queenside.',
|
||||
black_castled_kingside: 'Black has castled kingside.',
|
||||
black_castled_queenside: 'Black has castled queenside.',
|
||||
white_in_check: 'White is in check.',
|
||||
black_in_check: 'Black is in check.',
|
||||
white_promoted: 'White has promoted.',
|
||||
black_promoted: 'Black has promoted.',
|
||||
white_checkmate: 'Checkmate. White wins.',
|
||||
black_checkmate: 'Checkmate. Black wins.',
|
||||
stalemate: 'Stalemate. The game is a draw.',
|
||||
draw_insufficient: 'Draw — insufficient material.',
|
||||
draw_fifty: 'Draw — fifty-move rule.',
|
||||
draw_threefold: 'Draw — threefold repetition.',
|
||||
white_resigned: 'White has resigned.',
|
||||
black_resigned: 'Black has resigned.',
|
||||
draw_agreed: 'The game is a draw by agreement.',
|
||||
game_abandoned: 'The game has been abandoned.',
|
||||
};
|
||||
|
||||
export function moderatorText(
|
||||
text: ModeratorText,
|
||||
payload?: { promotedTo?: 'q' | 'r' | 'b' | 'n' | 'p' | 'k' },
|
||||
): string {
|
||||
const base = MAP[text];
|
||||
if ((text === 'white_promoted' || text === 'black_promoted') && payload?.promotedTo) {
|
||||
const piece = { q: 'queen', r: 'rook', b: 'bishop', n: 'knight', p: 'pawn', k: 'king' }[payload.promotedTo];
|
||||
return base.replace('promoted.', `promoted to a ${piece}.`);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Piece } from '@blind-chess/shared';
|
||||
|
||||
export function pieceGlyph(piece: Piece): string {
|
||||
const map: Record<string, string> = {
|
||||
wk: '♔', wq: '♕', wr: '♖', wb: '♗', wn: '♘', wp: '♙',
|
||||
bk: '♚', bq: '♛', br: '♜', bb: '♝', bn: '♞', bp: '♟',
|
||||
};
|
||||
return map[`${piece.color}${piece.type}`] ?? '?';
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type {
|
||||
Announcement,
|
||||
BoardView,
|
||||
ClientMessage,
|
||||
Color,
|
||||
ErrorCode,
|
||||
GameStatus,
|
||||
Mode,
|
||||
PromotionType,
|
||||
ServerMessage,
|
||||
Square,
|
||||
EndReason,
|
||||
} from '@blind-chess/shared';
|
||||
|
||||
interface GameStateValue {
|
||||
ws: WebSocket | null;
|
||||
connected: boolean;
|
||||
gameId: string | null;
|
||||
you: Color | null;
|
||||
view: BoardView | null;
|
||||
announcements: Announcement[];
|
||||
gameStatus: GameStatus;
|
||||
mode: Mode | null;
|
||||
highlightingEnabled: boolean;
|
||||
touchedPiece: Square | null;
|
||||
drawOffer: { from: Color } | null;
|
||||
endReason: EndReason | null;
|
||||
winner: Color | null;
|
||||
opponentConnected: boolean;
|
||||
lastError: { code: ErrorCode; message: string; at: number } | null;
|
||||
}
|
||||
|
||||
function makeStore() {
|
||||
const state = $state<GameStateValue>({
|
||||
ws: null,
|
||||
connected: false,
|
||||
gameId: null,
|
||||
you: null,
|
||||
view: null,
|
||||
announcements: [],
|
||||
gameStatus: 'waiting',
|
||||
mode: null,
|
||||
highlightingEnabled: false,
|
||||
touchedPiece: null,
|
||||
drawOffer: null,
|
||||
endReason: null,
|
||||
winner: null,
|
||||
opponentConnected: false,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
function tokenKey(gameId: string) { return `bc:${gameId}`; }
|
||||
|
||||
function connect(gameId: string, joinAs?: Color | 'auto') {
|
||||
if (state.ws) state.ws.close();
|
||||
state.gameId = gameId;
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const url = `${proto}://${location.host}/ws?game=${gameId}`;
|
||||
const ws = new WebSocket(url);
|
||||
state.ws = ws;
|
||||
ws.onopen = () => {
|
||||
state.connected = true;
|
||||
const token = localStorage.getItem(tokenKey(gameId)) ?? undefined;
|
||||
const hello: ClientMessage = token
|
||||
? { type: 'hello', gameId, token }
|
||||
: { type: 'hello', gameId, joinAs: joinAs ?? 'auto' };
|
||||
ws.send(JSON.stringify(hello));
|
||||
};
|
||||
ws.onmessage = (ev) => onServerMessage(JSON.parse(ev.data) as ServerMessage);
|
||||
ws.onclose = () => {
|
||||
state.connected = false;
|
||||
// attempt reconnect after 2s if game is still active
|
||||
if (state.gameStatus === 'active') {
|
||||
setTimeout(() => { if (state.gameId === gameId) connect(gameId); }, 2000);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => { state.connected = false; };
|
||||
}
|
||||
|
||||
function onServerMessage(m: ServerMessage) {
|
||||
switch (m.type) {
|
||||
case 'joined':
|
||||
if (m.you === 'spectator-rejected') {
|
||||
state.lastError = { code: 'spectators_disabled', message: 'both slots filled', at: Date.now() };
|
||||
return;
|
||||
}
|
||||
state.you = m.you;
|
||||
state.view = m.view;
|
||||
state.announcements = m.announcements;
|
||||
state.gameStatus = m.gameStatus;
|
||||
state.mode = m.mode;
|
||||
state.highlightingEnabled = m.highlightingEnabled;
|
||||
state.opponentConnected = m.opponentConnected;
|
||||
if (state.gameId) localStorage.setItem(tokenKey(state.gameId), m.token);
|
||||
break;
|
||||
case 'update':
|
||||
state.view = m.view;
|
||||
state.gameStatus = m.gameStatus;
|
||||
state.touchedPiece = m.touchedPiece ?? null;
|
||||
state.drawOffer = m.drawOffer ?? null;
|
||||
state.endReason = m.endReason ?? null;
|
||||
state.winner = m.winner ?? null;
|
||||
if (m.newAnnouncements.length) {
|
||||
state.announcements = [...state.announcements, ...m.newAnnouncements];
|
||||
}
|
||||
break;
|
||||
case 'peer-status':
|
||||
if (state.you && m.color !== state.you) {
|
||||
state.opponentConnected = m.connected;
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
state.lastError = { code: m.code, message: m.message, at: Date.now() };
|
||||
break;
|
||||
case 'ping':
|
||||
send({ type: 'pong' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function send(m: ClientMessage) {
|
||||
if (state.ws?.readyState === WebSocket.OPEN) {
|
||||
state.ws.send(JSON.stringify(m));
|
||||
}
|
||||
}
|
||||
|
||||
function commit(from: Square, to?: Square, promotion?: PromotionType) {
|
||||
send({ type: 'commit', from, to, promotion });
|
||||
}
|
||||
|
||||
function resign() { send({ type: 'resign' }); }
|
||||
function offerDraw() { send({ type: 'offer-draw' }); }
|
||||
function respondDraw(accept: boolean) { send({ type: 'respond-draw', accept }); }
|
||||
|
||||
function disconnect() {
|
||||
state.ws?.close();
|
||||
state.ws = null;
|
||||
state.connected = false;
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
connect,
|
||||
disconnect,
|
||||
commit,
|
||||
resign,
|
||||
offerDraw,
|
||||
respondDraw,
|
||||
};
|
||||
}
|
||||
|
||||
export const game = makeStore();
|
||||
@@ -0,0 +1,6 @@
|
||||
import { mount } from 'svelte';
|
||||
import App from './App.svelte';
|
||||
import './app.css';
|
||||
|
||||
const app = mount(App, { target: document.getElementById('app')! });
|
||||
export default app;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"types": ["svelte", "vite/client"],
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src/**/*", "src/**/*.svelte"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
'/ws': { target: 'ws://localhost:3000', ws: true, rewriteWsOrigin: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
@@ -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"}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
Generated
+2492
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- 'packages/*'
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"isolatedModules": true,
|
||||
"declaration": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user