Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- Created: 2026-04-28 ~19:15 UTC
|
- Created: 2026-04-28 ~19:15 UTC
|
||||||
- Project: /home/claude/bin/blind_chess
|
- Project: /home/claude/bin/blind_chess
|
||||||
- Branch: `feat/ai-player-phase-1-casual` (16 commits ahead of main; pending merge as final step of this handoff)
|
- 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`
|
- Repo: `git.sethpc.xyz/Seth/blind_chess`
|
||||||
- Live URL: **https://chess.sethpc.xyz** (Phase 1 deployed and verified)
|
- Live URL: **https://chess.sethpc.xyz** (Phase 1 deployed and verified)
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ This session executed `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casua
|
|||||||
| Casual ≥80% vs Random, both colors | ✅ 97% as W, 96% as B |
|
| Casual ≥80% vs Random, both colors | ✅ 97% as W, 96% as B |
|
||||||
| All unit + integration tests pass | ✅ 75/75 (21 shared + 54 server) |
|
| All unit + integration tests pass | ✅ 75/75 (21 shared + 54 server) |
|
||||||
| Live smoke checklist | ✅ /api/health, AI game creation, recon→503, no journald errors |
|
| Live smoke checklist | ✅ /api/health, AI game creation, recon→503, no journald errors |
|
||||||
| Branch merged + deployed | ⏳ Pending merge (final step of this session) |
|
| Branch merged + deployed | ✅ Merged to main (`1674695`); deployed to CT 690 |
|
||||||
|
|
||||||
## Critical Files
|
## Critical Files
|
||||||
|
|
||||||
@@ -72,20 +72,14 @@ This session executed `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casua
|
|||||||
|
|
||||||
## Immediate Next Steps
|
## Immediate Next Steps
|
||||||
|
|
||||||
1. **Merge `feat/ai-player-phase-1-casual` to `main`** (final step of this handoff).
|
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"'`).
|
||||||
```bash
|
|
||||||
git checkout main
|
|
||||||
git merge --ff-only feat/ai-player-phase-1-casual || git merge --no-ff feat/ai-player-phase-1-casual
|
|
||||||
git push origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Soak Phase 1 for a few days of real play** before starting Phase 2. Watch for:
|
|
||||||
- Bot-driver errors in journald (`journalctl -u blind-chess | grep "bot driver error"`).
|
|
||||||
- Mid-game crashes or stuck games.
|
- Mid-game crashes or stuck games.
|
||||||
- User feedback on Casual's strength (too weak / too strong / fine).
|
- User feedback on Casual's strength (too weak / too strong / fine — defaults to `js-chess-engine` level 2).
|
||||||
|
|
||||||
3. **When ready, write Phase 2 plan** — `docs/superpowers/plans/2026-04-28-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.
|
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
|
## Blockers / Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -15,6 +15,9 @@ build/
|
|||||||
coverage/
|
coverage/
|
||||||
.vitest/
|
.vitest/
|
||||||
|
|
||||||
|
# Self-play transcripts (operator scratch)
|
||||||
|
tmp/
|
||||||
|
|
||||||
# Editor / OS
|
# Editor / OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
Then check `DECISIONS.md` for settled choices, and the design specs:
|
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-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-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
|
## Project Identity
|
||||||
|
|
||||||
@@ -18,12 +19,12 @@ The system's most distinctive property: highlighting in blind mode reveals **zer
|
|||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
- **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.
|
- **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`.
|
- **Repo:** `git.sethpc.xyz/Seth/blind_chess`.
|
||||||
- **Stack:** Node 22 + TypeScript, Fastify + `ws`, Svelte 5 + Vite, `chess.js`, `js-chess-engine` (Casual vanilla AI). pnpm workspace with `packages/{server,client,shared}`.
|
- **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.
|
- **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:** 75 passing — 21 in shared (geometric helper), 54 in server (FSM + view + candidates + casual brain + driver + scripted-game + ai-game-casual integration).
|
- **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 input (click-to-move only), full integration coverage of every endgame path, mobile-specific polish, observability beyond `/api/health`.
|
- **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.
|
- **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
|
## Key files
|
||||||
@@ -36,8 +37,10 @@ The system's most distinctive property: highlighting in blind mode reveals **zer
|
|||||||
- `packages/server/src/view.ts` — `buildView`, the security boundary.
|
- `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/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/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/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/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`.
|
- `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/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.
|
- `deploy/Caddyfile.snippet` — block already added to `/etc/caddy/Caddyfile` on CT 600.
|
||||||
@@ -49,6 +52,17 @@ The system's most distinctive property: highlighting in blind mode reveals **zer
|
|||||||
- **Health:** `curl https://chess.sethpc.xyz/api/health`
|
- **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).
|
- **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
|
## Conventions
|
||||||
|
|
||||||
- Inherits global homelab conventions from `~/bin/CLAUDE.md` (gitea CLI, conventional commits, `.claude/handoffs/` for session state).
|
- Inherits global homelab conventions from `~/bin/CLAUDE.md` (gitea CLI, conventional commits, `.claude/handoffs/` for session state).
|
||||||
|
|||||||
@@ -75,6 +75,18 @@ Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`. **Phase 1 (Casual
|
|||||||
- 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: **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.
|
- 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
|
## Deferred / Rejected
|
||||||
|
|
||||||
<!-- Decisions NOT to do something are just as valuable -- prevents re-proposing rejected ideas -->
|
<!-- Decisions NOT to do something are just as valuable -- prevents re-proposing rejected ideas -->
|
||||||
@@ -99,3 +111,6 @@ Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`. **Phase 1 (Casual
|
|||||||
- 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: **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: **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-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.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ALL_SQUARES, FILES, RANKS, geometricMoves, type Color, type Piece, type Square } from '@blind-chess/shared';
|
import { ALL_SQUARES, FILES, RANKS, geometricMoves, type Color, type Piece, type Square } from '@blind-chess/shared';
|
||||||
import { pieceGlyph } from './pieces.js';
|
import { pieceGlyph } from './pieces.js';
|
||||||
|
import { phantomDrag } from './stores/phantom-drag.svelte.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pieces: Partial<Record<Square, Piece>>;
|
pieces: Partial<Record<Square, Piece>>;
|
||||||
@@ -8,6 +9,8 @@
|
|||||||
toMove: Color;
|
toMove: Color;
|
||||||
mode: 'blind' | 'vanilla';
|
mode: 'blind' | 'vanilla';
|
||||||
highlightingEnabled: boolean;
|
highlightingEnabled: boolean;
|
||||||
|
phantoms?: Partial<Record<Square, Piece>>;
|
||||||
|
phantomsEnabled?: boolean;
|
||||||
armedSquare: Square | null; // local visual arm (pre-commit)
|
armedSquare: Square | null; // local visual arm (pre-commit)
|
||||||
touchedSquare: Square | null; // server-authoritative touch
|
touchedSquare: Square | null; // server-authoritative touch
|
||||||
onArm: (sq: Square | null) => void;
|
onArm: (sq: Square | null) => void;
|
||||||
@@ -16,6 +19,7 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
pieces, you, toMove, mode, highlightingEnabled,
|
pieces, you, toMove, mode, highlightingEnabled,
|
||||||
|
phantoms = {}, phantomsEnabled = false,
|
||||||
armedSquare, touchedSquare, onArm, onCommit,
|
armedSquare, touchedSquare, onArm, onCommit,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -43,6 +47,14 @@
|
|||||||
return new Set(moves);
|
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' {
|
function squareColor(sq: Square): 'light' | 'dark' {
|
||||||
const f = sq.charCodeAt(0) - 'a'.charCodeAt(0);
|
const f = sq.charCodeAt(0) - 'a'.charCodeAt(0);
|
||||||
const r = parseInt(sq[1]!, 10) - 1;
|
const r = parseInt(sq[1]!, 10) - 1;
|
||||||
@@ -50,6 +62,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onSquareClick(sq: Square) {
|
function onSquareClick(sq: Square) {
|
||||||
|
if (phantomDrag.shouldSuppressClick(sq)) return;
|
||||||
const piece = pieces[sq];
|
const piece = pieces[sq];
|
||||||
|
|
||||||
// If a piece is touched (server-authoritative), only commits to that square's destinations are valid.
|
// 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)}
|
{#each filesDisplay as f (f)}
|
||||||
{@const sq = `${f}${r}` as Square}
|
{@const sq = `${f}${r}` as Square}
|
||||||
{@const piece = pieces[sq]}
|
{@const piece = pieces[sq]}
|
||||||
|
{@const ph = phantomsEnabled ? phantoms[sq] : undefined}
|
||||||
{@const sc = squareColor(sq)}
|
{@const sc = squareColor(sq)}
|
||||||
{@const isArmed = sq === armedSquare}
|
{@const isArmed = sq === armedSquare}
|
||||||
{@const isTouched = sq === touchedSquare}
|
{@const isTouched = sq === touchedSquare}
|
||||||
@@ -96,6 +110,7 @@
|
|||||||
class:hl-cap={isHighlightCap}
|
class:hl-cap={isHighlightCap}
|
||||||
onclick={() => onSquareClick(sq)}
|
onclick={() => onSquareClick(sq)}
|
||||||
aria-label={sq}
|
aria-label={sq}
|
||||||
|
data-square={sq}
|
||||||
>
|
>
|
||||||
{#if r === (you === 'w' ? '1' : '8') && f === filesDisplay[0]}
|
{#if r === (you === 'w' ? '1' : '8') && f === filesDisplay[0]}
|
||||||
<span class="coord coord-rank">{r}</span>
|
<span class="coord coord-rank">{r}</span>
|
||||||
@@ -106,6 +121,17 @@
|
|||||||
{#if piece}
|
{#if piece}
|
||||||
<span class="piece piece-{piece.color}">{pieceGlyph(piece)}</span>
|
<span class="piece piece-{piece.color}">{pieceGlyph(piece)}</span>
|
||||||
{/if}
|
{/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}
|
{#if isHighlight && !piece}
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -154,6 +180,23 @@
|
|||||||
.piece-w { color: #fafafa; }
|
.piece-w { color: #fafafa; }
|
||||||
.piece-b { color: #1a1a1a; }
|
.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); }
|
.armed { box-shadow: inset 0 0 0 4px var(--armed); }
|
||||||
.touched { box-shadow: inset 0 0 0 4px var(--touched); }
|
.touched { box-shadow: inset 0 0 0 4px var(--touched); }
|
||||||
.hl::before {
|
.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 { game } from './stores/game.svelte.js';
|
||||||
import Board from './Board.svelte';
|
import Board from './Board.svelte';
|
||||||
import ModeratorPanel from './ModeratorPanel.svelte';
|
import ModeratorPanel from './ModeratorPanel.svelte';
|
||||||
|
import CaptureTally from './CaptureTally.svelte';
|
||||||
import PromotionDialog from './PromotionDialog.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; }
|
interface Props { gameId: string; }
|
||||||
let { gameId }: Props = $props();
|
let { gameId }: Props = $props();
|
||||||
@@ -35,13 +40,13 @@
|
|||||||
function onCommit(from: Square, to: Square) {
|
function onCommit(from: Square, to: Square) {
|
||||||
const piece = game.state.view?.pieces[from];
|
const piece = game.state.view?.pieces[from];
|
||||||
if (!piece) return;
|
if (!piece) return;
|
||||||
// Promotion check (white pawn to rank 8, black pawn to rank 1).
|
// A pawn promotes only from the rank adjacent to its promotion rank —
|
||||||
if (piece.type === 'p') {
|
// isPromotionMove checks the source rank, destination rank, and file delta,
|
||||||
const rank = to[1];
|
// so a pawn elsewhere "moved" toward the last rank no longer pops this
|
||||||
if ((piece.color === 'w' && rank === '8') || (piece.color === 'b' && rank === '1')) {
|
// dialog (which a click on a phantom-occupied back-rank square could do).
|
||||||
pendingPromotion = { from, to };
|
if (isPromotionMove(piece, from, to)) {
|
||||||
return;
|
pendingPromotion = { from, to };
|
||||||
}
|
return;
|
||||||
}
|
}
|
||||||
game.commit(from, to);
|
game.commit(from, to);
|
||||||
}
|
}
|
||||||
@@ -91,6 +96,40 @@
|
|||||||
}
|
}
|
||||||
return 'Opponent thinking';
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="game-layout" class:waiting={game.state.gameStatus === 'waiting'}>
|
<div class="game-layout" class:waiting={game.state.gameStatus === 'waiting'}>
|
||||||
@@ -128,15 +167,21 @@
|
|||||||
toMove={game.state.view.toMove}
|
toMove={game.state.view.toMove}
|
||||||
mode={game.state.mode ?? 'blind'}
|
mode={game.state.mode ?? 'blind'}
|
||||||
highlightingEnabled={game.state.highlightingEnabled}
|
highlightingEnabled={game.state.highlightingEnabled}
|
||||||
|
phantoms={phantomLayerEnabled ? phantoms.state.phantoms : {}}
|
||||||
|
phantomsEnabled={phantomLayerEnabled}
|
||||||
armedSquare={armedSquare}
|
armedSquare={armedSquare}
|
||||||
touchedSquare={game.state.touchedPiece}
|
touchedSquare={game.state.touchedPiece}
|
||||||
{onArm}
|
{onArm}
|
||||||
{onCommit}
|
{onCommit}
|
||||||
/>
|
/>
|
||||||
|
{#if phantomLayerEnabled}
|
||||||
|
<PhantomPalette {oppColor} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="side">
|
<aside class="side">
|
||||||
<ModeratorPanel announcements={game.state.announcements} you={game.state.you} />
|
<ModeratorPanel announcements={game.state.announcements} you={game.state.you} />
|
||||||
|
<CaptureTally captures={game.state.captures} you={game.state.you} />
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if game.state.gameStatus === 'active'}
|
{#if game.state.gameStatus === 'active'}
|
||||||
@@ -168,6 +213,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{#if pendingPromotion && game.state.you}
|
||||||
<PromotionDialog
|
<PromotionDialog
|
||||||
color={game.state.you}
|
color={game.state.you}
|
||||||
@@ -219,7 +271,13 @@
|
|||||||
color: var(--text-dim);
|
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 {
|
.side {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -268,4 +326,18 @@
|
|||||||
.waiting-card h2 { margin: 0 0 8px; font-size: 18px; }
|
.waiting-card h2 { margin: 0 0 8px; font-size: 18px; }
|
||||||
.link-row { display: flex; gap: 8px; margin-top: 16px; }
|
.link-row { display: flex; gap: 8px; margin-top: 16px; }
|
||||||
.link { flex: 1; font-family: ui-monospace, monospace; font-size: 13px; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -24,9 +24,11 @@
|
|||||||
<header>Moderator</header>
|
<header>Moderator</header>
|
||||||
<div class="log" bind:this={scrollEl}>
|
<div class="log" bind:this={scrollEl}>
|
||||||
{#each visible as a, i (i)}
|
{#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="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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="empty muted">The moderator is silent.</div>
|
<div class="empty muted">The moderator is silent.</div>
|
||||||
@@ -67,7 +69,7 @@
|
|||||||
border-bottom: 1px dashed rgba(255,255,255,0.05);
|
border-bottom: 1px dashed rgba(255,255,255,0.05);
|
||||||
}
|
}
|
||||||
.entry:last-child { border-bottom: none; }
|
.entry:last-child { border-bottom: none; }
|
||||||
.entry.err .text { color: #f87171; }
|
.entry.attempt .text { color: var(--text-dim); font-style: italic; }
|
||||||
.ply {
|
.ply {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-family: ui-monospace, monospace;
|
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 {
|
import type {
|
||||||
Announcement,
|
Announcement,
|
||||||
BoardView,
|
BoardView,
|
||||||
|
CaptureTally,
|
||||||
ClientMessage,
|
ClientMessage,
|
||||||
Color,
|
Color,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
@@ -29,6 +30,7 @@ interface GameStateValue {
|
|||||||
opponentConnected: boolean;
|
opponentConnected: boolean;
|
||||||
lastError: { code: ErrorCode; message: string; at: number } | null;
|
lastError: { code: ErrorCode; message: string; at: number } | null;
|
||||||
aiOpponent: { color: Color; brain: 'casual' | 'recon' } | null;
|
aiOpponent: { color: Color; brain: 'casual' | 'recon' } | null;
|
||||||
|
captures: CaptureTally;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStore() {
|
function makeStore() {
|
||||||
@@ -49,6 +51,7 @@ function makeStore() {
|
|||||||
opponentConnected: false,
|
opponentConnected: false,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
aiOpponent: null,
|
aiOpponent: null,
|
||||||
|
captures: { byYou: {}, byOpponent: {} },
|
||||||
});
|
});
|
||||||
|
|
||||||
function tokenKey(gameId: string) { return `bc:${gameId}`; }
|
function tokenKey(gameId: string) { return `bc:${gameId}`; }
|
||||||
@@ -94,6 +97,7 @@ function makeStore() {
|
|||||||
state.highlightingEnabled = m.highlightingEnabled;
|
state.highlightingEnabled = m.highlightingEnabled;
|
||||||
state.opponentConnected = m.opponentConnected;
|
state.opponentConnected = m.opponentConnected;
|
||||||
state.aiOpponent = m.aiOpponent ?? null;
|
state.aiOpponent = m.aiOpponent ?? null;
|
||||||
|
state.captures = m.captures;
|
||||||
if (state.gameId) localStorage.setItem(tokenKey(state.gameId), m.token);
|
if (state.gameId) localStorage.setItem(tokenKey(state.gameId), m.token);
|
||||||
break;
|
break;
|
||||||
case 'update':
|
case 'update':
|
||||||
@@ -107,6 +111,7 @@ function makeStore() {
|
|||||||
state.announcements = [...state.announcements, ...m.newAnnouncements];
|
state.announcements = [...state.announcements, ...m.newAnnouncements];
|
||||||
}
|
}
|
||||||
if (m.aiOpponent !== undefined) state.aiOpponent = m.aiOpponent;
|
if (m.aiOpponent !== undefined) state.aiOpponent = m.aiOpponent;
|
||||||
|
state.captures = m.captures;
|
||||||
break;
|
break;
|
||||||
case 'peer-status':
|
case 'peer-status':
|
||||||
if (state.you && m.color !== state.you) {
|
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();
|
||||||
@@ -65,8 +65,15 @@ export class CasualBrain implements Brain {
|
|||||||
// can't validate against the candidate list.
|
// can't validate against the candidate list.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blind mode (or vanilla fallback): score-based heuristic.
|
// Blind mode (or vanilla fallback): score-based heuristic. When the
|
||||||
const choice = this.heuristicPick(filtered, input.view, input.ply);
|
// 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 {
|
return {
|
||||||
type: 'commit',
|
type: 'commit',
|
||||||
from: choice.from,
|
from: choice.from,
|
||||||
@@ -75,6 +82,11 @@ export class CasualBrain implements Brain {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
* Run js-chess-engine on the given FEN and return a candidate matching
|
||||||
* its choice, or null if no match was found.
|
* its choice, or null if no match was found.
|
||||||
@@ -113,19 +125,35 @@ export class CasualBrain implements Brain {
|
|||||||
candidates: CandidateMove[],
|
candidates: CandidateMove[],
|
||||||
view: BoardView,
|
view: BoardView,
|
||||||
ply: number,
|
ply: number,
|
||||||
|
inCheck: boolean,
|
||||||
): CandidateMove {
|
): CandidateMove {
|
||||||
|
const kingSquare = inCheck ? this.findOwnKing(view) : null;
|
||||||
const scored = candidates.map((c) => {
|
const scored = candidates.map((c) => {
|
||||||
let score = this.scoreMove(c, view, ply);
|
let score = this.scoreMove(c, view, ply);
|
||||||
if (c.promotion === 'q') score += 1000;
|
if (c.promotion === 'q') score += 1000;
|
||||||
else if (c.promotion === 'r') score += 500;
|
else if (c.promotion === 'r') score += 500;
|
||||||
else if (c.promotion === 'b') score += 100;
|
else if (c.promotion === 'b') score += 100;
|
||||||
else if (c.promotion === 'n') score += 50;
|
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 };
|
return { move: c, score: score + this.rng() * 0.01 };
|
||||||
});
|
});
|
||||||
scored.sort((a, b) => b.score - a.score);
|
scored.sort((a, b) => b.score - a.score);
|
||||||
return scored[0]!.move;
|
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(
|
private excludeRejected(
|
||||||
candidates: CandidateMove[],
|
candidates: CandidateMove[],
|
||||||
history: BrainInput['attemptHistory'],
|
history: BrainInput['attemptHistory'],
|
||||||
|
|||||||
@@ -13,7 +13,23 @@ import { buildView } from '../view.js';
|
|||||||
import { announce } from '../translator.js';
|
import { announce } from '../translator.js';
|
||||||
import { finalizeIfEnded } from '../game-end.js';
|
import { finalizeIfEnded } from '../game-end.js';
|
||||||
|
|
||||||
const RETRY_CAP = 5;
|
// 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 {
|
interface BotDriverOpts {
|
||||||
game: Game;
|
game: Game;
|
||||||
@@ -76,14 +92,23 @@ export class BotDriver {
|
|||||||
const attemptHistory: AttemptHistoryEntry[] = [];
|
const attemptHistory: AttemptHistoryEntry[] = [];
|
||||||
|
|
||||||
for (let attempt = 0; attempt < RETRY_CAP; attempt++) {
|
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);
|
const input = this.buildBrainInput(attemptHistory);
|
||||||
let outcome: { kind: 'done' } | { kind: 'retry'; entry: AttemptHistoryEntry };
|
let outcome: { kind: 'done' } | { kind: 'retry'; entry: AttemptHistoryEntry };
|
||||||
try {
|
try {
|
||||||
const action = await this.brain.decide(input);
|
const action = await this.brain.decide(input);
|
||||||
outcome = this.dispatch(action);
|
outcome = this.dispatch(action);
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Brain exception OR programming error in dispatch. Safe failure: resign.
|
// Brain exception OR programming error in dispatch. Safe failure: resign.
|
||||||
this.botResign();
|
this.botResign('brain_threw', { err: errString(err) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (outcome.kind === 'done') {
|
if (outcome.kind === 'done') {
|
||||||
@@ -93,7 +118,9 @@ export class BotDriver {
|
|||||||
attemptHistory.push(outcome.entry);
|
attemptHistory.push(outcome.entry);
|
||||||
}
|
}
|
||||||
this.lastSeenAnnouncementCount = this.game.announcements.length;
|
this.lastSeenAnnouncementCount = this.game.announcements.length;
|
||||||
this.botResign();
|
this.botResign('retry_cap_exhausted', {
|
||||||
|
attempts: attemptHistory.map((a) => `${a.move.from}-${a.move.to}:${a.rejection}`),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildBrainInput(attemptHistory: AttemptHistoryEntry[]): BrainInput {
|
private buildBrainInput(attemptHistory: AttemptHistoryEntry[]): BrainInput {
|
||||||
@@ -135,9 +162,19 @@ export class BotDriver {
|
|||||||
return { kind: 'done' };
|
return { kind: 'done' };
|
||||||
}
|
}
|
||||||
if (result.kind === 'announce') {
|
if (result.kind === 'announce') {
|
||||||
const text = result.announcements[0]!.text;
|
const rejection = result.announcements[0]!;
|
||||||
|
const text = rejection.text;
|
||||||
if (text === 'wont_help' || text === 'illegal_move'
|
if (text === 'wont_help' || text === 'illegal_move'
|
||||||
|| text === 'no_such_piece' || text === 'no_legal_moves') {
|
|| 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 {
|
return {
|
||||||
kind: 'retry',
|
kind: 'retry',
|
||||||
entry: {
|
entry: {
|
||||||
@@ -150,15 +187,18 @@ export class BotDriver {
|
|||||||
if (result.kind === 'silent') {
|
if (result.kind === 'silent') {
|
||||||
// Brain sent only `from` (arming). CasualBrain always commits with
|
// Brain sent only `from` (arming). CasualBrain always commits with
|
||||||
// `to`; treat as a logic error and resign safely.
|
// `to`; treat as a logic error and resign safely.
|
||||||
this.botResign();
|
this.botResign('commit_silent', { from: action.from, to: action.to });
|
||||||
return { kind: 'done' };
|
return { kind: 'done' };
|
||||||
}
|
}
|
||||||
// result.kind === 'error' — bug path; resign.
|
// result.kind === 'error' — bug path; resign.
|
||||||
this.botResign();
|
this.botResign('commit_error', {
|
||||||
|
code: result.kind === 'error' ? result.code : undefined,
|
||||||
|
announcement: result.kind === 'announce' ? result.announcements[0]?.text : undefined,
|
||||||
|
});
|
||||||
return { kind: 'done' };
|
return { kind: 'done' };
|
||||||
}
|
}
|
||||||
case 'resign':
|
case 'resign':
|
||||||
this.botResign();
|
this.botResign('brain_chose_resign');
|
||||||
return { kind: 'done' };
|
return { kind: 'done' };
|
||||||
case 'offer-draw':
|
case 'offer-draw':
|
||||||
if (!this.game.drawOffer) {
|
if (!this.game.drawOffer) {
|
||||||
@@ -185,7 +225,7 @@ export class BotDriver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private botResign(): void {
|
private botResign(reason: BotResignReason, detail?: Record<string, unknown>): void {
|
||||||
if (this.game.status !== 'active') return;
|
if (this.game.status !== 'active') return;
|
||||||
const ply = this.game.chess.history().length;
|
const ply = this.game.chess.history().length;
|
||||||
const text = this.color === 'w' ? 'white_resigned' : 'black_resigned';
|
const text = this.color === 'w' ? 'white_resigned' : 'black_resigned';
|
||||||
@@ -195,6 +235,15 @@ export class BotDriver {
|
|||||||
this.game.endReason = 'resign';
|
this.game.endReason = 'resign';
|
||||||
this.game.winner = this.color === 'w' ? 'b' : 'w';
|
this.game.winner = this.color === 'w' ? 'b' : 'w';
|
||||||
this.game.finishedAt = Date.now();
|
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> {
|
private async disposeBrain(): Promise<void> {
|
||||||
|
|||||||
@@ -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 type { Move } from 'chess.js';
|
||||||
import {
|
import {
|
||||||
geometricMoves,
|
geometricMoves,
|
||||||
|
isPromotionMove,
|
||||||
type Announcement,
|
type Announcement,
|
||||||
type Color,
|
type Color,
|
||||||
type Piece,
|
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;
|
const piece = game.chess.get(msg.from) as { color: Color; type: Piece['type'] } | false;
|
||||||
if (!piece || piece.color !== color) {
|
if (!piece || piece.color !== color) {
|
||||||
return announceWith(game, 'no_such_piece', color);
|
return announceWith(game, 'no_such_piece');
|
||||||
}
|
}
|
||||||
|
|
||||||
const pseudo = geometricMoves(
|
const pseudo = geometricMoves(
|
||||||
@@ -46,12 +47,12 @@ export function handleCommit(game: Game, color: Color, msg: CommitInput): Commit
|
|||||||
ownSquares(game, color),
|
ownSquares(game, color),
|
||||||
);
|
);
|
||||||
if (pseudo.length === 0) {
|
if (pseudo.length === 0) {
|
||||||
return announceWith(game, 'no_legal_moves', color);
|
return announceWith(game, 'no_legal_moves');
|
||||||
}
|
}
|
||||||
|
|
||||||
const legal = chessJsLegalFrom(game, msg.from);
|
const legal = chessJsLegalFrom(game, msg.from);
|
||||||
if (legal.length === 0) {
|
if (legal.length === 0) {
|
||||||
return announceWith(game, 'wont_help', color);
|
return announceWith(game, 'wont_help');
|
||||||
}
|
}
|
||||||
|
|
||||||
game.armed = { color, from: msg.from };
|
game.armed = { color, from: msg.from };
|
||||||
@@ -77,7 +78,7 @@ function tryMove(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!move) {
|
if (!move) {
|
||||||
return announceWith(game, 'illegal_move', color);
|
return announceWith(game, 'illegal_move');
|
||||||
}
|
}
|
||||||
|
|
||||||
game.armed = null;
|
game.armed = null;
|
||||||
@@ -110,10 +111,10 @@ function tryMove(
|
|||||||
function announceWith(
|
function announceWith(
|
||||||
game: Game,
|
game: Game,
|
||||||
text: 'no_such_piece' | 'no_legal_moves' | 'wont_help' | 'illegal_move',
|
text: 'no_such_piece' | 'no_legal_moves' | 'wont_help' | 'illegal_move',
|
||||||
color: Color,
|
|
||||||
): CommitResult {
|
): CommitResult {
|
||||||
const ply = game.chess.history().length;
|
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);
|
game.announcements.push(a);
|
||||||
return { kind: 'announce', announcements: [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 {
|
function isPromotionRequired(game: Game, from: Square, to: Square): boolean {
|
||||||
const piece = game.chess.get(from);
|
const piece = game.chess.get(from);
|
||||||
if (!piece || piece.type !== 'p') return false;
|
if (!piece) return false;
|
||||||
const toRank = to[1];
|
return isPromotionMove({ color: piece.color, type: piece.type }, from, to);
|
||||||
if (piece.color === 'w' && toRank === '8') return true;
|
|
||||||
if (piece.color === 'b' && toRank === '1') return true;
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ export function announce(
|
|||||||
/**
|
/**
|
||||||
* Translate an applied chess.js Move into the moderator vocabulary.
|
* Translate an applied chess.js Move into the moderator vocabulary.
|
||||||
*
|
*
|
||||||
* Capturing player learns the captured piece type via their `view` update
|
* Every announcement is emitted with audience 'both' — the moderator speaks
|
||||||
* (their canonical board reflects the capture; the captured-pieces tray is
|
* each event aloud and both players hear it. Move events carry no square
|
||||||
* populated from move history). The opponent gets only the `*_moved_captured`
|
* coordinates, so a player learns *that* the opponent moved / captured /
|
||||||
* announcement.
|
* castled, never *where*. The capturing player additionally sees the capture
|
||||||
|
* reflected in their own view update.
|
||||||
*/
|
*/
|
||||||
export function translateMove(game: Game, move: Move): Announcement[] {
|
export function translateMove(game: Game, move: Move): Announcement[] {
|
||||||
const out: Announcement[] = [];
|
const out: Announcement[] = [];
|
||||||
@@ -33,21 +34,21 @@ export function translateMove(game: Game, move: Move): Announcement[] {
|
|||||||
const isQueensideCastle = move.isQueensideCastle();
|
const isQueensideCastle = move.isQueensideCastle();
|
||||||
const isProm = !!move.promotion;
|
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) {
|
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) {
|
} 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) {
|
} 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) {
|
} else if (isCap) {
|
||||||
out.push(announce(`${moverWord}_moved_captured` as ModeratorText, opp, ply));
|
out.push(announce(`${moverWord}_moved_captured` as ModeratorText, 'both', ply));
|
||||||
} else {
|
} else {
|
||||||
out.push(announce(`${moverWord}_moved` as ModeratorText, opp, ply));
|
out.push(announce(`${moverWord}_moved` as ModeratorText, 'both', ply));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isProm) {
|
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.
|
// To both: state changes.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { announce } from './translator.js';
|
|||||||
import { buildView } from './view.js';
|
import { buildView } from './view.js';
|
||||||
import { consumeCommitToken } from './ratelimit.js';
|
import { consumeCommitToken } from './ratelimit.js';
|
||||||
import { endGame, finalizeIfEnded } from './game-end.js';
|
import { endGame, finalizeIfEnded } from './game-end.js';
|
||||||
|
import { captureTally } from './captures.js';
|
||||||
|
|
||||||
async function pokeBot(game: Game): Promise<void> {
|
async function pokeBot(game: Game): Promise<void> {
|
||||||
const driver = getBotDriver(game.id);
|
const driver = getBotDriver(game.id);
|
||||||
@@ -147,6 +148,7 @@ async function onHello(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'hell
|
|||||||
mode: game.mode,
|
mode: game.mode,
|
||||||
highlightingEnabled: game.highlightingEnabled,
|
highlightingEnabled: game.highlightingEnabled,
|
||||||
opponentConnected: !!game.players[color === 'w' ? 'b' : 'w']?.socket,
|
opponentConnected: !!game.players[color === 'w' ? 'b' : 'w']?.socket,
|
||||||
|
captures: captureTally(game, color),
|
||||||
aiOpponent: game.aiOpponent,
|
aiOpponent: game.aiOpponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -284,6 +286,7 @@ function sendUpdateTo(
|
|||||||
drawOffer,
|
drawOffer,
|
||||||
endReason: game.endReason,
|
endReason: game.endReason,
|
||||||
winner: game.winner ?? null,
|
winner: game.winner ?? null,
|
||||||
|
captures: captureTally(game, color),
|
||||||
aiOpponent: game.aiOpponent,
|
aiOpponent: game.aiOpponent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,99 @@ describe('CasualBrain', () => {
|
|||||||
expect(aAct).toEqual(bAct);
|
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 () => {
|
it('opening moves favor center pawns over flank pawns (e/d > a/h)', async () => {
|
||||||
const candidates: CandidateMove[] = [
|
const candidates: CandidateMove[] = [
|
||||||
{ from: 'a2', to: 'a3' },
|
{ from: 'a2', to: 'a3' },
|
||||||
|
|||||||
@@ -115,20 +115,44 @@ describe('BotDriver', () => {
|
|||||||
expect(game.chess.turn()).toBe('w');
|
expect(game.chess.turn()).toBe('w');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retry cap (5): after 5 wont_help, driver resigns the bot', async () => {
|
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';
|
const fen = '4k2K/4b3/8/8/8/8/8/4R3 b - - 0 1';
|
||||||
game = makeGame({ fen });
|
game = makeGame({ fen });
|
||||||
brain = new StubBrain();
|
brain = new StubBrain();
|
||||||
driver = new BotDriver({ game, brain, color: 'b' });
|
driver = new BotDriver({ game, brain, color: 'b' });
|
||||||
await driver.init();
|
await driver.init();
|
||||||
for (let i = 0; i < 6; i++) {
|
// 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' });
|
brain.enqueue({ type: 'commit', from: 'e7', to: 'd6' });
|
||||||
}
|
}
|
||||||
await driver.onStateChange();
|
await driver.onStateChange();
|
||||||
expect(game.status).toBe('finished');
|
expect(game.status).toBe('finished');
|
||||||
expect(game.endReason).toBe('resign');
|
expect(game.endReason).toBe('resign');
|
||||||
expect(game.winner).toBe('w');
|
expect(game.winner).toBe('w');
|
||||||
expect(brain.decide).toHaveBeenCalledTimes(5);
|
expect(brain.decide).toHaveBeenCalledTimes(25);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('respond-draw: when drawOffer is from opponent, driver fires decide and dispatches', async () => {
|
it('respond-draw: when drawOffer is from opponent, driver fires decide and dispatches', async () => {
|
||||||
@@ -148,6 +172,24 @@ describe('BotDriver', () => {
|
|||||||
expect(brain.dispose).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 () => {
|
it('bot move that delivers checkmate finalizes game.status', async () => {
|
||||||
// FEN: '1k6/8/1K6/8/8/8/8/7Q w - - 0 1'
|
// FEN: '1k6/8/1K6/8/8/8/8/7Q w - - 0 1'
|
||||||
// White king b6, white queen h1, black king b8.
|
// White king b6, white queen h1, black king b8.
|
||||||
|
|||||||
@@ -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: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,6 +74,20 @@ describe('hierarchy decision table', () => {
|
|||||||
expect(game.armed).toBeNull();
|
expect(game.armed).toBeNull();
|
||||||
expect(game.chess.history()).toContain('e4');
|
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', () => {
|
describe('touch-move enforcement', () => {
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ export * from './types.js';
|
|||||||
export * from './moderator.js';
|
export * from './moderator.js';
|
||||||
export * from './protocol.js';
|
export * from './protocol.js';
|
||||||
export * from './geometric.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 {
|
import type {
|
||||||
BoardView, Color, GameId, GameStatus, Mode, PlayerToken,
|
BoardView, CaptureTally, Color, GameId, GameStatus, Mode, PlayerToken,
|
||||||
PromotionType, Square, EndReason,
|
PromotionType, Square, EndReason,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import type { Announcement } from './moderator.js';
|
import type { Announcement } from './moderator.js';
|
||||||
@@ -34,6 +34,7 @@ export type ServerMessage =
|
|||||||
mode: Mode;
|
mode: Mode;
|
||||||
highlightingEnabled: boolean;
|
highlightingEnabled: boolean;
|
||||||
opponentConnected: boolean;
|
opponentConnected: boolean;
|
||||||
|
captures: CaptureTally;
|
||||||
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
|
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -45,6 +46,7 @@ export type ServerMessage =
|
|||||||
drawOffer?: { from: Color } | null;
|
drawOffer?: { from: Color } | null;
|
||||||
endReason?: EndReason;
|
endReason?: EndReason;
|
||||||
winner?: Color | null;
|
winner?: Color | null;
|
||||||
|
captures: CaptureTally;
|
||||||
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
|
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
|
||||||
}
|
}
|
||||||
| { type: 'peer-status'; color: Color; connected: boolean; graceUntil?: number }
|
| { type: 'peer-status'; color: Color; connected: boolean; graceUntil?: number }
|
||||||
|
|||||||
@@ -61,3 +61,12 @@ export function squareAt(fileIdx: number, rankIdx: number): Square | null {
|
|||||||
const r = String.fromCharCode('1'.charCodeAt(0) + rankIdx);
|
const r = String.fromCharCode('1'.charCodeAt(0) + rankIdx);
|
||||||
return `${f}${r}` as Square;
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user