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.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 19:44:01 -06:00
commit d93bcf6c81
No known key found for this signature in database
12 changed files with 1319 additions and 26 deletions

View file

@ -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<PieceType>): 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<PieceType>` 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<PieceType>` 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.

View file

@ -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=<id>` or `?fen=<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=<encoded>` — 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.