Compare commits
55 Commits
6d457a2321
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d3ac69cbb | |||
| 33a7cef656 | |||
| fef6dcf095 | |||
| d95ab2abf1 | |||
| c01244c850 | |||
| 5d995eb428 | |||
| d10e581243 | |||
| 077330054b | |||
| 0c0e739bd3 | |||
| 2e808008b1 | |||
| 59717b3b5b | |||
| 82a69d8812 | |||
| 313837eb21 | |||
| 816f89be36 | |||
| c65db03cfa | |||
| 599dc17f44 | |||
| 4b3e587f6c | |||
| f52f7dbb8f | |||
| bd98315fe3 | |||
| 0583984723 | |||
| 2ae2c8013c | |||
| a574100e25 | |||
| 783d85a40c | |||
| 3169995d7f | |||
| ce36755a89 | |||
| 0498f1de43 | |||
| 5282237027 | |||
| 558891ed37 | |||
| 76717cf52e | |||
| 41b3ab93bb | |||
| be8ecd96b6 | |||
| f8faa11b6d | |||
| b01f324c3b | |||
| e75f5fff7b | |||
| 04494fcdee | |||
| f00164ebbb | |||
| dc7f8adcdf | |||
| 1213ec8fb1 | |||
| 1674695eef | |||
| 7c18725586 | |||
| dc5e6678b9 | |||
| 06bd144f7c | |||
| 31f68db654 | |||
| cb8e017792 | |||
| 73d5d0cb93 | |||
| 88bc23b0d0 | |||
| a9660c0694 | |||
| 58e1fc5bd8 | |||
| 9a837ec319 | |||
| 4407110147 | |||
| 3798b9c00d | |||
| ebd1463b0a | |||
| aa7bc30ee1 | |||
| f48e0a9cdf | |||
| bc954f4748 |
@@ -0,0 +1,143 @@
|
||||
# Handoff: AI Phase 1 (Casual bot) shipped
|
||||
|
||||
## Session Metadata
|
||||
|
||||
- Created: 2026-04-28 ~19:15 UTC
|
||||
- Project: /home/claude/bin/blind_chess
|
||||
- Branch: `feat/ai-player-phase-1-casual` merged to `main` via fast-forward at commit `1674695` and pushed.
|
||||
- Repo: `git.sethpc.xyz/Seth/blind_chess`
|
||||
- Live URL: **https://chess.sethpc.xyz** (Phase 1 deployed and verified)
|
||||
|
||||
## Handoff Chain
|
||||
|
||||
- **Continues from**: [2026-04-28-170713-ai-player-spec.md](./2026-04-28-170713-ai-player-spec.md) — AI player spec written and approved.
|
||||
- **Supersedes**: None.
|
||||
|
||||
## Current State Summary
|
||||
|
||||
Phase 1 of the AI player feature (Casual bot) is **deployed and live**. Playing vs a Casual bot is now an option from the landing page, alongside the existing "play with a friend" flow.
|
||||
|
||||
This session executed `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md` via subagent-driven development: 13 tasks, dispatched as fresh subagents per task with two-stage review (spec compliance + code quality). Several tasks surfaced real plan bugs that subagents fixed inline; the most consequential reversal was during Task 11 (self-play harness): the hand-rolled scoring algorithm in `CasualBrain` lost to a random-move baseline 7-7 in 100-game self-play, far below the spec's ≥80% acceptance bar. Solution: swapped vanilla-mode CasualBrain to delegate to `js-chess-engine` (level 2, randomness=30); blind mode kept the heuristic. Casual now wins 96-97% vs Random in vanilla, in both colors.
|
||||
|
||||
## Architecture Overview (what's deployed)
|
||||
|
||||
- **`packages/server/src/bot/`** — new module:
|
||||
- `brain.ts` — `Brain` interface, `BrainInput`/`BrainAction`/`CandidateMove`/`AttemptHistoryEntry` types. `BrainInput.fen` set ONLY in vanilla mode (preserves view-filter invariant).
|
||||
- `candidates.ts` — `legalCandidates(game, color)`. Vanilla: `chess.js .moves({verbose: true})`. Blind: `geometricMoves` over own pieces + promotion expansion.
|
||||
- `casual-brain.ts` — `CasualBrain implements Brain`. Vanilla: delegates to `js-chess-engine` at level 2; blind: heuristic scoring (capture proxy / development / center / advance). Promotion default: queen. Draw response based on own material count.
|
||||
- `driver.ts` — `BotDriver` per-game orchestrator. Mutex via `decideInFlight`, retry cap of 5, dispatches via `handleCommit`/announce, on game end calls `brain.dispose?.()`.
|
||||
- `index.ts` — public re-exports.
|
||||
- **`packages/server/src/game-end.ts`** — extracted from `ws.ts`: `endGame`/`finalizeIfEnded`. Both `ws.ts` and `bot/driver.ts` use it.
|
||||
- **`packages/server/src/games.ts`** — bot driver registry (`attachBotDriver`, `getBotDriver`, `disposeBotDriver`). `createGame` accepts optional `vsAi: { brain }` and fills the bot's slot with a synthetic player slot (random token, no socket). `pruneFinished` cleans the registry.
|
||||
- **`packages/server/src/state.ts`** — `Game` gains optional `aiOpponent?: { color; brain }` (informational) and required `lastBroadcastIdx: { w: number; b: number }` (per-color watermark for slice broadcasting).
|
||||
- **`packages/server/src/ws.ts`** — refactored: `pokeBot(game)` helper called after every state-mutating handler; `broadcastSinceLast(game)` replaces the old `broadcastNewAnnouncements` (slices `game.announcements` from each color's watermark). Handlers are async; router uses `void` casts to discard handler Promises.
|
||||
- **`packages/server/src/server.ts`** — `POST /api/games` handles `vsAi: { brain: 'casual' }`: instantiates `CasualBrain` + `BotDriver`, attaches to registry. `vsAi.brain === 'recon'` returns 503 (Phase 2 not implemented). `joinUrl: null` for AI games.
|
||||
- **`packages/shared/src/protocol.ts`** — `CreateGameRequest.vsAi`, `CreateGameResponse.joinUrl: string | null`, `aiOpponent` on `joined` and `update` server messages.
|
||||
- **`packages/server/src/validation.ts`** — Zod schema for `vsAi`.
|
||||
- **Client (`packages/client/`)** — landing page split into two sections (friend / vs computer). In-game UI shows a "Casual bot" badge in the topbar; turn label says "Casual bot is moving…" when bot's turn. The "Opponent disconnected" banner is suppressed for AI games.
|
||||
- **`scripts/selfplay.ts`** — operator CLI. `pnpm selfplay --white casual --black random --games 100 --mode vanilla`. Reports W/B/D/MaxPly/Err and end-reason histogram. Supports `--transcripts` for per-game logs.
|
||||
|
||||
## Phase 1 Acceptance — Met
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| 100 Casual self-play vanilla games complete | ✅ Err=0 across all runs |
|
||||
| Median ply 20-200 in self-play | ✅ avgPly~52 (engine vs random), ~116 (Casual vs Casual) |
|
||||
| Casual ≥80% vs Random, both colors | ✅ 97% as W, 96% as B |
|
||||
| All unit + integration tests pass | ✅ 75/75 (21 shared + 54 server) |
|
||||
| Live smoke checklist | ✅ /api/health, AI game creation, recon→503, no journald errors |
|
||||
| Branch merged + deployed | ✅ Merged to main (`1674695`); deployed to CT 690 |
|
||||
|
||||
## Critical Files
|
||||
|
||||
| File | Status | Notes |
|
||||
|---|---|---|
|
||||
| `docs/superpowers/specs/2026-04-28-ai-player-design.md` | Unchanged | Original spec; still the source of truth for Phase 2. |
|
||||
| `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md` | Unchanged | Phase 1 plan; can be archived or marked "executed" if useful. |
|
||||
| `CLAUDE.md` | ✅ Updated | "Current State" reflects Phase 1 deployed; "Key files" lists new bot module. |
|
||||
| `DECISIONS.md` | ✅ Updated | New "Phase 1 implementation outcomes" section; the previous "Stockfish deferred" entry is now strikethrough (partial supersede — using `js-chess-engine` instead). |
|
||||
| `packages/server/src/bot/` | ✅ New | Brain, BotDriver, CasualBrain, candidates, index. |
|
||||
| `packages/server/src/game-end.ts` | ✅ New | Extracted endGame/finalizeIfEnded. |
|
||||
| `scripts/selfplay.ts` | ✅ New | Self-play harness. Run via `pnpm selfplay`. |
|
||||
| `.secrets.baseline` | ✅ Refreshed | The previous baseline was stale (~6087 lines → 8196 after refresh). pnpm-lock.yaml integrity hashes for js-chess-engine were tripping the secret-detection hook. |
|
||||
|
||||
## Decisions Made (highlights — full list in DECISIONS.md)
|
||||
|
||||
- **CasualBrain reversal**: vanilla mode now delegates to `js-chess-engine` at level 2. Hand-rolled scorer lost to random — empirically broken. Engine swap brought it to 96-97% vs random.
|
||||
- **`BrainInput.fen` is vanilla-only**: blind mode omits the FEN to preserve the view-filter invariant. The engine cannot smuggle opponent positions past the security boundary.
|
||||
- **Blind mode keeps the heuristic**: a chess engine isn't useful when the bot only sees its own pieces. That gap is what Phase 2 (Recon) addresses with belief-state-from-announcements.
|
||||
- **Bot-slot tokens are randomized**: not a fixed placeholder. Closes a hijack vector caught in code review.
|
||||
- **`endGame`/`finalizeIfEnded` extracted to `game-end.ts`**: both ws and driver need to set finished state; duplication risk eliminated.
|
||||
- **`pokeBot → broadcastSinceLast` order is load-bearing**: the bot's response (move + announcements) must be in `game.announcements` before broadcasting, so the human sees the bot's reply in the same WS message they receive after their own move.
|
||||
|
||||
## Immediate Next Steps
|
||||
|
||||
1. **Soak Phase 1 for a few days of real play** before starting Phase 2. Watch for:
|
||||
- Bot-driver errors in journald (`ssh root@192.168.0.245 'journalctl -u blind-chess | grep "bot driver error"'`).
|
||||
- Mid-game crashes or stuck games.
|
||||
- User feedback on Casual's strength (too weak / too strong / fine — defaults to `js-chess-engine` level 2).
|
||||
|
||||
2. **When ready, write Phase 2 plan** — `docs/superpowers/plans/YYYY-MM-DD-ai-player-phase-2-recon.md` against the existing spec. Phase 2 reuses the `Brain` and `BotDriver` infrastructure unchanged; new pieces are `OllamaClient`, `ollama-endpoints` (preflight + failover), `prompt`, `parse`, `ReconBrain`, plus `aiInfo` protocol field, `'ai_unavailable'` end reason, post-game reasoning reveal UI.
|
||||
|
||||
3. **Tune Casual difficulty if needed.** Single-line change in `packages/server/src/bot/casual-brain.ts` — `level` default in `CasualOpts` (currently 2). Drop to 1 if it feels unbeatable; raise to 3 if trivial.
|
||||
|
||||
## Blockers / Open Questions
|
||||
|
||||
- **Casual at level 2 may be too strong for some users.** Beats random 96-97% which is the intended acceptance bar, but a careful human is supposed to win against Casual. If users report Casual is unbeatable, drop to level 1. If users report it's trivial, raise to level 3. (`packages/server/src/bot/casual-brain.ts:33` — change the default in `CasualOpts`.)
|
||||
- **Blind mode self-play games are very short** (avgPly=16, all resignations). The heuristic exhausts its retry cap (5) when the bot picks a move that can't legally proceed in blind mode. This is functional but observation: blind Casual is much weaker than vanilla Casual. Consider raising retry cap or improving heuristic if blind Casual feels broken in real play.
|
||||
- **`js-chess-engine` declares `engines: { node: '>=24' }`** but works on Node 22.22.2. Engines is advisory by default. If a future Node update breaks it, pin to v1.x of the package (`npm i js-chess-engine@^1.0.0`) — older API but compatible.
|
||||
|
||||
## Deferred Items (Phase 2 work)
|
||||
|
||||
All from the original AI spec, untouched:
|
||||
- `ReconBrain` (gemma4:26b chat agent on steel141 RTX 3090 Ti, pve197 V100 fallback).
|
||||
- Mid-game GPU failover, preflight, AI-unavailable end state.
|
||||
- Persistent chat history per game; post-game reasoning reveal UI.
|
||||
- `aiInfo` protocol field (model + GPU + host).
|
||||
- Acceptance bar: Recon wins ≥60% over 50 Recon-vs-Casual self-play games.
|
||||
|
||||
## Important Context for Future Sessions
|
||||
|
||||
- **The bot's `BoardView` is the only egress to the engine, in vanilla mode.** This invariant is preserved structurally: the FEN is set in `BrainInput` only when `mode === 'vanilla'`. Phase 2 ReconBrain will not need this field at all (it gets the view + announcements only — same input shape as a human player who can't see the FEN of the actual game).
|
||||
- **`Casual` and `Recon` brains are both architecturally instances of `Brain`.** Phase 2 just adds another `Brain` implementation against the same `BotDriver`. The driver's mutex / retry / dispatch / dispose lifecycle does NOT need changes.
|
||||
- **Watermark advance only on successful dispatch** (in `BotDriver.runDecisionCycle`). On retry, the brain still sees the FSM's rejection announcement in `newAnnouncements`. This matters for ReconBrain (Phase 2) which uses announcements as evidence; CasualBrain ignores them.
|
||||
- **`scripts/selfplay.ts` is the canonical evaluation tool**. Phase 2 will extend it to support `--white recon --black casual` etc. The harness sets `game.aiOpponent = undefined; game.status = 'active'` after `createGame` returns — that's how it transitions out of "waiting" without a hello.
|
||||
- **The pre-commit hook is `detect-secrets-hook --baseline .secrets.baseline`** in `/home/claude/.config/git/hooks/pre-commit`. If you add a new dep and pnpm-lock.yaml hashes get flagged, run `detect-secrets scan > .secrets.baseline` to refresh.
|
||||
|
||||
## Files Modified / Added This Session
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| (new) `packages/server/src/bot/{brain,candidates,casual-brain,driver,index}.ts` | The bot module (~600 LoC). |
|
||||
| (new) `packages/server/src/game-end.ts` | Extracted from ws.ts. |
|
||||
| (new) `packages/server/test/unit/bot/{candidates,casual-brain,driver}.test.ts` | 27 unit tests. |
|
||||
| (new) `packages/server/test/integration/ai-game-casual.test.ts` | 5 integration tests. |
|
||||
| (new) `scripts/selfplay.ts` | Operator CLI. |
|
||||
| (new) `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md` | The plan. |
|
||||
| `packages/server/src/state.ts`, `games.ts`, `validation.ts`, `server.ts`, `ws.ts` | Wired up. |
|
||||
| `packages/shared/src/protocol.ts` | Added `vsAi`, `aiOpponent`, nullable `joinUrl`. |
|
||||
| `packages/client/src/lib/Landing.svelte`, `Game.svelte`, `stores/game.svelte.ts` | UI. |
|
||||
| `package.json`, `pnpm-lock.yaml`, `packages/server/package.json` | Added `js-chess-engine`, `tsx`. |
|
||||
| `CLAUDE.md`, `DECISIONS.md` | Context updates. |
|
||||
| `.secrets.baseline` | Refreshed. |
|
||||
|
||||
## Environment State
|
||||
|
||||
- **CT 690 / blind-chess.service:** running. `systemctl is-active` returns `active`. Uptime measured from the deploy-restart at 2026-04-28 ~19:14 UTC.
|
||||
- **Active processes:** none session-relevant. The deploy was a normal restart of the systemd unit.
|
||||
- **Environment variables:** none added/changed.
|
||||
- **Secrets:** none added; `.secrets.baseline` was refreshed to a clean state (the old one had ~4500 lines of stale per-file entries).
|
||||
|
||||
## Related Resources
|
||||
|
||||
- Live URL: https://chess.sethpc.xyz — Phase 1 live.
|
||||
- Repo: https://git.sethpc.xyz/Seth/blind_chess — `feat/ai-player-phase-1-casual` branch (pending merge to main).
|
||||
- Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`.
|
||||
- Plan: `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md`.
|
||||
- Decisions: `DECISIONS.md` "AI / computer player" section + new "Phase 1 implementation outcomes" subsection.
|
||||
- Project identity: `CLAUDE.md`.
|
||||
- Prior handoffs: `2026-04-28-170713-ai-player-spec.md`, `2026-04-28-152000-mvp-deployed.md`, `2026-04-28-104344-spec-approved-ready-for-plan.md`, `2026-04-28-kickoff.md`.
|
||||
|
||||
---
|
||||
|
||||
**Security Reminder**: This handoff describes Phase 1 deployment; no credentials, secrets, or sensitive endpoints are exposed in the handoff or the deployed code. The bot uses no external services in Phase 1 (Phase 2 will add Ollama endpoints).
|
||||
@@ -0,0 +1,147 @@
|
||||
# Handoff: Blind Casual check-resolution fix shipped
|
||||
|
||||
## Session Metadata
|
||||
|
||||
- Created: 2026-04-29 06:01:21 UTC
|
||||
- Project: /home/claude/bin/blind_chess
|
||||
- Branch: main (commits `dc7f8ad`, `f00164e` pushed)
|
||||
- Session duration: ~1 hour
|
||||
- Live URL: https://chess.sethpc.xyz (deployed and verified)
|
||||
|
||||
### Recent Commits (for context)
|
||||
|
||||
- `f00164e` chore: gitignore tmp/ for self-play transcripts
|
||||
- `dc7f8ad` fix(bot): blind Casual no longer resigns prematurely under check
|
||||
- `1213ec8` docs: handoff reflects final merged state
|
||||
- `1674695` docs: AI Phase 1 shipped — context, decisions, handoff
|
||||
- `7c18725` feat(bot): vanilla CasualBrain delegates to js-chess-engine
|
||||
|
||||
## Handoff Chain
|
||||
|
||||
- **Continues from**: [2026-04-28-191500-ai-phase-1-shipped.md](./2026-04-28-191500-ai-phase-1-shipped.md) — Phase 1 (Casual bot) deployed; the prior handoff predicted this exact bug as a deferred risk: *"the heuristic exhausts its retry cap (5) when the bot picks a move that can't legally proceed in blind mode... Consider raising retry cap or improving heuristic if blind Casual feels broken in real play."*
|
||||
- **Supersedes**: None.
|
||||
|
||||
## Current State Summary
|
||||
|
||||
User reported: *"casual bot is resigning prematurely."* Investigation confirmed the prior handoff's prediction. Vanilla mode is rock-solid (0 resigns across 80 stress games); blind mode was 100% resign at avg ply 26 in self-play. Root cause: `CasualBrain.heuristicPick` ignored the `<own>_in_check` moderator announcement and scored moves on capture/advance signals uncorrelated with check resolution. chess.js rejected every non-resolving attempt, `BotDriver.RETRY_CAP=5` fired, and the bot resigned. Fix shipped in two commits, deployed to CT 690, smoke-tested. **Blind self-play (100 games): resigns 100% → 17%, avg ply 26 → 90.** Vanilla regression check confirmed unchanged strength.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The fix preserves the spec's view-filter invariant — **the brain still sees only its own pieces + announcements, no oracle access added**. The data needed to detect check was already being delivered to the brain in `newAnnouncements`; the heuristic just wasn't reading it. This is a recurring shape worth recognizing: a bug that looks like "the AI is broken" often turns out to be "the AI ignored a signal the protocol already sends."
|
||||
|
||||
The retry-cap raise (5 → 25) is essentially free for vanilla because chess.js verbose moves are guaranteed legal — vanilla never exercises retries. Blind needs the larger budget because pseudo-legal candidates from `geometricMoves` are filtered by chess.js at commit time and many fail (pinned pieces, unresolved check).
|
||||
|
||||
The new `[bot resign]` log line in `BotDriver.botResign()` decouples observability from the fix. Phase 1 had silent resignations — operators couldn't grep journald for them, which is why the bug surfaced as a user report rather than an alert. Future regressions are now greppable: `journalctl -u blind-chess | grep "bot resign"`.
|
||||
|
||||
## Critical Files
|
||||
|
||||
| File | Purpose | Relevance |
|
||||
|------|---------|-----------|
|
||||
| `packages/server/src/bot/casual-brain.ts` | Decision logic; vanilla delegates to js-chess-engine, blind uses heuristic | New `detectOwnCheck()` and `findOwnKing()` methods; `heuristicPick` takes `inCheck` parameter and applies +5000 boost to king moves |
|
||||
| `packages/server/src/bot/driver.ts` | Per-game orchestrator; mutex, retry, dispatch, dispose | `RETRY_CAP` 5 → 25; `botResign()` now takes a `BotResignReason` and logs `[bot resign]` with structured detail |
|
||||
| `packages/server/test/unit/bot/casual-brain.test.ts` | Unit tests | +2 tests: check-aware king bias (20-seed determinism check), and fall-through to non-king when all king moves are rejected |
|
||||
| `packages/server/test/unit/bot/driver.test.ts` | Unit tests | Retry-cap test updated for new RETRY_CAP=25 |
|
||||
| `scripts/selfplay.ts` | Operator CLI for evaluation | Used heavily this session — `pnpm selfplay --white casual --black casual --games 100 --mode blind --seed 100` |
|
||||
|
||||
## Verification Results
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Blind 100-game self-play (Casual vs Casual, seed=100) | resigns 100% → 17%, avgPly 26 → 90; 42 checkmates, 41 threefolds |
|
||||
| Blind 20-game self-play (seed=42, same as pre-fix benchmark) | resigns 100% → 35%, avgPly 26 → 82 |
|
||||
| Vanilla 30-game self-play (Casual vs Casual, seed=42) | 0 resigns; 27 checkmates, 2 threefolds, 1 fifty-move |
|
||||
| Vanilla 50-game self-play (Casual W vs Random B, seed=7) | 0 resigns; Casual wins 49/50 |
|
||||
| Vanilla 50-game self-play (Random W vs Casual B, seed=7) | 0 resigns; Casual wins 49/50 |
|
||||
| Test suite | 78 passing (was 75; +2 new check tests, +1 driver retry-cap test updated) |
|
||||
| Live `/api/health` | `{"ok":true,"activeGames":0,"uptime":4}` |
|
||||
| Live POST `/api/games` with `vsAi.brain=casual` blind mode | 200 + `joinUrl:null` |
|
||||
| Live POST `/api/games` with `vsAi.brain=recon` | 503 + `ai_offline` (Phase 2 unimplemented, expected) |
|
||||
| journald post-deploy | No errors/warnings |
|
||||
|
||||
## Decisions Made
|
||||
|
||||
| Decision | Options Considered | Rationale |
|
||||
|----------|-------------------|-----------|
|
||||
| Boost king moves in heuristic vs filter candidates by chess.js legality | (a) heuristic boost — preserves view-filter invariant; (b) chess.js pre-filter — would leak attacker info | Chose (a). Preserves "bots play through the same view filter as humans" principle from the AI spec; same information ration as a human player |
|
||||
| `RETRY_CAP` 5 → 25 (single global cap) vs per-mode caps | Per-mode (5 vanilla, 25 blind) vs global 25 | Chose global. Vanilla never hits the cap, so single cap simplifies code with no regression |
|
||||
| King-move boost magnitude +5000 | Smaller (e.g., +200) vs larger | +5000 is large enough to deterministically dominate all other heuristic factors plus the 0.01 random tiebreak; unit test asserts 20/20 seeds pick king moves under check |
|
||||
| Add resign logging now vs defer | (a) bundled with fix; (b) separate later commit | Bundled. The handoff explicitly noted the silent-resign observability gap; fixing that gap was load-bearing for any future regression detection |
|
||||
| Two commits (fix + .gitignore) vs one | One bundled commit vs split | Split. Per global homelab convention: "no batching unrelated changes" — .gitignore drift was pre-existing and orthogonal |
|
||||
|
||||
## Immediate Next Steps
|
||||
|
||||
1. **Soak the fix for a few days of real play** before declaring "blind Casual is solid". Watch for:
|
||||
- `ssh root@192.168.0.245 'journalctl -u blind-chess | grep "bot resign"'` — should be rare; legitimate forced positions only.
|
||||
- User feedback on whether blind Casual still feels broken (lower bar but still possible).
|
||||
- Mid-game stuck states (the retry budget is now 25; with degenerate brain output that's 25× more compute per cycle — should still be sub-second).
|
||||
2. **When ready, write Phase 2 plan** — `docs/superpowers/plans/<DATE>-ai-player-phase-2-recon.md`. Phase 2 reuses the `Brain`/`BotDriver` infrastructure unchanged; new pieces are `OllamaClient`, `ollama-endpoints` (preflight + failover), `prompt`, `parse`, `ReconBrain`, plus `aiInfo` protocol field, `'ai_unavailable'` end reason, post-game reasoning reveal UI.
|
||||
3. **(Cleanup, low priority)** `git rm --cached packages/server/tsconfig.tsbuildinfo` — file is tracked from before the `*.tsbuildinfo` rule was added to `.gitignore`. Persistent `M` noise in `git status` between any rebuilds. Not blocking.
|
||||
|
||||
## Blockers / Open Questions
|
||||
|
||||
- **Blind Casual is now noticeably stronger but still loses to careful play.** The 17% post-fix resign rate represents legitimately stuck positions (multi-piece checks with no king escape, etc.) more than blunders. A human in those positions would also struggle. If users still feel blind Casual is unbeatable-or-broken, the next lever is making the heuristic *also* prefer captures and adjacent-to-king moves under check (likely block targets).
|
||||
- **Threefold draws spiked from 0% → 41% in blind self-play.** Two Casual bots with the same seed/heuristic shuffle pieces and repeat positions. This is more a self-play artifact than a real-play concern; humans don't repeat. Worth watching but not actionable yet.
|
||||
|
||||
## Deferred Items
|
||||
|
||||
All Phase 2 work, untouched:
|
||||
- `ReconBrain` (gemma4:26b chat agent on steel141 RTX 3090 Ti, pve197 V100 fallback)
|
||||
- Mid-game GPU failover, preflight, AI-unavailable end state
|
||||
- Persistent chat history per game; post-game reasoning reveal UI
|
||||
- `aiInfo` protocol field (model + GPU + host)
|
||||
- Acceptance bar: Recon wins ≥60% over 50 Recon-vs-Casual self-play games
|
||||
|
||||
## Important Context
|
||||
|
||||
- **The view-filter invariant is preserved.** No oracle access was added. The brain detects check via `<own_color>_in_check` in `newAnnouncements`, which is a public moderator announcement humans receive too. Phase 2 ReconBrain will read these same announcements — the pattern is now established.
|
||||
- **`BrainInput.fen` is set ONLY in vanilla mode.** Blind mode omits it so the engine path can't smuggle opponent positions past the view filter. The fix did not touch this; the security boundary holds.
|
||||
- **Watermark advance only on successful dispatch** is load-bearing for the fix. On retry, the brain still sees the original `<color>_in_check` announcement from the opponent's move (because `lastSeenAnnouncementCount` doesn't advance until success). This is what makes `detectOwnCheck` robust across retries.
|
||||
- **The bot still uses the heuristic in vanilla as fallback** if the engine returns a move not in the chess.js candidate list. Vanilla never exercised this path in our tests, but the new `inCheck` parameter is wired through it for safety.
|
||||
- **`scripts/selfplay.ts` is the canonical evaluation tool.** Phase 2 will extend it to support `--white recon --black casual` etc. The harness sets `game.aiOpponent = undefined; game.status = 'active'` after `createGame` returns — that's how it transitions out of "waiting" without a hello.
|
||||
|
||||
## Assumptions Made
|
||||
|
||||
- The user was playing in **blind mode** when they reported premature resignation. I didn't ask, but vanilla self-play showed 0 resigns across 80 games while blind showed 100%, so blind was overwhelmingly the more likely mode. If they were actually playing vanilla, that's a different bug — though I have no evidence of one.
|
||||
- The +5000 king-move boost is "large enough." Verified by 20-seed determinism test; if the heuristic ever gains another factor scoring above ~5000, this assumption breaks and the test will catch it.
|
||||
- `RETRY_CAP=25` is sufficient. 100-game blind self-play showed 17% still hit the cap — those are legitimate stuck positions, not under-budgeted retry. If real-play feedback says otherwise, raise further (each retry is microseconds for the heuristic; the cap could go to 50+ without performance concern).
|
||||
|
||||
## Potential Gotchas
|
||||
|
||||
- **`packages/server/tsconfig.tsbuildinfo` shows persistent `M`** in `git status` — it was tracked before `*.tsbuildinfo` was gitignored. Don't be alarmed; it's preexisting drift, not your work.
|
||||
- **The pre-commit hook is `detect-secrets-hook --baseline .secrets.baseline`** at `~/.config/git/hooks/pre-commit`. If you add a new dep and pnpm-lock hashes get flagged, run `detect-secrets scan > .secrets.baseline` to refresh.
|
||||
- **Server restart drops in-memory games.** Acceptable for MVP per prior decisions, but be aware: any active player-vs-Casual game in flight at deploy time will lose state.
|
||||
- **`js-chess-engine` declares `engines: { node: '>=24' }`** but works on Node 22.22.2. Engines is advisory by default. If a future Node update breaks it, pin to v1.x of the package.
|
||||
|
||||
## Files Modified This Session
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `packages/server/src/bot/casual-brain.ts` | +35 LoC: new `detectOwnCheck`, `findOwnKing`; `heuristicPick` takes `inCheck`, boosts king moves +5000 when set |
|
||||
| `packages/server/src/bot/driver.ts` | `RETRY_CAP` 5 → 25; `botResign(reason, detail?)` with `console.error('[bot resign]', ...)`; `BotResignReason` union; `errString` helper |
|
||||
| `packages/server/test/unit/bot/casual-brain.test.ts` | +2 tests (check-aware king preference; fall-through to non-king when king moves exhausted) |
|
||||
| `packages/server/test/unit/bot/driver.test.ts` | Retry-cap test updated 5 → 25, expected calls updated |
|
||||
| `.gitignore` | +`tmp/` (separate commit `f00164e`) |
|
||||
|
||||
## Environment State
|
||||
|
||||
- **CT 690 / blind-chess.service:** running. Restarted 09:54 UTC after deploy. `systemctl is-active` returns `active`.
|
||||
- **Active processes:** none session-relevant. Deploy was a normal restart of the systemd unit.
|
||||
- **Environment variables:** none added/changed.
|
||||
- **Backups:**
|
||||
- Local: `packages/server/src/bot/.backup/{casual-brain,driver}.ts.1777455623`
|
||||
- CT 690: `/opt/blind-chess/.backup/server-1777456437.tar.gz`
|
||||
- **Secrets:** none added; pre-commit detect-secrets hook passed both commits clean.
|
||||
|
||||
## Related Resources
|
||||
|
||||
- Live URL: https://chess.sethpc.xyz
|
||||
- Repo: https://git.sethpc.xyz/Seth/blind_chess (`main` at `f00164e`)
|
||||
- AI Phase 1 spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`
|
||||
- Phase 1 plan: `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md`
|
||||
- DECISIONS.md "AI / computer player" section
|
||||
- Project identity: `CLAUDE.md`
|
||||
- Prior handoffs: `2026-04-28-191500-ai-phase-1-shipped.md`, `2026-04-28-170713-ai-player-spec.md`, `2026-04-28-152000-mvp-deployed.md`, `2026-04-28-104344-spec-approved-ready-for-plan.md`, `2026-04-28-kickoff.md`
|
||||
|
||||
---
|
||||
|
||||
**Security Reminder**: This handoff describes a behavior fix; no credentials, secrets, or sensitive endpoints are exposed in the handoff or the deployed code.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Handoff: Table-fidelity batch — deployed; manual test pass underway
|
||||
|
||||
## Session Metadata
|
||||
- Created: 2026-05-18 20:57:36
|
||||
- Project: /home/claude/bin/blind_chess
|
||||
- Branch: main (all work committed and pushed to `git.sethpc.xyz/Seth/blind_chess`)
|
||||
- Session duration: ~one full session (brainstorm → spec → plan → 12-task execution)
|
||||
|
||||
## Handoff Chain
|
||||
|
||||
- **Continues from**: [2026-04-29-060121-blind-casual-check-fix.md](./2026-04-29-060121-blind-casual-check-fix.md) — the prior session fixed blind Casual's premature resignation.
|
||||
- **Supersedes**: None.
|
||||
|
||||
## Recent Commits (for context)
|
||||
|
||||
- `c01244c` fix: promotion dialog only fires for genuine pawn promotions — **deployed**
|
||||
- `5d995eb` docs: update handoff
|
||||
- `d10e581` fix(client): light outline on dark phantom glyphs for panel contrast — **deployed**
|
||||
- `0773300` docs: table-fidelity batch deployed to both instances
|
||||
- `2e80800` docs: record table-fidelity feature batch as code-complete
|
||||
- Feature implementation range: `be8ecd9..2e80800` (20 commits); two follow-up fixes since (`d10e581`, `c01244c`), both deployed.
|
||||
|
||||
## Current State Summary
|
||||
|
||||
The "table-fidelity feature batch" (three features Andrew Freiberg — Seth's dad, a physical-blind-chess player — requested by email) is **fully implemented, reviewed, committed to `main`, and deployed** (2026-05-18) to both live instances — `chess.sethpc.xyz` (CT 690) and `chess.local` (VDJ-RIG). All 12 plan tasks were executed via subagent-driven development with two-stage review per task plus a final whole-batch review. Build, typecheck, and the 94-test suite all pass (32 shared + 62 server).
|
||||
|
||||
A **manual browser test pass is underway** (Seth). It has surfaced two bugs so far — both fixed and deployed:
|
||||
1. **Contrast** (`d10e581`): opponent (black) phantom pieces were near-invisible on the dark `--panel` background — black glyphs in the palette, Captures panel, and drag-ghost now get a light text-shadow outline.
|
||||
2. **Spurious promotion dialog** (`c01244c`): the "Promote pawn" modal fired for any pawn "moved" toward the last rank because the commit paths checked the destination rank but not the pawn's *source* rank — easy to hit once the phantom layer filled ranks 7-8 with tappable phantoms. Fixed with a new shared `isPromotionMove(piece, from, to)` (pawn, from the rank adjacent to promotion, to the promotion rank, ≤1 file over), used by both client `onCommit` and server `isPromotionRequired`.
|
||||
|
||||
Both fixes are live — the two instances and `main` are all at `c01244c`.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Three features, two increments, all shipped:
|
||||
|
||||
1. **Announce-all (F1):** every moderator `Announcement` is now `audience: 'both'`. Previously move events went only to the opponent and attempted-move errors only to the actor. The `audience` field is retained (uniformly `'both'`) as the egress-control hook in `ws.ts`/`ModeratorPanel`. The Casual bot's *intermediate* retry-rejection announcements are popped in `BotDriver.dispatch` so its blind-mode search does not broadcast as churn — only its final move is announced.
|
||||
2. **Capture tally (F2):** a server-derived per-viewer `captures: CaptureTally` field on the `joined`/`update` messages, rendered by a new `CaptureTally.svelte` panel. Must be server-side — in blind mode the capturing client cannot see what it took.
|
||||
3. **Phantom layer (F3):** a client-LOCAL overlay of guessed opponent pieces, blind mode only. Seeded once with the opponent's standard army, then fully manual: pointer-drag a phantom anywhere, off-board to remove, re-add from an unlimited palette. Persisted to `localStorage` (`bc:phantoms:<gameId>`). **Never sent to the server** — it lives in its own store so the zero-leak property is auditable.
|
||||
|
||||
The zero-leak core (`buildView`, `geometric.ts`) was deliberately untouched.
|
||||
|
||||
## Critical Files
|
||||
|
||||
| File | Purpose | Relevance |
|
||||
|------|---------|-----------|
|
||||
| `docs/superpowers/specs/2026-05-18-table-fidelity-features-design.md` | The spec | Design rationale, info-leak analysis |
|
||||
| `docs/superpowers/plans/2026-05-18-table-fidelity-features.md` | The 12-task plan | Amended to match shipped code |
|
||||
| `packages/server/src/translator.ts`, `commit.ts` | F1 audience change | All announcements `'both'` |
|
||||
| `packages/server/src/bot/driver.ts` | F1 bot-churn suppression | `dispatch` pops intermediate rejections |
|
||||
| `packages/server/src/captures.ts` | F2 `captureTally` | Pure per-viewer derivation |
|
||||
| `packages/shared/src/phantoms.ts` | F3 pure model | `opponentStartPosition`, `deserializePhantoms` (tested) |
|
||||
| `packages/client/src/lib/stores/phantoms.svelte.ts` | F3 local store | Never read in any `send` path |
|
||||
| `packages/client/src/lib/stores/phantom-drag.svelte.ts` | F3 drag controller | Pointer events, tap-vs-drag, `pointercancel`-safe |
|
||||
| `packages/client/src/lib/Board.svelte`, `Game.svelte`, `PhantomPalette.svelte` | F3 UI | Phantom rendering + wiring |
|
||||
|
||||
## Key Patterns Discovered
|
||||
|
||||
- **Build ordering:** `server`/`client` resolve `@blind-chess/shared` from its built `dist/`. After editing `shared`, run `pnpm --filter @blind-chess/shared build` before downstream typecheck/build. `pnpm -r build` handles order automatically.
|
||||
- **Client has no test harness** (by design). Pure logic worth testing goes to `packages/shared` (vitest); Svelte components are covered by `svelte-check` + manual.
|
||||
- **`ply`-parity actor derivation:** the four attempted-move enums carry no colour; the client derives White/Black from `ply % 2` (an attempt only happens on the actor's turn).
|
||||
|
||||
## Work Completed
|
||||
|
||||
- Tasks 1–11 of the plan (all three features), each with implementer + spec-compliance review + code-quality review and fix loops.
|
||||
- Final whole-batch code review — verdict: ready to ship, no Critical/Important issues.
|
||||
- Checkpoint A and B verifications: `pnpm -r build && pnpm -r typecheck && pnpm -r test` all clean; 87 tests pass (25 shared + 62 server).
|
||||
- DECISIONS.md, CLAUDE.md, and the spec updated to reflect the shipped state.
|
||||
|
||||
## Files Modified
|
||||
|
||||
See `git diff --stat be8ecd9..2e80800`. New files: `packages/server/src/captures.ts`, `packages/server/test/unit/captures.test.ts`, `packages/shared/src/phantoms.ts`, `packages/shared/test/phantoms.test.ts`, `packages/client/src/lib/{CaptureTally,PhantomPalette}.svelte`, `packages/client/src/lib/stores/{phantoms,phantom-drag}.svelte.ts`. Modified: `translator.ts`, `commit.ts`, `bot/driver.ts`, `ws.ts`, `shared/{types,protocol,index}.ts`, `client/lib/{Board,Game,ModeratorPanel}.svelte`, `client/lib/stores/game.svelte.ts`.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
All recorded in `DECISIONS.md` under "Table-fidelity features (2026-05-18)" and "Deferred / Rejected". Key ones: announcements widened to `'both'` (deliberate, authorised); manual phantom model (smart-tracker rejected); phantom layer client-local only; drag-and-drop for phantoms only (real moves stay click-to-move).
|
||||
|
||||
## Immediate Next Steps
|
||||
|
||||
1. **Continue the manual browser/phone test pass.** Open https://chess.sethpc.xyz, create a **blind game vs computer**, and check: the moderator panel shows White/Black-labelled attempt lines; the Captures panel updates on a capture; the phantom layer renders 16 seeded pieces; dragging a phantom moves it / off-board removes it; the palette places phantoms; a *tap* still makes real moves; genuine pawn promotion (pawn e7→e8) still pops the dialog; phantoms persist across reload; vanilla mode shows no phantom UI; phantoms hide on game end. The phantom drag is the main mobile-risk surface — test on a phone.
|
||||
2. **Check the board phantom glyph contrast.** `.phantom-b` (dark phantoms on the board) render at `opacity: 0.4` on the board squares; they have a dashed frame so the square reads, but the piece *type* may be hard to tell on dark squares. Flagged to Seth to eyeball during the test pass — if too faint, give `.phantom-b` a subtle light outline without killing the intentional translucency.
|
||||
3. (Optional) Fix the `install-local.sh` redeploy gap, and reconcile the stale VDJ-RIG "no Caddy" note — see Potential Gotchas.
|
||||
|
||||
## Blockers / Open Questions
|
||||
|
||||
- **Board phantom glyph contrast — open question.** See Immediate Next Steps #2 — needs a human eye on a real board before deciding whether `.phantom-b` needs an outline.
|
||||
- **Manual browser test pass is in progress, not complete.** Two bugs found and fixed+deployed so far (contrast, spurious promotion dialog); the rest of the checklist in Next Step #1 is unverified. The phantom drag-and-drop in particular (pointer events, tap-vs-drag, hit-testing) is verified only by code review, not by clicking.
|
||||
|
||||
## Deferred Items
|
||||
|
||||
- Phantom-layer `localStorage` cleanup for games abandoned mid-play (no `finished` transition) — tiny leak, add a stale-key sweep only if it matters.
|
||||
- Highlighting interacting with phantoms (rays stopping at phantom pieces) — safe but out of v1 scope.
|
||||
- An `ai-game-casual` WebSocket integration test for F1 — the `driver.test.ts` unit test was chosen instead (covers the same commit-path; spec updated to record this).
|
||||
|
||||
## Important Context
|
||||
|
||||
- **Everything is on `main` and pushed.** Seth explicitly chose to work directly on `main` (no feature branch). There is nothing to merge.
|
||||
- **The feature batch plus both follow-up fixes are deployed and live** on both instances (`chess.sethpc.xyz` and `chess.local`) at commit `c01244c` — `main` and the live site match. Deployment is outward-facing and drops in-memory games — confirm timing with Seth.
|
||||
- **Two deploy instances now exist** (CLAUDE.md was updated mid-project): CT 690 / `chess.sethpc.xyz`, and `chess.local` on VDJ-RIG. Both need the update.
|
||||
- The final review confirmed the security invariant holds: no `phantom` token anywhere under `packages/server/src/`, `buildView`/`geometric.ts` byte-for-byte unchanged.
|
||||
|
||||
## Assumptions Made
|
||||
|
||||
- The client a11y trade-off (phantom spans and palette pieces are pointer-only, with a documented `svelte-ignore a11y_no_static_element_interactions`) is acceptable — adding `tabindex` without a keyboard drag would be worse a11y. Real gameplay stays fully keyboard-operable via the square buttons.
|
||||
- 94 is the expected test count (32 shared + 62 server); the client contributes 0 (no harness).
|
||||
|
||||
## Potential Gotchas
|
||||
|
||||
- `packages/server/tsconfig.tsbuildinfo` shows persistent `M` in `git status` — pre-existing drift (tracked before `*.tsbuildinfo` was gitignored), not this session's work.
|
||||
- The pre-commit hook is `detect-secrets-hook --baseline .secrets.baseline`.
|
||||
- Server restart on deploy drops all in-memory games. (CT 690 had 3 "active" games at deploy time — almost certainly stale abandoned games, since active games never auto-expire and uptime was 19 days; dropped per the pre-accepted MVP policy.)
|
||||
- `deploy/install-local.sh` (the `chess.local` installer) ends with `systemctl enable --now blind-chess.service`, which does NOT restart an already-running service — a redeploy via the script alone leaves the old code running. Deploys work around it with an explicit `sudo systemctl restart blind-chess` after the script. Proper fix: change the script's `enable --now` to `enable` then `restart`.
|
||||
- **VDJ-RIG port 80 has a Caddy** (host-routing): `curl http://chess.local/` serves the app correctly, but `curl http://localhost/` returns a Caddy `502`. CLAUDE.md's `chess.local` operations note says "no Caddy" — that note is stale or incomplete. Doesn't affect the instance; verify rig deploys via the `chess.local` hostname, not `localhost`.
|
||||
|
||||
## Environment State
|
||||
|
||||
- **Tools/Services:** pnpm workspace; `gitea` CLI for push. Subagent-driven development for execution.
|
||||
- **Active Processes:** none. No dev servers left running.
|
||||
- **Environment Variables:** none added or changed.
|
||||
|
||||
## Related Resources
|
||||
|
||||
- Spec: `docs/superpowers/specs/2026-05-18-table-fidelity-features-design.md`
|
||||
- Plan: `docs/superpowers/plans/2026-05-18-table-fidelity-features.md`
|
||||
- `DECISIONS.md` → "Table-fidelity features (2026-05-18)"
|
||||
- Live URL: https://chess.sethpc.xyz (deployed at `c01244c`) · Repo: https://git.sethpc.xyz/Seth/blind_chess (`main` at `c01244c`)
|
||||
|
||||
---
|
||||
|
||||
**Security Reminder**: No credentials or secrets are included in this handoff.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Handoff: 3rd landing card linking to duplicate-chess (deployed)
|
||||
|
||||
## Session Metadata
|
||||
- Created: 2026-05-19 18:14:48
|
||||
- Project: /home/claude/bin/blind_chess
|
||||
- Branch: main (commits pushed)
|
||||
- Session duration: ~30 minutes; the bulk of this session's substantive work
|
||||
happened in the sibling `duplicate_chess` repo — see that repo's handoff for
|
||||
the full deploy story.
|
||||
|
||||
### Recent Commits (for context)
|
||||
- `33a7cef` docs: record duplicate-chess sub-app sharing the chess.sethpc.xyz origin
|
||||
- `fef6dcf` feat(client): third landing card linking to duplicate chess
|
||||
- `d95ab2a` docs: refresh handoff — promotion fix shipped, both fixes deployed
|
||||
- `c01244c` fix: promotion dialog only fires for genuine pawn promotions
|
||||
|
||||
## Handoff Chain
|
||||
|
||||
- **Continues from**: [2026-05-18-205736-table-fidelity-features.md](./2026-05-18-205736-table-fidelity-features.md)
|
||||
— that handoff's state is otherwise still current; the manual browser test
|
||||
pass on the table-fidelity batch is still pending.
|
||||
- **Cross-repo companion**: `~/bin/duplicate_chess/.claude/handoffs/2026-05-19-181212-deployed-as-chess-sethpc-xyz-duplicate.md`
|
||||
— the full story of the deploy lives there.
|
||||
|
||||
## Current State Summary
|
||||
|
||||
The blind_chess landing now shows a 3rd card — "Duplicate Chess (under
|
||||
development)" — that links to `/duplicate/`. The page-level deploy on
|
||||
`chess.sethpc.xyz` is live and verified: the served bundle contains the new
|
||||
strings ("Duplicate Chess", "under development", "/duplicate/") and CSS classes
|
||||
(`.card-link`, `.badge`, `.open-cue`); curling `/duplicate/` returns the
|
||||
duplicate_chess static index from a separate `handle_path` block in the same
|
||||
Caddy site. blind_chess's server was restarted (1 in-memory game dropped per
|
||||
the pre-accepted MVP policy).
|
||||
|
||||
State of blind_chess proper is otherwise unchanged from the table-fidelity
|
||||
handoff — the same open items (manual browser test pass, board phantom glyph
|
||||
contrast eyeball) apply.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The `chess.sethpc.xyz` Caddy block on CT 600 now routes:
|
||||
|
||||
- `/duplicate/*` → static `/var/www/duplicate-chess/` (`handle_path` strips
|
||||
the prefix; Vite was built with `base: '/duplicate/'`).
|
||||
- Everything else → reverse_proxy to CT 690:3000 (blind_chess Fastify, as
|
||||
before).
|
||||
|
||||
This means duplicate_chess deploys never restart blind_chess. The blind_chess
|
||||
client just contains a link to `/duplicate/` — no SPA-level integration.
|
||||
|
||||
## Critical Files
|
||||
|
||||
| File | Purpose | Relevance |
|
||||
|------|---------|-----------|
|
||||
| `packages/client/src/lib/Landing.svelte` | 3rd card + `.card-link`, `.badge`, `.open-cue` CSS | The visible entry point |
|
||||
| `CLAUDE.md` (deploy line) | Notes the `/duplicate/*` Caddy block | Resume reference |
|
||||
| Caddy on CT 600 (`/etc/caddy/Caddyfile` lines ~1112–1124) | `handle_path /duplicate/*` sub-block | Backup at `/etc/caddy/Caddyfile.bak.duplicate-chess-1779228542` |
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `packages/client/src/lib/Landing.svelte` — added a 3rd `<a class="card card-link" href="/duplicate/">` card under the existing two cards (friend/AI). New CSS for the link-shaped card, the "under development" pill badge, and the `Open →` cue.
|
||||
- `CLAUDE.md` — Deploy bullet extended with one sentence about the `/duplicate/*` handler.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
| Decision | Why |
|
||||
|----------|-----|
|
||||
| 3rd card as a plain `<a href>`, not an SPA-internal route | Honest about the architecture: duplicate is a separate static app behind a Caddy `handle_path`, not a route within blind_chess. Plain anchor survives any future static-fallback rewriting. |
|
||||
| Visibly tagged "under development" | Sets Andrew's (the inventor's) expectations that duplicate isn't at parity with blind/vanilla. |
|
||||
| Drop the 1 active in-memory game on restart | Acceptable per pre-existing MVP policy in DECISIONS.md. |
|
||||
|
||||
## Immediate Next Steps
|
||||
|
||||
1. **The table-fidelity manual browser test pass is still pending** (from the
|
||||
prior handoff — Step 1 there). Now an even bigger reason to do it: Andrew
|
||||
will be looking at the landing and seeing 3 cards; if the layout breaks at
|
||||
a phone width or the card looks broken, fix it before he tests duplicate.
|
||||
2. **Visually verify the new 3rd card** in a real browser at
|
||||
https://chess.sethpc.xyz/ — colours, badge contrast, hover state, mobile
|
||||
layout. Only confirmed via curl + JS/CSS string grep so far.
|
||||
3. **Decide whether chess.local should also serve duplicate.** Currently the
|
||||
LAN-only VDJ-RIG instance is on the previous blind_chess client (no 3rd
|
||||
card) and has no `/duplicate/*` handler. If Seth wants LAN parity, redeploy
|
||||
blind_chess client there AND copy duplicate's dist to the rig with a Caddy
|
||||
block of its own.
|
||||
|
||||
## Important Context
|
||||
|
||||
- **The bulk of the work is in `duplicate_chess`**, not here. See its handoff
|
||||
for the deploy details (Vite base, Caddy `handle_path`, `tar`-pipe transfer,
|
||||
Caddyfile backup name).
|
||||
- **`fef6dcf` + `33a7cef` are the only two commits this session in blind_chess.**
|
||||
Everything else (vite.config, page title, CLAUDE.md, DECISIONS.md, handoff)
|
||||
is in `duplicate_chess`.
|
||||
- **The table-fidelity batch state in the prior handoff is unchanged.** The
|
||||
manual browser test pass is still in progress. The deploy on CT 690 is now
|
||||
at the new commit `fef6dcf` (client only — server code is byte-identical
|
||||
to `c01244c`).
|
||||
|
||||
## Potential Gotchas
|
||||
|
||||
- **The blind_chess server WAS restarted** even though server code didn't
|
||||
change (Fastify-static reads `STATIC_DIR` at startup, so the new
|
||||
`index.html` + bundle hashes only get picked up after a service restart).
|
||||
Took uptime back to 0; the 1 in-memory game at restart was dropped.
|
||||
- **The Caddyfile edit was on CT 600 only.** chess.local on VDJ-RIG was not
|
||||
touched and has no `/duplicate/*` route.
|
||||
|
||||
## Assumptions Made
|
||||
|
||||
- That "incorporate as a 3rd game mode 'under development'" meant a 3rd
|
||||
top-level landing card (the existing "mode" was a radio inside each card
|
||||
for Blind/Vanilla, but duplicate is 4-player so it can't fit that radio).
|
||||
- That a plain link out to `/duplicate/` was the right level of integration
|
||||
(vs iframe or source merge).
|
||||
|
||||
## Environment State
|
||||
|
||||
- `caddy.service` on CT 600 — reloaded.
|
||||
- `blind-chess.service` on CT 690 — restarted (1 in-memory game dropped).
|
||||
- chess.local on VDJ-RIG — not touched.
|
||||
- No dev servers running locally.
|
||||
|
||||
## Related Resources
|
||||
|
||||
- Cross-repo companion handoff: `~/bin/duplicate_chess/.claude/handoffs/2026-05-19-181212-deployed-as-chess-sethpc-xyz-duplicate.md`
|
||||
- Live URLs:
|
||||
- https://chess.sethpc.xyz/ — blind_chess landing (3rd card visible).
|
||||
- https://chess.sethpc.xyz/duplicate/ — duplicate_chess sandbox.
|
||||
- https://chess.sethpc.xyz/api/health — blind_chess API health.
|
||||
|
||||
---
|
||||
|
||||
**Security Reminder**: No credentials or secrets in this handoff.
|
||||
@@ -15,6 +15,9 @@ build/
|
||||
coverage/
|
||||
.vitest/
|
||||
|
||||
# Self-play transcripts (operator scratch)
|
||||
tmp/
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
.idea/
|
||||
|
||||
+299
-4331
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@
|
||||
Then check `DECISIONS.md` for settled choices, and the design specs:
|
||||
- `docs/superpowers/specs/2026-04-28-blind-chess-design.md` — original MVP spec (data model, protocol, FSM, testing).
|
||||
- `docs/superpowers/specs/2026-04-28-ai-player-design.md` — AI/computer player spec (Casual + gemma4 recon bots, two-phase plan).
|
||||
- `docs/superpowers/specs/2026-05-18-table-fidelity-features-design.md` — table-fidelity batch (announce-all, capture tally, phantom opponent-piece layer).
|
||||
|
||||
## Project Identity
|
||||
|
||||
@@ -18,13 +19,13 @@ The system's most distinctive property: highlighting in blind mode reveals **zer
|
||||
|
||||
## Current State
|
||||
|
||||
- **Phase:** MVP **deployed and live** at https://chess.sethpc.xyz (2026-04-28). **AI/computer player feature spec written and approved** (2026-04-28); implementation pending.
|
||||
- **Phase:** MVP **deployed and live** at https://chess.sethpc.xyz (2026-04-28). **AI Phase 1 (Casual bot) deployed** (2026-04-28) — "Play vs computer" → Casual bot. **Blind Casual check-resolution fix shipped** (2026-04-29). **Table-fidelity feature batch deployed** (2026-05-18) — moderator announces every move/attempt to both players, a capture tally, and a client-local phantom opponent-piece layer for blind mode; live on both instances (CT 690 + `chess.local`). Client UI not yet manually browser-tested.
|
||||
- **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`.
|
||||
- **AI player (designed, not built):** Two-phase plan in `docs/superpowers/specs/2026-04-28-ai-player-design.md`. Phase 1 = Casual bot (algorithmic, ~200 LoC). Phase 2 = gemma4 recon bot (`gemma4:26b` chat agent on steel141 RTX 3090 Ti primary, pve197 V100 fallback). Bots play through the same view filter and FSM as humans — no oracle access.
|
||||
- **Stack:** Node 22 + TypeScript, Fastify + `ws`, Svelte 5 + Vite, `chess.js`, `js-chess-engine` (Casual vanilla AI). 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. The `chess.sethpc.xyz` Caddy block also serves a sibling app at `/duplicate/*` (the `duplicate_chess` static bundle from `/var/www/duplicate-chess/` on CT 600) — see Landing.svelte for the 3rd "under development" card.
|
||||
- **Tests:** 87 passing — 25 in shared (geometric + phantom-model helpers), 62 in server (FSM + view + candidates + casual brain + driver + captures + scripted-game + ai-game-casual integration). The client package has no test harness by design.
|
||||
- **Known gaps (deferred):** drag-and-drop for *real* moves (still click-to-move; the phantom layer added pointer-drag for phantom pieces only), full integration coverage of every endgame path, mobile-specific polish, observability beyond `/api/health` and `[bot resign]` (no metrics, no per-game tracing), `localStorage` cleanup for phantom layers of abandoned games.
|
||||
- **AI Phase 2 (gemma4 recon, not built):** Spec in `docs/superpowers/specs/2026-04-28-ai-player-design.md`. Will reuse the Phase 1 `Brain`/`BotDriver` infrastructure. Plan to be written when Phase 1 has soaked. Bots play through the same view filter and FSM as humans — no oracle access.
|
||||
|
||||
## Key files
|
||||
|
||||
@@ -36,6 +37,11 @@ The system's most distinctive property: highlighting in blind mode reveals **zer
|
||||
- `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.
|
||||
- `packages/server/src/captures.ts` — `captureTally`, the per-viewer capture-count derivation (Feature 2).
|
||||
- `packages/server/src/game-end.ts` — shared `endGame` / `finalizeIfEnded` helpers used by both ws and bot driver.
|
||||
- `packages/server/src/bot/` — Brain interface, BotDriver, CasualBrain, candidates. Vanilla mode delegates to `js-chess-engine` at level 2; blind mode uses a heuristic.
|
||||
- `packages/client/src/lib/stores/phantoms.svelte.ts` — client-LOCAL phantom opponent-piece store (Feature 3). Never sent to the server; `phantom-drag.svelte.ts` is its pointer-drag controller.
|
||||
- `scripts/selfplay.ts` — operator CLI for evaluating Casual vs Casual / Random self-play. `pnpm selfplay --help`.
|
||||
- `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.
|
||||
|
||||
@@ -46,6 +52,17 @@ The system's most distinctive property: highlighting in blind mode reveals **zer
|
||||
- **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).
|
||||
|
||||
### Local instance — `chess.local` on VDJ-RIG
|
||||
|
||||
A second, **LAN-only** deploy on VDJ-RIG (192.168.0.143), fully independent of the CT 690 / chess.sethpc.xyz instance (separate in-memory state). Serves on **port 80**, reached at `http://chess.local` via an mDNS alias — no Caddy, no TLS.
|
||||
|
||||
- **Artifacts** (`deploy/`): `blind-chess-local.service` (server unit; binds port 80 as the non-root `blindchess` user via `CAP_NET_BIND_SERVICE`), `chess-mdns-alias` + `chess-mdns-alias.service` (publishes the `chess.local` mDNS name with `avahi-publish -a -R` — `-R` avoids a reverse-PTR collision with the host's own `.local` name), `install-local.sh` (root-side installer).
|
||||
- **On the rig:** tree at `/opt/blind-chess/{server,client/dist}`, units `blind-chess.service` + `chess-mdns-alias.service`, Node 22 via NodeSource.
|
||||
- **Logs:** `ssh vdj-rig 'journalctl -u blind-chess -f'`
|
||||
- **Restart:** `ssh vdj-rig 'sudo systemctl restart blind-chess'`
|
||||
- **Health:** `curl http://chess.local/api/health`
|
||||
- **Redeploy:** on steel141 `pnpm -r build` + `pnpm --filter @blind-chess/server deploy --prod --legacy .deploy-server`; rsync `.deploy-server/` → rig `~/blind-chess-stage/server/`, `packages/client/dist/` → `~/blind-chess-stage/client-dist/`, and the `deploy/` files → `~/blind-chess-stage/`; then `sudo bash ~/blind-chess-stage/install-local.sh`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Inherits global homelab conventions from `~/bin/CLAUDE.md` (gitea CLI, conventional commits, `.claude/handoffs/` for session state).
|
||||
|
||||
+27
-3
@@ -50,9 +50,9 @@ Format: `YYYY-MM-DD: <decision> — <why>`
|
||||
- 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.
|
||||
|
||||
## AI / computer player (designed 2026-04-28, not yet implemented)
|
||||
## AI / computer player
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`. All decisions below are settled at spec-approval time; revisit if implementation surfaces something the spec didn't anticipate.
|
||||
Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`. **Phase 1 (Casual bot) deployed 2026-04-28** — live at https://chess.sethpc.xyz "Play vs computer". Phase 2 (Recon) deferred until Phase 1 has soaked.
|
||||
|
||||
- 2026-04-28: **Two AI bots, phased delivery** — `CasualBrain` (Phase 1, algorithmic, in-process) ships first; `ReconBrain` (Phase 2, `gemma4:26b` chat agent) ships second. Phased to keep research uncertainty (Recon's actual playing strength) from blocking shipping anything. Rejected: combined launch, single difficulty-dial UX, throwaway Casual-as-stub.
|
||||
- 2026-04-28: **Bots use the same view filter as humans** — `BotDriver` calls `buildView(game, botColor)`; bot input is filtered `BoardView` + `Announcement[]`. No oracle access. Preserves the architectural invariant: the view filter is the only egress for board state, even for in-process bots. Rejected: "easy mode" oracle access for Casual to keep it simple.
|
||||
@@ -66,6 +66,27 @@ Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`. All decisions bel
|
||||
- 2026-04-28: **Reasoning hidden during play, revealed post-game** — Gemma's chat history is private during the game; on game end, the chat history is copied to `Game.aiThoughtsLog` and the post-game screen shows a collapsible "View gemma4's reasoning" section. Rejected: live streaming "thinking tokens" to user (leaks strategy), permanent hiding (loses showcase value of the project).
|
||||
- 2026-04-28: **`vsAi` field added to `CreateGameRequest`; `aiInfo` field added to `joined`/`update` server messages; `'ai_unavailable'` added to `EndReason`** — minimal protocol surface for the feature. AI metadata is NOT in `ModeratorText` enum (kept clean). UI-system messages for game-start info and failover events are style-distinct from `Announcement` entries.
|
||||
|
||||
### Phase 1 implementation outcomes (2026-04-28)
|
||||
|
||||
- 2026-04-28: **Phase 1 shipped to https://chess.sethpc.xyz.** 13 implementation tasks executed via subagent-driven development against `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md`. 75 tests passing (21 shared + 54 server). Live smoke checklist passed.
|
||||
- 2026-04-28: **CasualBrain reversal — vanilla mode now uses `js-chess-engine` (level 2, randomness=30), not the hand-rolled scorer.** The original heuristic lost to a random-move baseline 7-7 in 100-game self-play (target was ≥80%). After swap-in: Casual wins 97% as white and 96% as black vs Random, ~5-30ms/move. Supersedes the spec's "no Stockfish" decision in spirit — `js-chess-engine` is MIT-licensed, ~400KB, no native deps, and at level 2 plays "Casual" strength (beats random comfortably, loses to a careful human). Originally rejected "Stockfish for strong vanilla AI" was about *strength*, not about *using a pre-made engine*. Documented and pushed; accepted as a learning.
|
||||
- 2026-04-28: **Bot's BoardView is the only egress to the engine.** `BrainInput.fen` is set ONLY in vanilla mode (where the view is full reveal); blind mode omits it. Engine cannot smuggle opponent positions past the view filter — same architectural invariant the brainstorming session established for human-played blind chess.
|
||||
- 2026-04-28: **Blind mode keeps the heuristic (not engine).** Architecturally Stockfish/js-chess-engine can't usefully play blind chess — they need a full board to evaluate, and giving them one would be oracle access. Building a belief-state from announcements is the Recon bot's design (Phase 2). Self-play confirmed blind heuristic completes games (avgPly=16, 0 errors, all decisive) — short games but functional.
|
||||
- 2026-04-28: **Bot-slot synthetic token is randomized, not a fixed placeholder.** Using a hard-coded placeholder ("botxxxxxxxxxxxxxxxxxxxxx") would let any client knowing it claim the bot's color via `hello`. Random tokens (same shape as human tokens) close that hole. Caught in code review of Task 7.
|
||||
- 2026-04-28: **`endGame` and `finalizeIfEnded` extracted from `ws.ts` to `packages/server/src/game-end.ts`.** Both `ws.ts` and `bot/driver.ts` need to set the game-finished state — duplication risk. Hoist resolves it.
|
||||
|
||||
## Table-fidelity features (2026-05-18)
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-05-18-table-fidelity-features-design.md`. Plan: `docs/superpowers/plans/2026-05-18-table-fidelity-features.md`. Three features requested by Andrew Freiberg (a physical-game player); shipped to `main` 2026-05-18, 12 tasks via subagent-driven development. 87 tests passing (25 shared + 62 server).
|
||||
|
||||
- 2026-05-18: **All moderator announcements are `audience: 'both'`** — every move event and every attempted-move error reaches both players, faithful to the physical game where the moderator speaks aloud. A deliberate, authorised widening of the moderator channel (it makes blind mode slightly less blind — e.g. you hear "won't help you" on the opponent's turn). The `audience` field is retained (now uniformly `'both'`) as the egress-control hook in `ws.ts` / `ModeratorPanel`.
|
||||
- 2026-05-18: **Bot intermediate retry-rejection announcements are popped in `BotDriver.dispatch`** — the blind Casual bot's retry search would otherwise broadcast up to 25 churn announcements per turn. Only the bot's final committed move is announced. Human probes (1–3 pieces, human-paced) still broadcast — that is the feature.
|
||||
- 2026-05-18: **Capture tally is a server-derived per-viewer `captures` field on `joined`/`update`**, not a `ModeratorText` enum entry — the announcement vocabulary stays a pure event enum; the tally is structured data (`CaptureTally = { byYou, byOpponent }`). Must be server-side: in blind mode the capturing client can't see what it took.
|
||||
- 2026-05-18: **Phantom opponent-piece layer is 100% client-local** — never sent to the server, persisted only to `localStorage` (`bc:phantoms:<gameId>`), in its own store (`phantoms.svelte.ts`) separate from the protocol store so the zero-leak property is auditable. Blind mode only. `buildView` / `geometric.ts` untouched.
|
||||
- 2026-05-18: **Manual phantom model** — seeded once with the opponent's standard starting army, then fully manual: drag anywhere, drag off-board to remove, re-add from an unlimited palette, no automation. Rejected: a "smart tracker" that auto-removes on capture and tracks promotions (Seth chose the manual model).
|
||||
- 2026-05-18: **Phantom manipulation is pointer-event drag-and-drop** with a tap-vs-drag threshold so a tap still makes a real move. Real chess moves stay click-to-move — the deferred drag-and-drop decision for *real* moves still stands; F3's drag is phantom-only.
|
||||
- 2026-05-18: **Client has no unit-test harness** (deliberate) — Feature 3's testable pure logic (`opponentStartPosition`, `deserializePhantoms`) lives in `packages/shared` and is unit-tested there; Svelte components/stores are covered by `svelte-check` typechecking plus manual verification.
|
||||
|
||||
## Deferred / Rejected
|
||||
|
||||
<!-- Decisions NOT to do something are just as valuable -- prevents re-proposing rejected ideas -->
|
||||
@@ -83,10 +104,13 @@ Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`. All decisions bel
|
||||
- 2026-04-28: **Pre-deploy "server restarting" warning to active players** — stretch goal, not MVP. Mitigation for now: deploy during low-usage windows.
|
||||
- 2026-04-28: ~~**Client-side AI / hint generation** — explicitly out of scope. Human vs. human only.~~ **Partially superseded 2026-04-28** by AI-player spec. Reversal applies *only* to the human-vs-AI path; client-side AI / hint generation in human-vs-human games remains rejected.
|
||||
- 2026-04-28: **Difficulty slider for AI** — rejected. Two named buttons (Casual, Recon) only. No continuum; the two bots are architecturally different, not tuneable strengths of the same engine.
|
||||
- 2026-04-28: **Stockfish for vanilla-mode AI strength** — deferred. Vanilla is a side-effect, not a feature target. Revisit if users explicitly ask for strong vanilla AI.
|
||||
- 2026-04-28: ~~**Stockfish for vanilla-mode AI strength** — deferred. Vanilla is a side-effect, not a feature target. Revisit if users explicitly ask for strong vanilla AI.~~ **Partially superseded 2026-04-28** during Phase 1 implementation — using `js-chess-engine` (smaller, MIT, no GPL concerns) at level 2 for Casual vanilla, capped at ~30ms/move. The original rejection was about not making Casual *strong*; the engine at level 2 is genuinely casual-strength while still beating random comfortably. Stockfish itself remains rejected (GPL, 7MB+ wasm, overkill for the strength target).
|
||||
- 2026-04-28: **Live token streaming during Gemma's thinking** — rejected for MVP. Static "AI is thinking..." indicator only. Streaming would leak strategic intent and adds protocol complexity.
|
||||
- 2026-04-28: **Mid-game GPU flap-back** — rejected. Once failed over to V100, stays there for the rest of the game even if steel141 recovers. Simpler, more predictable, and chat-history is mid-flight.
|
||||
- 2026-04-28: **AI vs AI public spectate-able games** — rejected for MVP. Self-play harness is CLI-only (`scripts/selfplay.ts`).
|
||||
- 2026-04-28: **Per-turn context compaction** — deferred. Spec uses `num_ctx: 32768` which covers ~128 turns; longer games would overflow but are rare in casual play. Add running-summary compaction if seen in practice.
|
||||
- 2026-04-28: **Bot rating / Elo / personalities** — out of scope. Two named buttons, no scoreboard.
|
||||
- 2026-04-28: **In-game chat (player ↔ player and human ↔ Gemma)** — deferred indefinitely. Two failure modes drove the deferral: (1) blind-mode chat is a side channel that bypasses the moderator-vocabulary security boundary ("knight on c3, take it" defeats the entire view-filter architecture); (2) chatting with Gemma during play leaks the bot's belief state and undermines the post-game reasoning reveal. Resolvable but expensive (two-history split for Gemma, blind-mode mute or social-variant warnings, mobile UI real estate). Revisit only if users explicitly ask. The post-game reasoning reveal already covers most of the "see what Gemma was thinking" appeal without the leak surface.
|
||||
- 2026-05-18: **Smart-tracker phantom model** (auto-remove a phantom on capture, track promotions, constrain the phantom set to the opponent's surviving army) — rejected in favour of the fully-manual model. More code and more edge cases; Seth wanted the manual ritual.
|
||||
- 2026-05-18: **Highlighting interacting with phantoms** (bishop/rook rays stopping at phantom pieces) — deferred. Safe to do (phantoms carry zero real opponent info) but out of scope for v1; phantoms are a pure annotation layer that highlighting ignores.
|
||||
- 2026-05-18: **Phantom-layer `localStorage` cleanup for abandoned games** — deferred. `clearForGame` only fires when the game reaches `finished` while `<Game>` is mounted; a tab closed mid-game leaves a stale `bc:phantoms:<id>` key. Each entry is a tiny JSON object; add a stale-key sweep on app start only if it ever matters.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
[Unit]
|
||||
Description=blind_chess server — local LAN instance (chess.local)
|
||||
Documentation=https://git.sethpc.xyz/Seth/blind_chess
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=blindchess
|
||||
Group=blindchess
|
||||
WorkingDirectory=/opt/blind-chess/server
|
||||
ExecStart=/usr/bin/node /opt/blind-chess/server/dist/server.js
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PORT=80
|
||||
Environment=HOST=0.0.0.0
|
||||
Environment=STATIC_DIR=/opt/blind-chess/client/dist
|
||||
Environment=PUBLIC_BASE=http://chess.local
|
||||
Environment=LOG_LEVEL=info
|
||||
Restart=always
|
||||
RestartSec=2s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# Bind privileged port 80 as a non-root user
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/blind-chess
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Publish "chess.local" as an mDNS alias for this host's primary IPv4 address.
|
||||
# Invoked by chess-mdns-alias.service (blind_chess local deploy).
|
||||
#
|
||||
# avahi-daemon already advertises the host's own <hostname>.local; this adds
|
||||
# the friendly "chess.local" name pointing at the same machine. Runs in the
|
||||
# foreground holding the registration until the service is stopped.
|
||||
set -euo pipefail
|
||||
|
||||
IP="$(hostname -I | awk '{print $1}')"
|
||||
if [ -z "$IP" ]; then
|
||||
echo "chess-mdns-alias: no IPv4 address found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "chess-mdns-alias: publishing chess.local -> $IP"
|
||||
# -R/--no-reverse: skip the reverse (PTR) record. avahi-daemon already owns the
|
||||
# PTR for this IP via the host's own <hostname>.local, so publishing chess.local
|
||||
# for the same address *with* a reverse entry collides ("Local name collision").
|
||||
# Clients only need the forward A record, which -a still publishes.
|
||||
exec avahi-publish -a -R chess.local "$IP"
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Publish chess.local mDNS alias for the blind_chess local deploy
|
||||
Requires=avahi-daemon.service
|
||||
After=avahi-daemon.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/chess-mdns-alias
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
# blind_chess — local (chess.local) deploy installer.
|
||||
#
|
||||
# Run as root ON THE TARGET HOST. Expects a staging directory containing:
|
||||
# server/ pnpm-deploy bundle (dist/ + node_modules/)
|
||||
# client-dist/ vite build output
|
||||
# chess-mdns-alias mDNS alias helper script
|
||||
# blind-chess-local.service systemd unit for the server
|
||||
# chess-mdns-alias.service systemd unit for the mDNS alias
|
||||
#
|
||||
# Usage: sudo bash install-local.sh [STAGE_DIR]
|
||||
# (STAGE_DIR defaults to the directory containing this script)
|
||||
set -euo pipefail
|
||||
|
||||
STAGE="${1:-$(cd "$(dirname "$0")" && pwd)}"
|
||||
echo "=== blind_chess local install (staging: $STAGE) ==="
|
||||
|
||||
# --- Node.js 22 (Debian trixie ships only 20; blind_chess needs >=22) ---
|
||||
need_node=1
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
major="$(node -e 'process.stdout.write(String(process.versions.node.split(".")[0]))' 2>/dev/null || echo 0)"
|
||||
if [ "${major:-0}" -ge 22 ] 2>/dev/null; then need_node=0; fi
|
||||
fi
|
||||
if [ "$need_node" -eq 1 ]; then
|
||||
echo "--- installing Node.js 22 via NodeSource ---"
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y -o DPkg::Lock::Timeout=600 nodejs
|
||||
fi
|
||||
echo "node: $(node --version)"
|
||||
|
||||
# --- avahi-utils provides avahi-publish ---
|
||||
command -v avahi-publish >/dev/null 2>&1 || \
|
||||
apt-get install -y -o DPkg::Lock::Timeout=600 avahi-utils
|
||||
|
||||
# --- dedicated unprivileged service user ---
|
||||
getent passwd blindchess >/dev/null 2>&1 || \
|
||||
useradd --system --user-group --no-create-home --shell /usr/sbin/nologin blindchess
|
||||
|
||||
# --- deploy tree under /opt/blind-chess ---
|
||||
install -d /opt/blind-chess
|
||||
rm -rf /opt/blind-chess/server /opt/blind-chess/client
|
||||
cp -a "$STAGE/server" /opt/blind-chess/server
|
||||
install -d /opt/blind-chess/client
|
||||
cp -a "$STAGE/client-dist" /opt/blind-chess/client/dist
|
||||
chown -R blindchess:blindchess /opt/blind-chess
|
||||
|
||||
# --- mDNS alias helper ---
|
||||
install -m 0755 "$STAGE/chess-mdns-alias" /usr/local/bin/chess-mdns-alias
|
||||
|
||||
# --- systemd units (the server unit installs under the canonical name) ---
|
||||
install -m 0644 "$STAGE/blind-chess-local.service" /etc/systemd/system/blind-chess.service
|
||||
install -m 0644 "$STAGE/chess-mdns-alias.service" /etc/systemd/system/chess-mdns-alias.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now chess-mdns-alias.service
|
||||
systemctl enable --now blind-chess.service
|
||||
|
||||
echo "=== install complete ==="
|
||||
systemctl --no-pager --lines=0 status blind-chess.service chess-mdns-alias.service || true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,439 @@
|
||||
# Table-Fidelity Features — Design Spec
|
||||
|
||||
> Three features that bring digital blind chess closer to the physical-table
|
||||
> experience, requested by Andrew Freiberg (an experienced physical-game
|
||||
> player) and refined by Seth.
|
||||
|
||||
- **Date:** 2026-05-18
|
||||
- **Status:** Approved (brainstorm), pending spec review
|
||||
- **Project:** blind_chess
|
||||
- **Supersedes/extends:** `2026-04-28-blind-chess-design.md` (moderator vocabulary,
|
||||
view filter, FSM). No prior decision is reversed; one (`audience` filtering of
|
||||
move events) is deliberately widened — see Feature 1.
|
||||
|
||||
## Motivation
|
||||
|
||||
From Andrew's email (the physical game uses a human moderator and three people):
|
||||
|
||||
1. *"Can you make it so the moderator announces all moves or attempted moves,
|
||||
white and black? One of the things a player listens for is what the moderator
|
||||
says when it is the other player's turn."*
|
||||
2. *"A running tabulation so you can see how many pieces you have captured.
|
||||
Normally when you play, you set up the opponent's pieces on your board and
|
||||
remove them as you make a capture to keep track."*
|
||||
|
||||
And Seth's framing of the third: *"You move the other players' pieces at will to
|
||||
create a model 'guess' of where their pieces are... move the opponent's pieces
|
||||
anywhere you want, including off the board."*
|
||||
|
||||
These map to three features. They are independent in code but share one theme:
|
||||
making the digital game as faithful to the physical table as Andrew's
|
||||
real-world experience of it.
|
||||
|
||||
## Scope at a glance
|
||||
|
||||
| # | Feature | Where | Size |
|
||||
|---|---------|-------|------|
|
||||
| 1 | Moderator announces every move & attempted move, to both players | server (+ tiny client) | small |
|
||||
| 2 | Running capture tally | server + client panel | small–medium |
|
||||
| 3 | Phantom opponent pieces (private opponent-model overlay) | client only | medium |
|
||||
|
||||
**Phasing:** one spec, one plan, implemented in two increments —
|
||||
*Increment 1* = Features 1 + 2 (server-centric, shippable alone);
|
||||
*Increment 2* = Feature 3 (the larger client build).
|
||||
|
||||
---
|
||||
|
||||
## Feature 1 — Moderator announces everything, to both players
|
||||
|
||||
### Current behaviour
|
||||
|
||||
`Announcement` carries an `audience` field (`'w' | 'b' | 'both'`), filtered in
|
||||
exactly two places: the server's `broadcastSinceLast` (`ws.ts`) and the
|
||||
client's `ModeratorPanel`.
|
||||
|
||||
- **Move events** (`white_moved`, `*_moved_captured`, `*_castled_*`,
|
||||
`*_promoted`) — emitted with `audience: opp`. Only the opponent of the mover
|
||||
sees them.
|
||||
- **Attempted-move errors** (`no_such_piece`, `no_legal_moves`, `wont_help`,
|
||||
`illegal_move`) — emitted with `audience: actor`. Only the player who made
|
||||
the attempt sees them.
|
||||
- **State changes** (`*_in_check`, `*_checkmate`, draws) and
|
||||
resign/draw/abandon — already `audience: 'both'`.
|
||||
|
||||
So a player does **not** hear what the moderator says about the opponent's
|
||||
*failed* attempts — exactly the channel Andrew listens for at the table.
|
||||
|
||||
### Change
|
||||
|
||||
Both move events and attempted-move errors become `audience: 'both'`. After
|
||||
this change **every** announcement is `'both'`; the `audience` field becomes
|
||||
uniformly `'both'` but is **retained** as the moderator-channel egress-control
|
||||
hook (the filtering code in `ws.ts` and `ModeratorPanel` stays — it is the
|
||||
security boundary, now a pass-through).
|
||||
|
||||
Concretely:
|
||||
|
||||
- `translator.ts` `translateMove` — the six move-event `announce(...)` calls
|
||||
change their audience argument from `opp` to `'both'`.
|
||||
- `commit.ts` `announceWith` — `announce(text, color, ply)` becomes
|
||||
`announce(text, 'both', ply)`.
|
||||
|
||||
No protocol change, no enum change.
|
||||
|
||||
### Client: labelling attempted-move lines
|
||||
|
||||
The four error enums carry **no colour** (`wont_help`, not `white_wont_help`).
|
||||
With the announcement now shared, the panel must say *whose* attempt it was.
|
||||
|
||||
It can be derived for free: an attempted move only ever happens on the actor's
|
||||
turn, and an error announcement's `ply` is `chess.history().length` captured
|
||||
*before* the move applies. Therefore **`ply` parity is the actor**:
|
||||
`ply % 2 === 0` → White attempted, odd → Black. (Move events already encode
|
||||
colour in the enum text and need no parity prefix.)
|
||||
|
||||
The client (`ModeratorPanel` / `moderator-strings`) prefixes the four error
|
||||
texts with `"White — "` / `"Black — "` derived from `ply` parity. The current
|
||||
alarm-red `.err` styling on those four entries is replaced with a neutral/muted
|
||||
"moderator info" style — with a shared transcript they are commentary, not
|
||||
"you did something wrong".
|
||||
|
||||
### Bot retry suppression
|
||||
|
||||
The blind Casual bot searches for a legal move by retrying rejected candidates
|
||||
inside one decision cycle (`BotDriver`, `RETRY_CAP = 25`). Each rejection runs
|
||||
through `announceWith`, which pushes one error announcement onto
|
||||
`game.announcements`. With those now `'both'`, the human opponent would see the
|
||||
bot's internal search as up to 25 lines of moderator spam.
|
||||
|
||||
Fix, entirely within `bot/driver.ts`: in `dispatch`, on the `'announce'` retry
|
||||
branch, pop the just-pushed announcement off `game.announcements` before
|
||||
returning `{ kind: 'retry' }`. This is safe because:
|
||||
|
||||
- `announceWith` pushes exactly one announcement and the driver receives it as
|
||||
`result.announcements[0]`; nothing mutates `game.announcements` in between,
|
||||
so it is the last element. Pop with an identity guard
|
||||
(`if (last === result.announcements[0]) pop()`).
|
||||
- The whole bot decision cycle runs inside `pokeBot(game)` and completes
|
||||
*before* `ws.ts` calls `broadcastSinceLast` — intermediate announcements are
|
||||
removed before any broadcast ever runs.
|
||||
- The bot tracks its own rejections via `attemptHistory` (explicitly passed),
|
||||
not via `newAnnouncements`, so popping does not affect the bot's logic.
|
||||
|
||||
Only the bot's **final committed move** announcement survives and is broadcast
|
||||
(that is Feature 1 working: the human hears "Black has moved"). A bot that
|
||||
exhausts retries still resigns; the human sees the resignation, not the 25
|
||||
fumbles.
|
||||
|
||||
Human probes are **not** suppressed: a person tries 1–3 pieces per turn,
|
||||
naturally bounded — broadcasting those is the feature.
|
||||
|
||||
### Information-channel note (deliberate)
|
||||
|
||||
Feature 1 genuinely widens what blind mode reveals. Hearing "won't help you" on
|
||||
the opponent's turn tells you they are pinned or otherwise constrained;
|
||||
`no_legal_moves` tells you they touched a fully-boxed-in piece. This is the
|
||||
*intended* moderator channel and it is faithful to the physical game (Andrew,
|
||||
an experienced physical-game player, explicitly asked for it). It is recorded
|
||||
here as a conscious, authorised reduction in blindness — **not** a leak through
|
||||
an illegitimate side channel. `buildView` and `geometric.ts` (the zero-leak
|
||||
core) are untouched.
|
||||
|
||||
### Files
|
||||
|
||||
- `packages/server/src/translator.ts` — audience of move events.
|
||||
- `packages/server/src/commit.ts` — audience in `announceWith`.
|
||||
- `packages/server/src/bot/driver.ts` — pop intermediate retry rejections.
|
||||
- `packages/client/src/lib/moderator-strings.ts` /
|
||||
`packages/client/src/lib/ModeratorPanel.svelte` — actor prefix via `ply`
|
||||
parity; neutral styling for attempted-move lines.
|
||||
|
||||
### Tests
|
||||
|
||||
- Unit (`translator`): move events carry `audience: 'both'`.
|
||||
- Unit (`commit-fsm`): `no_such_piece` / `no_legal_moves` / `wont_help` /
|
||||
`illegal_move` carry `audience: 'both'`.
|
||||
- Unit (`driver`): after a decision cycle that incurs ≥1 retry, only the final
|
||||
move's announcement(s) remain in `game.announcements`; intermediate
|
||||
rejections are absent.
|
||||
- The `driver` unit test is the chosen coverage for the bot-suppression path:
|
||||
it drives the real `BotDriver` → `handleCommit` → `announceWith` pipeline, so
|
||||
it verifies suppression at the commit-path level. A separate `ai-game-casual`
|
||||
WebSocket integration test was considered and dropped — it would only
|
||||
additionally exercise the trivial `broadcastSinceLast` pass-through filter,
|
||||
for materially more harness complexity.
|
||||
|
||||
---
|
||||
|
||||
## Feature 2 — Running capture tally
|
||||
|
||||
### What the player sees
|
||||
|
||||
A read-only panel beside the board:
|
||||
|
||||
- Primary line — **"You've captured:"** followed by glyphs of the opponent
|
||||
pieces you have taken, with a count, e.g. `♟ ♟ ♞ (3)`.
|
||||
- Secondary muted line — **"Lost:"** followed by glyphs of your pieces the
|
||||
opponent has taken, e.g. `♙ ♗ (2)`.
|
||||
|
||||
(Andrew asked specifically for captures; losses are free to compute and
|
||||
complete the picture. Seth may drop the "Lost" line at spec review.)
|
||||
|
||||
### Why this needs the server
|
||||
|
||||
In blind mode the capturing client cannot see what it captured — opponent
|
||||
pieces are filtered out of its `BoardView`. The captured piece's *type* must
|
||||
come from the server. This is the same single-piece-of-history reveal the
|
||||
physical moderator gives you when you take a piece.
|
||||
|
||||
### Data model
|
||||
|
||||
`MoveRecord` already records `by: Color` and `capturedPieceType?: PieceType`
|
||||
for every move (`state.ts`). The tally is a pure derivation of
|
||||
`game.moveHistory`.
|
||||
|
||||
New shared type (`packages/shared/src/types.ts`):
|
||||
|
||||
```ts
|
||||
export type PieceTally = Partial<Record<PieceType, number>>;
|
||||
```
|
||||
|
||||
New field on the `joined` and `update` server messages
|
||||
(`packages/shared/src/protocol.ts`):
|
||||
|
||||
```ts
|
||||
captures: { byYou: PieceTally; byOpponent: PieceTally };
|
||||
```
|
||||
|
||||
`captures` is sent on **every** `update` (it is tiny and keeps `update`
|
||||
idempotent — replaying the latest `update` still renders correctly, matching
|
||||
the existing protocol decision).
|
||||
|
||||
### Server computation
|
||||
|
||||
New module `packages/server/src/captures.ts`:
|
||||
|
||||
```ts
|
||||
function captureTally(game, viewer): { byYou: PieceTally; byOpponent: PieceTally }
|
||||
```
|
||||
|
||||
Iterates `game.moveHistory`; for each record with `capturedPieceType`,
|
||||
increments `byYou` if `by === viewer`, else `byOpponent`. En-passant captures
|
||||
are included automatically (`capturedPieceType` is `'p'`).
|
||||
|
||||
`ws.ts` includes `captures: captureTally(game, color)` in the `joined` payload
|
||||
(`onHello`) and in `update` payloads (`sendUpdateTo`).
|
||||
|
||||
### Client
|
||||
|
||||
- `game.svelte.ts` store gains a `captures` field, set from `joined` and
|
||||
`update`.
|
||||
- New component `packages/client/src/lib/CaptureTally.svelte`, rendered in the
|
||||
side panel of `Game.svelte` near `ModeratorPanel`. Reuses `pieces.ts`
|
||||
`pieceGlyph`. `byYou` glyphs render in the opponent's colour (they are
|
||||
opponent pieces); `byOpponent` glyphs render in your colour.
|
||||
|
||||
### Modes
|
||||
|
||||
Built for both modes. In vanilla it is a simple scoreboard; in blind it is the
|
||||
load-bearing feature. The bot ignores `captures`; a future ReconBrain may
|
||||
consume it (Phase 2, out of scope here).
|
||||
|
||||
### Files
|
||||
|
||||
- `packages/shared/src/types.ts` — `PieceTally`.
|
||||
- `packages/shared/src/protocol.ts` — `captures` on `joined` / `update`.
|
||||
- `packages/server/src/captures.ts` — new, `captureTally`.
|
||||
- `packages/server/src/ws.ts` — include `captures` in `joined` / `update`.
|
||||
- `packages/client/src/lib/stores/game.svelte.ts` — store field.
|
||||
- `packages/client/src/lib/CaptureTally.svelte` — new component.
|
||||
- `packages/client/src/lib/Game.svelte` — mount the panel.
|
||||
|
||||
### Tests
|
||||
|
||||
- Unit (`captures`): `captureTally` returns correct per-viewer counts for a
|
||||
`moveHistory` containing captures by both colours, including en passant.
|
||||
- Client component verified manually (no client test harness — see Feature 3).
|
||||
|
||||
---
|
||||
|
||||
## Feature 3 — Phantom opponent pieces
|
||||
|
||||
A private opponent-model overlay on the player's own board: the digital form of
|
||||
"set up the opponent's pieces on your board and move/remove them to keep
|
||||
track." **Blind mode only** — pointless in vanilla, where real opponent pieces
|
||||
are visible.
|
||||
|
||||
### Behaviour (the manual model, per Seth's decision)
|
||||
|
||||
- **Seeded once** at game start with the opponent's 16 pieces on their standard
|
||||
home squares — saves the manual initial setup.
|
||||
- **Fully manual thereafter.** Drag any phantom anywhere; drag it off the board
|
||||
to remove it; place fresh phantoms from an always-available palette of the
|
||||
six piece types. **No count limits, no automation, no auto-removal on
|
||||
capture.** Editable at any time, including the opponent's turn.
|
||||
- The capture tally (Feature 2) is a **separate** read-only counter — it is
|
||||
*not* coupled to the phantom layer.
|
||||
- Phantoms are visually distinct from real pieces (translucent, dashed
|
||||
outline) and use the opponent's colour.
|
||||
- A phantom **cannot** occupy a square holding one of your real pieces (your
|
||||
real pieces are known truth). Other squares are all valid — overlapping where
|
||||
the opponent might really be is the entire point.
|
||||
- On game-over the board reveals all real pieces; the phantom overlay and
|
||||
palette are **hidden** so the reveal is clean.
|
||||
|
||||
### The security invariant (load-bearing)
|
||||
|
||||
The phantom layer is **100% client-local**. It is never serialized to the wire,
|
||||
never sent to the server, never seen by the opponent. It contains zero real
|
||||
opponent information by construction — it is the player's own fiction.
|
||||
|
||||
To make this auditable, the phantom layer gets its **own store**, separate from
|
||||
the server-synced `game.svelte.ts`:
|
||||
|
||||
> New store `packages/client/src/lib/stores/phantoms.svelte.ts`. A reviewer
|
||||
> confirms "phantoms never leak" by verifying this store is never read in any
|
||||
> `send(...)` / `commit(...)` path. No `ClientMessage` variant carries phantom
|
||||
> data.
|
||||
|
||||
`buildView` and `geometric.ts` are untouched.
|
||||
|
||||
### Data model
|
||||
|
||||
- Phantom state: `Partial<Record<Square, Piece>>` — at most one phantom per
|
||||
square; `Piece.color` is always the opponent's colour. Placing on an occupied
|
||||
phantom square replaces.
|
||||
- Store operations: `place(sq, type)`, `move(from, to)`, `remove(sq)`,
|
||||
`clear()`, `loadForGame(gameId, you)`.
|
||||
- **Persistence:** `localStorage`, key `bc:phantoms:<gameId>`, value = JSON of
|
||||
the phantom map. Survives reload / reconnect (important on phones).
|
||||
- **Seeding:** on first load of a blind game (no `localStorage` key present),
|
||||
seed the 16 opponent pieces at standard start squares for the opponent
|
||||
colour (`you === 'w' ? 'b' : 'w'`) and persist immediately. The presence of
|
||||
the key thereafter means "already seeded — load, do not re-seed", so a reload
|
||||
never wipes the player's edits.
|
||||
- On `gameStatus === 'finished'`, clear the `localStorage` key (avoids
|
||||
unbounded accumulation across games).
|
||||
|
||||
The pure transformation logic — standard start squares for a colour,
|
||||
place/move/remove on a map, (de)serialization — is extracted into a plain
|
||||
(non-`.svelte`) module so it can be unit-tested. `phantoms.svelte.ts` is the
|
||||
thin reactive wrapper. (The plan decides whether to stand up a `vitest` config
|
||||
in the client package, which currently has none, or host the pure module in
|
||||
`packages/shared`.)
|
||||
|
||||
### Interaction — drag-and-drop (approved option A)
|
||||
|
||||
Drag-and-drop via **pointer events** (`pointerdown` / `pointermove` /
|
||||
`pointerup`) so it works for both mouse and touch. Real moves stay
|
||||
click-to-move (the touch-move FSM is unchanged — the deferred decision against
|
||||
drag-and-drop for *real* moves still stands; F3's drag is phantom-only).
|
||||
|
||||
- **Move a phantom:** `pointerdown` on a phantom → once the pointer moves past a
|
||||
small threshold (~6 px) it is a drag; a drag image follows the pointer.
|
||||
`pointerup` over another square → move the phantom there; over a square with
|
||||
your real piece → invalid, snap back; outside the board → remove the phantom.
|
||||
- **Place from palette:** `pointerdown` on a palette piece → drag → `pointerup`
|
||||
over a board square free of your real piece → place a phantom of that type.
|
||||
- **Tap vs drag:** a `pointerdown`+`pointerup` with no movement past the
|
||||
threshold is **not** a phantom action — it is forwarded to the board's normal
|
||||
`onSquareClick`, so you can still arm/commit a real move onto a square you
|
||||
have a phantom guess on (e.g. to capture there). A drag past the threshold
|
||||
`stopPropagation`s so the underlying square click does not also fire.
|
||||
|
||||
This isolation — tap → real move, drag → phantom — means phantom editing never
|
||||
blocks the live game's move path and needs no mode toggle.
|
||||
|
||||
### Rendering & components
|
||||
|
||||
- The phantom layer is rendered **within `Board.svelte`** as an additional
|
||||
styled layer in each grid cell (alignment with real squares is then free).
|
||||
`Board.svelte` owns the pointer-event handling and the tap-vs-drag
|
||||
disambiguation, because that decision cannot be cleanly split across
|
||||
components. `Board.svelte` remains prop-driven: it receives phantom data and
|
||||
`onPhantomMove` / `onPhantomPlace` / `onPhantomRemove` callbacks.
|
||||
- New component `packages/client/src/lib/PhantomPalette.svelte` — the
|
||||
six-type palette, rendered beside the board on desktop / below on mobile;
|
||||
source of palette→board drags.
|
||||
- `Game.svelte` wires the `phantoms` store to `Board` and `PhantomPalette`,
|
||||
and gates the whole phantom UI on `mode === 'blind' && gameStatus ===
|
||||
'active'`.
|
||||
- Phantom styling lives in `app.css` / component styles (translucent, dashed).
|
||||
|
||||
### Out of scope for v1
|
||||
|
||||
Highlighting ignores phantoms — the geometric highlight stays a function of
|
||||
your real pieces only. Letting bishop/rook rays stop at phantom pieces would be
|
||||
information-safe (phantoms hold zero real opponent data) and is a reasonable
|
||||
future enhancement, but it is not in this spec.
|
||||
|
||||
### Files
|
||||
|
||||
- New: `packages/client/src/lib/stores/phantoms.svelte.ts`,
|
||||
`packages/client/src/lib/PhantomPalette.svelte`, a pure phantom-model module
|
||||
(location per plan), optionally a small pointer-drag helper.
|
||||
- Modified: `packages/client/src/lib/Board.svelte` (phantom layer + drag +
|
||||
tap-vs-drag), `packages/client/src/lib/Game.svelte` (mount palette, wire
|
||||
store, blind+active gating), `packages/client/src/app.css` (phantom styles).
|
||||
- No server or shared changes for Feature 3 (unless the pure model module is
|
||||
hosted in `packages/shared`).
|
||||
|
||||
### Tests
|
||||
|
||||
Feature 3 is client-only and the project currently has no client test harness
|
||||
(the 78 existing tests are all `shared` + `server`). The pure phantom-model
|
||||
logic (seed squares, place/move/remove, (de)serialization) is unit-tested; the
|
||||
drag interaction and rendering are verified manually on phone + desktop. The
|
||||
plan decides the test-infra approach.
|
||||
|
||||
---
|
||||
|
||||
## Architecture & invariants summary
|
||||
|
||||
- **Feature 1** widens the moderator channel: every `Announcement` becomes
|
||||
`audience: 'both'`. The field and its filtering are retained as the egress
|
||||
control. This is a deliberate, authorised increase in shared information,
|
||||
faithful to the physical game.
|
||||
- **Feature 2** adds one server-derived, per-viewer protocol field
|
||||
(`captures`). Capture *types* stay out of the `ModeratorText` enum —
|
||||
announcements remain a pure event vocabulary; the tally is structured data.
|
||||
- **Feature 3** adds a client-local-only layer that never reaches the wire,
|
||||
isolated in its own store for auditability.
|
||||
- The zero-leak core — `buildView` and `geometric.ts` — is **not touched** by
|
||||
any of the three features.
|
||||
|
||||
## Phasing
|
||||
|
||||
| Increment | Contents | Independently shippable |
|
||||
|-----------|----------|--------------------------|
|
||||
| 1 | Features 1 + 2 | Yes — server + a read-only client panel |
|
||||
| 2 | Feature 3 | Yes — client phantom layer |
|
||||
|
||||
One implementation plan; tasks ordered so Increment 1 completes (and can
|
||||
deploy) before Increment 2 begins.
|
||||
|
||||
## Out of scope / explicitly rejected
|
||||
|
||||
- **Smart-tracker phantom model** (auto-removal on capture, promotion
|
||||
bookkeeping, constrained opponent army) — rejected by Seth in favour of the
|
||||
manual model.
|
||||
- **Phantom layer in vanilla mode** — pointless; excluded.
|
||||
- **Drag-and-drop for real moves** — still deferred (DECISIONS.md). F3's drag
|
||||
is phantom-only.
|
||||
- **Highlighting interacting with phantoms** — safe future enhancement, not
|
||||
v1.
|
||||
- **Capture tally feeding bot decisions** — bot ignores it; possible Phase-2
|
||||
ReconBrain input.
|
||||
- **Sending phantom state to the server / persisting it server-side** — would
|
||||
break the security invariant; never.
|
||||
|
||||
## Open questions
|
||||
|
||||
None outstanding. Resolved during brainstorm:
|
||||
|
||||
- Phantom model: manual, seeded once, no automation, unlimited placement,
|
||||
editable anytime (Seth).
|
||||
- Phantom interaction: drag-and-drop, tap-vs-drag disambiguation, no mode
|
||||
toggle (Seth — option A).
|
||||
- Feature 2 "Lost" secondary line: included by default; Seth may drop it at
|
||||
spec review.
|
||||
+3
-1
@@ -12,9 +12,11 @@
|
||||
"test": "pnpm -r test",
|
||||
"dev:server": "pnpm --filter @blind-chess/server dev",
|
||||
"dev:client": "pnpm --filter @blind-chess/client dev",
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"selfplay": "tsx scripts/selfplay.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ALL_SQUARES, FILES, RANKS, geometricMoves, type Color, type Piece, type Square } from '@blind-chess/shared';
|
||||
import { pieceGlyph } from './pieces.js';
|
||||
import { phantomDrag } from './stores/phantom-drag.svelte.js';
|
||||
|
||||
interface Props {
|
||||
pieces: Partial<Record<Square, Piece>>;
|
||||
@@ -8,6 +9,8 @@
|
||||
toMove: Color;
|
||||
mode: 'blind' | 'vanilla';
|
||||
highlightingEnabled: boolean;
|
||||
phantoms?: Partial<Record<Square, Piece>>;
|
||||
phantomsEnabled?: boolean;
|
||||
armedSquare: Square | null; // local visual arm (pre-commit)
|
||||
touchedSquare: Square | null; // server-authoritative touch
|
||||
onArm: (sq: Square | null) => void;
|
||||
@@ -16,6 +19,7 @@
|
||||
|
||||
let {
|
||||
pieces, you, toMove, mode, highlightingEnabled,
|
||||
phantoms = {}, phantomsEnabled = false,
|
||||
armedSquare, touchedSquare, onArm, onCommit,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -43,6 +47,14 @@
|
||||
return new Set(moves);
|
||||
});
|
||||
|
||||
// The board square a phantom is currently being dragged out of (so it can
|
||||
// be dimmed while the drag ghost is shown). Bind `active` to a local first
|
||||
// so TypeScript narrows the discriminated union reliably.
|
||||
const dragOrigin = $derived.by(() => {
|
||||
const a = phantomDrag.state.active;
|
||||
return a?.kind === 'board' && phantomDrag.state.moved ? a.from : null;
|
||||
});
|
||||
|
||||
function squareColor(sq: Square): 'light' | 'dark' {
|
||||
const f = sq.charCodeAt(0) - 'a'.charCodeAt(0);
|
||||
const r = parseInt(sq[1]!, 10) - 1;
|
||||
@@ -50,6 +62,7 @@
|
||||
}
|
||||
|
||||
function onSquareClick(sq: Square) {
|
||||
if (phantomDrag.shouldSuppressClick(sq)) return;
|
||||
const piece = pieces[sq];
|
||||
|
||||
// If a piece is touched (server-authoritative), only commits to that square's destinations are valid.
|
||||
@@ -82,6 +95,7 @@
|
||||
{#each filesDisplay as f (f)}
|
||||
{@const sq = `${f}${r}` as Square}
|
||||
{@const piece = pieces[sq]}
|
||||
{@const ph = phantomsEnabled ? phantoms[sq] : undefined}
|
||||
{@const sc = squareColor(sq)}
|
||||
{@const isArmed = sq === armedSquare}
|
||||
{@const isTouched = sq === touchedSquare}
|
||||
@@ -96,6 +110,7 @@
|
||||
class:hl-cap={isHighlightCap}
|
||||
onclick={() => onSquareClick(sq)}
|
||||
aria-label={sq}
|
||||
data-square={sq}
|
||||
>
|
||||
{#if r === (you === 'w' ? '1' : '8') && f === filesDisplay[0]}
|
||||
<span class="coord coord-rank">{r}</span>
|
||||
@@ -106,6 +121,17 @@
|
||||
{#if piece}
|
||||
<span class="piece piece-{piece.color}">{pieceGlyph(piece)}</span>
|
||||
{/if}
|
||||
{#if ph && !piece}
|
||||
<!-- Phantom pieces are a pointer-only private annotation overlay;
|
||||
there is no keyboard drag interaction. The real game stays fully
|
||||
keyboard-operable via the square button below. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="phantom phantom-{ph.color}"
|
||||
class:dragging={sq === dragOrigin}
|
||||
onpointerdown={(e) => { e.preventDefault(); phantomDrag.start({ kind: 'board', from: sq, type: ph.type }, e); }}
|
||||
>{pieceGlyph(ph)}</span>
|
||||
{/if}
|
||||
{#if isHighlight && !piece}
|
||||
<span class="dot"></span>
|
||||
{/if}
|
||||
@@ -154,6 +180,23 @@
|
||||
.piece-w { color: #fafafa; }
|
||||
.piece-b { color: #1a1a1a; }
|
||||
|
||||
.phantom {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
opacity: 0.4;
|
||||
outline: 2px dashed var(--text-dim);
|
||||
outline-offset: -5px;
|
||||
}
|
||||
.phantom-w { color: #fafafa; }
|
||||
.phantom-b { color: #1a1a1a; }
|
||||
.phantom.dragging { opacity: 0.12; }
|
||||
|
||||
.armed { box-shadow: inset 0 0 0 4px var(--armed); }
|
||||
.touched { box-shadow: inset 0 0 0 4px var(--touched); }
|
||||
.hl::before {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import type { CaptureTally, Color, PieceTally, PieceType } from '@blind-chess/shared';
|
||||
import { pieceGlyph } from './pieces.js';
|
||||
|
||||
interface Props {
|
||||
captures: CaptureTally;
|
||||
you: Color;
|
||||
}
|
||||
let { captures, you }: Props = $props();
|
||||
|
||||
const ORDER: PieceType[] = ['q', 'r', 'b', 'n', 'p'];
|
||||
const oppColor = $derived<Color>(you === 'w' ? 'b' : 'w');
|
||||
|
||||
function glyphs(tally: PieceTally, color: Color): { glyph: string; key: string }[] {
|
||||
const out: { glyph: string; key: string }[] = [];
|
||||
for (const t of ORDER) {
|
||||
const n = tally[t] ?? 0;
|
||||
for (let i = 0; i < n; i++) out.push({ glyph: pieceGlyph({ color, type: t }), key: `${t}${i}` });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function total(tally: PieceTally): number {
|
||||
return Object.values(tally).reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
const youTook = $derived(glyphs(captures.byYou, oppColor));
|
||||
const youLost = $derived(glyphs(captures.byOpponent, you));
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<header>Captures</header>
|
||||
<div class="row">
|
||||
<span class="label">You took</span>
|
||||
<span class="pieces">
|
||||
{#each youTook as g (g.key)}<span class="g g-{oppColor}">{g.glyph}</span>{:else}<span class="muted">—</span>{/each}
|
||||
</span>
|
||||
<span class="n">{total(captures.byYou)}</span>
|
||||
</div>
|
||||
<div class="row lost">
|
||||
<span class="label">Lost</span>
|
||||
<span class="pieces">
|
||||
{#each youLost as g (g.key)}<span class="g g-{you}">{g.glyph}</span>{:else}<span class="muted">—</span>{/each}
|
||||
</span>
|
||||
<span class="n">{total(captures.byOpponent)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
header {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.row.lost { opacity: 0.7; }
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
width: 56px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pieces { flex: 1; font-size: 20px; line-height: 1; }
|
||||
.g-w { color: #fafafa; }
|
||||
/* Dark pieces need a light outline to read on the dark panel background. */
|
||||
.g-b { color: #1a1a1a; text-shadow: 0 0 2px var(--text), 0 0 2px var(--text); }
|
||||
.n {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -3,8 +3,13 @@
|
||||
import { game } from './stores/game.svelte.js';
|
||||
import Board from './Board.svelte';
|
||||
import ModeratorPanel from './ModeratorPanel.svelte';
|
||||
import CaptureTally from './CaptureTally.svelte';
|
||||
import PromotionDialog from './PromotionDialog.svelte';
|
||||
import type { PromotionType, Square } from '@blind-chess/shared';
|
||||
import PhantomPalette from './PhantomPalette.svelte';
|
||||
import { pieceGlyph } from './pieces.js';
|
||||
import { phantoms } from './stores/phantoms.svelte.js';
|
||||
import { phantomDrag } from './stores/phantom-drag.svelte.js';
|
||||
import { isPromotionMove, type PromotionType, type Square } from '@blind-chess/shared';
|
||||
|
||||
interface Props { gameId: string; }
|
||||
let { gameId }: Props = $props();
|
||||
@@ -35,13 +40,13 @@
|
||||
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;
|
||||
}
|
||||
// A pawn promotes only from the rank adjacent to its promotion rank —
|
||||
// isPromotionMove checks the source rank, destination rank, and file delta,
|
||||
// so a pawn elsewhere "moved" toward the last rank no longer pops this
|
||||
// dialog (which a click on a phantom-occupied back-rank square could do).
|
||||
if (isPromotionMove(piece, from, to)) {
|
||||
pendingPromotion = { from, to };
|
||||
return;
|
||||
}
|
||||
game.commit(from, to);
|
||||
}
|
||||
@@ -61,6 +66,17 @@
|
||||
setTimeout(() => copied = false, 1500);
|
||||
}
|
||||
|
||||
const isBotTurn = $derived(
|
||||
!!game.state.aiOpponent
|
||||
&& game.state.gameStatus === 'active'
|
||||
&& game.state.view?.toMove === game.state.aiOpponent.color,
|
||||
);
|
||||
|
||||
const aiBadgeText = $derived.by(() => {
|
||||
if (!game.state.aiOpponent) return null;
|
||||
return game.state.aiOpponent.brain === 'casual' ? 'Casual bot' : 'gemma4 recon';
|
||||
});
|
||||
|
||||
const turnLabel = $derived.by(() => {
|
||||
if (game.state.gameStatus === 'finished') {
|
||||
const reason = game.state.endReason;
|
||||
@@ -74,8 +90,46 @@
|
||||
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';
|
||||
if (game.state.aiOpponent) {
|
||||
const name = game.state.aiOpponent.brain === 'casual' ? 'Casual bot' : 'gemma4 recon';
|
||||
return `${name} is moving…`;
|
||||
}
|
||||
return 'Opponent thinking';
|
||||
});
|
||||
|
||||
const oppColor = $derived<'w' | 'b'>(game.state.you === 'w' ? 'b' : 'w');
|
||||
|
||||
// Phantom layer is blind-mode-only and shown only during active play.
|
||||
const phantomLayerEnabled = $derived(
|
||||
game.state.mode === 'blind' && game.state.gameStatus === 'active',
|
||||
);
|
||||
|
||||
// The piece type currently being dragged (for the floating ghost), or null.
|
||||
const dragGhost = $derived.by(() => {
|
||||
const a = phantomDrag.state.active;
|
||||
return a && phantomDrag.state.moved ? a.type : null;
|
||||
});
|
||||
|
||||
// Load the phantom layer when `you` is known (blind games only). Keyed on
|
||||
// gameId — like the connection effect — so it reloads if this <Game>
|
||||
// instance is reused for a different game without a remount.
|
||||
let loadedFor: string | null = $state(null);
|
||||
$effect(() => {
|
||||
const id = gameId;
|
||||
const you = game.state.you;
|
||||
if (loadedFor === id) return;
|
||||
if (you && game.state.mode === 'blind') {
|
||||
untrack(() => phantoms.loadForGame(id, you));
|
||||
loadedFor = id;
|
||||
}
|
||||
});
|
||||
|
||||
// Drop the phantom layer when the game ends.
|
||||
$effect(() => {
|
||||
if (game.state.gameStatus === 'finished') {
|
||||
untrack(() => phantoms.clearForGame(gameId));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="game-layout" class:waiting={game.state.gameStatus === 'waiting'}>
|
||||
@@ -89,6 +143,11 @@
|
||||
· You: {game.state.you === 'w' ? 'White' : 'Black'}
|
||||
{/if}
|
||||
</span>
|
||||
{#if aiBadgeText}
|
||||
<span class="ai-badge" class:thinking={isBotTurn}>
|
||||
{aiBadgeText}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if game.state.gameStatus === 'waiting'}
|
||||
@@ -108,15 +167,21 @@
|
||||
toMove={game.state.view.toMove}
|
||||
mode={game.state.mode ?? 'blind'}
|
||||
highlightingEnabled={game.state.highlightingEnabled}
|
||||
phantoms={phantomLayerEnabled ? phantoms.state.phantoms : {}}
|
||||
phantomsEnabled={phantomLayerEnabled}
|
||||
armedSquare={armedSquare}
|
||||
touchedSquare={game.state.touchedPiece}
|
||||
{onArm}
|
||||
{onCommit}
|
||||
/>
|
||||
{#if phantomLayerEnabled}
|
||||
<PhantomPalette {oppColor} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<aside class="side">
|
||||
<ModeratorPanel announcements={game.state.announcements} you={game.state.you} />
|
||||
<CaptureTally captures={game.state.captures} you={game.state.you} />
|
||||
|
||||
<div class="actions">
|
||||
{#if game.state.gameStatus === 'active'}
|
||||
@@ -141,13 +206,20 @@
|
||||
<div class="banner err">⚠ {game.state.lastError.code}: {game.state.lastError.message}</div>
|
||||
{/if}
|
||||
|
||||
{#if !game.state.opponentConnected && game.state.gameStatus === 'active'}
|
||||
{#if !game.state.opponentConnected && game.state.gameStatus === 'active' && !game.state.aiOpponent}
|
||||
<div class="banner muted">Opponent disconnected — 5 minute grace window.</div>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if phantomLayerEnabled && dragGhost}
|
||||
<div
|
||||
class="drag-ghost piece-{oppColor}"
|
||||
style="left: {phantomDrag.state.x}px; top: {phantomDrag.state.y}px;"
|
||||
>{pieceGlyph({ color: oppColor, type: dragGhost })}</div>
|
||||
{/if}
|
||||
|
||||
{#if pendingPromotion && game.state.you}
|
||||
<PromotionDialog
|
||||
color={game.state.you}
|
||||
@@ -199,7 +271,13 @@
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.board-area { display: flex; align-items: center; justify-content: center; }
|
||||
.board-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
@@ -221,6 +299,22 @@
|
||||
.banner .row { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.banner .row button { flex: 1; }
|
||||
|
||||
.ai-badge {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--panel);
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-badge.thinking {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: rgba(211, 84, 0, 0.07);
|
||||
}
|
||||
|
||||
.waiting-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
@@ -232,4 +326,18 @@
|
||||
.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; }
|
||||
|
||||
.drag-ghost {
|
||||
position: fixed;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
font-size: 44px;
|
||||
line-height: 1;
|
||||
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.drag-ghost.piece-w { color: #fafafa; }
|
||||
/* A dark dragged piece needs a light outline (the base dark shadow above
|
||||
does nothing for it) so it reads over dark areas of the page. */
|
||||
.drag-ghost.piece-b { color: #1a1a1a; text-shadow: 0 0 3px var(--text), 0 0 2px var(--text); }
|
||||
</style>
|
||||
|
||||
@@ -1,30 +1,60 @@
|
||||
<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);
|
||||
// Friend section state.
|
||||
let friendMode: Mode = $state('blind');
|
||||
let friendSide: Color | 'random' = $state('random');
|
||||
let friendHighlight = $state(false);
|
||||
let friendCreating = $state(false);
|
||||
let friendError: string | null = $state(null);
|
||||
|
||||
async function create() {
|
||||
creating = true;
|
||||
error = null;
|
||||
// AI section state (separate so user can configure each independently).
|
||||
let aiMode: Mode = $state('blind');
|
||||
let aiSide: Color | 'random' = $state('random');
|
||||
let aiHighlight = $state(false);
|
||||
let aiCreating = $state(false);
|
||||
let aiError: string | null = $state(null);
|
||||
|
||||
async function createWithFriend() {
|
||||
friendCreating = true; friendError = null;
|
||||
try {
|
||||
const res = await fetch('/api/games', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ mode, side, highlightingEnabled }),
|
||||
body: JSON.stringify({
|
||||
mode: friendMode, side: friendSide, highlightingEnabled: friendHighlight,
|
||||
}),
|
||||
});
|
||||
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);
|
||||
friendError = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
creating = false;
|
||||
friendCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createVsCasual() {
|
||||
aiCreating = true; aiError = null;
|
||||
try {
|
||||
const res = await fetch('/api/games', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mode: aiMode, side: aiSide, highlightingEnabled: aiHighlight,
|
||||
vsAi: { brain: 'casual' },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json: CreateGameResponse & { creatorColor: Color } = await res.json();
|
||||
localStorage.setItem(`bc:${json.gameId}`, json.creatorToken);
|
||||
location.hash = `#/g/${json.gameId}`;
|
||||
} catch (e) {
|
||||
aiError = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
aiCreating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -36,18 +66,19 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Create a game</h2>
|
||||
<h2>Play with a friend</h2>
|
||||
<p class="card-sub muted">Get a shareable link, send it to someone, play together.</p>
|
||||
|
||||
<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" />
|
||||
<label class="opt" class:active={friendMode === 'blind'}>
|
||||
<input type="radio" bind:group={friendMode} 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" />
|
||||
<label class="opt" class:active={friendMode === 'vanilla'}>
|
||||
<input type="radio" bind:group={friendMode} value="vanilla" />
|
||||
<span class="opt-title">Vanilla</span>
|
||||
<span class="opt-sub">Normal chess. Both players see everything.</span>
|
||||
</label>
|
||||
@@ -57,31 +88,93 @@
|
||||
<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>
|
||||
<label><input type="radio" bind:group={friendSide} value="w" /> White</label>
|
||||
<label><input type="radio" bind:group={friendSide} value="b" /> Black</label>
|
||||
<label><input type="radio" bind:group={friendSide} value="random" /> Random</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" bind:checked={highlightingEnabled} />
|
||||
<input type="checkbox" bind:checked={friendHighlight} />
|
||||
<span>Highlight reachable squares</span>
|
||||
{#if mode === 'blind'}
|
||||
{#if friendMode === '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 class="primary big" disabled={friendCreating} onclick={createWithFriend}>
|
||||
{friendCreating ? 'Creating…' : 'Create game'}
|
||||
</button>
|
||||
|
||||
{#if error}
|
||||
<p class="error">Error: {error}</p>
|
||||
{/if}
|
||||
{#if friendError}<p class="error">Error: {friendError}</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Play vs computer</h2>
|
||||
<p class="card-sub muted">Always-available opponent. No link to share — game starts immediately.</p>
|
||||
|
||||
<div class="field">
|
||||
<span class="lbl">Mode</span>
|
||||
<div class="opts">
|
||||
<label class="opt" class:active={aiMode === 'blind'}>
|
||||
<input type="radio" bind:group={aiMode} 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={aiMode === 'vanilla'}>
|
||||
<input type="radio" bind:group={aiMode} 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={aiSide} value="w" /> White</label>
|
||||
<label><input type="radio" bind:group={aiSide} value="b" /> Black</label>
|
||||
<label><input type="radio" bind:group={aiSide} value="random" /> Random</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" bind:checked={aiHighlight} />
|
||||
<span>Highlight reachable squares</span>
|
||||
{#if aiMode === 'blind'}
|
||||
<span class="hint muted">(geometric only — no opponent info)</span>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="ai-buttons">
|
||||
<button class="primary" disabled={aiCreating} onclick={createVsCasual}>
|
||||
{aiCreating ? 'Creating…' : 'Casual bot'}
|
||||
</button>
|
||||
<button class="secondary" disabled title="Coming soon">
|
||||
gemma4 recon (coming soon)
|
||||
</button>
|
||||
</div>
|
||||
<p class="card-sub muted small">
|
||||
Casual: fast, plays simple moves, makes mistakes. Good for a quick game.
|
||||
</p>
|
||||
{#if aiError}<p class="error">Error: {aiError}</p>{/if}
|
||||
</div>
|
||||
|
||||
<a class="card card-link" href="/duplicate/">
|
||||
<h2>
|
||||
Duplicate Chess
|
||||
<span class="badge">under development</span>
|
||||
</h2>
|
||||
<p class="card-sub muted">
|
||||
A four-player chess variant invented by Andrew Freiberg. Perfect information —
|
||||
every player sees all four boards. Local sandbox; open it and play around.
|
||||
</p>
|
||||
<span class="open-cue">Open →</span>
|
||||
</a>
|
||||
|
||||
<footer class="muted">
|
||||
<span class="mono">git.sethpc.xyz/Seth/blind_chess</span>
|
||||
</footer>
|
||||
@@ -114,8 +207,11 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 22px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h2 { font-size: 18px; margin: 0 0 16px; }
|
||||
.card-sub { font-size: 13px; margin: -10px 0 16px; }
|
||||
.card-sub.small { margin-top: 12px; font-size: 12px; }
|
||||
h2 { font-size: 18px; margin: 0 0 8px; }
|
||||
|
||||
.field { margin-bottom: 20px; }
|
||||
.lbl {
|
||||
@@ -157,6 +253,44 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ai-buttons { display: grid; gap: 8px; grid-template-columns: 1fr 1fr; }
|
||||
@media (max-width: 480px) { .ai-buttons { grid-template-columns: 1fr; } }
|
||||
.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.secondary:disabled { cursor: not-allowed; opacity: 0.6; }
|
||||
|
||||
.error { color: #f87171; margin-top: 12px; }
|
||||
footer { text-align: center; margin-top: 24px; font-size: 12px; }
|
||||
|
||||
.card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: border 0.15s, background 0.15s;
|
||||
}
|
||||
.card-link:hover { border-color: var(--accent-dim); background: rgba(211,84,0,0.05); }
|
||||
.card-link h2 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
color: var(--accent);
|
||||
background: rgba(211,84,0,0.10);
|
||||
border: 1px solid var(--accent-dim);
|
||||
}
|
||||
.open-cue {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,9 +24,11 @@
|
||||
<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'}>
|
||||
{@const isAttempt = a.text === 'no_such_piece' || a.text === 'no_legal_moves' || a.text === 'wont_help' || a.text === 'illegal_move'}
|
||||
{@const actor = a.ply % 2 === 0 ? 'White' : 'Black'}
|
||||
<div class="entry" class:attempt={isAttempt}>
|
||||
<span class="ply">{a.ply > 0 ? `#${a.ply}` : ''}</span>
|
||||
<span class="text">{moderatorText(a.text, a.payload)}</span>
|
||||
<span class="text">{isAttempt ? `${actor} — ` : ''}{moderatorText(a.text, a.payload)}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty muted">The moderator is silent.</div>
|
||||
@@ -67,7 +69,7 @@
|
||||
border-bottom: 1px dashed rgba(255,255,255,0.05);
|
||||
}
|
||||
.entry:last-child { border-bottom: none; }
|
||||
.entry.err .text { color: #f87171; }
|
||||
.entry.attempt .text { color: var(--text-dim); font-style: italic; }
|
||||
.ply {
|
||||
color: var(--text-dim);
|
||||
font-family: ui-monospace, monospace;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import type { Color, PieceType } from '@blind-chess/shared';
|
||||
import { pieceGlyph } from './pieces.js';
|
||||
import { phantomDrag } from './stores/phantom-drag.svelte.js';
|
||||
|
||||
interface Props { oppColor: Color; }
|
||||
let { oppColor }: Props = $props();
|
||||
|
||||
const TYPES: PieceType[] = ['q', 'r', 'b', 'n', 'p', 'k'];
|
||||
</script>
|
||||
|
||||
<div class="palette">
|
||||
<span class="hint muted">Drag onto your board — your guess of where the opponent is.</span>
|
||||
<div class="pieces">
|
||||
{#each TYPES as t (t)}
|
||||
<!-- Pointer-only drag source — same deliberate a11y trade-off as the
|
||||
phantom spans in Board.svelte (no keyboard drag interaction). -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="pp pp-{oppColor}"
|
||||
onpointerdown={(e) => { e.preventDefault(); phantomDrag.start({ kind: 'palette', type: t }, e); }}
|
||||
>{pieceGlyph({ color: oppColor, type: t })}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.palette {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.hint { font-size: 12px; }
|
||||
.pieces { display: flex; gap: 6px; }
|
||||
.pp {
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
opacity: 0.75;
|
||||
user-select: none;
|
||||
}
|
||||
.pp:hover { opacity: 1; }
|
||||
.pp-w { color: #fafafa; }
|
||||
/* Dark pieces need a light outline to read on the dark panel background. */
|
||||
.pp-b { color: #1a1a1a; text-shadow: 0 0 2px var(--text), 0 0 2px var(--text); }
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
Announcement,
|
||||
BoardView,
|
||||
CaptureTally,
|
||||
ClientMessage,
|
||||
Color,
|
||||
ErrorCode,
|
||||
@@ -28,6 +29,8 @@ interface GameStateValue {
|
||||
winner: Color | null;
|
||||
opponentConnected: boolean;
|
||||
lastError: { code: ErrorCode; message: string; at: number } | null;
|
||||
aiOpponent: { color: Color; brain: 'casual' | 'recon' } | null;
|
||||
captures: CaptureTally;
|
||||
}
|
||||
|
||||
function makeStore() {
|
||||
@@ -47,6 +50,8 @@ function makeStore() {
|
||||
winner: null,
|
||||
opponentConnected: false,
|
||||
lastError: null,
|
||||
aiOpponent: null,
|
||||
captures: { byYou: {}, byOpponent: {} },
|
||||
});
|
||||
|
||||
function tokenKey(gameId: string) { return `bc:${gameId}`; }
|
||||
@@ -91,6 +96,8 @@ function makeStore() {
|
||||
state.mode = m.mode;
|
||||
state.highlightingEnabled = m.highlightingEnabled;
|
||||
state.opponentConnected = m.opponentConnected;
|
||||
state.aiOpponent = m.aiOpponent ?? null;
|
||||
state.captures = m.captures;
|
||||
if (state.gameId) localStorage.setItem(tokenKey(state.gameId), m.token);
|
||||
break;
|
||||
case 'update':
|
||||
@@ -103,6 +110,8 @@ function makeStore() {
|
||||
if (m.newAnnouncements.length) {
|
||||
state.announcements = [...state.announcements, ...m.newAnnouncements];
|
||||
}
|
||||
if (m.aiOpponent !== undefined) state.aiOpponent = m.aiOpponent;
|
||||
state.captures = m.captures;
|
||||
break;
|
||||
case 'peer-status':
|
||||
if (state.you && m.color !== state.you) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { PieceType, Square } from '@blind-chess/shared';
|
||||
import { phantoms } from './phantoms.svelte.js';
|
||||
import { game } from './game.svelte.js';
|
||||
|
||||
export type DragSource =
|
||||
| { kind: 'palette'; type: PieceType }
|
||||
| { kind: 'board'; from: Square; type: PieceType };
|
||||
|
||||
/**
|
||||
* Pointer-event drag controller for the phantom layer. A drag past THRESHOLD
|
||||
* px places/moves/removes a phantom; a sub-threshold press is a tap and is
|
||||
* left for the board's normal click handler. Real moves are unaffected.
|
||||
*/
|
||||
function makeDrag() {
|
||||
const state = $state<{
|
||||
active: DragSource | null;
|
||||
x: number;
|
||||
y: number;
|
||||
moved: boolean;
|
||||
}>({ active: null, x: 0, y: 0, moved: false });
|
||||
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
// Set only when a board-phantom drag ends back on its origin square — the
|
||||
// browser then fires a spurious `click` on that square's button which must
|
||||
// be swallowed so it doesn't trigger a real move.
|
||||
let suppressClickOn: Square | null = null;
|
||||
const THRESHOLD = 6;
|
||||
|
||||
function detach() {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
window.removeEventListener('pointercancel', onCancel);
|
||||
}
|
||||
|
||||
function onMove(e: PointerEvent) {
|
||||
if (!state.active) return;
|
||||
state.x = e.clientX;
|
||||
state.y = e.clientY;
|
||||
if (!state.moved && Math.hypot(e.clientX - startX, e.clientY - startY) > THRESHOLD) {
|
||||
state.moved = true;
|
||||
}
|
||||
}
|
||||
|
||||
// pointercancel fires instead of pointerup when the browser/OS takes over
|
||||
// the gesture (common on touch). Abort the drag: clean up, drop nothing.
|
||||
function onCancel() {
|
||||
detach();
|
||||
state.active = null;
|
||||
state.moved = false;
|
||||
}
|
||||
|
||||
function onUp(e: PointerEvent) {
|
||||
detach();
|
||||
const src = state.active;
|
||||
const wasDrag = state.moved;
|
||||
state.active = null;
|
||||
state.moved = false;
|
||||
if (!src || !wasDrag) return; // a tap — the board click handler deals with it
|
||||
|
||||
// elementFromPoint returns null off-viewport — treated as an off-board drop.
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
const sqEl = el?.closest('[data-square]') as HTMLElement | null;
|
||||
const target = sqEl?.dataset.square as Square | undefined;
|
||||
|
||||
if (src.kind === 'board') {
|
||||
if (target === src.from) { suppressClickOn = src.from; return; }
|
||||
if (!target) { phantoms.remove(src.from); return; } // dropped off the board
|
||||
if (game.state.view?.pieces[target]) return; // your own real piece — reject
|
||||
phantoms.move(src.from, target);
|
||||
return;
|
||||
}
|
||||
// palette → board
|
||||
if (target && !game.state.view?.pieces[target]) phantoms.place(target, src.type);
|
||||
}
|
||||
|
||||
function start(src: DragSource, e: PointerEvent) {
|
||||
detach(); // idempotency — drop any listeners from an unfinished prior drag
|
||||
suppressClickOn = null;
|
||||
state.active = src;
|
||||
state.x = startX = e.clientX;
|
||||
state.y = startY = e.clientY;
|
||||
state.moved = false;
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
window.addEventListener('pointercancel', onCancel);
|
||||
}
|
||||
|
||||
/** The board calls this first in its square-click handler. */
|
||||
function shouldSuppressClick(sq: Square): boolean {
|
||||
if (suppressClickOn === sq) { suppressClickOn = null; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
return { state, start, shouldSuppressClick };
|
||||
}
|
||||
|
||||
export const phantomDrag = makeDrag();
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
opponentStartPosition,
|
||||
deserializePhantoms,
|
||||
type Color,
|
||||
type Piece,
|
||||
type PieceType,
|
||||
type Square,
|
||||
} from '@blind-chess/shared';
|
||||
|
||||
/**
|
||||
* Client-LOCAL store for the player's phantom opponent-model layer.
|
||||
* This data NEVER reaches the server — it is the player's private guess.
|
||||
* Do not read this store in any `send`/`commit` path.
|
||||
*/
|
||||
function makeStore() {
|
||||
const state = $state<{ phantoms: Partial<Record<Square, Piece>> }>({ phantoms: {} });
|
||||
let gameId: string | null = null;
|
||||
let oppColor: Color = 'b';
|
||||
|
||||
function key(id: string) { return `bc:phantoms:${id}`; }
|
||||
|
||||
function persist() {
|
||||
if (gameId) localStorage.setItem(key(gameId), JSON.stringify(state.phantoms));
|
||||
}
|
||||
|
||||
/** Load (or first-time seed) the phantom layer for a blind game. */
|
||||
function loadForGame(id: string, you: Color) {
|
||||
gameId = id;
|
||||
oppColor = you === 'w' ? 'b' : 'w';
|
||||
const raw = localStorage.getItem(key(id));
|
||||
if (raw === null) {
|
||||
// First load — seed with the opponent's starting army.
|
||||
state.phantoms = opponentStartPosition(oppColor);
|
||||
persist();
|
||||
} else {
|
||||
state.phantoms = deserializePhantoms(raw);
|
||||
}
|
||||
}
|
||||
|
||||
function place(sq: Square, type: PieceType) {
|
||||
if (!gameId) return;
|
||||
state.phantoms = { ...state.phantoms, [sq]: { color: oppColor, type } };
|
||||
persist();
|
||||
}
|
||||
|
||||
function move(from: Square, to: Square) {
|
||||
if (!gameId || from === to) return;
|
||||
const p = state.phantoms[from];
|
||||
if (!p) return;
|
||||
const next = { ...state.phantoms };
|
||||
delete next[from];
|
||||
next[to] = p;
|
||||
state.phantoms = next;
|
||||
persist();
|
||||
}
|
||||
|
||||
function remove(sq: Square) {
|
||||
if (!gameId) return;
|
||||
const next = { ...state.phantoms };
|
||||
delete next[sq];
|
||||
state.phantoms = next;
|
||||
persist();
|
||||
}
|
||||
|
||||
/** Drop the layer when a game ends — avoids unbounded localStorage growth. */
|
||||
function clearForGame(id: string) {
|
||||
localStorage.removeItem(key(id));
|
||||
if (gameId === id) { state.phantoms = {}; gameId = null; }
|
||||
}
|
||||
|
||||
return { state, loadForGame, place, move, remove, clearForGame };
|
||||
}
|
||||
|
||||
export const phantoms = makeStore();
|
||||
@@ -17,6 +17,7 @@
|
||||
"@fastify/websocket": "^11.0.0",
|
||||
"chess.js": "^1.4.0",
|
||||
"fastify": "^5.2.0",
|
||||
"js-chess-engine": "^2.4.6",
|
||||
"pino": "^9.5.0",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.24.0"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
Announcement,
|
||||
BoardView,
|
||||
Color,
|
||||
PromotionType,
|
||||
Square,
|
||||
} from '@blind-chess/shared';
|
||||
import type { ModeratorText } from '@blind-chess/shared';
|
||||
|
||||
export interface CandidateMove {
|
||||
from: Square;
|
||||
to: Square;
|
||||
promotion?: PromotionType;
|
||||
}
|
||||
|
||||
export interface AttemptHistoryEntry {
|
||||
move: CandidateMove;
|
||||
rejection: ModeratorText;
|
||||
}
|
||||
|
||||
export interface BrainInput {
|
||||
view: BoardView;
|
||||
newAnnouncements: Announcement[];
|
||||
legalCandidates: CandidateMove[];
|
||||
attemptHistory: AttemptHistoryEntry[];
|
||||
drawOfferFromOpponent: boolean;
|
||||
ply: number;
|
||||
/**
|
||||
* Full position FEN. Set only in vanilla mode where `view` is already a
|
||||
* full reveal — omitted in blind mode, since passing the FEN there would
|
||||
* leak opponent positions and violate the view-filter invariant. Brains
|
||||
* with an internal chess engine rely on this; brains that don't can
|
||||
* ignore it.
|
||||
*/
|
||||
fen?: string;
|
||||
}
|
||||
|
||||
export type BrainAction =
|
||||
| { type: 'commit'; from: Square; to: Square; promotion?: PromotionType }
|
||||
| { type: 'resign' }
|
||||
| { type: 'offer-draw' }
|
||||
| { type: 'respond-draw'; accept: boolean };
|
||||
|
||||
export interface BrainInitArgs {
|
||||
color: Color;
|
||||
mode: 'blind' | 'vanilla';
|
||||
gameId: string;
|
||||
}
|
||||
|
||||
export interface Brain {
|
||||
init(args: BrainInitArgs): Promise<void>;
|
||||
decide(input: BrainInput): Promise<BrainAction>;
|
||||
dispose?(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
geometricMoves,
|
||||
type Color,
|
||||
type Piece,
|
||||
type PieceType,
|
||||
type PromotionType,
|
||||
type Square,
|
||||
} from '@blind-chess/shared';
|
||||
import type { Game } from '../state.js';
|
||||
import { ownSquares } from '../view.js';
|
||||
import type { CandidateMove } from './brain.js';
|
||||
|
||||
const PROMOTION_TYPES: PromotionType[] = ['q', 'r', 'b', 'n'];
|
||||
|
||||
export function legalCandidates(game: Game, color: Color): CandidateMove[] {
|
||||
if (game.mode === 'vanilla') return vanillaCandidates(game, color);
|
||||
return blindCandidates(game, color);
|
||||
}
|
||||
|
||||
function vanillaCandidates(game: Game, color: Color): CandidateMove[] {
|
||||
// chess.js only returns moves for the side to move via `.moves()`. To get a
|
||||
// hypothetical move list for the other color we'd need to rotate — but the
|
||||
// bot driver only invokes legalCandidates when it's the bot's turn, so this
|
||||
// is fine in practice. Tests for "wrong color" use blind mode.
|
||||
if (game.chess.turn() !== color) return [];
|
||||
|
||||
const moves = game.chess.moves({ verbose: true }) as Array<{
|
||||
from: Square; to: Square; promotion?: PromotionType;
|
||||
}>;
|
||||
const out: CandidateMove[] = [];
|
||||
for (const m of moves) {
|
||||
out.push({ from: m.from, to: m.to, promotion: m.promotion });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function blindCandidates(game: Game, color: Color): CandidateMove[] {
|
||||
const own = ownSquares(game, color);
|
||||
const board = game.chess.board();
|
||||
const out: CandidateMove[] = [];
|
||||
|
||||
for (const row of board) {
|
||||
if (!row) continue;
|
||||
for (const cell of row) {
|
||||
if (!cell) continue;
|
||||
if (cell.color !== color) continue;
|
||||
const piece: Piece = { color: cell.color, type: cell.type as PieceType };
|
||||
const from = cell.square as Square;
|
||||
const tos = geometricMoves(piece, from, own);
|
||||
for (const to of tos) {
|
||||
if (isPromotionSquare(piece, to)) {
|
||||
for (const promo of PROMOTION_TYPES) {
|
||||
out.push({ from, to, promotion: promo });
|
||||
}
|
||||
} else {
|
||||
out.push({ from, to });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isPromotionSquare(piece: Piece, to: Square): boolean {
|
||||
if (piece.type !== 'p') return false;
|
||||
const rank = to[1];
|
||||
return (piece.color === 'w' && rank === '8') || (piece.color === 'b' && rank === '1');
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { Game as EngineGame } from 'js-chess-engine';
|
||||
import type { BoardView, Color, PieceType, PromotionType, Square } from '@blind-chess/shared';
|
||||
import type {
|
||||
Brain,
|
||||
BrainAction,
|
||||
BrainInitArgs,
|
||||
BrainInput,
|
||||
CandidateMove,
|
||||
} from './brain.js';
|
||||
|
||||
interface CasualOpts {
|
||||
seed?: number;
|
||||
/**
|
||||
* Engine difficulty for vanilla mode (1-5; 1 is weakest).
|
||||
* `js-chess-engine` level 1 plays at roughly beginner strength —
|
||||
* crushes random moves but loses to a careful human. Higher levels
|
||||
* raise both strength and per-move latency.
|
||||
*/
|
||||
level?: 1 | 2 | 3 | 4 | 5;
|
||||
}
|
||||
|
||||
const PIECE_VALUE: Record<PieceType, number> = {
|
||||
p: 1, n: 3, b: 3, r: 5, q: 9, k: 0,
|
||||
};
|
||||
|
||||
export class CasualBrain implements Brain {
|
||||
private color: Color = 'w';
|
||||
private mode: 'blind' | 'vanilla' = 'blind';
|
||||
private level: 1 | 2 | 3 | 4 | 5;
|
||||
private rng: () => number;
|
||||
|
||||
constructor(opts: CasualOpts = {}) {
|
||||
this.rng = mulberry32(opts.seed ?? Math.floor(Math.random() * 0xffffffff));
|
||||
this.level = opts.level ?? 2;
|
||||
}
|
||||
|
||||
async init(args: BrainInitArgs): Promise<void> {
|
||||
this.color = args.color;
|
||||
this.mode = args.mode;
|
||||
}
|
||||
|
||||
async decide(input: BrainInput): Promise<BrainAction> {
|
||||
if (input.drawOfferFromOpponent) {
|
||||
return { type: 'respond-draw', accept: this.acceptDraw(input.view) };
|
||||
}
|
||||
|
||||
const filtered = this.excludeRejected(input.legalCandidates, input.attemptHistory);
|
||||
if (filtered.length === 0) {
|
||||
throw new Error('CasualBrain: zero candidates after exclusion');
|
||||
}
|
||||
|
||||
// Vanilla mode: delegate to a real chess engine. The driver supplies
|
||||
// a FEN only in vanilla mode, so this branch is naturally gated.
|
||||
if (this.mode === 'vanilla' && input.fen) {
|
||||
const engineMove = this.engineMove(input.fen, filtered);
|
||||
if (engineMove) {
|
||||
return {
|
||||
type: 'commit',
|
||||
from: engineMove.from,
|
||||
to: engineMove.to,
|
||||
promotion: engineMove.promotion,
|
||||
};
|
||||
}
|
||||
// Fall through to heuristic if the engine produced something we
|
||||
// can't validate against the candidate list.
|
||||
}
|
||||
|
||||
// Blind mode (or vanilla fallback): score-based heuristic. When the
|
||||
// moderator says we're in check, bias toward king moves — the only
|
||||
// class of moves that resolves nearly every check (king moves out of
|
||||
// attack), and the only class the bot can identify without seeing the
|
||||
// attacker. Without this bias the heuristic scores capture/advance
|
||||
// signals uncorrelated with check resolution, the FSM rejects every
|
||||
// non-resolving attempt, and the driver's retry cap fires.
|
||||
const inCheck = this.detectOwnCheck(input.newAnnouncements);
|
||||
const choice = this.heuristicPick(filtered, input.view, input.ply, inCheck);
|
||||
return {
|
||||
type: 'commit',
|
||||
from: choice.from,
|
||||
to: choice.to,
|
||||
promotion: choice.promotion,
|
||||
};
|
||||
}
|
||||
|
||||
private detectOwnCheck(announcements: BrainInput['newAnnouncements']): boolean {
|
||||
const tag = this.color === 'w' ? 'white_in_check' : 'black_in_check';
|
||||
return announcements.some((a) => a.text === tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run js-chess-engine on the given FEN and return a candidate matching
|
||||
* its choice, or null if no match was found.
|
||||
*/
|
||||
private engineMove(fen: string, candidates: CandidateMove[]): CandidateMove | null {
|
||||
let result: { move: Record<string, string> };
|
||||
try {
|
||||
const g = new EngineGame(fen);
|
||||
// randomness=30 picks among moves within 30 centipawns of best; this
|
||||
// breaks threefold-repetition draws when the bot is clearly winning
|
||||
// but doesn't see the conversion path.
|
||||
result = g.ai({ level: this.level, play: false, randomness: 30 }) as { move: Record<string, string> };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const entry = Object.entries(result.move ?? {})[0];
|
||||
if (!entry) return null;
|
||||
const [fromUC, toUC] = entry;
|
||||
const from = (fromUC as string).toLowerCase() as Square;
|
||||
const to = (toUC as string).toLowerCase() as Square;
|
||||
// Find a candidate matching this from-to. If the move is a promotion,
|
||||
// js-chess-engine emits the destination square (e.g., {E7: 'E8'}) but
|
||||
// doesn't separately surface the promotion piece — default to queen.
|
||||
const matches = candidates.filter((c) => c.from === from && c.to === to);
|
||||
if (matches.length === 0) return null;
|
||||
const queen = matches.find((c) => c.promotion === 'q');
|
||||
if (queen) return queen;
|
||||
return matches[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score-based fallback used for blind mode and any vanilla case where
|
||||
* the engine's pick wasn't in the candidate list. Plays badly on purpose.
|
||||
*/
|
||||
private heuristicPick(
|
||||
candidates: CandidateMove[],
|
||||
view: BoardView,
|
||||
ply: number,
|
||||
inCheck: boolean,
|
||||
): CandidateMove {
|
||||
const kingSquare = inCheck ? this.findOwnKing(view) : null;
|
||||
const scored = candidates.map((c) => {
|
||||
let score = this.scoreMove(c, view, ply);
|
||||
if (c.promotion === 'q') score += 1000;
|
||||
else if (c.promotion === 'r') score += 500;
|
||||
else if (c.promotion === 'b') score += 100;
|
||||
else if (c.promotion === 'n') score += 50;
|
||||
// King moves dominate when in check. The boost is large enough to
|
||||
// beat any combination of other heuristic factors so the driver
|
||||
// exhausts king escapes first; if all king moves are rejected the
|
||||
// attemptHistory exclusion strips them and the bot falls through
|
||||
// to non-king options (block / capture-attacker guesses).
|
||||
if (kingSquare && c.from === kingSquare) score += 5000;
|
||||
return { move: c, score: score + this.rng() * 0.01 };
|
||||
});
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
return scored[0]!.move;
|
||||
}
|
||||
|
||||
private findOwnKing(view: BoardView): Square | null {
|
||||
for (const sq of Object.keys(view.pieces) as Square[]) {
|
||||
const p = view.pieces[sq];
|
||||
if (p && p.color === this.color && p.type === 'k') return sq;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private excludeRejected(
|
||||
candidates: CandidateMove[],
|
||||
history: BrainInput['attemptHistory'],
|
||||
): CandidateMove[] {
|
||||
if (history.length === 0) return candidates;
|
||||
const rejected = new Set(history.map((h) => moveKey(h.move)));
|
||||
return candidates.filter((c) => !rejected.has(moveKey(c)));
|
||||
}
|
||||
|
||||
private scoreMove(move: CandidateMove, view: BoardView, ply: number): number {
|
||||
let score = 0;
|
||||
const destPiece = view.pieces[move.to];
|
||||
if (!destPiece) score += 50;
|
||||
|
||||
const piece = view.pieces[move.from];
|
||||
if (!piece) return score;
|
||||
|
||||
const ownStartingRank = this.color === 'w' ? '1' : '8';
|
||||
const ownPawnStartingRank = this.color === 'w' ? '2' : '7';
|
||||
|
||||
if (ply < 16 && (piece.type === 'n' || piece.type === 'b')
|
||||
&& move.from[1] === ownStartingRank) {
|
||||
score += 30;
|
||||
}
|
||||
|
||||
if (piece.type === 'p' && move.from[1] === ownPawnStartingRank) {
|
||||
const file = move.from[0];
|
||||
if (file === 'd' || file === 'e') score += 25;
|
||||
else if (file === 'c' || file === 'f') score += 10;
|
||||
}
|
||||
|
||||
const fromRank = parseInt(move.from[1]!, 10);
|
||||
const toRank = parseInt(move.to[1]!, 10);
|
||||
const advance = this.color === 'w' ? toRank - fromRank : fromRank - toRank;
|
||||
if (advance > 0) score += 15 * advance;
|
||||
|
||||
if (move.from[1] === ownStartingRank && (piece.type === 'q' || piece.type === 'r')) {
|
||||
score -= 40;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private acceptDraw(view: BoardView): boolean {
|
||||
let own = 0;
|
||||
for (const sq of Object.keys(view.pieces) as Square[]) {
|
||||
const p = view.pieces[sq];
|
||||
if (p) own += PIECE_VALUE[p.type];
|
||||
}
|
||||
return own < 15;
|
||||
}
|
||||
}
|
||||
|
||||
function moveKey(m: CandidateMove): string {
|
||||
return `${m.from}-${m.to}${m.promotion ?? ''}`;
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return function () {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { Color } from '@blind-chess/shared';
|
||||
import type { Game } from '../state.js';
|
||||
import type {
|
||||
AttemptHistoryEntry,
|
||||
Brain,
|
||||
BrainAction,
|
||||
BrainInput,
|
||||
CandidateMove,
|
||||
} from './brain.js';
|
||||
import { legalCandidates } from './candidates.js';
|
||||
import { handleCommit } from '../commit.js';
|
||||
import { buildView } from '../view.js';
|
||||
import { announce } from '../translator.js';
|
||||
import { finalizeIfEnded } from '../game-end.js';
|
||||
|
||||
// Per-decision-cycle retry budget. In vanilla mode chess.js verbose moves are
|
||||
// guaranteed legal so the cap is never exercised. In blind mode the brain
|
||||
// supplies pseudo-legal candidates and chess.js may reject many (pinned pieces,
|
||||
// unresolved check); we need budget to find a legal move before giving up.
|
||||
const RETRY_CAP = 25;
|
||||
|
||||
type BotResignReason =
|
||||
| 'retry_cap_exhausted'
|
||||
| 'brain_threw'
|
||||
| 'brain_chose_resign'
|
||||
| 'commit_silent'
|
||||
| 'commit_error';
|
||||
|
||||
function errString(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
}
|
||||
|
||||
interface BotDriverOpts {
|
||||
game: Game;
|
||||
brain: Brain;
|
||||
color: Color;
|
||||
}
|
||||
|
||||
export class BotDriver {
|
||||
private game: Game;
|
||||
private brain: Brain;
|
||||
private color: Color;
|
||||
|
||||
private decideInFlight = false;
|
||||
private disposed = false;
|
||||
private lastSeenAnnouncementCount = 0;
|
||||
|
||||
constructor(opts: BotDriverOpts) {
|
||||
this.game = opts.game;
|
||||
this.brain = opts.brain;
|
||||
this.color = opts.color;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.brain.init({
|
||||
color: this.color,
|
||||
mode: this.game.mode,
|
||||
gameId: this.game.id,
|
||||
});
|
||||
this.lastSeenAnnouncementCount = this.game.announcements.length;
|
||||
}
|
||||
|
||||
async onStateChange(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
|
||||
if (this.game.status === 'finished') {
|
||||
await this.disposeBrain();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.decideInFlight) return;
|
||||
if (!this.shouldDecide()) return;
|
||||
|
||||
this.decideInFlight = true;
|
||||
try {
|
||||
await this.runDecisionCycle();
|
||||
} finally {
|
||||
this.decideInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private shouldDecide(): boolean {
|
||||
if (this.game.status !== 'active') return false;
|
||||
// Respond to a draw offer from opponent even when it's not our turn.
|
||||
if (this.game.drawOffer && this.game.drawOffer.from !== this.color) return true;
|
||||
if (this.game.chess.turn() === this.color) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private async runDecisionCycle(): Promise<void> {
|
||||
const attemptHistory: AttemptHistoryEntry[] = [];
|
||||
|
||||
for (let attempt = 0; attempt < RETRY_CAP; attempt++) {
|
||||
// The bot makes atomic (from,to) commits — there is no touched-piece UX.
|
||||
// A prior attempt that survived past `tryMove` (e.g. illegal_move,
|
||||
// promotion_required) leaves `game.armed` set; a retry that picks a
|
||||
// different `from` would otherwise be rejected as
|
||||
// `must_move_touched_piece` and resign the bot. Clear here so each
|
||||
// attempt starts from a clean FSM state.
|
||||
if (this.game.armed?.color === this.color) {
|
||||
this.game.armed = null;
|
||||
}
|
||||
const input = this.buildBrainInput(attemptHistory);
|
||||
let outcome: { kind: 'done' } | { kind: 'retry'; entry: AttemptHistoryEntry };
|
||||
try {
|
||||
const action = await this.brain.decide(input);
|
||||
outcome = this.dispatch(action);
|
||||
} catch (err) {
|
||||
// Brain exception OR programming error in dispatch. Safe failure: resign.
|
||||
this.botResign('brain_threw', { err: errString(err) });
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === 'done') {
|
||||
this.lastSeenAnnouncementCount = this.game.announcements.length;
|
||||
return;
|
||||
}
|
||||
attemptHistory.push(outcome.entry);
|
||||
}
|
||||
this.lastSeenAnnouncementCount = this.game.announcements.length;
|
||||
this.botResign('retry_cap_exhausted', {
|
||||
attempts: attemptHistory.map((a) => `${a.move.from}-${a.move.to}:${a.rejection}`),
|
||||
});
|
||||
}
|
||||
|
||||
private buildBrainInput(attemptHistory: AttemptHistoryEntry[]): BrainInput {
|
||||
const view = buildView(this.game, this.color);
|
||||
const sliceStart = this.lastSeenAnnouncementCount;
|
||||
// NOTE: do NOT advance lastSeenAnnouncementCount here. The caller advances
|
||||
// it once the decision cycle terminates successfully — otherwise retried
|
||||
// attempts would not see the FSM's rejection announcements in their input.
|
||||
const newAnnouncements = this.game.announcements
|
||||
.slice(sliceStart)
|
||||
.filter((a) => a.audience === 'both' || a.audience === this.color);
|
||||
|
||||
const candidates: CandidateMove[] = legalCandidates(this.game, this.color);
|
||||
|
||||
return {
|
||||
view,
|
||||
newAnnouncements,
|
||||
legalCandidates: candidates,
|
||||
attemptHistory,
|
||||
drawOfferFromOpponent: !!(this.game.drawOffer && this.game.drawOffer.from !== this.color),
|
||||
ply: this.game.chess.history().length,
|
||||
// Vanilla mode: full reveal, FEN exposes nothing the brain can't already
|
||||
// see. Blind mode: omit FEN so the engine path can't smuggle opponent
|
||||
// positions past the view filter.
|
||||
fen: this.game.mode === 'vanilla' ? this.game.chess.fen() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private dispatch(
|
||||
action: BrainAction,
|
||||
): { kind: 'done' } | { kind: 'retry'; entry: AttemptHistoryEntry } {
|
||||
switch (action.type) {
|
||||
case 'commit': {
|
||||
const result = handleCommit(this.game, this.color, {
|
||||
from: action.from, to: action.to, promotion: action.promotion,
|
||||
});
|
||||
if (result.kind === 'applied') {
|
||||
finalizeIfEnded(this.game, result.announcements);
|
||||
return { kind: 'done' };
|
||||
}
|
||||
if (result.kind === 'announce') {
|
||||
const rejection = result.announcements[0]!;
|
||||
const text = rejection.text;
|
||||
if (text === 'wont_help' || text === 'illegal_move'
|
||||
|| text === 'no_such_piece' || text === 'no_legal_moves') {
|
||||
// Attempted-move announcements are audience 'both'. The bot's
|
||||
// intermediate retry rejections are internal search churn, not
|
||||
// deliberate probing — suppress them so they don't broadcast to
|
||||
// the human. The bot tracks its own rejections via attemptHistory,
|
||||
// so removing the announcement is safe. The whole decision cycle
|
||||
// runs before ws.ts broadcasts, so this pop always happens before
|
||||
// any broadcast.
|
||||
const anns = this.game.announcements;
|
||||
if (anns[anns.length - 1] === rejection) anns.pop();
|
||||
return {
|
||||
kind: 'retry',
|
||||
entry: {
|
||||
move: { from: action.from, to: action.to, promotion: action.promotion },
|
||||
rejection: text,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
if (result.kind === 'silent') {
|
||||
// Brain sent only `from` (arming). CasualBrain always commits with
|
||||
// `to`; treat as a logic error and resign safely.
|
||||
this.botResign('commit_silent', { from: action.from, to: action.to });
|
||||
return { kind: 'done' };
|
||||
}
|
||||
// result.kind === 'error' — bug path; resign.
|
||||
this.botResign('commit_error', {
|
||||
code: result.kind === 'error' ? result.code : undefined,
|
||||
announcement: result.kind === 'announce' ? result.announcements[0]?.text : undefined,
|
||||
});
|
||||
return { kind: 'done' };
|
||||
}
|
||||
case 'resign':
|
||||
this.botResign('brain_chose_resign');
|
||||
return { kind: 'done' };
|
||||
case 'offer-draw':
|
||||
if (!this.game.drawOffer) {
|
||||
this.game.drawOffer = { from: this.color, at: Date.now() };
|
||||
}
|
||||
return { kind: 'done' };
|
||||
case 'respond-draw':
|
||||
if (!this.game.drawOffer || this.game.drawOffer.from === this.color) {
|
||||
return { kind: 'done' };
|
||||
}
|
||||
if (action.accept) {
|
||||
const ply = this.game.chess.history().length;
|
||||
const a = announce('draw_agreed', 'both', ply);
|
||||
this.game.announcements.push(a);
|
||||
this.game.drawOffer = null;
|
||||
this.game.status = 'finished';
|
||||
this.game.endReason = 'draw_agreed';
|
||||
this.game.winner = null;
|
||||
this.game.finishedAt = Date.now();
|
||||
} else {
|
||||
this.game.drawOffer = null;
|
||||
}
|
||||
return { kind: 'done' };
|
||||
}
|
||||
}
|
||||
|
||||
private botResign(reason: BotResignReason, detail?: Record<string, unknown>): void {
|
||||
if (this.game.status !== 'active') return;
|
||||
const ply = this.game.chess.history().length;
|
||||
const text = this.color === 'w' ? 'white_resigned' : 'black_resigned';
|
||||
const a = announce(text, 'both', ply);
|
||||
this.game.announcements.push(a);
|
||||
this.game.status = 'finished';
|
||||
this.game.endReason = 'resign';
|
||||
this.game.winner = this.color === 'w' ? 'b' : 'w';
|
||||
this.game.finishedAt = Date.now();
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[bot resign]', {
|
||||
gameId: this.game.id,
|
||||
color: this.color,
|
||||
mode: this.game.mode,
|
||||
ply,
|
||||
reason,
|
||||
...detail,
|
||||
});
|
||||
}
|
||||
|
||||
private async disposeBrain(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
try {
|
||||
await this.brain.dispose?.();
|
||||
} catch {/* ignore */}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
Brain, BrainInput, BrainAction, BrainInitArgs,
|
||||
CandidateMove, AttemptHistoryEntry,
|
||||
} from './brain.js';
|
||||
export { CasualBrain } from './casual-brain.js';
|
||||
export { BotDriver } from './driver.js';
|
||||
export { legalCandidates } from './candidates.js';
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CaptureTally, Color, PieceTally } from '@blind-chess/shared';
|
||||
import type { Game } from './state.js';
|
||||
|
||||
/**
|
||||
* Per-viewer capture tally derived from move history. `byYou` is the set of
|
||||
* opponent pieces this viewer has captured; `byOpponent` is the set of this
|
||||
* viewer's pieces the opponent has captured. Pure function of move history —
|
||||
* no live board state, no opponent positions.
|
||||
*/
|
||||
export function captureTally(game: Game, viewer: Color): CaptureTally {
|
||||
const byYou: PieceTally = {};
|
||||
const byOpponent: PieceTally = {};
|
||||
for (const rec of game.moveHistory) {
|
||||
const captured = rec.capturedPieceType;
|
||||
if (!captured) continue;
|
||||
const bucket = rec.by === viewer ? byYou : byOpponent;
|
||||
bucket[captured] = (bucket[captured] ?? 0) + 1;
|
||||
}
|
||||
return { byYou, byOpponent };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Move } from 'chess.js';
|
||||
import {
|
||||
geometricMoves,
|
||||
isPromotionMove,
|
||||
type Announcement,
|
||||
type Color,
|
||||
type Piece,
|
||||
@@ -37,7 +38,7 @@ export function handleCommit(game: Game, color: Color, msg: CommitInput): Commit
|
||||
|
||||
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);
|
||||
return announceWith(game, 'no_such_piece');
|
||||
}
|
||||
|
||||
const pseudo = geometricMoves(
|
||||
@@ -46,12 +47,12 @@ export function handleCommit(game: Game, color: Color, msg: CommitInput): Commit
|
||||
ownSquares(game, color),
|
||||
);
|
||||
if (pseudo.length === 0) {
|
||||
return announceWith(game, 'no_legal_moves', color);
|
||||
return announceWith(game, 'no_legal_moves');
|
||||
}
|
||||
|
||||
const legal = chessJsLegalFrom(game, msg.from);
|
||||
if (legal.length === 0) {
|
||||
return announceWith(game, 'wont_help', color);
|
||||
return announceWith(game, 'wont_help');
|
||||
}
|
||||
|
||||
game.armed = { color, from: msg.from };
|
||||
@@ -77,7 +78,7 @@ function tryMove(
|
||||
}
|
||||
|
||||
if (!move) {
|
||||
return announceWith(game, 'illegal_move', color);
|
||||
return announceWith(game, 'illegal_move');
|
||||
}
|
||||
|
||||
game.armed = null;
|
||||
@@ -110,10 +111,10 @@ function tryMove(
|
||||
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);
|
||||
// Attempted moves are part of the shared moderator transcript — both players hear them.
|
||||
const a = announce(text, 'both', ply);
|
||||
game.announcements.push(a);
|
||||
return { kind: 'announce', announcements: [a] };
|
||||
}
|
||||
@@ -124,9 +125,6 @@ function chessJsLegalFrom(game: Game, from: Square): 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;
|
||||
if (!piece) return false;
|
||||
return isPromotionMove({ color: piece.color, type: piece.type }, from, to);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Color } from '@blind-chess/shared';
|
||||
import type { Game } from './state.js';
|
||||
|
||||
export function endGame(game: Game, reason: Game['endReason'], winner: Color | null): void {
|
||||
game.status = 'finished';
|
||||
game.endReason = reason;
|
||||
game.winner = winner;
|
||||
game.finishedAt = Date.now();
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
@@ -3,9 +3,23 @@ import { randomBytes } from 'node:crypto';
|
||||
import type {
|
||||
Color, GameId, Mode, PlayerToken,
|
||||
} from '@blind-chess/shared';
|
||||
import type { BotDriver } from './bot/driver.js';
|
||||
import { type Game, PRUNE_AFTER_FINISHED_MS, RATE_LIMIT } from './state.js';
|
||||
|
||||
const games = new Map<GameId, Game>();
|
||||
const botDrivers = new Map<GameId, BotDriver>();
|
||||
|
||||
export function attachBotDriver(id: GameId, driver: BotDriver): void {
|
||||
botDrivers.set(id, driver);
|
||||
}
|
||||
|
||||
export function getBotDriver(id: GameId): BotDriver | undefined {
|
||||
return botDrivers.get(id);
|
||||
}
|
||||
|
||||
export function disposeBotDriver(id: GameId): void {
|
||||
botDrivers.delete(id);
|
||||
}
|
||||
|
||||
export function newGameId(): GameId {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
@@ -31,11 +45,16 @@ export function createGame(opts: {
|
||||
mode: Mode;
|
||||
creatorSide: Color;
|
||||
highlightingEnabled: boolean;
|
||||
vsAi?: { brain: 'casual' | 'recon' };
|
||||
}): { game: Game; creatorToken: PlayerToken } {
|
||||
const id = newGameId();
|
||||
const creatorToken = newPlayerToken();
|
||||
const now = Date.now();
|
||||
|
||||
const botColor: Color | null = opts.vsAi
|
||||
? (opts.creatorSide === 'w' ? 'b' : 'w')
|
||||
: null;
|
||||
|
||||
const game: Game = {
|
||||
id,
|
||||
mode: opts.mode,
|
||||
@@ -46,12 +65,18 @@ export function createGame(opts: {
|
||||
moveHistory: [],
|
||||
announcements: [],
|
||||
players: {
|
||||
w: opts.creatorSide === 'w' ? makeSlot(creatorToken, now) : null,
|
||||
b: opts.creatorSide === 'b' ? makeSlot(creatorToken, now) : null,
|
||||
w: opts.creatorSide === 'w' ? makeSlot(creatorToken, now)
|
||||
: (botColor === 'w' ? makeBotSlot(now) : null),
|
||||
b: opts.creatorSide === 'b' ? makeSlot(creatorToken, now)
|
||||
: (botColor === 'b' ? makeBotSlot(now) : null),
|
||||
},
|
||||
armed: null,
|
||||
drawOffer: null,
|
||||
disconnectAt: {},
|
||||
lastBroadcastIdx: { w: 0, b: 0 },
|
||||
aiOpponent: opts.vsAi && botColor
|
||||
? { color: botColor, brain: opts.vsAi.brain }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
games.set(id, game);
|
||||
@@ -67,6 +92,18 @@ function makeSlot(token: PlayerToken, now: number) {
|
||||
};
|
||||
}
|
||||
|
||||
function makeBotSlot(now: number) {
|
||||
// Synthetic slot: occupies the player's color but never connects. The token
|
||||
// is randomized (same shape as a real client token) so a third party can't
|
||||
// hijack the bot's color by guessing a fixed placeholder.
|
||||
return {
|
||||
token: newPlayerToken(),
|
||||
socket: null,
|
||||
joinedAt: now,
|
||||
rateBucket: { tokens: RATE_LIMIT.capacity, last: now },
|
||||
};
|
||||
}
|
||||
|
||||
export function getGame(id: GameId): Game | undefined {
|
||||
return games.get(id);
|
||||
}
|
||||
@@ -115,6 +152,7 @@ export function pruneFinished(): number {
|
||||
for (const [id, g] of games) {
|
||||
if (g.status === 'finished' && g.finishedAt && now - g.finishedAt > PRUNE_AFTER_FINISHED_MS) {
|
||||
games.delete(id);
|
||||
botDrivers.delete(id);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
chooseSide,
|
||||
createGame,
|
||||
pruneFinished,
|
||||
attachBotDriver,
|
||||
} from './games.js';
|
||||
import { attachSocket } from './ws.js';
|
||||
import { createGameSchema } from './validation.js';
|
||||
import { CasualBrain, BotDriver } from './bot/index.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -45,13 +47,28 @@ fastify.post('/api/games', async (req, reply) => {
|
||||
reply.code(400);
|
||||
return { error: 'malformed', detail: parsed.error.issues };
|
||||
}
|
||||
const { mode, side, highlightingEnabled } = parsed.data;
|
||||
const { mode, side, highlightingEnabled, vsAi } = parsed.data;
|
||||
|
||||
// Phase 1: only 'casual' is implemented. 'recon' returns 503.
|
||||
if (vsAi && vsAi.brain === 'recon') {
|
||||
reply.code(503);
|
||||
return { error: 'ai_offline', detail: 'recon bot not yet implemented' };
|
||||
}
|
||||
|
||||
const creatorSide = chooseSide(side);
|
||||
const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled });
|
||||
const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled, vsAi });
|
||||
|
||||
// For AI games, wire the bot.
|
||||
if (vsAi && game.aiOpponent) {
|
||||
const brain = new CasualBrain({});
|
||||
const driver = new BotDriver({ game, brain, color: game.aiOpponent.color });
|
||||
await driver.init();
|
||||
attachBotDriver(game.id, driver);
|
||||
}
|
||||
|
||||
const publicBase = PUBLIC_BASE
|
||||
|| (req.headers.host ? `${req.protocol}://${req.headers.host}` : '');
|
||||
const joinUrl = `${publicBase}/g/${game.id}`;
|
||||
const joinUrl = vsAi ? null : `${publicBase}/g/${game.id}`;
|
||||
return { gameId: game.id, creatorToken, creatorColor: creatorSide, joinUrl };
|
||||
});
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface Game {
|
||||
armed: { color: Color; from: Square } | null;
|
||||
drawOffer: { from: Color; at: number } | null;
|
||||
disconnectAt: { w?: number; b?: number };
|
||||
lastBroadcastIdx: { w: number; b: number };
|
||||
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
|
||||
}
|
||||
|
||||
export const RATE_LIMIT = { capacity: 20, refillPerSec: 10 };
|
||||
|
||||
@@ -14,10 +14,11 @@ export function announce(
|
||||
/**
|
||||
* 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.
|
||||
* Every announcement is emitted with audience 'both' — the moderator speaks
|
||||
* each event aloud and both players hear it. Move events carry no square
|
||||
* coordinates, so a player learns *that* the opponent moved / captured /
|
||||
* castled, never *where*. The capturing player additionally sees the capture
|
||||
* reflected in their own view update.
|
||||
*/
|
||||
export function translateMove(game: Game, move: Move): Announcement[] {
|
||||
const out: Announcement[] = [];
|
||||
@@ -33,21 +34,21 @@ export function translateMove(game: Game, move: Move): Announcement[] {
|
||||
const isQueensideCastle = move.isQueensideCastle();
|
||||
const isProm = !!move.promotion;
|
||||
|
||||
// To opponent: the move event itself.
|
||||
// The move event itself — the moderator announces every move aloud, so both players hear it.
|
||||
if (isKingsideCastle) {
|
||||
out.push(announce(`${moverWord}_castled_kingside` as ModeratorText, opp, ply));
|
||||
out.push(announce(`${moverWord}_castled_kingside` as ModeratorText, 'both', ply));
|
||||
} else if (isQueensideCastle) {
|
||||
out.push(announce(`${moverWord}_castled_queenside` as ModeratorText, opp, ply));
|
||||
out.push(announce(`${moverWord}_castled_queenside` as ModeratorText, 'both', ply));
|
||||
} else if (isCap && isEp) {
|
||||
out.push(announce(`${moverWord}_moved_captured_ep` as ModeratorText, opp, ply));
|
||||
out.push(announce(`${moverWord}_moved_captured_ep` as ModeratorText, 'both', ply));
|
||||
} else if (isCap) {
|
||||
out.push(announce(`${moverWord}_moved_captured` as ModeratorText, opp, ply));
|
||||
out.push(announce(`${moverWord}_moved_captured` as ModeratorText, 'both', ply));
|
||||
} else {
|
||||
out.push(announce(`${moverWord}_moved` as ModeratorText, opp, ply));
|
||||
out.push(announce(`${moverWord}_moved` as ModeratorText, 'both', ply));
|
||||
}
|
||||
|
||||
if (isProm) {
|
||||
out.push(announce(`${moverWord}_promoted` as ModeratorText, opp, ply, { promotedTo: move.promotion }));
|
||||
out.push(announce(`${moverWord}_promoted` as ModeratorText, 'both', ply, { promotedTo: move.promotion }));
|
||||
}
|
||||
|
||||
// To both: state changes.
|
||||
|
||||
@@ -41,4 +41,7 @@ export const createGameSchema = z.object({
|
||||
mode: z.union([z.literal('blind'), z.literal('vanilla')]),
|
||||
side: z.union([colorSchema, z.literal('random')]),
|
||||
highlightingEnabled: z.boolean(),
|
||||
vsAi: z.object({
|
||||
brain: z.union([z.literal('casual'), z.literal('recon')]),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
+59
-55
@@ -10,6 +10,7 @@ import {
|
||||
claimSlot,
|
||||
findTokenInGame,
|
||||
getGame,
|
||||
getBotDriver,
|
||||
} from './games.js';
|
||||
import type { Game } from './state.js';
|
||||
import { GRACE_MS } from './state.js';
|
||||
@@ -17,6 +18,30 @@ import { handleCommit } from './commit.js';
|
||||
import { announce } from './translator.js';
|
||||
import { buildView } from './view.js';
|
||||
import { consumeCommitToken } from './ratelimit.js';
|
||||
import { endGame, finalizeIfEnded } from './game-end.js';
|
||||
import { captureTally } from './captures.js';
|
||||
|
||||
async function pokeBot(game: Game): Promise<void> {
|
||||
const driver = getBotDriver(game.id);
|
||||
if (!driver) return;
|
||||
try {
|
||||
await driver.onStateChange();
|
||||
} catch (err) {
|
||||
// Don't throw out of message handlers — log and continue.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[bot driver error]', { gameId: game.id, err });
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastSinceLast(game: Game): void {
|
||||
for (const c of ['w', 'b'] as const) {
|
||||
const lastIdx = game.lastBroadcastIdx[c];
|
||||
const all = game.announcements;
|
||||
const slice = all.slice(lastIdx).filter((a) => a.audience === 'both' || a.audience === c);
|
||||
sendUpdateTo(game, c, slice);
|
||||
game.lastBroadcastIdx[c] = all.length;
|
||||
}
|
||||
}
|
||||
|
||||
interface SocketCtx {
|
||||
socket: WebSocket;
|
||||
@@ -60,7 +85,7 @@ function onMessage(ctx: SocketCtx, data: unknown): void {
|
||||
}
|
||||
const msg = result.data as ClientMessage;
|
||||
|
||||
if (msg.type === 'hello') return onHello(ctx, msg);
|
||||
if (msg.type === 'hello') { void onHello(ctx, msg); return; }
|
||||
if (msg.type === 'pong') return;
|
||||
|
||||
if (!ctx.game || !ctx.color) {
|
||||
@@ -68,14 +93,14 @@ function onMessage(ctx: SocketCtx, data: unknown): void {
|
||||
}
|
||||
|
||||
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);
|
||||
case 'commit': void onCommit(ctx, msg); return;
|
||||
case 'resign': void onResign(ctx); return;
|
||||
case 'offer-draw': void onOfferDraw(ctx); return;
|
||||
case 'respond-draw': void onRespondDraw(ctx, msg.accept); return;
|
||||
}
|
||||
}
|
||||
|
||||
function onHello(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'hello' }>): void {
|
||||
async function onHello(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'hello' }>): Promise<void> {
|
||||
const game = getGame(msg.gameId);
|
||||
if (!game) return sendError(ctx.socket, 'game_not_found');
|
||||
|
||||
@@ -123,17 +148,20 @@ function onHello(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'hello' }>)
|
||||
mode: game.mode,
|
||||
highlightingEnabled: game.highlightingEnabled,
|
||||
opponentConnected: !!game.players[color === 'w' ? 'b' : 'w']?.socket,
|
||||
captures: captureTally(game, color),
|
||||
aiOpponent: game.aiOpponent,
|
||||
});
|
||||
|
||||
// Notify peer that we're connected.
|
||||
notifyPeer(game, color, true);
|
||||
// If activation just happened, push update to both.
|
||||
// If activation just happened, poke bot then broadcast.
|
||||
if (game.status === 'active') {
|
||||
broadcastUpdate(game);
|
||||
await pokeBot(game);
|
||||
broadcastSinceLast(game);
|
||||
}
|
||||
}
|
||||
|
||||
function onCommit(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'commit' }>): void {
|
||||
async function onCommit(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'commit' }>): Promise<void> {
|
||||
const game = ctx.game!;
|
||||
const color = ctx.color!;
|
||||
|
||||
@@ -153,17 +181,19 @@ function onCommit(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'commit' }
|
||||
return;
|
||||
case 'announce':
|
||||
// Announcement to actor; opponent is unaffected unless audience=both.
|
||||
broadcastNewAnnouncements(game, result.announcements);
|
||||
// Non-turn-ending — no bot poke.
|
||||
broadcastSinceLast(game);
|
||||
return;
|
||||
case 'applied':
|
||||
// Move applied. Check end conditions.
|
||||
// Move applied. Check end conditions, then poke bot.
|
||||
finalizeIfEnded(game, result.announcements);
|
||||
broadcastNewAnnouncements(game, result.announcements);
|
||||
await pokeBot(game);
|
||||
broadcastSinceLast(game);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function onResign(ctx: SocketCtx): void {
|
||||
async function onResign(ctx: SocketCtx): Promise<void> {
|
||||
const game = ctx.game!;
|
||||
const color = ctx.color!;
|
||||
if (game.status !== 'active') return;
|
||||
@@ -172,19 +202,22 @@ function onResign(ctx: SocketCtx): void {
|
||||
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]);
|
||||
await pokeBot(game); // bot.onStateChange will see status=finished and dispose
|
||||
broadcastSinceLast(game);
|
||||
}
|
||||
|
||||
function onOfferDraw(ctx: SocketCtx): void {
|
||||
async function onOfferDraw(ctx: SocketCtx): Promise<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);
|
||||
// Poke bot — it may auto-respond to the draw offer.
|
||||
await pokeBot(game);
|
||||
// broadcastSinceLast sends drawOffer field via sendUpdateTo's existing logic.
|
||||
broadcastSinceLast(game);
|
||||
}
|
||||
|
||||
function onRespondDraw(ctx: SocketCtx, accept: boolean): void {
|
||||
async function onRespondDraw(ctx: SocketCtx, accept: boolean): Promise<void> {
|
||||
const game = ctx.game!;
|
||||
const color = ctx.color!;
|
||||
if (!game.drawOffer || game.drawOffer.from === color) return;
|
||||
@@ -194,11 +227,11 @@ function onRespondDraw(ctx: SocketCtx, accept: boolean): void {
|
||||
game.announcements.push(a);
|
||||
game.drawOffer = null;
|
||||
endGame(game, 'draw_agreed', null);
|
||||
broadcastNewAnnouncements(game, [a]);
|
||||
} else {
|
||||
game.drawOffer = null;
|
||||
broadcastUpdate(game);
|
||||
}
|
||||
await pokeBot(game);
|
||||
broadcastSinceLast(game);
|
||||
}
|
||||
|
||||
function onClose(ctx: SocketCtx): void {
|
||||
@@ -211,13 +244,13 @@ function onClose(ctx: SocketCtx): void {
|
||||
if (game.status === 'active') {
|
||||
game.disconnectAt[color] = Date.now();
|
||||
// Schedule grace timer.
|
||||
setTimeout(() => maybeAbandon(game, color), GRACE_MS + 100);
|
||||
setTimeout(() => { void maybeAbandon(game, color); }, GRACE_MS + 100);
|
||||
}
|
||||
notifyPeer(game, color, false, Date.now() + GRACE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeAbandon(game: Game, color: Color): void {
|
||||
async function maybeAbandon(game: Game, color: Color): Promise<void> {
|
||||
if (game.status !== 'active') return;
|
||||
const slot = game.players[color];
|
||||
if (!slot) return;
|
||||
@@ -228,42 +261,11 @@ function maybeAbandon(game: Game, color: Color): void {
|
||||
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]);
|
||||
await pokeBot(game); // dispose bot if game ended
|
||||
broadcastSinceLast(game);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -284,6 +286,8 @@ function sendUpdateTo(
|
||||
drawOffer,
|
||||
endReason: game.endReason,
|
||||
winner: game.winner ?? null,
|
||||
captures: captureTally(game, color),
|
||||
aiOpponent: game.aiOpponent,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
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, attachBotDriver,
|
||||
} from '../../src/games.js';
|
||||
import { attachSocket } from '../../src/ws.js';
|
||||
import { createGameSchema } from '../../src/validation.js';
|
||||
import { CasualBrain, BotDriver } from '../../src/bot/index.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 { mode, side, highlightingEnabled, vsAi } = parsed.data;
|
||||
if (vsAi && vsAi.brain === 'recon') {
|
||||
reply.code(503); return { error: 'ai_offline' };
|
||||
}
|
||||
const creatorSide = chooseSide(side);
|
||||
const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled, vsAi });
|
||||
if (vsAi && game.aiOpponent) {
|
||||
const brain = new CasualBrain({ seed: 1 });
|
||||
const driver = new BotDriver({ game, brain, color: game.aiOpponent.color });
|
||||
await driver.init();
|
||||
attachBotDriver(game.id, driver);
|
||||
}
|
||||
const joinUrl = vsAi ? null : `http://placeholder/g/${game.id}`;
|
||||
return { gameId: game.id, creatorToken, creatorColor: creatorSide, joinUrl };
|
||||
});
|
||||
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 = 2000) => 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 createAiGame(side: 'w' | 'b', mode: 'blind' | 'vanilla' = 'vanilla'): Promise<{ gameId: string; creatorToken: string }> {
|
||||
const res = await fetch(`${baseUrl}/api/games`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ mode, side, highlightingEnabled: false, vsAi: { brain: 'casual' } }),
|
||||
});
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
describe('AI game / Casual', () => {
|
||||
it('human as black: bot moves first as white', async () => {
|
||||
const { gameId, creatorToken } = await createAiGame('b');
|
||||
const human = await makeClient(gameId);
|
||||
human.send({ type: 'hello', gameId, token: creatorToken });
|
||||
const joined = await human.waitFor((m) => m.type === 'joined');
|
||||
expect(joined.type === 'joined' && joined.you).toBe('b');
|
||||
if (joined.type === 'joined') {
|
||||
expect(joined.aiOpponent).toEqual({ color: 'w', brain: 'casual' });
|
||||
}
|
||||
|
||||
// Bot's opening move should arrive as an update (bot moves first as white).
|
||||
const botMoved = await human.waitFor((m) =>
|
||||
m.type === 'update' && m.newAnnouncements.some((a) => a.text === 'white_moved'),
|
||||
2000,
|
||||
);
|
||||
expect(botMoved.type).toBe('update');
|
||||
human.close();
|
||||
});
|
||||
|
||||
it('human as white: human moves first, bot replies', async () => {
|
||||
const { gameId, creatorToken } = await createAiGame('w');
|
||||
const human = await makeClient(gameId);
|
||||
human.send({ type: 'hello', gameId, token: creatorToken });
|
||||
await human.waitFor((m) => m.type === 'joined');
|
||||
|
||||
// Human plays e2e4 (arm + commit).
|
||||
human.send({ type: 'commit', from: 'e2' });
|
||||
await human.waitFor((m) => m.type === 'update' && m.touchedPiece === 'e2');
|
||||
human.send({ type: 'commit', from: 'e2', to: 'e4' });
|
||||
|
||||
// Bot replies as black.
|
||||
const botMoved = await human.waitFor((m) =>
|
||||
m.type === 'update' && m.newAnnouncements.some((a) => a.text === 'black_moved'),
|
||||
);
|
||||
expect(botMoved.type).toBe('update');
|
||||
|
||||
// After bot reply, it's white's turn again.
|
||||
if (botMoved.type === 'update') {
|
||||
expect(botMoved.view.toMove).toBe('w');
|
||||
}
|
||||
human.close();
|
||||
});
|
||||
|
||||
it('bot alternate exchanges: game doesn\'t end prematurely', async () => {
|
||||
// One human-bot exchange: human plays white e2-e4, bot replies as black.
|
||||
const { gameId, creatorToken } = await createAiGame('w');
|
||||
const human = await makeClient(gameId);
|
||||
human.send({ type: 'hello', gameId, token: creatorToken });
|
||||
const joined = await human.waitFor((m) => m.type === 'joined');
|
||||
expect(joined.type === 'joined' && joined.gameStatus).toBe('active');
|
||||
|
||||
human.send({ type: 'commit', from: 'e2' });
|
||||
await human.waitFor((m) => m.type === 'update' && m.touchedPiece === 'e2');
|
||||
human.send({ type: 'commit', from: 'e2', to: 'e4' });
|
||||
const botReplied = await human.waitFor((m) =>
|
||||
m.type === 'update' &&
|
||||
(m.gameStatus === 'finished' || m.view.toMove === 'w'),
|
||||
2000,
|
||||
);
|
||||
// Game should still be active after one exchange.
|
||||
expect(botReplied.type === 'update' && botReplied.gameStatus).toBe('active');
|
||||
human.close();
|
||||
});
|
||||
|
||||
it('joinUrl is null for AI games', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/games`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'blind', side: 'w', highlightingEnabled: false, vsAi: { brain: 'casual' } }),
|
||||
});
|
||||
const json = await res.json() as { joinUrl: string | null };
|
||||
expect(json.joinUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('recon brain returns 503 in Phase 1', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/games`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'blind', side: 'w', highlightingEnabled: false, vsAi: { brain: 'recon' } }),
|
||||
});
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ beforeAll(async () => {
|
||||
mode: parsed.data.mode,
|
||||
creatorSide,
|
||||
highlightingEnabled: parsed.data.highlightingEnabled,
|
||||
vsAi: parsed.data.vsAi,
|
||||
});
|
||||
return { gameId: game.id, creatorToken, creatorColor: creatorSide };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Chess } from 'chess.js';
|
||||
import { legalCandidates } from '../../../src/bot/candidates.js';
|
||||
import type { Game } from '../../../src/state.js';
|
||||
import { RATE_LIMIT } from '../../../src/state.js';
|
||||
|
||||
function makeGame(mode: 'blind' | 'vanilla', fen?: string): Game {
|
||||
return {
|
||||
id: 'cand0001',
|
||||
mode,
|
||||
highlightingEnabled: false,
|
||||
status: 'active',
|
||||
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: {},
|
||||
lastBroadcastIdx: { w: 0, b: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
describe('legalCandidates / vanilla', () => {
|
||||
it('starting position: 20 candidates for white', () => {
|
||||
const game = makeGame('vanilla');
|
||||
const candidates = legalCandidates(game, 'w');
|
||||
expect(candidates.length).toBe(20);
|
||||
});
|
||||
|
||||
it('returns from/to on each candidate', () => {
|
||||
const game = makeGame('vanilla');
|
||||
const candidates = legalCandidates(game, 'w');
|
||||
expect(candidates.every((c) => c.from && c.to)).toBe(true);
|
||||
});
|
||||
|
||||
it('vanilla excludes pinned-piece moves (chess.js filters self-check)', () => {
|
||||
// White king e1, white bishop e2, black rook e8. Bishop is pinned.
|
||||
const game = makeGame('vanilla', '4r2k/8/8/8/8/8/4B3/4K3 w - - 0 1');
|
||||
const candidates = legalCandidates(game, 'w');
|
||||
// Bishop on e2 has zero legal moves (any move drops the king to check).
|
||||
expect(candidates.find((c) => c.from === 'e2')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('vanilla expands all 4 promotion options', () => {
|
||||
// White pawn on a7, ready to promote.
|
||||
const game = makeGame('vanilla', '4k3/P7/8/8/8/8/8/4K3 w - - 0 1');
|
||||
const candidates = legalCandidates(game, 'w').filter((c) => c.from === 'a7');
|
||||
expect(candidates.map((c) => c.promotion).sort()).toEqual(['b', 'n', 'q', 'r']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legalCandidates / blind', () => {
|
||||
it('starting position: 34 geometric candidates for white', () => {
|
||||
// 8 pawns: edge pawns (a2, h2) return 3 moves each (forward 1, forward 2, 1 diagonal),
|
||||
// interior pawns (b2-g2) return 4 moves each (forward 1, forward 2, 2 diagonals).
|
||||
// Total pawn: 2*3 + 6*4 = 30.
|
||||
// 2 knights: 2 moves each = 4.
|
||||
// Total: 34.
|
||||
const game = makeGame('blind');
|
||||
const candidates = legalCandidates(game, 'w');
|
||||
expect(candidates.length).toBe(34);
|
||||
});
|
||||
|
||||
it('blind INCLUDES pinned-piece moves (geometric does not know about pins)', () => {
|
||||
// Same pinned-bishop position. Geometric move-gen sees no own piece blocking;
|
||||
// bishop can geometrically reach d3, c4, b5, a6, f3, etc.
|
||||
const game = makeGame('blind', '4r2k/8/8/8/8/8/4B3/4K3 w - - 0 1');
|
||||
const candidates = legalCandidates(game, 'w');
|
||||
expect(candidates.some((c) => c.from === 'e2')).toBe(true);
|
||||
});
|
||||
|
||||
it('blind expands all 4 promotion options for own pawn', () => {
|
||||
const game = makeGame('blind', '4k3/P7/8/8/8/8/8/4K3 w - - 0 1');
|
||||
const candidates = legalCandidates(game, 'w').filter((c) => c.from === 'a7' && c.to === 'a8');
|
||||
expect(candidates.map((c) => c.promotion).sort()).toEqual(['b', 'n', 'q', 'r']);
|
||||
});
|
||||
|
||||
it('blind ignores whose turn it is (returns moves for either color)', () => {
|
||||
// Vanilla path filters by chess.js .moves() which respects toMove. Blind
|
||||
// path iterates own pieces directly, so black candidates exist on move 0.
|
||||
const game = makeGame('blind');
|
||||
const candidates = legalCandidates(game, 'b');
|
||||
// Same geometric move count as white: 34.
|
||||
expect(candidates.length).toBe(34);
|
||||
});
|
||||
|
||||
it('zero own pieces = zero candidates (degenerate)', () => {
|
||||
// FEN with only black king + pieces — but FEN must be valid, kings required.
|
||||
const game = makeGame('blind', '4k3/8/8/8/8/8/8/4K3 w - - 0 1');
|
||||
const black = legalCandidates(game, 'b');
|
||||
// Black king on e8 has 5 geometric king moves (d8, f8, d7, e7, f7).
|
||||
expect(black.length).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CasualBrain } from '../../../src/bot/casual-brain.js';
|
||||
import type { BrainInput, CandidateMove } from '../../../src/bot/brain.js';
|
||||
import type { BoardView } from '@blind-chess/shared';
|
||||
|
||||
function makeInput(overrides: Partial<BrainInput> = {}): BrainInput {
|
||||
const view: BoardView = {
|
||||
pieces: { e2: { color: 'w', type: 'p' } },
|
||||
toMove: 'w',
|
||||
inCheck: false,
|
||||
};
|
||||
return {
|
||||
view,
|
||||
newAnnouncements: [],
|
||||
legalCandidates: [{ from: 'e2', to: 'e4' }],
|
||||
attemptHistory: [],
|
||||
drawOfferFromOpponent: false,
|
||||
ply: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CasualBrain', () => {
|
||||
it('init() resolves', async () => {
|
||||
const brain = new CasualBrain({ seed: 1 });
|
||||
await brain.init({ color: 'w', mode: 'blind', gameId: 'casualab' });
|
||||
});
|
||||
|
||||
it('single candidate -> picks it', async () => {
|
||||
const brain = new CasualBrain({ seed: 1 });
|
||||
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
const action = await brain.decide(makeInput());
|
||||
expect(action.type).toBe('commit');
|
||||
if (action.type === 'commit') {
|
||||
expect(action.from).toBe('e2');
|
||||
expect(action.to).toBe('e4');
|
||||
}
|
||||
});
|
||||
|
||||
it('zero candidates -> throws', async () => {
|
||||
const brain = new CasualBrain({ seed: 1 });
|
||||
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
await expect(brain.decide(makeInput({ legalCandidates: [] }))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('attemptHistory excludes the rejected move', async () => {
|
||||
const brain = new CasualBrain({ seed: 1 });
|
||||
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
const input = makeInput({
|
||||
legalCandidates: [
|
||||
{ from: 'e2', to: 'e4' },
|
||||
{ from: 'd2', to: 'd4' },
|
||||
],
|
||||
attemptHistory: [{ move: { from: 'e2', to: 'e4' }, rejection: 'wont_help' }],
|
||||
});
|
||||
const action = await brain.decide(input);
|
||||
expect(action.type).toBe('commit');
|
||||
if (action.type === 'commit') {
|
||||
expect(action.from).toBe('d2');
|
||||
expect(action.to).toBe('d4');
|
||||
}
|
||||
});
|
||||
|
||||
it('promotion: when multiple candidates differ only by promotion, picks queen', async () => {
|
||||
const brain = new CasualBrain({ seed: 1 });
|
||||
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
const candidates: CandidateMove[] = [
|
||||
{ from: 'a7', to: 'a8', promotion: 'q' },
|
||||
{ from: 'a7', to: 'a8', promotion: 'r' },
|
||||
{ from: 'a7', to: 'a8', promotion: 'b' },
|
||||
{ from: 'a7', to: 'a8', promotion: 'n' },
|
||||
];
|
||||
const action = await brain.decide(makeInput({ legalCandidates: candidates }));
|
||||
if (action.type === 'commit') expect(action.promotion).toBe('q');
|
||||
});
|
||||
|
||||
it('draw offer at material parity -> accept', async () => {
|
||||
const brain = new CasualBrain({ seed: 1 });
|
||||
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
// White has 1 king + 1 rook = 5 material; Casual heuristic accepts when own material < 15.
|
||||
const view: BoardView = {
|
||||
pieces: {
|
||||
e1: { color: 'w', type: 'k' },
|
||||
a1: { color: 'w', type: 'r' },
|
||||
},
|
||||
toMove: 'w', inCheck: false,
|
||||
};
|
||||
const action = await brain.decide({
|
||||
view, newAnnouncements: [], legalCandidates: [{ from: 'e1', to: 'e2' }],
|
||||
attemptHistory: [], drawOfferFromOpponent: true, ply: 30,
|
||||
});
|
||||
expect(action.type).toBe('respond-draw');
|
||||
if (action.type === 'respond-draw') expect(action.accept).toBe(true);
|
||||
});
|
||||
|
||||
it('never voluntarily offers resign', async () => {
|
||||
const brain = new CasualBrain({ seed: 1 });
|
||||
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const action = await brain.decide(makeInput({ ply: i }));
|
||||
expect(action.type).not.toBe('resign');
|
||||
}
|
||||
});
|
||||
|
||||
it('seeded determinism: same seed + same input -> same move', async () => {
|
||||
const candidates: CandidateMove[] = [
|
||||
{ from: 'e2', to: 'e4' },
|
||||
{ from: 'd2', to: 'd4' },
|
||||
{ from: 'g1', to: 'f3' },
|
||||
];
|
||||
const a = new CasualBrain({ seed: 42 });
|
||||
await a.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
const b = new CasualBrain({ seed: 42 });
|
||||
await b.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
const aAct = await a.decide(makeInput({ legalCandidates: candidates }));
|
||||
const bAct = await b.decide(makeInput({ legalCandidates: candidates }));
|
||||
expect(aAct).toEqual(bAct);
|
||||
});
|
||||
|
||||
it('blind mode + own_color_in_check announcement -> prefers king moves over other candidates', async () => {
|
||||
// The bot only sees its own pieces in blind mode and cannot deduce the
|
||||
// attacker. Per the AI spec ("Casual never resigns voluntarily"), the
|
||||
// brain must use the public moderator announcement to bias toward
|
||||
// check-resolving moves — most commonly, moving the king. Without this
|
||||
// bias, the heuristic scores capture/advance signals that are uncorrelated
|
||||
// with check resolution, the FSM rejects every non-resolving move, and
|
||||
// the driver's retry cap fires => premature resignation.
|
||||
const view: BoardView = {
|
||||
pieces: {
|
||||
e1: { color: 'w', type: 'k' },
|
||||
a2: { color: 'w', type: 'p' },
|
||||
h2: { color: 'w', type: 'p' },
|
||||
b1: { color: 'w', type: 'n' },
|
||||
},
|
||||
toMove: 'w',
|
||||
inCheck: true,
|
||||
};
|
||||
const candidates: CandidateMove[] = [
|
||||
// king moves (8 possible escape squares; only some are off the board /
|
||||
// off own-occupied — geometricMoves would have excluded those, but for
|
||||
// the test we just enumerate a few plausible ones).
|
||||
{ from: 'e1', to: 'd1' },
|
||||
{ from: 'e1', to: 'f1' },
|
||||
{ from: 'e1', to: 'd2' },
|
||||
{ from: 'e1', to: 'e2' },
|
||||
{ from: 'e1', to: 'f2' },
|
||||
// non-king alternatives that the heuristic would otherwise prefer
|
||||
{ from: 'a2', to: 'a4' },
|
||||
{ from: 'h2', to: 'h4' },
|
||||
{ from: 'b1', to: 'c3' },
|
||||
{ from: 'b1', to: 'a3' },
|
||||
];
|
||||
let kingHits = 0;
|
||||
for (let s = 0; s < 20; s++) {
|
||||
const brain = new CasualBrain({ seed: s });
|
||||
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
const action = await brain.decide({
|
||||
view,
|
||||
newAnnouncements: [
|
||||
{ text: 'white_in_check', audience: 'both', ply: 10, at: Date.now() },
|
||||
],
|
||||
legalCandidates: candidates,
|
||||
attemptHistory: [],
|
||||
drawOfferFromOpponent: false,
|
||||
ply: 10,
|
||||
});
|
||||
if (action.type === 'commit' && action.from === 'e1') kingHits++;
|
||||
}
|
||||
// Every seed should pick a king move when the boost is large enough to
|
||||
// dominate the heuristic + tiebreak.
|
||||
expect(kingHits).toBe(20);
|
||||
});
|
||||
|
||||
it('blind mode + own_color_in_check + king moves all rejected -> falls through to non-king', async () => {
|
||||
// Defensive: if every king move has been tried (knight check forcing
|
||||
// king moves into other attacks, double check, etc.), the bot should
|
||||
// still pick *something* from remaining candidates rather than throw.
|
||||
const view: BoardView = {
|
||||
pieces: {
|
||||
e1: { color: 'w', type: 'k' },
|
||||
b1: { color: 'w', type: 'n' },
|
||||
},
|
||||
toMove: 'w',
|
||||
inCheck: true,
|
||||
};
|
||||
const candidates: CandidateMove[] = [
|
||||
{ from: 'e1', to: 'd1' },
|
||||
{ from: 'e1', to: 'e2' },
|
||||
{ from: 'b1', to: 'c3' },
|
||||
];
|
||||
const brain = new CasualBrain({ seed: 1 });
|
||||
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
const action = await brain.decide({
|
||||
view,
|
||||
newAnnouncements: [
|
||||
{ text: 'white_in_check', audience: 'both', ply: 10, at: Date.now() },
|
||||
],
|
||||
legalCandidates: candidates,
|
||||
attemptHistory: [
|
||||
{ move: { from: 'e1', to: 'd1' }, rejection: 'illegal_move' },
|
||||
{ move: { from: 'e1', to: 'e2' }, rejection: 'illegal_move' },
|
||||
],
|
||||
drawOfferFromOpponent: false,
|
||||
ply: 10,
|
||||
});
|
||||
expect(action.type).toBe('commit');
|
||||
if (action.type === 'commit') {
|
||||
expect(action.from).toBe('b1');
|
||||
expect(action.to).toBe('c3');
|
||||
}
|
||||
});
|
||||
|
||||
it('opening moves favor center pawns over flank pawns (e/d > a/h)', async () => {
|
||||
const candidates: CandidateMove[] = [
|
||||
{ from: 'a2', to: 'a3' },
|
||||
{ from: 'h2', to: 'h3' },
|
||||
{ from: 'e2', to: 'e4' },
|
||||
{ from: 'd2', to: 'd4' },
|
||||
];
|
||||
// Many seeds → assert e2 or d2 wins majority. The score gap (center pawn
|
||||
// scores ~25 + 15*2 = 55 over flank pawn ~15*1 = 15) is well over the
|
||||
// 0.01 random tiebreak, so center should win nearly always.
|
||||
let centerHits = 0;
|
||||
for (let s = 0; s < 20; s++) {
|
||||
const b = new CasualBrain({ seed: s });
|
||||
await b.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
const a = await b.decide(makeInput({ legalCandidates: candidates, ply: 0 }));
|
||||
if (a.type === 'commit' && (a.from === 'e2' || a.from === 'd2')) centerHits++;
|
||||
}
|
||||
expect(centerHits).toBeGreaterThan(15);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { Chess } from 'chess.js';
|
||||
import { BotDriver } from '../../../src/bot/driver.js';
|
||||
import type { Brain, BrainAction, BrainInput } from '../../../src/bot/brain.js';
|
||||
import type { Game } from '../../../src/state.js';
|
||||
import { RATE_LIMIT } from '../../../src/state.js';
|
||||
|
||||
function makeGame(opts: { mode?: 'blind' | 'vanilla'; fen?: string; status?: Game['status'] } = {}): Game {
|
||||
return {
|
||||
id: 'gabcd123',
|
||||
mode: opts.mode ?? 'blind',
|
||||
highlightingEnabled: false,
|
||||
status: opts.status ?? 'active',
|
||||
createdAt: Date.now(),
|
||||
chess: opts.fen ? new Chess(opts.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: {},
|
||||
lastBroadcastIdx: { w: 0, b: 0 },
|
||||
aiOpponent: { color: 'b', brain: 'casual' },
|
||||
};
|
||||
}
|
||||
|
||||
class StubBrain implements Brain {
|
||||
public decideCalls = 0;
|
||||
private script: BrainAction[] = [];
|
||||
init = vi.fn(async () => {});
|
||||
dispose = vi.fn(async () => {});
|
||||
decide = vi.fn(async (input: BrainInput): Promise<BrainAction> => {
|
||||
this.decideCalls++;
|
||||
if (this.script.length === 0) {
|
||||
// Default: trivial commit on the first legal candidate.
|
||||
if (input.legalCandidates.length === 0) throw new Error('no candidates');
|
||||
const c = input.legalCandidates[0]!;
|
||||
return { type: 'commit', from: c.from, to: c.to, promotion: c.promotion };
|
||||
}
|
||||
return this.script.shift()!;
|
||||
});
|
||||
|
||||
enqueue(...actions: BrainAction[]) { this.script.push(...actions); }
|
||||
}
|
||||
|
||||
describe('BotDriver', () => {
|
||||
let game: Game;
|
||||
let brain: StubBrain;
|
||||
let driver: BotDriver;
|
||||
|
||||
beforeEach(async () => {
|
||||
game = makeGame();
|
||||
brain = new StubBrain();
|
||||
driver = new BotDriver({ game, brain, color: 'b' });
|
||||
await driver.init();
|
||||
});
|
||||
|
||||
it('init() invokes brain.init with correct args', async () => {
|
||||
expect(brain.init).toHaveBeenCalledWith({
|
||||
color: 'b',
|
||||
mode: 'blind',
|
||||
gameId: 'gabcd123',
|
||||
});
|
||||
});
|
||||
|
||||
it('onStateChange does nothing when not bot turn', async () => {
|
||||
// White to move (start). Bot is black.
|
||||
await driver.onStateChange();
|
||||
expect(brain.decide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onStateChange fires decide when it is bot turn', async () => {
|
||||
game.chess.move('e4');
|
||||
await driver.onStateChange();
|
||||
expect(brain.decide).toHaveBeenCalledTimes(1);
|
||||
expect(game.chess.turn()).toBe('w'); // turn advanced
|
||||
});
|
||||
|
||||
it('mutex: second onStateChange while in-flight is a no-op', async () => {
|
||||
game.chess.move('e4');
|
||||
let release: () => void;
|
||||
const gate = new Promise<void>((r) => { release = r; });
|
||||
brain.decide.mockImplementationOnce(async (input) => {
|
||||
await gate;
|
||||
const c = input.legalCandidates[0]!;
|
||||
return { type: 'commit', from: c.from, to: c.to };
|
||||
});
|
||||
const p1 = driver.onStateChange();
|
||||
const p2 = driver.onStateChange();
|
||||
release!();
|
||||
await Promise.all([p1, p2]);
|
||||
expect(brain.decide).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retry on wont_help: pinned bishop scenario', async () => {
|
||||
// Black king h8, black bishop e7 pinned by white rook on e1.
|
||||
const fen = '4k2K/4b3/8/8/8/8/8/4R3 b - - 0 1';
|
||||
game = makeGame({ fen });
|
||||
brain = new StubBrain();
|
||||
driver = new BotDriver({ game, brain, color: 'b' });
|
||||
await driver.init();
|
||||
// First action: pinned-bishop move (FSM rejects with wont_help)
|
||||
// Second action: legal king move (black king at e8, not h8 which is white)
|
||||
brain.enqueue(
|
||||
{ type: 'commit', from: 'e7', to: 'd6' },
|
||||
{ type: 'commit', from: 'e8', to: 'f8' },
|
||||
);
|
||||
await driver.onStateChange();
|
||||
expect(brain.decide).toHaveBeenCalledTimes(2);
|
||||
expect(game.chess.turn()).toBe('w');
|
||||
});
|
||||
|
||||
it('retry on illegal_move: switching from after rejection still succeeds', async () => {
|
||||
// Black bishop b8 has legal moves (c7, d6, e5xpawn, a7) but b8→f4 is
|
||||
// illegal because the white pawn on e5 blocks the diagonal. Black king on a1.
|
||||
// The FSM ARMS the b8 piece during its first attempt then rejects with
|
||||
// illegal_move; on the retry the brain switches to a king move, which used
|
||||
// to trip "must_move_touched_piece" and resign the bot.
|
||||
const fen = '1b6/8/8/4P3/8/8/8/k6K b - - 0 1';
|
||||
game = makeGame({ mode: 'blind', fen });
|
||||
brain = new StubBrain();
|
||||
driver = new BotDriver({ game, brain, color: 'b' });
|
||||
await driver.init();
|
||||
brain.enqueue(
|
||||
{ type: 'commit', from: 'b8', to: 'f4' }, // illegal: blocked by white pawn
|
||||
{ type: 'commit', from: 'a1', to: 'b1' }, // legal king move
|
||||
);
|
||||
await driver.onStateChange();
|
||||
expect(brain.decide).toHaveBeenCalledTimes(2);
|
||||
expect(game.status).toBe('active');
|
||||
expect(game.chess.turn()).toBe('w');
|
||||
expect(game.armed).toBeNull();
|
||||
});
|
||||
|
||||
it('retry cap (25): after RETRY_CAP rejected attempts, driver resigns the bot', async () => {
|
||||
const fen = '4k2K/4b3/8/8/8/8/8/4R3 b - - 0 1';
|
||||
game = makeGame({ fen });
|
||||
brain = new StubBrain();
|
||||
driver = new BotDriver({ game, brain, color: 'b' });
|
||||
await driver.init();
|
||||
// Enqueue more than RETRY_CAP repeated illegal moves; driver should
|
||||
// exhaust the retry budget and resign.
|
||||
for (let i = 0; i < 30; i++) {
|
||||
brain.enqueue({ type: 'commit', from: 'e7', to: 'd6' });
|
||||
}
|
||||
await driver.onStateChange();
|
||||
expect(game.status).toBe('finished');
|
||||
expect(game.endReason).toBe('resign');
|
||||
expect(game.winner).toBe('w');
|
||||
expect(brain.decide).toHaveBeenCalledTimes(25);
|
||||
});
|
||||
|
||||
it('respond-draw: when drawOffer is from opponent, driver fires decide and dispatches', async () => {
|
||||
game.drawOffer = { from: 'w', at: Date.now() };
|
||||
brain.enqueue({ type: 'respond-draw', accept: true });
|
||||
await driver.onStateChange();
|
||||
expect(brain.decide).toHaveBeenCalledTimes(1);
|
||||
expect(game.status).toBe('finished');
|
||||
expect(game.endReason).toBe('draw_agreed');
|
||||
});
|
||||
|
||||
it('dispose on game finished: subsequent onStateChange is a no-op', async () => {
|
||||
game.chess.move('e4');
|
||||
game.status = 'finished';
|
||||
await driver.onStateChange();
|
||||
expect(brain.decide).not.toHaveBeenCalled();
|
||||
expect(brain.dispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('suppresses the bot intermediate retry rejection from the moderator log', async () => {
|
||||
// Pinned-bishop position: first action is rejected (wont_help), second
|
||||
// is a legal king move. The wont_help must NOT survive in announcements.
|
||||
const fen = '4k2K/4b3/8/8/8/8/8/4R3 b - - 0 1';
|
||||
game = makeGame({ fen });
|
||||
brain = new StubBrain();
|
||||
driver = new BotDriver({ game, brain, color: 'b' });
|
||||
await driver.init();
|
||||
brain.enqueue(
|
||||
{ type: 'commit', from: 'e7', to: 'd6' }, // rejected: wont_help
|
||||
{ type: 'commit', from: 'e8', to: 'f8' }, // legal king move
|
||||
);
|
||||
await driver.onStateChange();
|
||||
const texts = game.announcements.map((a) => a.text);
|
||||
expect(texts).not.toContain('wont_help');
|
||||
expect(texts).toContain('black_moved');
|
||||
});
|
||||
|
||||
it('bot move that delivers checkmate finalizes game.status', async () => {
|
||||
// FEN: '1k6/8/1K6/8/8/8/8/7Q w - - 0 1'
|
||||
// White king b6, white queen h1, black king b8.
|
||||
// Qh8# is mate: queen moves h1→h8, covers h8; white king b6 covers a7,b7,c7,a5,b5,c5.
|
||||
// Black king b8 escape squares (a7,b7,c7,a8,c8) are all covered. Verified with chess.js.
|
||||
const fen = '1k6/8/1K6/8/8/8/8/7Q w - - 0 1';
|
||||
game = makeGame({ fen });
|
||||
game.aiOpponent = { color: 'w', brain: 'casual' };
|
||||
brain = new StubBrain();
|
||||
driver = new BotDriver({ game, brain, color: 'w' });
|
||||
await driver.init();
|
||||
|
||||
brain.enqueue({ type: 'commit', from: 'h1', to: 'h8' });
|
||||
await driver.onStateChange();
|
||||
|
||||
expect(game.status).toBe('finished');
|
||||
expect(game.endReason).toBe('checkmate');
|
||||
expect(game.winner).toBe('w');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { captureTally } from '../../src/captures.js';
|
||||
import type { Game, MoveRecord } from '../../src/state.js';
|
||||
import type { Color, PieceType } from '@blind-chess/shared';
|
||||
|
||||
function rec(by: Color, capturedPieceType?: PieceType): MoveRecord {
|
||||
return {
|
||||
ply: 1, by, from: 'e2', to: 'e4', san: 'e4',
|
||||
capturedPieceType, flags: {}, at: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe('captureTally', () => {
|
||||
it('counts captures per viewer', () => {
|
||||
const moveHistory: MoveRecord[] = [
|
||||
rec('w', 'p'), rec('b'), rec('w', 'n'),
|
||||
rec('b', 'p'), rec('w', 'p'),
|
||||
];
|
||||
const game = { moveHistory } as unknown as Game;
|
||||
expect(captureTally(game, 'w')).toEqual({
|
||||
byYou: { p: 2, n: 1 }, byOpponent: { p: 1 },
|
||||
});
|
||||
expect(captureTally(game, 'b')).toEqual({
|
||||
byYou: { p: 1 }, byOpponent: { p: 2, n: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty tallies when there are no captures', () => {
|
||||
const game = { moveHistory: [rec('w'), rec('b')] } as unknown as Game;
|
||||
expect(captureTally(game, 'w')).toEqual({ byYou: {}, byOpponent: {} });
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ function makeGame(fen?: string): Game {
|
||||
armed: null,
|
||||
drawOffer: null,
|
||||
disconnectAt: {},
|
||||
lastBroadcastIdx: { w: 0, b: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,6 +74,20 @@ describe('hierarchy decision table', () => {
|
||||
expect(game.armed).toBeNull();
|
||||
expect(game.chess.history()).toContain('e4');
|
||||
});
|
||||
|
||||
it('attempted-move announcements are audience: both', () => {
|
||||
const r = handleCommit(game, 'w', { from: 'e4' }); // empty square -> no_such_piece
|
||||
expect(r.kind).toBe('announce');
|
||||
if (r.kind === 'announce') expect(r.announcements[0]!.audience).toBe('both');
|
||||
});
|
||||
|
||||
it('applied-move announcements are audience: both', () => {
|
||||
const r = handleCommit(game, 'w', { from: 'e2', to: 'e4' });
|
||||
expect(r.kind).toBe('applied');
|
||||
if (r.kind === 'applied') {
|
||||
for (const a of r.announcements) expect(a.audience).toBe('both');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('touch-move enforcement', () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeGame(mode: 'blind' | 'vanilla', fen?: string, status: 'active' | 'f
|
||||
armed: null,
|
||||
drawOffer: null,
|
||||
disconnectAt: {},
|
||||
lastBroadcastIdx: { w: 0, b: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,3 +2,5 @@ export * from './types.js';
|
||||
export * from './moderator.js';
|
||||
export * from './protocol.js';
|
||||
export * from './geometric.js';
|
||||
export * from './phantoms.js';
|
||||
export * from './promotion.js';
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { FILES, isSquare, type Color, type Piece, type PieceType, type Square } from './types.js';
|
||||
|
||||
const BACK_RANK: PieceType[] = ['r', 'n', 'b', 'q', 'k', 'b', 'n', 'r'];
|
||||
const COLORS: Color[] = ['w', 'b'];
|
||||
const TYPES: PieceType[] = ['p', 'n', 'b', 'r', 'q', 'k'];
|
||||
|
||||
/**
|
||||
* The standard starting position for one colour, as a square→piece map.
|
||||
* Used to seed the phantom opponent-model layer with the opponent's army.
|
||||
*/
|
||||
export function opponentStartPosition(opponentColor: Color): Partial<Record<Square, Piece>> {
|
||||
const backRank = opponentColor === 'w' ? '1' : '8';
|
||||
const pawnRank = opponentColor === 'w' ? '2' : '7';
|
||||
const out: Partial<Record<Square, Piece>> = {};
|
||||
FILES.forEach((file, i) => {
|
||||
out[`${file}${backRank}` as Square] = { color: opponentColor, type: BACK_RANK[i]! };
|
||||
out[`${file}${pawnRank}` as Square] = { color: opponentColor, type: 'p' };
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a persisted phantom map (from localStorage). Tolerant: returns {} on
|
||||
* any structural failure and silently drops individual invalid entries.
|
||||
*/
|
||||
export function deserializePhantoms(raw: string | null): Partial<Record<Square, Piece>> {
|
||||
if (!raw) return {};
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(raw); } catch { return {}; }
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {};
|
||||
const out: Partial<Record<Square, Piece>> = {};
|
||||
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (!isSquare(k)) continue;
|
||||
if (typeof v !== 'object' || v === null) continue;
|
||||
const { color, type } = v as { color?: unknown; type?: unknown };
|
||||
if (!COLORS.includes(color as Color)) continue;
|
||||
if (!TYPES.includes(type as PieceType)) continue;
|
||||
out[k] = { color: color as Color, type: type as PieceType };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { fileIndex, type Piece, type Square } from './types.js';
|
||||
|
||||
/**
|
||||
* True iff moving `piece` from→to is a pawn promotion.
|
||||
*
|
||||
* A promotion is a pawn advancing from the rank adjacent to its promotion rank
|
||||
* onto the promotion rank (7→8 for White, 2→1 for Black), at most one file
|
||||
* over — a straight push or a diagonal capture.
|
||||
*
|
||||
* Checking only the piece type and the destination rank — as the move-commit
|
||||
* paths previously did — wrongly flags e.g. a pawn on the 2nd rank "moved" to
|
||||
* the 8th, popping the promotion dialog for a move no pawn could ever make.
|
||||
*/
|
||||
export function isPromotionMove(
|
||||
piece: Piece | undefined,
|
||||
from: Square,
|
||||
to: Square,
|
||||
): boolean {
|
||||
if (!piece || piece.type !== 'p') return false;
|
||||
if (Math.abs(fileIndex(from) - fileIndex(to)) > 1) return false;
|
||||
if (piece.color === 'w') return from[1] === '7' && to[1] === '8';
|
||||
return from[1] === '2' && to[1] === '1';
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {
|
||||
BoardView, Color, GameId, GameStatus, Mode, PlayerToken,
|
||||
BoardView, CaptureTally, Color, GameId, GameStatus, Mode, PlayerToken,
|
||||
PromotionType, Square, EndReason,
|
||||
} from './types.js';
|
||||
import type { Announcement } from './moderator.js';
|
||||
@@ -34,6 +34,8 @@ export type ServerMessage =
|
||||
mode: Mode;
|
||||
highlightingEnabled: boolean;
|
||||
opponentConnected: boolean;
|
||||
captures: CaptureTally;
|
||||
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
|
||||
}
|
||||
| {
|
||||
type: 'update';
|
||||
@@ -44,6 +46,8 @@ export type ServerMessage =
|
||||
drawOffer?: { from: Color } | null;
|
||||
endReason?: EndReason;
|
||||
winner?: Color | null;
|
||||
captures: CaptureTally;
|
||||
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
|
||||
}
|
||||
| { type: 'peer-status'; color: Color; connected: boolean; graceUntil?: number }
|
||||
| { type: 'error'; code: ErrorCode; message: string }
|
||||
@@ -53,10 +57,11 @@ export interface CreateGameRequest {
|
||||
mode: Mode;
|
||||
side: Color | 'random';
|
||||
highlightingEnabled: boolean;
|
||||
vsAi?: { brain: 'casual' | 'recon' };
|
||||
}
|
||||
|
||||
export interface CreateGameResponse {
|
||||
gameId: GameId;
|
||||
creatorToken: PlayerToken;
|
||||
joinUrl: string;
|
||||
joinUrl: string | null;
|
||||
}
|
||||
|
||||
@@ -61,3 +61,12 @@ export function squareAt(fileIdx: number, rankIdx: number): Square | null {
|
||||
const r = String.fromCharCode('1'.charCodeAt(0) + rankIdx);
|
||||
return `${f}${r}` as Square;
|
||||
}
|
||||
|
||||
/** Count of pieces by type — used for the capture tally. */
|
||||
export type PieceTally = Partial<Record<PieceType, number>>;
|
||||
|
||||
/** Per-viewer capture tally: what you took, and what you lost. */
|
||||
export interface CaptureTally {
|
||||
byYou: PieceTally;
|
||||
byOpponent: PieceTally;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { opponentStartPosition, deserializePhantoms } from '../src/phantoms.js';
|
||||
|
||||
describe('opponentStartPosition', () => {
|
||||
it('seeds 16 black pieces on ranks 7-8', () => {
|
||||
const p = opponentStartPosition('b');
|
||||
expect(Object.keys(p).length).toBe(16);
|
||||
expect(p.e8).toEqual({ color: 'b', type: 'k' });
|
||||
expect(p.d8).toEqual({ color: 'b', type: 'q' });
|
||||
expect(p.a8).toEqual({ color: 'b', type: 'r' });
|
||||
expect(p.h7).toEqual({ color: 'b', type: 'p' });
|
||||
});
|
||||
|
||||
it('seeds 16 white pieces on ranks 1-2', () => {
|
||||
const p = opponentStartPosition('w');
|
||||
expect(Object.keys(p).length).toBe(16);
|
||||
expect(p.e1).toEqual({ color: 'w', type: 'k' });
|
||||
expect(p.a2).toEqual({ color: 'w', type: 'p' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserializePhantoms', () => {
|
||||
it('returns {} for null or invalid JSON', () => {
|
||||
expect(deserializePhantoms(null)).toEqual({});
|
||||
expect(deserializePhantoms('not json')).toEqual({});
|
||||
expect(deserializePhantoms('[]')).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps valid entries and drops invalid ones', () => {
|
||||
const raw = JSON.stringify({
|
||||
e5: { color: 'b', type: 'n' },
|
||||
zz: { color: 'b', type: 'p' }, // invalid square
|
||||
a1: { color: 'x', type: 'p' }, // invalid colour
|
||||
b2: { color: 'b', type: 'z' }, // invalid type
|
||||
d3: null, // valid square, null value
|
||||
});
|
||||
expect(deserializePhantoms(raw)).toEqual({ e5: { color: 'b', type: 'n' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isPromotionMove } from '../src/promotion.js';
|
||||
import type { Piece } from '../src/types.js';
|
||||
|
||||
const wp: Piece = { color: 'w', type: 'p' };
|
||||
const bp: Piece = { color: 'b', type: 'p' };
|
||||
const wn: Piece = { color: 'w', type: 'n' };
|
||||
|
||||
describe('isPromotionMove', () => {
|
||||
it('white pawn from the 7th rank to the 8th is a promotion', () => {
|
||||
expect(isPromotionMove(wp, 'e7', 'e8')).toBe(true);
|
||||
expect(isPromotionMove(wp, 'd7', 'e8')).toBe(true); // capture-promotion
|
||||
});
|
||||
|
||||
it('black pawn from the 2nd rank to the 1st is a promotion', () => {
|
||||
expect(isPromotionMove(bp, 'e2', 'e1')).toBe(true);
|
||||
expect(isPromotionMove(bp, 'd2', 'c1')).toBe(true);
|
||||
});
|
||||
|
||||
it('a pawn NOT on the rank adjacent to promotion is not a promotion', () => {
|
||||
expect(isPromotionMove(wp, 'e2', 'e8')).toBe(false); // the reported bug
|
||||
expect(isPromotionMove(wp, 'e5', 'e8')).toBe(false);
|
||||
expect(isPromotionMove(bp, 'e7', 'e1')).toBe(false);
|
||||
});
|
||||
|
||||
it('an ordinary pawn move is not a promotion', () => {
|
||||
expect(isPromotionMove(wp, 'e2', 'e4')).toBe(false);
|
||||
expect(isPromotionMove(bp, 'e7', 'e5')).toBe(false);
|
||||
});
|
||||
|
||||
it('a non-pawn reaching the last rank is not a promotion', () => {
|
||||
expect(isPromotionMove(wn, 'g6', 'e8')).toBe(false);
|
||||
});
|
||||
|
||||
it('a pawn move spanning more than one file is not a promotion', () => {
|
||||
expect(isPromotionMove(wp, 'a7', 'h8')).toBe(false);
|
||||
});
|
||||
|
||||
it('an undefined piece is not a promotion', () => {
|
||||
expect(isPromotionMove(undefined, 'e7', 'e8')).toBe(false);
|
||||
});
|
||||
});
|
||||
Generated
+12
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5.6.0
|
||||
version: 5.9.3
|
||||
@@ -54,6 +57,9 @@ importers:
|
||||
fastify:
|
||||
specifier: ^5.2.0
|
||||
version: 5.8.5
|
||||
js-chess-engine:
|
||||
specifier: ^2.4.6
|
||||
version: 2.4.6
|
||||
pino:
|
||||
specifier: ^9.5.0
|
||||
version: 9.14.0
|
||||
@@ -933,6 +939,10 @@ packages:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
js-chess-engine@2.4.6:
|
||||
resolution: {integrity: sha512-OKvWKICifXLjUilGzT5RstUv9iGpk04PjGpTyVT0lMlxX2HptoXZ2Q9hNicidnYjFcR7FHpnXFVwreDSF6a5Ng==}
|
||||
engines: {node: '>=24'}
|
||||
|
||||
js-tokens@9.0.1:
|
||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||
|
||||
@@ -2075,6 +2085,8 @@ snapshots:
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-chess-engine@2.4.6: {}
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
|
||||
json-schema-ref-resolver@3.0.0:
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Self-play harness for the Casual bot.
|
||||
*
|
||||
* Runs N games in-process (no HTTP). Reports stats and optionally writes a
|
||||
* transcript per game. Supports CasualBrain on either color and a
|
||||
* RandomBrain baseline for measuring Casual's strength.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm selfplay --games 100 --mode vanilla
|
||||
* pnpm selfplay --white casual --black random --games 100 --mode vanilla
|
||||
* pnpm selfplay --white random --black casual --games 100 --mode vanilla
|
||||
* pnpm selfplay --games 50 --mode blind --transcripts
|
||||
* pnpm selfplay --games 10 --seed 42
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { CasualBrain, BotDriver } from '../packages/server/src/bot/index.js';
|
||||
import type { Brain, BrainAction, BrainInitArgs, BrainInput }
|
||||
from '../packages/server/src/bot/brain.js';
|
||||
import { createGame } from '../packages/server/src/games.js';
|
||||
|
||||
interface Args {
|
||||
white: 'casual' | 'random';
|
||||
black: 'casual' | 'random';
|
||||
games: number;
|
||||
mode: 'blind' | 'vanilla';
|
||||
seed: number;
|
||||
transcripts: boolean;
|
||||
maxPly: number;
|
||||
}
|
||||
|
||||
function parseArgs(): Args {
|
||||
const args: Args = {
|
||||
white: 'casual', black: 'casual',
|
||||
games: 10, mode: 'blind', seed: 1, transcripts: false, maxPly: 400,
|
||||
};
|
||||
const a = process.argv.slice(2);
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const k = a[i]!;
|
||||
const v = a[i + 1]!;
|
||||
if (k === '--white') { args.white = v as 'casual' | 'random'; i++; }
|
||||
else if (k === '--black') { args.black = v as 'casual' | 'random'; i++; }
|
||||
else if (k === '--games') { args.games = parseInt(v, 10); i++; }
|
||||
else if (k === '--mode') { args.mode = v as 'blind' | 'vanilla'; i++; }
|
||||
else if (k === '--seed') { args.seed = parseInt(v, 10); i++; }
|
||||
else if (k === '--max-ply') { args.maxPly = parseInt(v, 10); i++; }
|
||||
else if (k === '--transcripts') { args.transcripts = true; }
|
||||
else if (k === '--help' || k === '-h') {
|
||||
console.log('Usage: pnpm selfplay [--white casual|random] [--black casual|random]');
|
||||
console.log(' [--games N] [--mode blind|vanilla]');
|
||||
console.log(' [--seed N] [--max-ply N] [--transcripts]');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
class RandomBrain implements Brain {
|
||||
private rng: () => number;
|
||||
constructor(seed: number) {
|
||||
let a = seed >>> 0;
|
||||
this.rng = () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
async init(_args: BrainInitArgs): Promise<void> {}
|
||||
async decide(input: BrainInput): Promise<BrainAction> {
|
||||
const cs = input.legalCandidates;
|
||||
if (cs.length === 0) throw new Error('no candidates');
|
||||
const i = Math.floor(this.rng() * cs.length);
|
||||
const c = cs[i]!;
|
||||
return { type: 'commit', from: c.from, to: c.to, promotion: c.promotion };
|
||||
}
|
||||
}
|
||||
|
||||
function makeBrain(kind: 'casual' | 'random', seed: number): Brain {
|
||||
return kind === 'casual' ? new CasualBrain({ seed }) : new RandomBrain(seed);
|
||||
}
|
||||
|
||||
interface GameResult {
|
||||
result: 'w' | 'b' | 'draw' | 'maxply' | 'error';
|
||||
endReason: string;
|
||||
ply: number;
|
||||
ms: number;
|
||||
transcript: string[];
|
||||
}
|
||||
|
||||
async function runOneGame(args: Args, gameIdx: number): Promise<GameResult> {
|
||||
const startMs = Date.now();
|
||||
const transcript: string[] = [];
|
||||
|
||||
const { game } = createGame({
|
||||
mode: args.mode, creatorSide: 'w', highlightingEnabled: false,
|
||||
vsAi: { brain: 'casual' },
|
||||
});
|
||||
// createGame already filled both slots when vsAi is set. Clear the
|
||||
// aiOpponent tag (this is a self-play game, not a vs-AI game) and flip
|
||||
// status to 'active' (no hello will arrive in self-play).
|
||||
game.aiOpponent = undefined;
|
||||
game.status = 'active';
|
||||
|
||||
const wBrain = makeBrain(args.white, args.seed + gameIdx * 2);
|
||||
const bBrain = makeBrain(args.black, args.seed + gameIdx * 2 + 1);
|
||||
const wDriver = new BotDriver({ game, brain: wBrain, color: 'w' });
|
||||
const bDriver = new BotDriver({ game, brain: bBrain, color: 'b' });
|
||||
await wDriver.init();
|
||||
await bDriver.init();
|
||||
|
||||
let ply = 0;
|
||||
while (game.status === 'active' && ply < args.maxPly) {
|
||||
const turn = game.chess.turn() as 'w' | 'b';
|
||||
const driver = turn === 'w' ? wDriver : bDriver;
|
||||
try {
|
||||
await driver.onStateChange();
|
||||
} catch (err) {
|
||||
transcript.push(`!! error at ply ${ply}: ${(err as Error).message}`);
|
||||
return { result: 'error', endReason: (err as Error).message,
|
||||
ply, ms: Date.now() - startMs, transcript };
|
||||
}
|
||||
const newPly = game.chess.history().length;
|
||||
if (newPly === ply && game.status === 'active') {
|
||||
// Driver didn't move and game didn't end — defensive break.
|
||||
transcript.push(`!! stuck at ply ${ply} (${turn} to move)`);
|
||||
return { result: 'error', endReason: 'stuck',
|
||||
ply, ms: Date.now() - startMs, transcript };
|
||||
}
|
||||
if (newPly > ply) {
|
||||
const lastSan = game.chess.history()[newPly - 1];
|
||||
transcript.push(`${newPly}. ${turn === 'w' ? 'W' : 'B'}: ${lastSan}`);
|
||||
}
|
||||
ply = newPly;
|
||||
}
|
||||
|
||||
const ms = Date.now() - startMs;
|
||||
if (game.status !== 'finished') {
|
||||
return { result: 'maxply', endReason: 'max_ply', ply, ms, transcript };
|
||||
}
|
||||
const result: 'w' | 'b' | 'draw' = game.winner ?? 'draw';
|
||||
return { result, endReason: game.endReason ?? 'unknown', ply, ms, transcript };
|
||||
}
|
||||
|
||||
function summarize(rs: GameResult[]): string {
|
||||
const w = rs.filter((r) => r.result === 'w').length;
|
||||
const b = rs.filter((r) => r.result === 'b').length;
|
||||
const d = rs.filter((r) => r.result === 'draw').length;
|
||||
const mp = rs.filter((r) => r.result === 'maxply').length;
|
||||
const er = rs.filter((r) => r.result === 'error').length;
|
||||
const avgPly = rs.reduce((s, r) => s + r.ply, 0) / Math.max(rs.length, 1);
|
||||
const avgMs = rs.reduce((s, r) => s + r.ms, 0) / Math.max(rs.length, 1);
|
||||
return `W=${w} B=${b} D=${d} MaxPly=${mp} Err=${er} avgPly=${avgPly.toFixed(0)} avgMs=${avgMs.toFixed(0)}`;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs();
|
||||
console.log(`selfplay: ${args.games} game(s), mode=${args.mode}, white=${args.white}, black=${args.black}, seed=${args.seed}`);
|
||||
const results: GameResult[] = [];
|
||||
|
||||
let outDir: string | null = null;
|
||||
if (args.transcripts) {
|
||||
outDir = resolve('tmp', 'selfplay-runs', String(Date.now()));
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
console.log(`transcripts -> ${outDir}`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < args.games; i++) {
|
||||
const r = await runOneGame(args, i);
|
||||
results.push(r);
|
||||
if (outDir) {
|
||||
writeFileSync(
|
||||
resolve(outDir, `game-${String(i + 1).padStart(4, '0')}.txt`),
|
||||
`result=${r.result} reason=${r.endReason} ply=${r.ply} ms=${r.ms}\n${r.transcript.join('\n')}\n`,
|
||||
);
|
||||
}
|
||||
if ((i + 1) % 10 === 0 || i === args.games - 1) {
|
||||
console.log(`[${i + 1}/${args.games}] ${summarize(results)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== summary ===');
|
||||
console.log(summarize(results));
|
||||
const reasons = new Map<string, number>();
|
||||
for (const r of results) reasons.set(r.endReason, (reasons.get(r.endReason) ?? 0) + 1);
|
||||
console.log('end reasons:');
|
||||
for (const [k, v] of [...reasons.entries()].sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${k}: ${v}`);
|
||||
}
|
||||
console.log('errors: ' + results.filter((r) => r.result === 'error').length);
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error(err); process.exit(1); });
|
||||
Reference in New Issue
Block a user