Compare commits

...

10 commits

Author SHA1 Message Date
af9973adfa
feat(branding): rename product to Houserules
Product name: Houserules \u2014 chess with the rules you negotiate at the
table, not the ones FIDE hands you. Updated the three user-visible
strings: browser title, game-view header, lobby header. Kept the
@paratype/* npm scope intact since those are technical internals.

New tagline on the lobby: "Chess. Your rules." replacing the more
verbose "Realtime multiplayer with custom rules"; the shorter phrase
leans into the product name rather than describing features.
2026-04-17 15:27:48 -06:00
3e8bc7394e
Add sisyphus 2026-04-17 15:22:04 -06:00
60217adf97
fix(chess): cylindrical board wraps every piece, plus eliminate piece image flash
wrap-board previously only wrapped rooks and queens horizontally,
which meant knights, kings, bishops, and pawn captures couldn`t
cross the file seam at all — a knight on h4 with cylindrical
enabled had no wrap targets, contrary to the "horizontal cylinder"
framing. Rewrote the hook to handle every piece type:
knights/kings via mod-8 file offsets; bishops/queens/rooks via
cylindrical ray walkers; pawn captures via mod-8 diagonal targets.
Rank bounds still terminate walks (cylinder, not torus). Added 4
piece-type tests to cover the new cases.

Piece image flash on remount: every move triggers a FLIP unmount
at the source square and remount at the destination, producing a
brand-new <img> element. Browsers don`t block paint on image
load, so the alt text briefly rendered before the SVG decoded.
Preload and eagerly-decode every piece SVG at module init to keep
the decoded bitmap warm in the browser image cache, and set
`decoding="sync"` plus empty alt on the Piece img so the alt
never has a paint window. aria-label preserves the accessible
name.
2026-04-17 15:17:57 -06:00
882e176b34
fix(chess): real cylindrical-board slide wrap + working check indicator
wrap-board previously contributed a single one-square hop when the
rook/queen was already on an edge file, which is essentially a no-op
for 99% of positions. Rewrote the hook to implement an actual
cylindrical topology: sliders walk past the a/h seam and continue
along the rank on the opposite side, stopping at the first ally (no
destination) or enemy (capture). Preserves the rook-warp incompatibility
since the two presets have different "when does the wrap apply"
preconditions.

Check indicator: GameView looked for an `InCheck` boolean fact that
doesn`t exist in the session — check status is a derived predicate
(isInCheck), not a stored fact. Exported isInCheck from the package
public API, wired GameView to query it per render, added a pulsing
red "Check!" banner in the header, and fixed the useChessEngine
audio path which had the same stale-fact bug (was never playing the
check sound).

Tests: 6 new wrap-board tests covering empty-rank slides, partial
blocks, queen slides, and the no-op case for non-sliders. Total
849 tests pass (+6).
2026-04-17 15:03:49 -06:00
598370fe2e
feat(chess): hover affordance on draggable pieces
Pieces you can actually pick up (your color, your turn, game ongoing)
now lift slightly on mouseover — spring scale to 1.08 with a deeper
drop shadow — so you see at a glance which piece you are about to
grab. Opponent pieces and your pieces on the opponent\`s turn stay
static, so the hover never advertises an action you cannot take.
The effect is suppressed while actively dragging; the larger drag
scale already owns the visual treatment there.
2026-04-17 14:55:37 -06:00
ae87772277
feat(chess): drop-target hover indicator + personalized turn banner
Board now renders three visual states during a drag: faint dot for
legal quiet-move targets, ring for legal capture targets, and a bright
emerald fill when the cursor is actually over a valid drop square so
the user sees exactly where the piece will land. Hovering an invalid
square shows a subtle red tint telling the player the drag will snap
back on release.

Turn indicator shows "Your turn" (with a pulsing emerald dot and
green card treatment) when it`s the local player`s turn in
multiplayer, and "Opponent`s turn" otherwise. Solo play falls back
to the neutral "White`s turn" / "Black`s turn" phrasing.
2026-04-17 14:41:01 -06:00
08b5f794a7
feat(chess): allow pawns-move-backward and double-pawn-sprint simultaneously
Audited all 15 preset incompatibilities. Five of the six declared
pairs are genuine mechanical conflicts (rook-warp vs wrap-board have
contradictory board-topology semantics; piece-hp vs explosive-rook
define capture as damage vs removal; capture-to-win vs
last-piece-standing are competing win conditions). The
pawns-move-backward vs double-pawn-sprint pair is not — both are pure
additive getExtraMoves hooks over the base pawn rule; the move sets
are disjoint (backward-1 vs forward-2); and no generated move
contradicts any other.

Dropped the declaration from both preset files. Moved the
mutually-incompatible-pair tests to use rook-warp vs wrap-board,
which is the canonical genuine-conflict pair and exercises the same
compat machinery.
2026-04-17 14:34:18 -06:00
de059fe707
feat(chess): per-color preset scope, turn-limited duration, server-authoritative sync
Presets previously lived on a process-global singleton with only a
binary on/off toggle. Two bugs followed:

1. Multiplayer illegal-move errors — the client-side toggle didn`t
   reach the server, so optimistic moves legal under client rules got
   rejected by the server`s unmodified ChessEngine.
2. No way to apply a rule to just white or just black, or to time-box
   it for N turns.

Replaces the shared `PRESET_REGISTRY.active: Set<string>` with
instance-owned `ChessEngine.activePresets: ActivePresetSet`. Each
activation carries:

  - scope: `both` | `white` | `black`
  - turnsRemaining: positive int or null (permanent)

Engine reads `getForColor(color)` per piece, so scope=white never
contributes moves during black`s turn. `applyMove` calls
`tickAfterMove(moverColor)` which implements player-local counting:
white-only durations tick only when white moves.

Compatibility is the LOOSE rule — `incompatibleWith` blocks only when
the two activations have overlapping scopes. `scope=white` + `scope=black`
pair of otherwise-incompatible presets is allowed because the engine
never evaluates both for the same side.

Server changes: GameSession owns an ActivePresetSet. New protocol
messages:

  - client → server: `room.setPresets` with full activation list
  - server → client: `game.presets` broadcast on every set change
    (post-setPresets + post-move-with-expiry)

`game.state` snapshots now include `activations` so reconnects pick
up the current rule set without extra round-trips.

Client changes: PredictionManager applies `game.presets` to the base
engine`s ActivePresetSet and re-renders via onStateChange; cloneEngine
carries activations onto the predicted clone. New hook surface:

  - activations: readonly PresetActivation[]
  - setPresets(next): replace the active set

useMultiplayerGame dispatches setPresets through the socket
(server-authoritative); useChessEngine mutates in-place (local mode).

UI: RulesDrawer + RulesView render scope radios (Both/White/Black)
and a duration input per active preset. Empty duration means
permanent, positive integers last N player-local turns.

Tests:

  - 15 new ActivePresetSet unit tests (scope, tick, loose compat,
    atomicity, clone)
  - 4 new engine-presets integration tests (per-color, duration,
    white-only vs black-only)
  - Migrated older preset tests from `PRESET_REGISTRY.activate` to
    the instance API
  - New E2E regression test: enable knights-leap-twice scope=white in
    multiplayer; verify the double-leap is accepted by the server,
    verify black`s knight cannot use it
2026-04-17 14:23:37 -06:00
5fb96647eb
fix(chess): wire multiplayer live sync via GameClient + PredictionManager
Previously, GameView used a local ChessEngine regardless of whether the
user was in a multiplayer room. Moves were never sent to the server and
the opponent only saw updates on full page reload.

Introduce useMultiplayerGame, a React hook that wraps GameClient and
PredictionManager and exposes the same shape as useChessEngine. App
reads sessionStorage once at mount of /game and dispatches to either
MultiplayerGameView (server-backed) or GameView (local) accordingly.

Board now accepts myColor to gate drag by piece ownership in addition
to turn, so black pieces never become draggable on white`s board and
vice versa.

Rewrites the multiplayer E2E to actually validate live sync: each drag
on one page is asserted to appear on the opposite page before the next
move. The previous test drove both colors from one page because the
GameClient wiring was missing; that workaround is no longer needed.
2026-04-17 13:54:06 -06:00
8b8a5179ec
fix(chess): lift dragged piece above all other pieces
The piece`s own z-index could only stack within its grid cell`s context,
so when translated over neighbouring cells it rendered underneath their
pieces. Promote the hosting cell to z-50 while it holds the dragged
piece so the whole cell (and piece inside) float above the board.
2026-04-17 13:31:44 -06:00
33 changed files with 2920 additions and 619 deletions

View file

@ -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"

View 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`

View file

@ -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`

View file

@ -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();
});

View file

@ -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>

View file

@ -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

View file

@ -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);
}
}
}

View file

@ -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);
});
});
});

View file

@ -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();
}

View file

@ -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,
};
}

View 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,
};
}

View file

@ -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";

View file

@ -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;

View file

@ -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;
}

View file

@ -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>;

View 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);
});
});
});

View 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;
}
}

View file

@ -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,
});

View file

@ -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([]);
});

View file

@ -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,
});

View file

@ -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);
});
});

View file

@ -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);
}
}

View 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);
});
});

View file

@ -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);
},
});

View file

@ -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}
/>

View file

@ -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>
);
}

View file

@ -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}

View file

@ -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}
/>

View file

@ -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>
</>

View file

@ -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

View file

@ -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

View file

@ -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();

View file

@ -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];