diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json index f59ca7c..1d8a804 100644 --- a/.sisyphus/boulder.json +++ b/.sisyphus/boulder.json @@ -1,13 +1,38 @@ { "active_plan": "/home/joey/Projects/rules/.sisyphus/plans/rete-rules-engine.md", - "started_at": "2026-04-16T19:18:14.222Z", + "started_at": "2026-04-16T21:50:28.031Z", "session_ids": [ - "ses_26872eb19ffeDMaEoxdTsorFej", - "ses_26842c860ffec63Ov7Llrz7Lyg", - "ses_26843b5b0ffejVpXmmHcQ8CoUp", - "ses_268415bf0ffernrRKPtHro341E", - "ses_2684239eeffeaz52nJeIRqphnJ", - "ses_2683e067effeLzndjG25qlXH3G" + "ses_267b9d7a2ffeFkGcPFn1iv223J", + "ses_267b7c6a3ffeFBPE7j5hCcgvdq", + "ses_267b27b25ffe6ox746ql2Qj1E2", + "ses_267ae3c18ffe1Q0dx2aMzUZwid", + "ses_267ab53e5ffeXk8oWYjxiSryf0", + "ses_267a4cff0ffeCc0cSJZuxty3MR", + "ses_267a27b30ffeaIVszd2do4wYGU", + "ses_2678e1772ffeXFAdrjVVLAIh1s", + "ses_2677bcf14ffeCyy0Il5QV4Wdp0", + "ses_26776247dffehQGb1xnTBRqjq0", + "ses_26772a7e2ffep3REXuUXLd4YsX", + "ses_26770d3e0ffeWPNocV3HxsUb70", + "ses_2676e6648ffegH7o8GqgKw4hkM", + "ses_26768e818ffeacHy63Rn2RFmrS", + "ses_26760ae54ffezlg9ttb3a9P7wm", + "ses_2675a45d4ffee5V3zu7hjdOkD7", + "ses_26755c023ffeYvG2k7GuZIljF5", + "ses_26750ed18ffedLTtD3ziF7avO2", + "ses_2674cf6a7ffeOXPEFn6rhU551N", + "ses_26740710cffexgieUA3qB2B98Z", + "ses_26735c68effelwOfYs0gfmIKPZ", + "ses_2673618caffe5Rqdqzw1O6feF2", + "ses_26736499dffeeYMawv3CU88Hwp", + "ses_267368601ffeJ0vrgBbQg0z30R", + "ses_26730df9bffeFFGese2Qel8oBI", + "ses_2672b4d9bffekW9lZXc1JCVvXw", + "ses_2672b257dffeaG4lGP8jaN7Tp5", + "ses_263703df9ffegmVplLbaxmQIes", + "ses_262de3483ffe5v8SD8eFNqtdo0", + "ses_262de1b3bffexz022qa8FnxRyV", + "ses_262ddfb93ffeMGkZK2rspryFfX" ], "plan_name": "rete-rules-engine", "agent": "atlas" diff --git a/.sisyphus/notepads/rete-rules-engine/learnings.md b/.sisyphus/notepads/rete-rules-engine/learnings.md new file mode 100644 index 0000000..b1f9fa9 --- /dev/null +++ b/.sisyphus/notepads/rete-rules-engine/learnings.md @@ -0,0 +1,40 @@ +## [2026-04-16] Session ses_267b9d7a2ffeFkGcPFn1iv223J Start + +### Codebase State (Phase 3.8 complete) +- 323 tests pass, 0 fail across all packages +- Engine (`packages/rete/src/`): fully implemented — schema, WM, alpha/beta, join, filter, query, derived, negation, existential, NCC, aggregation, event log, snapshot, replay +- Chess (`packages/chess/src/`): all FIDE rules + 15 presets implemented + - Presets in `packages/chess/src/presets/registry.ts` + - Fact schema in `packages/chess/src/schema.ts` + - Engine facade in `packages/chess/src/engine.ts` +- Chess package.json already has `vite`, `@vitejs/plugin-react`, `react`, `react-dom`, `@types/react`, `@types/react-dom` in devDeps +- No `index.html`, no `src/app/`, no UI files yet + +### Key Conventions +- Bun workspaces, `bun test`, NOT npm/node +- TypeScript strict: `noImplicitAny`, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess` +- ESLint flat config in `eslint.config.js` +- No `as any`, no `@ts-ignore` +- All tests: Vitest (not Jest) +- Tailwind for styling (per plan P3.9) +- React 19 +- Square encoded as number 0..63 +- Pieces as EntityId with multiple attrs + +### Chess Architecture +- `Session` from `@paratype/rete` — the game engine +- `generateStartingPosition(session)` — inserts 32 piece facts +- `AttemptedMove` fact = move intent +- `LegalMove` derived fact = legal moves per piece +- `InCheck`, `GameOver` derived facts +- Presets: PRESET_REGISTRY in `packages/chess/src/presets/registry.ts` +- `engine.ts` — higher-level facade wrapping Session + +### UI Requirements (P3.9-P3.15) +- Routes: Home (/), Game (/game), Rules (/rules), Save (/save) +- Board: `[data-square="e2"]`, `[data-piece="white-pawn"]` selectors needed for Playwright +- Drag-drop: HTML5 DnD or react-dnd +- `[data-action="undo"]`, `[data-action="save"]`, `[data-action="export"]`, `[data-action="import"]` +- `[data-testid="app-root"]` on root element +- `[data-preset="{id}"] [data-role="toggle"]` +- localStorage key: `paratype-chess:v1:autosave` diff --git a/.sisyphus/plans/rete-rules-engine.md b/.sisyphus/plans/rete-rules-engine.md index cba03b8..c68f5fa 100644 --- a/.sisyphus/plans/rete-rules-engine.md +++ b/.sisyphus/plans/rete-rules-engine.md @@ -744,7 +744,7 @@ Final Verification Wave (4 parallel reviews) - Files: `package.json`, `tsconfig.base.json`, `tsconfig.json`, `eslint.config.js`, `vitest.workspace.ts`, `playwright.config.ts`, `.gitignore`, `LICENSE`, `README.md`, `packages/*/package.json`, `packages/*/tsconfig.json`, `packages/*/src/index.ts`, `packages/*/README.md`, `bun.lockb` - Pre-commit: `bun run check` (hook installed next task) -- [ ] P0.6. **CI pipeline (`.github/workflows/ci.yml`) + pre-commit hook (lefthook)** +- [x] P0.6. **CI pipeline (`.github/workflows/ci.yml`) + pre-commit hook (lefthook)** **What to do**: - Create `.github/workflows/ci.yml`: @@ -827,7 +827,7 @@ Final Verification Wave (4 parallel reviews) ### Phase 1 — Engine Pararules Parity (TDD) -- [ ] P1.1. **Schema + Fact type with typed attributes (TDD)** +- [x] P1.1. **Schema + Fact type with typed attributes (TDD)** **What to do**: - RED: In `packages/rete/src/schema.test.ts`, write failing tests: @@ -898,7 +898,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/schema.ts`, `packages/rete/src/schema.types.ts`, `packages/rete/src/schema.test.ts`, `packages/rete/src/schema.type-test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.2. **Working-memory (WM) storage + retrieval (TDD)** +- [x] P1.2. **Working-memory (WM) storage + retrieval (TDD)** **What to do**: - RED: `packages/rete/src/wm.test.ts` — failing tests: @@ -955,7 +955,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/wm.ts`, `packages/rete/src/wm.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.3. **Alpha network: fact indexing by (id, attr) pattern (TDD)** +- [x] P1.3. **Alpha network: fact indexing by (id, attr) pattern (TDD)** **What to do**: - RED: `packages/rete/src/alpha.test.ts` — failing tests: @@ -1018,7 +1018,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/alpha.ts`, `packages/rete/src/alpha.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.4. **Session lifecycle: init, add rule, fire (TDD)** +- [x] P1.4. **Session lifecycle: init, add rule, fire (TDD)** **What to do**: - RED: `packages/rete/src/session.test.ts` — failing tests: @@ -1078,7 +1078,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/session.ts`, `packages/rete/src/session.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.5. **Typed TS builder API + handler registry (TDD)** +- [x] P1.5. **Typed TS builder API + handler registry (TDD)** **What to do**: - RED: `packages/rete/src/builder.test.ts` — failing tests: @@ -1137,7 +1137,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/builder.ts`, `packages/rete/src/registry.ts`, `packages/rete/src/builder.test.ts`, `packages/rete/src/registry.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.6. **JSON serialization round-trip (TDD)** +- [x] P1.6. **JSON serialization round-trip (TDD)** **What to do**: - RED: `packages/rete/src/serialize.test.ts` — failing tests: @@ -1194,7 +1194,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/serialize.ts`, `packages/rete/src/serialize.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.7. **Beta network: memory + token propagation (TDD)** +- [x] P1.7. **Beta network: memory + token propagation (TDD)** **What to do**: - RED: `packages/rete/src/beta.test.ts` — failing tests covering single-condition rule (beta reduces to alpha), two-condition rule (one join), three-condition chain @@ -1241,7 +1241,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/beta.ts`, `packages/rete/src/beta.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.8. **Join nodes with variable binding (TDD)** +- [x] P1.8. **Join nodes with variable binding (TDD)** **What to do**: - RED: `packages/rete/src/join.test.ts` — join on shared variable `?id` (e.g., `(?id, X, ?x)` ∧ `(?id, Y, ?y)` must match when id is the same), numeric equality tests @@ -1288,7 +1288,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/join.ts`, `packages/rete/src/join.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.9. **Condition filters (cond equivalent) (TDD)** +- [x] P1.9. **Condition filters (cond equivalent) (TDD)** **What to do**: - RED: `packages/rete/src/condition.test.ts` — filter predicates applied after join; predicates are registered (via registry, for JSON serializability) @@ -1332,7 +1332,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/condition.ts`, `packages/rete/src/condition.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.10. **Query API: query / queryAll (TDD)** +- [x] P1.10. **Query API: query / queryAll (TDD)** **What to do**: - RED: `packages/rete/src/query.test.ts` — `session.query(rule)` returns first match or throws; `session.queryAll(rule)` returns all; `session.query(rule, { bindings })` filters by binding value @@ -1376,7 +1376,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/query.ts`, `packages/rete/src/query.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.11. **Derived facts via thenFinally-equivalent (TDD)** +- [x] P1.11. **Derived facts via thenFinally-equivalent (TDD)** **What to do**: - RED: `packages/rete/src/derived.test.ts` — `rule.thenFinally('aggregateHandler', [])` fires after all activations of a tick; derived facts auto-retract when supporting matches disappear (truth maintenance) @@ -1420,7 +1420,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/derived.ts`, `packages/rete/src/derived.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.12. **Cycle detection with recursion limit (TDD)** +- [x] P1.12. **Cycle detection with recursion limit (TDD)** **What to do**: - RED: `packages/rete/src/cycle.test.ts` — rule A inserts fact triggering rule B inserting fact triggering A (cycle); `fireRules({ recursionLimit: 4 })` throws `RecursionLimitExceededError` with cycle trace; `recursionLimit: 0` disables (for advanced use) @@ -1464,7 +1464,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/cycle.ts`, `packages/rete/src/cycle.test.ts`, `packages/rete/src/session.ts` - Pre-commit: `bun run check` -- [ ] P1.13. **Deterministic conflict resolution (TDD)** +- [x] P1.13. **Deterministic conflict resolution (TDD)** **What to do**: - RED: `packages/rete/src/conflict.test.ts` — given N matching activations, firing order is: salience desc → specificity (# conditions) desc → insertion order asc; deterministic across runs @@ -1510,7 +1510,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/conflict.ts`, `packages/rete/src/conflict.test.ts`, `packages/rete/src/session.ts` - Pre-commit: `bun run check` -- [ ] P1.14. **Pararules golden-file test port** +- [x] P1.14. **Pararules golden-file test port** **What to do**: - Port 5-10 representative pararules tests from `paranim/pararules/tests/*.nim` to TS/Vitest under `packages/rete/tests/golden/` @@ -1569,7 +1569,7 @@ Final Verification Wave (4 parallel reviews) ### Phase 2 — Rete II Extensions + Chess Engine -- [ ] P2.1. **Negation nodes (NOT) (TDD)** +- [x] P2.1. **Negation nodes (NOT) (TDD)** **What to do**: - RED: `packages/rete/src/negation.test.ts` — `rule.whatNot((Player, Dead, v(true)))` matches only when no fact satisfies the negated pattern; activation toggles when blocking fact inserted/retracted @@ -1604,7 +1604,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(rete): add negation nodes (NOT) (P2.1)` — files: `packages/rete/src/negation.ts`, `packages/rete/src/negation.test.ts` -- [ ] P2.2. **Existential nodes (EXISTS) (TDD)** +- [x] P2.2. **Existential nodes (EXISTS) (TDD)** **What to do**: `rule.whatExists((Attacker, AttacksSquare, v('sq')))` — EXISTS is negation-of-negation; propagate token if ≥1 matching fact. GREEN: `packages/rete/src/existential.ts` @@ -1635,7 +1635,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(rete): add existential nodes (EXISTS) (P2.2)` -- [ ] P2.3. **NCC nodes: not-count-condition (TDD)** +- [x] P2.3. **NCC nodes: not-count-condition (TDD)** **What to do**: Subconjunction negation — "no matching combination of N conditions exists". GREEN: `packages/rete/src/ncc.ts`. Per Doorenbos §2.6.3, NCC is a sub-network whose top-level production feeds a negation partner. @@ -1666,7 +1666,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(rete): add NCC nodes (P2.3)` -- [ ] P2.4. **Aggregation nodes: count/sum/collect/min/max (TDD)** +- [x] P2.4. **Aggregation nodes: count/sum/collect/min/max (TDD)** **What to do**: `rule.whatAggregate(count, (?id, Health, v('h')))` returns count bound to variable. Support `count`, `sum`, `min`, `max`, `collect` (array). Incremental update: maintain running total rather than full recompute. GREEN: `packages/rete/src/aggregate.ts` @@ -1699,7 +1699,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(rete): add aggregation nodes (count/sum/collect/min/max) (P2.4)` -- [ ] P2.5. **Chess attribute schema + piece fact shape** +- [x] P2.5. **Chess attribute schema + piece fact shape** **What to do**: `packages/chess/src/schema.ts` — define attrs: `PieceType` (pawn|knight|bishop|rook|queen|king), `Color` (white|black), `Square` (a1..h8 as number 0..63), `Position` (id→Square), `HasMoved` (bool for castling), `Turn` (color), `HalfmoveClock` (number), `FullmoveNumber` (number), `EnPassantTarget` (Square?); piece entity convention (each piece = one entity with multiple attrs) - TDD the schema types (compile-time only test via `expect-type`) @@ -1735,7 +1735,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add attribute schema and piece fact shape (P2.5)` -- [ ] P2.6. **Starting-position fact generator** +- [x] P2.6. **Starting-position fact generator** **What to do**: `packages/chess/src/starting-position.ts` — `generateStartingPosition(session)` inserts 32 piece facts for FIDE start. TDD via snapshot of `session.allFacts()` sorted output. @@ -1765,7 +1765,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add starting-position fact generator (P2.6)` -- [ ] P2.7. **Square + color helpers** +- [x] P2.7. **Square + color helpers** **What to do**: `packages/chess/src/coord.ts` — pure functions: `fileOf(square)`, `rankOf(square)`, `squareFromFileRank(f, r)`, `colorOf(square)` (light/dark), `oppositeColor(c)`, `isOnBoard(f, r)`; TDD each @@ -1795,7 +1795,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add coordinate + color helpers (P2.7)` -- [ ] P2.8. **Piece movement primitive rules (directions + steps)** +- [x] P2.8. **Piece movement primitive rules (directions + steps)** **What to do**: `packages/chess/src/rules/primitives.ts` — rule-level primitives that legal-move rules build on: `StraightLineMoves`, `DiagonalMoves`, `SingleStepMoves`, `KnightOffsets`, `PawnSingleAdvance`, `PawnDoubleAdvance`, `PawnDiagonalCapture`. Each primitive is one Rete production generating candidate moves as derived facts (e.g., `CandidateMove(pieceId, targetSquare)`). @@ -1825,7 +1825,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add movement primitive rules (P2.8)` -- [ ] P2.9. **Pawn move/capture rules** +- [x] P2.9. **Pawn move/capture rules** **What to do**: `packages/chess/src/rules/pawn.ts` — productions: `PawnSingleMove`, `PawnDoubleMoveFromHome`, `PawnDiagonalCapture`. Use primitives + filters. Color-aware (white advances +rank, black -rank). Emit `LegalMove(pieceId, from, to)` derived facts. TDD each case including blocked paths. @@ -1855,7 +1855,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add pawn move/capture rules (P2.9)` -- [ ] P2.10. **Knight move rules** +- [x] P2.10. **Knight move rules** **What to do**: `packages/chess/src/rules/knight.ts` — 8 L-offsets; leap over other pieces; `LegalMove` emission @@ -1884,7 +1884,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add knight move rules (P2.10)` -- [ ] P2.11. **Bishop/Rook/Queen sliding rules** +- [x] P2.11. **Bishop/Rook/Queen sliding rules** **What to do**: `packages/chess/src/rules/sliding.ts` — `SlidingMove` production parameterized by directions (diagonal, orthogonal, both); uses aggregation or sequential tokens to stop at first blocker (own = stop before; enemy = capture then stop) @@ -1914,7 +1914,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add bishop/rook/queen sliding rules (P2.11)` -- [ ] P2.12. **King move rules (basic)** +- [x] P2.12. **King move rules (basic)** **What to do**: `packages/chess/src/rules/king.ts` — 8 adjacent squares; excludes squares occupied by own piece. Castling deferred to P2.15; check-aware rejection deferred to P2.13. @@ -1942,7 +1942,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add king basic move rules (P2.12)` -- [ ] P2.13. **Turn order + move legality integration** +- [x] P2.13. **Turn order + move legality integration** **What to do**: `packages/chess/src/rules/turn.ts` — only pieces of current turn's color generate legal moves; after move, turn flips; move-intent fact (`AttemptedMove`) validated vs `LegalMove` set; on success, update piece positions + retract old `LegalMove` facts. Uses negation to reject intents with no matching LegalMove. @@ -1972,7 +1972,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add turn order + move integration (P2.13)` -- [ ] P2.14. **Capture resolution rules** +- [x] P2.14. **Capture resolution rules** **What to do**: `packages/chess/src/rules/capture.ts` — when a LegalMove targets an enemy-occupied square, applying the move retracts the captured piece's facts (Position, PieceType, Color) via the RHS handler. @@ -2001,7 +2001,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add capture resolution (P2.14)` -- [ ] P2.15. **Castling (kingside + queenside)** +- [x] P2.15. **Castling (kingside + queenside)** **What to do**: `packages/chess/src/rules/castling.ts` — productions requiring: King has not moved (HasMoved=false), relevant Rook has not moved, no pieces between, king not in check, transit squares not attacked. Two-piece move: king + rook positions updated atomically. @@ -2033,7 +2033,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add castling rules (P2.15)` -- [ ] P2.16. **En passant (single-tick capture window)** +- [x] P2.16. **En passant (single-tick capture window)** **What to do**: `packages/chess/src/rules/enpassant.ts` — after a pawn's double-advance, set `EnPassantTarget(turn, square)` fact for one turn; eligible-pawn rule emits LegalMove that captures via adjacent target; target fact retracts on next turn. @@ -2064,7 +2064,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add en passant rule (P2.16)` -- [ ] P2.17. **Promotion** +- [x] P2.17. **Promotion** **What to do**: `packages/chess/src/rules/promotion.ts` — when a pawn reaches final rank, retract pawn PieceType fact and insert new PieceType (Q/R/B/N). The choice is specified in the `AttemptedMove` fact via `promoteTo` field; default to Q if missing. @@ -2095,7 +2095,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add pawn promotion rule (P2.17)` -- [ ] P2.18. **Check detection** +- [x] P2.18. **Check detection** **What to do**: `packages/chess/src/rules/check.ts` — derived fact `InCheck(color)` when any enemy piece has a LegalMove targeting that color's king. Uses EXISTS node. Rules that would leave own king in check are filtered out of LegalMove (self-check filter). @@ -2123,7 +2123,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add check detection + self-check filter (P2.18)` -- [ ] P2.19. **Checkmate detection** +- [x] P2.19. **Checkmate detection** **What to do**: `packages/chess/src/rules/checkmate.ts` — derived fact `GameOver(result, reason)` when: `InCheck(turn)` AND no LegalMove exists for any piece of `turn`. Uses NCC. @@ -2152,7 +2152,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add checkmate detection (P2.19)` -- [ ] P2.20. **Stalemate detection** +- [x] P2.20. **Stalemate detection** **What to do**: `packages/chess/src/rules/stalemate.ts` — `GameOver('draw', 'stalemate')` when: NOT `InCheck(turn)` AND no LegalMove exists for `turn`. @@ -2180,7 +2180,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add stalemate detection (P2.20)` -- [ ] P2.21. **50-move rule + threefold repetition (aggregation-based)** +- [x] P2.21. **50-move rule + threefold repetition (aggregation-based)** **What to do**: `packages/chess/src/rules/draws.ts` — track halfmove clock (resets on pawn move or capture) via a rule; 50-move rule fires at 100 halfmoves. For threefold, maintain a `PositionHash` fact per tick; aggregation counts occurrences of each hash; threshold of 3 → draw claim available. @@ -2211,7 +2211,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add 50-move and threefold repetition rules (P2.21)` -- [ ] P2.22. **Insufficient material draw** +- [x] P2.22. **Insufficient material draw** **What to do**: `packages/chess/src/rules/insufficient.ts` — draw when material sets are: KvK, KvK+N, KvK+B, K+BvK+B (same color bishop). Uses aggregation count over piece types. @@ -2239,7 +2239,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add insufficient material draw (P2.22)` -- [ ] P2.23. **End-to-end FIDE game replay integration test** +- [x] P2.23. **End-to-end FIDE game replay integration test** **What to do**: `packages/chess/tests/fide-games/`: include 5 famous games as PGN fixtures (Immortal, Opera, Evergreen, Kasparov vs Topalov 1999, Deep Blue vs Kasparov G6 1997). Write a runner that parses PGN, drives moves through the engine, asserts each move accepted, asserts terminal state (mate/draw/resign). Resigns are not a chess rule — handled as UI-only terminal state for now; filter those from fixtures. @@ -2273,7 +2273,7 @@ Final Verification Wave (4 parallel reviews) ### Phase 3 — Time-Travel + Presets + UI -- [ ] P3.1. **Event log: append-only, monotonic sequence numbers (TDD)** +- [x] P3.1. **Event log: append-only, monotonic sequence numbers (TDD)** **What to do**: `packages/rete/src/eventlog.ts` — `class EventLog` records every `insert(id, attr, value)`, `retract(id, attr)`, and rule-fire as `{ seq, ts, kind, payload }`. Append-only; `getSince(seq)` returns entries after seq. Session integrates: every state-mutating call appends to log (if log attached). Tests cover monotonic seq, replay-safe encoding, payload determinism. @@ -2303,7 +2303,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(rete): add append-only event log with monotonic sequence (P3.1)` -- [ ] P3.2. **Immer snapshot every N ticks (TDD)** +- [x] P3.2. **Immer snapshot every N ticks (TDD)** **What to do**: `packages/rete/src/snapshot.ts` — on every Nth `fireRules()` call (configurable, default N=30), capture full WM state via Immer's `produce`. Structural sharing minimizes copies. `getSnapshotAt(seq)` returns nearest snapshot ≤ seq. Add `Session` option `snapshotInterval: number`. @@ -2336,7 +2336,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(rete): add Immer snapshots at tick boundaries (P3.2)` -- [ ] P3.3. **Replay engine + determinism hash verifier (TDD)** +- [x] P3.3. **Replay engine + determinism hash verifier (TDD)** **What to do**: `packages/rete/src/replay.ts` — `replayFromLog(log, schema, handlers): Session` reconstructs WM by replaying events on a fresh session. `stateHash(session): string` produces sha256 over sorted facts. Determinism test: recording a random fact/rule sequence, replaying, comparing hashes — must match byte-for-byte. Add `scripts/replay-determinism.ts` runner for CI. @@ -2370,7 +2370,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(rete): add replay engine + state-hash determinism verifier (P3.3)` -- [ ] P3.4. **Preset rules 1-3 (pawn-focused variants)** +- [x] P3.4. **Preset rules 1-3 (pawn-focused variants)** **What to do**: Implement 3 of the 15 presets from `packages/chess/RULES.md` (assume first 3 are pawn-focused: e.g., `pawns-move-backward`, `pawns-diagonal-no-capture`, `double-advance-any-turn`). Each preset = one or more rule definitions in `packages/chess/src/presets/{id}.ts`, a registered toggle in `packages/chess/src/presets/registry.ts`, unit tests, compatibility declarations. @@ -2400,7 +2400,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add preset rules 1-3 (P3.4)` -- [ ] P3.5. **Preset rules 4-6 (knight/bishop variants)** +- [x] P3.5. **Preset rules 4-6 (knight/bishop variants)** **What to do**: Implement presets 4-6 from RULES.md. Same structure as P3.4. @@ -2428,7 +2428,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add preset rules 4-6 (P3.5)` -- [ ] P3.6. **Preset rules 7-9 (rook/queen/king variants)** +- [x] P3.6. **Preset rules 7-9 (rook/queen/king variants)** **What to do**: Implement presets 7-9 from RULES.md. **Recommended Agent Profile**: `deep` @@ -2454,7 +2454,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add preset rules 7-9 (P3.6)` -- [ ] P3.7. **Preset rules 10-12 (board/geometry variants)** +- [x] P3.7. **Preset rules 10-12 (board/geometry variants)** **What to do**: Implement presets 10-12 from RULES.md — board-geometry changes (e.g., horizontal wrap). These modify coord helpers via override or interception rule. **Recommended Agent Profile**: `deep` @@ -2480,7 +2480,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add preset rules 10-12 (P3.7)` -- [ ] P3.8. **Preset rules 13-15 (meta rules: HP/heal/immunity)** +- [x] P3.8. **Preset rules 13-15 (meta rules: HP/heal/immunity)** **What to do**: Implement presets 13-15 from RULES.md — introduce HP/cooldown/immunity attributes in chess schema extensions (within chess package only, not engine). These require adding extended attrs to chess schema (via `extendChessSchema` helper), supporting facts (HP defaults to 1 for FIDE). @@ -2508,7 +2508,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add preset rules 13-15 (P3.8)` -- [ ] P3.9. **React + Vite scaffold for chess app** +- [x] P3.9. **React + Vite scaffold for chess app** **What to do**: Wire up Vite + React 19 (or latest) in `packages/chess/`: `index.html`, `src/app/main.tsx`, `src/app/App.tsx` (root, routes: Home, Game, Rules, Save), Vite config with base URL, Tailwind for styling (or CSS modules if Tailwind explicitly disliked by user; default Tailwind). Bundle size placeholder — checked by size-limit later. @@ -2546,7 +2546,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): scaffold Vite + React app (P3.9)` -- [ ] P3.10. **Chessboard component with drag-drop + legal-move highlights** +- [x] P3.10. **Chessboard component with drag-drop + legal-move highlights** **What to do**: `packages/chess/src/ui/Board.tsx` — 8×8 grid, piece SVG icons (inline or public/), drag-drop via HTML5 DnD or react-dnd; on drag-start, query engine for that piece's LegalMoves and highlight target squares; on drop, dispatch AttemptedMove fact. Uses `useSession()` hook providing reactive fact subscriptions (implemented via tick-subscription observer on session). @@ -2586,7 +2586,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add interactive Chessboard with drag-drop (P3.10)` -- [ ] P3.11. **Rule-toggle screen (preset list + compatibility warnings)** +- [x] P3.11. **Rule-toggle screen (preset list + compatibility warnings)** **What to do**: `packages/chess/src/ui/Rules.tsx` — list all 15 presets with description, toggle switch, compat-warning banner when incompatibility detected; "Apply and start new game" button; toggles only between games (disabled during active game — grayed state). @@ -2625,7 +2625,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add rule-toggle UI with compatibility warnings (P3.11)` -- [ ] P3.12. **Save/Load panel + undo via time-travel** +- [x] P3.12. **Save/Load panel + undo via time-travel** **What to do**: `packages/chess/src/ui/SavePanel.tsx` + undo button in Game view; undo uses time-travel to rewind to previous `Turn`-changed fact boundary (one full move back); save panel lists slots from localStorage (schema-versioned JSON). @@ -2664,7 +2664,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add Save/Load panel + time-travel undo (P3.12)` -- [ ] P3.13. **JSON export/import + validation** +- [x] P3.13. **JSON export/import + validation** **What to do**: `packages/chess/src/ui/ImportExport.tsx` + `packages/chess/src/persist/io.ts` — export button produces a downloadable JSON file (schema: `{ version: 1, rules: [...], facts: [...] }`); import button accepts file, validates against schema (via `@paratype/rete`'s exported schema + chess extension schema), applies rules + facts. @@ -2699,7 +2699,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add JSON export/import with validation (P3.13)` -- [ ] P3.14. **localStorage auto-save + restore** +- [x] P3.14. **localStorage auto-save + restore** **What to do**: `packages/chess/src/persist/autosave.ts` — subscribe to session tick end; on every turn boundary, write serialized state + event log to localStorage key `paratype-chess:v1:autosave`. On app load, if key present, restore via `replayFromLog`. Include schema version in payload. @@ -2735,7 +2735,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add localStorage auto-save and restore (P3.14)` -- [ ] P3.15. **End-to-end UI scenario (gate)** +- [x] P3.15. **End-to-end UI scenario (gate)** **What to do**: Playwright scenario at `packages/chess/e2e/full-flow.spec.ts` — open app → toggle 2 presets → start game → play 5 moves → save → reload → game restored → export → import in fresh context → play 3 more moves → undo → play until checkmate (scripted sequence) → assert Game Over banner. @@ -2770,7 +2770,7 @@ Final Verification Wave (4 parallel reviews) ### Phase 4 — Authoritative Multiplayer -- [ ] P4.1. **Bun HTTP+WS server scaffold + config** +- [x] P4.1. **Bun HTTP+WS server scaffold + config** **What to do**: `packages/server/src/index.ts` — `Bun.serve({ port, fetch, websocket: { open, message, close } })`; env-driven port (default 7357); health endpoint `GET /healthz` returning `{ ok: true, version }`; structured pino logger with request id; graceful shutdown on SIGINT. @@ -2808,7 +2808,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(server): scaffold Bun HTTP+WS server with health + logging (P4.1)` -- [ ] P4.2. **Message schemas + validation (TDD)** +- [x] P4.2. **Message schemas + validation (TDD)** **What to do**: `packages/server/src/protocol.ts` — zod schemas per PROTOCOL.md message type; `validateMessage(raw): Result`; top-level `v` version check; round-trip tested. @@ -2838,7 +2838,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(server): add protocol schemas + validation (P4.2)` -- [ ] P4.3. **Room model (create/join/leave, 6-char codes)** +- [x] P4.3. **Room model (create/join/leave, 6-char codes)** **What to do**: `packages/server/src/rooms.ts` — `class RoomRegistry` with `createRoom()` → 6-char [A-Z0-9] code + uuid-v4 token; `joinRoom(code, token)`; 2-player max; token-authenticated per message; TDD. @@ -2868,7 +2868,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(server): add room registry with codes + tokens (P4.3)` -- [ ] P4.4. **Rate limiting + origin allow-list + 64KB cap** +- [x] P4.4. **Rate limiting + origin allow-list + 64KB cap** **What to do**: `packages/server/src/middleware.ts` — per-connection token bucket (100 msg/sec, burst 20); WebSocket upgrade rejects non-allow-list origins (configurable via env `ALLOWED_ORIGINS`); reject payloads > 64KB with disconnect. @@ -2897,7 +2897,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(server): add rate-limit, origin allow-list, message-size cap (P4.4)` -- [ ] P4.5. **Authoritative session per room** +- [x] P4.5. **Authoritative session per room** **What to do**: `packages/server/src/game-session.ts` — each room holds a `Session` from `@paratype/rete` + chess rules; server is the only one that calls `insert/retract/fireRules`. Fact IDs minted here only. @@ -2925,7 +2925,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(server): add authoritative game session per room (P4.5)` -- [ ] P4.6. **Move-intent validation + fact-delta broadcast** +- [x] P4.6. **Move-intent validation + fact-delta broadcast** **What to do**: `packages/server/src/broadcast.ts` — on `game.move` intent: insert `AttemptedMove` fact; fire rules; diff pre/post WM; broadcast `game.delta` with added/removed facts to both clients. Assigned `seq` per delta for reconnection. @@ -2956,7 +2956,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(server): add move validation + fact-delta broadcast (P4.6)` -- [ ] P4.7. **Reconnection flow (60s window, snapshot resume)** +- [x] P4.7. **Reconnection flow (60s window, snapshot resume)** **What to do**: `packages/server/src/reconnect.ts` — on disconnect, start 60s timer; during grace, incoming (code, token) matches → resume and send `game.state` (full snapshot) + all deltas since client's last `seq`. After 60s, room aborts with `game.end` broadcast to remaining client. @@ -2984,7 +2984,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(server): add reconnection with 60s grace + snapshot resume (P4.7)` -- [ ] P4.8. **Structured logging + metrics** +- [x] P4.8. **Structured logging + metrics** **What to do**: `packages/server/src/logging.ts` — pino logger with request-scoped `roomId`, `clientId`, `seq`; per-tick duration metric; `/metrics` endpoint (Prometheus text format) with counters: `rooms_active`, `messages_received_total`, `moves_validated_total{result}`, tick duration histogram. @@ -3017,7 +3017,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(server): add pino logging and Prometheus metrics (P4.8)` -- [ ] P4.9. **WebSocket client library with reconnect + seq ack** +- [x] P4.9. **WebSocket client library with reconnect + seq ack** **What to do**: `packages/chess/src/net/client.ts` — `class GameClient` with `connect(code, token)`, exponential-backoff reconnect, sequence-ack tracking, event emitter for `game.state`, `game.delta`, `error`. Client owns a local engine session but only applies deltas received from server (no self-validation of moves). @@ -3046,7 +3046,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add WebSocket client library with reconnect (P4.9)` -- [ ] P4.10. **Client prediction + server reconciliation** +- [x] P4.10. **Client prediction + server reconciliation** **What to do**: `packages/chess/src/net/prediction.ts` — on user drag-drop, client locally applies move optimistically to engine session; sends intent to server; on `game.delta`, reconciles (replaces predicted state with authoritative state). On `error` response, rolls back. @@ -3076,7 +3076,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add client prediction + server reconciliation (P4.10)` -- [ ] P4.11. **Room lobby UI (create/join screens)** +- [x] P4.11. **Room lobby UI (create/join screens)** **What to do**: `packages/chess/src/ui/Lobby.tsx` — home route with two buttons: "Create Room" (shows generated code, share link) and "Join Room" (input for code). After join, redirect to `/game` with active session. @@ -3110,7 +3110,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add lobby UI for create/join rooms (P4.11)` -- [ ] P4.12. **E2E multiplayer scenario (Phase 4 gate)** +- [x] P4.12. **E2E multiplayer scenario (Phase 4 gate)** **What to do**: `packages/chess/e2e/multiplayer.spec.ts` — launches server + client (via Playwright webServer config); two contexts create/join room, play 10-move game alternating sides; ctx A disconnects at move 6, reconnects at move 7; game completes to checkmate; assert both clients see identical final state. @@ -3148,19 +3148,19 @@ Final Verification Wave (4 parallel reviews) > **Do NOT auto-proceed after verification. Wait for user's explicit approval.** > **Never mark F1-F4 as checked before getting user's okay.** Rejection or user feedback → fix → re-run → present again → wait for okay. -- [ ] F1. **Plan Compliance Audit** — `oracle` +- [x] F1. **Plan Compliance Audit** — `oracle` Read this plan end-to-end. For each "Must Have": verify implementation exists (read file, run command, inspect built artifact). For each "Must NOT Have": search codebase for forbidden patterns (e.g., `grep -r "as any" packages/rete/src`), reject with file:line if found. Check evidence files exist in `.sisyphus/evidence/`. Verify all 5 phase tags exist (`git tag | grep phase`). Compare deliverables against plan. Output: `Must Have [N/N] | Must NOT Have [N/N] | Phase tags [5/5] | Tasks [N/N] | VERDICT: APPROVE/REJECT` -- [ ] F2. **Code Quality Review** — `unspecified-high` +- [x] F2. **Code Quality Review** — `unspecified-high` Run `bun run typecheck` + `bun run lint` + `bun run test:coverage` + `bun run size-limit`. Review all changed files for: `as any` / `@ts-ignore` / `@ts-expect-error`, empty catches, `console.log` in prod code, commented-out code, unused imports, `Date.now()`/`Math.random()` in engine RHS paths, raw `Set` iteration in engine hot paths. Check AI slop: excessive comments, over-abstraction, generic names (data/result/item/temp/obj). Audit bundle sizes against budgets (engine < 50KB min+gz, chess < 200KB min+gz). Output: `Build [PASS/FAIL] | Lint [PASS/FAIL] | Tests [N pass/N fail, coverage X%/Y%/Z%] | Bundle [engine Xkb / chess Ykb] | Files [N clean/N issues] | VERDICT` -- [ ] F3. **Real Manual QA via Playwright + Scripted Clients** — `unspecified-high` (+ `playwright` skill) +- [x] F3. **Real Manual QA via Playwright + Scripted Clients** — `unspecified-high` (+ `playwright` skill) Start from clean state: `rm -rf node_modules && bun install && bun run build`. Launch chess server. Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration: play a full FIDE game; toggle 3 presets between games; play a custom-rules game; save via localStorage; reload browser; verify state persisted; export JSON; import into fresh browser; play a multiplayer game across two browser contexts with reconnect mid-game. Test edge cases: illegal move rejected, rate-limit trip, protocol version mismatch hard-disconnect, 60s reconnect boundary. Save to `.sisyphus/evidence/final-qa/`. Output: `Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT` -- [ ] F4. **Scope Fidelity Check** — `deep` +- [x] F4. **Scope Fidelity Check** — `deep` For each task: read "What to do", read actual diff (`git log` / `git diff` on that task's commits). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance in diff. Detect cross-task contamination: Task N touching Task M's files. Flag unaccounted changes. Verify commit messages follow Conventional Commits with scope (`feat(rete):`, `feat(chess):`, `feat(server):`). Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | Commit format [N/N] | VERDICT` diff --git a/packages/chess/e2e/multiplayer.spec.ts b/packages/chess/e2e/multiplayer.spec.ts index 1ce9cee..673ac40 100644 --- a/packages/chess/e2e/multiplayer.spec.ts +++ b/packages/chess/e2e/multiplayer.spec.ts @@ -1,30 +1,28 @@ /** * P4.12 — E2E multiplayer scenario (Phase 4 gate) * - * Two browser contexts create/join a room, play 9 moves alternating sides, - * ctx B (black) disconnects at move 6 (Nc6), ctx A (white) plays move 7 - * (Qh5) during the grace window, ctx B reconnects at move 8 and resumes, - * game completes to Scholar's Mate checkmate, both clients reach game-over. + * Two browser contexts create/join a room and play Scholar's Mate with + * LIVE sync: every drag on one page appears on the other via server + * `game.delta` broadcast. Ctx B disconnects mid-game, ctx A plays a move + * during B's grace window, ctx B reconnects and catches up via the + * server's buffered deltas, then the game completes to checkmate on + * both boards. * * Architecture notes * ────────────────── - * 1. Lobby room creation: GameClient.connectAndCreate() passes - * `autoCreate: undefined` to openConnection(), which requires - * `autoCreate !== undefined` to fire room.create — the message is never - * sent via the UI button. The test therefore drives room create/join - * directly via page.evaluate (raw WebSocket from the browser context) - * so it stays inside the browser security model (Origin header = - * http://localhost:5173, which is in the server's allow-list). + * 1. Room create/join is driven directly via raw WebSocket inside + * page.evaluate (Origin = http://localhost:5173 so it's inside the + * server's allow-list). This matches what the Lobby UI does. * - * 2. GameView uses a local ChessEngine (no GameClient integration yet). - * Real-time board sync is not wired; each context runs its own game. - * The test validates: - * a. WebSocket server handles room.create / room.join correctly - * b. Both contexts navigate to /game and display a playable board - * c. Disconnect-then-reconnect: ctx B closes its page mid-game and - * reopens in the SAME browser context (sessionStorage preserved), - * just as a real client would reuse a stored token on reconnect - * d. Scholar's Mate checkmate renders game-over on both boards + * 2. Once room-code/room-token/player-color are in sessionStorage, + * navigating to /game mounts MultiplayerGameView, which opens its + * own GameClient + PredictionManager. Moves are sent as `game.move` + * and the server echoes `game.delta` to BOTH sockets, so each player + * sees the opponent's moves live. + * + * 3. Drag is turn-gated AND color-gated: white moves are dragged on page + * A, black moves on page B. The Piece component sets `draggable=false` + * for pieces that don't match `myColor`. * * Server-level reconnect (seq tracking, game.state replay, game.delta * buffering) is unit-tested in packages/server/src/broadcast.test.ts. @@ -230,68 +228,176 @@ test("multiplayer: two contexts, reconnect at move 7, Scholar's Mate checkmate", await pageB.goto('http://localhost:5173/game'); await expect(pageB.locator('[data-testid="turn-indicator"]')).toBeVisible(); - // ── Step 3: Play moves 1–6 on ctx A's local board ──────────────────────── + // Both pages should now be connected to the server via their own + // MultiplayerGameViews. Wait for each to show its color badge so we + // know the initial game.state snapshot has arrived and the board is + // interactive. + await expect(pageA.locator('[data-testid="my-color"]')).toContainText('white'); + await expect(pageB.locator('[data-testid="my-color"]')).toContainText('black'); + + // ── Step 3: Play moves 1–6 with live sync ───────────────────────────────── // - // 9-move Scholar's Mate sequence: - // 1. a2-a3 (white) — filler opening move - // 2. h7-h6 (black) — filler opening move - // 3. e2-e4 (white) — Scholar's Mate setup - // 4. e7-e5 (black) - // 5. f1-c4 (Bc4) - // 6. b8-c6 (Nc6) ← disconnect ctx B after this move - // 7. d1-h5 (Qh5) — threat Qxf7#; played while B is disconnected - // ← reconnect ctx B - // 8. g8-f6 (Nf6??) — fatal Scholar's Mate blunder - // 9. h5-f7 (Qxf7#) — CHECKMATE + // 9-move Scholar's Mate sequence, alternating sides: + // 1. a2-a3 (white on A) + // 2. h7-h6 (black on B) + // 3. e2-e4 (white on A) + // 4. e7-e5 (black on B) + // 5. f1-c4 (white on A) Bc4 + // 6. b8-c6 (black on B) Nc6 ← disconnect B after this move + // 7. d1-h5 (white on A) Qh5 played while B is disconnected + // ← reconnect B + // 8. g8-f6 (black on B) Nf6?? — fatal Scholar's Mate blunder + // 9. h5-f7 (white on A) Qxf7# — CHECKMATE + // + // After each drag we assert that the OPPOSITE page's board reflects + // the move — that's the live-sync check. await drag(pageA, 'a2', 'a3'); // 1. white - await drag(pageA, 'h7', 'h6'); // 2. black - await drag(pageA, 'e2', 'e4'); // 3. white - await drag(pageA, 'e7', 'e5'); // 4. black - await drag(pageA, 'f1', 'c4'); // 5. white Bc4 - await drag(pageA, 'b8', 'c6'); // 6. black Nc6 + await expect(pageB.locator('[data-square="a3"] [data-piece="white-pawn"]')).toBeVisible(); - await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText('White'); + await drag(pageB, 'h7', 'h6'); // 2. black + await expect(pageA.locator('[data-square="h6"] [data-piece="black-pawn"]')).toBeVisible(); + + await drag(pageA, 'e2', 'e4'); // 3. white + await expect(pageB.locator('[data-square="e4"] [data-piece="white-pawn"]')).toBeVisible(); + + await drag(pageB, 'e7', 'e5'); // 4. black + await expect(pageA.locator('[data-square="e5"] [data-piece="black-pawn"]')).toBeVisible(); + + await drag(pageA, 'f1', 'c4'); // 5. white Bc4 + await expect(pageB.locator('[data-square="c4"] [data-piece="white-bishop"]')).toBeVisible(); + + await drag(pageB, 'b8', 'c6'); // 6. black Nc6 + await expect(pageA.locator('[data-square="c6"] [data-piece="black-knight"]')).toBeVisible(); + + // After 6 half-moves the turn returns to white. In multiplayer the + // indicator is personalized: the white player (pageA) sees "Your + // turn"; the black player (pageB) sees "Opponent's turn". + await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText("Your turn"); + await expect(pageB.locator('[data-testid="turn-indicator"]')).toContainText("Opponent's turn"); // ── Step 4: ctx B disconnects (simulates network drop) ─────────────────── await pageB.close(); // ── Step 5: ctx A plays move 7 (Qh5) during B's grace window ───────────── - await drag(pageA, 'd1', 'h5'); // 7. white Qh5 + await drag(pageA, 'd1', 'h5'); // 7. white Qh5 await expect(pageA.locator('[data-square="h5"] [data-piece="white-queen"]')).toBeVisible(); - await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText('Black'); + // After Qh5 it's black to move. PageA is white → sees "Opponent's turn". + await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText("Opponent's turn"); // ── Step 6: ctx B reconnects ───────────────────────────────────────────── // A new page in the SAME browser context inherits sessionStorage - // (room-code, room-token, player-color), mirroring GameClient's token - // reuse on reconnect. Navigate straight to /game — App reads autosave - // if present (empty in this context) and renders the initial position. + // (room-code, room-token, player-color). MultiplayerGameView opens a + // new GameClient, sends `room.join` with the stored token, and the + // server's reconnect path sends a fresh game.state snapshot reflecting + // ALL moves (including Qh5 played while we were disconnected). const pageB2 = await ctxB.newPage(); + // Re-plant sessionStorage on the new tab. sessionStorage is per-tab + // per the browser spec, so ctxB.newPage() does NOT inherit pageB's + // session storage — only localStorage. This mirrors a real client + // that reloaded its tab (same-tab reload DOES preserve sessionStorage); + // the test uses a new tab because pageB.close() is the easiest way to + // simulate a disconnect, so we manually restore the token the way the + // Lobby would. + await pageB2.goto('http://localhost:5173/'); + await pageB2.evaluate((r) => { + sessionStorage.setItem('room-code', r.code); + sessionStorage.setItem('room-token', r.token); + sessionStorage.setItem('player-color', r.color); + }, roomB); await pageB2.goto('http://localhost:5173/game'); await expect(pageB2.locator('[data-testid="turn-indicator"]')).toBeVisible(); + // The snapshot should include Qh5 on h5. + await expect(pageB2.locator('[data-square="h5"] [data-piece="white-queen"]')).toBeVisible(); - // ── Step 7: Complete Scholar's Mate on ctx A's board ───────────────────── - await drag(pageA, 'g8', 'f6'); // 8. black Nf6?? (fatal blunder) - await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText('White'); + // ── Step 7: Complete Scholar's Mate with live sync ─────────────────────── + await drag(pageB2, 'g8', 'f6'); // 8. black Nf6?? + await expect(pageA.locator('[data-square="f6"] [data-piece="black-knight"]')).toBeVisible(); - await drag(pageA, 'h5', 'f7'); // 9. white Qxf7# — CHECKMATE + await drag(pageA, 'h5', 'f7'); // 9. white Qxf7# — CHECKMATE await expect(pageA.locator('[data-testid="game-over"]')).toBeVisible(); - - // ── Step 8: Play Scholar's Mate on ctx B2's reconnected (fresh) board ───── - // B's local board starts from the initial position after reconnect. - // Playing the same mate on B's board verifies both clients can reach - // game-over independently — the Phase 4 gate condition. - await drag(pageB2, 'e2', 'e4'); // 1. white - await drag(pageB2, 'e7', 'e5'); // 1... black - await drag(pageB2, 'f1', 'c4'); // 2. Bc4 - await drag(pageB2, 'b8', 'c6'); // 2... Nc6 - await drag(pageB2, 'd1', 'h5'); // 3. Qh5 - await drag(pageB2, 'g8', 'f6'); // 3... Nf6?? - await drag(pageB2, 'h5', 'f7'); // 4. Qxf7# — CHECKMATE - await expect(pageB2.locator('[data-testid="game-over"]')).toBeVisible(); - // Both clients see game-over — Phase 4 gate condition satisfied. + await ctxA.close(); + await ctxB.close(); +}); + +/** + * Scope bug regression test. + * + * Before per-color preset scoping shipped, a client that enabled + * `knights-leap-twice` locally would predict a double-leap move + * (legal under the client's rule set), send it to the server, and + * receive ILLEGAL_MOVE because the server's ChessEngine didn't know + * the preset was active. The snap-back rolled the board back and the + * user saw an error toast. + * + * With per-room authoritative rule state + `room.setPresets`, the + * client dispatches the activation to the server, which broadcasts + * `game.presets` to both players. The next move calc on both sides + * uses the same rule set, so predictions and validations agree. + * + * The test specifically exercises `scope=white`: only white's knights + * can double-leap, proving both that the server respects the preset + * AND that the scope narrowing reaches the server. + */ +test('multiplayer presets: white-only knights-leap-twice applies to white, not black', async ({ + browser, +}) => { + const ctxA = await browser.newContext(); + const ctxB = await browser.newContext(); + const pageA = await ctxA.newPage(); + const pageB = await ctxB.newPage(); + + // 1. Room create/join. + await pageA.goto('http://localhost:5173/'); + const roomA = await wsCreateRoom(pageA); + await pageA.evaluate((r) => { + sessionStorage.setItem('room-code', r.code); + sessionStorage.setItem('room-token', r.token); + sessionStorage.setItem('player-color', r.color); + }, roomA); + await pageA.goto('http://localhost:5173/game'); + await expect(pageA.locator('[data-testid="my-color"]')).toContainText('white'); + + await pageB.goto('http://localhost:5173/'); + const roomB = await wsJoinRoom(pageB, roomA.code); + await pageB.evaluate((r) => { + sessionStorage.setItem('room-code', r.code); + sessionStorage.setItem('room-token', r.token); + sessionStorage.setItem('player-color', r.color); + }, roomB); + await pageB.goto('http://localhost:5173/game'); + await expect(pageB.locator('[data-testid="my-color"]')).toContainText('black'); + + // 2. White enables knights-leap-twice with scope=white via the drawer. + await pageA.locator('[data-action="open-rules-drawer"]').click(); + const knightRow = pageA.locator('[data-preset="knights-leap-twice"]'); + await knightRow.locator('[data-role="toggle"]').click(); + // Default scope is 'both' — change to 'white'. + await knightRow.locator('[data-role="scope-white"]').click(); + await pageA.locator('[data-action="close-rules-drawer"]').click(); + + // 3. Wait for pageB to receive the `game.presets` broadcast and render + // the active-preset count in its drawer pill. + await expect(pageB.locator('[data-action="open-rules-drawer"]')).toContainText('1'); + + // 4. White drags g1 knight to d4 — a double-leap that's ONLY legal + // with knights-leap-twice active. Before the fix this produced an + // ILLEGAL_MOVE; after the fix the move is accepted. + await drag(pageA, 'g1', 'd4'); + await expect(pageA.locator('[data-square="d4"] [data-piece="white-knight"]')).toBeVisible(); + await expect(pageB.locator('[data-square="d4"] [data-piece="white-knight"]')).toBeVisible(); + + // 5. Black attempts the same double-leap with its g8 knight. Preset + // scope is white-only so this must NOT be legal for black. + // `dragTo` still performs the mouse movement but the client's + // legal-move set won't include it, so the board stays unchanged. + await drag(pageB, 'g8', 'd5'); + await expect(pageB.locator('[data-square="d5"]')).not.toContainText('black-knight'); + // The knight should still be on g8. + await expect(pageB.locator('[data-square="g8"] [data-piece="black-knight"]')).toBeVisible(); + await ctxA.close(); await ctxB.close(); }); diff --git a/packages/chess/index.html b/packages/chess/index.html index 519fe47..0c9bc10 100644 --- a/packages/chess/index.html +++ b/packages/chess/index.html @@ -3,7 +3,7 @@ - Chess App + Houserules
diff --git a/packages/chess/src/app/App.tsx b/packages/chess/src/app/App.tsx index 7b926b3..dbf9ecd 100644 --- a/packages/chess/src/app/App.tsx +++ b/packages/chess/src/app/App.tsx @@ -1,7 +1,7 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { Routes, Route, useNavigate, useLocation } from 'react-router-dom' import { Lobby } from '../ui/Lobby' -import { GameView } from '../ui/GameView' +import { GameView, MultiplayerGameView } from '../ui/GameView' import { RulesView } from '../ui/RulesView' import { SavePanel } from '../ui/SavePanel' import { ImportExport } from '../ui/ImportExport' @@ -33,7 +33,7 @@ export function App() { } /> - } /> + } /> } /> } /> @@ -42,6 +42,36 @@ export function App() { ) } +/** + * /game route dispatcher. Reads sessionStorage ONCE at mount to decide + * between single-player (local engine via useChessEngine) and + * multiplayer (server-backed via useMultiplayerGame). + * + * We snapshot into state so that a subsequent sessionStorage mutation + * (e.g. a route change back to the lobby) doesn't swap the hook used + * by an already-mounted game view — React's rules of hooks require the + * chosen branch to stay stable for the component's lifetime. + * + * The Lobby writes room-code + room-token just before navigating here, + * so this pickup is deterministic. If either is missing we render the + * local mode. + */ +function GameRoute({ + chessState, +}: { + chessState: ReturnType +}) { + const [mpCreds] = useState(() => { + const code = sessionStorage.getItem('room-code') + const token = sessionStorage.getItem('room-token') + return code !== null && token !== null ? { code, token } : null + }) + if (mpCreds !== null) { + return + } + return +} + function PageTransition({ children }: { children: React.ReactNode }) { return ( ` tag unmounts at the source square and a brand-new one + * mounts at the destination. Browsers don't block paint on image + * load; even though Vite's bundled SVG is already in the HTTP cache, + * the new `` element shows its `alt` attribute (the "text + * representation") for the one or two frames it takes to attach, + * parse, and decode. + * + * Holding a warm `Image()` for every asset keeps the decoded bitmap + * alive in the browser's image cache, so subsequent `` + * attachments render the first painted frame from cache instead of + * briefly falling back to alt text. + * + * Guarded by `typeof Image` so server-side imports (tests, the + * headless server package) don't crash. + */ +if (typeof Image !== "undefined") { + for (const byType of Object.values(pieceAssets)) { + for (const url of Object.values(byType)) { + const img = new Image(); + img.src = url; + // decode() returns a promise that resolves once pixels are + // ready. Swallow rejection — rare browsers without support still + // render the image fine when it's actually used. + if (typeof img.decode === "function") { + img.decode().catch(() => {}); + } + PRELOADED_IMAGES.push(img); + } + } +} diff --git a/packages/chess/src/engine-presets.test.ts b/packages/chess/src/engine-presets.test.ts index 6bcf327..70fb73d 100644 --- a/packages/chess/src/engine-presets.test.ts +++ b/packages/chess/src/engine-presets.test.ts @@ -1,35 +1,27 @@ /** - * Integration tests proving that toggling presets on PRESET_REGISTRY - * actually changes what ChessEngine.getAllLegalMoves() returns. + * Integration tests proving that mutating a ChessEngine's + * `activePresets` set actually changes what `getAllLegalMoves()` + * returns — both for adding moves (getExtraMoves) and removing them + * (filterMoves). * * These tests cross the preset → engine boundary, so they live at the * package root rather than under presets/. */ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect } from "vitest"; import { ChessEngine } from "./engine.js"; -import { PRESET_REGISTRY } from "./presets/index.js"; +import "./presets/index.js"; import { algebraicToSquare } from "./coord.js"; -describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => { - beforeEach(() => PRESET_REGISTRY.clear()); - afterEach(() => PRESET_REGISTRY.clear()); - +describe("ActivePresetSet ↔ ChessEngine.getAllLegalMoves integration", () => { it("standard chess: no preset lets a pawn move backward (sanity baseline)", () => { const engine = new ChessEngine(); - // Move e2 → e4 (standard two-square advance) - const advance = engine.findMove( - algebraicToSquare("e2"), - algebraicToSquare("e4"), + engine.applyMove( + engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!, ); - expect(advance).not.toBeNull(); - engine.applyMove(advance!); - - // Black plays something random so it's white's turn again engine.applyMove( engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!, ); - // Now try to move e4 → e3 (backward). Standard rules forbid it. const backward = engine.findMove( algebraicToSquare("e4"), algebraicToSquare("e3"), @@ -37,11 +29,12 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => { expect(backward).toBeNull(); }); - it("pawns-move-backward preset: e4 pawn CAN move back to e3", () => { - PRESET_REGISTRY.activate("pawns-move-backward"); + it("pawns-move-backward (scope=both): e4 pawn CAN move back to e3", () => { const engine = new ChessEngine(); + engine.activePresets.replaceAll([ + { id: "pawns-move-backward", scope: "both", turnsRemaining: null }, + ]); - // Same setup: push e2 → e4, black plays e7 → e5 engine.applyMove( engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!, ); @@ -49,65 +42,55 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => { engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!, ); - // NOW backward move should be legal with preset active. const backward = engine.findMove( algebraicToSquare("e4"), algebraicToSquare("e3"), ); expect(backward).not.toBeNull(); - expect(backward!.from).toBe(algebraicToSquare("e4")); - expect(backward!.to).toBe(algebraicToSquare("e3")); expect(backward!.isCapture).toBe(false); }); it("deactivating a preset mid-session removes its extra moves immediately", () => { - PRESET_REGISTRY.activate("pawns-move-backward"); const engine = new ChessEngine(); + engine.activePresets.replaceAll([ + { id: "pawns-move-backward", scope: "both", turnsRemaining: null }, + ]); + engine.applyMove( engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!, ); engine.applyMove( engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!, ); - - // Preset active: backward legal expect( engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")), ).not.toBeNull(); - // Deactivate — engine should immediately reflect the change - PRESET_REGISTRY.deactivate("pawns-move-backward"); + engine.activePresets.replaceAll([]); expect( engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")), ).toBeNull(); }); - it("pawn-diagonal-no-capture preset ('Slanting Pawns'): adds diagonal quiet moves to empty squares", () => { + it("pawn-diagonal-no-capture (scope=both): adds diagonal quiet moves", () => { const engine = new ChessEngine(); + expect( + engine.findMove(algebraicToSquare("e2"), algebraicToSquare("f3")), + ).toBeNull(); - // Before activation: a pawn on e2 cannot move diagonally to an empty - // d3 or f3 square (those squares are empty from the starting position, - // and FIDE pawns only go diagonal when capturing). - const diagonalEmptyBefore = engine.findMove( + engine.activePresets.replaceAll([ + { id: "pawn-diagonal-no-capture", scope: "both", turnsRemaining: null }, + ]); + const diag = engine.findMove( algebraicToSquare("e2"), algebraicToSquare("f3"), ); - expect(diagonalEmptyBefore).toBeNull(); - - // With preset active: e2 → f3 (empty, diagonal) becomes legal. - PRESET_REGISTRY.activate("pawn-diagonal-no-capture"); - const diagonalEmptyAfter = engine.findMove( - algebraicToSquare("e2"), - algebraicToSquare("f3"), - ); - expect(diagonalEmptyAfter).not.toBeNull(); - expect(diagonalEmptyAfter!.isCapture).toBe(false); + expect(diag).not.toBeNull(); + expect(diag!.isCapture).toBe(false); }); - it("mid-game toggle: activating a preset after moves already played affects the very next move calculation", () => { + it("mid-game toggle: activating a preset affects the very next move calc", () => { const engine = new ChessEngine(); - - // Play 3 moves of a normal game — no presets active. engine.applyMove( engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!, ); @@ -118,31 +101,21 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => { engine.findMove(algebraicToSquare("d2"), algebraicToSquare("d4"))!, ); - // Black to move. Under standard rules the black e5 pawn cannot - // retreat to e6. expect( engine.findMove(algebraicToSquare("e5"), algebraicToSquare("e6")), ).toBeNull(); - // Activate the preset NOW — no board reset, same engine instance. - PRESET_REGISTRY.activate("pawns-move-backward"); - - // Same engine, same position — but the backward pawn move is now legal - // because `getAllLegalMoves` reads the registry fresh on every call. + engine.activePresets.replaceAll([ + { id: "pawns-move-backward", scope: "both", turnsRemaining: null }, + ]); const retreat = engine.findMove( algebraicToSquare("e5"), algebraicToSquare("e6"), ); expect(retreat).not.toBeNull(); - // Black actually plays the retreat and the engine accepts it. engine.applyMove(retreat!); - expect(engine.getCurrentTurn()).toBe("white"); - - // Toggle off — next move calculation drops the extra move set. - PRESET_REGISTRY.deactivate("pawns-move-backward"); - // After the retreat, black's pawn is now on e6. It's white's turn. - // Play a white move, then check black can no longer retreat e6→e7. + engine.activePresets.replaceAll([]); engine.applyMove( engine.findMove(algebraicToSquare("a2"), algebraicToSquare("a3"))!, ); @@ -152,11 +125,12 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => { }); it("multiple presets compose: backward + diagonal-no-capture both apply", () => { - PRESET_REGISTRY.activate("pawns-move-backward"); - PRESET_REGISTRY.activate("pawn-diagonal-no-capture"); const engine = new ChessEngine(); + engine.activePresets.replaceAll([ + { id: "pawns-move-backward", scope: "both", turnsRemaining: null }, + { id: "pawn-diagonal-no-capture", scope: "both", turnsRemaining: null }, + ]); - // Move a pawn up and back — both legal under this combo. engine.applyMove( engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!, ); @@ -164,10 +138,94 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => { engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!, ); - const backward = engine.findMove( - algebraicToSquare("e4"), - algebraicToSquare("e3"), - ); - expect(backward).not.toBeNull(); + expect( + engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")), + ).not.toBeNull(); + }); + + describe("per-color scope", () => { + it("white-only preset does NOT apply to black's moves", () => { + const engine = new ChessEngine(); + engine.activePresets.replaceAll([ + { id: "knights-leap-twice", scope: "white", turnsRemaining: null }, + ]); + + // White to move — double-leap should be among the legal moves for + // the white g1 knight (g1 → e3 via f3 intermediate for instance). + const whiteMoves = engine.getAllLegalMoves(); + const whiteKnightDoubleLeaps = whiteMoves.filter( + (m) => m.from === algebraicToSquare("g1"), + ); + // Standard single leaps: f3, h3. Double-leap adds extras. + expect(whiteKnightDoubleLeaps.length).toBeGreaterThan(2); + + // Apply a neutral white move so it's black's turn. + engine.applyMove( + engine.findMove(algebraicToSquare("a2"), algebraicToSquare("a3"))!, + ); + + // Now black to move — the preset is white-scope, so black's + // knights should only have the 2 standard single leaps. + const blackMoves = engine.getAllLegalMoves(); + const blackKnightMoves = blackMoves.filter( + (m) => m.from === algebraicToSquare("g8"), + ); + expect(blackKnightMoves).toHaveLength(2); + }); + + it("black-only preset does NOT apply to white's moves", () => { + const engine = new ChessEngine(); + engine.activePresets.replaceAll([ + { id: "knights-leap-twice", scope: "black", turnsRemaining: null }, + ]); + + const whiteKnightMoves = engine + .getAllLegalMoves() + .filter((m) => m.from === algebraicToSquare("g1")); + // Standard knight has only 2 legal leaps from g1. + expect(whiteKnightMoves).toHaveLength(2); + }); + }); + + describe("turn-limited duration", () => { + it("preset expires after its duration elapses", () => { + const engine = new ChessEngine(); + engine.activePresets.replaceAll([ + { id: "pawns-move-backward", scope: "both", turnsRemaining: 2 }, + ]); + + engine.applyMove( + engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!, + ); + // After white's move, scope=both ticks → 1 remaining. + expect( + engine.activePresets.list()[0]?.turnsRemaining, + ).toBe(1); + + engine.applyMove( + engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!, + ); + // After black's move, 0 → removed. + expect(engine.activePresets.list()).toHaveLength(0); + + // Further backward move attempts should fail. + expect( + engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")), + ).toBeNull(); + }); + + it("scope=white duration ticks only on white's turns", () => { + const engine = new ChessEngine(); + engine.activePresets.replaceAll([ + { id: "pawns-move-backward", scope: "white", turnsRemaining: 1 }, + ]); + + // Black moves first? No — white always moves first. Play a white + // move — that ticks the white counter from 1 → expired. + engine.applyMove( + engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!, + ); + expect(engine.activePresets.list()).toHaveLength(0); + }); }); }); diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index 0698c89..f9b0ba6 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -47,7 +47,10 @@ import { } from "./rules/draws.js"; import { applyCapture } from "./rules/capture.js"; import type { LegalMove } from "./rules/types.js"; -import { PRESET_REGISTRY } from "./presets/index.js"; +import { ActivePresetSet } from "./presets/active-set.js"; +// Importing from the barrel guarantees every preset module's +// side-effect registration has run before the first engine is created. +import "./presets/index.js"; type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[]; @@ -70,11 +73,24 @@ export type GameResult = export class ChessEngine { public readonly session: Session; + /** + * Per-engine preset activation. Every engine owns its own set so two + * concurrent games (most importantly: the authoritative server session + * and a client's predicted clone) can hold different rule sets without + * a shared-module dance. + * + * Mutable by design — UIs and servers replace its contents via + * `.replaceAll()`. The engine reads it fresh on every call to + * `getAllLegalMoves()` so mid-game toggles take effect on the next + * move calculation with no reset. + */ + public readonly activePresets: ActivePresetSet; - constructor() { + constructor(activePresets?: ActivePresetSet) { this.session = new Session({ autoFire: false }); generateStartingPosition(this.session); recordPosition(this.session); + this.activePresets = activePresets ?? new ActivePresetSet(); } getCurrentTurn(): PieceColor { @@ -130,11 +146,15 @@ export class ChessEngine { } } - // Apply active preset rules: add extra moves, then run filter hooks. + // Apply active preset rules for the color whose turn it is. The + // per-color filter implements `scope=white` / `scope=black` — a + // white-only preset never contributes moves while black is on move. + // // Order matters — `getExtraMoves` contributes to the set that // `filterMoves` operates on, so every active preset sees the full // aggregated set (including prior presets' additions). - for (const preset of PRESET_REGISTRY.getActive()) { + const activePresets = this.activePresets.getForColor(color); + for (const preset of activePresets) { if (preset.getExtraMoves) { pieceMoves = [ ...pieceMoves, @@ -142,7 +162,7 @@ export class ChessEngine { ]; } } - for (const preset of PRESET_REGISTRY.getActive()) { + for (const preset of activePresets) { if (preset.filterMoves) { pieceMoves = preset.filterMoves(pieceMoves, this, piece.id); } @@ -219,6 +239,12 @@ export class ChessEngine { // Record position for threefold repetition recordPosition(this.session); + // Tick preset durations with the color that JUST moved. Player-local + // turn counting: a `scope=white` preset with 3 turns remaining + // ticks only when white plays; a `scope=both` ticks on every + // half-move. Entries reaching 0 are removed. + this.activePresets.tickAfterMove(color); + return this.checkGameResult(); } diff --git a/packages/chess/src/hooks/useChessEngine.ts b/packages/chess/src/hooks/useChessEngine.ts index 20e6796..4a175f5 100644 --- a/packages/chess/src/hooks/useChessEngine.ts +++ b/packages/chess/src/hooks/useChessEngine.ts @@ -3,6 +3,8 @@ import { ChessEngine, type GameResult } from '../engine'; import type { PieceType } from '../schema'; import { saveAutoSave } from '../persist/autosave.js'; import * as audio from '../audio'; +import { isInCheck } from '../rules/check'; +import type { PresetActivation } from '../net/types'; export function useChessEngine() { const engineRef = useRef(null); @@ -49,10 +51,13 @@ export function useChessEngine() { saveAutoSave(engine.session.allFacts()); setTick(t => t + 1); // trigger re-render - // Play appropriate sound + // Play appropriate sound. Check detection is a DERIVED predicate + // over the session (not a stored `InCheck` fact), so we call the + // helper directly against the side that just received the move. + const opponentColor = engine.getCurrentTurn(); if (result === 'checkmate') { audio.play('checkmate'); - } else if (engine.session.allFacts().some(f => f.attr === 'InCheck' && f.value === true)) { + } else if (isInCheck(engine.session, opponentColor)) { audio.play('check'); } else if (move.isCapture) { audio.play('capture'); @@ -97,6 +102,18 @@ export function useChessEngine() { setTick(t => t + 1); }, []); + /** + * Replace the local engine's active preset set. Mirrors the + * multiplayer hook's shape so GameView / RulesDrawer can share a + * single setter. Unlike multiplayer, the write is synchronous and + * the local UI updates immediately (no server round-trip). + */ + const setPresets = useCallback((activations: PresetActivation[]) => { + engine.activePresets.replaceAll(activations); + saveAutoSave(engine.session.allFacts()); + setTick(t => t + 1); + }, [engine]); + return { engine, turn: getTurn(), @@ -109,5 +126,7 @@ export function useChessEngine() { loadEngine, refresh, lastMove, + activations: engine.activePresets.list(), + setPresets, }; } diff --git a/packages/chess/src/hooks/useMultiplayerGame.ts b/packages/chess/src/hooks/useMultiplayerGame.ts new file mode 100644 index 0000000..590813a --- /dev/null +++ b/packages/chess/src/hooks/useMultiplayerGame.ts @@ -0,0 +1,275 @@ +/** + * React hook for a server-backed chess game. + * + * Wraps GameClient + PredictionManager and exposes the same surface as + * `useChessEngine` so `` doesn't need to care whether it's + * running locally or over the wire. Consumers pick the hook based on + * whether sessionStorage has a room-code/token pair (see GameView). + * + * Lifecycle + * ───────── + * - On mount: opens a WebSocket, sends `room.join` with the stored token. + * The server recognises the token (set by the Lobby's one-shot create/ + * join flow that closed its socket moments before) and follows the + * reconnect code path, which responds with `room.joined` + a full + * `game.state` snapshot. PredictionManager populates `baseEngine` from + * that snapshot. + * - While connected: remote `game.delta` events are applied to + * `baseEngine`, and `onStateChange` fires — which bumps a React tick + * and re-derives the hook's outputs from the (now updated) engine. + * Local `applyMove` calls `applyPrediction` which updates UI optimistically + * and sends `game.move` to the server. The server echoes a `game.delta` + * that clears the prediction. + * - On unmount: closes the socket. A fresh mount will reconnect again. + * + * Turn gating + * ─────────── + * The hook exposes `myColor` so the UI can disable drag for opponent + * pieces. The server rejects out-of-turn `game.move` frames too, which + * would roll the prediction back via the `error` event — but we prefer + * not to even fire the optimistic update so the UI stays honest. + */ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { PieceType } from '../schema'; +import type { GameResult } from '../engine'; +import { GameClient } from '../net/client'; +import { PredictionManager } from '../net/prediction'; +import type { Color, PresetActivation, PromotionPiece } from '../net/types'; +import * as audio from '../audio'; + +const WS_URL = + (import.meta as { env?: Record }).env?.['VITE_WS_URL'] ?? + 'ws://localhost:7357/ws'; + +export interface MultiplayerGameState { + /** Whether the socket is currently open. */ + connected: boolean; + /** The side this browser controls ('white' | 'black'), or null until + * room.joined arrives. */ + myColor: Color | null; + /** True while we're waiting for the initial game.state snapshot to + * arrive (engine may be empty). */ + loading: boolean; + /** Room error surfaced to the UI (e.g. ROOM_NOT_FOUND on reconnect + * after grace expiry). Null when healthy. */ + error: string | null; +} + +/** + * @param code Room code from sessionStorage. + * @param token Player token from sessionStorage. + */ +export function useMultiplayerGame(code: string, token: string) { + // One tick counter drives re-renders on every PredictionManager state + // change. The engine itself is stored on a ref so we can read its + // latest state synchronously inside callbacks without a stale closure. + const clientRef = useRef(null); + const managerRef = useRef(null); + const [tick, setTick] = useState(0); + const [meta, setMeta] = useState({ + connected: false, + myColor: null, + loading: true, + error: null, + }); + + // Mount the connection exactly once per code/token pair. + useEffect(() => { + const client = new GameClient(WS_URL); + const manager = new PredictionManager(client, () => { + // Any change in authoritative or predicted state: bump tick so the + // component re-renders and picks up the new facts. + setTick((t) => t + 1); + }); + clientRef.current = client; + managerRef.current = manager; + + const onConnected = () => { + setMeta((m) => ({ ...m, connected: true, error: null })); + }; + const onDisconnected = (e: { willReconnect: boolean }) => { + setMeta((m) => ({ + ...m, + connected: false, + error: e.willReconnect ? null : 'Disconnected from server', + })); + }; + const onJoined = (e: { payload: { color: Color } }) => { + setMeta((m) => ({ + ...m, + myColor: e.payload.color, + loading: false, + })); + }; + const onGameState = () => { + // The first game.state snapshot after (re)connect clears `loading`. + setMeta((m) => ({ ...m, loading: false })); + }; + const onGameDelta = (e: { + payload: { + gameOver: { winner: string; reason: string } | null; + turn: Color; + }; + }) => { + // Play sound based on the delta outcome. We deliberately classify + // by the delta rather than by inspecting retracted/inserted facts + // because the server doesn't include move semantics (capture, + // check, etc.) in the wire payload today. + if (e.payload.gameOver !== null) { + audio.play( + e.payload.gameOver.reason === 'checkmate' ? 'checkmate' : 'move', + ); + } else { + audio.play('move'); + } + }; + const onError = (e: { payload: { code: string; message: string; fatal: boolean } }) => { + // Fatal errors tear down the session entirely and the server + // closes the socket. Non-fatal server errors (ILLEGAL_MOVE, + // NOT_YOUR_TURN, GAME_OVER) roll back the prediction inside + // PredictionManager; we surface them briefly so users see why + // their move didn't stick. + if (e.payload.fatal) { + setMeta((m) => ({ ...m, error: e.payload.message })); + } else { + setMeta((m) => ({ ...m, error: e.payload.message })); + // Clear non-fatal errors after a short delay so a single bad + // click doesn't leave a stale banner. + setTimeout(() => { + setMeta((m) => + m.error === e.payload.message ? { ...m, error: null } : m, + ); + }, 2000); + } + }; + + client.on('connected', onConnected); + client.on('disconnected', onDisconnected); + client.on('room.joined', onJoined); + client.on('game.state', onGameState); + client.on('game.delta', onGameDelta); + client.on('error', onError); + + client.connect(code, token).catch((err: unknown) => { + setMeta((m) => ({ + ...m, + error: err instanceof Error ? err.message : 'Connection failed', + loading: false, + })); + }); + + return () => { + // Remove listeners before close so in-flight events don't trigger + // state updates after unmount. + client.off('connected', onConnected); + client.off('disconnected', onDisconnected); + client.off('room.joined', onJoined); + client.off('game.state', onGameState); + client.off('game.delta', onGameDelta); + client.off('error', onError); + client.close(); + clientRef.current = null; + managerRef.current = null; + }; + }, [code, token]); + + // Outputs derived from the manager's current engine. We recompute on + // every tick; PredictionManager guarantees `onStateChange` fires for + // every mutation. + const manager = managerRef.current; + const engine = manager?.getCurrentEngine() ?? null; + + const facts = engine?.session.allFacts() ?? []; + const turn = engine?.getCurrentTurn() ?? 'white'; + const legalMoves = engine?.getAllLegalMoves() ?? []; + const result: GameResult = engine?.checkGameResult() ?? 'ongoing'; + + const applyMove = useCallback( + (from: number, to: number, promoteTo: PieceType = 'queen'): GameResult | null => { + const mgr = managerRef.current; + if (!mgr) return null; + const promo = promoteTo as PromotionPiece; + const ok = mgr.applyPrediction(from, to, promo); + if (!ok) return null; + // `applyPrediction` updated the engine synchronously via + // `onStateChange`; we can just return the new result. + const eng = mgr.getCurrentEngine(); + const moveResult = eng.checkGameResult(); + // Local sound playback for the moving player. The opponent's client + // plays its own sound off the `game.delta` event. + if (moveResult === 'checkmate') audio.play('checkmate'); + else audio.play('move'); + return moveResult; + }, + [], + ); + + // Undo is not meaningful in multiplayer: moves are authoritative on + // the server. Return a no-op + canUndo=false so GameView's shared + // contract still works. + const undo = useCallback(() => { + // intentional no-op + }, []); + + const refresh = useCallback(() => { + setTick((t) => t + 1); + }, []); + + /** + * Replace the room's preset set. The server validates and, on + * success, broadcasts `game.presets` which PredictionManager applies + * to the base engine and re-renders us via the tick bump. We do NOT + * flip local UI state here — the UI should mirror the server's echo, + * not the request, so that rejected requests leave the UI unchanged. + */ + const setPresets = useCallback((activations: PresetActivation[]) => { + clientRef.current?.sendSetPresets(activations); + }, []); + + const loadEngine = useCallback(() => { + // Also a no-op: authoritative state is server-driven. The UI should + // not have any need to swap the engine under multiplayer — all state + // changes come through `game.state` / `game.delta`. + }, []); + + // `lastMove` in multiplayer: we don't track an optimistic move history + // here because re-renders are driven by engine snapshots, not a list. + // Deriving last-move from Position facts is O(n); acceptable for + // boards with ≤ 32 pieces. We return null for simplicity — the board's + // yellow last-move highlight will simply not appear in multiplayer + // until we wire it through `game.delta.moveNotation` in a later pass. + const lastMove = null; + + // Explicit reference so lint doesn't flag `tick` as unused. Each tick + // change invalidates the derived outputs above naturally via closure + // because `engine.session.allFacts()` is called fresh each render. + void tick; + + // Current authoritative preset activations. Read fresh each render + // so the UI reflects server echoes immediately after `setTick` fires. + const activations: PresetActivation[] = engine + ? engine.activePresets.list() + : []; + + return { + engine, + turn, + facts, + legalMoves, + result, + applyMove, + undo, + canUndo: false, + loadEngine, + refresh, + lastMove, + activations, + setPresets, + // Multiplayer-only metadata. GameView uses these to gate drag and + // render a "waiting for opponent" / error overlay. + myColor: meta.myColor, + connected: meta.connected, + loading: meta.loading, + error: meta.error, + }; +} diff --git a/packages/chess/src/index.ts b/packages/chess/src/index.ts index 42013ee..6e1cda3 100644 --- a/packages/chess/src/index.ts +++ b/packages/chess/src/index.ts @@ -29,3 +29,12 @@ export { type ChessFact, } from "./schema.js"; export type { LegalMove } from "./rules/types.js"; +export { isInCheck } from "./rules/check.js"; +export { PRESET_REGISTRY, type PresetDef } from "./presets/index.js"; +export { + ActivePresetSet, + PresetActivationError, + type ActivationRequest, + type PresetActivation, + type PresetScope, +} from "./presets/active-set.js"; diff --git a/packages/chess/src/net/client.ts b/packages/chess/src/net/client.ts index c8b9be8..45295ce 100644 --- a/packages/chess/src/net/client.ts +++ b/packages/chess/src/net/client.ts @@ -16,10 +16,13 @@ import type { GameDeltaPayload, GameEndPayload, GameMovePayload, + GamePresetsPayload, GameStatePayload, + PresetActivation, PromotionPiece, RoomCreatedPayload, RoomJoinedPayload, + RoomSetPresetsPayload, } from "./types.js"; // --------------------------------------------------------------------------- @@ -30,6 +33,7 @@ export type GameClientEvent = | { type: "game.state"; payload: GameStatePayload } | { type: "game.delta"; payload: GameDeltaPayload } | { type: "game.end"; payload: GameEndPayload } + | { type: "game.presets"; payload: GamePresetsPayload } | { type: "room.created"; payload: RoomCreatedPayload } | { type: "room.joined"; payload: RoomJoinedPayload } | { type: "error"; payload: ErrorPayload } @@ -260,6 +264,17 @@ export class GameClient { this.send({ type: "game.move", payload }); } + /** + * Convenience: send `room.setPresets` to replace the room's entire + * active preset set. The server validates and — on success — + * broadcasts `game.presets` to both players; on failure the caller + * gets a non-fatal `error` event with `INVALID_MESSAGE`. + */ + sendSetPresets(activations: PresetActivation[]): void { + const payload: RoomSetPresetsPayload = { activations }; + this.send({ type: "room.setPresets", payload }); + } + // ------------------------------------------------------------------------- // Accessors (primarily for tests & reconnect logic) // ------------------------------------------------------------------------- @@ -414,6 +429,9 @@ export class GameClient { case "game.end": this.emit({ type, payload: payload as GameEndPayload }); return; + case "game.presets": + this.emit({ type, payload: payload as GamePresetsPayload }); + return; case "room.created": this.emit({ type, payload: payload as RoomCreatedPayload }); return; diff --git a/packages/chess/src/net/prediction.ts b/packages/chess/src/net/prediction.ts index d1dbe9e..b91ee20 100644 --- a/packages/chess/src/net/prediction.ts +++ b/packages/chess/src/net/prediction.ts @@ -22,7 +22,9 @@ import type { GameClient } from "./client.js"; import type { Fact, GameDeltaPayload, + GamePresetsPayload, GameStatePayload, + PresetActivation, PromotionPiece, } from "./types.js"; @@ -112,6 +114,7 @@ export class PredictionManager { private attachListeners(): void { this.client.on("game.state", (e) => this.applyFullState(e.payload)); this.client.on("game.delta", (e) => this.reconcile(e.payload)); + this.client.on("game.presets", (e) => this.applyPresets(e.payload)); this.client.on("error", (e) => { // Fatal errors tear down the session; the app restarts from a fresh // `game.state`. Non-fatal errors (ILLEGAL_MOVE / NOT_YOUR_TURN / …) @@ -121,12 +124,49 @@ export class PredictionManager { }); } + /** + * Sync the authoritative preset set onto the base engine. Any current + * prediction is discarded: the preset change might invalidate + * previously-legal optimistic moves, and re-validating them here would + * duplicate server logic. Simpler to let the next user action + * re-predict against the freshly-synced base. + */ + private applyPresets(payload: GamePresetsPayload): void { + try { + this.baseEngine.activePresets.replaceAll(payload.activations); + } catch { + // A bad set from the server shouldn't crash the client; the server + // already validated, so this branch is defensive only. We clear + // instead of keeping stale rules. + this.baseEngine.activePresets.clear(); + } + this.predictedEngine = null; + this.pendingMoves = []; + this.onStateChange(this.baseEngine); + } + + /** The current authoritative preset activation list. Used by hooks to + * surface it to the UI. */ + getPresetActivations(): readonly PresetActivation[] { + return this.baseEngine.activePresets.list(); + } + private applyFullState(state: GameStatePayload): void { // Full snapshot from the server (on join or after a large gap). Replace // `baseEngine` entirely and drop any outstanding predictions — the // server view supersedes everything. const next = freshEngine(); loadFacts(next, state.facts); + // Also apply the authoritative preset set. `activations` is a newer + // field; tolerate older servers that don't include it by defaulting + // to an empty set. + if (Array.isArray(state.activations)) { + try { + next.activePresets.replaceAll(state.activations); + } catch { + next.activePresets.clear(); + } + } this.baseEngine = next; this.predictedEngine = null; this.pendingMoves = []; @@ -179,6 +219,11 @@ function cloneEngine(src: ChessEngine): ChessEngine { attr: f.attr as string, value: f.value, }))); + // Preset state must be copied so the prediction's tickAfterMove on + // applyMove doesn't mutate the base's duration counters. `replaceAll` + // with the base's current list is the public way to do this; since + // the base's list already passed validation, the call can't throw. + next.activePresets.replaceAll(src.activePresets.list()); return next; } diff --git a/packages/chess/src/net/types.ts b/packages/chess/src/net/types.ts index 9ed212d..5423905 100644 --- a/packages/chess/src/net/types.ts +++ b/packages/chess/src/net/types.ts @@ -39,15 +39,34 @@ export interface Fact { // Server → Client payloads // --------------------------------------------------------------------------- +export type PresetScope = "both" | "white" | "black"; + +export interface PresetActivation { + id: string; + scope: PresetScope; + turnsRemaining: number | null; +} + export interface GameStatePayload { facts: Fact[]; turn: Color; lastSeq: number; moveHistory: string[]; activeRules: string[]; + /** Optional for wire compat with older servers. Newer ones always + * emit this field; PredictionManager treats missing as empty. */ + activations?: PresetActivation[]; fen: string; } +export interface GamePresetsPayload { + activations: PresetActivation[]; +} + +export interface RoomSetPresetsPayload { + activations: PresetActivation[]; +} + export interface GameOver { winner: Winner; reason: GameEndReason; @@ -121,6 +140,7 @@ export type ServerMessage = | MessageEnvelope<"game.state", GameStatePayload> | MessageEnvelope<"game.delta", GameDeltaPayload> | MessageEnvelope<"game.end", GameEndPayload> + | MessageEnvelope<"game.presets", GamePresetsPayload> | MessageEnvelope<"room.created", RoomCreatedPayload> | MessageEnvelope<"room.joined", RoomJoinedPayload> | MessageEnvelope<"error", ErrorPayload>; @@ -129,4 +149,5 @@ export type ClientMessage = | MessageEnvelope<"room.create", RoomCreatePayload> | MessageEnvelope<"room.join", RoomJoinPayload> | MessageEnvelope<"room.leave", Record> - | MessageEnvelope<"game.move", GameMovePayload>; + | MessageEnvelope<"game.move", GameMovePayload> + | MessageEnvelope<"room.setPresets", RoomSetPresetsPayload>; diff --git a/packages/chess/src/presets/active-set.test.ts b/packages/chess/src/presets/active-set.test.ts new file mode 100644 index 0000000..78e177a --- /dev/null +++ b/packages/chess/src/presets/active-set.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import "./index.js"; +import { + ActivePresetSet, + PresetActivationError, +} from "./active-set.js"; + +describe("ActivePresetSet", () => { + let set: ActivePresetSet; + + beforeEach(() => { + set = new ActivePresetSet(); + }); + + describe("replaceAll — single-entry validation", () => { + it("rejects unknown preset ids", () => { + expect(() => + set.replaceAll([ + { id: "does-not-exist", scope: "both", turnsRemaining: null }, + ]), + ).toThrow(PresetActivationError); + }); + + it("rejects invalid scope strings", () => { + // TS would normally catch this, but wire traffic can pass anything. + expect(() => + set.replaceAll([ + { + id: "knights-leap-twice", + // @ts-expect-error — deliberately invalid for runtime test + scope: "sideways", + turnsRemaining: null, + }, + ]), + ).toThrow(/invalid scope/); + }); + + it("rejects zero / negative / non-integer turnsRemaining", () => { + for (const bad of [0, -1, 1.5, Number.NaN]) { + expect(() => + set.replaceAll([ + { id: "knights-leap-twice", scope: "both", turnsRemaining: bad }, + ]), + ).toThrow(/turnsRemaining/); + } + }); + + it("accepts null turnsRemaining as permanent", () => { + set.replaceAll([ + { id: "knights-leap-twice", scope: "both", turnsRemaining: null }, + ]); + expect(set.has("knights-leap-twice")).toBe(true); + }); + + it("accepts positive integer turnsRemaining", () => { + set.replaceAll([ + { id: "knights-leap-twice", scope: "white", turnsRemaining: 3 }, + ]); + expect(set.list()[0]?.turnsRemaining).toBe(3); + }); + }); + + describe("replaceAll — loose compatibility", () => { + // Uses rook-warp ↔ wrap-board as the canonical mutually-incompatible + // pair — they define contradictory board-topology semantics and + // genuinely cannot coexist. See RULES.md for the full matrix. + it("blocks incompatible presets when scopes overlap (both+any)", () => { + expect(() => + set.replaceAll([ + { id: "rook-warp", scope: "both", turnsRemaining: null }, + { id: "wrap-board", scope: "white", turnsRemaining: null }, + ]), + ).toThrow(/incompatible/); + }); + + it("allows incompatible presets when scopes are disjoint", () => { + // White-only rook-warp + black-only wrap-board — per the loose + // rule these never overlap, so the block is lifted. + set.replaceAll([ + { id: "rook-warp", scope: "white", turnsRemaining: null }, + { id: "wrap-board", scope: "black", turnsRemaining: null }, + ]); + expect(set.has("rook-warp")).toBe(true); + expect(set.has("wrap-board")).toBe(true); + }); + + it("blocks when both are the same explicit color", () => { + expect(() => + set.replaceAll([ + { id: "rook-warp", scope: "white", turnsRemaining: null }, + { id: "wrap-board", scope: "white", turnsRemaining: null }, + ]), + ).toThrow(/incompatible/); + }); + }); + + describe("replaceAll — atomicity", () => { + it("leaves existing set untouched if validation fails", () => { + set.replaceAll([ + { id: "knights-leap-twice", scope: "both", turnsRemaining: null }, + ]); + expect(() => + set.replaceAll([ + { id: "knights-leap-twice", scope: "both", turnsRemaining: null }, + { id: "does-not-exist", scope: "both", turnsRemaining: null }, + ]), + ).toThrow(PresetActivationError); + // The original single-entry set must still be intact. + expect(set.list()).toHaveLength(1); + expect(set.has("knights-leap-twice")).toBe(true); + }); + }); + + describe("getForColor", () => { + it("returns `both`-scoped presets for either color", () => { + set.replaceAll([ + { id: "knights-leap-twice", scope: "both", turnsRemaining: null }, + ]); + expect(set.getForColor("white").map((p) => p.id)).toEqual([ + "knights-leap-twice", + ]); + expect(set.getForColor("black").map((p) => p.id)).toEqual([ + "knights-leap-twice", + ]); + }); + + it("filters color-scoped presets", () => { + set.replaceAll([ + { id: "knights-leap-twice", scope: "white", turnsRemaining: null }, + { id: "bishops-ignore-color", scope: "black", turnsRemaining: null }, + ]); + expect(set.getForColor("white").map((p) => p.id)).toEqual([ + "knights-leap-twice", + ]); + expect(set.getForColor("black").map((p) => p.id)).toEqual([ + "bishops-ignore-color", + ]); + }); + }); + + describe("tickAfterMove — player-local turn counting", () => { + it("ticks `both`-scoped presets on every half-move", () => { + set.replaceAll([ + { id: "knights-leap-twice", scope: "both", turnsRemaining: 2 }, + ]); + set.tickAfterMove("white"); + expect(set.list()[0]?.turnsRemaining).toBe(1); + set.tickAfterMove("black"); + // Second tick on `both` scope expires it. + expect(set.list()).toHaveLength(0); + }); + + it("ticks white-scoped presets only after white moves", () => { + set.replaceAll([ + { id: "knights-leap-twice", scope: "white", turnsRemaining: 2 }, + ]); + set.tickAfterMove("black"); + // Not our color; duration unchanged. + expect(set.list()[0]?.turnsRemaining).toBe(2); + set.tickAfterMove("white"); + expect(set.list()[0]?.turnsRemaining).toBe(1); + set.tickAfterMove("black"); + expect(set.list()[0]?.turnsRemaining).toBe(1); + set.tickAfterMove("white"); + expect(set.list()).toHaveLength(0); + }); + + it("never ticks permanent (turnsRemaining=null) entries", () => { + set.replaceAll([ + { id: "knights-leap-twice", scope: "both", turnsRemaining: null }, + ]); + for (let i = 0; i < 50; i++) { + set.tickAfterMove(i % 2 === 0 ? "white" : "black"); + } + expect(set.has("knights-leap-twice")).toBe(true); + }); + }); + + describe("clone", () => { + it("returns an independent deep copy", () => { + set.replaceAll([ + { id: "knights-leap-twice", scope: "white", turnsRemaining: 5 }, + ]); + const copy = set.clone(); + copy.tickAfterMove("white"); + // Original unaffected by copy's tick. + expect(set.list()[0]?.turnsRemaining).toBe(5); + expect(copy.list()[0]?.turnsRemaining).toBe(4); + }); + }); +}); diff --git a/packages/chess/src/presets/active-set.ts b/packages/chess/src/presets/active-set.ts new file mode 100644 index 0000000..66d4394 --- /dev/null +++ b/packages/chess/src/presets/active-set.ts @@ -0,0 +1,233 @@ +/** + * Per-game active preset tracking. + * + * Earlier iterations of the preset system used a module-level + * `PRESET_REGISTRY.active: Set` which was shared by every + * ChessEngine in the process — fine for the browser, but wrong for the + * server where a single process hosts multiple concurrent rooms with + * different rule sets. It also couldn't express per-color scope or + * turn-limited durations. + * + * `ActivePresetSet` replaces that shared Set with an INSTANCE held by + * each `GameSession` / `ChessEngine`. It tracks: + * + * - which presets are active, + * - for which color they apply (`both` | `white` | `black`), + * - how many of that color's upcoming turns they remain active + * (`null` = permanent). + * + * The per-color-turn counting policy matches the user's confirmed + * design: for a `white`-scoped preset, one "tick" happens after each + * white move (not each half-move). For `both`-scoped presets we tick + * on every half-move. This is what the plan calls "player-local turns". + * + * Compatibility uses the LOOSE rule: two presets that list each other + * in `incompatibleWith` may still coexist if their SCOPES don't + * overlap — e.g. `A scope=white` + `B scope=black` is allowed even + * when A and B are nominally incompatible, because the engine only + * ever evaluates one side's rules at a time. + */ + +import type { PresetDef } from "./registry.js"; +import { PRESET_REGISTRY } from "./registry.js"; + +export type PresetScope = "both" | "white" | "black"; + +export interface PresetActivation { + readonly id: string; + readonly scope: PresetScope; + /** Number of player-local turns remaining. `null` = permanent. */ + readonly turnsRemaining: number | null; +} + +/** Input-shape when the UI / wire protocol asks to (re)activate a preset. */ +export interface ActivationRequest { + readonly id: string; + readonly scope: PresetScope; + /** Player-local turns the preset should last. `null` = permanent. */ + readonly turnsRemaining: number | null; +} + +/** Wire-serializable snapshot — identical shape today; kept as its own + * name so downstream protocol types can alias it. */ +export type ActivationEntry = PresetActivation; + +/** + * Returns true iff `a`'s scope and `b`'s scope intersect — i.e. there is + * at least one color where BOTH presets are active and would be applied + * to the same side's moves in the same turn. + */ +function scopesOverlap(a: PresetScope, b: PresetScope): boolean { + if (a === "both" || b === "both") return true; + return a === b; +} + +/** + * Extracted for testing and for the server to reject bad requests with a + * specific reason. Thrown from `ActivePresetSet.activate`. + */ +export class PresetActivationError extends Error { + readonly code: + | "UNKNOWN_PRESET" + | "INCOMPATIBLE" + | "MISSING_REQUIREMENT" + | "INVALID_SCOPE" + | "INVALID_DURATION"; + constructor(code: PresetActivationError["code"], message: string) { + super(message); + this.name = "PresetActivationError"; + this.code = code; + } +} + +export class ActivePresetSet { + private readonly entries = new Map(); + + /** + * Replace the entire active set with `requests`. Used by the server's + * authoritative path and by client sync on `game.presets`. Validates + * each entry individually AND the whole set together (so cross-preset + * incompatibilities are caught before any mutation). + * + * On failure the existing set is untouched — errors abort the entire + * replacement so the caller never observes a half-applied state. + */ + replaceAll(requests: readonly ActivationRequest[]): void { + // Validate each request in isolation first (preset exists, scope + // legal, duration legal). + for (const r of requests) { + this.validateSingle(r); + } + + // Then pairwise: `incompatibleWith` blocks only when scopes overlap; + // `requires` must hold with overlapping scope. + for (const r of requests) { + const def = PRESET_REGISTRY.get(r.id); + if (!def) continue; // validateSingle already threw if missing + + for (const other of requests) { + if (other.id === r.id) continue; + if (def.incompatibleWith.includes(other.id) && scopesOverlap(r.scope, other.scope)) { + throw new PresetActivationError( + "INCOMPATIBLE", + `Preset "${r.id}" (scope=${r.scope}) is incompatible with "${other.id}" (scope=${other.scope}) under overlapping scope.`, + ); + } + } + + for (const dep of def.requires) { + const met = requests.some( + (rq) => rq.id === dep && scopesOverlap(rq.scope, r.scope), + ); + if (!met) { + throw new PresetActivationError( + "MISSING_REQUIREMENT", + `Preset "${r.id}" requires "${dep}" to also be active under an overlapping scope.`, + ); + } + } + } + + // All valid — swap atomically. + this.entries.clear(); + for (const r of requests) { + this.entries.set(r.id, { + id: r.id, + scope: r.scope, + turnsRemaining: r.turnsRemaining, + }); + } + } + + private validateSingle(r: ActivationRequest): void { + const def = PRESET_REGISTRY.get(r.id); + if (!def) { + throw new PresetActivationError( + "UNKNOWN_PRESET", + `Preset "${r.id}" is not registered.`, + ); + } + if (r.scope !== "both" && r.scope !== "white" && r.scope !== "black") { + throw new PresetActivationError( + "INVALID_SCOPE", + `Preset "${r.id}" has invalid scope "${String(r.scope)}".`, + ); + } + if (r.turnsRemaining !== null) { + if ( + !Number.isInteger(r.turnsRemaining) || + r.turnsRemaining <= 0 + ) { + throw new PresetActivationError( + "INVALID_DURATION", + `Preset "${r.id}" has invalid turnsRemaining "${String(r.turnsRemaining)}" (must be a positive integer or null).`, + ); + } + } + } + + /** + * Returns the list of preset definitions that apply to moves made by + * the given color right now. Presets with `scope=both` are included for + * both colors; `scope=white` only for white; `scope=black` only for + * black. This is what ChessEngine.getAllLegalMoves calls per piece. + */ + getForColor(color: "white" | "black"): PresetDef[] { + const out: PresetDef[] = []; + for (const entry of this.entries.values()) { + if (entry.scope !== "both" && entry.scope !== color) continue; + const def = PRESET_REGISTRY.get(entry.id); + if (def) out.push(def); + } + return out; + } + + /** + * Called by ChessEngine.applyMove AFTER the move is applied, with the + * color that just moved (NOT the new side to move). Decrements every + * entry whose scope applies to `moverColor` and removes entries whose + * `turnsRemaining` reaches 0. + * + * This implements the "player-local turns" policy: a `scope=white` + * preset ticks only after white moves; `scope=both` ticks on every + * half-move. + */ + tickAfterMove(moverColor: "white" | "black"): void { + const toRemove: string[] = []; + for (const entry of this.entries.values()) { + if (entry.scope !== "both" && entry.scope !== moverColor) continue; + if (entry.turnsRemaining === null) continue; + const next = entry.turnsRemaining - 1; + if (next <= 0) { + toRemove.push(entry.id); + } else { + this.entries.set(entry.id, { ...entry, turnsRemaining: next }); + } + } + for (const id of toRemove) this.entries.delete(id); + } + + /** All active entries, in registration order. Used by UI + wire sync. */ + list(): PresetActivation[] { + return [...this.entries.values()]; + } + + /** Fast "is X active under any scope?" query — mainly for tests. */ + has(id: string): boolean { + return this.entries.has(id); + } + + /** Remove everything — used on new-game / engine swap. */ + clear(): void { + this.entries.clear(); + } + + /** Deep copy — used by PredictionManager when cloning engines. */ + clone(): ActivePresetSet { + const next = new ActivePresetSet(); + for (const entry of this.entries.values()) { + next.entries.set(entry.id, { ...entry }); + } + return next; + } +} diff --git a/packages/chess/src/presets/double-pawn-sprint.ts b/packages/chess/src/presets/double-pawn-sprint.ts index 298db5b..88aa078 100644 --- a/packages/chess/src/presets/double-pawn-sprint.ts +++ b/packages/chess/src/presets/double-pawn-sprint.ts @@ -8,7 +8,9 @@ * preset only adds the double advance when the pawn is OFF its home rank. * * Mode: override (conceptually removes the `HasMoved = false` guard). - * Incompatible with `pawns-move-backward`. + * Combines cleanly with `pawns-move-backward` — the two presets add + * moves in disjoint directions and the base pawn rule still runs, + * so no move contradicts another. */ import type { EntityId } from "@paratype/rete"; import type { ChessEngine } from "../engine.js"; @@ -56,7 +58,7 @@ PRESET_REGISTRY.register({ name: "Perpetual Sprint", description: "Pawns may advance 2 squares straight forward from ANY rank (not only the home rank).", - incompatibleWith: ["pawns-move-backward"], + incompatibleWith: [], requires: [], getExtraMoves: getDoubleSprintMove, }); diff --git a/packages/chess/src/presets/pawns-move-backward.test.ts b/packages/chess/src/presets/pawns-move-backward.test.ts index 30d7d41..26ee6c2 100644 --- a/packages/chess/src/presets/pawns-move-backward.test.ts +++ b/packages/chess/src/presets/pawns-move-backward.test.ts @@ -2,17 +2,14 @@ * Tests for preset rule 1 (pawns-move-backward) and registry invariants * required by P3.4. */ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect } from "vitest"; import type { EntityId } from "@paratype/rete"; import { ChessEngine } from "../engine.js"; import { PRESET_REGISTRY } from "./index.js"; +import { ActivePresetSet } from "./active-set.js"; import type { Square } from "../schema.js"; -describe("Preset registry (P3.4)", () => { - beforeEach(() => { - PRESET_REGISTRY.clear(); - }); - +describe("Preset registry (catalog)", () => { it("pawns-move-backward preset is registered", () => { const all = PRESET_REGISTRY.getAll(); expect(all.some((p) => p.id === "pawns-move-backward")).toBe(true); @@ -26,36 +23,29 @@ describe("Preset registry (P3.4)", () => { expect(PRESET_REGISTRY.getAll().length).toBeGreaterThanOrEqual(3); }); - it("activate / isActive / deactivate round-trip", () => { - expect(PRESET_REGISTRY.isActive("pawns-move-backward")).toBe(false); - PRESET_REGISTRY.activate("pawns-move-backward"); - expect(PRESET_REGISTRY.isActive("pawns-move-backward")).toBe(true); - expect(PRESET_REGISTRY.getActive().map((p) => p.id)).toContain( - "pawns-move-backward", - ); - PRESET_REGISTRY.deactivate("pawns-move-backward"); - expect(PRESET_REGISTRY.isActive("pawns-move-backward")).toBe(false); + it("activating an unknown preset throws via ActivePresetSet", () => { + const set = new ActivePresetSet(); + expect(() => + set.replaceAll([ + { id: "nonexistent-rule", scope: "both", turnsRemaining: null }, + ]), + ).toThrow(/not registered/); }); - it("activating an unknown preset throws", () => { - expect(() => PRESET_REGISTRY.activate("nonexistent-rule")).toThrow( - /Unknown preset/, - ); - }); - - it("cannot activate two mutually-incompatible presets", () => { - PRESET_REGISTRY.activate("pawns-move-backward"); - expect(() => PRESET_REGISTRY.activate("double-pawn-sprint")).toThrow( - /incompatible/, - ); + it("mutually-incompatible presets throw under overlapping scope", () => { + // rook-warp + wrap-board is the canonical hard-incompatible pair + // (see RULES.md): they define conflicting board topology semantics. + const set = new ActivePresetSet(); + expect(() => + set.replaceAll([ + { id: "rook-warp", scope: "both", turnsRemaining: null }, + { id: "wrap-board", scope: "both", turnsRemaining: null }, + ]), + ).toThrow(/incompatible/); }); }); describe("Preset: pawns-move-backward", () => { - beforeEach(() => { - PRESET_REGISTRY.clear(); - }); - const preset = () => PRESET_REGISTRY.getAll().find((p) => p.id === "pawns-move-backward")!; @@ -68,11 +58,11 @@ describe("Preset: pawns-move-backward", () => { return pos ? (pos.id as EntityId) : null; } - it("declares incompatibility with double-pawn-sprint", () => { - expect(preset().incompatibleWith).toContain("double-pawn-sprint"); + it("is compatible with double-pawn-sprint (mechanically disjoint move sets)", () => { + expect(preset().incompatibleWith).not.toContain("double-pawn-sprint"); }); - it("has no other hard requirements", () => { + it("has no hard requirements", () => { expect(preset().requires).toEqual([]); }); diff --git a/packages/chess/src/presets/pawns-move-backward.ts b/packages/chess/src/presets/pawns-move-backward.ts index 4af4338..e2c1a2d 100644 --- a/packages/chess/src/presets/pawns-move-backward.ts +++ b/packages/chess/src/presets/pawns-move-backward.ts @@ -4,7 +4,9 @@ * Pawns may additionally move exactly one square straight backward to an * empty square. Backward moves may NOT capture and do NOT enable en passant. * - * Mode: additive. Incompatible with `double-pawn-sprint`. + * Mode: additive. Combines cleanly with `double-pawn-sprint` — one adds + * a backward-1 move, the other adds forward-2 from any rank; the move + * sets are disjoint and the base pawn rule still runs unchanged. */ import type { EntityId } from "@paratype/rete"; import type { ChessEngine } from "../engine.js"; @@ -42,7 +44,7 @@ PRESET_REGISTRY.register({ name: "Backward-Marching Pawns", description: "Pawns may also move 1 square straight backward to an empty square (no capture).", - incompatibleWith: ["double-pawn-sprint"], + incompatibleWith: [], requires: [], getExtraMoves: getPawnBackwardMove, }); diff --git a/packages/chess/src/presets/presets.test.ts b/packages/chess/src/presets/presets.test.ts index 65f3aad..984a448 100644 --- a/packages/chess/src/presets/presets.test.ts +++ b/packages/chess/src/presets/presets.test.ts @@ -3,8 +3,9 @@ * Verifies: all 15 presets registered, incompatibilities correct, * functional hooks work for presets that have them. */ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect } from "vitest"; import { PRESET_REGISTRY } from "./index.js"; +import { ActivePresetSet } from "./active-set.js"; import { Session } from "@paratype/rete"; import type { EntityId } from "@paratype/rete"; import type { ChessEngine } from "../engine.js"; @@ -53,11 +54,16 @@ describe("Preset registry — all 15 registered", () => { }); describe("Incompatibility declarations", () => { - it("pawns-move-backward ↔ double-pawn-sprint are mutually incompatible", () => { + it("pawns-move-backward ↔ double-pawn-sprint are now compatible", () => { + // Historical note: these were originally incompatible on design + // grounds (author felt oscillating pawns were ugly). Mechanically + // they're fine — the move sets are disjoint (backward-1 vs + // forward-2) and both are pure getExtraMoves additions over the + // base pawn rule, so no move contradicts another. const a = PRESET_REGISTRY.getAll().find(p => p.id === "pawns-move-backward")!; const b = PRESET_REGISTRY.getAll().find(p => p.id === "double-pawn-sprint")!; - expect(a.incompatibleWith).toContain("double-pawn-sprint"); - expect(b.incompatibleWith).toContain("pawns-move-backward"); + expect(a.incompatibleWith).not.toContain("double-pawn-sprint"); + expect(b.incompatibleWith).not.toContain("pawns-move-backward"); }); it("rook-warp ↔ wrap-board are mutually incompatible", () => { @@ -95,8 +101,6 @@ describe("Requires declarations", () => { }); describe("Functional hook: pawns-move-backward", () => { - beforeEach(() => { PRESET_REGISTRY.clear(); }); - it("adds backward move for pawn on rank ≥2", () => { const preset = PRESET_REGISTRY.getAll().find(p => p.id === "pawns-move-backward")!; const engine = makeEngine([{ id: 1, type: "pawn", color: "white", sq: 20 }]); // e3 @@ -152,15 +156,32 @@ describe("Functional hook: wrap-board", () => { }); }); -describe("PRESET_REGISTRY.activate with incompatibility check", () => { - beforeEach(() => { PRESET_REGISTRY.clear(); }); - - it("activating incompatible pair throws", () => { - PRESET_REGISTRY.activate("rook-warp"); - expect(() => PRESET_REGISTRY.activate("wrap-board")).toThrow(); +describe("ActivePresetSet incompatibility + requires", () => { + it("activating incompatible pair (overlapping scope) throws", () => { + const set = new ActivePresetSet(); + expect(() => + set.replaceAll([ + { id: "rook-warp", scope: "both", turnsRemaining: null }, + { id: "wrap-board", scope: "both", turnsRemaining: null }, + ]), + ).toThrow(); }); - it("activating without dependency throws", () => { - expect(() => PRESET_REGISTRY.activate("king-heals")).toThrow(/requires/); + it("activating without required dependency throws", () => { + const set = new ActivePresetSet(); + expect(() => + set.replaceAll([ + { id: "king-heals", scope: "both", turnsRemaining: null }, + ]), + ).toThrow(/requires/); + }); + + it("activating with satisfied requirement succeeds", () => { + const set = new ActivePresetSet(); + set.replaceAll([ + { id: "piece-hp", scope: "both", turnsRemaining: null }, + { id: "king-heals", scope: "both", turnsRemaining: null }, + ]); + expect(set.has("king-heals")).toBe(true); }); }); diff --git a/packages/chess/src/presets/registry.ts b/packages/chess/src/presets/registry.ts index 12db5ab..49c28c1 100644 --- a/packages/chess/src/presets/registry.ts +++ b/packages/chess/src/presets/registry.ts @@ -31,54 +31,29 @@ export interface PresetDef { ) => LegalMove[]; } +/** + * Pure catalog of preset DEFINITIONS. Does NOT track activation any more + * — activation is per-engine via `ChessEngine.activePresets: ActivePresetSet` + * so concurrent games in the same process (e.g. server rooms) can hold + * different rule sets without sharing module-level state. + */ class PresetRegistryClass { private readonly presets = new Map(); - private readonly active = new Set(); register(preset: PresetDef): void { this.presets.set(preset.id, preset); } - activate(id: string): void { - const preset = this.presets.get(id); - if (!preset) throw new Error(`Unknown preset: ${id}`); - for (const other of this.active) { - const otherDef = this.presets.get(other); - if (otherDef?.incompatibleWith.includes(id)) { - throw new Error(`Preset ${id} is incompatible with already-active ${other}`); - } - if (preset.incompatibleWith.includes(other)) { - throw new Error(`Preset ${id} is incompatible with already-active ${other}`); - } - } - for (const dep of preset.requires) { - if (!this.active.has(dep)) { - throw new Error(`Preset ${id} requires ${dep} to be active first`); - } - } - this.active.add(id); - } - - deactivate(id: string): void { - this.active.delete(id); - } - - isActive(id: string): boolean { - return this.active.has(id); - } - - getActive(): PresetDef[] { - return [...this.active] - .map((id) => this.presets.get(id)) - .filter((p): p is PresetDef => p !== undefined); - } - getAll(): PresetDef[] { return [...this.presets.values()]; } - clear(): void { - this.active.clear(); + /** + * Look up a preset definition by id. Used by ActivePresetSet to resolve + * entries into callable hooks. Returns undefined for unknown ids. + */ + get(id: string): PresetDef | undefined { + return this.presets.get(id); } } diff --git a/packages/chess/src/presets/wrap-board.test.ts b/packages/chess/src/presets/wrap-board.test.ts new file mode 100644 index 0000000..a5a7fa9 --- /dev/null +++ b/packages/chess/src/presets/wrap-board.test.ts @@ -0,0 +1,298 @@ +/** + * Targeted tests for the cylindrical-board preset. + * + * Earlier implementation contributed only a single one-square hop from + * an edge file to the opposite edge file; this suite documents the + * intended "slides continue past the seam" behaviour. + */ +import { describe, it, expect } from "vitest"; +import "./index.js"; +import { ChessEngine } from "../engine.js"; +import { algebraicToSquare, squareOf, fileOf, rankOf } from "../coord.js"; +import type { Square } from "../schema.js"; +import type { EntityId } from "@paratype/rete"; + +function activateWrap(engine: ChessEngine): void { + engine.activePresets.replaceAll([ + { id: "wrap-board", scope: "both", turnsRemaining: null }, + ]); +} + +/** Remove every piece standing on the given rank so we can stage a + * clean rook slide without worrying about the starting position's + * pawns and pieces interfering. */ +function clearRank(engine: ChessEngine, rank: number): void { + const facts = engine.session.allFacts(); + const toRetract: number[] = []; + for (const f of facts) { + if (f.attr !== "Position") continue; + if (rankOf(f.value as number) === rank) toRetract.push(f.id as number); + } + for (const id of toRetract) { + const eid = id as EntityId; + if (engine.session.contains(eid, "Position")) { + engine.session.retract(eid, "Position"); + } + } +} + +/** Move a piece to a specific square. Used to park a rook where we + * want it for the test. */ +function teleport(engine: ChessEngine, pieceId: number, to: Square): void { + engine.session.insert(pieceId as EntityId, "Position", to); +} + +/** Find any piece of a given color+type. */ +function findPiece( + engine: ChessEngine, + color: string, + type: string, +): number | null { + const facts = engine.session.allFacts(); + for (const f of facts) { + if (f.attr !== "PieceType" || f.value !== type) continue; + const colorFact = facts.find(c => c.id === f.id && c.attr === "Color"); + if (colorFact?.value === color) return f.id as number; + } + return null; +} + +describe("wrap-board preset — horizontal slide wrap", () => { + it("rook sliding off h-file toward a-file reaches the opposite side", () => { + const engine = new ChessEngine(); + activateWrap(engine); + clearRank(engine, 3); // rank 4 in 1-indexed = rank 3 in 0-indexed + + const rook = findPiece(engine, "white", "rook")!; + teleport(engine, rook, squareOf(3, 3) as Square); // d4 + + // With an empty rank 4 in either direction, d4 rook should be able + // to wrap past h4 to land on a4/b4/c4/.. up to just before d4. + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === rook); + const targetFiles = new Set(moves.map(m => fileOf(m.to as number))); + + // Normal slide on rank 4: a4..h4 minus d4 itself. Wrap should add + // nothing new (all files already reachable via normal slides on + // an empty rank) — but we assert the base slide IS complete, + // which was broken before the fix (wrap would only add a single + // hop from edge files). + expect(targetFiles.has(0)).toBe(true); // a4 + expect(targetFiles.has(7)).toBe(true); // h4 + }); + + it("rook on a-file reaches h-file via wrap when path on both sides is clear", () => { + const engine = new ChessEngine(); + activateWrap(engine); + clearRank(engine, 3); + + const rook = findPiece(engine, "white", "rook")!; + teleport(engine, rook, squareOf(0, 3) as Square); // a4 + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === rook); + const targetFiles = new Set(moves.map(m => fileOf(m.to as number))); + // a4 → b4..h4 via normal slide is already covered; wrap contributes + // the other direction: a4 → h4 (direct seam crossing). Assert both + // ends of the rank are reachable. + expect(targetFiles.has(7)).toBe(true); // h4 via the wrap seam + expect(targetFiles.has(1)).toBe(true); // b4 via normal slide + }); + + it("a friendly piece blocks the outgoing rightward slide but leftward wrap still reaches the other side", () => { + const engine = new ChessEngine(); + activateWrap(engine); + clearRank(engine, 3); + + const rook = findPiece(engine, "white", "rook")!; + teleport(engine, rook, squareOf(3, 3) as Square); // d4 + + // Park a white pawn on e4 (file 4). The rightward slide is blocked + // immediately; its wrap branch — which requires a clear outgoing + // path to h4 — cannot fire. The leftward slide is still clear + // through a4, so the leftward wrap branch DOES fire: past the + // a-seam onto h4, then rightward along rank 4 until we hit e4 + // (ally) and stop. So h4/g4/f4 remain reachable via wrap, but e4 + // itself never is (it's the ally). Squares directly right of d4 + // are not reachable because the normal rightward slide is blocked. + const whitePawn = (() => { + const facts = engine.session.allFacts(); + for (const f of facts) { + if (f.attr !== "PieceType" || f.value !== "pawn") continue; + const c = facts.find(x => x.id === f.id && x.attr === "Color"); + if (c?.value === "white") return f.id as number; + } + return null; + })()!; + teleport(engine, whitePawn, squareOf(4, 3) as Square); // e4 + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === rook); + const targets = new Set(moves.map(m => m.to as number)); + + expect(targets.has(algebraicToSquare("e4"))).toBe(false); // ally blocks + expect(targets.has(algebraicToSquare("h4"))).toBe(true); // leftward wrap + expect(targets.has(algebraicToSquare("g4"))).toBe(true); // leftward wrap + expect(targets.has(algebraicToSquare("f4"))).toBe(true); // leftward wrap + expect(targets.has(algebraicToSquare("a4"))).toBe(true); // normal left slide + expect(targets.has(algebraicToSquare("c4"))).toBe(true); // normal left slide + }); + + it("both outgoing paths blocked by allies — no wrap in either direction", () => { + const engine = new ChessEngine(); + activateWrap(engine); + clearRank(engine, 3); + + const rook = findPiece(engine, "white", "rook")!; + teleport(engine, rook, squareOf(3, 3) as Square); // d4 + + // Bracket the rook with own pawns so neither rightward nor + // leftward slide can reach its respective edge. + const whitePawns: number[] = []; + const facts = engine.session.allFacts(); + for (const f of facts) { + if (f.attr !== "PieceType" || f.value !== "pawn") continue; + const c = facts.find(x => x.id === f.id && x.attr === "Color"); + if (c?.value === "white") whitePawns.push(f.id as number); + } + teleport(engine, whitePawns[0]!, squareOf(4, 3) as Square); // e4 + teleport(engine, whitePawns[1]!, squareOf(2, 3) as Square); // c4 + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === rook); + const wrapTargets = moves + .filter(m => rankOf(m.to as number) === 3) + .map(m => fileOf(m.to as number)); + // Rook can't go anywhere on rank 4 — both neighbours are allies. + expect(wrapTargets).toHaveLength(0); + }); + + it("queen also benefits from the wrap (not just rooks)", () => { + const engine = new ChessEngine(); + activateWrap(engine); + clearRank(engine, 3); + + const queen = findPiece(engine, "white", "queen")!; + teleport(engine, queen, squareOf(3, 3) as Square); // d4 + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === queen); + const targetFiles = new Set( + moves + .filter(m => rankOf(m.to as number) === 3) // only rank-4 moves + .map(m => fileOf(m.to as number)), + ); + expect(targetFiles.has(0)).toBe(true); // a4 + expect(targetFiles.has(7)).toBe(true); // h4 + }); + + it("knight on h-file gets wrap-around L-leaps to a-file", () => { + const engine = new ChessEngine(); + activateWrap(engine); + + const knight = findPiece(engine, "white", "knight")!; + // Park the knight on h3 (file 7, rank 2). With the cylinder, the + // 8 knight offsets wrap file mod 8. From (file=7, rank=2) the + // wrap-crossing targets are: + // (+1,+2) → file 0, rank 4 → a5 + // (+2,+1) → file 1, rank 3 → b4 + // (+2,-1) → file 1, rank 1 → b2 + // (+1,-2) → file 0, rank 0 → a1 + // (Same-side targets f2, f4, g1, g5 come from the base rule.) + clearRank(engine, 0); + clearRank(engine, 1); + clearRank(engine, 2); + clearRank(engine, 3); + clearRank(engine, 4); + teleport(engine, knight, squareOf(7, 2) as Square); // h3 + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === knight); + const targets = new Set(moves.map(m => m.to as number)); + expect(targets.has(algebraicToSquare("a5"))).toBe(true); + expect(targets.has(algebraicToSquare("b4"))).toBe(true); + expect(targets.has(algebraicToSquare("b2"))).toBe(true); + expect(targets.has(algebraicToSquare("a1"))).toBe(true); + }); + + it("knight on a-file gets wrap-around L-leaps to h-file", () => { + // Regression: a knight near the left edge leaping across the seam. + // b1 (file 1, rank 0) has one wrap-only target at h2: + // (-2,+1) → file -1 = 7, rank 1 → h2. + // Plus the standard a3, c3, d2 from the base rule. + const engine = new ChessEngine(); + activateWrap(engine); + clearRank(engine, 0); + clearRank(engine, 1); + clearRank(engine, 2); + const knight = findPiece(engine, "white", "knight")!; + teleport(engine, knight, squareOf(1, 0) as Square); // b1 + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === knight); + const targets = new Set(moves.map(m => m.to as number)); + expect(targets.has(algebraicToSquare("h2"))).toBe(true); // -2,+1 wrap + expect(targets.has(algebraicToSquare("a3"))).toBe(true); // -1,+2 base + expect(targets.has(algebraicToSquare("c3"))).toBe(true); // +1,+2 base + }); + + it("knight on h4 reaches a5 and b4 via the seam (user screenshot regression)", () => { + // User reported: with cylindrical enabled, a knight on h4 had no + // wrap moves into a-file territory. + // From (file 7, rank 3): + // (+1,+2) → file 0, rank 5 → a6 + // (+2,+1) → file 1, rank 4 → b5 ← b5 per user, close to b4 expectation + // (+2,-1) → file 1, rank 2 → b3 + // (+1,-2) → file 0, rank 1 → a2 + // Note the user said "a5 and b4" but the strict knight geometry + // from h4 actually produces a6/b5/b3/a2. The IMPORTANT thing is + // that wrap-crossing targets exist at all — the old implementation + // returned NONE. We assert on the real math. + const engine = new ChessEngine(); + activateWrap(engine); + for (let r = 0; r <= 6; r++) clearRank(engine, r); + const knight = findPiece(engine, "white", "knight")!; + teleport(engine, knight, squareOf(7, 3) as Square); // h4 + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === knight); + const targets = new Set(moves.map(m => m.to as number)); + expect(targets.has(algebraicToSquare("a6"))).toBe(true); + expect(targets.has(algebraicToSquare("b5"))).toBe(true); + expect(targets.has(algebraicToSquare("b3"))).toBe(true); + expect(targets.has(algebraicToSquare("a2"))).toBe(true); + }); + + it("king on a-file can step onto h-file via the seam", () => { + const engine = new ChessEngine(); + activateWrap(engine); + // Clear rank 2 so the a2 square is empty and the king has empty + // squares to walk onto. Also clear the king's home rank neighbours. + clearRank(engine, 1); + clearRank(engine, 0); + + const king = findPiece(engine, "white", "king")!; + teleport(engine, king, squareOf(0, 3) as Square); // a4 + clearRank(engine, 3); + teleport(engine, king, squareOf(0, 3) as Square); // a4 again after rank clear + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === king); + const targets = new Set(moves.map(m => m.to as number)); + expect(targets.has(algebraicToSquare("h4"))).toBe(true); // west wrap + expect(targets.has(algebraicToSquare("h5"))).toBe(true); // NW wrap + expect(targets.has(algebraicToSquare("h3"))).toBe(true); // SW wrap + }); + + it("bishop on a-file gets wrap-around diagonal moves to h-file", () => { + const engine = new ChessEngine(); + activateWrap(engine); + // Clear a diagonal path so the wrap is unobstructed. + clearRank(engine, 1); + clearRank(engine, 2); + clearRank(engine, 3); + + const bishop = findPiece(engine, "white", "bishop")!; + teleport(engine, bishop, squareOf(0, 3) as Square); // a4 + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === bishop); + const targets = new Set(moves.map(m => m.to as number)); + // Bishop on a4 walking up-left wraps: a4 -> h5 (file -1 = 7, rank 4) + // Then g6 (file -2 = 6, rank 5) — but we didn't clear rank 5, so + // stops at h5 or whatever blocker. Just assert h5 is reachable. + expect(targets.has(algebraicToSquare("h5"))).toBe(true); + // And down-left from a4: file -1 = 7, rank 2 → h3. + expect(targets.has(algebraicToSquare("h3"))).toBe(true); + }); +}); diff --git a/packages/chess/src/presets/wrap-board.ts b/packages/chess/src/presets/wrap-board.ts index 979ffee..4b811c4 100644 --- a/packages/chess/src/presets/wrap-board.ts +++ b/packages/chess/src/presets/wrap-board.ts @@ -1,27 +1,237 @@ +/** + * Preset: `wrap-board` (Cylindrical Board, RULES.md rule #7) + * + * The board is a horizontal cylinder: a-file and h-file are adjacent. + * Every piece whose movement could "fall off" the left or right edge + * instead emerges on the opposite side, continuing its geometry there. + * Vertical edges (ranks 1 and 8) do NOT wrap — the board is a cylinder, + * not a torus. + * + * Concretely: + * - Rooks / queen horizontals: slides continue past the seam along the + * rank, stopping at the first blocker or capturing the first enemy. + * - Bishops / queen diagonals: diagonal slides wrap in file but still + * terminate when the rank leaves the board. + * - Knights: the 8 L-offsets are computed with file `mod 8`, so a knight + * on h4 can leap to a6/b5/b3/a2 as if file 8 ≡ file 0. + * - Kings: all 8 adjacent offsets with file `mod 8`. + * - Pawn captures: the two diagonal-forward capture squares wrap too. + * - Pawn advances do NOT wrap — they move along files, not across the + * file seam, so wrapping is irrelevant there. + * + * We implement this with a single `getExtraMoves` hook that, per piece, + * computes the wrap-aware destinations and returns only the ones the + * base FIDE rule could not already produce (because its file math is + * clamped to [0,7]). The base rule's standard moves still run, so the + * sum is the full cylindrical move set. + * + * Incompatible with `rook-warp`: rook-warp uses a stricter precondition + * (the full path to the edge must be clear before any wrap is legal) + * and applies only to rooks. Combining them would produce ambiguous + * destination sets — see RULES.md for the full matrix. + */ import { PRESET_REGISTRY } from "./registry.js"; import { fileOf, rankOf, squareOf } from "../coord.js"; -import { isAllyAt } from "../rules/board-queries.js"; -import type { PieceColor, PieceType } from "../schema.js"; +import { + isAllyAt, + isEnemyAt, + isPieceAt, +} from "../rules/board-queries.js"; +import type { PieceColor, PieceType, Square } from "../schema.js"; +import type { Session, EntityId } from "@paratype/rete"; +import type { LegalMove } from "../rules/types.js"; + +/** Modulo that handles negative file deltas correctly (JS `%` returns + * negative values for e.g. `-1 % 8`). */ +function wrapFile(file: number): number { + return ((file % 8) + 8) % 8; +} + +/** + * Walk a cylindrical ray with the given (fileDelta, rankDelta) step. + * Files wrap mod 8; ranks terminate the walk when out of [0,7]. + * The starting square is never included. Stops at the first ally, or + * captures and stops at the first enemy. If the walk loops back to + * `from` (possible on a pure-horizontal ray since files wrap), it + * stops without re-adding. + */ +function walkRay( + session: Session, + pieceId: EntityId, + from: Square, + color: PieceColor, + fileDelta: number, + rankDelta: number, +): LegalMove[] { + const out: LegalMove[] = []; + let file = fileOf(from) + fileDelta; + let rank = rankOf(from) + rankDelta; + // Safety cap: at most 8 file-wraps * 8 ranks = 64, pick 80 for margin. + for (let step = 0; step < 80; step++) { + if (rank < 0 || rank > 7) break; + const sq = squareOf(wrapFile(file), rank); + if (sq === from) break; + if (isAllyAt(session, sq, color)) break; + if (isEnemyAt(session, sq, color)) { + out.push({ pieceId, from, to: sq, isCapture: true }); + break; + } + out.push({ pieceId, from, to: sq, isCapture: false }); + file += fileDelta; + rank += rankDelta; + } + return out; +} + +/** + * Single-offset (non-sliding) cylinder move. Returns 0 or 1 legal + * moves. Ranks out of bounds → nothing (cylinder, not torus). + */ +function offsetMove( + session: Session, + pieceId: EntityId, + from: Square, + color: PieceColor, + fileDelta: number, + rankDelta: number, +): LegalMove | null { + const rank = rankOf(from) + rankDelta; + if (rank < 0 || rank > 7) return null; + const file = wrapFile(fileOf(from) + fileDelta); + const sq = squareOf(file, rank); + if (sq === from) return null; + if (isAllyAt(session, sq, color)) return null; + return { + pieceId, + from, + to: sq, + isCapture: isPieceAt(session, sq), + }; +} + +/** Knight L-shape offsets. */ +const KNIGHT_OFFSETS: ReadonlyArray = [ + [1, 2], [2, 1], [2, -1], [1, -2], + [-1, -2], [-2, -1], [-2, 1], [-1, 2], +]; + +/** King 8-neighbour offsets. */ +const KING_OFFSETS: ReadonlyArray = [ + [1, 0], [1, 1], [0, 1], [-1, 1], + [-1, 0], [-1, -1], [0, -1], [1, -1], +]; + +/** Rook ray directions. */ +const ROOK_RAYS: ReadonlyArray = [ + [1, 0], [-1, 0], [0, 1], [0, -1], +]; + +/** Bishop ray directions. */ +const BISHOP_RAYS: ReadonlyArray = [ + [1, 1], [1, -1], [-1, 1], [-1, -1], +]; + +/** Per-type cylinder-aware move generation. */ +function computeCylinderMoves( + session: Session, + pieceId: EntityId, + from: Square, + color: PieceColor, + type: PieceType, +): LegalMove[] { + switch (type) { + case "knight": { + const out: LegalMove[] = []; + for (const [df, dr] of KNIGHT_OFFSETS) { + const m = offsetMove(session, pieceId, from, color, df, dr); + if (m !== null) out.push(m); + } + return out; + } + case "king": { + const out: LegalMove[] = []; + for (const [df, dr] of KING_OFFSETS) { + const m = offsetMove(session, pieceId, from, color, df, dr); + if (m !== null) out.push(m); + } + return out; + } + case "rook": { + const out: LegalMove[] = []; + for (const [df, dr] of ROOK_RAYS) { + out.push(...walkRay(session, pieceId, from, color, df, dr)); + } + return out; + } + case "bishop": { + const out: LegalMove[] = []; + for (const [df, dr] of BISHOP_RAYS) { + out.push(...walkRay(session, pieceId, from, color, df, dr)); + } + return out; + } + case "queen": { + const out: LegalMove[] = []; + for (const [df, dr] of [...ROOK_RAYS, ...BISHOP_RAYS]) { + out.push(...walkRay(session, pieceId, from, color, df, dr)); + } + return out; + } + case "pawn": { + // Only diagonal CAPTURES wrap — straight advance never crosses + // the file seam. Emit both diagonals (forward one rank for this + // color), wrapping file, and only if an enemy actually occupies + // the target. + const dr = color === "white" ? 1 : -1; + const targetRank = rankOf(from) + dr; + if (targetRank < 0 || targetRank > 7) return []; + const out: LegalMove[] = []; + for (const df of [-1, 1]) { + const file = wrapFile(fileOf(from) + df); + const sq = squareOf(file, targetRank); + if (sq === from) continue; + if (isEnemyAt(session, sq, color)) { + out.push({ pieceId, from, to: sq, isCapture: true }); + } + } + return out; + } + default: + return []; + } +} PRESET_REGISTRY.register({ id: "wrap-board", name: "Cylindrical Board", - description: "The board wraps horizontally: pieces moving off the a-file appear on the h-file and vice versa.", + description: + "The board wraps horizontally: every piece (knights, kings, bishops, queens, rooks, and pawn captures) can move off one side and appear on the other.", incompatibleWith: ["rook-warp"], requires: [], getExtraMoves: (engine, pieceId) => { const facts = engine.session.allFacts(); - const type = facts.find(f => f.id === pieceId && f.attr === "PieceType")?.value as PieceType; - if (!["rook", "queen"].includes(type)) return []; - const color = facts.find(f => f.id === pieceId && f.attr === "Color")?.value as PieceColor; - const from = facts.find(f => f.id === pieceId && f.attr === "Position")?.value as number; - if (from === undefined || !color) return []; - const file = fileOf(from), rank = rankOf(from); - const extras: number[] = []; - if (file === 7) extras.push(squareOf(0, rank)); - if (file === 0) extras.push(squareOf(7, rank)); - return extras - .filter(sq => !isAllyAt(engine.session, sq, color)) - .map(to => ({ pieceId, from, to, isCapture: facts.some(f => f.attr === "Position" && f.value === to) })); + const type = facts.find( + (f) => f.id === pieceId && f.attr === "PieceType", + )?.value as PieceType | undefined; + const color = facts.find( + (f) => f.id === pieceId && f.attr === "Color", + )?.value as PieceColor | undefined; + const fromVal = facts.find( + (f) => f.id === pieceId && f.attr === "Position", + )?.value as number | undefined; + if (type === undefined || color === undefined || fromVal === undefined) { + return []; + } + const from = fromVal as Square; + const session = engine.session; + + // Return the full cylinder-aware move set. We deliberately do NOT + // subtract the base-rule targets: duplicates here are harmless + // (downstream consumers dedup by (from,to) square) and returning + // a complete move list makes the preset testable in isolation — + // a test fixture with only the rook present should still see + // `wrap-board` contribute the slide targets rather than returning + // an empty list because the base rule would have reached them. + return computeCylinderMoves(session, pieceId, from, color, type); }, }); diff --git a/packages/chess/src/ui/Board.tsx b/packages/chess/src/ui/Board.tsx index 1bba8b3..9913b70 100644 --- a/packages/chess/src/ui/Board.tsx +++ b/packages/chess/src/ui/Board.tsx @@ -11,6 +11,10 @@ interface BoardProps { legalMoves: LegalMove[]; onMove: (from: number, to: number, promoteTo?: PieceType) => void; turn: PieceColor; + /** The color the local player controls. `null` means local/solo play + * (either color can be dragged on its turn). In multiplayer this + * restricts drag to the player's own pieces only. */ + myColor?: PieceColor | null; /** Last move played — extra fields ignored. Accepting the wider shape lets * callers pass the hook's return value directly without stripping keys. */ lastMove?: { from: number; to: number; [key: string]: unknown } | null | undefined; @@ -23,7 +27,7 @@ interface PieceState { color: PieceColor; } -export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSquare }: BoardProps) { +export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare }: BoardProps) { // Build pieces map: square -> { id, type, color } const pieces = useMemo(() => { const map = new Map(); @@ -57,7 +61,12 @@ export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSq // Drag state const [draggedPiece, setDraggedPiece] = useState<{ id: number, square: number } | null>(null); - + // The square the cursor is currently hovering during a drag. Distinct + // from `draggedPiece.square` (which stays pinned to the origin) — this + // one tracks wherever the cursor is right now so we can render a + // stronger highlight on the would-be drop target. + const [hoverSquare, setHoverSquare] = useState(null); + // Promotion picker state const [promotionMove, setPromotionMove] = useState<{ from: number, to: number, color: PieceColor } | null>(null); @@ -74,23 +83,47 @@ export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSq return targets; }, [draggedPiece, legalMoves]); - // Handlers + // Handlers. Drag is only allowed for pieces of the side to move AND, + // in multiplayer, the local player's own pieces. `myColor === null` + // indicates local/solo mode where both sides are controlled from this + // client. const handleDragStart = (id: number, square: number) => { const p = pieces.get(square); - if (p && p.color === turn) { // only drag pieces of current turn - setDraggedPiece({ id, square }); - } + if (!p || p.color !== turn) return; + if (myColor !== null && myColor !== undefined && p.color !== myColor) return; + setDraggedPiece({ id, square }); }; const handleDragEnd = () => { setDraggedPiece(null); + setHoverSquare(null); }; const handleDragOver = (e: React.DragEvent, square: number) => { if (!draggedPiece) return; + // Track the hovered square ANY time the cursor is over a cell during + // a drag, not just on valid-drop cells. We still gate preventDefault + // on `highlightedSquares.has(square)` below so the native DnD system + // only considers legal targets droppable; but the hover highlight + // gives feedback even for invalid cells (via the illegal-cursor + // style we set). + if (hoverSquare !== square) setHoverSquare(square); if (highlightedSquares.has(square)) { e.preventDefault(); // allow drop e.dataTransfer.dropEffect = 'move'; + } else { + // Legal-move set doesn't include this square → show the "no-drop" + // cursor so the user knows releasing here will snap back. + e.dataTransfer.dropEffect = 'none'; + } + }; + + const handleDragLeave = (e: React.DragEvent, square: number) => { + // Only clear the hover if we're actually leaving THIS square, not + // because a child element fired a spurious dragleave. Compare to + // currentTarget so cursor moves within the cell don't flicker. + if (hoverSquare === square && e.currentTarget === e.target) { + setHoverSquare(null); } }; @@ -111,6 +144,7 @@ export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSq } } setDraggedPiece(null); + setHoverSquare(null); }; // Generate board squares (rank 7 down to 0, file 0 to 7) @@ -125,23 +159,95 @@ export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSq const isCheckedKing = checkedKingSquare === sq; const piece = pieces.get(sq); - + // While this cell hosts the actively-dragged piece, lift the whole + // cell above all siblings in the grid. Z-index inside the + // component can only stack within its own cell's stacking context, + // so the piece would render *under* neighbouring cells' pieces when + // translated over them. Promoting the cell itself fixes that. + const isHostingDragged = draggedPiece?.square === sq; + + // Hover-state classification for the active drag: + // - hoveredValid : cursor is over THIS square AND it's a legal + // drop target for the dragged piece. + // - hoveredInvalid : cursor is over THIS square and it ISN'T a + // legal target (give clear "snap-back" feedback). + // We compute both rather than a single `isHovered` because the UI + // treatment differs significantly between the two cases. + const isHovered = hoverSquare === sq && draggedPiece !== null; + const hoveredValid = isHovered && isHighlighted; + const hoveredInvalid = isHovered && !isHighlighted && !isHostingDragged; + // Is the legal target a capture? We use that to render a ring (on + // captures) instead of a dot (on quiet moves), matching standard + // chess-UI convention. + const isCaptureTarget = + isHighlighted && piece !== undefined && piece.color !== turn; + squares.push(
handleDragOver(e, sq)} + onDragLeave={(e) => handleDragLeave(e, sq)} onDrop={(e) => handleDrop(e, sq)} > {isLastMove && (
)} - {isHighlighted && ( -
+ {/* + * Legal-target affordance — two visual forms: + * - Quiet move (empty destination): small central dot + * - Capture (destination has enemy piece): ring around cell + * Both are superseded by the stronger hoveredValid treatment + * below when the cursor is actually over the cell. + */} + {isHighlighted && !hoveredValid && !isCaptureTarget && ( +
+
+
+ )} + {isHighlighted && !hoveredValid && isCaptureTarget && ( +
+ )} + + {/* + * Hovered + legal: bright green glow so the player sees exactly + * where the piece will land on release. Z ordering is above the + * faint dot/ring so hovering a capture-ring cell visibly swaps + * the indicator instead of layering them. + */} + {hoveredValid && ( + + )} + + {/* + * Hovered + illegal: subtle red tint telling the user that + * releasing here will snap the piece back to origin. Subtle + * rather than alarming — it's not an error, just a hint. + */} + {hoveredInvalid && ( +
)} {isCheckedKing && ( @@ -169,7 +275,10 @@ export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSq type={piece.type} pieceId={piece.id} square={sq} - isDraggable={piece.color === turn} + isDraggable={ + piece.color === turn && + (myColor === null || myColor === undefined || piece.color === myColor) + } onDragStart={handleDragStart} onDragEnd={handleDragEnd} /> diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx index 5641461..0a7fdff 100644 --- a/packages/chess/src/ui/GameView.tsx +++ b/packages/chess/src/ui/GameView.tsx @@ -1,30 +1,139 @@ import { Board } from './Board'; import { RulesDrawer } from './RulesDrawer'; import { useChessEngine } from '../hooks/useChessEngine'; +import { useMultiplayerGame } from '../hooks/useMultiplayerGame'; import type { ChessFact, ChessAttrMap, PieceType } from '../schema'; +import type { Color, PresetActivation } from '../net/types'; +import type { GameResult } from '../engine'; +import type { LegalMove } from '../rules/types'; +import type { ChessEngine } from '../engine'; +import { isInCheck } from '../rules/check'; import { useEffect, useState } from 'react'; import confetti from 'canvas-confetti'; import { motion, AnimatePresence } from 'motion/react'; import { Volume2, VolumeX } from 'lucide-react'; import * as audio from '../audio'; +/** + * Shared state shape consumed by the game UI, covering both the local + * single-player engine and the server-backed multiplayer engine. Anything + * specific to one mode (undo stack, prediction status) lives on the + * mode-specific hook and is exposed via optional fields. + */ +interface GameEngineState { + /** The underlying engine — used for check detection via isInCheck. May + * be null only transiently during multiplayer's initial load. */ + engine: ChessEngine | null; + facts: ReadonlyArray> | ReturnType['facts']; + legalMoves: LegalMove[]; + turn: Color | 'white' | 'black'; + result: GameResult; + applyMove: (from: number, to: number, promoteTo?: PieceType) => GameResult | null; + undo: () => void; + canUndo: boolean; + lastMove: { from: number; to: number; [key: string]: unknown } | null | undefined; + refresh: () => void; + activations: PresetActivation[]; + setPresets: (activations: PresetActivation[]) => void; +} + interface GameViewProps { + /** Optional injected state (local mode) — when omitted, GameView runs + * its own local useChessEngine. */ engineState?: ReturnType; } +/** + * Local single-player GameView. Renders the board against a + * browser-local ChessEngine. Used by /game when the user hit + * "Play Solo" on the lobby or loaded directly from autosave. + */ export function GameView({ engineState }: GameViewProps) { - // Use passed in engine state or create local state if none provided const localChessState = useChessEngine(); const state = engineState || localChessState; - - const { facts, legalMoves, turn, result, applyMove, undo, canUndo, lastMove, refresh } = state; + return ; +} + +interface MultiplayerGameViewProps { + code: string; + token: string; +} + +/** + * Multiplayer GameView. Opens a WebSocket, drives the board off a + * server-authoritative PredictionManager, and disables drag for the + * opponent's pieces. The Lobby sets `room-code`/`room-token` in + * sessionStorage before navigating here; App.tsx reads them and picks + * this component vs the local GameView accordingly. + */ +export function MultiplayerGameView({ code, token }: MultiplayerGameViewProps) { + const state = useMultiplayerGame(code, token); + + // While we're waiting for the initial game.state snapshot, don't render + // the board. Two players loading simultaneously both call room.join on + // connect; the server responds with game.state only after both are in + // the room, so `loading` guarantees both sides start from the same + // authoritative position. + if (state.loading) { + return ( +
+
Waiting for opponent…
+
+ Room: {code} +
+
+ ); + } + + return ( + <> + {state.error !== null && ( +
+ {state.error} +
+ )} + + + ); +} + +/** + * Mode-agnostic board + header layout. Accepts a pre-built state shape + * (from either hook) plus an optional `myColor` that gates drag for + * opponent pieces. Everything UI-level — confetti, mute toggle, game-over + * banner — lives here so both modes share it. + */ +function GameLayout({ + state, + myColor, +}: { + state: GameEngineState; + myColor: Color | null; +}) { + const { + engine, + facts, + legalMoves, + turn, + result, + applyMove, + undo, + canUndo, + lastMove, + refresh, + activations, + setPresets, + } = state; const handleMove = (from: number, to: number, promoteTo?: PieceType) => { applyMove(from, to, promoteTo || 'queen'); }; const isGameOver = result !== 'ongoing'; - + // Confetti on checkmate useEffect(() => { if (result === 'checkmate') { @@ -63,54 +172,148 @@ export function GameView({ engineState }: GameViewProps) { setIsMuted(next); }; - // Find checked king for indicator - const checkedKingSquare = facts.find( - f => f.attr === 'InCheck' && f.value === true - ) ? facts.find( - f => f.attr === 'PieceType' && f.value === 'king' && - facts.some(f2 => f2.id === f.id && f2.attr === 'Color' && f2.value === turn) - )?.id ? facts.find(f3 => f3.id === facts.find( - f => f.attr === 'PieceType' && f.value === 'king' && - facts.some(f2 => f2.id === f.id && f2.attr === 'Color' && f2.value === turn) - )?.id && f3.attr === 'Position')?.value as number : null : null; + // Check detection for the side to move. `InCheck` is NOT a stored + // fact — it's a derived query over the session state — so we call + // the isInCheck predicate directly against the engine. We also find + // the king's square so Board.tsx can render the pulsing red + // indicator on it. Guarded against the null-engine case that + // multiplayer briefly hits while waiting for the first game.state. + const turnAsColor = turn as 'white' | 'black'; + const isCheck = + engine !== null ? isInCheck(engine.session, turnAsColor) : false; + const checkedKingSquare: number | null = (() => { + if (!isCheck) return null; + // Find the (king, current-turn-color) entity. facts is unordered + // so we search for a PieceType='king' fact whose entity also has + // Color=. + const colorById = new Map(); + for (const f of facts) { + if (f.attr === 'Color') colorById.set(f.id as number, f.value as string); + } + for (const f of facts) { + if (f.attr !== 'PieceType' || f.value !== 'king') continue; + if (colorById.get(f.id as number) !== turnAsColor) continue; + // Grab the Position fact for this entity. + const pos = facts.find((p) => p.id === f.id && p.attr === 'Position'); + if (pos === undefined) return null; + return pos.value as number; + } + return null; + })(); return ( - - +
- + {/* Header/Info section */}

- Chess + Houserules

- + {myColor && ( + + You are {myColor} + + )}
- +
-
-
- {turn === 'white' ? "White's turn" : "Black's turn"} -
- + {/* + * Turn indicator. In multiplayer we personalize the text: + * - Your turn (turn === myColor) + * - Opponent's turn (turn !== myColor) + * In local/solo play (myColor === null) we fall back to the + * neutral "White's turn" / "Black's turn" phrasing. + * + * When it's the local player's turn we also upgrade the + * visual treatment (green tint + pulsing dot) so the call to + * action is unmistakable. + */} + {(() => { + const isMyTurn = myColor !== null && turn === myColor; + const isOpponentTurn = myColor !== null && turn !== myColor; + const label = isMyTurn + ? 'Your turn' + : isOpponentTurn + ? "Opponent's turn" + : turn === 'white' + ? "White's turn" + : "Black's turn"; + const containerClass = isMyTurn + ? 'flex items-center gap-2 px-4 py-2 bg-emerald-50 border border-emerald-300 rounded-md font-semibold text-emerald-900 shadow-sm' + : 'flex items-center gap-2 px-4 py-2 bg-white border border-neutral-200 rounded-md font-medium text-neutral-700 shadow-sm'; + return ( +
+ {isMyTurn ? ( + + ) : ( +
+ )} + {label} +
+ ); + })()} + + {/* Check banner. Only renders when the side to move is in + * check AND the game hasn't reached a terminal state + * (checkmate shows the game-over banner instead). Turns + * red to match the pulsing king highlight on the board. */} + + {isCheck && !isGameOver && ( + + + Check! + + )} + + + {/* Undo is only meaningful in local mode (server is authoritative + * in multiplayer); the hook exposes canUndo=false there so the + * button naturally stays disabled. */} {isGameOver && ( -
- []} - legalMoves={legalMoves} - turn={turn} - onMove={handleMove} + []} + legalMoves={legalMoves} + turn={turn as Color} + myColor={myColor} + onMove={handleMove} lastMove={lastMove} checkedKingSquare={checkedKingSquare} /> - + {/* Overlay for game over to prevent further interaction visually */} {isGameOver && ( - )}
- +
); } diff --git a/packages/chess/src/ui/Lobby.tsx b/packages/chess/src/ui/Lobby.tsx index a627db9..875450c 100644 --- a/packages/chess/src/ui/Lobby.tsx +++ b/packages/chess/src/ui/Lobby.tsx @@ -175,8 +175,8 @@ export function Lobby({ chessState }: LobbyProps = {}) {
-

Paratype Chess

-

Realtime multiplayer with custom rules

+

Houserules

+

Chess. Your rules.

+ + {isOn && ( +
+ {/* Scope radios */} +
+ +
+ {(['both', 'white', 'black'] as const).map( + (scope) => { + const selected = active.scope === scope; + return ( + + ); + }, + )} +
+
+ + {/* Duration input */} +
+ +
+ setDuration(preset.id, e)} + className="w-20 px-2 py-1 border border-neutral-300 rounded-md text-sm text-right focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent" + /> + + {active.turnsRemaining === null + ? 'permanent' + : 'turns'} + +
+
+
+ )}
); })}
- {activeIds.size === 0 + {activeById.size === 0 ? 'Standard FIDE chess — no presets active' - : `${activeIds.size} preset${activeIds.size === 1 ? '' : 's'} active`} + : `${activeById.size} preset${activeById.size === 1 ? '' : 's'} active`}
diff --git a/packages/chess/src/ui/RulesView.tsx b/packages/chess/src/ui/RulesView.tsx index e87991d..360fc11 100644 --- a/packages/chess/src/ui/RulesView.tsx +++ b/packages/chess/src/ui/RulesView.tsx @@ -1,107 +1,96 @@ -import { useState, useMemo, useEffect } from 'react'; +import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { PRESET_REGISTRY } from '../presets/index.js'; import { ChessEngine } from '../engine.js'; import { clearAutoSave } from '../persist/autosave.js'; import type { useChessEngine } from '../hooks/useChessEngine.js'; +import type { PresetActivation } from '../net/types.js'; +import type { PresetScope } from '../presets/active-set.js'; interface RulesViewProps { - /** Only required when rules should drive a new game start. */ - chessState?: ReturnType; + /** Required — the view always shows the engine's current active set + * and delegates mutation back to `chessState.setPresets`. */ + chessState: ReturnType; isGameActive?: boolean; } +/** Build a fresh activation list that reflects a single edit. */ +function applyEdit( + current: readonly PresetActivation[], + id: string, + patch: Partial & { remove?: boolean }, +): PresetActivation[] { + if (patch.remove) return current.filter((a) => a.id !== id); + const idx = current.findIndex((a) => a.id === id); + if (idx < 0) { + return [ + ...current, + { + id, + scope: patch.scope ?? 'both', + turnsRemaining: patch.turnsRemaining ?? null, + }, + ]; + } + const next = [...current]; + const existing = next[idx]!; + next[idx] = { + id: existing.id, + scope: patch.scope ?? existing.scope, + turnsRemaining: + patch.turnsRemaining === undefined + ? existing.turnsRemaining + : patch.turnsRemaining, + }; + return next; +} + export function RulesView({ chessState, isGameActive }: RulesViewProps) { const navigate = useNavigate(); - // Seed local selection from whatever is already active on the registry so - // navigating to /rules mid-game reflects the real state (not a fresh set). - const [selected, setSelected] = useState>( - () => new Set(PRESET_REGISTRY.getActive().map((p) => p.id)), - ); - - const presets = PRESET_REGISTRY.getAll(); - - const hasIncompatibilities = useMemo(() => { - for (const a of selected) { - const aDef = presets.find((p) => p.id === a); - if (!aDef) continue; - for (const b of selected) { - if (a === b) continue; - if (aDef.incompatibleWith.includes(b)) return true; - } - } - return false; - }, [selected, presets]); - - const missingRequires = useMemo(() => { - const missing: Array<{ id: string; needs: string }> = []; - for (const a of selected) { - const def = presets.find((p) => p.id === a); - if (!def) continue; - for (const need of def.requires) { - if (!selected.has(need)) missing.push({ id: a, needs: need }); - } - } - return missing; - }, [selected, presets]); + const { activations, setPresets } = chessState; + const presets = useMemo(() => PRESET_REGISTRY.getAll(), []); + const activeById = useMemo(() => { + const map = new Map(); + for (const a of activations) map.set(a.id, a); + return map; + }, [activations]); const togglePreset = (id: string) => { if (isGameActive) return; - setSelected((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); + const current = activeById.get(id); + setPresets( + current + ? applyEdit(activations, id, { remove: true }) + : applyEdit(activations, id, { scope: 'both', turnsRemaining: null }), + ); }; - // Keep the singleton registry in sync with the user's current selection on - // every change. This means toggles take effect live — players navigating - // back and forth between /rules and /game see the new rules immediately, - // and any newly-created engine (P4 server path or /save load) will pick - // up the active presets automatically. - useEffect(() => { - // Deactivate first so the activation loop can't trip the registry's - // incompatibility guard against stale entries. - for (const p of PRESET_REGISTRY.getActive()) { - if (!selected.has(p.id)) PRESET_REGISTRY.deactivate(p.id); + const setScope = (id: string, scope: PresetScope) => { + if (isGameActive) return; + setPresets(applyEdit(activations, id, { scope })); + }; + + const setDuration = (id: string, raw: string) => { + if (isGameActive) return; + if (raw === '') { + setPresets(applyEdit(activations, id, { turnsRemaining: null })); + return; } - // Activate in dependency-friendly order: items whose `requires` are - // already active first. Simple fixpoint loop — stops when no more - // progress can be made, at which point remaining items are either - // already active or blocked by missing prerequisites / conflicts. - const toActivate = [...selected].filter((id) => !PRESET_REGISTRY.isActive(id)); - let progress = true; - while (progress && toActivate.length > 0) { - progress = false; - for (let i = toActivate.length - 1; i >= 0; i--) { - const id = toActivate[i]!; - try { - PRESET_REGISTRY.activate(id); - toActivate.splice(i, 1); - progress = true; - } catch { - /* requires not met yet, or incompatible — try again next pass */ - } - } - } - }, [selected]); + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) return; + setPresets(applyEdit(activations, id, { turnsRemaining: n })); + }; const handleApply = () => { - // Starting a new game with the selected ruleset: reset the engine so - // opening moves are generated under the active presets. Clear the - // autosave too — otherwise a full-page reload would re-hydrate the - // previous in-progress game. The registry is already synced via the - // useEffect above, so the fresh engine will see the active presets - // on its first getAllLegalMoves() call. + // Starting a new game preserving the currently configured rule set. clearAutoSave(); - if (chessState) { - chessState.loadEngine(new ChessEngine()); - } + const newEngine = new ChessEngine(); + newEngine.activePresets.replaceAll(activations); + chessState.loadEngine(newEngine); navigate('/game'); }; - const activeCount = selected.size; + const activeCount = activeById.size; return (
@@ -109,7 +98,8 @@ export function RulesView({ chessState, isGameActive }: RulesViewProps) {

Preset Rules

- Toggle custom chess rules. Changes apply to the next game. + Toggle custom chess rules. Each rule can target a color + (white/black/both) and run for a limited number of turns.

+
+ + {isOn && ( +
+
+ +
+ {(['both', 'white', 'black'] as const).map((scope) => { + const selected = active.scope === scope; + return ( + + ); + })} +
+
+
+ + setDuration(preset.id, e.target.value)} + className="w-full px-3 py-1.5 border border-neutral-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
)}
- -
- ))} + ); + })}
- Tip: toggles above take effect immediately — including in the middle of a game. No reset required. + Tip: edits above take effect immediately — + including in the middle of a game. No reset required.
{activeCount === 0 diff --git a/packages/server/src/broadcast.ts b/packages/server/src/broadcast.ts index 1f926ec..56711ce 100644 --- a/packages/server/src/broadcast.ts +++ b/packages/server/src/broadcast.ts @@ -26,8 +26,10 @@ import { type ErrorCode, type Fact as WireFact, type GameMovePayload, + type PresetActivation, type RoomCreatePayload, type RoomJoinPayload, + type RoomSetPresetsPayload, type ServerMessage, } from "./protocol.js"; import { DEFAULT_GRACE_MS, reconnectManager } from "./reconnect.js"; @@ -228,11 +230,15 @@ export function handleMessage( case "game.move": handleGameMove(ws, msg.payload); break; + case "room.setPresets": + handleSetPresets(ws, msg.payload); + break; case "room.created": case "room.joined": case "game.state": case "game.delta": case "game.end": + case "game.presets": case "error": sendTo( ws, @@ -357,6 +363,7 @@ function handleRoomJoin( lastSeq: 0, moveHistory: [], activeRules: [...result.activeRules], + activations: session.getPresetActivations(), // fen is a UI convenience for v1; we haven't wired FEN generation // on the server yet, so we send an empty string. Clients that need // FEN can derive it from `facts`. @@ -446,6 +453,7 @@ function handleReconnect( lastSeq: missed.length > 0 ? (missed[missed.length - 1]?.seq ?? 0) : 0, moveHistory: [], activeRules: [...room.rulesetIds], + activations: session.getPresetActivations(), fen: "", }), ); @@ -529,6 +537,12 @@ function handleGameMove( return; } + // Snapshot the preset set before the move so we can detect whether + // any durations expired during `tickAfterMove`. We only broadcast + // `game.presets` when the set actually changed — otherwise the + // message is noise. + const presetsBefore = session.getPresetActivations(); + const tickStart = performance.now(); const moveResult = session.applyMove( payload.from, @@ -575,6 +589,16 @@ function handleGameMove( broadcastToRoom(roomCode, deltaMsg); bufferDeltaForDisconnected(roomCode, token, deltaMsg.seq, deltaPayload); + // If any preset durations expired during this move's tick, push the + // new set so clients stop rendering those rules. We skip the broadcast + // when the set is byte-identical to pre-move — the common case — + // so a vanilla game doesn't generate a `game.presets` message on + // every half-move. + const presetsAfter = session.getPresetActivations(); + if (JSON.stringify(presetsBefore) !== JSON.stringify(presetsAfter)) { + broadcastPresets(roomCode, presetsAfter); + } + // Terminal positions also get an explicit game.end for clarity per // PROTOCOL.md §game.end. `finalFen` is empty for v1 (see note above). if (moveResult.gameOver !== null) { @@ -589,6 +613,52 @@ function handleGameMove( } } +function handleSetPresets( + ws: ServerWebSocket, + payload: RoomSetPresetsPayload, +): void { + const { roomCode, token } = ws.data; + if (roomCode === undefined || token === undefined) { + sendTo( + ws, + errorMessage("BAD_TOKEN", "not authenticated into a room", false), + ); + return; + } + const session = sessionRegistry.get(roomCode); + if (!session) { + sendTo( + ws, + errorMessage("INVALID_MESSAGE", "internal error: missing game session", true), + ); + ws.close(); + return; + } + + // Validate by handing to the GameSession which delegates to + // ActivePresetSet. Bad inputs (unknown id, incompatible pair under + // overlapping scope, missing requirement) come back as structured + // errors that we surface as INVALID_MESSAGE so the client can show + // them without disconnecting. + const result = session.setPresets(payload.activations); + if (!result.ok) { + sendTo(ws, errorMessage("INVALID_MESSAGE", result.error, false)); + return; + } + + broadcastPresets(roomCode, session.getPresetActivations()); +} + +/** Broadcast the current preset set to everyone in `code`. Used both + * after an explicit `room.setPresets` and after a move whose duration + * tick expired some entries. */ +function broadcastPresets( + code: string, + activations: PresetActivation[], +): void { + broadcastToRoom(code, envelope("game.presets", { activations })); +} + /** * For every OTHER player in `code` whose slot is mid-grace-window * (disconnected), buffer a copy of a just-broadcast delta so the diff --git a/packages/server/src/game-session.ts b/packages/server/src/game-session.ts index 1f2083f..ba551db 100644 --- a/packages/server/src/game-session.ts +++ b/packages/server/src/game-session.ts @@ -12,6 +12,9 @@ import { ChessEngine, algebraicToSquare, + PresetActivationError, + type ActivationRequest, + type PresetActivation, type GameResult, type PieceColor, type PieceType, @@ -87,16 +90,64 @@ export class GameSession { > | null = null; /** - * @param _rulesetIds — activated preset IDs from room.create. v1: accepted - * for API shape but not yet wired to ChessEngine. Preset activation is - * tracked by PRESET_REGISTRY which is process-global today; per-room - * preset isolation is a follow-up (tracked in PROTOCOL.md). + * @param rulesetIds — preset IDs from room.create. Each is activated + * as `scope=both, turnsRemaining=null` (permanent) on the engine's + * private ActivePresetSet. Invalid ids are silently skipped here — + * the server validates them earlier at the protocol layer. */ - constructor(_rulesetIds: readonly string[] = []) { + constructor(rulesetIds: readonly string[] = []) { this.engine = new ChessEngine(); + if (rulesetIds.length > 0) { + try { + this.engine.activePresets.replaceAll( + rulesetIds.map((id) => ({ + id, + scope: "both" as const, + turnsRemaining: null, + })), + ); + } catch { + // A bad initial rulesetId shouldn't prevent session creation — + // start empty and let clients reconfigure via room.setPresets. + this.engine.activePresets.clear(); + } + } this.prevFacts = this.snapshotFacts(); } + /** + * Replace the full active preset set. Returns `{ ok: true }` on + * success so the server can then broadcast `game.presets`; returns + * `{ ok: false, error }` on validation failure (unknown id, + * incompatibility, missing requirement) so the server can surface + * an `INVALID_MESSAGE` error to the requesting client. + * + * Rejections leave the pre-existing set intact (see + * ActivePresetSet.replaceAll for atomicity). + */ + setPresets( + activations: readonly ActivationRequest[], + ): { ok: true } | { ok: false; error: string } { + try { + this.engine.activePresets.replaceAll(activations); + return { ok: true }; + } catch (e) { + const msg = + e instanceof PresetActivationError + ? `${e.code}: ${e.message}` + : e instanceof Error + ? e.message + : String(e); + return { ok: false, error: msg }; + } + } + + /** Snapshot of the current active preset set, wire-shape. Used by + * broadcast to populate `game.state.activations` and `game.presets`. */ + getPresetActivations(): PresetActivation[] { + return this.engine.activePresets.list(); + } + /** Returns a fresh snapshot of current facts in deterministic order. */ getAllFacts(): Fact[] { return this.snapshotFacts(); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index de6dfa5..3954750 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -109,6 +109,40 @@ export const GameMovePayloadSchema = z.object({ }); export type GameMovePayload = z.infer; +// --------------------------------------------------------------------------- +// Preset scope + activation — used by both directions of the preset sync. +// --------------------------------------------------------------------------- + +export const PresetScopeSchema = z.enum(["both", "white", "black"]); +export type PresetScope = z.infer; + +/** + * Wire shape for one active preset. `turnsRemaining` is `null` for + * permanent activations; otherwise a positive integer count of + * player-local turns (see ActivePresetSet for counting policy). + */ +export const PresetActivationSchema = z.object({ + id: z.string().min(1), + scope: PresetScopeSchema, + turnsRemaining: z.number().int().positive().nullable(), +}); +export type PresetActivation = z.infer; + +/** + * Client → server: replace the room's entire active preset set. Server + * validates (catalog lookup, pairwise compatibility under the scope-overlap + * rule, requires) and either accepts — broadcasting `game.presets` to all + * players — or rejects with `INVALID_MESSAGE`. + * + * We chose "replace whole set" over "toggle one" because it sidesteps + * ordering issues when two clients race toggles and gives us idempotency + * for reconnect: the server just re-sends the current set on game.state. + */ +export const RoomSetPresetsPayloadSchema = z.object({ + activations: z.array(PresetActivationSchema), +}); +export type RoomSetPresetsPayload = z.infer; + // --------------------------------------------------------------------------- // Server → Client payloads // --------------------------------------------------------------------------- @@ -133,11 +167,27 @@ export const GameStatePayloadSchema = z.object({ turn: ColorSchema, lastSeq: z.number().int().nonnegative(), moveHistory: z.array(z.string()), + /** Legacy rule-id list (v1 compat). Present alongside `activations` + * which is the new authoritative shape. */ activeRules: z.array(z.string()), + /** Full preset activation set authoritative on the server. Optional + * on the wire so older servers don't break the schema check. */ + activations: z.array(PresetActivationSchema).optional(), fen: z.string(), }); export type GameStatePayload = z.infer; +/** + * Server → client: authoritative preset set just changed. Sent after a + * successful `room.setPresets` or after a move that expired durations. + * Clients apply this directly to their local engines' ActivePresetSet + * — never store rules locally, always mirror what the server says. + */ +export const GamePresetsPayloadSchema = z.object({ + activations: z.array(PresetActivationSchema), +}); +export type GamePresetsPayload = z.infer; + export const GameOverSchema = z.object({ winner: WinnerSchema, reason: GameEndReasonSchema, @@ -188,12 +238,17 @@ export const RoomCreateMessageSchema = msg( export const RoomJoinMessageSchema = msg("room.join", RoomJoinPayloadSchema); export const RoomLeaveMessageSchema = msg("room.leave", RoomLeavePayloadSchema); export const GameMoveMessageSchema = msg("game.move", GameMovePayloadSchema); +export const RoomSetPresetsMessageSchema = msg( + "room.setPresets", + RoomSetPresetsPayloadSchema, +); export const ClientMessageSchema = z.discriminatedUnion("type", [ RoomCreateMessageSchema, RoomJoinMessageSchema, RoomLeaveMessageSchema, GameMoveMessageSchema, + RoomSetPresetsMessageSchema, ]); export type ClientMessage = z.infer; @@ -208,6 +263,10 @@ export const RoomJoinedMessageSchema = msg( export const GameStateMessageSchema = msg("game.state", GameStatePayloadSchema); export const GameDeltaMessageSchema = msg("game.delta", GameDeltaPayloadSchema); export const GameEndMessageSchema = msg("game.end", GameEndPayloadSchema); +export const GamePresetsMessageSchema = msg( + "game.presets", + GamePresetsPayloadSchema, +); export const ErrorMessageSchema = msg("error", ErrorPayloadSchema); export const ServerMessageSchema = z.discriminatedUnion("type", [ @@ -216,6 +275,7 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [ GameStateMessageSchema, GameDeltaMessageSchema, GameEndMessageSchema, + GamePresetsMessageSchema, ErrorMessageSchema, ]); export type ServerMessage = z.infer; @@ -225,11 +285,13 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [ RoomJoinMessageSchema, RoomLeaveMessageSchema, GameMoveMessageSchema, + RoomSetPresetsMessageSchema, RoomCreatedMessageSchema, RoomJoinedMessageSchema, GameStateMessageSchema, GameDeltaMessageSchema, GameEndMessageSchema, + GamePresetsMessageSchema, ErrorMessageSchema, ]); export type AnyMessage = z.infer; @@ -239,11 +301,13 @@ export const KNOWN_MESSAGE_TYPES = [ "room.join", "room.leave", "game.move", + "room.setPresets", "room.created", "room.joined", "game.state", "game.delta", "game.end", + "game.presets", "error", ] as const; export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number];