Compare commits
10 commits
d4622b2cb4
...
af9973adfa
| Author | SHA1 | Date | |
|---|---|---|---|
|
af9973adfa |
|||
|
3e8bc7394e |
|||
|
60217adf97 |
|||
|
882e176b34 |
|||
|
598370fe2e |
|||
|
ae87772277 |
|||
|
08b5f794a7 |
|||
|
de059fe707 |
|||
|
5fb96647eb |
|||
|
8b8a5179ec |
33 changed files with 2920 additions and 619 deletions
|
|
@ -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"
|
||||
|
|
|
|||
40
.sisyphus/notepads/rete-rules-engine/learnings.md
Normal file
40
.sisyphus/notepads/rete-rules-engine/learnings.md
Normal file
|
|
@ -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`
|
||||
|
|
@ -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<object>` 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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Chess App</title>
|
||||
<title>Houserules</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<AnimatePresence mode="wait">
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route path="/" element={<PageTransition><Lobby chessState={chessState} /></PageTransition>} />
|
||||
<Route path="/game" element={<PageTransition><GameView engineState={chessState} /></PageTransition>} />
|
||||
<Route path="/game" element={<PageTransition><GameRoute chessState={chessState} /></PageTransition>} />
|
||||
<Route path="/rules" element={<PageTransition><RulesView chessState={chessState} /></PageTransition>} />
|
||||
<Route path="/save" element={<PageTransition><SaveWrapper chessState={chessState} /></PageTransition>} />
|
||||
</Routes>
|
||||
|
|
@ -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<typeof useChessEngine>
|
||||
}) {
|
||||
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 <MultiplayerGameView code={mpCreds.code} token={mpCreds.token} />
|
||||
}
|
||||
return <GameView engineState={chessState} />
|
||||
}
|
||||
|
||||
function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
|
|
|
|||
|
|
@ -29,3 +29,48 @@ export const pieceAssets = {
|
|||
pawn: blackPawn,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Module-level array anchoring preloaded Image objects so the browser
|
||||
* GC doesn't collect them after the init block below completes. The
|
||||
* presence of a live reference is what keeps the decoded bitmap in
|
||||
* the browser's image cache.
|
||||
*/
|
||||
// Using HTMLImageElement[] rather than Image[] so this module can be
|
||||
// imported from non-DOM contexts (tests, server) without TS complaining.
|
||||
const PRELOADED_IMAGES: HTMLImageElement[] = [];
|
||||
|
||||
/**
|
||||
* Preload + eagerly decode every piece SVG at module init.
|
||||
*
|
||||
* Why: every move triggers a FLIP remount in Piece.tsx — the old
|
||||
* `<img>` 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 `<img>` 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 `<img src=…>`
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ChessEngine | null>(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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
275
packages/chess/src/hooks/useMultiplayerGame.ts
Normal file
275
packages/chess/src/hooks/useMultiplayerGame.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
/**
|
||||
* React hook for a server-backed chess game.
|
||||
*
|
||||
* Wraps GameClient + PredictionManager and exposes the same surface as
|
||||
* `useChessEngine` so `<GameView>` 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<string, string> }).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<GameClient | null>(null);
|
||||
const managerRef = useRef<PredictionManager | null>(null);
|
||||
const [tick, setTick] = useState(0);
|
||||
const [meta, setMeta] = useState<MultiplayerGameState>({
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, never>>
|
||||
| MessageEnvelope<"game.move", GameMovePayload>;
|
||||
| MessageEnvelope<"game.move", GameMovePayload>
|
||||
| MessageEnvelope<"room.setPresets", RoomSetPresetsPayload>;
|
||||
|
|
|
|||
191
packages/chess/src/presets/active-set.test.ts
Normal file
191
packages/chess/src/presets/active-set.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
233
packages/chess/src/presets/active-set.ts
Normal file
233
packages/chess/src/presets/active-set.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
/**
|
||||
* Per-game active preset tracking.
|
||||
*
|
||||
* Earlier iterations of the preset system used a module-level
|
||||
* `PRESET_REGISTRY.active: Set<string>` 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<string, PresetActivation>();
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, PresetDef>();
|
||||
private readonly active = new Set<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
298
packages/chess/src/presets/wrap-board.test.ts
Normal file
298
packages/chess/src/presets/wrap-board.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<readonly [number, number]> = [
|
||||
[1, 2], [2, 1], [2, -1], [1, -2],
|
||||
[-1, -2], [-2, -1], [-2, 1], [-1, 2],
|
||||
];
|
||||
|
||||
/** King 8-neighbour offsets. */
|
||||
const KING_OFFSETS: ReadonlyArray<readonly [number, number]> = [
|
||||
[1, 0], [1, 1], [0, 1], [-1, 1],
|
||||
[-1, 0], [-1, -1], [0, -1], [1, -1],
|
||||
];
|
||||
|
||||
/** Rook ray directions. */
|
||||
const ROOK_RAYS: ReadonlyArray<readonly [number, number]> = [
|
||||
[1, 0], [-1, 0], [0, 1], [0, -1],
|
||||
];
|
||||
|
||||
/** Bishop ray directions. */
|
||||
const BISHOP_RAYS: ReadonlyArray<readonly [number, number]> = [
|
||||
[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);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<number, PieceState>();
|
||||
|
|
@ -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<number | null>(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 <Piece>
|
||||
// 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(
|
||||
<div
|
||||
key={sq}
|
||||
data-square={algebraic}
|
||||
className={`relative aspect-square flex items-center justify-center ${
|
||||
isDark ? 'bg-[#B58863]' : 'bg-[#F0D9B5]'
|
||||
} shadow-[inset_0_0_8px_rgba(0,0,0,0.15)]`}
|
||||
} shadow-[inset_0_0_8px_rgba(0,0,0,0.15)] ${isHostingDragged ? 'z-50' : ''}`}
|
||||
onDragOver={(e) => handleDragOver(e, sq)}
|
||||
onDragLeave={(e) => handleDragLeave(e, sq)}
|
||||
onDrop={(e) => handleDrop(e, sq)}
|
||||
>
|
||||
{isLastMove && (
|
||||
<div className="absolute inset-0 bg-yellow-400/30 pointer-events-none z-0" />
|
||||
)}
|
||||
|
||||
{isHighlighted && (
|
||||
<div className="absolute inset-0 bg-black/15 pointer-events-none ring-4 ring-inset ring-black/20 z-10 rounded-full m-4" />
|
||||
{/*
|
||||
* 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 && (
|
||||
<div
|
||||
data-role="drop-target"
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-none z-10"
|
||||
>
|
||||
<div className="w-1/3 h-1/3 rounded-full bg-black/25" />
|
||||
</div>
|
||||
)}
|
||||
{isHighlighted && !hoveredValid && isCaptureTarget && (
|
||||
<div
|
||||
data-role="drop-target-capture"
|
||||
className="absolute inset-1 pointer-events-none z-10 rounded-full ring-[6px] ring-inset ring-black/25"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* 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 && (
|
||||
<motion.div
|
||||
data-role="drop-hover-valid"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className={`absolute inset-0 pointer-events-none z-10 ${
|
||||
isCaptureTarget
|
||||
? 'ring-[6px] ring-inset ring-emerald-400/80 bg-emerald-400/25'
|
||||
: 'bg-emerald-400/35 ring-4 ring-inset ring-emerald-500/50'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* 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 && (
|
||||
<div
|
||||
data-role="drop-hover-invalid"
|
||||
className="absolute inset-0 pointer-events-none z-10 bg-red-500/15"
|
||||
/>
|
||||
)}
|
||||
|
||||
{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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<ChessFact<keyof ChessAttrMap>> | ReturnType<typeof useChessEngine>['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<typeof useChessEngine>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <GameLayout state={state} myColor={null} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<div className="text-neutral-500 font-medium">Waiting for opponent…</div>
|
||||
<div className="mt-2 text-sm text-neutral-400" data-testid="mp-room-code">
|
||||
Room: {code}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{state.error !== null && (
|
||||
<div
|
||||
data-testid="mp-error"
|
||||
className="fixed top-4 left-1/2 -translate-x-1/2 z-50 px-4 py-2 bg-red-50 border border-red-200 text-red-700 text-sm font-medium rounded-md shadow-sm"
|
||||
>
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
<GameLayout state={state} myColor={state.myColor} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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=<turn>.
|
||||
const colorById = new Map<number, string>();
|
||||
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 (
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex flex-col items-center gap-8 py-8 w-full max-w-4xl mx-auto"
|
||||
>
|
||||
<RulesDrawer onRulesChanged={refresh} />
|
||||
<RulesDrawer
|
||||
activations={activations}
|
||||
setPresets={setPresets}
|
||||
onRulesChanged={refresh}
|
||||
/>
|
||||
<div className="flex flex-col md:flex-row w-full items-start justify-between gap-4 px-4">
|
||||
|
||||
|
||||
{/* Header/Info section */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-neutral-800">
|
||||
Chess
|
||||
Houserules
|
||||
</h1>
|
||||
<button
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="p-2 text-neutral-500 hover:text-neutral-800 hover:bg-neutral-100 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-200"
|
||||
title={isMuted ? "Unmute sounds" : "Mute sounds"}
|
||||
>
|
||||
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
</button>
|
||||
{myColor && (
|
||||
<span
|
||||
data-testid="my-color"
|
||||
className="text-sm font-medium text-neutral-500 px-2 py-1 bg-neutral-100 rounded"
|
||||
>
|
||||
You are {myColor}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
data-testid="turn-indicator"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-neutral-200 rounded-md font-medium text-neutral-700 shadow-sm"
|
||||
>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full shadow-inner ${turn === 'white' ? 'bg-white border border-neutral-300' : 'bg-neutral-900 border border-neutral-950'}`}
|
||||
/>
|
||||
<span>{turn === 'white' ? "White's turn" : "Black's turn"}</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* 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 (
|
||||
<div
|
||||
data-testid="turn-indicator"
|
||||
data-my-turn={isMyTurn ? 'true' : 'false'}
|
||||
className={containerClass}
|
||||
>
|
||||
{isMyTurn ? (
|
||||
<motion.div
|
||||
className="w-3 h-3 rounded-full bg-emerald-500 shadow-inner"
|
||||
animate={{ scale: [1, 1.25, 1], opacity: [1, 0.7, 1] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5, ease: 'easeInOut' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full shadow-inner ${turn === 'white' ? 'bg-white border border-neutral-300' : 'bg-neutral-900 border border-neutral-950'}`}
|
||||
/>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 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. */}
|
||||
<AnimatePresence>
|
||||
{isCheck && !isGameOver && (
|
||||
<motion.div
|
||||
key="check-banner"
|
||||
initial={{ opacity: 0, scale: 0.8, x: -10 }}
|
||||
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.8, x: -10 }}
|
||||
transition={{ type: 'spring', damping: 18, stiffness: 280 }}
|
||||
data-testid="check-banner"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-50 border border-red-300 text-red-800 rounded-md font-semibold shadow-sm"
|
||||
>
|
||||
<motion.span
|
||||
className="w-2 h-2 rounded-full bg-red-500"
|
||||
animate={{ opacity: [1, 0.4, 1] }}
|
||||
transition={{ repeat: Infinity, duration: 1.2, ease: 'easeInOut' }}
|
||||
/>
|
||||
Check!
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Undo is only meaningful in local mode (server is authoritative
|
||||
* in multiplayer); the hook exposes canUndo=false there so the
|
||||
* button naturally stays disabled. */}
|
||||
<motion.button
|
||||
whileTap={canUndo ? { scale: 0.95 } : {}}
|
||||
data-action="undo"
|
||||
|
|
@ -127,7 +330,7 @@ export function GameView({ engineState }: GameViewProps) {
|
|||
{/* Game Result Banner */}
|
||||
<AnimatePresence>
|
||||
{isGameOver && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: -20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ type: 'spring', damping: 20 }}
|
||||
|
|
@ -141,25 +344,26 @@ export function GameView({ engineState }: GameViewProps) {
|
|||
</div>
|
||||
|
||||
<div className="w-full px-4 relative">
|
||||
<Board
|
||||
facts={facts as ChessFact<keyof ChessAttrMap>[]}
|
||||
legalMoves={legalMoves}
|
||||
turn={turn}
|
||||
onMove={handleMove}
|
||||
<Board
|
||||
facts={facts as ChessFact<keyof ChessAttrMap>[]}
|
||||
legalMoves={legalMoves}
|
||||
turn={turn as Color}
|
||||
myColor={myColor}
|
||||
onMove={handleMove}
|
||||
lastMove={lastMove}
|
||||
checkedKingSquare={checkedKingSquare}
|
||||
/>
|
||||
|
||||
|
||||
{/* Overlay for game over to prevent further interaction visually */}
|
||||
{isGameOver && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="absolute inset-0 bg-white/10 backdrop-blur-[1px] pointer-events-none z-30"
|
||||
className="absolute inset-0 bg-white/10 backdrop-blur-[1px] pointer-events-none z-30"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,8 +175,8 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
|||
|
||||
<div className="w-full max-w-md bg-white/70 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/50 overflow-hidden z-10 relative">
|
||||
<div className="p-8 text-center border-b border-neutral-200/50">
|
||||
<h1 className="text-3xl font-extrabold text-neutral-900 tracking-tight">Paratype Chess</h1>
|
||||
<p className="text-neutral-500 font-medium mt-2">Realtime multiplayer with custom rules</p>
|
||||
<h1 className="text-3xl font-extrabold text-neutral-900 tracking-tight">Houserules</h1>
|
||||
<p className="text-neutral-500 font-medium mt-2">Chess. Your rules.</p>
|
||||
<button
|
||||
data-action="play-solo"
|
||||
onClick={handlePlaySolo}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,11 @@ export function Piece({
|
|||
const staleStashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
// Hover state — tracked only when the piece is draggable (i.e. it's
|
||||
// this player's turn AND this is one of their pieces). Feeds a subtle
|
||||
// scale-up + brighter shadow so you can see at a glance which piece
|
||||
// you're about to pick up.
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const detachDragOver = () => {
|
||||
if (dragOverHandlerRef.current) {
|
||||
|
|
@ -274,14 +279,28 @@ export function Piece({
|
|||
? 'cursor-grab active:cursor-grabbing'
|
||||
: 'cursor-default';
|
||||
|
||||
// Only apply hover affordance when the piece can actually be picked
|
||||
// up. On opponent pieces (or your pieces on the opponent's turn) the
|
||||
// hover is a no-op so we don't advertise an action the player can't
|
||||
// take. We also suppress it while actively dragging — the scale-up
|
||||
// there is owned by the drag treatment.
|
||||
const showHover = isHovered && isDraggable && !isDragging;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<div
|
||||
draggable={isDraggable}
|
||||
data-piece={`${color}-${type}`}
|
||||
data-piece-id={pieceId}
|
||||
data-hovered={showHover ? 'true' : 'false'}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
// Mouse hover toggles are gated to draggable pieces inside
|
||||
// `showHover` so we don't need to branch the handlers; keeping
|
||||
// them unconditional means the state is correct if `isDraggable`
|
||||
// flips mid-hover (e.g. a move completes while hovering).
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={`flex items-center justify-center w-full h-full select-none ${cursorClass}`}
|
||||
>
|
||||
<motion.div
|
||||
|
|
@ -289,8 +308,14 @@ export function Piece({
|
|||
className={`flex items-center justify-center w-full h-full ${isDragging ? 'pointer-events-none' : ''}`}
|
||||
style={{ x, y, rotate }}
|
||||
animate={{
|
||||
scale: isDragging ? 1.15 : 1,
|
||||
zIndex: isDragging ? 50 : 0,
|
||||
// Scale priorities (highest wins):
|
||||
// isDragging -> 1.15 (big lift, matches drag juice)
|
||||
// showHover -> 1.08 (subtle pickup affordance)
|
||||
// otherwise -> 1.00
|
||||
// zIndex bumps on hover too so the enlarged piece overlaps
|
||||
// neighbours cleanly without clipping.
|
||||
scale: isDragging ? 1.15 : showHover ? 1.08 : 1,
|
||||
zIndex: isDragging ? 50 : showHover ? 10 : 0,
|
||||
}}
|
||||
transition={{
|
||||
scale: { type: 'spring', stiffness: 500, damping: 30 },
|
||||
|
|
@ -299,11 +324,23 @@ export function Piece({
|
|||
>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={`${color} ${type}`}
|
||||
// Empty alt + `decoding=sync` eliminate the text-flash
|
||||
// between unmount at the old square and mount at the new
|
||||
// one. Previously the freshly-attached <img> briefly
|
||||
// displayed its `alt` string (e.g. "white knight") for the
|
||||
// frame it took the browser to decode the SVG. With the
|
||||
// images preloaded at module init (see ./assets/pieces)
|
||||
// and sync decoding requested here, the first painted
|
||||
// frame always has the piece bitmap.
|
||||
alt=""
|
||||
aria-label={`${color} ${type}`}
|
||||
decoding="sync"
|
||||
className={`w-[85%] h-[85%] pointer-events-none transition-[filter] duration-200 ${
|
||||
isDragging
|
||||
? 'drop-shadow-[0_12px_16px_rgba(0,0,0,0.45)]'
|
||||
: 'drop-shadow-[0_4px_4px_rgba(0,0,0,0.3)]'
|
||||
: showHover
|
||||
? 'drop-shadow-[0_8px_10px_rgba(0,0,0,0.4)]'
|
||||
: 'drop-shadow-[0_4px_4px_rgba(0,0,0,0.3)]'
|
||||
}`}
|
||||
draggable={false}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useMemo, useState, type ChangeEvent } from 'react';
|
||||
import { PRESET_REGISTRY } from '../presets/index.js';
|
||||
import type { PresetScope } from '../presets/active-set.js';
|
||||
import type { PresetActivation } from '../net/types.js';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { Settings2, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
|
@ -7,57 +9,121 @@ import { toast } from 'sonner';
|
|||
/**
|
||||
* A collapsible side-drawer for toggling preset rules mid-game.
|
||||
*
|
||||
* Writes straight to PRESET_REGISTRY on every toggle. The ChessEngine reads
|
||||
* PRESET_REGISTRY.getActive() fresh on every call to getAllLegalMoves(), so
|
||||
* a toggle takes effect on the very next move calculation — no reset, no
|
||||
* reload, no navigation.
|
||||
*
|
||||
* Mounts a small tick counter to force a re-render when the registry
|
||||
* changes externally (e.g. via the /rules page), so this drawer stays in
|
||||
* sync if both UIs are open.
|
||||
* Server-authoritative: every toggle/scope/duration change builds a
|
||||
* full replacement list and dispatches via `setPresets`. In multiplayer
|
||||
* that goes to the server which echoes `game.presets` back; in local
|
||||
* mode it mutates the engine directly. Either way the UI reads
|
||||
* `activations` as its source of truth — we never flip a toggle locally
|
||||
* and then try to reconcile.
|
||||
*/
|
||||
interface RulesDrawerProps {
|
||||
/** Called after any toggle so the parent can recompute derived UI state
|
||||
* (legal-move highlights, etc.) that depends on the active preset set. */
|
||||
/** Current authoritative preset activations (from hook). */
|
||||
activations: readonly PresetActivation[];
|
||||
/** Replace the active set on the engine / server. */
|
||||
setPresets: (activations: PresetActivation[]) => void;
|
||||
/** Called after any toggle so the parent can recompute derived UI
|
||||
* state (legal-move highlights, etc) — local mode only. */
|
||||
onRulesChanged?: () => void;
|
||||
}
|
||||
|
||||
export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
||||
/** Build a fresh activation list that reflects a single edit. */
|
||||
function applyEdit(
|
||||
current: readonly PresetActivation[],
|
||||
id: string,
|
||||
patch: Partial<PresetActivation> & { 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 RulesDrawer({
|
||||
activations,
|
||||
setPresets,
|
||||
onRulesChanged,
|
||||
}: RulesDrawerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// Force-refresh trigger. The registry itself has no event emitter, so we
|
||||
// bump this counter on every open and every toggle click to re-read the
|
||||
// active set. The value is never read directly — only the setter is used
|
||||
// to trigger a re-render of this component.
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
// Re-read active set on every open so external changes reflect.
|
||||
useEffect(() => {
|
||||
if (open) setTick((t) => t + 1);
|
||||
}, [open]);
|
||||
|
||||
const presets = PRESET_REGISTRY.getAll();
|
||||
const activeIds = new Set(PRESET_REGISTRY.getActive().map((p) => p.id));
|
||||
const presets = useMemo(() => PRESET_REGISTRY.getAll(), []);
|
||||
const activeById = useMemo(() => {
|
||||
const map = new Map<string, PresetActivation>();
|
||||
for (const a of activations) map.set(a.id, a);
|
||||
return map;
|
||||
}, [activations]);
|
||||
|
||||
const toggle = (id: string, name: string) => {
|
||||
const isActivating = !activeIds.has(id);
|
||||
if (!isActivating) {
|
||||
PRESET_REGISTRY.deactivate(id);
|
||||
toast(`Preset disabled: ${name}`);
|
||||
} else {
|
||||
try {
|
||||
PRESET_REGISTRY.activate(id);
|
||||
toast.info(`Preset enabled: ${name}`);
|
||||
} catch (err) {
|
||||
// activate() throws for missing requires or incompatibilities. We
|
||||
// surface the reason via the UI only for the user's next refresh.
|
||||
console.warn(`Could not activate ${id}:`, err);
|
||||
toast.error(`Could not activate ${name}`);
|
||||
}
|
||||
const currently = activeById.get(id);
|
||||
const next: PresetActivation[] = currently
|
||||
? applyEdit(activations, id, { remove: true })
|
||||
: applyEdit(activations, id, { scope: 'both', turnsRemaining: null });
|
||||
try {
|
||||
setPresets(next);
|
||||
toast(
|
||||
currently ? `Preset disabled: ${name}` : `Preset enabled: ${name}`,
|
||||
);
|
||||
} catch (err) {
|
||||
// Local mode throws synchronously; multiplayer rejects async via
|
||||
// the server's error event. We tell the user either way.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(`Could not update ${name}: ${msg}`);
|
||||
}
|
||||
onRulesChanged?.();
|
||||
};
|
||||
|
||||
const setScope = (id: string, scope: PresetScope) => {
|
||||
const next = applyEdit(activations, id, { scope });
|
||||
try {
|
||||
setPresets(next);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Could not set scope: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
onRulesChanged?.();
|
||||
};
|
||||
|
||||
const setDuration = (
|
||||
id: string,
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const raw = event.target.value.trim();
|
||||
// Empty string === permanent (null). Otherwise must parse to a
|
||||
// positive integer; invalid input is silently ignored so a user
|
||||
// mid-typing "12" doesn't crash on intermediate "1".
|
||||
let turnsRemaining: number | null = null;
|
||||
if (raw !== '') {
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n <= 0) return;
|
||||
turnsRemaining = n;
|
||||
}
|
||||
try {
|
||||
setPresets(applyEdit(activations, id, { turnsRemaining }));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Could not set duration: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
setTick((t) => t + 1);
|
||||
// Ask the parent engine state to recompute legalMoves — otherwise the
|
||||
// Board's highlighted drop targets won't reflect the new rule set
|
||||
// until the next move bumps the hook's internal tick.
|
||||
onRulesChanged?.();
|
||||
};
|
||||
|
||||
|
|
@ -75,14 +141,13 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
|||
>
|
||||
<Settings2 size={16} />
|
||||
Rules
|
||||
{activeIds.size > 0 && (
|
||||
{activeById.size > 0 && (
|
||||
<span className="bg-neutral-800 text-white rounded-full px-2 py-0.5 text-[10px] font-bold ml-1">
|
||||
{activeIds.size}
|
||||
{activeById.size}
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
{/* Backdrop + drawer */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
|
|
@ -103,9 +168,11 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
|||
>
|
||||
<header className="px-6 py-5 border-b border-neutral-100 flex items-center justify-between bg-white">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight text-neutral-900">Live Rules</h2>
|
||||
<h2 className="text-xl font-bold tracking-tight text-neutral-900">
|
||||
Live Rules
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-500 mt-1">
|
||||
Toggle rules mid-game
|
||||
Toggle rules mid-game · scope + duration per rule
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -121,16 +188,8 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
|||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{presets.map((preset) => {
|
||||
const isOn = activeIds.has(preset.id);
|
||||
const blockedBy = preset.incompatibleWith.filter((id) =>
|
||||
activeIds.has(id),
|
||||
);
|
||||
const missingReqs = preset.requires.filter(
|
||||
(id) => !activeIds.has(id),
|
||||
);
|
||||
const blocked =
|
||||
!isOn && (blockedBy.length > 0 || missingReqs.length > 0);
|
||||
|
||||
const active = activeById.get(preset.id);
|
||||
const isOn = active !== undefined;
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
|
|
@ -138,8 +197,6 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
|||
className={`border rounded-xl p-4 transition-all duration-200 ${
|
||||
isOn
|
||||
? 'border-neutral-300 bg-neutral-50 shadow-sm'
|
||||
: blocked
|
||||
? 'border-neutral-100 bg-neutral-50/50 opacity-60'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
|
|
@ -151,25 +208,14 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
|||
<p className="text-sm text-neutral-500 mt-1 leading-relaxed">
|
||||
{preset.description}
|
||||
</p>
|
||||
{blockedBy.length > 0 && (
|
||||
<p className="text-xs font-medium text-amber-600 mt-2">
|
||||
Conflicts with active: {blockedBy.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{missingReqs.length > 0 && (
|
||||
<p className="text-xs font-medium text-amber-600 mt-2">
|
||||
Requires: {missingReqs.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-role="toggle"
|
||||
disabled={blocked}
|
||||
onClick={() => toggle(preset.id, preset.name)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 focus:ring-offset-white ${
|
||||
isOn ? 'bg-neutral-900' : 'bg-neutral-200'
|
||||
} ${blocked ? 'cursor-not-allowed' : ''}`}
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={isOn}
|
||||
>
|
||||
|
|
@ -182,15 +228,87 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
|||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOn && (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 space-y-3">
|
||||
{/* Scope radios */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<label className="text-xs font-semibold text-neutral-600 uppercase tracking-wide">
|
||||
Apply to
|
||||
</label>
|
||||
<div
|
||||
className="flex rounded-lg bg-neutral-100 p-0.5 text-xs font-medium"
|
||||
role="radiogroup"
|
||||
aria-label={`${preset.name} scope`}
|
||||
data-role="scope-group"
|
||||
>
|
||||
{(['both', 'white', 'black'] as const).map(
|
||||
(scope) => {
|
||||
const selected = active.scope === scope;
|
||||
return (
|
||||
<button
|
||||
key={scope}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
data-role={`scope-${scope}`}
|
||||
onClick={() => setScope(preset.id, scope)}
|
||||
className={`px-3 py-1 rounded-md transition-colors capitalize ${
|
||||
selected
|
||||
? 'bg-white text-neutral-900 shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-800'
|
||||
}`}
|
||||
>
|
||||
{scope}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration input */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<label
|
||||
htmlFor={`duration-${preset.id}`}
|
||||
className="text-xs font-semibold text-neutral-600 uppercase tracking-wide"
|
||||
>
|
||||
Duration
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={`duration-${preset.id}`}
|
||||
data-role="duration-input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="∞"
|
||||
value={
|
||||
active.turnsRemaining === null
|
||||
? ''
|
||||
: String(active.turnsRemaining)
|
||||
}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{active.turnsRemaining === null
|
||||
? 'permanent'
|
||||
: 'turns'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<footer className="p-4 border-t border-neutral-100 bg-white text-xs font-medium text-neutral-400 text-center">
|
||||
{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`}
|
||||
</footer>
|
||||
</motion.aside>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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<typeof useChessEngine>;
|
||||
/** Required — the view always shows the engine's current active set
|
||||
* and delegates mutation back to `chessState.setPresets`. */
|
||||
chessState: ReturnType<typeof useChessEngine>;
|
||||
isGameActive?: boolean;
|
||||
}
|
||||
|
||||
/** Build a fresh activation list that reflects a single edit. */
|
||||
function applyEdit(
|
||||
current: readonly PresetActivation[],
|
||||
id: string,
|
||||
patch: Partial<PresetActivation> & { 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<Set<string>>(
|
||||
() => 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<string, PresetActivation>();
|
||||
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 (
|
||||
<div className="p-6 max-w-3xl mx-auto space-y-6" data-testid="page-rules">
|
||||
|
|
@ -109,7 +98,8 @@ export function RulesView({ chessState, isGameActive }: RulesViewProps) {
|
|||
<div>
|
||||
<h1 className="text-3xl font-bold">Preset Rules</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -121,95 +111,114 @@ export function RulesView({ chessState, isGameActive }: RulesViewProps) {
|
|||
</button>
|
||||
</header>
|
||||
|
||||
{isGameActive && (
|
||||
<div className="bg-gray-100 text-gray-700 p-4 rounded-md">
|
||||
Rules can only be changed between games
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasIncompatibilities && (
|
||||
<div
|
||||
data-testid="compat-warning"
|
||||
className="bg-amber-100 text-amber-800 p-4 rounded-md font-semibold"
|
||||
>
|
||||
Warning: Some selected rules are mutually incompatible — they will not both apply.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{missingRequires.length > 0 && (
|
||||
<div
|
||||
data-testid="requires-warning"
|
||||
className="bg-amber-100 text-amber-800 p-4 rounded-md"
|
||||
>
|
||||
Some selected rules are missing prerequisites:
|
||||
<ul className="list-disc list-inside mt-1">
|
||||
{missingRequires.map((m) => (
|
||||
<li key={`${m.id}-${m.needs}`}>
|
||||
<code>{m.id}</code> requires <code>{m.needs}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{presets.map((preset) => (
|
||||
<div
|
||||
key={preset.id}
|
||||
data-preset={preset.id}
|
||||
className={`border rounded-lg p-4 flex items-center justify-between transition-colors ${
|
||||
isGameActive ? 'opacity-50 grayscale' : 'hover:border-blue-400'
|
||||
}`}
|
||||
>
|
||||
<div className="pr-4">
|
||||
<h3 className="text-lg font-semibold">{preset.name}</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">{preset.description}</p>
|
||||
{preset.incompatibleWith.length > 0 && (
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Incompatible with:{' '}
|
||||
{preset.incompatibleWith.map((x) => (
|
||||
<code key={x} className="mx-1">
|
||||
{x}
|
||||
</code>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
{preset.requires.length > 0 && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Requires:{' '}
|
||||
{preset.requires.map((x) => (
|
||||
<code key={x} className="mx-1">
|
||||
{x}
|
||||
</code>
|
||||
))}
|
||||
</p>
|
||||
{presets.map((preset) => {
|
||||
const active = activeById.get(preset.id);
|
||||
const isOn = active !== undefined;
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
data-preset={preset.id}
|
||||
className={`border rounded-lg p-4 transition-colors ${
|
||||
isGameActive
|
||||
? 'opacity-50 grayscale'
|
||||
: 'hover:border-blue-400'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="pr-4">
|
||||
<h3 className="text-lg font-semibold">{preset.name}</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{preset.description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-role="toggle"
|
||||
disabled={isGameActive}
|
||||
onClick={() => togglePreset(preset.id)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
|
||||
isOn ? 'bg-blue-600' : 'bg-gray-200'
|
||||
} ${isGameActive ? 'cursor-not-allowed' : ''}`}
|
||||
role="switch"
|
||||
aria-checked={isOn}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
isOn ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOn && (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-neutral-600 uppercase tracking-wide mb-2">
|
||||
Apply to
|
||||
</label>
|
||||
<div
|
||||
className="flex rounded-lg bg-neutral-100 p-0.5 text-sm font-medium"
|
||||
role="radiogroup"
|
||||
data-role="scope-group"
|
||||
>
|
||||
{(['both', 'white', 'black'] as const).map((scope) => {
|
||||
const selected = active.scope === scope;
|
||||
return (
|
||||
<button
|
||||
key={scope}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
data-role={`scope-${scope}`}
|
||||
onClick={() => setScope(preset.id, scope)}
|
||||
className={`flex-1 px-3 py-1.5 rounded-md transition-colors capitalize ${
|
||||
selected
|
||||
? 'bg-white text-neutral-900 shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-800'
|
||||
}`}
|
||||
>
|
||||
{scope}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`duration-${preset.id}`}
|
||||
className="block text-xs font-semibold text-neutral-600 uppercase tracking-wide mb-2"
|
||||
>
|
||||
Duration (turns, blank = permanent)
|
||||
</label>
|
||||
<input
|
||||
id={`duration-${preset.id}`}
|
||||
data-role="duration-input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="∞"
|
||||
value={
|
||||
active.turnsRemaining === null
|
||||
? ''
|
||||
: String(active.turnsRemaining)
|
||||
}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-role="toggle"
|
||||
disabled={isGameActive}
|
||||
onClick={() => togglePreset(preset.id)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
|
||||
selected.has(preset.id) ? 'bg-blue-600' : 'bg-gray-200'
|
||||
} ${isGameActive ? 'cursor-not-allowed' : ''}`}
|
||||
role="switch"
|
||||
aria-checked={selected.has(preset.id)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
selected.has(preset.id) ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pt-6 space-y-3">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-3 text-sm text-blue-900">
|
||||
<strong>Tip:</strong> toggles above take effect <em>immediately</em> — including in the middle of a game. No reset required.
|
||||
<strong>Tip:</strong> edits above take effect <em>immediately</em> —
|
||||
including in the middle of a game. No reset required.
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{activeCount === 0
|
||||
|
|
|
|||
|
|
@ -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<ClientData>,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -109,6 +109,40 @@ export const GameMovePayloadSchema = z.object({
|
|||
});
|
||||
export type GameMovePayload = z.infer<typeof GameMovePayloadSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preset scope + activation — used by both directions of the preset sync.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PresetScopeSchema = z.enum(["both", "white", "black"]);
|
||||
export type PresetScope = z.infer<typeof PresetScopeSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof PresetActivationSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof RoomSetPresetsPayloadSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<typeof GameStatePayloadSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof GamePresetsPayloadSchema>;
|
||||
|
||||
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<typeof ClientMessageSchema>;
|
||||
|
||||
|
|
@ -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<typeof ServerMessageSchema>;
|
||||
|
|
@ -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<typeof AnyMessageSchema>;
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue