Compare commits
17 Commits
6d457a2321
...
1674695eef
| Author | SHA1 | Date | |
|---|---|---|---|
| 1674695eef | |||
| 7c18725586 | |||
| dc5e6678b9 | |||
| 06bd144f7c | |||
| 31f68db654 | |||
| cb8e017792 | |||
| 73d5d0cb93 | |||
| 88bc23b0d0 | |||
| a9660c0694 | |||
| 58e1fc5bd8 | |||
| 9a837ec319 | |||
| 4407110147 | |||
| 3798b9c00d | |||
| ebd1463b0a | |||
| aa7bc30ee1 | |||
| f48e0a9cdf | |||
| bc954f4748 |
@@ -0,0 +1,149 @@
|
||||
# Handoff: AI Phase 1 (Casual bot) shipped
|
||||
|
||||
## Session Metadata
|
||||
|
||||
- Created: 2026-04-28 ~19:15 UTC
|
||||
- Project: /home/claude/bin/blind_chess
|
||||
- Branch: `feat/ai-player-phase-1-casual` (16 commits ahead of main; pending merge as final step of this handoff)
|
||||
- Repo: `git.sethpc.xyz/Seth/blind_chess`
|
||||
- Live URL: **https://chess.sethpc.xyz** (Phase 1 deployed and verified)
|
||||
|
||||
## Handoff Chain
|
||||
|
||||
- **Continues from**: [2026-04-28-170713-ai-player-spec.md](./2026-04-28-170713-ai-player-spec.md) — AI player spec written and approved.
|
||||
- **Supersedes**: None.
|
||||
|
||||
## Current State Summary
|
||||
|
||||
Phase 1 of the AI player feature (Casual bot) is **deployed and live**. Playing vs a Casual bot is now an option from the landing page, alongside the existing "play with a friend" flow.
|
||||
|
||||
This session executed `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md` via subagent-driven development: 13 tasks, dispatched as fresh subagents per task with two-stage review (spec compliance + code quality). Several tasks surfaced real plan bugs that subagents fixed inline; the most consequential reversal was during Task 11 (self-play harness): the hand-rolled scoring algorithm in `CasualBrain` lost to a random-move baseline 7-7 in 100-game self-play, far below the spec's ≥80% acceptance bar. Solution: swapped vanilla-mode CasualBrain to delegate to `js-chess-engine` (level 2, randomness=30); blind mode kept the heuristic. Casual now wins 96-97% vs Random in vanilla, in both colors.
|
||||
|
||||
## Architecture Overview (what's deployed)
|
||||
|
||||
- **`packages/server/src/bot/`** — new module:
|
||||
- `brain.ts` — `Brain` interface, `BrainInput`/`BrainAction`/`CandidateMove`/`AttemptHistoryEntry` types. `BrainInput.fen` set ONLY in vanilla mode (preserves view-filter invariant).
|
||||
- `candidates.ts` — `legalCandidates(game, color)`. Vanilla: `chess.js .moves({verbose: true})`. Blind: `geometricMoves` over own pieces + promotion expansion.
|
||||
- `casual-brain.ts` — `CasualBrain implements Brain`. Vanilla: delegates to `js-chess-engine` at level 2; blind: heuristic scoring (capture proxy / development / center / advance). Promotion default: queen. Draw response based on own material count.
|
||||
- `driver.ts` — `BotDriver` per-game orchestrator. Mutex via `decideInFlight`, retry cap of 5, dispatches via `handleCommit`/announce, on game end calls `brain.dispose?.()`.
|
||||
- `index.ts` — public re-exports.
|
||||
- **`packages/server/src/game-end.ts`** — extracted from `ws.ts`: `endGame`/`finalizeIfEnded`. Both `ws.ts` and `bot/driver.ts` use it.
|
||||
- **`packages/server/src/games.ts`** — bot driver registry (`attachBotDriver`, `getBotDriver`, `disposeBotDriver`). `createGame` accepts optional `vsAi: { brain }` and fills the bot's slot with a synthetic player slot (random token, no socket). `pruneFinished` cleans the registry.
|
||||
- **`packages/server/src/state.ts`** — `Game` gains optional `aiOpponent?: { color; brain }` (informational) and required `lastBroadcastIdx: { w: number; b: number }` (per-color watermark for slice broadcasting).
|
||||
- **`packages/server/src/ws.ts`** — refactored: `pokeBot(game)` helper called after every state-mutating handler; `broadcastSinceLast(game)` replaces the old `broadcastNewAnnouncements` (slices `game.announcements` from each color's watermark). Handlers are async; router uses `void` casts to discard handler Promises.
|
||||
- **`packages/server/src/server.ts`** — `POST /api/games` handles `vsAi: { brain: 'casual' }`: instantiates `CasualBrain` + `BotDriver`, attaches to registry. `vsAi.brain === 'recon'` returns 503 (Phase 2 not implemented). `joinUrl: null` for AI games.
|
||||
- **`packages/shared/src/protocol.ts`** — `CreateGameRequest.vsAi`, `CreateGameResponse.joinUrl: string | null`, `aiOpponent` on `joined` and `update` server messages.
|
||||
- **`packages/server/src/validation.ts`** — Zod schema for `vsAi`.
|
||||
- **Client (`packages/client/`)** — landing page split into two sections (friend / vs computer). In-game UI shows a "Casual bot" badge in the topbar; turn label says "Casual bot is moving…" when bot's turn. The "Opponent disconnected" banner is suppressed for AI games.
|
||||
- **`scripts/selfplay.ts`** — operator CLI. `pnpm selfplay --white casual --black random --games 100 --mode vanilla`. Reports W/B/D/MaxPly/Err and end-reason histogram. Supports `--transcripts` for per-game logs.
|
||||
|
||||
## Phase 1 Acceptance — Met
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| 100 Casual self-play vanilla games complete | ✅ Err=0 across all runs |
|
||||
| Median ply 20-200 in self-play | ✅ avgPly~52 (engine vs random), ~116 (Casual vs Casual) |
|
||||
| Casual ≥80% vs Random, both colors | ✅ 97% as W, 96% as B |
|
||||
| All unit + integration tests pass | ✅ 75/75 (21 shared + 54 server) |
|
||||
| Live smoke checklist | ✅ /api/health, AI game creation, recon→503, no journald errors |
|
||||
| Branch merged + deployed | ⏳ Pending merge (final step of this session) |
|
||||
|
||||
## Critical Files
|
||||
|
||||
| File | Status | Notes |
|
||||
|---|---|---|
|
||||
| `docs/superpowers/specs/2026-04-28-ai-player-design.md` | Unchanged | Original spec; still the source of truth for Phase 2. |
|
||||
| `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md` | Unchanged | Phase 1 plan; can be archived or marked "executed" if useful. |
|
||||
| `CLAUDE.md` | ✅ Updated | "Current State" reflects Phase 1 deployed; "Key files" lists new bot module. |
|
||||
| `DECISIONS.md` | ✅ Updated | New "Phase 1 implementation outcomes" section; the previous "Stockfish deferred" entry is now strikethrough (partial supersede — using `js-chess-engine` instead). |
|
||||
| `packages/server/src/bot/` | ✅ New | Brain, BotDriver, CasualBrain, candidates, index. |
|
||||
| `packages/server/src/game-end.ts` | ✅ New | Extracted endGame/finalizeIfEnded. |
|
||||
| `scripts/selfplay.ts` | ✅ New | Self-play harness. Run via `pnpm selfplay`. |
|
||||
| `.secrets.baseline` | ✅ Refreshed | The previous baseline was stale (~6087 lines → 8196 after refresh). pnpm-lock.yaml integrity hashes for js-chess-engine were tripping the secret-detection hook. |
|
||||
|
||||
## Decisions Made (highlights — full list in DECISIONS.md)
|
||||
|
||||
- **CasualBrain reversal**: vanilla mode now delegates to `js-chess-engine` at level 2. Hand-rolled scorer lost to random — empirically broken. Engine swap brought it to 96-97% vs random.
|
||||
- **`BrainInput.fen` is vanilla-only**: blind mode omits the FEN to preserve the view-filter invariant. The engine cannot smuggle opponent positions past the security boundary.
|
||||
- **Blind mode keeps the heuristic**: a chess engine isn't useful when the bot only sees its own pieces. That gap is what Phase 2 (Recon) addresses with belief-state-from-announcements.
|
||||
- **Bot-slot tokens are randomized**: not a fixed placeholder. Closes a hijack vector caught in code review.
|
||||
- **`endGame`/`finalizeIfEnded` extracted to `game-end.ts`**: both ws and driver need to set finished state; duplication risk eliminated.
|
||||
- **`pokeBot → broadcastSinceLast` order is load-bearing**: the bot's response (move + announcements) must be in `game.announcements` before broadcasting, so the human sees the bot's reply in the same WS message they receive after their own move.
|
||||
|
||||
## Immediate Next Steps
|
||||
|
||||
1. **Merge `feat/ai-player-phase-1-casual` to `main`** (final step of this handoff).
|
||||
|
||||
```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.
|
||||
- User feedback on Casual's strength (too weak / too strong / fine).
|
||||
|
||||
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.
|
||||
|
||||
## Blockers / Open Questions
|
||||
|
||||
- **Casual at level 2 may be too strong for some users.** Beats random 96-97% which is the intended acceptance bar, but a careful human is supposed to win against Casual. If users report Casual is unbeatable, drop to level 1. If users report it's trivial, raise to level 3. (`packages/server/src/bot/casual-brain.ts:33` — change the default in `CasualOpts`.)
|
||||
- **Blind mode self-play games are very short** (avgPly=16, all resignations). The heuristic exhausts its retry cap (5) when the bot picks a move that can't legally proceed in blind mode. This is functional but observation: blind Casual is much weaker than vanilla Casual. Consider raising retry cap or improving heuristic if blind Casual feels broken in real play.
|
||||
- **`js-chess-engine` declares `engines: { node: '>=24' }`** but works on Node 22.22.2. Engines is advisory by default. If a future Node update breaks it, pin to v1.x of the package (`npm i js-chess-engine@^1.0.0`) — older API but compatible.
|
||||
|
||||
## Deferred Items (Phase 2 work)
|
||||
|
||||
All from the original AI spec, untouched:
|
||||
- `ReconBrain` (gemma4:26b chat agent on steel141 RTX 3090 Ti, pve197 V100 fallback).
|
||||
- Mid-game GPU failover, preflight, AI-unavailable end state.
|
||||
- Persistent chat history per game; post-game reasoning reveal UI.
|
||||
- `aiInfo` protocol field (model + GPU + host).
|
||||
- Acceptance bar: Recon wins ≥60% over 50 Recon-vs-Casual self-play games.
|
||||
|
||||
## Important Context for Future Sessions
|
||||
|
||||
- **The bot's `BoardView` is the only egress to the engine, in vanilla mode.** This invariant is preserved structurally: the FEN is set in `BrainInput` only when `mode === 'vanilla'`. Phase 2 ReconBrain will not need this field at all (it gets the view + announcements only — same input shape as a human player who can't see the FEN of the actual game).
|
||||
- **`Casual` and `Recon` brains are both architecturally instances of `Brain`.** Phase 2 just adds another `Brain` implementation against the same `BotDriver`. The driver's mutex / retry / dispatch / dispose lifecycle does NOT need changes.
|
||||
- **Watermark advance only on successful dispatch** (in `BotDriver.runDecisionCycle`). On retry, the brain still sees the FSM's rejection announcement in `newAnnouncements`. This matters for ReconBrain (Phase 2) which uses announcements as evidence; CasualBrain ignores them.
|
||||
- **`scripts/selfplay.ts` is the canonical evaluation tool**. Phase 2 will extend it to support `--white recon --black casual` etc. The harness sets `game.aiOpponent = undefined; game.status = 'active'` after `createGame` returns — that's how it transitions out of "waiting" without a hello.
|
||||
- **The pre-commit hook is `detect-secrets-hook --baseline .secrets.baseline`** in `/home/claude/.config/git/hooks/pre-commit`. If you add a new dep and pnpm-lock.yaml hashes get flagged, run `detect-secrets scan > .secrets.baseline` to refresh.
|
||||
|
||||
## Files Modified / Added This Session
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| (new) `packages/server/src/bot/{brain,candidates,casual-brain,driver,index}.ts` | The bot module (~600 LoC). |
|
||||
| (new) `packages/server/src/game-end.ts` | Extracted from ws.ts. |
|
||||
| (new) `packages/server/test/unit/bot/{candidates,casual-brain,driver}.test.ts` | 27 unit tests. |
|
||||
| (new) `packages/server/test/integration/ai-game-casual.test.ts` | 5 integration tests. |
|
||||
| (new) `scripts/selfplay.ts` | Operator CLI. |
|
||||
| (new) `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md` | The plan. |
|
||||
| `packages/server/src/state.ts`, `games.ts`, `validation.ts`, `server.ts`, `ws.ts` | Wired up. |
|
||||
| `packages/shared/src/protocol.ts` | Added `vsAi`, `aiOpponent`, nullable `joinUrl`. |
|
||||
| `packages/client/src/lib/Landing.svelte`, `Game.svelte`, `stores/game.svelte.ts` | UI. |
|
||||
| `package.json`, `pnpm-lock.yaml`, `packages/server/package.json` | Added `js-chess-engine`, `tsx`. |
|
||||
| `CLAUDE.md`, `DECISIONS.md` | Context updates. |
|
||||
| `.secrets.baseline` | Refreshed. |
|
||||
|
||||
## Environment State
|
||||
|
||||
- **CT 690 / blind-chess.service:** running. `systemctl is-active` returns `active`. Uptime measured from the deploy-restart at 2026-04-28 ~19:14 UTC.
|
||||
- **Active processes:** none session-relevant. The deploy was a normal restart of the systemd unit.
|
||||
- **Environment variables:** none added/changed.
|
||||
- **Secrets:** none added; `.secrets.baseline` was refreshed to a clean state (the old one had ~4500 lines of stale per-file entries).
|
||||
|
||||
## Related Resources
|
||||
|
||||
- Live URL: https://chess.sethpc.xyz — Phase 1 live.
|
||||
- Repo: https://git.sethpc.xyz/Seth/blind_chess — `feat/ai-player-phase-1-casual` branch (pending merge to main).
|
||||
- Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`.
|
||||
- Plan: `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md`.
|
||||
- Decisions: `DECISIONS.md` "AI / computer player" section + new "Phase 1 implementation outcomes" subsection.
|
||||
- Project identity: `CLAUDE.md`.
|
||||
- Prior handoffs: `2026-04-28-170713-ai-player-spec.md`, `2026-04-28-152000-mvp-deployed.md`, `2026-04-28-104344-spec-approved-ready-for-plan.md`, `2026-04-28-kickoff.md`.
|
||||
|
||||
---
|
||||
|
||||
**Security Reminder**: This handoff describes Phase 1 deployment; no credentials, secrets, or sensitive endpoints are exposed in the handoff or the deployed code. The bot uses no external services in Phase 1 (Phase 2 will add Ollama endpoints).
|
||||
+299
-4331
File diff suppressed because it is too large
Load Diff
@@ -18,13 +18,13 @@ The system's most distinctive property: highlighting in blind mode reveals **zer
|
||||
|
||||
## Current State
|
||||
|
||||
- **Phase:** MVP **deployed and live** at https://chess.sethpc.xyz (2026-04-28). **AI/computer player feature spec written and approved** (2026-04-28); implementation pending.
|
||||
- **Phase:** MVP **deployed and live** at https://chess.sethpc.xyz (2026-04-28). **AI Phase 1 (Casual bot) deployed** (2026-04-28) — "Play vs computer" → Casual bot.
|
||||
- **Repo:** `git.sethpc.xyz/Seth/blind_chess`.
|
||||
- **Stack:** Node 22 + TypeScript, Fastify + `ws`, Svelte 5 + Vite, `chess.js`. pnpm workspace with `packages/{server,client,shared}`.
|
||||
- **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.
|
||||
- **Tests:** 43 passing — 21 in shared (geometric helper), 22 in server (FSM + view + 4 real-WS integration).
|
||||
- **Tests:** 75 passing — 21 in shared (geometric helper), 54 in server (FSM + view + candidates + casual brain + driver + scripted-game + ai-game-casual integration).
|
||||
- **Known gaps (deferred):** drag-and-drop input (click-to-move only), full integration coverage of every endgame path, mobile-specific polish, observability beyond `/api/health`.
|
||||
- **AI player (designed, not built):** Two-phase plan in `docs/superpowers/specs/2026-04-28-ai-player-design.md`. Phase 1 = Casual bot (algorithmic, ~200 LoC). Phase 2 = gemma4 recon bot (`gemma4:26b` chat agent on steel141 RTX 3090 Ti primary, pve197 V100 fallback). Bots play through the same view filter and FSM as humans — no oracle access.
|
||||
- **AI Phase 2 (gemma4 recon, not built):** Spec in `docs/superpowers/specs/2026-04-28-ai-player-design.md`. Will reuse the Phase 1 `Brain`/`BotDriver` infrastructure. Plan to be written when Phase 1 has soaked. Bots play through the same view filter and FSM as humans — no oracle access.
|
||||
|
||||
## Key files
|
||||
|
||||
@@ -36,6 +36,9 @@ The system's most distinctive property: highlighting in blind mode reveals **zer
|
||||
- `packages/server/src/view.ts` — `buildView`, the security boundary.
|
||||
- `packages/server/src/commit.ts` — touch-move FSM (the spec's hierarchy decision table).
|
||||
- `packages/server/src/translator.ts` — chess.js `Move` → moderator-vocabulary enum.
|
||||
- `packages/server/src/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.
|
||||
- `scripts/selfplay.ts` — operator CLI for evaluating Casual vs Casual / Random self-play. `pnpm selfplay --help`.
|
||||
- `deploy/blind-chess.service` — systemd unit (canonical at `/etc/systemd/system/blind-chess.service` on the CT).
|
||||
- `deploy/Caddyfile.snippet` — block already added to `/etc/caddy/Caddyfile` on CT 600.
|
||||
|
||||
|
||||
+12
-3
@@ -50,9 +50,9 @@ Format: `YYYY-MM-DD: <decision> — <why>`
|
||||
- 2026-04-28: **WS path through Caddy** — `wss://chess.sethpc.xyz/ws?game=<id>` works without explicit `transport ws` config. Caddy's reverse_proxy handles upgrade transparently.
|
||||
- 2026-04-28: **Public DNS** — relies on existing `*.sethpc.xyz` wildcard pointing at the WAN IP; no Pi-hole entry was needed. Caddy host-routes `chess.sethpc.xyz` to 192.168.0.245:3000.
|
||||
|
||||
## AI / computer player (designed 2026-04-28, not yet implemented)
|
||||
## AI / computer player
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`. All decisions below are settled at spec-approval time; revisit if implementation surfaces something the spec didn't anticipate.
|
||||
Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`. **Phase 1 (Casual bot) deployed 2026-04-28** — live at https://chess.sethpc.xyz "Play vs computer". Phase 2 (Recon) deferred until Phase 1 has soaked.
|
||||
|
||||
- 2026-04-28: **Two AI bots, phased delivery** — `CasualBrain` (Phase 1, algorithmic, in-process) ships first; `ReconBrain` (Phase 2, `gemma4:26b` chat agent) ships second. Phased to keep research uncertainty (Recon's actual playing strength) from blocking shipping anything. Rejected: combined launch, single difficulty-dial UX, throwaway Casual-as-stub.
|
||||
- 2026-04-28: **Bots use the same view filter as humans** — `BotDriver` calls `buildView(game, botColor)`; bot input is filtered `BoardView` + `Announcement[]`. No oracle access. Preserves the architectural invariant: the view filter is the only egress for board state, even for in-process bots. Rejected: "easy mode" oracle access for Casual to keep it simple.
|
||||
@@ -66,6 +66,15 @@ Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`. All decisions bel
|
||||
- 2026-04-28: **Reasoning hidden during play, revealed post-game** — Gemma's chat history is private during the game; on game end, the chat history is copied to `Game.aiThoughtsLog` and the post-game screen shows a collapsible "View gemma4's reasoning" section. Rejected: live streaming "thinking tokens" to user (leaks strategy), permanent hiding (loses showcase value of the project).
|
||||
- 2026-04-28: **`vsAi` field added to `CreateGameRequest`; `aiInfo` field added to `joined`/`update` server messages; `'ai_unavailable'` added to `EndReason`** — minimal protocol surface for the feature. AI metadata is NOT in `ModeratorText` enum (kept clean). UI-system messages for game-start info and failover events are style-distinct from `Announcement` entries.
|
||||
|
||||
### Phase 1 implementation outcomes (2026-04-28)
|
||||
|
||||
- 2026-04-28: **Phase 1 shipped to https://chess.sethpc.xyz.** 13 implementation tasks executed via subagent-driven development against `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md`. 75 tests passing (21 shared + 54 server). Live smoke checklist passed.
|
||||
- 2026-04-28: **CasualBrain reversal — vanilla mode now uses `js-chess-engine` (level 2, randomness=30), not the hand-rolled scorer.** The original heuristic lost to a random-move baseline 7-7 in 100-game self-play (target was ≥80%). After swap-in: Casual wins 97% as white and 96% as black vs Random, ~5-30ms/move. Supersedes the spec's "no Stockfish" decision in spirit — `js-chess-engine` is MIT-licensed, ~400KB, no native deps, and at level 2 plays "Casual" strength (beats random comfortably, loses to a careful human). Originally rejected "Stockfish for strong vanilla AI" was about *strength*, not about *using a pre-made engine*. Documented and pushed; accepted as a learning.
|
||||
- 2026-04-28: **Bot's BoardView is the only egress to the engine.** `BrainInput.fen` is set ONLY in vanilla mode (where the view is full reveal); blind mode omits it. Engine cannot smuggle opponent positions past the view filter — same architectural invariant the brainstorming session established for human-played blind chess.
|
||||
- 2026-04-28: **Blind mode keeps the heuristic (not engine).** Architecturally Stockfish/js-chess-engine can't usefully play blind chess — they need a full board to evaluate, and giving them one would be oracle access. Building a belief-state from announcements is the Recon bot's design (Phase 2). Self-play confirmed blind heuristic completes games (avgPly=16, 0 errors, all decisive) — short games but functional.
|
||||
- 2026-04-28: **Bot-slot synthetic token is randomized, not a fixed placeholder.** Using a hard-coded placeholder ("botxxxxxxxxxxxxxxxxxxxxx") would let any client knowing it claim the bot's color via `hello`. Random tokens (same shape as human tokens) close that hole. Caught in code review of Task 7.
|
||||
- 2026-04-28: **`endGame` and `finalizeIfEnded` extracted from `ws.ts` to `packages/server/src/game-end.ts`.** Both `ws.ts` and `bot/driver.ts` need to set the game-finished state — duplication risk. Hoist resolves it.
|
||||
|
||||
## Deferred / Rejected
|
||||
|
||||
<!-- Decisions NOT to do something are just as valuable -- prevents re-proposing rejected ideas -->
|
||||
@@ -83,7 +92,7 @@ Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`. All decisions bel
|
||||
- 2026-04-28: **Pre-deploy "server restarting" warning to active players** — stretch goal, not MVP. Mitigation for now: deploy during low-usage windows.
|
||||
- 2026-04-28: ~~**Client-side AI / hint generation** — explicitly out of scope. Human vs. human only.~~ **Partially superseded 2026-04-28** by AI-player spec. Reversal applies *only* to the human-vs-AI path; client-side AI / hint generation in human-vs-human games remains rejected.
|
||||
- 2026-04-28: **Difficulty slider for AI** — rejected. Two named buttons (Casual, Recon) only. No continuum; the two bots are architecturally different, not tuneable strengths of the same engine.
|
||||
- 2026-04-28: **Stockfish for vanilla-mode AI strength** — deferred. Vanilla is a side-effect, not a feature target. Revisit if users explicitly ask for strong vanilla AI.
|
||||
- 2026-04-28: ~~**Stockfish for vanilla-mode AI strength** — deferred. Vanilla is a side-effect, not a feature target. Revisit if users explicitly ask for strong vanilla AI.~~ **Partially superseded 2026-04-28** during Phase 1 implementation — using `js-chess-engine` (smaller, MIT, no GPL concerns) at level 2 for Casual vanilla, capped at ~30ms/move. The original rejection was about not making Casual *strong*; the engine at level 2 is genuinely casual-strength while still beating random comfortably. Stockfish itself remains rejected (GPL, 7MB+ wasm, overkill for the strength target).
|
||||
- 2026-04-28: **Live token streaming during Gemma's thinking** — rejected for MVP. Static "AI is thinking..." indicator only. Streaming would leak strategic intent and adds protocol complexity.
|
||||
- 2026-04-28: **Mid-game GPU flap-back** — rejected. Once failed over to V100, stays there for the rest of the game even if steel141 recovers. Simpler, more predictable, and chat-history is mid-flight.
|
||||
- 2026-04-28: **AI vs AI public spectate-able games** — rejected for MVP. Self-play harness is CLI-only (`scripts/selfplay.ts`).
|
||||
|
||||
+3
-1
@@ -12,9 +12,11 @@
|
||||
"test": "pnpm -r test",
|
||||
"dev:server": "pnpm --filter @blind-chess/server dev",
|
||||
"dev:client": "pnpm --filter @blind-chess/client dev",
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"selfplay": "tsx scripts/selfplay.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
|
||||
@@ -61,6 +61,17 @@
|
||||
setTimeout(() => copied = false, 1500);
|
||||
}
|
||||
|
||||
const isBotTurn = $derived(
|
||||
!!game.state.aiOpponent
|
||||
&& game.state.gameStatus === 'active'
|
||||
&& game.state.view?.toMove === game.state.aiOpponent.color,
|
||||
);
|
||||
|
||||
const aiBadgeText = $derived.by(() => {
|
||||
if (!game.state.aiOpponent) return null;
|
||||
return game.state.aiOpponent.brain === 'casual' ? 'Casual bot' : 'gemma4 recon';
|
||||
});
|
||||
|
||||
const turnLabel = $derived.by(() => {
|
||||
if (game.state.gameStatus === 'finished') {
|
||||
const reason = game.state.endReason;
|
||||
@@ -74,6 +85,10 @@
|
||||
if (game.state.gameStatus === 'waiting') return 'Waiting for opponent…';
|
||||
if (!game.state.you) return '…';
|
||||
if (game.state.view?.toMove === game.state.you) return 'Your turn';
|
||||
if (game.state.aiOpponent) {
|
||||
const name = game.state.aiOpponent.brain === 'casual' ? 'Casual bot' : 'gemma4 recon';
|
||||
return `${name} is moving…`;
|
||||
}
|
||||
return 'Opponent thinking';
|
||||
});
|
||||
</script>
|
||||
@@ -89,6 +104,11 @@
|
||||
· You: {game.state.you === 'w' ? 'White' : 'Black'}
|
||||
{/if}
|
||||
</span>
|
||||
{#if aiBadgeText}
|
||||
<span class="ai-badge" class:thinking={isBotTurn}>
|
||||
{aiBadgeText}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if game.state.gameStatus === 'waiting'}
|
||||
@@ -141,7 +161,7 @@
|
||||
<div class="banner err">⚠ {game.state.lastError.code}: {game.state.lastError.message}</div>
|
||||
{/if}
|
||||
|
||||
{#if !game.state.opponentConnected && game.state.gameStatus === 'active'}
|
||||
{#if !game.state.opponentConnected && game.state.gameStatus === 'active' && !game.state.aiOpponent}
|
||||
<div class="banner muted">Opponent disconnected — 5 minute grace window.</div>
|
||||
{/if}
|
||||
</aside>
|
||||
@@ -221,6 +241,22 @@
|
||||
.banner .row { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.banner .row button { flex: 1; }
|
||||
|
||||
.ai-badge {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--panel);
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-badge.thinking {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: rgba(211, 84, 0, 0.07);
|
||||
}
|
||||
|
||||
.waiting-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -1,30 +1,60 @@
|
||||
<script lang="ts">
|
||||
import type { Mode, Color, CreateGameResponse } from '@blind-chess/shared';
|
||||
|
||||
let mode: Mode = $state('blind');
|
||||
let side: Color | 'random' = $state('random');
|
||||
let highlightingEnabled = $state(false);
|
||||
let creating = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
// Friend section state.
|
||||
let friendMode: Mode = $state('blind');
|
||||
let friendSide: Color | 'random' = $state('random');
|
||||
let friendHighlight = $state(false);
|
||||
let friendCreating = $state(false);
|
||||
let friendError: string | null = $state(null);
|
||||
|
||||
async function create() {
|
||||
creating = true;
|
||||
error = null;
|
||||
// AI section state (separate so user can configure each independently).
|
||||
let aiMode: Mode = $state('blind');
|
||||
let aiSide: Color | 'random' = $state('random');
|
||||
let aiHighlight = $state(false);
|
||||
let aiCreating = $state(false);
|
||||
let aiError: string | null = $state(null);
|
||||
|
||||
async function createWithFriend() {
|
||||
friendCreating = true; friendError = null;
|
||||
try {
|
||||
const res = await fetch('/api/games', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ mode, side, highlightingEnabled }),
|
||||
body: JSON.stringify({
|
||||
mode: friendMode, side: friendSide, highlightingEnabled: friendHighlight,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json: CreateGameResponse & { creatorColor: Color } = await res.json();
|
||||
// store creator token before navigating
|
||||
localStorage.setItem(`bc:${json.gameId}`, json.creatorToken);
|
||||
location.hash = `#/g/${json.gameId}`;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
friendError = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
creating = false;
|
||||
friendCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createVsCasual() {
|
||||
aiCreating = true; aiError = null;
|
||||
try {
|
||||
const res = await fetch('/api/games', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mode: aiMode, side: aiSide, highlightingEnabled: aiHighlight,
|
||||
vsAi: { brain: 'casual' },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json: CreateGameResponse & { creatorColor: Color } = await res.json();
|
||||
localStorage.setItem(`bc:${json.gameId}`, json.creatorToken);
|
||||
location.hash = `#/g/${json.gameId}`;
|
||||
} catch (e) {
|
||||
aiError = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
aiCreating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -36,18 +66,19 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Create a game</h2>
|
||||
<h2>Play with a friend</h2>
|
||||
<p class="card-sub muted">Get a shareable link, send it to someone, play together.</p>
|
||||
|
||||
<div class="field">
|
||||
<span class="lbl">Mode</span>
|
||||
<div class="opts">
|
||||
<label class="opt" class:active={mode === 'blind'}>
|
||||
<input type="radio" bind:group={mode} value="blind" />
|
||||
<label class="opt" class:active={friendMode === 'blind'}>
|
||||
<input type="radio" bind:group={friendMode} value="blind" />
|
||||
<span class="opt-title">Blind</span>
|
||||
<span class="opt-sub">Each player sees only their own pieces.</span>
|
||||
</label>
|
||||
<label class="opt" class:active={mode === 'vanilla'}>
|
||||
<input type="radio" bind:group={mode} value="vanilla" />
|
||||
<label class="opt" class:active={friendMode === 'vanilla'}>
|
||||
<input type="radio" bind:group={friendMode} value="vanilla" />
|
||||
<span class="opt-title">Vanilla</span>
|
||||
<span class="opt-sub">Normal chess. Both players see everything.</span>
|
||||
</label>
|
||||
@@ -57,29 +88,79 @@
|
||||
<div class="field">
|
||||
<span class="lbl">You play as</span>
|
||||
<div class="row">
|
||||
<label><input type="radio" bind:group={side} value="w" /> White</label>
|
||||
<label><input type="radio" bind:group={side} value="b" /> Black</label>
|
||||
<label><input type="radio" bind:group={side} value="random" /> Random</label>
|
||||
<label><input type="radio" bind:group={friendSide} value="w" /> White</label>
|
||||
<label><input type="radio" bind:group={friendSide} value="b" /> Black</label>
|
||||
<label><input type="radio" bind:group={friendSide} value="random" /> Random</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" bind:checked={highlightingEnabled} />
|
||||
<input type="checkbox" bind:checked={friendHighlight} />
|
||||
<span>Highlight reachable squares</span>
|
||||
{#if mode === 'blind'}
|
||||
{#if friendMode === 'blind'}
|
||||
<span class="hint muted">(geometric only — no opponent info)</span>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="primary big" disabled={creating} onclick={create}>
|
||||
{creating ? 'Creating…' : 'Create game'}
|
||||
<button class="primary big" disabled={friendCreating} onclick={createWithFriend}>
|
||||
{friendCreating ? 'Creating…' : 'Create game'}
|
||||
</button>
|
||||
{#if friendError}<p class="error">Error: {friendError}</p>{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error">Error: {error}</p>
|
||||
{/if}
|
||||
<div class="card">
|
||||
<h2>Play vs computer</h2>
|
||||
<p class="card-sub muted">Always-available opponent. No link to share — game starts immediately.</p>
|
||||
|
||||
<div class="field">
|
||||
<span class="lbl">Mode</span>
|
||||
<div class="opts">
|
||||
<label class="opt" class:active={aiMode === 'blind'}>
|
||||
<input type="radio" bind:group={aiMode} value="blind" />
|
||||
<span class="opt-title">Blind</span>
|
||||
<span class="opt-sub">Each player sees only their own pieces.</span>
|
||||
</label>
|
||||
<label class="opt" class:active={aiMode === 'vanilla'}>
|
||||
<input type="radio" bind:group={aiMode} value="vanilla" />
|
||||
<span class="opt-title">Vanilla</span>
|
||||
<span class="opt-sub">Normal chess. Both players see everything.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="lbl">You play as</span>
|
||||
<div class="row">
|
||||
<label><input type="radio" bind:group={aiSide} value="w" /> White</label>
|
||||
<label><input type="radio" bind:group={aiSide} value="b" /> Black</label>
|
||||
<label><input type="radio" bind:group={aiSide} value="random" /> Random</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" bind:checked={aiHighlight} />
|
||||
<span>Highlight reachable squares</span>
|
||||
{#if aiMode === 'blind'}
|
||||
<span class="hint muted">(geometric only — no opponent info)</span>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="ai-buttons">
|
||||
<button class="primary" disabled={aiCreating} onclick={createVsCasual}>
|
||||
{aiCreating ? 'Creating…' : 'Casual bot'}
|
||||
</button>
|
||||
<button class="secondary" disabled title="Coming soon">
|
||||
gemma4 recon (coming soon)
|
||||
</button>
|
||||
</div>
|
||||
<p class="card-sub muted small">
|
||||
Casual: fast, plays simple moves, makes mistakes. Good for a quick game.
|
||||
</p>
|
||||
{#if aiError}<p class="error">Error: {aiError}</p>{/if}
|
||||
</div>
|
||||
|
||||
<footer class="muted">
|
||||
@@ -114,8 +195,11 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 22px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h2 { font-size: 18px; margin: 0 0 16px; }
|
||||
.card-sub { font-size: 13px; margin: -10px 0 16px; }
|
||||
.card-sub.small { margin-top: 12px; font-size: 12px; }
|
||||
h2 { font-size: 18px; margin: 0 0 8px; }
|
||||
|
||||
.field { margin-bottom: 20px; }
|
||||
.lbl {
|
||||
@@ -157,6 +241,15 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ai-buttons { display: grid; gap: 8px; grid-template-columns: 1fr 1fr; }
|
||||
@media (max-width: 480px) { .ai-buttons { grid-template-columns: 1fr; } }
|
||||
.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.secondary:disabled { cursor: not-allowed; opacity: 0.6; }
|
||||
|
||||
.error { color: #f87171; margin-top: 12px; }
|
||||
footer { text-align: center; margin-top: 24px; font-size: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -28,6 +28,7 @@ interface GameStateValue {
|
||||
winner: Color | null;
|
||||
opponentConnected: boolean;
|
||||
lastError: { code: ErrorCode; message: string; at: number } | null;
|
||||
aiOpponent: { color: Color; brain: 'casual' | 'recon' } | null;
|
||||
}
|
||||
|
||||
function makeStore() {
|
||||
@@ -47,6 +48,7 @@ function makeStore() {
|
||||
winner: null,
|
||||
opponentConnected: false,
|
||||
lastError: null,
|
||||
aiOpponent: null,
|
||||
});
|
||||
|
||||
function tokenKey(gameId: string) { return `bc:${gameId}`; }
|
||||
@@ -91,6 +93,7 @@ function makeStore() {
|
||||
state.mode = m.mode;
|
||||
state.highlightingEnabled = m.highlightingEnabled;
|
||||
state.opponentConnected = m.opponentConnected;
|
||||
state.aiOpponent = m.aiOpponent ?? null;
|
||||
if (state.gameId) localStorage.setItem(tokenKey(state.gameId), m.token);
|
||||
break;
|
||||
case 'update':
|
||||
@@ -103,6 +106,7 @@ function makeStore() {
|
||||
if (m.newAnnouncements.length) {
|
||||
state.announcements = [...state.announcements, ...m.newAnnouncements];
|
||||
}
|
||||
if (m.aiOpponent !== undefined) state.aiOpponent = m.aiOpponent;
|
||||
break;
|
||||
case 'peer-status':
|
||||
if (state.you && m.color !== state.you) {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@fastify/websocket": "^11.0.0",
|
||||
"chess.js": "^1.4.0",
|
||||
"fastify": "^5.2.0",
|
||||
"js-chess-engine": "^2.4.6",
|
||||
"pino": "^9.5.0",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.24.0"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
Announcement,
|
||||
BoardView,
|
||||
Color,
|
||||
PromotionType,
|
||||
Square,
|
||||
} from '@blind-chess/shared';
|
||||
import type { ModeratorText } from '@blind-chess/shared';
|
||||
|
||||
export interface CandidateMove {
|
||||
from: Square;
|
||||
to: Square;
|
||||
promotion?: PromotionType;
|
||||
}
|
||||
|
||||
export interface AttemptHistoryEntry {
|
||||
move: CandidateMove;
|
||||
rejection: ModeratorText;
|
||||
}
|
||||
|
||||
export interface BrainInput {
|
||||
view: BoardView;
|
||||
newAnnouncements: Announcement[];
|
||||
legalCandidates: CandidateMove[];
|
||||
attemptHistory: AttemptHistoryEntry[];
|
||||
drawOfferFromOpponent: boolean;
|
||||
ply: number;
|
||||
/**
|
||||
* Full position FEN. Set only in vanilla mode where `view` is already a
|
||||
* full reveal — omitted in blind mode, since passing the FEN there would
|
||||
* leak opponent positions and violate the view-filter invariant. Brains
|
||||
* with an internal chess engine rely on this; brains that don't can
|
||||
* ignore it.
|
||||
*/
|
||||
fen?: string;
|
||||
}
|
||||
|
||||
export type BrainAction =
|
||||
| { type: 'commit'; from: Square; to: Square; promotion?: PromotionType }
|
||||
| { type: 'resign' }
|
||||
| { type: 'offer-draw' }
|
||||
| { type: 'respond-draw'; accept: boolean };
|
||||
|
||||
export interface BrainInitArgs {
|
||||
color: Color;
|
||||
mode: 'blind' | 'vanilla';
|
||||
gameId: string;
|
||||
}
|
||||
|
||||
export interface Brain {
|
||||
init(args: BrainInitArgs): Promise<void>;
|
||||
decide(input: BrainInput): Promise<BrainAction>;
|
||||
dispose?(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
geometricMoves,
|
||||
type Color,
|
||||
type Piece,
|
||||
type PieceType,
|
||||
type PromotionType,
|
||||
type Square,
|
||||
} from '@blind-chess/shared';
|
||||
import type { Game } from '../state.js';
|
||||
import { ownSquares } from '../view.js';
|
||||
import type { CandidateMove } from './brain.js';
|
||||
|
||||
const PROMOTION_TYPES: PromotionType[] = ['q', 'r', 'b', 'n'];
|
||||
|
||||
export function legalCandidates(game: Game, color: Color): CandidateMove[] {
|
||||
if (game.mode === 'vanilla') return vanillaCandidates(game, color);
|
||||
return blindCandidates(game, color);
|
||||
}
|
||||
|
||||
function vanillaCandidates(game: Game, color: Color): CandidateMove[] {
|
||||
// chess.js only returns moves for the side to move via `.moves()`. To get a
|
||||
// hypothetical move list for the other color we'd need to rotate — but the
|
||||
// bot driver only invokes legalCandidates when it's the bot's turn, so this
|
||||
// is fine in practice. Tests for "wrong color" use blind mode.
|
||||
if (game.chess.turn() !== color) return [];
|
||||
|
||||
const moves = game.chess.moves({ verbose: true }) as Array<{
|
||||
from: Square; to: Square; promotion?: PromotionType;
|
||||
}>;
|
||||
const out: CandidateMove[] = [];
|
||||
for (const m of moves) {
|
||||
out.push({ from: m.from, to: m.to, promotion: m.promotion });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function blindCandidates(game: Game, color: Color): CandidateMove[] {
|
||||
const own = ownSquares(game, color);
|
||||
const board = game.chess.board();
|
||||
const out: CandidateMove[] = [];
|
||||
|
||||
for (const row of board) {
|
||||
if (!row) continue;
|
||||
for (const cell of row) {
|
||||
if (!cell) continue;
|
||||
if (cell.color !== color) continue;
|
||||
const piece: Piece = { color: cell.color, type: cell.type as PieceType };
|
||||
const from = cell.square as Square;
|
||||
const tos = geometricMoves(piece, from, own);
|
||||
for (const to of tos) {
|
||||
if (isPromotionSquare(piece, to)) {
|
||||
for (const promo of PROMOTION_TYPES) {
|
||||
out.push({ from, to, promotion: promo });
|
||||
}
|
||||
} else {
|
||||
out.push({ from, to });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isPromotionSquare(piece: Piece, to: Square): boolean {
|
||||
if (piece.type !== 'p') return false;
|
||||
const rank = to[1];
|
||||
return (piece.color === 'w' && rank === '8') || (piece.color === 'b' && rank === '1');
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Game as EngineGame } from 'js-chess-engine';
|
||||
import type { BoardView, Color, PieceType, PromotionType, Square } from '@blind-chess/shared';
|
||||
import type {
|
||||
Brain,
|
||||
BrainAction,
|
||||
BrainInitArgs,
|
||||
BrainInput,
|
||||
CandidateMove,
|
||||
} from './brain.js';
|
||||
|
||||
interface CasualOpts {
|
||||
seed?: number;
|
||||
/**
|
||||
* Engine difficulty for vanilla mode (1-5; 1 is weakest).
|
||||
* `js-chess-engine` level 1 plays at roughly beginner strength —
|
||||
* crushes random moves but loses to a careful human. Higher levels
|
||||
* raise both strength and per-move latency.
|
||||
*/
|
||||
level?: 1 | 2 | 3 | 4 | 5;
|
||||
}
|
||||
|
||||
const PIECE_VALUE: Record<PieceType, number> = {
|
||||
p: 1, n: 3, b: 3, r: 5, q: 9, k: 0,
|
||||
};
|
||||
|
||||
export class CasualBrain implements Brain {
|
||||
private color: Color = 'w';
|
||||
private mode: 'blind' | 'vanilla' = 'blind';
|
||||
private level: 1 | 2 | 3 | 4 | 5;
|
||||
private rng: () => number;
|
||||
|
||||
constructor(opts: CasualOpts = {}) {
|
||||
this.rng = mulberry32(opts.seed ?? Math.floor(Math.random() * 0xffffffff));
|
||||
this.level = opts.level ?? 2;
|
||||
}
|
||||
|
||||
async init(args: BrainInitArgs): Promise<void> {
|
||||
this.color = args.color;
|
||||
this.mode = args.mode;
|
||||
}
|
||||
|
||||
async decide(input: BrainInput): Promise<BrainAction> {
|
||||
if (input.drawOfferFromOpponent) {
|
||||
return { type: 'respond-draw', accept: this.acceptDraw(input.view) };
|
||||
}
|
||||
|
||||
const filtered = this.excludeRejected(input.legalCandidates, input.attemptHistory);
|
||||
if (filtered.length === 0) {
|
||||
throw new Error('CasualBrain: zero candidates after exclusion');
|
||||
}
|
||||
|
||||
// Vanilla mode: delegate to a real chess engine. The driver supplies
|
||||
// a FEN only in vanilla mode, so this branch is naturally gated.
|
||||
if (this.mode === 'vanilla' && input.fen) {
|
||||
const engineMove = this.engineMove(input.fen, filtered);
|
||||
if (engineMove) {
|
||||
return {
|
||||
type: 'commit',
|
||||
from: engineMove.from,
|
||||
to: engineMove.to,
|
||||
promotion: engineMove.promotion,
|
||||
};
|
||||
}
|
||||
// Fall through to heuristic if the engine produced something we
|
||||
// can't validate against the candidate list.
|
||||
}
|
||||
|
||||
// Blind mode (or vanilla fallback): score-based heuristic.
|
||||
const choice = this.heuristicPick(filtered, input.view, input.ply);
|
||||
return {
|
||||
type: 'commit',
|
||||
from: choice.from,
|
||||
to: choice.to,
|
||||
promotion: choice.promotion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run js-chess-engine on the given FEN and return a candidate matching
|
||||
* its choice, or null if no match was found.
|
||||
*/
|
||||
private engineMove(fen: string, candidates: CandidateMove[]): CandidateMove | null {
|
||||
let result: { move: Record<string, string> };
|
||||
try {
|
||||
const g = new EngineGame(fen);
|
||||
// randomness=30 picks among moves within 30 centipawns of best; this
|
||||
// breaks threefold-repetition draws when the bot is clearly winning
|
||||
// but doesn't see the conversion path.
|
||||
result = g.ai({ level: this.level, play: false, randomness: 30 }) as { move: Record<string, string> };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const entry = Object.entries(result.move ?? {})[0];
|
||||
if (!entry) return null;
|
||||
const [fromUC, toUC] = entry;
|
||||
const from = (fromUC as string).toLowerCase() as Square;
|
||||
const to = (toUC as string).toLowerCase() as Square;
|
||||
// Find a candidate matching this from-to. If the move is a promotion,
|
||||
// js-chess-engine emits the destination square (e.g., {E7: 'E8'}) but
|
||||
// doesn't separately surface the promotion piece — default to queen.
|
||||
const matches = candidates.filter((c) => c.from === from && c.to === to);
|
||||
if (matches.length === 0) return null;
|
||||
const queen = matches.find((c) => c.promotion === 'q');
|
||||
if (queen) return queen;
|
||||
return matches[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score-based fallback used for blind mode and any vanilla case where
|
||||
* the engine's pick wasn't in the candidate list. Plays badly on purpose.
|
||||
*/
|
||||
private heuristicPick(
|
||||
candidates: CandidateMove[],
|
||||
view: BoardView,
|
||||
ply: number,
|
||||
): CandidateMove {
|
||||
const scored = candidates.map((c) => {
|
||||
let score = this.scoreMove(c, view, ply);
|
||||
if (c.promotion === 'q') score += 1000;
|
||||
else if (c.promotion === 'r') score += 500;
|
||||
else if (c.promotion === 'b') score += 100;
|
||||
else if (c.promotion === 'n') score += 50;
|
||||
return { move: c, score: score + this.rng() * 0.01 };
|
||||
});
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
return scored[0]!.move;
|
||||
}
|
||||
|
||||
private excludeRejected(
|
||||
candidates: CandidateMove[],
|
||||
history: BrainInput['attemptHistory'],
|
||||
): CandidateMove[] {
|
||||
if (history.length === 0) return candidates;
|
||||
const rejected = new Set(history.map((h) => moveKey(h.move)));
|
||||
return candidates.filter((c) => !rejected.has(moveKey(c)));
|
||||
}
|
||||
|
||||
private scoreMove(move: CandidateMove, view: BoardView, ply: number): number {
|
||||
let score = 0;
|
||||
const destPiece = view.pieces[move.to];
|
||||
if (!destPiece) score += 50;
|
||||
|
||||
const piece = view.pieces[move.from];
|
||||
if (!piece) return score;
|
||||
|
||||
const ownStartingRank = this.color === 'w' ? '1' : '8';
|
||||
const ownPawnStartingRank = this.color === 'w' ? '2' : '7';
|
||||
|
||||
if (ply < 16 && (piece.type === 'n' || piece.type === 'b')
|
||||
&& move.from[1] === ownStartingRank) {
|
||||
score += 30;
|
||||
}
|
||||
|
||||
if (piece.type === 'p' && move.from[1] === ownPawnStartingRank) {
|
||||
const file = move.from[0];
|
||||
if (file === 'd' || file === 'e') score += 25;
|
||||
else if (file === 'c' || file === 'f') score += 10;
|
||||
}
|
||||
|
||||
const fromRank = parseInt(move.from[1]!, 10);
|
||||
const toRank = parseInt(move.to[1]!, 10);
|
||||
const advance = this.color === 'w' ? toRank - fromRank : fromRank - toRank;
|
||||
if (advance > 0) score += 15 * advance;
|
||||
|
||||
if (move.from[1] === ownStartingRank && (piece.type === 'q' || piece.type === 'r')) {
|
||||
score -= 40;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private acceptDraw(view: BoardView): boolean {
|
||||
let own = 0;
|
||||
for (const sq of Object.keys(view.pieces) as Square[]) {
|
||||
const p = view.pieces[sq];
|
||||
if (p) own += PIECE_VALUE[p.type];
|
||||
}
|
||||
return own < 15;
|
||||
}
|
||||
}
|
||||
|
||||
function moveKey(m: CandidateMove): string {
|
||||
return `${m.from}-${m.to}${m.promotion ?? ''}`;
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return function () {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { Color } from '@blind-chess/shared';
|
||||
import type { Game } from '../state.js';
|
||||
import type {
|
||||
AttemptHistoryEntry,
|
||||
Brain,
|
||||
BrainAction,
|
||||
BrainInput,
|
||||
CandidateMove,
|
||||
} from './brain.js';
|
||||
import { legalCandidates } from './candidates.js';
|
||||
import { handleCommit } from '../commit.js';
|
||||
import { buildView } from '../view.js';
|
||||
import { announce } from '../translator.js';
|
||||
import { finalizeIfEnded } from '../game-end.js';
|
||||
|
||||
const RETRY_CAP = 5;
|
||||
|
||||
interface BotDriverOpts {
|
||||
game: Game;
|
||||
brain: Brain;
|
||||
color: Color;
|
||||
}
|
||||
|
||||
export class BotDriver {
|
||||
private game: Game;
|
||||
private brain: Brain;
|
||||
private color: Color;
|
||||
|
||||
private decideInFlight = false;
|
||||
private disposed = false;
|
||||
private lastSeenAnnouncementCount = 0;
|
||||
|
||||
constructor(opts: BotDriverOpts) {
|
||||
this.game = opts.game;
|
||||
this.brain = opts.brain;
|
||||
this.color = opts.color;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.brain.init({
|
||||
color: this.color,
|
||||
mode: this.game.mode,
|
||||
gameId: this.game.id,
|
||||
});
|
||||
this.lastSeenAnnouncementCount = this.game.announcements.length;
|
||||
}
|
||||
|
||||
async onStateChange(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
|
||||
if (this.game.status === 'finished') {
|
||||
await this.disposeBrain();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.decideInFlight) return;
|
||||
if (!this.shouldDecide()) return;
|
||||
|
||||
this.decideInFlight = true;
|
||||
try {
|
||||
await this.runDecisionCycle();
|
||||
} finally {
|
||||
this.decideInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private shouldDecide(): boolean {
|
||||
if (this.game.status !== 'active') return false;
|
||||
// Respond to a draw offer from opponent even when it's not our turn.
|
||||
if (this.game.drawOffer && this.game.drawOffer.from !== this.color) return true;
|
||||
if (this.game.chess.turn() === this.color) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private async runDecisionCycle(): Promise<void> {
|
||||
const attemptHistory: AttemptHistoryEntry[] = [];
|
||||
|
||||
for (let attempt = 0; attempt < RETRY_CAP; attempt++) {
|
||||
const input = this.buildBrainInput(attemptHistory);
|
||||
let outcome: { kind: 'done' } | { kind: 'retry'; entry: AttemptHistoryEntry };
|
||||
try {
|
||||
const action = await this.brain.decide(input);
|
||||
outcome = this.dispatch(action);
|
||||
} catch {
|
||||
// Brain exception OR programming error in dispatch. Safe failure: resign.
|
||||
this.botResign();
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === 'done') {
|
||||
this.lastSeenAnnouncementCount = this.game.announcements.length;
|
||||
return;
|
||||
}
|
||||
attemptHistory.push(outcome.entry);
|
||||
}
|
||||
this.lastSeenAnnouncementCount = this.game.announcements.length;
|
||||
this.botResign();
|
||||
}
|
||||
|
||||
private buildBrainInput(attemptHistory: AttemptHistoryEntry[]): BrainInput {
|
||||
const view = buildView(this.game, this.color);
|
||||
const sliceStart = this.lastSeenAnnouncementCount;
|
||||
// NOTE: do NOT advance lastSeenAnnouncementCount here. The caller advances
|
||||
// it once the decision cycle terminates successfully — otherwise retried
|
||||
// attempts would not see the FSM's rejection announcements in their input.
|
||||
const newAnnouncements = this.game.announcements
|
||||
.slice(sliceStart)
|
||||
.filter((a) => a.audience === 'both' || a.audience === this.color);
|
||||
|
||||
const candidates: CandidateMove[] = legalCandidates(this.game, this.color);
|
||||
|
||||
return {
|
||||
view,
|
||||
newAnnouncements,
|
||||
legalCandidates: candidates,
|
||||
attemptHistory,
|
||||
drawOfferFromOpponent: !!(this.game.drawOffer && this.game.drawOffer.from !== this.color),
|
||||
ply: this.game.chess.history().length,
|
||||
// Vanilla mode: full reveal, FEN exposes nothing the brain can't already
|
||||
// see. Blind mode: omit FEN so the engine path can't smuggle opponent
|
||||
// positions past the view filter.
|
||||
fen: this.game.mode === 'vanilla' ? this.game.chess.fen() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private dispatch(
|
||||
action: BrainAction,
|
||||
): { kind: 'done' } | { kind: 'retry'; entry: AttemptHistoryEntry } {
|
||||
switch (action.type) {
|
||||
case 'commit': {
|
||||
const result = handleCommit(this.game, this.color, {
|
||||
from: action.from, to: action.to, promotion: action.promotion,
|
||||
});
|
||||
if (result.kind === 'applied') {
|
||||
finalizeIfEnded(this.game, result.announcements);
|
||||
return { kind: 'done' };
|
||||
}
|
||||
if (result.kind === 'announce') {
|
||||
const text = result.announcements[0]!.text;
|
||||
if (text === 'wont_help' || text === 'illegal_move'
|
||||
|| text === 'no_such_piece' || text === 'no_legal_moves') {
|
||||
return {
|
||||
kind: 'retry',
|
||||
entry: {
|
||||
move: { from: action.from, to: action.to, promotion: action.promotion },
|
||||
rejection: text,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
if (result.kind === 'silent') {
|
||||
// Brain sent only `from` (arming). CasualBrain always commits with
|
||||
// `to`; treat as a logic error and resign safely.
|
||||
this.botResign();
|
||||
return { kind: 'done' };
|
||||
}
|
||||
// result.kind === 'error' — bug path; resign.
|
||||
this.botResign();
|
||||
return { kind: 'done' };
|
||||
}
|
||||
case 'resign':
|
||||
this.botResign();
|
||||
return { kind: 'done' };
|
||||
case 'offer-draw':
|
||||
if (!this.game.drawOffer) {
|
||||
this.game.drawOffer = { from: this.color, at: Date.now() };
|
||||
}
|
||||
return { kind: 'done' };
|
||||
case 'respond-draw':
|
||||
if (!this.game.drawOffer || this.game.drawOffer.from === this.color) {
|
||||
return { kind: 'done' };
|
||||
}
|
||||
if (action.accept) {
|
||||
const ply = this.game.chess.history().length;
|
||||
const a = announce('draw_agreed', 'both', ply);
|
||||
this.game.announcements.push(a);
|
||||
this.game.drawOffer = null;
|
||||
this.game.status = 'finished';
|
||||
this.game.endReason = 'draw_agreed';
|
||||
this.game.winner = null;
|
||||
this.game.finishedAt = Date.now();
|
||||
} else {
|
||||
this.game.drawOffer = null;
|
||||
}
|
||||
return { kind: 'done' };
|
||||
}
|
||||
}
|
||||
|
||||
private botResign(): void {
|
||||
if (this.game.status !== 'active') return;
|
||||
const ply = this.game.chess.history().length;
|
||||
const text = this.color === 'w' ? 'white_resigned' : 'black_resigned';
|
||||
const a = announce(text, 'both', ply);
|
||||
this.game.announcements.push(a);
|
||||
this.game.status = 'finished';
|
||||
this.game.endReason = 'resign';
|
||||
this.game.winner = this.color === 'w' ? 'b' : 'w';
|
||||
this.game.finishedAt = Date.now();
|
||||
}
|
||||
|
||||
private async disposeBrain(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
try {
|
||||
await this.brain.dispose?.();
|
||||
} catch {/* ignore */}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
Brain, BrainInput, BrainAction, BrainInitArgs,
|
||||
CandidateMove, AttemptHistoryEntry,
|
||||
} from './brain.js';
|
||||
export { CasualBrain } from './casual-brain.js';
|
||||
export { BotDriver } from './driver.js';
|
||||
export { legalCandidates } from './candidates.js';
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Color } from '@blind-chess/shared';
|
||||
import type { Game } from './state.js';
|
||||
|
||||
export function endGame(game: Game, reason: Game['endReason'], winner: Color | null): void {
|
||||
game.status = 'finished';
|
||||
game.endReason = reason;
|
||||
game.winner = winner;
|
||||
game.finishedAt = Date.now();
|
||||
}
|
||||
|
||||
export function finalizeIfEnded(game: Game, announcements: ReadonlyArray<{ text: string }>): void {
|
||||
// Detect terminal moderator announcements.
|
||||
const lastTexts = new Set(announcements.map((a) => a.text));
|
||||
if (lastTexts.has('white_checkmate')) endGame(game, 'checkmate', 'w');
|
||||
else if (lastTexts.has('black_checkmate')) endGame(game, 'checkmate', 'b');
|
||||
else if (lastTexts.has('stalemate')) endGame(game, 'stalemate', null);
|
||||
else if (lastTexts.has('draw_insufficient')) endGame(game, 'insufficient', null);
|
||||
else if (lastTexts.has('draw_threefold')) endGame(game, 'threefold', null);
|
||||
else if (lastTexts.has('draw_fifty')) endGame(game, 'fifty_move', null);
|
||||
}
|
||||
@@ -3,9 +3,23 @@ import { randomBytes } from 'node:crypto';
|
||||
import type {
|
||||
Color, GameId, Mode, PlayerToken,
|
||||
} from '@blind-chess/shared';
|
||||
import type { BotDriver } from './bot/driver.js';
|
||||
import { type Game, PRUNE_AFTER_FINISHED_MS, RATE_LIMIT } from './state.js';
|
||||
|
||||
const games = new Map<GameId, Game>();
|
||||
const botDrivers = new Map<GameId, BotDriver>();
|
||||
|
||||
export function attachBotDriver(id: GameId, driver: BotDriver): void {
|
||||
botDrivers.set(id, driver);
|
||||
}
|
||||
|
||||
export function getBotDriver(id: GameId): BotDriver | undefined {
|
||||
return botDrivers.get(id);
|
||||
}
|
||||
|
||||
export function disposeBotDriver(id: GameId): void {
|
||||
botDrivers.delete(id);
|
||||
}
|
||||
|
||||
export function newGameId(): GameId {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
@@ -31,11 +45,16 @@ export function createGame(opts: {
|
||||
mode: Mode;
|
||||
creatorSide: Color;
|
||||
highlightingEnabled: boolean;
|
||||
vsAi?: { brain: 'casual' | 'recon' };
|
||||
}): { game: Game; creatorToken: PlayerToken } {
|
||||
const id = newGameId();
|
||||
const creatorToken = newPlayerToken();
|
||||
const now = Date.now();
|
||||
|
||||
const botColor: Color | null = opts.vsAi
|
||||
? (opts.creatorSide === 'w' ? 'b' : 'w')
|
||||
: null;
|
||||
|
||||
const game: Game = {
|
||||
id,
|
||||
mode: opts.mode,
|
||||
@@ -46,12 +65,18 @@ export function createGame(opts: {
|
||||
moveHistory: [],
|
||||
announcements: [],
|
||||
players: {
|
||||
w: opts.creatorSide === 'w' ? makeSlot(creatorToken, now) : null,
|
||||
b: opts.creatorSide === 'b' ? makeSlot(creatorToken, now) : null,
|
||||
w: opts.creatorSide === 'w' ? makeSlot(creatorToken, now)
|
||||
: (botColor === 'w' ? makeBotSlot(now) : null),
|
||||
b: opts.creatorSide === 'b' ? makeSlot(creatorToken, now)
|
||||
: (botColor === 'b' ? makeBotSlot(now) : null),
|
||||
},
|
||||
armed: null,
|
||||
drawOffer: null,
|
||||
disconnectAt: {},
|
||||
lastBroadcastIdx: { w: 0, b: 0 },
|
||||
aiOpponent: opts.vsAi && botColor
|
||||
? { color: botColor, brain: opts.vsAi.brain }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
games.set(id, game);
|
||||
@@ -67,6 +92,18 @@ function makeSlot(token: PlayerToken, now: number) {
|
||||
};
|
||||
}
|
||||
|
||||
function makeBotSlot(now: number) {
|
||||
// Synthetic slot: occupies the player's color but never connects. The token
|
||||
// is randomized (same shape as a real client token) so a third party can't
|
||||
// hijack the bot's color by guessing a fixed placeholder.
|
||||
return {
|
||||
token: newPlayerToken(),
|
||||
socket: null,
|
||||
joinedAt: now,
|
||||
rateBucket: { tokens: RATE_LIMIT.capacity, last: now },
|
||||
};
|
||||
}
|
||||
|
||||
export function getGame(id: GameId): Game | undefined {
|
||||
return games.get(id);
|
||||
}
|
||||
@@ -115,6 +152,7 @@ export function pruneFinished(): number {
|
||||
for (const [id, g] of games) {
|
||||
if (g.status === 'finished' && g.finishedAt && now - g.finishedAt > PRUNE_AFTER_FINISHED_MS) {
|
||||
games.delete(id);
|
||||
botDrivers.delete(id);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
chooseSide,
|
||||
createGame,
|
||||
pruneFinished,
|
||||
attachBotDriver,
|
||||
} from './games.js';
|
||||
import { attachSocket } from './ws.js';
|
||||
import { createGameSchema } from './validation.js';
|
||||
import { CasualBrain, BotDriver } from './bot/index.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -45,13 +47,28 @@ fastify.post('/api/games', async (req, reply) => {
|
||||
reply.code(400);
|
||||
return { error: 'malformed', detail: parsed.error.issues };
|
||||
}
|
||||
const { mode, side, highlightingEnabled } = parsed.data;
|
||||
const { mode, side, highlightingEnabled, vsAi } = parsed.data;
|
||||
|
||||
// Phase 1: only 'casual' is implemented. 'recon' returns 503.
|
||||
if (vsAi && vsAi.brain === 'recon') {
|
||||
reply.code(503);
|
||||
return { error: 'ai_offline', detail: 'recon bot not yet implemented' };
|
||||
}
|
||||
|
||||
const creatorSide = chooseSide(side);
|
||||
const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled });
|
||||
const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled, vsAi });
|
||||
|
||||
// For AI games, wire the bot.
|
||||
if (vsAi && game.aiOpponent) {
|
||||
const brain = new CasualBrain({});
|
||||
const driver = new BotDriver({ game, brain, color: game.aiOpponent.color });
|
||||
await driver.init();
|
||||
attachBotDriver(game.id, driver);
|
||||
}
|
||||
|
||||
const publicBase = PUBLIC_BASE
|
||||
|| (req.headers.host ? `${req.protocol}://${req.headers.host}` : '');
|
||||
const joinUrl = `${publicBase}/g/${game.id}`;
|
||||
const joinUrl = vsAi ? null : `${publicBase}/g/${game.id}`;
|
||||
return { gameId: game.id, creatorToken, creatorColor: creatorSide, joinUrl };
|
||||
});
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface Game {
|
||||
armed: { color: Color; from: Square } | null;
|
||||
drawOffer: { from: Color; at: number } | null;
|
||||
disconnectAt: { w?: number; b?: number };
|
||||
lastBroadcastIdx: { w: number; b: number };
|
||||
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
|
||||
}
|
||||
|
||||
export const RATE_LIMIT = { capacity: 20, refillPerSec: 10 };
|
||||
|
||||
@@ -41,4 +41,7 @@ export const createGameSchema = z.object({
|
||||
mode: z.union([z.literal('blind'), z.literal('vanilla')]),
|
||||
side: z.union([colorSchema, z.literal('random')]),
|
||||
highlightingEnabled: z.boolean(),
|
||||
vsAi: z.object({
|
||||
brain: z.union([z.literal('casual'), z.literal('recon')]),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
+56
-55
@@ -10,6 +10,7 @@ import {
|
||||
claimSlot,
|
||||
findTokenInGame,
|
||||
getGame,
|
||||
getBotDriver,
|
||||
} from './games.js';
|
||||
import type { Game } from './state.js';
|
||||
import { GRACE_MS } from './state.js';
|
||||
@@ -17,6 +18,29 @@ import { handleCommit } from './commit.js';
|
||||
import { announce } from './translator.js';
|
||||
import { buildView } from './view.js';
|
||||
import { consumeCommitToken } from './ratelimit.js';
|
||||
import { endGame, finalizeIfEnded } from './game-end.js';
|
||||
|
||||
async function pokeBot(game: Game): Promise<void> {
|
||||
const driver = getBotDriver(game.id);
|
||||
if (!driver) return;
|
||||
try {
|
||||
await driver.onStateChange();
|
||||
} catch (err) {
|
||||
// Don't throw out of message handlers — log and continue.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[bot driver error]', { gameId: game.id, err });
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastSinceLast(game: Game): void {
|
||||
for (const c of ['w', 'b'] as const) {
|
||||
const lastIdx = game.lastBroadcastIdx[c];
|
||||
const all = game.announcements;
|
||||
const slice = all.slice(lastIdx).filter((a) => a.audience === 'both' || a.audience === c);
|
||||
sendUpdateTo(game, c, slice);
|
||||
game.lastBroadcastIdx[c] = all.length;
|
||||
}
|
||||
}
|
||||
|
||||
interface SocketCtx {
|
||||
socket: WebSocket;
|
||||
@@ -60,7 +84,7 @@ function onMessage(ctx: SocketCtx, data: unknown): void {
|
||||
}
|
||||
const msg = result.data as ClientMessage;
|
||||
|
||||
if (msg.type === 'hello') return onHello(ctx, msg);
|
||||
if (msg.type === 'hello') { void onHello(ctx, msg); return; }
|
||||
if (msg.type === 'pong') return;
|
||||
|
||||
if (!ctx.game || !ctx.color) {
|
||||
@@ -68,14 +92,14 @@ function onMessage(ctx: SocketCtx, data: unknown): void {
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case 'commit': return onCommit(ctx, msg);
|
||||
case 'resign': return onResign(ctx);
|
||||
case 'offer-draw': return onOfferDraw(ctx);
|
||||
case 'respond-draw': return onRespondDraw(ctx, msg.accept);
|
||||
case 'commit': void onCommit(ctx, msg); return;
|
||||
case 'resign': void onResign(ctx); return;
|
||||
case 'offer-draw': void onOfferDraw(ctx); return;
|
||||
case 'respond-draw': void onRespondDraw(ctx, msg.accept); return;
|
||||
}
|
||||
}
|
||||
|
||||
function onHello(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'hello' }>): void {
|
||||
async function onHello(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'hello' }>): Promise<void> {
|
||||
const game = getGame(msg.gameId);
|
||||
if (!game) return sendError(ctx.socket, 'game_not_found');
|
||||
|
||||
@@ -123,17 +147,19 @@ function onHello(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'hello' }>)
|
||||
mode: game.mode,
|
||||
highlightingEnabled: game.highlightingEnabled,
|
||||
opponentConnected: !!game.players[color === 'w' ? 'b' : 'w']?.socket,
|
||||
aiOpponent: game.aiOpponent,
|
||||
});
|
||||
|
||||
// Notify peer that we're connected.
|
||||
notifyPeer(game, color, true);
|
||||
// If activation just happened, push update to both.
|
||||
// If activation just happened, poke bot then broadcast.
|
||||
if (game.status === 'active') {
|
||||
broadcastUpdate(game);
|
||||
await pokeBot(game);
|
||||
broadcastSinceLast(game);
|
||||
}
|
||||
}
|
||||
|
||||
function onCommit(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'commit' }>): void {
|
||||
async function onCommit(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'commit' }>): Promise<void> {
|
||||
const game = ctx.game!;
|
||||
const color = ctx.color!;
|
||||
|
||||
@@ -153,17 +179,19 @@ function onCommit(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'commit' }
|
||||
return;
|
||||
case 'announce':
|
||||
// Announcement to actor; opponent is unaffected unless audience=both.
|
||||
broadcastNewAnnouncements(game, result.announcements);
|
||||
// Non-turn-ending — no bot poke.
|
||||
broadcastSinceLast(game);
|
||||
return;
|
||||
case 'applied':
|
||||
// Move applied. Check end conditions.
|
||||
// Move applied. Check end conditions, then poke bot.
|
||||
finalizeIfEnded(game, result.announcements);
|
||||
broadcastNewAnnouncements(game, result.announcements);
|
||||
await pokeBot(game);
|
||||
broadcastSinceLast(game);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function onResign(ctx: SocketCtx): void {
|
||||
async function onResign(ctx: SocketCtx): Promise<void> {
|
||||
const game = ctx.game!;
|
||||
const color = ctx.color!;
|
||||
if (game.status !== 'active') return;
|
||||
@@ -172,19 +200,22 @@ function onResign(ctx: SocketCtx): void {
|
||||
const a = announce(color === 'w' ? 'white_resigned' : 'black_resigned', 'both', ply);
|
||||
game.announcements.push(a);
|
||||
endGame(game, 'resign', color === 'w' ? 'b' : 'w');
|
||||
broadcastNewAnnouncements(game, [a]);
|
||||
await pokeBot(game); // bot.onStateChange will see status=finished and dispose
|
||||
broadcastSinceLast(game);
|
||||
}
|
||||
|
||||
function onOfferDraw(ctx: SocketCtx): void {
|
||||
async function onOfferDraw(ctx: SocketCtx): Promise<void> {
|
||||
const game = ctx.game!;
|
||||
const color = ctx.color!;
|
||||
if (game.status !== 'active') return;
|
||||
game.drawOffer = { from: color, at: Date.now() };
|
||||
// Push update to both so opponent sees the drawOffer field.
|
||||
broadcastUpdate(game);
|
||||
// Poke bot — it may auto-respond to the draw offer.
|
||||
await pokeBot(game);
|
||||
// broadcastSinceLast sends drawOffer field via sendUpdateTo's existing logic.
|
||||
broadcastSinceLast(game);
|
||||
}
|
||||
|
||||
function onRespondDraw(ctx: SocketCtx, accept: boolean): void {
|
||||
async function onRespondDraw(ctx: SocketCtx, accept: boolean): Promise<void> {
|
||||
const game = ctx.game!;
|
||||
const color = ctx.color!;
|
||||
if (!game.drawOffer || game.drawOffer.from === color) return;
|
||||
@@ -194,11 +225,11 @@ function onRespondDraw(ctx: SocketCtx, accept: boolean): void {
|
||||
game.announcements.push(a);
|
||||
game.drawOffer = null;
|
||||
endGame(game, 'draw_agreed', null);
|
||||
broadcastNewAnnouncements(game, [a]);
|
||||
} else {
|
||||
game.drawOffer = null;
|
||||
broadcastUpdate(game);
|
||||
}
|
||||
await pokeBot(game);
|
||||
broadcastSinceLast(game);
|
||||
}
|
||||
|
||||
function onClose(ctx: SocketCtx): void {
|
||||
@@ -211,13 +242,13 @@ function onClose(ctx: SocketCtx): void {
|
||||
if (game.status === 'active') {
|
||||
game.disconnectAt[color] = Date.now();
|
||||
// Schedule grace timer.
|
||||
setTimeout(() => maybeAbandon(game, color), GRACE_MS + 100);
|
||||
setTimeout(() => { void maybeAbandon(game, color); }, GRACE_MS + 100);
|
||||
}
|
||||
notifyPeer(game, color, false, Date.now() + GRACE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeAbandon(game: Game, color: Color): void {
|
||||
async function maybeAbandon(game: Game, color: Color): Promise<void> {
|
||||
if (game.status !== 'active') return;
|
||||
const slot = game.players[color];
|
||||
if (!slot) return;
|
||||
@@ -228,42 +259,11 @@ function maybeAbandon(game: Game, color: Color): void {
|
||||
game.announcements.push(a);
|
||||
const winner = game.players[color === 'w' ? 'b' : 'w']?.socket ? (color === 'w' ? 'b' : 'w') : null;
|
||||
endGame(game, 'abandoned', winner);
|
||||
broadcastNewAnnouncements(game, [a]);
|
||||
await pokeBot(game); // dispose bot if game ended
|
||||
broadcastSinceLast(game);
|
||||
}
|
||||
|
||||
function endGame(game: Game, reason: Game['endReason'], winner: Color | null): void {
|
||||
game.status = 'finished';
|
||||
game.endReason = reason;
|
||||
game.winner = winner;
|
||||
game.finishedAt = Date.now();
|
||||
}
|
||||
|
||||
function finalizeIfEnded(game: Game, announcements: ReadonlyArray<{ text: string }>): void {
|
||||
// Detect terminal moderator announcements.
|
||||
const lastTexts = new Set(announcements.map((a) => a.text));
|
||||
if (lastTexts.has('white_checkmate')) endGame(game, 'checkmate', 'w');
|
||||
else if (lastTexts.has('black_checkmate')) endGame(game, 'checkmate', 'b');
|
||||
else if (lastTexts.has('stalemate')) endGame(game, 'stalemate', null);
|
||||
else if (lastTexts.has('draw_insufficient')) endGame(game, 'insufficient', null);
|
||||
else if (lastTexts.has('draw_threefold')) endGame(game, 'threefold', null);
|
||||
else if (lastTexts.has('draw_fifty')) endGame(game, 'fifty_move', null);
|
||||
}
|
||||
|
||||
function broadcastNewAnnouncements(
|
||||
game: Game,
|
||||
newAnnouncements: ReadonlyArray<import('@blind-chess/shared').Announcement>,
|
||||
): void {
|
||||
for (const c of ['w', 'b'] as const) {
|
||||
const filtered = newAnnouncements.filter((a) => a.audience === 'both' || a.audience === c);
|
||||
sendUpdateTo(game, c, filtered);
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastUpdate(game: Game): void {
|
||||
for (const c of ['w', 'b'] as const) {
|
||||
sendUpdateTo(game, c, []);
|
||||
}
|
||||
}
|
||||
|
||||
function sendUpdateTo(
|
||||
game: Game,
|
||||
@@ -284,6 +284,7 @@ function sendUpdateTo(
|
||||
drawOffer,
|
||||
endReason: game.endReason,
|
||||
winner: game.winner ?? null,
|
||||
aiOpponent: game.aiOpponent,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { WebSocket } from 'ws';
|
||||
import Fastify from 'fastify';
|
||||
import websocketPlugin from '@fastify/websocket';
|
||||
import {
|
||||
activeGameCount, chooseSide, createGame, attachBotDriver,
|
||||
} from '../../src/games.js';
|
||||
import { attachSocket } from '../../src/ws.js';
|
||||
import { createGameSchema } from '../../src/validation.js';
|
||||
import { CasualBrain, BotDriver } from '../../src/bot/index.js';
|
||||
import type { ServerMessage } from '@blind-chess/shared';
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let baseUrl = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(websocketPlugin);
|
||||
app.get('/api/health', async () => ({ ok: true, activeGames: activeGameCount() }));
|
||||
app.post('/api/games', async (req, reply) => {
|
||||
const parsed = createGameSchema.safeParse(req.body);
|
||||
if (!parsed.success) { reply.code(400); return { error: 'malformed' }; }
|
||||
const { mode, side, highlightingEnabled, vsAi } = parsed.data;
|
||||
if (vsAi && vsAi.brain === 'recon') {
|
||||
reply.code(503); return { error: 'ai_offline' };
|
||||
}
|
||||
const creatorSide = chooseSide(side);
|
||||
const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled, vsAi });
|
||||
if (vsAi && game.aiOpponent) {
|
||||
const brain = new CasualBrain({ seed: 1 });
|
||||
const driver = new BotDriver({ game, brain, color: game.aiOpponent.color });
|
||||
await driver.init();
|
||||
attachBotDriver(game.id, driver);
|
||||
}
|
||||
const joinUrl = vsAi ? null : `http://placeholder/g/${game.id}`;
|
||||
return { gameId: game.id, creatorToken, creatorColor: creatorSide, joinUrl };
|
||||
});
|
||||
app.get('/ws', { websocket: true }, (socket) => {
|
||||
const raw = (socket as unknown as { socket?: unknown }).socket ?? socket;
|
||||
attachSocket(raw as never);
|
||||
});
|
||||
await app.listen({ port: 0, host: '127.0.0.1' });
|
||||
const addr = app.server.address();
|
||||
if (typeof addr !== 'object' || !addr) throw new Error('no address');
|
||||
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => { await app.close(); });
|
||||
|
||||
interface Client {
|
||||
ws: WebSocket;
|
||||
msgs: ServerMessage[];
|
||||
waitFor: (pred: (m: ServerMessage) => boolean, timeoutMs?: number) => Promise<ServerMessage>;
|
||||
send: (m: unknown) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
function makeClient(gameId: string): Promise<Client> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(baseUrl.replace('http', 'ws') + `/ws?game=${gameId}`);
|
||||
const msgs: ServerMessage[] = [];
|
||||
const waiters: Array<{ pred: (m: ServerMessage) => boolean;
|
||||
resolve: (m: ServerMessage) => void;
|
||||
reject: (e: Error) => void;
|
||||
timer: NodeJS.Timeout }> = [];
|
||||
ws.on('message', (data) => {
|
||||
const m = JSON.parse(data.toString()) as ServerMessage;
|
||||
msgs.push(m);
|
||||
for (const w of [...waiters]) {
|
||||
if (w.pred(m)) {
|
||||
clearTimeout(w.timer);
|
||||
waiters.splice(waiters.indexOf(w), 1);
|
||||
w.resolve(m);
|
||||
}
|
||||
}
|
||||
});
|
||||
ws.on('open', () => resolve({
|
||||
ws, msgs,
|
||||
waitFor: (pred, timeoutMs = 2000) => new Promise<ServerMessage>((res, rej) => {
|
||||
const existing = msgs.find(pred);
|
||||
if (existing) return res(existing);
|
||||
const timer = setTimeout(() => rej(new Error('waitFor timeout')), timeoutMs);
|
||||
waiters.push({ pred, resolve: res, reject: rej, timer });
|
||||
}),
|
||||
send: (m) => ws.send(JSON.stringify(m)),
|
||||
close: () => ws.close(),
|
||||
}));
|
||||
ws.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function createAiGame(side: 'w' | 'b', mode: 'blind' | 'vanilla' = 'vanilla'): Promise<{ gameId: string; creatorToken: string }> {
|
||||
const res = await fetch(`${baseUrl}/api/games`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ mode, side, highlightingEnabled: false, vsAi: { brain: 'casual' } }),
|
||||
});
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
describe('AI game / Casual', () => {
|
||||
it('human as black: bot moves first as white', async () => {
|
||||
const { gameId, creatorToken } = await createAiGame('b');
|
||||
const human = await makeClient(gameId);
|
||||
human.send({ type: 'hello', gameId, token: creatorToken });
|
||||
const joined = await human.waitFor((m) => m.type === 'joined');
|
||||
expect(joined.type === 'joined' && joined.you).toBe('b');
|
||||
if (joined.type === 'joined') {
|
||||
expect(joined.aiOpponent).toEqual({ color: 'w', brain: 'casual' });
|
||||
}
|
||||
|
||||
// Bot's opening move should arrive as an update (bot moves first as white).
|
||||
const botMoved = await human.waitFor((m) =>
|
||||
m.type === 'update' && m.newAnnouncements.some((a) => a.text === 'white_moved'),
|
||||
2000,
|
||||
);
|
||||
expect(botMoved.type).toBe('update');
|
||||
human.close();
|
||||
});
|
||||
|
||||
it('human as white: human moves first, bot replies', async () => {
|
||||
const { gameId, creatorToken } = await createAiGame('w');
|
||||
const human = await makeClient(gameId);
|
||||
human.send({ type: 'hello', gameId, token: creatorToken });
|
||||
await human.waitFor((m) => m.type === 'joined');
|
||||
|
||||
// Human plays e2e4 (arm + commit).
|
||||
human.send({ type: 'commit', from: 'e2' });
|
||||
await human.waitFor((m) => m.type === 'update' && m.touchedPiece === 'e2');
|
||||
human.send({ type: 'commit', from: 'e2', to: 'e4' });
|
||||
|
||||
// Bot replies as black.
|
||||
const botMoved = await human.waitFor((m) =>
|
||||
m.type === 'update' && m.newAnnouncements.some((a) => a.text === 'black_moved'),
|
||||
);
|
||||
expect(botMoved.type).toBe('update');
|
||||
|
||||
// After bot reply, it's white's turn again.
|
||||
if (botMoved.type === 'update') {
|
||||
expect(botMoved.view.toMove).toBe('w');
|
||||
}
|
||||
human.close();
|
||||
});
|
||||
|
||||
it('bot alternate exchanges: game doesn\'t end prematurely', async () => {
|
||||
// One human-bot exchange: human plays white e2-e4, bot replies as black.
|
||||
const { gameId, creatorToken } = await createAiGame('w');
|
||||
const human = await makeClient(gameId);
|
||||
human.send({ type: 'hello', gameId, token: creatorToken });
|
||||
const joined = await human.waitFor((m) => m.type === 'joined');
|
||||
expect(joined.type === 'joined' && joined.gameStatus).toBe('active');
|
||||
|
||||
human.send({ type: 'commit', from: 'e2' });
|
||||
await human.waitFor((m) => m.type === 'update' && m.touchedPiece === 'e2');
|
||||
human.send({ type: 'commit', from: 'e2', to: 'e4' });
|
||||
const botReplied = await human.waitFor((m) =>
|
||||
m.type === 'update' &&
|
||||
(m.gameStatus === 'finished' || m.view.toMove === 'w'),
|
||||
2000,
|
||||
);
|
||||
// Game should still be active after one exchange.
|
||||
expect(botReplied.type === 'update' && botReplied.gameStatus).toBe('active');
|
||||
human.close();
|
||||
});
|
||||
|
||||
it('joinUrl is null for AI games', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/games`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'blind', side: 'w', highlightingEnabled: false, vsAi: { brain: 'casual' } }),
|
||||
});
|
||||
const json = await res.json() as { joinUrl: string | null };
|
||||
expect(json.joinUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('recon brain returns 503 in Phase 1', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/games`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'blind', side: 'w', highlightingEnabled: false, vsAi: { brain: 'recon' } }),
|
||||
});
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ beforeAll(async () => {
|
||||
mode: parsed.data.mode,
|
||||
creatorSide,
|
||||
highlightingEnabled: parsed.data.highlightingEnabled,
|
||||
vsAi: parsed.data.vsAi,
|
||||
});
|
||||
return { gameId: game.id, creatorToken, creatorColor: creatorSide };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Chess } from 'chess.js';
|
||||
import { legalCandidates } from '../../../src/bot/candidates.js';
|
||||
import type { Game } from '../../../src/state.js';
|
||||
import { RATE_LIMIT } from '../../../src/state.js';
|
||||
|
||||
function makeGame(mode: 'blind' | 'vanilla', fen?: string): Game {
|
||||
return {
|
||||
id: 'cand0001',
|
||||
mode,
|
||||
highlightingEnabled: false,
|
||||
status: 'active',
|
||||
createdAt: Date.now(),
|
||||
chess: fen ? new Chess(fen) : new Chess(),
|
||||
moveHistory: [],
|
||||
announcements: [],
|
||||
players: {
|
||||
w: { token: 'w'.repeat(24), socket: null, joinedAt: 0,
|
||||
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
|
||||
b: { token: 'b'.repeat(24), socket: null, joinedAt: 0,
|
||||
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
|
||||
},
|
||||
armed: null,
|
||||
drawOffer: null,
|
||||
disconnectAt: {},
|
||||
lastBroadcastIdx: { w: 0, b: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
describe('legalCandidates / vanilla', () => {
|
||||
it('starting position: 20 candidates for white', () => {
|
||||
const game = makeGame('vanilla');
|
||||
const candidates = legalCandidates(game, 'w');
|
||||
expect(candidates.length).toBe(20);
|
||||
});
|
||||
|
||||
it('returns from/to on each candidate', () => {
|
||||
const game = makeGame('vanilla');
|
||||
const candidates = legalCandidates(game, 'w');
|
||||
expect(candidates.every((c) => c.from && c.to)).toBe(true);
|
||||
});
|
||||
|
||||
it('vanilla excludes pinned-piece moves (chess.js filters self-check)', () => {
|
||||
// White king e1, white bishop e2, black rook e8. Bishop is pinned.
|
||||
const game = makeGame('vanilla', '4r2k/8/8/8/8/8/4B3/4K3 w - - 0 1');
|
||||
const candidates = legalCandidates(game, 'w');
|
||||
// Bishop on e2 has zero legal moves (any move drops the king to check).
|
||||
expect(candidates.find((c) => c.from === 'e2')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('vanilla expands all 4 promotion options', () => {
|
||||
// White pawn on a7, ready to promote.
|
||||
const game = makeGame('vanilla', '4k3/P7/8/8/8/8/8/4K3 w - - 0 1');
|
||||
const candidates = legalCandidates(game, 'w').filter((c) => c.from === 'a7');
|
||||
expect(candidates.map((c) => c.promotion).sort()).toEqual(['b', 'n', 'q', 'r']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legalCandidates / blind', () => {
|
||||
it('starting position: 34 geometric candidates for white', () => {
|
||||
// 8 pawns: edge pawns (a2, h2) return 3 moves each (forward 1, forward 2, 1 diagonal),
|
||||
// interior pawns (b2-g2) return 4 moves each (forward 1, forward 2, 2 diagonals).
|
||||
// Total pawn: 2*3 + 6*4 = 30.
|
||||
// 2 knights: 2 moves each = 4.
|
||||
// Total: 34.
|
||||
const game = makeGame('blind');
|
||||
const candidates = legalCandidates(game, 'w');
|
||||
expect(candidates.length).toBe(34);
|
||||
});
|
||||
|
||||
it('blind INCLUDES pinned-piece moves (geometric does not know about pins)', () => {
|
||||
// Same pinned-bishop position. Geometric move-gen sees no own piece blocking;
|
||||
// bishop can geometrically reach d3, c4, b5, a6, f3, etc.
|
||||
const game = makeGame('blind', '4r2k/8/8/8/8/8/4B3/4K3 w - - 0 1');
|
||||
const candidates = legalCandidates(game, 'w');
|
||||
expect(candidates.some((c) => c.from === 'e2')).toBe(true);
|
||||
});
|
||||
|
||||
it('blind expands all 4 promotion options for own pawn', () => {
|
||||
const game = makeGame('blind', '4k3/P7/8/8/8/8/8/4K3 w - - 0 1');
|
||||
const candidates = legalCandidates(game, 'w').filter((c) => c.from === 'a7' && c.to === 'a8');
|
||||
expect(candidates.map((c) => c.promotion).sort()).toEqual(['b', 'n', 'q', 'r']);
|
||||
});
|
||||
|
||||
it('blind ignores whose turn it is (returns moves for either color)', () => {
|
||||
// Vanilla path filters by chess.js .moves() which respects toMove. Blind
|
||||
// path iterates own pieces directly, so black candidates exist on move 0.
|
||||
const game = makeGame('blind');
|
||||
const candidates = legalCandidates(game, 'b');
|
||||
// Same geometric move count as white: 34.
|
||||
expect(candidates.length).toBe(34);
|
||||
});
|
||||
|
||||
it('zero own pieces = zero candidates (degenerate)', () => {
|
||||
// FEN with only black king + pieces — but FEN must be valid, kings required.
|
||||
const game = makeGame('blind', '4k3/8/8/8/8/8/8/4K3 w - - 0 1');
|
||||
const black = legalCandidates(game, 'b');
|
||||
// Black king on e8 has 5 geometric king moves (d8, f8, d7, e7, f7).
|
||||
expect(black.length).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CasualBrain } from '../../../src/bot/casual-brain.js';
|
||||
import type { BrainInput, CandidateMove } from '../../../src/bot/brain.js';
|
||||
import type { BoardView } from '@blind-chess/shared';
|
||||
|
||||
function makeInput(overrides: Partial<BrainInput> = {}): BrainInput {
|
||||
const view: BoardView = {
|
||||
pieces: { e2: { color: 'w', type: 'p' } },
|
||||
toMove: 'w',
|
||||
inCheck: false,
|
||||
};
|
||||
return {
|
||||
view,
|
||||
newAnnouncements: [],
|
||||
legalCandidates: [{ from: 'e2', to: 'e4' }],
|
||||
attemptHistory: [],
|
||||
drawOfferFromOpponent: false,
|
||||
ply: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CasualBrain', () => {
|
||||
it('init() resolves', async () => {
|
||||
const brain = new CasualBrain({ seed: 1 });
|
||||
await brain.init({ color: 'w', mode: 'blind', gameId: 'casualab' });
|
||||
});
|
||||
|
||||
it('single candidate -> picks it', async () => {
|
||||
const brain = new CasualBrain({ seed: 1 });
|
||||
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
const action = await brain.decide(makeInput());
|
||||
expect(action.type).toBe('commit');
|
||||
if (action.type === 'commit') {
|
||||
expect(action.from).toBe('e2');
|
||||
expect(action.to).toBe('e4');
|
||||
}
|
||||
});
|
||||
|
||||
it('zero candidates -> throws', async () => {
|
||||
const brain = new CasualBrain({ seed: 1 });
|
||||
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
await expect(brain.decide(makeInput({ legalCandidates: [] }))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('attemptHistory excludes the rejected move', async () => {
|
||||
const brain = new CasualBrain({ seed: 1 });
|
||||
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
const input = makeInput({
|
||||
legalCandidates: [
|
||||
{ from: 'e2', to: 'e4' },
|
||||
{ from: 'd2', to: 'd4' },
|
||||
],
|
||||
attemptHistory: [{ move: { from: 'e2', to: 'e4' }, rejection: 'wont_help' }],
|
||||
});
|
||||
const action = await brain.decide(input);
|
||||
expect(action.type).toBe('commit');
|
||||
if (action.type === 'commit') {
|
||||
expect(action.from).toBe('d2');
|
||||
expect(action.to).toBe('d4');
|
||||
}
|
||||
});
|
||||
|
||||
it('promotion: when multiple candidates differ only by promotion, picks queen', async () => {
|
||||
const brain = new CasualBrain({ seed: 1 });
|
||||
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
const candidates: CandidateMove[] = [
|
||||
{ from: 'a7', to: 'a8', promotion: 'q' },
|
||||
{ from: 'a7', to: 'a8', promotion: 'r' },
|
||||
{ from: 'a7', to: 'a8', promotion: 'b' },
|
||||
{ from: 'a7', to: 'a8', promotion: 'n' },
|
||||
];
|
||||
const action = await brain.decide(makeInput({ legalCandidates: candidates }));
|
||||
if (action.type === 'commit') expect(action.promotion).toBe('q');
|
||||
});
|
||||
|
||||
it('draw offer at material parity -> accept', async () => {
|
||||
const brain = new CasualBrain({ seed: 1 });
|
||||
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
// White has 1 king + 1 rook = 5 material; Casual heuristic accepts when own material < 15.
|
||||
const view: BoardView = {
|
||||
pieces: {
|
||||
e1: { color: 'w', type: 'k' },
|
||||
a1: { color: 'w', type: 'r' },
|
||||
},
|
||||
toMove: 'w', inCheck: false,
|
||||
};
|
||||
const action = await brain.decide({
|
||||
view, newAnnouncements: [], legalCandidates: [{ from: 'e1', to: 'e2' }],
|
||||
attemptHistory: [], drawOfferFromOpponent: true, ply: 30,
|
||||
});
|
||||
expect(action.type).toBe('respond-draw');
|
||||
if (action.type === 'respond-draw') expect(action.accept).toBe(true);
|
||||
});
|
||||
|
||||
it('never voluntarily offers resign', async () => {
|
||||
const brain = new CasualBrain({ seed: 1 });
|
||||
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const action = await brain.decide(makeInput({ ply: i }));
|
||||
expect(action.type).not.toBe('resign');
|
||||
}
|
||||
});
|
||||
|
||||
it('seeded determinism: same seed + same input -> same move', async () => {
|
||||
const candidates: CandidateMove[] = [
|
||||
{ from: 'e2', to: 'e4' },
|
||||
{ from: 'd2', to: 'd4' },
|
||||
{ from: 'g1', to: 'f3' },
|
||||
];
|
||||
const a = new CasualBrain({ seed: 42 });
|
||||
await a.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
const b = new CasualBrain({ seed: 42 });
|
||||
await b.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
const aAct = await a.decide(makeInput({ legalCandidates: candidates }));
|
||||
const bAct = await b.decide(makeInput({ legalCandidates: candidates }));
|
||||
expect(aAct).toEqual(bAct);
|
||||
});
|
||||
|
||||
it('opening moves favor center pawns over flank pawns (e/d > a/h)', async () => {
|
||||
const candidates: CandidateMove[] = [
|
||||
{ from: 'a2', to: 'a3' },
|
||||
{ from: 'h2', to: 'h3' },
|
||||
{ from: 'e2', to: 'e4' },
|
||||
{ from: 'd2', to: 'd4' },
|
||||
];
|
||||
// Many seeds → assert e2 or d2 wins majority. The score gap (center pawn
|
||||
// scores ~25 + 15*2 = 55 over flank pawn ~15*1 = 15) is well over the
|
||||
// 0.01 random tiebreak, so center should win nearly always.
|
||||
let centerHits = 0;
|
||||
for (let s = 0; s < 20; s++) {
|
||||
const b = new CasualBrain({ seed: s });
|
||||
await b.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
||||
const a = await b.decide(makeInput({ legalCandidates: candidates, ply: 0 }));
|
||||
if (a.type === 'commit' && (a.from === 'e2' || a.from === 'd2')) centerHits++;
|
||||
}
|
||||
expect(centerHits).toBeGreaterThan(15);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { Chess } from 'chess.js';
|
||||
import { BotDriver } from '../../../src/bot/driver.js';
|
||||
import type { Brain, BrainAction, BrainInput } from '../../../src/bot/brain.js';
|
||||
import type { Game } from '../../../src/state.js';
|
||||
import { RATE_LIMIT } from '../../../src/state.js';
|
||||
|
||||
function makeGame(opts: { mode?: 'blind' | 'vanilla'; fen?: string; status?: Game['status'] } = {}): Game {
|
||||
return {
|
||||
id: 'gabcd123',
|
||||
mode: opts.mode ?? 'blind',
|
||||
highlightingEnabled: false,
|
||||
status: opts.status ?? 'active',
|
||||
createdAt: Date.now(),
|
||||
chess: opts.fen ? new Chess(opts.fen) : new Chess(),
|
||||
moveHistory: [],
|
||||
announcements: [],
|
||||
players: {
|
||||
w: { token: 'w'.repeat(24), socket: null, joinedAt: 0,
|
||||
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
|
||||
b: { token: 'b'.repeat(24), socket: null, joinedAt: 0,
|
||||
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
|
||||
},
|
||||
armed: null,
|
||||
drawOffer: null,
|
||||
disconnectAt: {},
|
||||
lastBroadcastIdx: { w: 0, b: 0 },
|
||||
aiOpponent: { color: 'b', brain: 'casual' },
|
||||
};
|
||||
}
|
||||
|
||||
class StubBrain implements Brain {
|
||||
public decideCalls = 0;
|
||||
private script: BrainAction[] = [];
|
||||
init = vi.fn(async () => {});
|
||||
dispose = vi.fn(async () => {});
|
||||
decide = vi.fn(async (input: BrainInput): Promise<BrainAction> => {
|
||||
this.decideCalls++;
|
||||
if (this.script.length === 0) {
|
||||
// Default: trivial commit on the first legal candidate.
|
||||
if (input.legalCandidates.length === 0) throw new Error('no candidates');
|
||||
const c = input.legalCandidates[0]!;
|
||||
return { type: 'commit', from: c.from, to: c.to, promotion: c.promotion };
|
||||
}
|
||||
return this.script.shift()!;
|
||||
});
|
||||
|
||||
enqueue(...actions: BrainAction[]) { this.script.push(...actions); }
|
||||
}
|
||||
|
||||
describe('BotDriver', () => {
|
||||
let game: Game;
|
||||
let brain: StubBrain;
|
||||
let driver: BotDriver;
|
||||
|
||||
beforeEach(async () => {
|
||||
game = makeGame();
|
||||
brain = new StubBrain();
|
||||
driver = new BotDriver({ game, brain, color: 'b' });
|
||||
await driver.init();
|
||||
});
|
||||
|
||||
it('init() invokes brain.init with correct args', async () => {
|
||||
expect(brain.init).toHaveBeenCalledWith({
|
||||
color: 'b',
|
||||
mode: 'blind',
|
||||
gameId: 'gabcd123',
|
||||
});
|
||||
});
|
||||
|
||||
it('onStateChange does nothing when not bot turn', async () => {
|
||||
// White to move (start). Bot is black.
|
||||
await driver.onStateChange();
|
||||
expect(brain.decide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onStateChange fires decide when it is bot turn', async () => {
|
||||
game.chess.move('e4');
|
||||
await driver.onStateChange();
|
||||
expect(brain.decide).toHaveBeenCalledTimes(1);
|
||||
expect(game.chess.turn()).toBe('w'); // turn advanced
|
||||
});
|
||||
|
||||
it('mutex: second onStateChange while in-flight is a no-op', async () => {
|
||||
game.chess.move('e4');
|
||||
let release: () => void;
|
||||
const gate = new Promise<void>((r) => { release = r; });
|
||||
brain.decide.mockImplementationOnce(async (input) => {
|
||||
await gate;
|
||||
const c = input.legalCandidates[0]!;
|
||||
return { type: 'commit', from: c.from, to: c.to };
|
||||
});
|
||||
const p1 = driver.onStateChange();
|
||||
const p2 = driver.onStateChange();
|
||||
release!();
|
||||
await Promise.all([p1, p2]);
|
||||
expect(brain.decide).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retry on wont_help: pinned bishop scenario', async () => {
|
||||
// Black king h8, black bishop e7 pinned by white rook on e1.
|
||||
const fen = '4k2K/4b3/8/8/8/8/8/4R3 b - - 0 1';
|
||||
game = makeGame({ fen });
|
||||
brain = new StubBrain();
|
||||
driver = new BotDriver({ game, brain, color: 'b' });
|
||||
await driver.init();
|
||||
// First action: pinned-bishop move (FSM rejects with wont_help)
|
||||
// Second action: legal king move (black king at e8, not h8 which is white)
|
||||
brain.enqueue(
|
||||
{ type: 'commit', from: 'e7', to: 'd6' },
|
||||
{ type: 'commit', from: 'e8', to: 'f8' },
|
||||
);
|
||||
await driver.onStateChange();
|
||||
expect(brain.decide).toHaveBeenCalledTimes(2);
|
||||
expect(game.chess.turn()).toBe('w');
|
||||
});
|
||||
|
||||
it('retry cap (5): after 5 wont_help, driver resigns the bot', async () => {
|
||||
const fen = '4k2K/4b3/8/8/8/8/8/4R3 b - - 0 1';
|
||||
game = makeGame({ fen });
|
||||
brain = new StubBrain();
|
||||
driver = new BotDriver({ game, brain, color: 'b' });
|
||||
await driver.init();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
brain.enqueue({ type: 'commit', from: 'e7', to: 'd6' });
|
||||
}
|
||||
await driver.onStateChange();
|
||||
expect(game.status).toBe('finished');
|
||||
expect(game.endReason).toBe('resign');
|
||||
expect(game.winner).toBe('w');
|
||||
expect(brain.decide).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('respond-draw: when drawOffer is from opponent, driver fires decide and dispatches', async () => {
|
||||
game.drawOffer = { from: 'w', at: Date.now() };
|
||||
brain.enqueue({ type: 'respond-draw', accept: true });
|
||||
await driver.onStateChange();
|
||||
expect(brain.decide).toHaveBeenCalledTimes(1);
|
||||
expect(game.status).toBe('finished');
|
||||
expect(game.endReason).toBe('draw_agreed');
|
||||
});
|
||||
|
||||
it('dispose on game finished: subsequent onStateChange is a no-op', async () => {
|
||||
game.chess.move('e4');
|
||||
game.status = 'finished';
|
||||
await driver.onStateChange();
|
||||
expect(brain.decide).not.toHaveBeenCalled();
|
||||
expect(brain.dispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('bot move that delivers checkmate finalizes game.status', async () => {
|
||||
// FEN: '1k6/8/1K6/8/8/8/8/7Q w - - 0 1'
|
||||
// White king b6, white queen h1, black king b8.
|
||||
// Qh8# is mate: queen moves h1→h8, covers h8; white king b6 covers a7,b7,c7,a5,b5,c5.
|
||||
// Black king b8 escape squares (a7,b7,c7,a8,c8) are all covered. Verified with chess.js.
|
||||
const fen = '1k6/8/1K6/8/8/8/8/7Q w - - 0 1';
|
||||
game = makeGame({ fen });
|
||||
game.aiOpponent = { color: 'w', brain: 'casual' };
|
||||
brain = new StubBrain();
|
||||
driver = new BotDriver({ game, brain, color: 'w' });
|
||||
await driver.init();
|
||||
|
||||
brain.enqueue({ type: 'commit', from: 'h1', to: 'h8' });
|
||||
await driver.onStateChange();
|
||||
|
||||
expect(game.status).toBe('finished');
|
||||
expect(game.endReason).toBe('checkmate');
|
||||
expect(game.winner).toBe('w');
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ function makeGame(fen?: string): Game {
|
||||
armed: null,
|
||||
drawOffer: null,
|
||||
disconnectAt: {},
|
||||
lastBroadcastIdx: { w: 0, b: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeGame(mode: 'blind' | 'vanilla', fen?: string, status: 'active' | 'f
|
||||
armed: null,
|
||||
drawOffer: null,
|
||||
disconnectAt: {},
|
||||
lastBroadcastIdx: { w: 0, b: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export type ServerMessage =
|
||||
mode: Mode;
|
||||
highlightingEnabled: boolean;
|
||||
opponentConnected: boolean;
|
||||
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
|
||||
}
|
||||
| {
|
||||
type: 'update';
|
||||
@@ -44,6 +45,7 @@ export type ServerMessage =
|
||||
drawOffer?: { from: Color } | null;
|
||||
endReason?: EndReason;
|
||||
winner?: Color | null;
|
||||
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
|
||||
}
|
||||
| { type: 'peer-status'; color: Color; connected: boolean; graceUntil?: number }
|
||||
| { type: 'error'; code: ErrorCode; message: string }
|
||||
@@ -53,10 +55,11 @@ export interface CreateGameRequest {
|
||||
mode: Mode;
|
||||
side: Color | 'random';
|
||||
highlightingEnabled: boolean;
|
||||
vsAi?: { brain: 'casual' | 'recon' };
|
||||
}
|
||||
|
||||
export interface CreateGameResponse {
|
||||
gameId: GameId;
|
||||
creatorToken: PlayerToken;
|
||||
joinUrl: string;
|
||||
joinUrl: string | null;
|
||||
}
|
||||
|
||||
Generated
+12
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5.6.0
|
||||
version: 5.9.3
|
||||
@@ -54,6 +57,9 @@ importers:
|
||||
fastify:
|
||||
specifier: ^5.2.0
|
||||
version: 5.8.5
|
||||
js-chess-engine:
|
||||
specifier: ^2.4.6
|
||||
version: 2.4.6
|
||||
pino:
|
||||
specifier: ^9.5.0
|
||||
version: 9.14.0
|
||||
@@ -933,6 +939,10 @@ packages:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
js-chess-engine@2.4.6:
|
||||
resolution: {integrity: sha512-OKvWKICifXLjUilGzT5RstUv9iGpk04PjGpTyVT0lMlxX2HptoXZ2Q9hNicidnYjFcR7FHpnXFVwreDSF6a5Ng==}
|
||||
engines: {node: '>=24'}
|
||||
|
||||
js-tokens@9.0.1:
|
||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||
|
||||
@@ -2075,6 +2085,8 @@ snapshots:
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-chess-engine@2.4.6: {}
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
|
||||
json-schema-ref-resolver@3.0.0:
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Self-play harness for the Casual bot.
|
||||
*
|
||||
* Runs N games in-process (no HTTP). Reports stats and optionally writes a
|
||||
* transcript per game. Supports CasualBrain on either color and a
|
||||
* RandomBrain baseline for measuring Casual's strength.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm selfplay --games 100 --mode vanilla
|
||||
* pnpm selfplay --white casual --black random --games 100 --mode vanilla
|
||||
* pnpm selfplay --white random --black casual --games 100 --mode vanilla
|
||||
* pnpm selfplay --games 50 --mode blind --transcripts
|
||||
* pnpm selfplay --games 10 --seed 42
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { CasualBrain, BotDriver } from '../packages/server/src/bot/index.js';
|
||||
import type { Brain, BrainAction, BrainInitArgs, BrainInput }
|
||||
from '../packages/server/src/bot/brain.js';
|
||||
import { createGame } from '../packages/server/src/games.js';
|
||||
|
||||
interface Args {
|
||||
white: 'casual' | 'random';
|
||||
black: 'casual' | 'random';
|
||||
games: number;
|
||||
mode: 'blind' | 'vanilla';
|
||||
seed: number;
|
||||
transcripts: boolean;
|
||||
maxPly: number;
|
||||
}
|
||||
|
||||
function parseArgs(): Args {
|
||||
const args: Args = {
|
||||
white: 'casual', black: 'casual',
|
||||
games: 10, mode: 'blind', seed: 1, transcripts: false, maxPly: 400,
|
||||
};
|
||||
const a = process.argv.slice(2);
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const k = a[i]!;
|
||||
const v = a[i + 1]!;
|
||||
if (k === '--white') { args.white = v as 'casual' | 'random'; i++; }
|
||||
else if (k === '--black') { args.black = v as 'casual' | 'random'; i++; }
|
||||
else if (k === '--games') { args.games = parseInt(v, 10); i++; }
|
||||
else if (k === '--mode') { args.mode = v as 'blind' | 'vanilla'; i++; }
|
||||
else if (k === '--seed') { args.seed = parseInt(v, 10); i++; }
|
||||
else if (k === '--max-ply') { args.maxPly = parseInt(v, 10); i++; }
|
||||
else if (k === '--transcripts') { args.transcripts = true; }
|
||||
else if (k === '--help' || k === '-h') {
|
||||
console.log('Usage: pnpm selfplay [--white casual|random] [--black casual|random]');
|
||||
console.log(' [--games N] [--mode blind|vanilla]');
|
||||
console.log(' [--seed N] [--max-ply N] [--transcripts]');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
class RandomBrain implements Brain {
|
||||
private rng: () => number;
|
||||
constructor(seed: number) {
|
||||
let a = seed >>> 0;
|
||||
this.rng = () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
async init(_args: BrainInitArgs): Promise<void> {}
|
||||
async decide(input: BrainInput): Promise<BrainAction> {
|
||||
const cs = input.legalCandidates;
|
||||
if (cs.length === 0) throw new Error('no candidates');
|
||||
const i = Math.floor(this.rng() * cs.length);
|
||||
const c = cs[i]!;
|
||||
return { type: 'commit', from: c.from, to: c.to, promotion: c.promotion };
|
||||
}
|
||||
}
|
||||
|
||||
function makeBrain(kind: 'casual' | 'random', seed: number): Brain {
|
||||
return kind === 'casual' ? new CasualBrain({ seed }) : new RandomBrain(seed);
|
||||
}
|
||||
|
||||
interface GameResult {
|
||||
result: 'w' | 'b' | 'draw' | 'maxply' | 'error';
|
||||
endReason: string;
|
||||
ply: number;
|
||||
ms: number;
|
||||
transcript: string[];
|
||||
}
|
||||
|
||||
async function runOneGame(args: Args, gameIdx: number): Promise<GameResult> {
|
||||
const startMs = Date.now();
|
||||
const transcript: string[] = [];
|
||||
|
||||
const { game } = createGame({
|
||||
mode: args.mode, creatorSide: 'w', highlightingEnabled: false,
|
||||
vsAi: { brain: 'casual' },
|
||||
});
|
||||
// createGame already filled both slots when vsAi is set. Clear the
|
||||
// aiOpponent tag (this is a self-play game, not a vs-AI game) and flip
|
||||
// status to 'active' (no hello will arrive in self-play).
|
||||
game.aiOpponent = undefined;
|
||||
game.status = 'active';
|
||||
|
||||
const wBrain = makeBrain(args.white, args.seed + gameIdx * 2);
|
||||
const bBrain = makeBrain(args.black, args.seed + gameIdx * 2 + 1);
|
||||
const wDriver = new BotDriver({ game, brain: wBrain, color: 'w' });
|
||||
const bDriver = new BotDriver({ game, brain: bBrain, color: 'b' });
|
||||
await wDriver.init();
|
||||
await bDriver.init();
|
||||
|
||||
let ply = 0;
|
||||
while (game.status === 'active' && ply < args.maxPly) {
|
||||
const turn = game.chess.turn() as 'w' | 'b';
|
||||
const driver = turn === 'w' ? wDriver : bDriver;
|
||||
try {
|
||||
await driver.onStateChange();
|
||||
} catch (err) {
|
||||
transcript.push(`!! error at ply ${ply}: ${(err as Error).message}`);
|
||||
return { result: 'error', endReason: (err as Error).message,
|
||||
ply, ms: Date.now() - startMs, transcript };
|
||||
}
|
||||
const newPly = game.chess.history().length;
|
||||
if (newPly === ply && game.status === 'active') {
|
||||
// Driver didn't move and game didn't end — defensive break.
|
||||
transcript.push(`!! stuck at ply ${ply} (${turn} to move)`);
|
||||
return { result: 'error', endReason: 'stuck',
|
||||
ply, ms: Date.now() - startMs, transcript };
|
||||
}
|
||||
if (newPly > ply) {
|
||||
const lastSan = game.chess.history()[newPly - 1];
|
||||
transcript.push(`${newPly}. ${turn === 'w' ? 'W' : 'B'}: ${lastSan}`);
|
||||
}
|
||||
ply = newPly;
|
||||
}
|
||||
|
||||
const ms = Date.now() - startMs;
|
||||
if (game.status !== 'finished') {
|
||||
return { result: 'maxply', endReason: 'max_ply', ply, ms, transcript };
|
||||
}
|
||||
const result: 'w' | 'b' | 'draw' = game.winner ?? 'draw';
|
||||
return { result, endReason: game.endReason ?? 'unknown', ply, ms, transcript };
|
||||
}
|
||||
|
||||
function summarize(rs: GameResult[]): string {
|
||||
const w = rs.filter((r) => r.result === 'w').length;
|
||||
const b = rs.filter((r) => r.result === 'b').length;
|
||||
const d = rs.filter((r) => r.result === 'draw').length;
|
||||
const mp = rs.filter((r) => r.result === 'maxply').length;
|
||||
const er = rs.filter((r) => r.result === 'error').length;
|
||||
const avgPly = rs.reduce((s, r) => s + r.ply, 0) / Math.max(rs.length, 1);
|
||||
const avgMs = rs.reduce((s, r) => s + r.ms, 0) / Math.max(rs.length, 1);
|
||||
return `W=${w} B=${b} D=${d} MaxPly=${mp} Err=${er} avgPly=${avgPly.toFixed(0)} avgMs=${avgMs.toFixed(0)}`;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs();
|
||||
console.log(`selfplay: ${args.games} game(s), mode=${args.mode}, white=${args.white}, black=${args.black}, seed=${args.seed}`);
|
||||
const results: GameResult[] = [];
|
||||
|
||||
let outDir: string | null = null;
|
||||
if (args.transcripts) {
|
||||
outDir = resolve('tmp', 'selfplay-runs', String(Date.now()));
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
console.log(`transcripts -> ${outDir}`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < args.games; i++) {
|
||||
const r = await runOneGame(args, i);
|
||||
results.push(r);
|
||||
if (outDir) {
|
||||
writeFileSync(
|
||||
resolve(outDir, `game-${String(i + 1).padStart(4, '0')}.txt`),
|
||||
`result=${r.result} reason=${r.endReason} ply=${r.ply} ms=${r.ms}\n${r.transcript.join('\n')}\n`,
|
||||
);
|
||||
}
|
||||
if ((i + 1) % 10 === 0 || i === args.games - 1) {
|
||||
console.log(`[${i + 1}/${args.games}] ${summarize(results)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== summary ===');
|
||||
console.log(summarize(results));
|
||||
const reasons = new Map<string, number>();
|
||||
for (const r of results) reasons.set(r.endReason, (reasons.get(r.endReason) ?? 0) + 1);
|
||||
console.log('end reasons:');
|
||||
for (const [k, v] of [...reasons.entries()].sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${k}: ${v}`);
|
||||
}
|
||||
console.log('errors: ' + results.filter((r) => r.result === 'error').length);
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error(err); process.exit(1); });
|
||||
Reference in New Issue
Block a user