From d93bcf6c812c6650fc1f8d1c9ec0c5fecd1a8504 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 19:44:01 -0600 Subject: [PATCH] feat(chess): starting-layout foundation (Phase A) Introduces a pluggable StartingLayout abstraction so the engine can open from positions other than FIDE without per-caller special casing. - layouts/{types,registry,index}.ts: StartingLayout + LAYOUT_REGISTRY, mirroring the PRESET_REGISTRY / PIECE_TYPE_REGISTRY pattern. - starting-position.ts: CLASSIC_LAYOUT + applyLayout(session, layout) as the parametrized spawn path. generateStartingPosition stays as a thin back-compat wrapper so no existing call site changes. - layouts/{classic,empty}.ts: first two premades registered via side-effect imports from the barrel. - engine.ts: ChessEngine constructor now accepts either legacy (activePresets) positional or new options-bag form ({ activePresets?, layout? }). Detection uses a method-shape probe rather than instanceof so both overload forms compose cleanly under strict TS. - Tests: 11 new tests in starting-position.test.ts + engine-presets cover applyLayout ordering, hasMoved pre-revocation, empty layout, classic equivalence, options-bag equivalence with legacy. Also lands the full execution plans for starting-layouts and rule-variants under .sisyphus/plans/ (both Momus-reviewed OKAY). 941 tests passing; bun run check clean. --- .sisyphus/boulder.json | 5 +- .sisyphus/plans/rule-variants.md | 389 +++++++++++++++++++ .sisyphus/plans/starting-layouts.md | 370 ++++++++++++++++++ packages/chess/src/engine-presets.test.ts | 57 +++ packages/chess/src/engine.ts | 66 +++- packages/chess/src/layouts/classic.ts | 16 + packages/chess/src/layouts/empty.ts | 27 ++ packages/chess/src/layouts/index.ts | 28 ++ packages/chess/src/layouts/registry.ts | 65 ++++ packages/chess/src/layouts/types.ts | 84 ++++ packages/chess/src/starting-position.test.ts | 115 +++++- packages/chess/src/starting-position.ts | 123 +++++- 12 files changed, 1319 insertions(+), 26 deletions(-) create mode 100644 .sisyphus/plans/rule-variants.md create mode 100644 .sisyphus/plans/starting-layouts.md create mode 100644 packages/chess/src/layouts/classic.ts create mode 100644 packages/chess/src/layouts/empty.ts create mode 100644 packages/chess/src/layouts/index.ts create mode 100644 packages/chess/src/layouts/registry.ts create mode 100644 packages/chess/src/layouts/types.ts diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json index 232b8e5..f132d2c 100644 --- a/.sisyphus/boulder.json +++ b/.sisyphus/boulder.json @@ -35,7 +35,10 @@ "ses_262ddfb93ffeMGkZK2rspryFfX", "ses_262998e6fffeRY6BJVTKB7Jtwb", "ses_26299a749ffe3jnwQzrfJ9g1Wc", - "ses_261e4ca30ffexBShmvjEhVSHRy" + "ses_261e4ca30ffexBShmvjEhVSHRy", + "ses_25d474e64ffeuPnRfi4X9al43a", + "ses_25d39bf44ffeY9WOIQgNCijBt0", + "ses_25d319ba5ffe37R9eKUEq95w7o" ], "plan_name": "rete-rules-engine", "agent": "atlas" diff --git a/.sisyphus/plans/rule-variants.md b/.sisyphus/plans/rule-variants.md new file mode 100644 index 0000000..5b28921 --- /dev/null +++ b/.sisyphus/plans/rule-variants.md @@ -0,0 +1,389 @@ +# Rule Variants — Greenchess cat=4 Preset Suite + +## TL;DR + +Ship the rule-variant presets from [greenchess.net cat=4](https://greenchess.net/variants.php?cat=4). Adds **four new preset-API hooks** (royal piece override, global legal-move filter, turn-advancement override, per-piece-move override), then **12 new presets** built on them. + +**Deliverable**: users toggle any of 12 new rule-variant presets in the lobby, which compose cleanly with existing presets (HP, queen-splits, etc.) and the layouts system from the prior plan. Existing 930+ tests stay green; each new preset ships with 8-15 behavioral tests. + +**Tiered** so the user can stop mid-execution. Tier 1 (T1) must ship to close the deferred rules from the layouts plan; Tier 2 (T2) rounds out greenchess cat=4 coverage; Tier 3 (T3) is explicitly deferred (Royalty Transfer needs mid-game state we don't want to build yet). + +**Phases**: 6 phases, 31 tasks, hard-gated by `bun run check` between each. + +--- + +## Context + +### What Shipped Last + +- Preset-flexibility architecture (commit `cc30545`): hook context objects, `PIECE_TYPE_REGISTRY`, damage pipeline, preset-state storage, visual-effect bus. +- Starting Layouts plan (`.sisyphus/plans/starting-layouts.md`, queued): introduces Dunsany, Monster, Pawns-Only, Horde, Knightmate, Chess960 as layout-only presets — layout descriptions explicitly flag that the *rules* for Monster/Knightmate/Pawns-Only ship here. +- Existing relevant presets: `wrap-board` (cylinder), `pawns-move-backward`, `double-pawn-sprint`, `knights-leap-twice`, `knight-immunity`, `bishops-ignore-color`, `rook-warp`, `explosive-rook`, `piece-hp`, `king-heals`, `poisoned-squares`, `queen-splits`, `capture-to-win`, `last-piece-standing`, `pawn-diagonal-no-capture`. + +### What the Current Preset API Can't Express + +Audited every greenchess cat=4 variant against the current `PresetDef` surface. Gaps found: + +1. **Royal piece override** — `isInCheck`, `isCheckmate`, `filterSelfCheckMoves`, `isStalemate` all hardcode `"king"` as the royal piece type. Knightmate (knight is royal), Coregal (king AND queen royal), Dual (two kings, either mates) can't be expressed without a hook. + - File anchors: `rules/check.ts:83-97`, `rules/checkmate.ts`, `rules/stalemate.ts`, `rules/check.ts:filterSelfCheckMoves`. +2. **Global legal-move filter** — current `filterMoves(moves, engine, pieceId)` is per-piece. Suicide Chess's "compulsory capture" rule needs "if ANY move on the board is a capture, remove every non-capture". Requires post-aggregation filter. + - File anchor: `engine.ts:getAllLegalMoves` — aggregation happens but no per-color post-filter hook exists. +3. **Turn-advancement override** — `applyMove` unconditionally flips Turn. Double-move and Monster Chess need "don't flip until N half-moves this turn". + - File anchor: `engine.ts:applyMove` turn flip near line ~900. +4. **Piece-move replacement** — `getExtraMoves` adds to a piece's move list; `filterMoves` subtracts. Neither cleanly REPLACES. Berolina's pawns move diagonally and capture orthogonally — you don't want to intersect with FIDE pawn rules, you want to swap them wholesale. + - Today possible via `filterMoves` returning `[]` + `getExtraMoves` returning the new set, but that's a foot-gun (two hooks must stay in sync; other presets may still contribute extras). + +### What DOES Work Already + +- Bouncing bishops/queens: `getExtraMoves` on bishop/queen PieceType that computes reflected rays. Adds, doesn't replace — great. +- Extinction Chess (capture-all-of-a-type-wins): `onCheckGameResult` + `presetState` for tracking. Pure existing surface. +- Capture-all (no check/checkmate): `shouldFilterSelfCheck → false` + `onCheckGameResult` that reads "any opponent pieces left?". +- Pawns-only first-promotion-wins: `describeMoveEffect` detects promotion + `onCheckGameResult` reads promotion count. +- Knightmate / Coregal / Dual ROYAL-PIECE handling needs the new hook; the actual "checkmate the royal" flow is then automatic via existing machinery. + +### Out of Scope (Deferred Hard) + +- **Royalty Transfer Chess** (two kings but only one is active at a time). Needs mid-game "which king is active" state flip triggered by a non-move action. This UX doesn't fit current "one move at a time" plumbing. Deferred to a post-v1 plan. +- **Any 4-player / multi-side variants**. Engine assumes exactly two sides; changing that is not in cat=4 anyway. +- **Non-8×8 board variants from other greenchess cats** (Capablanca, Janus, etc.). Needs board-dimensions refactor — see the layouts plan's deferrals section. +- **Castling interactions with Knightmate**. Knightmate says "regular pieces capture normally; knight is royal." Castling rights conceptually apply to the royal piece today (king); under Knightmate we treat castling as FIDE-normal (involves the non-royal king on e1/e8 — unchanged). Document this as a design call, not a feature gap. +- **PGN notation updates**. Algebraic notation with non-king royalty (`N+`, `N#`?) is a rabbit hole. The move-log will display effects via `describeMoveEffect`, not via FIDE notation. Defer. + +--- + +## Work Objectives + +### Core Objective + +Extend the preset API with four additive hooks that unblock 12 new variants, then ship the variants themselves, each fully tested in isolation and in composition with at least one compatible existing preset. + +### Primary Deliverables + +1. **Hook: `getRoyalPieces(ctx): EntityId[] | undefined`** — overrides "which pieces of this color count as royal" for check/checkmate/self-check-filter detection. Default (undefined from every preset) → engine uses `PieceType === "king"`. +2. **Hook: `filterLegalMoves(ctx): LegalMove[]`** — post-aggregation filter on the full per-color legal-move list. Engine applies these in preset-registration order. +3. **Hook: `shouldAdvanceTurn(ctx): boolean | undefined`** — preset intervenes in the turn-flip decision. First `false` wins; default (all undefined) → turn advances normally. +4. **Hook: `overridePieceMoves(engine, pieceId): LegalMove[] | undefined`** — return non-undefined to REPLACE the default generator's output for that piece. First match wins. +5. **Tier 1 presets** (close the layouts-plan deferrals): + - `knightmate-rules` — knight is royal piece. + - `double-move` — both players move twice per turn (last move of turn flips sides). + - `monster-rules` — white moves twice; black moves once (asymmetric scope). + - `first-promotion-wins` — first player to promote a pawn wins. +6. **Tier 2 presets** (cat=4 completeness): + - `coregal` — king AND queen are royal. + - `dual-king` — two kings per side; mating either wins (strong). + - `weak-dual-king` — two kings per side; must mate both (weak). + - `suicide-chess` — captures are compulsory; lose-all-pieces wins; no check/checkmate. + - `capture-all` — no check/checkmate; capture every opposing piece to win. + - `extinction-chess` — capture every opposing piece of a chosen type to win. + - `berolina-pawns` — pawns push diagonally, capture orthogonally forward. + - `berolina-pawns-2` — like berolina plus sideways capture. + - `bouncing-pieces` — bishop/queen bounce off left/right edges. + - `bouncing-pieces-2` — bounce off all four edges. +7. **Test utilities updates**: `test-utils.ts` helpers extended for multi-royal assertions. + +### Secondary Deliverables + +- Lobby UI: the RulesDrawer's dependency-chip UI already handles `requires` / `incompatibleWith`. Each new preset declares these correctly — no UI change needed. +- Auto-suggest in layouts picker: when a user picks "Monster" layout, show a dimmed chip "Suggested: monster-rules" with a one-click enable. (Implemented in layouts plan; this plan just populates the `suggestedPresets` field for each layout.) +- E2E smoke: create room with Dunsany layout + suicide-chess preset; verify compulsory capture enforces server-side. + +### Non-Goals (clarifying) + +- No Chess960 rule changes (Chess960 is a layout, not a rule, per the layouts plan). +- No UI mini-animations for royal-piece-in-check unique to each variant — reuse the existing check banner. +- No ranking / ELO / SPRT tuning. These presets are opt-in novelty. + +--- + +## Tier Ordering + +Each variant is labeled T1 (must-ship) / T2 (should-ship) / T3 (deferred). + +| Variant | Tier | Rationale | +|---|---|---| +| knightmate-rules | T1 | Closes layouts-plan deferral for Knightmate. | +| double-move | T1 | Closes layouts-plan deferral for Monster / Double-move. | +| monster-rules | T1 | Asymmetric double-move + Monster layout. | +| first-promotion-wins | T1 | Closes layouts-plan deferral for Pawns-Only. | +| coregal | T2 | Cheap once royal hook exists. | +| dual-king | T2 | Same. | +| weak-dual-king | T2 | Same. | +| suicide-chess | T2 | Popular variant; exercises filterLegalMoves. | +| capture-all | T2 | Pure existing-API composition. | +| extinction-chess | T2 | Pure existing-API. | +| berolina-pawns | T2 | Exercises overridePieceMoves. | +| berolina-pawns-2 | T2 | Trivial once berolina exists. | +| bouncing-pieces | T2 | Uses existing getExtraMoves. | +| bouncing-pieces-2 | T2 | Extension of bouncing. | +| royalty-transfer | T3 | Mid-game state flip — deferred. | + +--- + +## Architecture Sketch + +### Hook additions to `PresetDef` + +```ts +// rules/check.ts and callers +interface RoyalContext extends HookContext { + readonly color: "white" | "black"; +} + +// Returned array = the royal pieces for this color. Engine uses the +// UNION of all active presets' returns (for Coregal: king + queens). +// Returning undefined = "I don't override, use default (king)." +readonly getRoyalPieces?: (ctx: RoyalContext) => readonly EntityId[] | undefined; + +// Returns the filtered legal-move list. Engine runs each hook in +// preset-registration order, piping the result into the next. +interface FilterLegalMovesContext extends HookContext { + readonly moves: readonly LegalMove[]; + readonly color: "white" | "black"; +} +readonly filterLegalMoves?: (ctx: FilterLegalMovesContext) => LegalMove[]; + +// Returns false to KEEP the current turn (same side to move). +// Engine iterates in preset-registration order; first false wins. +interface TurnAdvanceContext extends HookContext { + readonly mover: "white" | "black"; + readonly halfMovesThisTurn: number; // tracked by engine +} +readonly shouldAdvanceTurn?: (ctx: TurnAdvanceContext) => boolean | undefined; + +// Return non-undefined to REPLACE default moves for `pieceId`. +// First preset to return wins. +readonly overridePieceMoves?: ( + engine: ChessEngine, + pieceId: EntityId, +) => LegalMove[] | undefined; +``` + +### Engine integration + +```ts +// rules/check.ts — isInCheck becomes: +export function isInCheck(session: Session, color: PieceColor, royalTypes?: Set): boolean +// With royalTypes undefined: uses {"king"} (current behavior). +// ChessEngine wraps this: computes royalTypes from preset returns, passes in. + +// Engine state: tracks halfMovesThisTurn for shouldAdvanceTurn. +// Resets to 0 on every turn flip. Increments on each applyMove. + +// Piece move generation dispatch order: +// 1) overridePieceMoves (first match wins) +// 2) PIECE_TYPE_REGISTRY default +// 3) getExtraMoves (each preset contributes) +// 4) filterMoves (each preset filters) +// 5) aggregate all pieces' moves +// 6) self-check filter (suppressible via shouldFilterSelfCheck) +// 7) filterLegalMoves (each preset runs in order) +``` + +### New session fact: `HalfMovesThisTurn` on GAME_ENTITY + +Used by `shouldAdvanceTurn` hook. Serialized naturally with other game-entity facts. Reset to 0 on turn flip. + +--- + +## Phase A — Hook API Extensions (architecture) + +### A.1 — `getRoyalPieces` hook + +- `[packages/chess/src/presets/registry.ts]` Add: `RoyalContext` type, `getRoyalPieces` hook signature on `PresetDef` — expect: compiles; existing presets continue to declare no royal override (all undefined). +- `[packages/chess/src/rules/check.ts]` Refactor: Add optional `royalTypes?: Set` parameter to `isInCheck`; when present, find royal pieces by those types, else default `{"king"}`. Same for `isSquareAttacked` callers that need it — expect: tests still green without passing the new param (default maintained). +- `[packages/chess/src/rules/checkmate.ts]` Refactor: Accept royal-pieces set; treat loss as "no legal move AND at least one royal in check". Document behavior under multi-royal: with Coregal, game ends if ANY royal is checkmated (OR), not all. +- `[packages/chess/src/rules/stalemate.ts]` Refactor: Accept royal-pieces set; stalemate = "no legal move AND no royal in check". +- `[packages/chess/src/engine.ts]` Refactor: Add private `getActiveRoyalTypes(color): Set` that unions returns from every active preset's `getRoyalPieces` hook, maps EntityId → PieceType via session facts. When union is empty → default `{"king"}`. Use this in all check/mate/stalemate calls — expect: no active royal-override preset = engine behavior unchanged; 930+ tests green. +- `[packages/chess/src/presets/royal-pieces.test.ts]` Create: Prototype "double-king" preset that returns all kings (should typically be one) for each side; assert `isInCheck` still works — expect: green. + +### A.2 — `filterLegalMoves` hook + +- `[packages/chess/src/presets/registry.ts]` Add: `FilterLegalMovesContext`, `filterLegalMoves` hook — expect: compiles. +- `[packages/chess/src/engine.ts]` Refactor: After existing self-check filter, iterate active presets and pipe each's `filterLegalMoves` through the list. Document: "order matters; preset authors should commute where possible or declare `incompatibleWith`" — expect: no-op when no preset implements. +- `[packages/chess/src/presets/filter-legal-moves.test.ts]` Create: Prototype "no-a-file moves" preset filtering out all moves from/to a-file; assert engine respects it across multiple pieces — expect: green. + +### A.3 — `shouldAdvanceTurn` hook + `HalfMovesThisTurn` tracking + +- `[packages/chess/src/schema.ts]` Add: `HalfMovesThisTurn` to `ChessAttrMap` with value type `number` — expect: compiles. +- `[packages/chess/src/starting-position.ts]` Update: Seed `HalfMovesThisTurn = 0` on GAME_ENTITY alongside Turn — expect: every new engine/layout has the fact. +- `[packages/chess/src/layouts/classic.ts]` and all layouts in the layouts plan: no change needed because layouts call the shared starting-position helper for game facts. +- `[packages/chess/src/presets/registry.ts]` Add: `TurnAdvanceContext`, `shouldAdvanceTurn` hook — expect: compiles. +- `[packages/chess/src/engine.ts]` Refactor: In `applyMove`, increment `HalfMovesThisTurn` before the turn-flip decision. Iterate active presets' `shouldAdvanceTurn`; if any returns `false`, skip the turn flip. When flipping, reset `HalfMovesThisTurn = 0` — expect: no active turn-override = behavior unchanged. +- `[packages/chess/src/presets/turn-advance.test.ts]` Create: Prototype "never-flip" preset; assert white keeps moving indefinitely — expect: green. + +### A.4 — `overridePieceMoves` hook + +- `[packages/chess/src/presets/registry.ts]` Add: `overridePieceMoves` hook — expect: compiles. +- `[packages/chess/src/engine.ts]` Refactor: In `getAllLegalMoves`, before calling `lookupMoveGenerator`, iterate active presets' `overridePieceMoves(engine, pieceId)`. First non-undefined wins; cache result and skip default generator. Log collision warnings (two presets override same piece) in dev — expect: no preset overrides = default generator used. +- `[packages/chess/src/presets/override-piece-moves.test.ts]` Create: Prototype "lame-knight" preset that overrides knight moves to single-square orthogonal; assert knight behaves differently only with preset active — expect: green. + +### A.5 — Phase A verification gate + +- `[repo root]` Verify: `bun run check` — expect: 930+ tests green; no behavioral change from any new hook being undefined. +- `[packages/chess/docs/PRESET-API.md]` Update: Document the four new hooks with worked examples. Add a "Hook ordering" diagram covering all 10 preset hooks. + +--- + +## Phase B — Tier 1 Presets (layouts deferrals) + +### B.1 — `knightmate-rules` + +- `[packages/chess/src/presets/knightmate-rules.ts]` Create: Implements `getRoyalPieces` returning all knight entities of `color`. Since Knightmate's layout has kings replacing knights (non-royal), this preset treats knights as the royal type. Declares `requires: []` (works with any layout that has knights). Comment: "works best with the Knightmate layout but not strictly dependent — with FIDE layout, kings become non-royal and knights become royal, which is playable but weird" — expect: registers, typechecks. +- `[packages/chess/src/presets/knightmate-rules.test.ts]` Create: 10+ tests. Load Knightmate layout; assert (a) moving a non-royal king into attack is legal, (b) a knight in check restricts moves to resolving the check, (c) checkmating the last knight ends the game, (d) capture-all-knights is NOT game-over if any knight survives, (e) composition with piece-hp — knight loses HP on capture attempts rather than dying — expect: green. +- `[packages/chess/src/layouts/knightmate.ts]` (from layouts plan) Update: `suggestedPresets: ["knightmate-rules"]` now points at a real preset — expect: layout picker shows enabled chip. + +### B.2 — `double-move` + +- `[packages/chess/src/presets/double-move.ts]` Create: Implements `shouldAdvanceTurn` returning `false` when `halfMovesThisTurn < 2`. Also implements `getRoyalPieces`? No — use default king logic. Notes: "cannot be in check mid-turn" is the classic Double-move rule; we enforce it via `shouldFilterSelfCheck` — second half-move still considers king safety. Declares `incompatibleWith: ["monster-rules"]` (both override turn-advance for white). Scope defaults to `"both"` — expect: registers, typechecks. +- `[packages/chess/src/presets/double-move.test.ts]` Create: 12+ tests. (a) white plays two moves before black, (b) halfMovesThisTurn resets on flip, (c) checking opponent on first move ends opponent's turn immediately (edge case: some Double-move rulesets forbid check on move 1 — we go with the common "check allowed, opponent must respond on both of their moves" interpretation and document), (d) composition with piece-hp, (e) composition with king-heals (heal fires on second-move turn-flip only) — expect: green. + +### B.3 — `monster-rules` + +- `[packages/chess/src/presets/monster-rules.ts]` Create: Scope-aware. For scope=white, `shouldAdvanceTurn` returns false when `mover === "white"` and `halfMovesThisTurn < 2`. For scope=black, black behaves normally. Declares `incompatibleWith: ["double-move", "suicide-chess"]` — expect: registers. +- `[packages/chess/src/presets/monster-rules.test.ts]` Create: 10+ tests. White moves twice, black moves once. Combined with Monster layout → white plays king + 4 pawns twice each turn — expect: green. +- `[packages/chess/src/layouts/monster.ts]` (from layouts plan) Update: `suggestedPresets: ["monster-rules"]`. + +### B.4 — `first-promotion-wins` + +- `[packages/chess/src/presets/first-promotion-wins.ts]` Create: `onAfterMove` inspects the just-applied move via `engine.moveLog[last]`; if `promotion !== null`, records winner via `presetState`. `onCheckGameResult` returns the winner if set, else undefined. Also implements `shouldFilterSelfCheck → false` and overrides checkmate logic so the game doesn't end prematurely on check. Declares `incompatibleWith: ["capture-to-win", "last-piece-standing", "extinction-chess", "suicide-chess", "capture-all"]` — expect: registers. +- `[packages/chess/src/presets/first-promotion-wins.test.ts]` Create: 8+ tests. (a) first pawn promotion wins, (b) works with Pawns-Only layout, (c) composition with double-move (second move can also trigger win), (d) composition with piece-hp — expect: green. +- `[packages/chess/src/layouts/pawns-only.ts]` (from layouts plan) Update: `suggestedPresets: ["first-promotion-wins"]`. + +### B.5 — Phase B verification gate + +- `[repo root]` Verify: `bun run check` — expect: green. Manual smoke: open lobby, pick Knightmate layout + knightmate-rules, play a short game via Playwright script. + +--- + +## Phase C — Tier 2 Royal-Variant Presets + +### C.1 — `coregal` + +- `[packages/chess/src/presets/coregal.ts]` Create: `getRoyalPieces` returns union of kings + queens for `color`. `incompatibleWith: ["knightmate-rules", "dual-king", "weak-dual-king"]` — expect: registers. +- `[packages/chess/src/presets/coregal.test.ts]` Create: 10+ tests. (a) mating only the king ends the game (queen can't save), (b) mating only the queen ends the game, (c) with no queen on board, behaves as FIDE, (d) pins now affect the queen too (moving a pinned queen into attack leaves another royal in check → illegal) — expect: green. + +### C.2 — `dual-king` + +- `[packages/chess/src/presets/dual-king.ts]` Create: `getRoyalPieces` returns ALL king entities for `color` (any king in check that can't escape = checkmate). Works best with a 2-king layout, but also makes pawn-underpromotion-to-king meaningful. `incompatibleWith: ["weak-dual-king", "coregal", "knightmate-rules"]` — expect: registers. +- `[packages/chess/src/presets/dual-king.test.ts]` Create: 8+ tests. Use a custom test layout with 2 kings per side. Assert mating either king ends the game — expect: green. +- **Note**: Premade "Dual Chess" layout not shipped in layouts plan. For this preset, ship a `dual-classic` layout (2 kings each, otherwise FIDE) in the layouts plan OR recommend users construct one via the custom editor. Add a new layout file `packages/chess/src/layouts/dual-classic.ts` here. + +### C.3 — `weak-dual-king` + +- `[packages/chess/src/presets/weak-dual-king.ts]` Create: Implements both `getRoyalPieces` (returns all kings) AND an override to `onCheckGameResult`. Game-over only when ALL kings of one side are captured/mated. First mate of one king does NOT end the game. `incompatibleWith: ["dual-king", "coregal", "knightmate-rules"]` — expect: registers. +- `[packages/chess/src/presets/weak-dual-king.test.ts]` Create: 8+ tests. (a) one king lost → game continues, (b) both kings lost → game ends, (c) last king in check with mate → game ends — expect: green. + +### C.4 — Phase C verification gate + +- `[repo root]` Verify: `bun run check` — expect: green. Add an integration test: player runs coregal + piece-hp; asserts queen loses HP but king is still the primary royal. + +--- + +## Phase D — Tier 2 Objective-Variant Presets + +### D.1 — `suicide-chess` + +- `[packages/chess/src/presets/suicide-chess.ts]` Create: + - `filterLegalMoves`: if any move in the list has `isCapture === true`, return only captures. + - `shouldFilterSelfCheck`: returns false (king is a regular piece in Suicide). + - `getRoyalPieces`: returns `[]` empty array (no royalty — interpret: "no pieces are royal, check detection is a no-op"). This requires the engine's isInCheck to handle "empty royal set" → returns false always. Add this edge-case handling in the Phase A refactor if not already. + - `onCheckGameResult`: if one side has 0 pieces, declares that side the WINNER (Suicide inverts). + - `incompatibleWith: ["capture-to-win", "last-piece-standing", "monster-rules", "knightmate-rules", "coregal", "dual-king", "weak-dual-king", "capture-all", "extinction-chess", "first-promotion-wins"]` — expect: registers. +- `[packages/chess/src/presets/suicide-chess.test.ts]` Create: 14+ tests. (a) captures compulsory when available, (b) non-captures legal otherwise, (c) losing all pieces wins, (d) pawn promotion still legal (promotion to queen is fine), (e) composition with piece-hp — captures only count when piece actually dies (capture with HP remaining doesn't satisfy compulsion? — design call: YES it does, because the attempt IS a capture move even if the target survives. Document) — expect: green. + +### D.2 — `capture-all` + +- `[packages/chess/src/presets/capture-all.ts]` Create: + - `shouldFilterSelfCheck → false` + - `getRoyalPieces → []` + - `onCheckGameResult`: winner = side whose opponent has 0 pieces. + - `incompatibleWith: ["suicide-chess", "capture-to-win", "last-piece-standing", "extinction-chess", "first-promotion-wins"]` — expect: registers. +- `[packages/chess/src/presets/capture-all.test.ts]` Create: 8+ tests — expect: green. + +### D.3 — `extinction-chess` + +- `[packages/chess/src/presets/extinction-chess.ts]` Create: Configurable target type via `presetState({targetType: PieceType})`. Default: `"pawn"` (common extinction target — game about pawn attrition). UI chip shows "Extinction: pawns". `onCheckGameResult` returns winner when opposite side has 0 pieces of target type — expect: registers. +- `[packages/chess/src/presets/extinction-chess.test.ts]` Create: 10+ tests covering each target type — expect: green. +- **UI stretch**: Cycle target type via chip click in RulesDrawer. Out-of-scope for this phase; add as a follow-up UI task after the plan lands. + +### D.4 — Phase D verification gate + +- `[repo root]` Verify: `bun run check` — expect: green. + +--- + +## Phase E — Tier 2 Movement-Variant Presets + +### E.1 — `berolina-pawns` + +- `[packages/chess/src/presets/berolina-pawns.ts]` Create: `overridePieceMoves` for pawn pieces only. Generates: forward diagonals (non-capture push), forward orthogonal (capture only). Double-move from home rank = two diagonal pushes. En passant reinterpreted: if the opponent's pawn just made a double diagonal push landing on a square adjacent to yours, you can capture it orthogonally to the skipped square. Declares `incompatibleWith: ["pawn-diagonal-no-capture", "berolina-pawns-2", "pawns-move-backward"]` — expect: registers. +- `[packages/chess/src/presets/berolina-pawns.test.ts]` Create: 15+ tests. Double-push, capture, en passant, promotion (yes — diagonal push to promotion rank still promotes), block detection — expect: green. + +### E.2 — `berolina-pawns-2` + +- `[packages/chess/src/presets/berolina-pawns-2.ts]` Create: Identical to berolina-pawns but also allows sideways captures (same-rank, adjacent file). `incompatibleWith: ["berolina-pawns", ...same list]` — expect: registers. +- `[packages/chess/src/presets/berolina-pawns-2.test.ts]` Create: 6+ tests (diff from berolina-pawns) — expect: green. + +### E.3 — `bouncing-pieces` + +- `[packages/chess/src/presets/bouncing-pieces.ts]` Create: `getExtraMoves` for bishop + queen. Computes diagonal rays that reflect off the left/right file edges (file wraps to the opposite side with reflected direction). Stops on capture or friendly block. Respects the existing move-generator blocking logic. Stays within rank bounds. `incompatibleWith: ["bouncing-pieces-2", "wrap-board"]` (cylinder already wraps, so bouncing is redundant) — expect: registers. +- `[packages/chess/src/presets/bouncing-pieces.test.ts]` Create: 10+ tests. Bishop on e4 can reach squares via right-edge bounce; queen rays reflect and stop on block; capture on reflected ray works — expect: green. + +### E.4 — `bouncing-pieces-2` + +- `[packages/chess/src/presets/bouncing-pieces-2.ts]` Create: Like bouncing-pieces but reflects off all four edges. Max reflection count capped at 2 (prevents infinite-loop ray computation on empty board). Document the cap — expect: registers. +- `[packages/chess/src/presets/bouncing-pieces-2.test.ts]` Create: 8+ tests — expect: green. + +### E.5 — Phase E verification gate + +- `[repo root]` Verify: `bun run check` — expect: green. + +--- + +## Phase F — Lobby Integration + E2E + +### F.1 — Audit `incompatibleWith` graph + +- `[packages/chess/src/presets/presets.test.ts]` Add: Reflexive + symmetric check — if A declares B incompatible, assert B declares A incompatible (or explicitly opt out with a comment). Automate across all presets — expect: all 12 new presets pair correctly. + +### F.2 — `suggestedPresets` wiring in layouts + +- `[packages/chess/src/layouts/*.ts]` (each premade) Update: Populate `suggestedPresets` for Knightmate → knightmate-rules, Monster → monster-rules, Pawns-Only → first-promotion-wins, Dunsany → [] (no canonical rules), Horde → [] — expect: lobby shows suggestion chips. +- `[packages/chess/src/ui/LayoutPicker.tsx]` (from layouts plan) Extend: Render a small "Suggested rules" badge group next to each layout when `suggestedPresets.length > 0`. Tapping a badge enables that preset — expect: click-to-enable works. + +### F.3 — Lobby rule filter + +- `[packages/chess/src/ui/RulesDrawer.tsx]` Extend: Group the 12+existing presets into sections (King Variants, Objectives, Movement, Multimove, Pieces, …). Use a visual separator. Existing filtering/search still works — expect: drawer is navigable with 20+ presets. + +### F.4 — E2E: rule-variant smoke + +- `[packages/chess/e2e/rule-variants.spec.ts]` Create: Playwright test covering 3 representative variants end-to-end. (a) Knightmate: attack king, make a non-king move, attack knight → receive check banner; (b) Double-move: white plays two moves, black plays one, repeat; (c) Suicide-chess: capture becomes compulsory when available — expect: green. + +### F.5 — Docs + final check + +- `[packages/chess/docs/PRESET-API.md]` Update: New hooks section with worked example for each. Add a "Rule Variants Gallery" with one-paragraph descriptions of all 12. +- `[packages/chess/RULES.md]` Update: Cross-reference each new preset with greenchess's description. Link back to greenchess.net/variants.php?cat=4 for authority. +- `[repo root]` Verify: `bun run check` + Playwright — expect: full green. + +--- + +## Risks + +1. **Hook ordering matters more than before.** `filterLegalMoves` is pipeline-composable, but `shouldAdvanceTurn` and `overridePieceMoves` use first-match-wins. Mitigation: document ordering in PRESET-API.md; add incompatibleWith between presets that both override the same hook for the same subject (e.g., double-move vs monster-rules). +2. **Suicide + HP edge case.** Capture-is-compulsory + non-lethal-capture (HP): does "any capture available" check count moves that DEAL damage even if target survives? Design call in the plan: YES (the move IS a capture). Documented in D.1 notes. +3. **Multi-royal performance.** Check detection iterates all royal pieces. With Dual-king (2 kings) and Coregal (1 king + N queens), per-move cost grows linearly. Mitigation: cap the royal set effectively at ~5 pieces (realistic max: king + all queens on a mid-game board). Profile only if it shows up on the critical path. +4. **Check banner UX with multi-royal.** Current UI shows "Check!" on the king. With Coregal, which royal is attacked? Mitigation: adjust the banner to say "Check!" with a highlighted piece indicator rendered via the existing check-indicator component; deferred to a small follow-up UI task — not in this plan. +5. **Promotion to king (`dual-king` + underpromotion).** Possible to pile up 3+ kings per side via promotion. Game rules stay sound; just verify `getAllLegalMoves` doesn't explode. Mitigation: integration test with 3 kings per side. +6. **Berolina en passant interpretation.** Several competing interpretations exist; we pick one (reflected en passant to the passed-over square) and document it in the preset's comments. If users disagree, open follow-up issue. +7. **Override-piece-moves collisions.** Two presets overriding pawns: undefined behavior. Mitigation: `incompatibleWith` declarations + dev-mode console warning. + +--- + +## Deferred / Post-Landing + +- **Royalty Transfer Chess** (T3). Needs mid-game "which king is active" toggle — UX outside "one move at a time". +- **PGN notation with non-king royalty**. E.g., `+` on knight-in-check in Knightmate. Rabbit hole. +- **Ranked / ELO tracking per variant**. Out of scope; variants are opt-in novelty. +- **"Fairy chess" pieces** (Amazon, Nightrider, Archbishop). Registered via `PIECE_TYPE_REGISTRY` easily; but these are a separate piece-type plan, not rule variants. +- **Per-variant tutorials / help popovers**. Static text live in RULES.md and RulesDrawer descriptions today; a tutorial overlay is nice-to-have. +- **Allow Extinction Chess target type to be picked via UI chip**. Noted inside D.3; small follow-up UI task. diff --git a/.sisyphus/plans/starting-layouts.md b/.sisyphus/plans/starting-layouts.md new file mode 100644 index 0000000..05a46f3 --- /dev/null +++ b/.sisyphus/plans/starting-layouts.md @@ -0,0 +1,370 @@ +# Starting Layouts — Custom Board Setups Before Game Start + +## TL;DR + +Add a **Starting Layout** concept, orthogonal to rule presets, that controls which pieces start where. Ship 7 premades (Classic, Dunsany, Monster, Pawns-Only, Horde, Knightmate, Chess960) plus an **Empty board** sandbox, a **visual drag-and-drop editor**, **FEN import/export**, a **shareable code**, and a **localStorage library** of user-saved layouts. + +Architecture is layered so a future "board shape/dimensions" extension (non-8×8, removed squares, hex) can land without re-plumbing the wiring installed here. + +**Deliverable**: from the lobby, a user can pick "Dunsany" from a Layout dropdown (or paste a FEN, or draw their own) BEFORE creating a room. The selected layout is server-authoritative, broadcast on `room.create`, persisted through reconnect, and shown to both players in multiplayer. + +**Phases**: 5 phases, 22 tasks, hard-gated by `bun run check` green between each. + +--- + +## Context + +### What Shipped Last + +- Preset-flexibility architecture (commit `cc30545`): decoupled piece-type registry, damage pipeline, preset-state storage, visual-effect bus, hook context objects. +- Custom piece types plug in via `PIECE_TYPE_REGISTRY`; new piece attributes via `PresetDef.pieceAttributes`. +- Engine construction: `new ChessEngine(activePresets?)` — piece placement is **always** FIDE via `generateStartingPosition(session)` called unconditionally in the constructor. + +### Where "8×8" Is Hardcoded (inventory) + +A parallel explore pass found the following coupling. This is a **board-layouts** plan, not a board-dimensions plan — we deliberately scope around these so a future shape plan can ride on top without double-plumbing. + +**Board-shape-tight (NOT touched by this plan):** +- `coord.ts` — `fileOf/rankOf/squareOf` assume `rank*8+file`. +- `rules/castling.ts` — king/rook home squares hardcoded (e1=4, h1=7, a1=0, e8=60, h8=63, a8=56). +- `rules/promotion.ts` — `PROMOTION_RANK = {white:7, black:0}`. +- `rules/primitives.ts` — pawn home ranks (1, 6), double-advance distance (16). +- `rules/enpassant.ts` — ±8 rank offset for captured pawn. +- `ui/Board.tsx` — `grid-cols-8 grid-rows-8` and 0..7 loops. +- `protocol.ts` — algebraic regex `/^[a-h][1-8]$/`. + +**Layout-tight (WILL be touched):** +- `engine.ts:365` — unconditional `generateStartingPosition(this.session)` call in constructor. +- `starting-position.ts` — the function itself; must become configurable. +- `server/rooms.ts` + `PROTOCOL.md` — `room.create` payload gains optional `layoutId` and/or `fen`. +- `ui/Lobby.tsx` — "Create Room" flow currently passes `{}`; must pass layout selection. +- `net/types.ts` + `net/client.ts` — request/response shapes for layout. + +### What Still Decouples Cleanly + +- `engine.spawnPiece(type, color, square, {reason: "initial"})` already exists and fires `onPieceSpawn`. A configurable starting-position generator uses this directly — so `piece-hp` will seed `Hp` on every piece regardless of which layout is chosen, no preset edits needed. +- `PIECE_TYPE_REGISTRY` already dispatches move generation by type string — so if a layout places Nightriders or Amazons at start (via a future piece-types preset), the engine handles them. + +### Out of Scope (Deferred) + +- **Non-8×8 boards, irregular shapes, hex.** Layouts are piece-placement only; the board is always 64 squares of the same topology the engine already understands. A future "Board Shape" plan rides on top and adds a `BoardConfig` beside `StartingLayout`. +- **New rule-variant presets** (Berolina pawns, Bouncing bishop, Coregal, Suicide, Knightmate's king-is-a-knight rule, Double-move, Monster's white-moves-twice). Those are preset work. This plan ONLY adds layouts. **Exception**: Knightmate layout ships without its rule variant; user can enable a hypothetical `knightmate-rules` preset later, or just play it as "two kings, no knights" for now. The layout selector shows a "Suggested rules" hint (read-only) alongside each premade. +- **Mid-game position editing.** Editor is pre-game only. Editing a running game would need a whole analysis-mode workflow. +- **Server protocol v2.** We add OPTIONAL fields to `room.create`; omission = classic layout (backward compatible). +- **Save-file migration for FIDE autosaves.** Autosave is per-layout keyed; old FIDE saves keep working under the "Classic" layout. + +--- + +## Work Objectives + +### Core Objective + +Introduce a `StartingLayout` abstraction the engine consumes on construction, split the existing `generateStartingPosition` into a registry + default, and build the UI + networking that lets users pick, create, save, share, and import layouts. + +### Primary Deliverables + +1. **`StartingLayout` type** — `{ id: string; name: string; description: string; pieces: PiecePlacement[]; suggestedPresets?: string[] }`. +2. **`LAYOUT_REGISTRY`** with the 7 premades (Classic, Dunsany, Monster, Pawns-Only, Horde, Knightmate, Empty) + one pseudo-entry (Chess960) that generates a seeded random layout on each `.build(seed)`. +3. **`engine.ts` accepts `layout` option**; `generateStartingPosition` parametrized. +4. **`ui/LayoutPicker`** — lobby dropdown of premades with preview thumbnails and "Custom…" entry opening the editor. +5. **`ui/LayoutEditor`** — drag-and-drop piece placement, piece palette sidebar, FEN import/export, save to library, share-code generation, minimum-king validation. +6. **`net/protocol`** — `room.create` payload gains optional `layout: { id: string } | { fen: string } | { pieces: PiecePlacement[] }`. Server validates, echoes via `room.created`. +7. **Shareable layout URL** — `?layout=ENCODED_FEN` or `?layoutId=dunsany`; lobby pre-selects if present. +8. **localStorage library** — `houserules:layouts:v1` key, up to 20 recent + starred. +9. **FEN utilities** — `toFen(pieces)`, `fromFen(string)` round-trip with validation. +10. **Autosave isolation** — current autosave key `chess-autosave` becomes `chess-autosave:${layoutId}` so selecting a different layout doesn't resurrect a finished game. + +### Secondary Deliverables + +- Solo mode respects layout too (Play Solo from lobby honors the selector). +- Playwright E2E: create room with Dunsany; both clients render identical position. +- Unit tests for every layout (piece counts, king-per-side, no-pawn-on-promo-rank warnings). + +### Non-Goals (clarifying) + +- Editor operating on 10×10 board. Editor is 8×8 only this iteration. +- Undo/redo in the editor beyond natural drag-drop "pick up, put down". +- Auto-suggest of compatible presets (display only; no auto-enable). + +--- + +## Architecture Sketch + +### New file: `packages/chess/src/layouts/types.ts` +```ts +export interface PiecePlacement { + readonly type: PieceType; + readonly color: PieceColor; + readonly square: Square; + readonly hasMoved?: boolean; // default false +} + +export interface StartingLayout { + readonly id: string; // kebab-case; stable for URLs + readonly name: string; // display label + readonly description: string; + readonly pieces: readonly PiecePlacement[]; + /** Optional: preset IDs suggested alongside this layout. + * UI displays as a hint; user decides whether to enable. */ + readonly suggestedPresets?: readonly string[]; + /** "premade" — shipped in LAYOUT_REGISTRY. + * "custom" — user-authored, not in registry, carried by-value. */ + readonly source: "premade" | "custom"; +} +``` + +### New file: `packages/chess/src/layouts/registry.ts` +```ts +class LayoutRegistry { + register(layout: StartingLayout): void; + get(id: string): StartingLayout | undefined; + list(): readonly StartingLayout[]; +} +export const LAYOUT_REGISTRY = new LayoutRegistry(); +``` +Registry is registry-pattern identical to `PRESET_REGISTRY` and `PIECE_TYPE_REGISTRY`. Side-effect registration from a `layouts/index.ts` barrel. + +### New file: `packages/chess/src/layouts/fen.ts` +Implements standard Forsyth-Edwards piece placement (fields 1-2 of full FEN: `"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w"`). Ignores castling rights / en passant / halfmove for v1 (engine doesn't need them from the layout — they come from gameplay). + +### Refactor: `packages/chess/src/starting-position.ts` +```ts +// NEW signature (old one becomes a thin wrapper over CLASSIC layout) +export function applyLayout(session: Session, layout: StartingLayout): EntityId[]; + +// Back-compat wrapper — existing callers in tests stay working. +export function generateStartingPosition(session: Session): EntityId[] { + return applyLayout(session, CLASSIC_LAYOUT); +} +``` + +### Refactor: `packages/chess/src/engine.ts` +```ts +interface EngineOptions { + readonly activePresets?: ActivePresetSet; + readonly layout?: StartingLayout; // defaults to CLASSIC +} + +constructor(opts: EngineOptions = {}) { + this.session = new Session({ autoFire: false }); + applyLayout(this.session, opts.layout ?? CLASSIC_LAYOUT); + this.activePresets = opts.activePresets ?? new ActivePresetSet(); +} +``` + +**Backward compat**: existing call sites `new ChessEngine()` and `new ChessEngine(presets)` continue to work — we overload the constructor signature. + +### Protocol extension +`room.create` payload gains OPTIONAL `layout` field: +```ts +type RoomCreatePayload = { + rulesetIds?: string[]; + layout?: + | { kind: "premade"; id: string } + | { kind: "custom"; pieces: PiecePlacement[]; name?: string } + | { kind: "fen"; fen: string; name?: string }; +}; +``` +Server resolves to a `StartingLayout` value, validates (king count, square bounds, piece counts), stores in room, passes to `new ChessEngine({ layout })`. Protocol stays at v1. + +`room.created` response and `room.joined` event gain `layout` field (resolved form, always `{kind:"custom"|"premade", name, pieces}`) so late joiners can display the name. + +### UI shape +- **Lobby**: Layout selector above "Create Room" button. Dropdown with premades; last entry "Custom…" opens the editor modal. Selected layout encoded in the Create button's outgoing request. +- **Editor modal**: Full-screen overlay. Left sidebar = piece palette (drag source) + FEN field + "Save to Library" + "Copy Share Link". Center = interactive 8×8 board (drop target, click-to-delete). Right sidebar = validation panel + "Use This Layout" CTA. +- **Library drawer**: Opens from within the editor; lists saved layouts with name, piece count, last-used date, star/unstar, delete, duplicate. + +--- + +## Phase A — Foundations (engine + types) + +### A.1 — Add `StartingLayout` types and `LAYOUT_REGISTRY` + +- `[packages/chess/src/layouts/types.ts]` Create: Define `PiecePlacement`, `StartingLayout`, `LayoutValidationResult` — expect: new exported types compile under strict TS. +- `[packages/chess/src/layouts/registry.ts]` Create: Implement registry class mirroring `PRESET_REGISTRY` shape — expect: `register / get / list` work; duplicate-id registration throws. +- `[packages/chess/src/layouts/index.ts]` Create: Barrel that imports every premade module for side-effect registration — expect: one import line = all layouts registered. + +### A.2 — Parametrize `starting-position.ts` + +- `[packages/chess/src/starting-position.ts]` Refactor: Extract `applyLayout(session, layout): EntityId[]` that inserts piece facts via `spawnPiece`-equivalent (use `session.nextId()` + `insert` sequence matching the current loop). Add `CLASSIC_LAYOUT` const built from existing hardcoded arrays — expect: new `applyLayout` + old `generateStartingPosition` wrapper both return identical EntityId arrays for the default layout. +- `[packages/chess/src/starting-position.test.ts]` Update: Add tests that `applyLayout(session, CLASSIC_LAYOUT)` matches the existing 32-piece expectation — expect: green. + +### A.3 — Register Classic + Empty + +- `[packages/chess/src/layouts/classic.ts]` Create: Re-exports `CLASSIC_LAYOUT` from `starting-position.ts` under `{id: "classic", name: "Classic Chess", ..., source: "premade"}` and registers with `LAYOUT_REGISTRY` — expect: `LAYOUT_REGISTRY.get("classic")` returns the FIDE setup. +- `[packages/chess/src/layouts/empty.ts]` Create: Empty-board layout with `pieces: []` — expect: registered as `id: "empty"`. + +### A.4 — Engine accepts `layout` option + +- `[packages/chess/src/engine.ts]` Refactor: Change constructor to `constructor(opts: EngineOptions = {})` where `EngineOptions = {activePresets?, layout?}`. Default layout to `CLASSIC_LAYOUT`. Support legacy `new ChessEngine(activePresets)` call via overload signature — expect: all existing callers keep working unchanged (tests green). +- `[packages/chess/src/engine-presets.test.ts]` Sanity: Add one test instantiating `new ChessEngine({ layout: EMPTY_LAYOUT })` and asserting `session.allFacts().filter(f => f.attr === "PieceType").length === 0` — expect: green. + +### A.5 — Phase A verification gate + +- `[repo root]` Verify: Run `bun run check` — expect: typecheck + lint + all 930+ tests green. No functional behavior change. + +--- + +## Phase B — Premade Layouts + FEN + +### B.1 — FEN round-trip utility + +- `[packages/chess/src/layouts/fen.ts]` Create: `toFen(pieces: readonly PiecePlacement[]): string` and `fromFen(fen: string): {pieces: PiecePlacement[], errors: string[]}`. Support only the piece-placement field (first space-delimited token); ignore side-to-move/castling/en-passant/halfmove — expect: round-trip on CLASSIC_LAYOUT produces `"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"`. +- `[packages/chess/src/layouts/fen.test.ts]` Create: 10+ tests covering classic round-trip, empty board (`"8/8/8/8/8/8/8/8"`), partial positions, malformed input (returns errors, not throws), non-FIDE pieces (use `PIECE_TYPE_REGISTRY` glyph mapping — fall back to placeholder `*` for unknown types) — expect: green. + +### B.2 — Premade: Dunsany + +- `[packages/chess/src/layouts/dunsany.ts]` Create: White = 32 pawns on squares 0..31 (ranks 1-4). Black = standard FIDE back rank on rank 8 + pawns on rank 7 — expect: `pieces.length === 32 + 16 = 48`; registered as `id: "dunsany"`, name `"Dunsany's Chess"`; `suggestedPresets: []`. + +### B.3 — Premade: Monster + +- `[packages/chess/src/layouts/monster.ts]` Create: White = king on e1 + 4 pawns on c2/d2/e2/f2. Black = full FIDE setup — expect: `pieces.length === 5 + 16 = 21`; `suggestedPresets: ["double-move"]` (commented as not-yet-implemented preset, displayed as dimmed hint in UI). + +### B.4 — Premade: Pawns-Only + +- `[packages/chess/src/layouts/pawns-only.ts]` Create: Each side = king + 8 pawns on second/seventh ranks. No other pieces — expect: `pieces.length === 2 + 16 = 18`. (We keep 1 king per side to satisfy the "min 1 king" validator; the greenchess variant uses no king + first-promotion-wins — that's a rule-preset concern. Users enable `capture-to-win` alongside if they want that flavor.) + +### B.5 — Premade: Horde + +- `[packages/chess/src/layouts/horde.ts]` Create: White = 36 pawns (all of ranks 1-4 except a4/b4/g4/h4 which are empty — standard Horde pattern per lichess). Black = full FIDE setup — expect: `pieces.length === 36 + 16 = 52`. + +### B.6 — Premade: Knightmate layout + +- `[packages/chess/src/layouts/knightmate.ts]` Create: Both sides = knights replace king on e1/e8; kings replace knights on b1/g1/b8/g8. (Per greenchess: "knights become royal". Since we don't ship the royalty rule yet, this is just a piece-swap — play as "no king, two knights to defend". The file comment notes this limitation.) — expect: `pieces.length === 32`; `suggestedPresets: ["knightmate-rules"]` (future). + +### B.7 — Premade: Chess960 (Fischer random) + +- `[packages/chess/src/layouts/chess960.ts]` Create: Export function `buildChess960Layout(seed: number): StartingLayout` that follows the Chess960 generation rules (bishops on opposite-color squares, king between the two rooks, other pieces uniformly random). Register a SPECIAL SHIM in the registry with `id: "chess960"` whose name is `"Chess960 (Fischer Random)"` and whose `pieces` is a placeholder — the `LayoutPicker` detects this id and, on selection, calls `buildChess960Layout(Math.floor(Math.random() * 960))` to produce the actual layout — expect: generator produces valid 960 positions; bishop-color and king-between-rooks invariants hold for all 960 seeds; unit test asserts 20 random seeds all satisfy invariants. + +### B.8 — Layout validator + +- `[packages/chess/src/layouts/validate.ts]` Create: `validateLayout(layout): {errors: string[]; warnings: string[]}`. Errors (block): not exactly 1 white king, not exactly 1 black king, duplicate square, square out of [0, 63]. Warnings (allow-but-flag): pawn on rank 1 or rank 8 (can't move / impossible end state), wildly unequal material, missing opponent's pieces — expect: unit tests cover every branch. +- `[packages/chess/src/layouts/validate.test.ts]` Create: 12+ tests — expect: green. + +### B.9 — Phase B verification gate + +- `[repo root]` Verify: Run `bun run check` — expect: green. Premade layouts loadable via registry, FEN round-trips, validator agrees with manually-verified cases. + +--- + +## Phase C — Server & Protocol + +### C.1 — Protocol schema for layout payload + +- `[packages/server/src/protocol.ts]` Extend: Add optional `layout` discriminated union to `RoomCreatePayload` schema (Valibot) — expect: messages without `layout` field still validate (backward compat); messages with `{kind: "premade", id: "dunsany"}` validate; malformed entries rejected with typed error. +- `[packages/server/src/protocol.test.ts]` Add tests for each kind — expect: green. + +### C.2 — Server resolves layout + validates + +- `[packages/server/src/rooms.ts]` Update: On `room.create`, resolve the incoming `layout` field to a concrete `StartingLayout` (registry lookup for `kind:"premade"`, `fromFen` for `kind:"fen"`, direct use for `kind:"custom"`), run `validateLayout`, reject with `LAYOUT_INVALID` on validation errors — expect: server accepts premade ids, valid FENs, valid custom piece arrays; rejects invalid. +- `[packages/server/src/game-session.ts]` Update: Pass resolved layout into `new ChessEngine({ layout, activePresets })` — expect: the engine instance starts from the requested layout. +- `[packages/server/src/rooms.test.ts]` Add 5+ test cases covering premade id, FEN, custom pieces, invalid FEN, invalid king count — expect: green. + +### C.3 — Echo resolved layout to clients + +- `[packages/server/src/broadcast.ts]` Update: `room.created` and `room.joined` responses include `layout: {id, name, pieces}` field so clients can display the name and re-render if they arrived via shared URL without local layout state — expect: protocol test updated; server unit test verifies both events carry the field. + +### C.4 — Update PROTOCOL.md + +- `[packages/server/PROTOCOL.md]` Update: Document the new `layout` field on `room.create`, its discriminated union, and the echo in `room.created` / `room.joined`. Include example payloads — expect: docs accurate. + +### C.5 — Phase C verification gate + +- `[repo root]` Verify: Run `bun run check` and manually smoke-test via `bun run packages/server/src/index.ts` + two browser tabs creating/joining rooms with different layouts — expect: green; smoke test clean. + +--- + +## Phase D — Lobby Integration + Client Wiring + +### D.1 — LayoutPicker component + +- `[packages/chess/src/ui/LayoutPicker.tsx]` Create: Dropdown + a mini-preview thumbnail. Lists all registry layouts + "Custom…" entry. Controlled via `value: string` / `onChange: (layoutId, resolvedLayout) => void`. For `chess960`, regenerates seed on every selection — expect: renders all premades; selecting one fires change handler with the resolved layout. + +### D.2 — Wire picker into Lobby + +- `[packages/chess/src/ui/Lobby.tsx]` Update: Add `LayoutPicker` above Create Room button. Store selected layout in component state. Pass to `oneShotRoomRequest('room.create', { layout: selectedLayout })`. `handlePlaySolo` uses it too, passing to `new ChessEngine({ layout })` — expect: E2E-visible: dropdown appears, selecting Dunsany then Create → server receives the layout. + +### D.3 — Protocol client updates + +- `[packages/chess/src/net/types.ts]` Extend: Mirror server's `RoomCreatePayload` type — expect: compile-clean. +- `[packages/chess/src/net/lobby-request.ts]` Update: Payload type for `room.create` includes optional `layout` — expect: typed end-to-end. + +### D.4 — GameView displays layout name + +- `[packages/chess/src/ui/GameView.tsx]` Update: Show a subtle label near the room code pill: "Dunsany's Chess" / "Custom layout" etc. Sourced from the `layout` field on the room-state broadcast — expect: label renders; hidden when classic. + +### D.5 — Autosave keyed by layout + +- `[packages/chess/src/persist/autosave.ts]` Refactor: Change autosave key from `chess-autosave` to `chess-autosave:${layoutId}`. Add migration reading the old key as `chess-autosave:classic` — expect: existing FIDE saves still load under Classic; a custom layout has its own autosave slot. + +### D.6 — Shareable layout URL + +- `[packages/chess/src/app/App.tsx]` Extend: `/` route reads `?layoutId=` or `?fen=` query params and pre-selects the picker. `/game/:code` unchanged (room state already carries layout). Layout URL share button in Lobby: copies `${origin}/?layoutId=dunsany` or for custom `${origin}/?fen=` — expect: pasting a layout URL pre-selects; copying regenerates on demand. + +### D.7 — Phase D verification gate + +- `[repo root]` Verify: Run `bun run check` — expect: green. E2E smoke: lobby picker, premade selection, multiplayer flow each work. + +--- + +## Phase E — Custom Layout Editor + Library + +### E.1 — LayoutEditor component (shell) + +- `[packages/chess/src/ui/LayoutEditor.tsx]` Create: Full-screen modal overlay with three panels: piece palette (left), 8×8 board (center), actions panel (right). Uses existing `Board` in "editor mode" — pieces draggable without move-legality checks, tap-to-remove — expect: modal opens from LayoutPicker's "Custom…" entry; closes via Esc or Cancel. + +### E.2 — Editor drag-and-drop placement + +- `[packages/chess/src/ui/LayoutEditor.tsx]` Extend: Palette items draggable onto squares; placing on an occupied square replaces; dragging off the board removes. Works on mobile via long-press + tap-target-to-place — expect: placing a queen on d4 yields `{type: "queen", color: "white", square: 27}` in the editor state. + +### E.3 — Editor validation panel + +- `[packages/chess/src/ui/LayoutEditor.tsx]` Extend: Live validation display using `validateLayout`. Green check when valid; red errors block "Use This Layout"; amber warnings display but don't block — expect: removing a king shows "Missing white king" error, hides on re-add. + +### E.4 — FEN import/export in editor + +- `[packages/chess/src/ui/LayoutEditor.tsx]` Extend: Textarea bound to live FEN string via `toFen`; "Load FEN" button calls `fromFen` and overwrites editor state; editing the field updates the board — expect: typing a valid FEN re-populates the board. + +### E.5 — Save to library + +- `[packages/chess/src/ui/LayoutEditor.tsx]` Extend: "Save to Library" button prompts for name, writes to `localStorage[houserules:layouts:v1]` as an array of saved layouts (max 20, FIFO eviction) with timestamp + starred bool. Duplicates (same piece set) replace-in-place — expect: saved layouts appear in LayoutPicker under a "Saved" section. + +### E.6 — Library drawer + +- `[packages/chess/src/ui/LayoutEditor.tsx]` Extend: "Library" button toggles a side drawer listing saved layouts: name, piece count, last-used, actions (load, star, duplicate, delete) — expect: drawer opens; actions persist to localStorage. + +### E.7 — Share-code export + +- `[packages/chess/src/ui/LayoutEditor.tsx]` Extend: "Copy Share Link" button writes `${origin}/?fen=${encodeURIComponent(fen)}&name=${encodeURIComponent(name)}` to clipboard. Toast confirms — expect: pasted link pre-selects custom layout with matching FEN in a fresh browser. + +### E.8 — "Use This Layout" commits to Lobby + +- `[packages/chess/src/ui/LayoutEditor.tsx]` Extend: CTA disabled until validation green. On click, calls `onApply(layoutAsStartingLayout)` passed from Lobby; editor closes; Lobby's LayoutPicker now reflects "Custom" selection — expect: Create Room after this uses the custom layout. + +### E.9 — E2E + final check + +- `[packages/chess/e2e/layout-editor.spec.ts]` Create: Playwright test for full flow — open editor, drag pieces, save, load, create multiplayer room, verify both clients render the same position — expect: green on first run. +- `[repo root]` Verify: `bun run check` + Playwright — expect: green. + +--- + +## Risks + +1. **Layout-data size over the wire.** A full custom layout is ~32 piece placements ≈ 500 bytes. Well under the 64KB WS limit. FENs are under 80 bytes. Non-issue. +2. **Mobile drag-and-drop UX** in the editor. Mitigation: tap-select-then-tap-square fallback plus haptic on place. Ship both. +3. **Chess960 bishop-color invariant bugs.** Mitigation: unit-test all 960 seeds (cheap — generation is pure). +4. **Autosave migration.** Moving the key is one-time; we read the old key on startup and rewrite under the new namespace. A single test covers round-trip. +5. **"Knightmate without its rule" confusion.** Mitigation: layout description text spells out "Layout only — Knight-as-royal rule coming later; today this plays as kingless". +6. **Server-side DoS via huge custom layouts.** Mitigation: validator caps piece count at 64 (board size), rejects duplicate squares, enforces square bounds. Already part of `validateLayout`. +7. **Preset compat regressions.** Piece-hp, queen-splits etc. all run through `spawnPiece` which layouts also use. Mitigation: keep `spawnPiece` as the single spawn path; add a direct unit test (start from Dunsany with piece-hp active → every pawn has Hp=2). + +--- + +## Deferred / Post-Landing + +- **Preset suggestions auto-apply toggle** — "Enable suggested rules for Monster? (adds double-move)". +- **Board shape/dimensions refactor** — non-8×8 boards, hex, removed squares. Rides on `StartingLayout.board?: BoardConfig` extension. +- **Monster / Knightmate / Berolina rule presets** — new preset work to make those layouts play their intended way. Orthogonal to this plan. +- **Mid-game position editor** — analysis-mode workflow. +- **Thumbnail preview in LayoutPicker** — render actual mini-board SVG instead of piece-count badge. Nice-to-have. +- **Layout import from lichess/chess.com PGN headers** — nice-to-have. diff --git a/packages/chess/src/engine-presets.test.ts b/packages/chess/src/engine-presets.test.ts index 70fb73d..8d12213 100644 --- a/packages/chess/src/engine-presets.test.ts +++ b/packages/chess/src/engine-presets.test.ts @@ -10,7 +10,12 @@ import { describe, it, expect } from "vitest"; import { ChessEngine } from "./engine.js"; import "./presets/index.js"; +import "./layouts/index.js"; import { algebraicToSquare } from "./coord.js"; +import { EMPTY_LAYOUT } from "./layouts/empty.js"; +import { CLASSIC_LAYOUT } from "./layouts/classic.js"; +import { ActivePresetSet } from "./presets/active-set.js"; +import { GAME_ENTITY } from "./schema.js"; describe("ActivePresetSet ↔ ChessEngine.getAllLegalMoves integration", () => { it("standard chess: no preset lets a pawn move backward (sanity baseline)", () => { @@ -229,3 +234,55 @@ describe("ActivePresetSet ↔ ChessEngine.getAllLegalMoves integration", () => { }); }); }); + +// ───────────────────────────────────────────────────────────────────── +// EngineOptions: layout + activePresets via options-bag constructor +// ───────────────────────────────────────────────────────────────────── + +describe("ChessEngine({ layout, activePresets }) — options-bag constructor", () => { + it("{ layout: EMPTY_LAYOUT } produces an engine with zero piece facts", () => { + const engine = new ChessEngine({ layout: EMPTY_LAYOUT }); + const pieceTypeFacts = engine.session + .allFacts() + .filter((f) => f.attr === "PieceType" && f.id !== GAME_ENTITY); + expect(pieceTypeFacts).toHaveLength(0); + }); + + it("{ layout: EMPTY_LAYOUT } still seeds game-level facts (Turn=white, etc.)", () => { + const engine = new ChessEngine({ layout: EMPTY_LAYOUT }); + expect(engine.session.get(GAME_ENTITY, "Turn")).toBe("white"); + expect(engine.session.get(GAME_ENTITY, "GameStatus")).toBe("active"); + }); + + it("{ layout: CLASSIC_LAYOUT } matches the legacy new ChessEngine() output", () => { + const legacy = new ChessEngine(); + const viaOpts = new ChessEngine({ layout: CLASSIC_LAYOUT }); + // Compare piece-fact sets; entity ids should match since both + // sessions start from 0 and apply identical layouts in identical + // order. + const factKey = (f: { id: number; attr: string; value: unknown }) => + `${f.id}|${f.attr}|${String(f.value)}`; + const a = new Set(legacy.session.allFacts().map(factKey)); + const b = new Set(viaOpts.session.allFacts().map(factKey)); + expect(a).toEqual(b); + }); + + it("{} (empty options bag) defaults to CLASSIC_LAYOUT + empty preset set", () => { + const engine = new ChessEngine({}); + expect(engine.activePresets.list()).toHaveLength(0); + const pieces = engine.session + .allFacts() + .filter((f) => f.attr === "PieceType" && f.id !== GAME_ENTITY); + expect(pieces).toHaveLength(32); // standard FIDE + }); + + it("{ activePresets } via options bag works identically to positional form", () => { + const presets = new ActivePresetSet(); + presets.replaceAll([ + { id: "pawns-move-backward", scope: "both", turnsRemaining: null }, + ]); + const engine = new ChessEngine({ activePresets: presets }); + expect(engine.activePresets.list()).toHaveLength(1); + expect(engine.activePresets.list()[0]?.id).toBe("pawns-move-backward"); + }); +}); diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index 7bade69..8d4b8c1 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -12,7 +12,8 @@ import { type PieceType, type PieceColor, } from "./schema.js"; -import { generateStartingPosition } from "./starting-position.js"; +import { applyLayout, CLASSIC_LAYOUT } from "./starting-position.js"; +import type { StartingLayout } from "./layouts/types.js"; import { getCastlingMoves, applyCastlingMove, @@ -282,6 +283,24 @@ class PresetStateImpl> } } +/** + * Options bag for the `ChessEngine` constructor. + * + * All fields optional. Omitted fields use a sensible default: + * - `activePresets` → a fresh empty ActivePresetSet (no presets). + * - `layout` → `CLASSIC_LAYOUT` (standard FIDE starting position). + * + * Passing a `layout` is how callers start from a non-FIDE position + * (Dunsany, Monster, Horde, custom editor output, etc.). The engine + * applies the layout FIRST, then activates presets, so every + * preset's `onActivate` scans the layout's pieces — no per-layout + * special-casing inside presets. + */ +export interface EngineOptions { + readonly activePresets?: ActivePresetSet; + readonly layout?: StartingLayout; +} + export class ChessEngine { public readonly session: Session; /** @@ -360,11 +379,50 @@ export class ChessEngine { }; } - constructor(activePresets?: ActivePresetSet) { + /** + * Construct a new engine. + * + * Two calling conventions are supported: + * + * 1. Legacy (no arg, or `activePresets` only): + * `new ChessEngine()` + * `new ChessEngine(activePresets)` + * + * 2. Options bag (preferred for new code): + * `new ChessEngine({ activePresets, layout })` + * `new ChessEngine({ layout: EMPTY_LAYOUT })` + * + * The options bag is how new callers specify a starting layout + * other than FIDE. When `layout` is omitted, defaults to + * `CLASSIC_LAYOUT` (the FIDE starting position), which keeps every + * pre-layouts call site working unchanged. + * + * `activePresets` is applied AFTER the layout, so every preset's + * `onActivate` hook sees the initial board state — piece-hp seeds + * `Hp` on every piece in the layout (FIDE, Dunsany, Horde, …) with + * no per-layout plumbing. + */ + constructor(activePresets?: ActivePresetSet); + constructor(opts: EngineOptions); + constructor(arg?: ActivePresetSet | EngineOptions) { this.session = new Session({ autoFire: false }); - generateStartingPosition(this.session); + + // Normalize the argument: ActivePresetSet stays as-is (legacy + // form); an options bag destructures. We detect by checking for + // `replaceAll` — an internal ActivePresetSet method that isn't + // on plain option bags. + const isLegacyPresetArg = + arg !== undefined && + arg !== null && + typeof (arg as ActivePresetSet).replaceAll === "function"; + const opts: EngineOptions = isLegacyPresetArg + ? { activePresets: arg as ActivePresetSet } + : ((arg as EngineOptions | undefined) ?? {}); + + const layout = opts.layout ?? CLASSIC_LAYOUT; + applyLayout(this.session, layout); recordPosition(this.session); - this.activePresets = activePresets ?? new ActivePresetSet(); + this.activePresets = opts.activePresets ?? new ActivePresetSet(); } /** diff --git a/packages/chess/src/layouts/classic.ts b/packages/chess/src/layouts/classic.ts new file mode 100644 index 0000000..f1fd47b --- /dev/null +++ b/packages/chess/src/layouts/classic.ts @@ -0,0 +1,16 @@ +/** + * Layout: Classic Chess (FIDE). + * + * Registers the standard FIDE starting position. The actual layout + * data lives in `starting-position.ts` as `CLASSIC_LAYOUT` — that + * module is the canonical source of truth for FIDE piece placement, + * shared with the back-compat `generateStartingPosition(session)` + * helper. This file is a one-line registration so the picker + * includes "Classic" in its dropdown without any duplication. + */ +import { CLASSIC_LAYOUT } from "../starting-position.js"; +import { LAYOUT_REGISTRY } from "./registry.js"; + +LAYOUT_REGISTRY.register(CLASSIC_LAYOUT); + +export { CLASSIC_LAYOUT }; diff --git a/packages/chess/src/layouts/empty.ts b/packages/chess/src/layouts/empty.ts new file mode 100644 index 0000000..8590353 --- /dev/null +++ b/packages/chess/src/layouts/empty.ts @@ -0,0 +1,27 @@ +/** + * Layout: Empty board (sandbox). + * + * Zero pieces. Useful as: + * - The "base" of the custom layout editor — open this and the + * board is blank; drag pieces from the palette to compose. + * - A test fixture for engine code that shouldn't assume any + * pieces exist. + * + * An empty layout will NOT validate for game-play (the validator + * requires one king per side). The lobby's Create Room button stays + * disabled when the editor commits an empty layout; the editor + * surfaces the validation error explicitly. + */ +import type { StartingLayout } from "./types.js"; +import { LAYOUT_REGISTRY } from "./registry.js"; + +export const EMPTY_LAYOUT: StartingLayout = { + id: "empty", + name: "Empty Board", + description: + "A blank board for composing your own position from scratch.", + pieces: [], + source: "premade", +}; + +LAYOUT_REGISTRY.register(EMPTY_LAYOUT); diff --git a/packages/chess/src/layouts/index.ts b/packages/chess/src/layouts/index.ts new file mode 100644 index 0000000..ee57af9 --- /dev/null +++ b/packages/chess/src/layouts/index.ts @@ -0,0 +1,28 @@ +/** + * Starting Layouts barrel + registration side-effects. + * + * Importing this module guarantees every premade layout has been + * registered in `LAYOUT_REGISTRY`. Consumers (engine, lobby, + * server) should import `./index.js` rather than the registry + * directly so the side-effect imports fire in a known order. + * + * Registration order determines the order layouts appear in the + * LayoutPicker dropdown. "Classic" is first so the default + * selection shows at the top; "Empty" is last so the sandbox + * option sits at the bottom. + */ + +// Core layouts — always registered. +// The order here determines LayoutPicker dropdown order. +import "./classic.js"; +// Sandbox — last so it's visually grouped separately in the picker. +import "./empty.js"; + +export { LAYOUT_REGISTRY } from "./registry.js"; +export { CLASSIC_LAYOUT } from "./classic.js"; +export { EMPTY_LAYOUT } from "./empty.js"; +export type { + PiecePlacement, + StartingLayout, + LayoutValidationResult, +} from "./types.js"; diff --git a/packages/chess/src/layouts/registry.ts b/packages/chess/src/layouts/registry.ts new file mode 100644 index 0000000..8097c89 --- /dev/null +++ b/packages/chess/src/layouts/registry.ts @@ -0,0 +1,65 @@ +/** + * Starting Layout registry. + * + * Mirrors the shape of `PRESET_REGISTRY` and `PIECE_TYPE_REGISTRY`: + * layouts register themselves via side-effect imports from their own + * module files, and `./index.ts` is the barrel that imports every + * premade. + * + * Duplicate-id registration throws — this is the signal that two + * different modules are trying to claim the same layout id, which + * would silently overwrite in a Map. Throwing surfaces the collision + * at load time rather than at runtime. + * + * Layout definitions are read-only once registered; the registry + * exposes no mutation paths. User-authored ("custom") layouts travel + * by-value (in network payloads, editor state) and never enter the + * registry. + */ +import type { StartingLayout } from "./types.js"; + +class LayoutRegistryClass { + readonly #byId = new Map(); + + /** + * Register a layout under its `id`. Throws if the id is already + * taken — a defensive check because silently overwriting would + * create confusing debugging situations (the "last registered + * wins" race depends on module import order). + */ + register(layout: StartingLayout): void { + if (this.#byId.has(layout.id)) { + throw new Error( + `LayoutRegistry: duplicate layout id "${layout.id}". ` + + `Each starting layout must have a unique id.`, + ); + } + this.#byId.set(layout.id, layout); + } + + /** + * Look up a layout by id. Returns undefined for unknown ids — the + * caller decides whether that's an error (the server rejecting an + * unknown premade in `room.create`) or a benign miss (the lobby + * falling back to "classic"). + */ + get(id: string): StartingLayout | undefined { + return this.#byId.get(id); + } + + /** + * All registered layouts, in registration (= import) order. The + * LayoutPicker UI relies on this being a stable order so the + * dropdown doesn't shuffle between renders. + */ + list(): readonly StartingLayout[] { + return [...this.#byId.values()]; + } + + /** True if a layout with the given id is registered. */ + has(id: string): boolean { + return this.#byId.has(id); + } +} + +export const LAYOUT_REGISTRY = new LayoutRegistryClass(); diff --git a/packages/chess/src/layouts/types.ts b/packages/chess/src/layouts/types.ts new file mode 100644 index 0000000..1b765cc --- /dev/null +++ b/packages/chess/src/layouts/types.ts @@ -0,0 +1,84 @@ +/** + * Starting Layout types. + * + * A `StartingLayout` describes WHICH pieces start WHERE on the board + * at game construction time. It is ORTHOGONAL to rule presets: + * + * - Layouts control initial position (Dunsany, Monster, Chess960, …) + * - Presets control gameplay rules (HP, king-heals, cylinder, …) + * + * A single game has exactly one layout and zero or more active presets. + * Selecting a layout does NOT imply enabling any preset — premades + * publish `suggestedPresets` as an advisory hint only; the user decides + * whether to activate them. + * + * Layouts are registered in `LAYOUT_REGISTRY` (registry.ts) via + * side-effect imports, mirroring `PRESET_REGISTRY` and + * `PIECE_TYPE_REGISTRY`. + */ +import type { PieceType, PieceColor, Square } from "../schema.js"; + +/** + * A single piece placement in a starting layout. + * + * `hasMoved` is optional (defaults to false). Useful for layouts that + * want castling rights pre-revoked, or for pawns that shouldn't have + * their double-push option (e.g., a layout with pawns starting on + * their second move rank rather than home rank). + */ +export interface PiecePlacement { + readonly type: PieceType; + readonly color: PieceColor; + readonly square: Square; + readonly hasMoved?: boolean; +} + +/** + * A complete starting layout. + * + * Identity: + * - `id` — stable kebab-case string used in URLs, protocol, storage. + * Never change an existing premade id; ship a new id instead. + * - `name` — human-readable display label. + * - `description` — one or two sentences of flavor text surfaced + * in the lobby picker. + * + * Content: + * - `pieces` — every piece placed on the board at game start. A + * layout with zero pieces is a valid "empty sandbox". + * + * Hints: + * - `suggestedPresets` — preset ids that the layout's authors + * recommend pairing. UI displays these as dimmed chips with a + * one-click enable; the user is never forced. A preset id here + * that doesn't exist in PRESET_REGISTRY is rendered as "coming + * soon" rather than an error — this lets layouts ship before + * their matching presets do. + * + * Provenance: + * - `source` — `"premade"` for layouts registered in LAYOUT_REGISTRY; + * `"custom"` for user-authored layouts passed by-value through the + * network (`room.create` payload) and the editor. + */ +export interface StartingLayout { + readonly id: string; + readonly name: string; + readonly description: string; + readonly pieces: readonly PiecePlacement[]; + readonly suggestedPresets?: readonly string[]; + readonly source: "premade" | "custom"; +} + +/** + * Outcome of `validateLayout(layout)`. + * + * `errors` block activation (the layout cannot be used). `warnings` + * are surfaced to the user but do not block — a pawn starting on rank + * 8 is legal, just unusual (and unable to move). + * + * Both arrays are empty for a layout the validator fully approves. + */ +export interface LayoutValidationResult { + readonly errors: readonly string[]; + readonly warnings: readonly string[]; +} diff --git a/packages/chess/src/starting-position.test.ts b/packages/chess/src/starting-position.test.ts index f9e741a..f7b0a67 100644 --- a/packages/chess/src/starting-position.test.ts +++ b/packages/chess/src/starting-position.test.ts @@ -1,7 +1,13 @@ import { describe, it, expect } from "vitest"; -import { generateStartingPosition, STARTING_PIECES_DEF } from "./starting-position.js"; +import { + CLASSIC_LAYOUT, + applyLayout, + generateStartingPosition, + STARTING_PIECES_DEF, +} from "./starting-position.js"; import { Session } from "@paratype/rete"; import { GAME_ENTITY } from "./schema.js"; +import type { StartingLayout } from "./layouts/types.js"; describe("generateStartingPosition()", () => { it("inserts exactly 32 piece entities", () => { @@ -118,3 +124,110 @@ describe("generateStartingPosition()", () => { expect(session.get(GAME_ENTITY, "Winner")).toBe(null); }); }); + +// ───────────────────────────────────────────────────────────────────── +// applyLayout() — the parametrized spawn path +// ───────────────────────────────────────────────────────────────────── + +describe("applyLayout()", () => { + it("applying CLASSIC_LAYOUT matches generateStartingPosition's piece count (32)", () => { + const a = new Session({ autoFire: false }); + const b = new Session({ autoFire: false }); + const idsA = applyLayout(a, CLASSIC_LAYOUT); + const idsB = generateStartingPosition(b); + expect(idsA).toHaveLength(32); + expect(idsB).toHaveLength(32); + expect(idsA).toEqual(idsB); // same EntityId sequence (sessions start fresh) + }); + + it("applying CLASSIC_LAYOUT produces the same piece facts as generateStartingPosition", () => { + const a = new Session({ autoFire: false }); + const b = new Session({ autoFire: false }); + applyLayout(a, CLASSIC_LAYOUT); + generateStartingPosition(b); + + // Compare piece facts (ignore game-level ones; both paths insert + // identical game-level facts). + const factKey = (f: { id: number; attr: string; value: unknown }) => + `${f.id}|${f.attr}|${String(f.value)}`; + const factsA = new Set( + a.allFacts().filter((f) => f.id !== GAME_ENTITY).map(factKey), + ); + const factsB = new Set( + b.allFacts().filter((f) => f.id !== GAME_ENTITY).map(factKey), + ); + expect(factsA).toEqual(factsB); + }); + + it("empty layout inserts zero piece facts but still seeds game-level facts", () => { + const empty: StartingLayout = { + id: "test-empty", + name: "Test Empty", + description: "zero pieces", + pieces: [], + source: "custom", + }; + const session = new Session({ autoFire: false }); + const ids = applyLayout(session, empty); + expect(ids).toHaveLength(0); + + const pieceFacts = session + .allFacts() + .filter((f) => f.id !== GAME_ENTITY); + expect(pieceFacts).toHaveLength(0); + + // Game facts still seeded. + expect(session.get(GAME_ENTITY, "Turn")).toBe("white"); + expect(session.get(GAME_ENTITY, "GameStatus")).toBe("active"); + }); + + it("placement.hasMoved=true is honored (pre-revokes castling rights)", () => { + const layout: StartingLayout = { + id: "test-moved", + name: "Test Moved King", + description: "single king with hasMoved=true", + pieces: [ + { type: "king", color: "white", square: 4, hasMoved: true }, + { type: "king", color: "black", square: 60 }, + ], + source: "custom", + }; + const session = new Session({ autoFire: false }); + const ids = applyLayout(session, layout); + expect(ids).toHaveLength(2); + + // White king has HasMoved=true; black king defaults to false. + const whiteKingMoved = session.get(ids[0]!, "HasMoved"); + const blackKingMoved = session.get(ids[1]!, "HasMoved"); + expect(whiteKingMoved).toBe(true); + expect(blackKingMoved).toBe(false); + }); + + it("CLASSIC_LAYOUT is registered with source='premade' and id='classic'", () => { + expect(CLASSIC_LAYOUT.id).toBe("classic"); + expect(CLASSIC_LAYOUT.source).toBe("premade"); + expect(CLASSIC_LAYOUT.pieces).toHaveLength(32); + }); + + it("pieces are inserted in layout.pieces order (EntityId sequence matches)", () => { + const layout: StartingLayout = { + id: "test-order", + name: "Test Order", + description: "two pieces in specific order", + pieces: [ + { type: "rook", color: "white", square: 0 }, + { type: "king", color: "white", square: 4 }, + ], + source: "custom", + }; + const session = new Session({ autoFire: false }); + const ids = applyLayout(session, layout); + expect(ids).toHaveLength(2); + + // First id → rook at square 0, second id → king at square 4. + expect(session.get(ids[0]!, "PieceType")).toBe("rook"); + expect(session.get(ids[0]!, "Position")).toBe(0); + expect(session.get(ids[1]!, "PieceType")).toBe("king"); + expect(session.get(ids[1]!, "Position")).toBe(4); + }); +}); diff --git a/packages/chess/src/starting-position.ts b/packages/chess/src/starting-position.ts index c9ebc5a..e037ec8 100644 --- a/packages/chess/src/starting-position.ts +++ b/packages/chess/src/starting-position.ts @@ -1,23 +1,52 @@ /** - * FIDE starting position generator. - * Inserts 32 piece entities + game-level facts into a Session. - * Per SPEC.md §ID Authority, pieces get incremental EntityIds; game uses GAME_ENTITY (0). + * Starting-position generator. + * + * Post-layouts-refactor, this module is a THIN wrapper around the + * layouts subsystem: + * + * - `CLASSIC_LAYOUT` — the FIDE starting layout, defined here as + * the canonical source of truth. Re-exported and registered by + * `layouts/classic.ts` so it participates in `LAYOUT_REGISTRY`. + * - `applyLayout(session, layout)` — inserts piece facts (one + * entity per placement) + game-level facts into an empty + * Session. The SHARED path for every starting layout — classic, + * custom, Dunsany, Chess960. + * - `generateStartingPosition(session)` — legacy entry point kept + * as a back-compat wrapper over `applyLayout(session, CLASSIC_LAYOUT)`. + * Called from older test sites that pre-date the layouts feature. + * + * `applyLayout` deliberately does NOT fire `onPieceSpawn` hooks — it + * runs at session-construction time, before any preset is active, so + * there's nothing to hook. Presets that need to tag initial pieces + * (piece-hp seeding `Hp`, etc.) do so via their `onActivate` scan, + * which the engine fires after the session is seeded. This keeps the + * pipeline's two "piece birth" paths distinct: + * + * 1. Starting pieces → inserted by applyLayout, tagged by each + * preset's onActivate when the preset set is applied. + * 2. Mid-game spawns (promotion, fission, summon) → created via + * `engine.spawnPiece` which DOES fire onPieceSpawn. + * + * Per SPEC.md §ID Authority, pieces get incremental EntityIds from + * `session.nextId()`; game-level facts live on `GAME_ENTITY` (0). */ import type { EntityId } from "@paratype/rete"; import type { Session } from "@paratype/rete"; import { GAME_ENTITY, type PieceType, - type PieceColor, type Square, } from "./schema.js"; +import type { PiecePlacement, StartingLayout } from "./layouts/types.js"; interface PieceDef { type: PieceType; - color: PieceColor; + color: "white" | "black"; square: Square; } +// ── FIDE starting-position data (kept as arrays for readability) ────── + const WHITE_BACK_RANK: PieceType[] = [ "rook", "knight", @@ -83,26 +112,73 @@ function buildStartingPieces(): PieceDef[] { const STARTING_PIECES = buildStartingPieces(); +/** The canonical FIDE starting piece definitions (for testing and reference). */ +export const STARTING_PIECES_DEF: ReadonlyArray> = + STARTING_PIECES; + /** - * Insert all 32 FIDE starting pieces + game-level facts into a Session. - * Each piece gets 4 facts: PieceType, Color, Position, HasMoved. - * Game level gets: Turn, HalfmoveClock, FullmoveNumber, EnPassantTarget, GameStatus, Winner. - * Returns the array of EntityIds assigned to pieces (in STARTING_PIECES order). + * The FIDE starting layout exported as a `StartingLayout` value. + * + * Lives here (rather than in `layouts/classic.ts`) so that: + * - The back-compat `generateStartingPosition` can reach it without + * importing the layouts barrel (which would transitively import + * every premade and pull them into the test harness for files + * that just want "the FIDE setup"). + * - `layouts/classic.ts` is a one-line registration file with no + * duplication. + * + * `source: "premade"` so the lobby can tell it apart from user- + * authored layouts. */ -export function generateStartingPosition(session: Session): EntityId[] { +export const CLASSIC_LAYOUT: StartingLayout = { + id: "classic", + name: "Classic Chess", + description: + "The standard FIDE starting position. 32 pieces, symmetric setup.", + pieces: STARTING_PIECES.map((p) => ({ + type: p.type, + color: p.color, + square: p.square, + })) satisfies PiecePlacement[], + source: "premade", +}; + +/** + * Insert every piece placement in `layout` into `session`, plus the + * standard game-level facts (Turn = white, clocks = 0, etc.). + * + * Returns the array of EntityIds assigned to the pieces, in the + * order they appear in `layout.pieces`. Callers that need to + * cross-reference piece ids with their placements (tests, save-state + * inspection) rely on this order being stable. + * + * Each piece gets four facts: `PieceType`, `Color`, `Position`, + * `HasMoved`. `HasMoved` defaults to `false` unless the placement + * explicitly sets it — layouts can pre-revoke castling / double-push + * rights by setting `hasMoved: true` on specific placements. + * + * Game-level facts (Turn, HalfmoveClock, FullmoveNumber, + * EnPassantTarget, GameStatus, Winner) are inserted identically + * regardless of layout — the layout controls WHERE pieces are, not + * the clock / turn state. Clock-related presets are free to adjust + * these via `onActivate` if they want custom initial values. + */ +export function applyLayout( + session: Session, + layout: StartingLayout, +): EntityId[] { const pieceIds: EntityId[] = []; - // Insert all 32 pieces with their attributes - for (const piece of STARTING_PIECES) { + for (const placement of layout.pieces) { const id = session.nextId(); - session.insert(id, "PieceType", piece.type); - session.insert(id, "Color", piece.color); - session.insert(id, "Position", piece.square); - session.insert(id, "HasMoved", false); + session.insert(id, "PieceType", placement.type); + session.insert(id, "Color", placement.color); + session.insert(id, "Position", placement.square); + session.insert(id, "HasMoved", placement.hasMoved ?? false); pieceIds.push(id); } - // Game-level facts + // Game-level facts — uniform across every layout. session.insert(GAME_ENTITY, "Turn", "white"); session.insert(GAME_ENTITY, "HalfmoveClock", 0); session.insert(GAME_ENTITY, "FullmoveNumber", 1); @@ -113,6 +189,13 @@ export function generateStartingPosition(session: Session): EntityId[] { return pieceIds; } -/** The canonical FIDE starting piece definitions (for testing and reference). */ -export const STARTING_PIECES_DEF: ReadonlyArray> = - STARTING_PIECES; +/** + * Back-compat wrapper: apply the FIDE starting layout to `session`. + * + * Pre-layouts-feature callers (tests mostly) use this. New code + * should call `applyLayout(session, layout)` directly or go through + * the engine constructor's `layout` option. + */ +export function generateStartingPosition(session: Session): EntityId[] { + return applyLayout(session, CLASSIC_LAYOUT); +}