From d93bcf6c812c6650fc1f8d1c9ec0c5fecd1a8504 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 19:44:01 -0600 Subject: [PATCH 1/9] 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); +} From f7099d754d5ba4cb595868d1c07c40b28e3c1a5c Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 19:52:22 -0600 Subject: [PATCH 2/9] feat(chess): FEN + premade starting layouts (Phase B) Ships the 7 premades (Classic was in Phase A, the rest here) plus the FEN round-trip utility and layout validator. New premades: - dunsany: 31 white pawns + king vs full black army (asymmetric). - monster: white king + 4 central pawns vs full black army. - pawns-only: each side = king + 8 pawns on home rank. - horde: white 'wall' of 35 pawns + king + 4 advanced vs full army. - knightmate: swaps kings and knights on the back rank. - chess960: Fischer Random shim with buildChess960Layout(seed) factory following the Scharnagl numbering scheme. Position 518 matches FIDE. All 960 seeds are exhaustively tested for bishop- opposite-colors + king-between-rooks invariants. Deviations from canonical are documented in each file: - Dunsany, Horde: canonical versions are kingless; we add a king on e1 and swap out the conflicting pawn so the current validator accepts them. Will ship canonical versions once capture-all or a no-royalty preset lands. - Knightmate: validator relaxed to 'at least 1 king per side' (from 'exactly 1') to support multi-king layouts. New utilities: - fen.ts: toFen / fromFen for piece-placement field of standard FEN. Supports custom piece types via {Type} bracket extension. - validate.ts: errors on king-count/bounds/duplicate-square; warnings on pawn-on-rank-1/8 and >32-material. Tests: 50 new tests across fen.test.ts (16), validate.test.ts (17), premades.test.ts (13), chess960.test.ts (4 incl. exhaustive 960-seed invariant check). 991 tests passing; bun run check clean. --- packages/chess/src/layouts/_helpers.ts | 73 ++++++ packages/chess/src/layouts/chess960.test.ts | 124 +++++++++ packages/chess/src/layouts/chess960.ts | 202 +++++++++++++++ packages/chess/src/layouts/dunsany.ts | 50 ++++ packages/chess/src/layouts/fen.test.ts | 144 +++++++++++ packages/chess/src/layouts/fen.ts | 271 ++++++++++++++++++++ packages/chess/src/layouts/horde.ts | 66 +++++ packages/chess/src/layouts/index.ts | 13 + packages/chess/src/layouts/knightmate.ts | 82 ++++++ packages/chess/src/layouts/monster.ts | 37 +++ packages/chess/src/layouts/pawns-only.ts | 48 ++++ packages/chess/src/layouts/premades.test.ts | 79 ++++++ packages/chess/src/layouts/validate.test.ts | 166 ++++++++++++ packages/chess/src/layouts/validate.ts | 132 ++++++++++ 14 files changed, 1487 insertions(+) create mode 100644 packages/chess/src/layouts/_helpers.ts create mode 100644 packages/chess/src/layouts/chess960.test.ts create mode 100644 packages/chess/src/layouts/chess960.ts create mode 100644 packages/chess/src/layouts/dunsany.ts create mode 100644 packages/chess/src/layouts/fen.test.ts create mode 100644 packages/chess/src/layouts/fen.ts create mode 100644 packages/chess/src/layouts/horde.ts create mode 100644 packages/chess/src/layouts/knightmate.ts create mode 100644 packages/chess/src/layouts/monster.ts create mode 100644 packages/chess/src/layouts/pawns-only.ts create mode 100644 packages/chess/src/layouts/premades.test.ts create mode 100644 packages/chess/src/layouts/validate.test.ts create mode 100644 packages/chess/src/layouts/validate.ts diff --git a/packages/chess/src/layouts/_helpers.ts b/packages/chess/src/layouts/_helpers.ts new file mode 100644 index 0000000..c422823 --- /dev/null +++ b/packages/chess/src/layouts/_helpers.ts @@ -0,0 +1,73 @@ +/** + * Internal helpers for composing premade layouts. + * + * NOT part of the public layouts API. Premades use these to compose + * "FIDE back rank + pawn rank for one color" subsets without + * duplicating the `PieceType[]` array in every file. + * + * Naming: underscore prefix marks this as a module-private helper, + * not listed in the layouts barrel's public re-exports. + */ +import type { PieceType, PieceColor, Square } from "../schema.js"; +import type { PiecePlacement } from "./types.js"; +import { squareOf } from "../coord.js"; + +/** Standard FIDE back-rank piece order, file 0 (a) through file 7 (h). */ +export const FIDE_BACK_RANK: readonly PieceType[] = [ + "rook", + "knight", + "bishop", + "queen", + "king", + "bishop", + "knight", + "rook", +]; + +/** + * Build the 16-piece FIDE setup for one color. + * + * - White: back rank on rank 0 (rank 1 in algebraic), pawns on rank 1. + * - Black: back rank on rank 7 (rank 8 in algebraic), pawns on rank 6. + */ +export function fideSideOf(color: PieceColor): PiecePlacement[] { + const backRank = color === "white" ? 0 : 7; + const pawnRank = color === "white" ? 1 : 6; + const pieces: PiecePlacement[] = []; + for (let file = 0; file < 8; file++) { + pieces.push({ + type: FIDE_BACK_RANK[file]!, + color, + square: squareOf(file, backRank) as Square, + }); + pieces.push({ + type: "pawn", + color, + square: squareOf(file, pawnRank) as Square, + }); + } + return pieces; +} + +/** + * Build pawns for one color filling every square on `ranks`. + * + * Used by Dunsany (white pawns on ranks 0-3) and Horde (white pawns + * on ranks 0-3 with specific gaps). + */ +export function pawnsOnRanks( + color: PieceColor, + ranks: readonly number[], +): PiecePlacement[] { + const pieces: PiecePlacement[] = []; + for (const rank of ranks) { + for (let file = 0; file < 8; file++) { + pieces.push({ + type: "pawn", + color, + square: squareOf(file, rank) as Square, + }); + } + } + return pieces; +} diff --git a/packages/chess/src/layouts/chess960.test.ts b/packages/chess/src/layouts/chess960.test.ts new file mode 100644 index 0000000..1a0e687 --- /dev/null +++ b/packages/chess/src/layouts/chess960.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { buildChess960Layout } from "./chess960.js"; +import { fileOf, rankOf } from "../coord.js"; +import type { PieceType } from "../schema.js"; + +/** + * Chess960 invariants (Scharnagl scheme): + * + * - Both bishops on opposite-color squares (one light, one dark). + * - King is strictly between the two rooks on the back rank. + * - Black back rank mirrors white. + * - Piece count exactly matches FIDE (8 pawns + 8 back-rank per side). + * - Position 518 equals FIDE. + */ + +function whiteBackRank(pieces: ReturnType["pieces"]) { + const row: (PieceType | null)[] = new Array(8).fill(null); + for (const p of pieces) { + if (p.color !== "white") continue; + if (p.type === "pawn") continue; + const rank = rankOf(p.square); + if (rank !== 0) continue; + row[fileOf(p.square)] = p.type; + } + return row; +} + +function blackBackRank(pieces: ReturnType["pieces"]) { + const row: (PieceType | null)[] = new Array(8).fill(null); + for (const p of pieces) { + if (p.color !== "black") continue; + if (p.type === "pawn") continue; + const rank = rankOf(p.square); + if (rank !== 7) continue; + row[fileOf(p.square)] = p.type; + } + return row; +} + +function checkInvariants(back: readonly (PieceType | null)[]): void { + // 8 non-null pieces. + const nonNull = back.filter((p) => p !== null); + expect(nonNull.length).toBe(8); + + // Bishops on opposite colors. Dark square = (file + rank) even; + // rank 0 means color parity follows file parity. + const bishopFiles = back + .map((p, i) => (p === "bishop" ? i : -1)) + .filter((i) => i >= 0); + expect(bishopFiles.length).toBe(2); + const [b1, b2] = bishopFiles as [number, number]; + expect(b1 % 2).not.toBe(b2 % 2); + + // King between rooks. + const kingFile = back.findIndex((p) => p === "king"); + const rookFiles = back + .map((p, i) => (p === "rook" ? i : -1)) + .filter((i) => i >= 0); + expect(kingFile).toBeGreaterThanOrEqual(0); + expect(rookFiles.length).toBe(2); + const [r1, r2] = rookFiles as [number, number]; + expect(Math.min(r1, r2)).toBeLessThan(kingFile); + expect(Math.max(r1, r2)).toBeGreaterThan(kingFile); + + // Exactly 2 knights. + const knightCount = back.filter((p) => p === "knight").length; + expect(knightCount).toBe(2); + + // Exactly 1 queen. + const queenCount = back.filter((p) => p === "queen").length; + expect(queenCount).toBe(1); +} + +describe("buildChess960Layout()", () => { + it("all 960 positions satisfy the invariants", () => { + // Exhaustive check — 960 is small. + for (let id = 0; id < 960; id++) { + const layout = buildChess960Layout(id); + const white = whiteBackRank(layout.pieces); + const black = blackBackRank(layout.pieces); + checkInvariants(white); + checkInvariants(black); + expect(white).toEqual(black); + } + }); + + it("position 518 equals the FIDE starting setup", () => { + const layout = buildChess960Layout(518); + const back = whiteBackRank(layout.pieces); + expect(back).toEqual([ + "rook", + "knight", + "bishop", + "queen", + "king", + "bishop", + "knight", + "rook", + ]); + }); + + it("each layout has exactly 32 pieces (8 back + 8 pawns) × 2", () => { + for (const seed of [0, 1, 518, 959]) { + const layout = buildChess960Layout(seed); + expect(layout.pieces).toHaveLength(32); + const whitePawns = layout.pieces.filter( + (p) => p.type === "pawn" && p.color === "white", + ); + const blackPawns = layout.pieces.filter( + (p) => p.type === "pawn" && p.color === "black", + ); + expect(whitePawns).toHaveLength(8); + expect(blackPawns).toHaveLength(8); + } + }); + + it("wraps out-of-range seeds via modulo", () => { + const inRange = buildChess960Layout(42); + const wrapped = buildChess960Layout(42 + 960); + expect(whiteBackRank(wrapped.pieces)).toEqual( + whiteBackRank(inRange.pieces), + ); + }); +}); diff --git a/packages/chess/src/layouts/chess960.ts b/packages/chess/src/layouts/chess960.ts new file mode 100644 index 0000000..f970566 --- /dev/null +++ b/packages/chess/src/layouts/chess960.ts @@ -0,0 +1,202 @@ +/** + * Layout: Chess960 (Fischer Random Chess). + * + * The back rank is randomized subject to three constraints: + * + * 1. Bishops on opposite-color squares. + * 2. The king sits BETWEEN the two rooks (so castling direction + * is unambiguous). + * 3. Black's back rank mirrors white's. + * + * There are exactly 960 legal starting positions (hence the name). + * Position 518 happens to be standard FIDE. + * + * This file is unusual among layouts: `buildChess960Layout(seed)` is + * a FACTORY that produces a fresh `StartingLayout` each call. The + * value registered in `LAYOUT_REGISTRY` is a placeholder shim — + * selecting it in the LayoutPicker triggers a random seed selection + * and materialization of the real layout on-the-fly. The shim's + * `pieces` field matches a default seed (518 = FIDE) so code that + * doesn't know about the shim treatment still gets a playable setup. + * + * ## Seed → position + * + * We use the standard Scharnagl numbering (0..959). Position 518 is + * the FIDE starting position. The seed is an integer; passing a + * value outside [0, 959] wraps via modulo so bad inputs still + * produce a valid layout. + * + * Reference: https://en.wikipedia.org/wiki/Fischer_random_chess_numbering_scheme + */ +import type { StartingLayout, PiecePlacement } from "./types.js"; +import { LAYOUT_REGISTRY } from "./registry.js"; +import type { PieceType } from "../schema.js"; +import { squareOf } from "../coord.js"; +import { fideSideOf } from "./_helpers.js"; + +const CHESS960_COUNT = 960; + +/** + * Build the 8-piece back rank for a Chess960 position numbered `id`. + * + * Uses Reinhard Scharnagl's standard numbering scheme (id 0..959, + * id 518 = FIDE). Returns an 8-element array of piece types at + * files a..h. + * + * The algorithm: + * 1. id mod 4 → light-square bishop file position (among 4). + * 2. (id / 4) mod 4 → dark-square bishop file position. + * 3. (id / 16) mod 6 → queen file position (among 6 remaining). + * 4. (id / 96) mod 10 → knight placement pattern (the Nx number, + * picking 2 of 5 remaining squares). + * 5. Remaining 3 squares get rook-king-rook in that order. + */ +function chess960BackRank(id: number): PieceType[] { + const n = ((id % CHESS960_COUNT) + CHESS960_COUNT) % CHESS960_COUNT; + + const back: (PieceType | null)[] = new Array(8).fill(null); + + // Step 1: bishop on a light square (squares 1, 3, 5, 7 indexed + // from a=0). id mod 4 picks one of these. + const lightBishopFile = (n % 4) * 2 + 1; + back[lightBishopFile] = "bishop"; + + // Step 2: bishop on a dark square (squares 0, 2, 4, 6). + const darkBishopFile = (Math.floor(n / 4) % 4) * 2; + back[darkBishopFile] = "bishop"; + + // Step 3: queen — one of 6 remaining empty files. + const queenIdx = Math.floor(n / 16) % 6; + let queenPlaced = false; + for (let file = 0, emptyCount = 0; file < 8; file++) { + if (back[file] === null) { + if (emptyCount === queenIdx) { + back[file] = "queen"; + queenPlaced = true; + break; + } + emptyCount++; + } + } + if (!queenPlaced) { + // Unreachable under valid input, but keep TS happy. + throw new Error(`Chess960: failed to place queen for id=${id}`); + } + + // Step 4: knights — one of 10 patterns placing N at 2 of 5 + // remaining positions. Table: (knight1_offset, knight2_offset) + // where offsets index into the list of still-empty files. + const knightPatterns: ReadonlyArray = [ + [0, 1], + [0, 2], + [0, 3], + [0, 4], + [1, 2], + [1, 3], + [1, 4], + [2, 3], + [2, 4], + [3, 4], + ]; + const patternIdx = Math.floor(n / 96) % 10; + const pattern = knightPatterns[patternIdx]!; + + // Enumerate empty files left-to-right. + const empties: number[] = []; + for (let file = 0; file < 8; file++) { + if (back[file] === null) empties.push(file); + } + if (empties.length !== 5) { + throw new Error( + `Chess960: expected 5 empty files for knight placement, got ${empties.length}`, + ); + } + back[empties[pattern[0]]!] = "knight"; + back[empties[pattern[1]]!] = "knight"; + + // Step 5: remaining 3 empty files get rook-king-rook in order. + const finalEmpties: number[] = []; + for (let file = 0; file < 8; file++) { + if (back[file] === null) finalEmpties.push(file); + } + if (finalEmpties.length !== 3) { + throw new Error( + `Chess960: expected 3 empty files for R-K-R placement, got ${finalEmpties.length}`, + ); + } + back[finalEmpties[0]!] = "rook"; + back[finalEmpties[1]!] = "king"; + back[finalEmpties[2]!] = "rook"; + + return back as PieceType[]; +} + +/** + * Build a full `StartingLayout` for Chess960 position `id`. + * + * White's back rank follows the Scharnagl formula; black mirrors. + * Pawns go on rank 2 (white) / rank 7 (black) identically to FIDE. + */ +export function buildChess960Layout(id: number): StartingLayout { + const whiteBack = chess960BackRank(id); + const pieces: PiecePlacement[] = []; + + // White back rank + pawns. + for (let file = 0; file < 8; file++) { + pieces.push({ + type: whiteBack[file]!, + color: "white", + square: squareOf(file, 0), + }); + pieces.push({ + type: "pawn", + color: "white", + square: squareOf(file, 1), + }); + } + + // Black back rank (mirror white) + pawns. + for (let file = 0; file < 8; file++) { + pieces.push({ + type: whiteBack[file]!, + color: "black", + square: squareOf(file, 7), + }); + pieces.push({ + type: "pawn", + color: "black", + square: squareOf(file, 6), + }); + } + + const wrappedId = ((id % CHESS960_COUNT) + CHESS960_COUNT) % CHESS960_COUNT; + return { + id: "chess960", + name: `Chess960 (#${wrappedId})`, + description: + "Fischer Random Chess. Back rank randomized with bishops on opposite colors and king between the rooks. Position number shown in the name.", + pieces, + suggestedPresets: [], + source: "premade", + }; +} + +/** + * The registry SHIM for Chess960. `pieces` is the FIDE default + * (position 518) so code that selects Chess960 without further + * handling still gets a playable board. The LayoutPicker detects + * this id and calls `buildChess960Layout(randomSeed)` to produce a + * fresh random position whenever the user re-selects it. + */ +export const CHESS960_SHIM: StartingLayout = { + id: "chess960", + name: "Chess960 (Fischer Random)", + description: + "Fischer Random Chess. Randomized back rank. Selecting this layout generates a new random position on each pick.", + // Default display uses the FIDE back rank (position 518). + pieces: [...fideSideOf("white"), ...fideSideOf("black")], + suggestedPresets: [], + source: "premade", +}; + +LAYOUT_REGISTRY.register(CHESS960_SHIM); diff --git a/packages/chess/src/layouts/dunsany.ts b/packages/chess/src/layouts/dunsany.ts new file mode 100644 index 0000000..b36f45a --- /dev/null +++ b/packages/chess/src/layouts/dunsany.ts @@ -0,0 +1,50 @@ +/** + * Layout: Dunsany's Chess. + * + * - White: pawns filling ranks 1-4, with a king on e1 in place of + * a pawn (31 pawns + 1 king). + * - Black: standard FIDE setup. + * + * ## Deviation from canonical Dunsany + * + * Canonical Dunsany has NO white king — black wins ONLY by + * eliminating every white pawn; white wins by checkmating black. + * Our current validator requires at least one king per side. Rather + * than block selection until the `capture-all` preset ships (which + * would let white opt out of royal-piece detection), we add a + * king on e1 and play under FIDE win conditions. + * + * The overall "pawn tide vs army" asymmetry is preserved: white + * has 31 pawns and a single king; black has a full army. + * + * When `capture-all` or a similar no-royalty preset lands, we'll + * ship the canonical version. + * + * Reference: https://en.wikipedia.org/wiki/Dunsany%27s_chess + */ +import type { StartingLayout, PiecePlacement } from "./types.js"; +import { LAYOUT_REGISTRY } from "./registry.js"; +import { fideSideOf, pawnsOnRanks } from "./_helpers.js"; +import { algebraicToSquare } from "../coord.js"; + +const E1 = algebraicToSquare("e1"); + +const whitePawnsMinusE1: PiecePlacement[] = pawnsOnRanks("white", [ + 0, 1, 2, 3, +]).filter((p) => p.square !== E1); + +export const DUNSANY_LAYOUT: StartingLayout = { + id: "dunsany", + name: "Dunsany's Chess", + description: + "White has 31 pawns and a king versus black's standard setup. An asymmetric 'pawn tide vs army' battle.", + pieces: [ + ...whitePawnsMinusE1, + { type: "king", color: "white", square: E1 }, + ...fideSideOf("black"), + ], + suggestedPresets: [], + source: "premade", +}; + +LAYOUT_REGISTRY.register(DUNSANY_LAYOUT); diff --git a/packages/chess/src/layouts/fen.test.ts b/packages/chess/src/layouts/fen.test.ts new file mode 100644 index 0000000..9942684 --- /dev/null +++ b/packages/chess/src/layouts/fen.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from "vitest"; +import { toFen, fromFen } from "./fen.js"; +import { CLASSIC_LAYOUT } from "../starting-position.js"; +import type { PiecePlacement } from "./types.js"; +import { algebraicToSquare } from "../coord.js"; + +describe("toFen()", () => { + it("encodes CLASSIC_LAYOUT as the standard FIDE starting FEN", () => { + expect(toFen(CLASSIC_LAYOUT.pieces)).toBe( + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", + ); + }); + + it("encodes an empty board as eight rows of '8'", () => { + expect(toFen([])).toBe("8/8/8/8/8/8/8/8"); + }); + + it("encodes a single white king on e1", () => { + const pieces: PiecePlacement[] = [ + { type: "king", color: "white", square: algebraicToSquare("e1") }, + ]; + expect(toFen(pieces)).toBe("8/8/8/8/8/8/8/4K3"); + }); + + it("encodes a single black queen on d8", () => { + const pieces: PiecePlacement[] = [ + { type: "queen", color: "black", square: algebraicToSquare("d8") }, + ]; + expect(toFen(pieces)).toBe("3q4/8/8/8/8/8/8/8"); + }); + + it("encodes multiple pieces on the same rank with digit runs between", () => { + // White king on a1, white rook on h1 → "R" and "K" with 6 empty + // squares between (in FEN rank-1 order: king first a1, rook last + // h1 → "K6R"). + const pieces: PiecePlacement[] = [ + { type: "king", color: "white", square: algebraicToSquare("a1") }, + { type: "rook", color: "white", square: algebraicToSquare("h1") }, + ]; + expect(toFen(pieces)).toBe("8/8/8/8/8/8/8/K6R"); + }); +}); + +describe("fromFen()", () => { + it("round-trips the FIDE starting FEN back to CLASSIC_LAYOUT's pieces", () => { + const fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"; + const { pieces, errors } = fromFen(fen); + expect(errors).toHaveLength(0); + expect(pieces).toHaveLength(32); + + // Re-encode; should match input. + expect(toFen(pieces)).toBe(fen); + }); + + it("round-trips an empty board", () => { + const fen = "8/8/8/8/8/8/8/8"; + const { pieces, errors } = fromFen(fen); + expect(errors).toHaveLength(0); + expect(pieces).toHaveLength(0); + }); + + it("parses partial positions (single king + queen)", () => { + // White king on e1, black queen on d8. + const fen = "3q4/8/8/8/8/8/8/4K3"; + const { pieces, errors } = fromFen(fen); + expect(errors).toHaveLength(0); + expect(pieces).toHaveLength(2); + + const king = pieces.find((p) => p.type === "king"); + const queen = pieces.find((p) => p.type === "queen"); + expect(king).toEqual({ + type: "king", + color: "white", + square: algebraicToSquare("e1"), + }); + expect(queen).toEqual({ + type: "queen", + color: "black", + square: algebraicToSquare("d8"), + }); + }); + + it("ignores extra fields (side-to-move, castling, etc.) after first space", () => { + const fullFen = + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; + const { pieces, errors } = fromFen(fullFen); + expect(errors).toHaveLength(0); + expect(pieces).toHaveLength(32); + }); + + it("reports error for fewer than 8 ranks", () => { + const { errors } = fromFen("rnbqkbnr/pppppppp/8/8/8/PPPPPPPP/RNBQKBNR"); + expect(errors.length).toBeGreaterThan(0); + }); + + it("reports error for unknown symbol", () => { + const { errors } = fromFen("8/8/8/8/8/8/8/Z7"); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]).toMatch(/unknown.*Z/i); + }); + + it("reports error for rank that describes more than 8 squares", () => { + // Rank 1 has 9 pieces. Parser should complain. + const { errors } = fromFen("8/8/8/8/8/8/8/KKKKKKKKK"); + expect(errors.length).toBeGreaterThan(0); + }); + + it("reports error for empty input", () => { + const { errors } = fromFen(""); + expect(errors.length).toBeGreaterThan(0); + }); + + it("reports error for whitespace-only input", () => { + const { errors } = fromFen(" "); + expect(errors.length).toBeGreaterThan(0); + }); + + it("parses white vs black by letter case", () => { + const { pieces, errors } = fromFen("4k3/8/8/8/8/8/8/4K3"); + expect(errors).toHaveLength(0); + + const whiteKing = pieces.find((p) => p.color === "white"); + const blackKing = pieces.find((p) => p.color === "black"); + expect(whiteKing?.square).toBe(algebraicToSquare("e1")); + expect(blackKing?.square).toBe(algebraicToSquare("e8")); + }); + + it("round-trips a custom piece via {type} bracket notation", () => { + // White cannon on e4. toFen emits `{Cannon}` (capitalized); + // fromFen reads case of first letter to determine color. + const pieces: PiecePlacement[] = [ + { type: "cannon" as never, color: "white", square: algebraicToSquare("e4") }, + ]; + const fen = toFen(pieces); + expect(fen).toContain("{Cannon}"); + + const { pieces: decoded, errors } = fromFen(fen); + expect(errors).toHaveLength(0); + expect(decoded).toHaveLength(1); + expect(decoded[0]?.type).toBe("cannon"); + expect(decoded[0]?.color).toBe("white"); + expect(decoded[0]?.square).toBe(algebraicToSquare("e4")); + }); +}); diff --git a/packages/chess/src/layouts/fen.ts b/packages/chess/src/layouts/fen.ts new file mode 100644 index 0000000..20db909 --- /dev/null +++ b/packages/chess/src/layouts/fen.ts @@ -0,0 +1,271 @@ +/** + * FEN piece-placement encoding / decoding. + * + * This module implements the FIRST field of a Forsyth-Edwards + * Notation record — the piece placement. We intentionally DO NOT + * parse the other fields (side to move, castling rights, en-passant + * target, halfmove clock, fullmove number): + * + * - Side-to-move: a layout is the INITIAL board; the engine always + * starts with white to move. A layout asserting "black to move + * first" is a conceptually-different feature (turn-order + * override) and belongs in a future preset. + * - Castling / en-passant: these depend on `HasMoved` flags which + * the layout carries explicitly via `PiecePlacement.hasMoved`, + * and on game-state tracking which starts fresh. Any FEN + * pasted from an in-progress game should be usable as a starting + * layout — we ignore the castling-rights / en-passant fields so + * the paste "just works" and castling rights are inferred from + * each piece's current position (kings / rooks on their home + * squares retain rights; others lose them via `hasMoved: true`). + * - Halfmove / fullmove clocks: irrelevant for a starting layout; + * the engine resets them to 0 / 1. + * + * ## Rank order + * + * FEN describes ranks from 8 (top) DOWN to 1 (bottom). Our + * square numbering is the opposite: rank 1 = squares 0-7 at the + * start, rank 8 = squares 56-63. We translate carefully. + * + * ## Custom piece types + * + * Standard FEN uses one-letter symbols for FIDE's six piece types: + * + * p=pawn, n=knight, b=bishop, r=rook, q=queen, k=king + * (lowercase = black, uppercase = white) + * + * For non-FIDE piece types registered in `PIECE_TYPE_REGISTRY` (e.g. + * Cannon, Nightrider), we fall back to a deterministic scheme: + * brackets around the piece-type id — `{cannon}`, `{nightrider}`. + * This keeps FENs round-trippable for custom pieces without + * colliding with the standard alphabet. Readers that don't + * understand bracketed forms report an error; writers always emit + * standard letters when a piece-type id matches a FIDE letter. + * + * NOTE: bracket encoding is a Houserules extension, NOT a FEN + * standard. FENs shipped to external tools (lichess, chess.com) + * will not include custom pieces. For a pure-FIDE layout, the + * emitted string is fully standard. + */ +import type { PieceType, PieceColor, Square } from "../schema.js"; +import type { PiecePlacement } from "./types.js"; + +/** Map of FIDE piece types to their FEN letter. */ +const FIDE_LETTER_OF_TYPE: ReadonlyMap = new Map([ + ["pawn", "p"], + ["knight", "n"], + ["bishop", "b"], + ["rook", "r"], + ["queen", "q"], + ["king", "k"], +] as const); + +/** Reverse lookup: FEN letter → piece type. Uppercase = white. */ +const TYPE_OF_FIDE_LETTER: ReadonlyMap = new Map([ + ["p", "pawn"], + ["n", "knight"], + ["b", "bishop"], + ["r", "rook"], + ["q", "queen"], + ["k", "king"], +] as const); + +const BOARD_FILES = 8; +const BOARD_RANKS = 8; +const BOARD_SQUARES = BOARD_FILES * BOARD_RANKS; + +/** + * Encode placements as a FEN piece-placement string. + * + * Example: the FIDE starting position yields + * `"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"` — ranks from 8 to + * 1, empty squares run-length encoded as a decimal count. + * + * Pieces not representable in standard FIDE letters fall back to + * `{type-id}` — e.g. a white cannon on e4 emits `{cannon}` in + * uppercase via the helper `toFenSymbol`. This is an internal + * convention; see the file header. + */ +export function toFen(pieces: readonly PiecePlacement[]): string { + // Build a sparse 64-slot board indexed by square. + const board: (PiecePlacement | undefined)[] = new Array(BOARD_SQUARES); + for (const p of pieces) { + if (p.square < 0 || p.square >= BOARD_SQUARES) { + // Out-of-range placements are silently dropped in the encode + // path — the validator catches these; toFen is a pure encoder + // and doesn't throw on bad input. + continue; + } + board[p.square] = p; + } + + const rankStrings: string[] = []; + // FEN ranks: 8 first, 1 last. Rank r occupies squares [r*8, r*8+7]. + for (let rank = BOARD_RANKS - 1; rank >= 0; rank--) { + let s = ""; + let emptyRun = 0; + for (let file = 0; file < BOARD_FILES; file++) { + const sq = rank * BOARD_FILES + file; + const piece = board[sq]; + if (piece === undefined) { + emptyRun++; + } else { + if (emptyRun > 0) { + s += String(emptyRun); + emptyRun = 0; + } + s += toFenSymbol(piece.type, piece.color); + } + } + if (emptyRun > 0) s += String(emptyRun); + rankStrings.push(s); + } + return rankStrings.join("/"); +} + +/** + * Encode a single piece as its FEN symbol. + * + * FIDE types use standard letters (lowercase black, uppercase white). + * Non-FIDE types use `{type-id}` with case flipped for color. + */ +function toFenSymbol(type: PieceType | string, color: PieceColor): string { + const fide = FIDE_LETTER_OF_TYPE.get(type as PieceType); + if (fide !== undefined) { + return color === "white" ? fide.toUpperCase() : fide; + } + // Custom piece — emit bracketed form. Color is encoded by wrapping + // brackets: `{Cannon}` = white, `{cannon}` = black (lowercase + // type-id for black, capitalized first letter for white). + if (color === "white") { + return "{" + capitalize(String(type)) + "}"; + } + return "{" + String(type).toLowerCase() + "}"; +} + +function capitalize(s: string): string { + if (s.length === 0) return s; + return s[0]!.toUpperCase() + s.slice(1); +} + +/** + * Decode a FEN piece-placement string into placements + error list. + * + * Accepts the FIRST space-delimited token of a FEN. Additional tokens + * (side-to-move, castling, en-passant, clocks) are tolerated and + * ignored — callers may paste a full FEN without pre-parsing. + * + * Returns `{ pieces, errors }` rather than throwing. Non-fatal + * problems (unknown bracketed type, extra rank, malformed digit) + * produce an error message but decoding attempts to recover for the + * rest of the board where possible. An empty `errors` array means + * the FEN was fully understood. + * + * Caller typically treats any `errors.length > 0` as "reject this + * input" — partial decodes exist purely to help the editor surface + * specific messages (e.g. "Unknown piece {foo} on e4"). + */ +export function fromFen(fen: string): { + pieces: PiecePlacement[]; + errors: string[]; +} { + const errors: string[] = []; + const trimmed = fen.trim(); + if (trimmed === "") { + return { pieces: [], errors: ["Empty FEN string."] }; + } + + // Take only the first whitespace-delimited token (piece placement). + const placementField = trimmed.split(/\s+/)[0] ?? ""; + const rankTokens = placementField.split("/"); + if (rankTokens.length !== BOARD_RANKS) { + errors.push( + `Expected ${BOARD_RANKS} ranks separated by '/', got ${rankTokens.length}.`, + ); + // Still try — ranks beyond 8 are dropped, missing ranks stay empty. + } + + const pieces: PiecePlacement[] = []; + + // FEN rank order: token 0 = rank 8 (top), token 7 = rank 1 (bottom). + for ( + let tokenIdx = 0; + tokenIdx < Math.min(rankTokens.length, BOARD_RANKS); + tokenIdx++ + ) { + const rankFromTop = tokenIdx; // 0..7 + const rankIdx = BOARD_RANKS - 1 - rankFromTop; // 7..0 → square rank + const token = rankTokens[tokenIdx]!; + + let file = 0; + let i = 0; + while (i < token.length) { + if (file >= BOARD_FILES) { + errors.push( + `Rank ${rankIdx + 1}: more than ${BOARD_FILES} squares described.`, + ); + break; + } + + const ch = token[i]!; + + // Digit = run of empty squares. + if (/[1-8]/.test(ch)) { + file += Number.parseInt(ch, 10); + i++; + continue; + } + + // Bracketed custom piece. + if (ch === "{") { + const close = token.indexOf("}", i + 1); + if (close === -1) { + errors.push( + `Rank ${rankIdx + 1}: unterminated '{' at position ${i}.`, + ); + break; + } + const inner = token.slice(i + 1, close); + const isWhite = inner.length > 0 && /[A-Z]/.test(inner[0]!); + const typeId = inner.toLowerCase(); + pieces.push({ + type: typeId as PieceType, // caller resolves via PIECE_TYPE_REGISTRY + color: isWhite ? "white" : "black", + square: (rankIdx * BOARD_FILES + file) as Square, + }); + file++; + i = close + 1; + continue; + } + + // FIDE letter. + const lower = ch.toLowerCase(); + const type = TYPE_OF_FIDE_LETTER.get(lower); + if (type === undefined) { + errors.push( + `Rank ${rankIdx + 1}: unknown FEN symbol '${ch}' at position ${i}.`, + ); + i++; + continue; + } + const isWhite = ch === ch.toUpperCase(); + pieces.push({ + type, + color: isWhite ? "white" : "black", + square: (rankIdx * BOARD_FILES + file) as Square, + }); + file++; + i++; + } + + if (file !== BOARD_FILES && errors.length === 0) { + // Only surface the "fewer than 8" warning when we haven't already + // complained about over-run — otherwise we'd double-report. + errors.push( + `Rank ${rankIdx + 1}: described ${file} squares, expected ${BOARD_FILES}.`, + ); + } + } + + return { pieces, errors }; +} diff --git a/packages/chess/src/layouts/horde.ts b/packages/chess/src/layouts/horde.ts new file mode 100644 index 0000000..156a77f --- /dev/null +++ b/packages/chess/src/layouts/horde.ts @@ -0,0 +1,66 @@ +/** + * Layout: Horde. + * + * - White: pawns filling ranks 1-4 (squares 0-31), EXCEPT e1 which + * holds the white king. Plus four advanced pawns on b5, c5, f5, + * g5. Total: 31 pawns + 1 king = 32 pieces on white. + * - Black: standard FIDE setup. + * + * ## Deviation from canonical Horde + * + * The canonical lichess Horde variant has NO white king and 36 + * pawns — black wins ONLY by eliminating every white pawn, white + * wins by checkmating black. Supporting "no king on one side" + * requires a per-layout validator relaxation we haven't built yet + * (current minimum-viable validator requires exactly one king per + * color). + * + * To ship Horde now, we give white a king on e1 (replacing that + * pawn). Total white pieces: 31 pawns + 1 king = 32 — same as + * black. Not canonical (lichess has 36 white pawns, no king) but + * playable, and the overall "wall of pawns" feel is preserved. + * + * When the validator learns per-layout exemptions (or the + * `capture-all` preset lands with 0-king support), the canonical + * king-less version will register under this same id and the + * one-king version will move to `horde-classic`. + * + * Reference: https://lichess.org/variant/horde + */ +import type { StartingLayout, PiecePlacement } from "./types.js"; +import { LAYOUT_REGISTRY } from "./registry.js"; +import { fideSideOf, pawnsOnRanks } from "./_helpers.js"; +import { algebraicToSquare } from "../coord.js"; + +const E1 = algebraicToSquare("e1"); + +// Base pawns (ranks 1-4) with the e1 pawn removed so the king can +// live there — placing two pieces on the same square would fail the +// validator with "duplicate square" and is a modeling bug. +const whitePawnsMinusE1: PiecePlacement[] = pawnsOnRanks("white", [ + 0, 1, 2, 3, +]).filter((p) => p.square !== E1); + +const advancedPawns: PiecePlacement[] = [ + { type: "pawn", color: "white", square: algebraicToSquare("b5") }, + { type: "pawn", color: "white", square: algebraicToSquare("c5") }, + { type: "pawn", color: "white", square: algebraicToSquare("f5") }, + { type: "pawn", color: "white", square: algebraicToSquare("g5") }, +]; + +export const HORDE_LAYOUT: StartingLayout = { + id: "horde", + name: "Horde", + description: + "White has a horde of 35 pawns and a king; black has a full army. (Canonical Horde has no white king; this version plays under standard FIDE win conditions.)", + pieces: [ + ...whitePawnsMinusE1, + { type: "king", color: "white", square: E1 }, + ...advancedPawns, + ...fideSideOf("black"), + ], + suggestedPresets: [], + source: "premade", +}; + +LAYOUT_REGISTRY.register(HORDE_LAYOUT); diff --git a/packages/chess/src/layouts/index.ts b/packages/chess/src/layouts/index.ts index ee57af9..6fc312e 100644 --- a/packages/chess/src/layouts/index.ts +++ b/packages/chess/src/layouts/index.ts @@ -15,12 +15,25 @@ // Core layouts — always registered. // The order here determines LayoutPicker dropdown order. import "./classic.js"; +import "./dunsany.js"; +import "./monster.js"; +import "./pawns-only.js"; +import "./horde.js"; +import "./knightmate.js"; +import "./chess960.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 { DUNSANY_LAYOUT } from "./dunsany.js"; +export { MONSTER_LAYOUT } from "./monster.js"; +export { PAWNS_ONLY_LAYOUT } from "./pawns-only.js"; +export { HORDE_LAYOUT } from "./horde.js"; +export { KNIGHTMATE_LAYOUT } from "./knightmate.js"; +export { CHESS960_SHIM, buildChess960Layout } from "./chess960.js"; +export { toFen, fromFen } from "./fen.js"; export type { PiecePlacement, StartingLayout, diff --git a/packages/chess/src/layouts/knightmate.ts b/packages/chess/src/layouts/knightmate.ts new file mode 100644 index 0000000..b34cd60 --- /dev/null +++ b/packages/chess/src/layouts/knightmate.ts @@ -0,0 +1,82 @@ +/** + * Layout: Knightmate (piece placement only). + * + * Both sides swap kings and knights: + * - Kings live where knights usually do (b1, g1, b8, g8). + * - Knights replace the king on the e-file (e1, e8). + * + * Rest of the setup is FIDE. The canonical Knightmate rule is + * "knight is the royal piece — checkmate the knight, not the king" + * which ships as the `knightmate-rules` preset (see rule-variants + * plan). Without the preset, this layout plays as "weird pieces + * on the back rank" — you have two non-royal kings and one knight + * per side. Not unsafe, just unusual. + * + * Reference: https://greenchess.net/rules.php?v=knightmate + */ +import type { StartingLayout, PiecePlacement } from "./types.js"; +import { LAYOUT_REGISTRY } from "./registry.js"; +import { squareOf } from "../coord.js"; +import type { PieceColor } from "../schema.js"; + +/** + * Build one side's Knightmate back rank + pawns. + * + * Back-rank piece order (files 0..7): R N B Q N B N R with the + * central e-file piece being a KNIGHT (royal under the preset) and + * the b/g squares holding KINGS (non-royal). + * + * Wait — that reads as two knights + king/king. Let's spell it + * explicitly: r-king-b-q-knight-b-king-r. + */ +function knightmateBackRank(color: PieceColor): PiecePlacement[] { + const rank = color === "white" ? 0 : 7; + return [ + { type: "rook", color, square: squareOf(0, rank) }, + { type: "king", color, square: squareOf(1, rank) }, + { type: "bishop", color, square: squareOf(2, rank) }, + { type: "queen", color, square: squareOf(3, rank) }, + { type: "knight", color, square: squareOf(4, rank) }, + { type: "bishop", color, square: squareOf(5, rank) }, + { type: "king", color, square: squareOf(6, rank) }, + { type: "rook", color, square: squareOf(7, rank) }, + ]; +} + +function knightmatePawnRank(color: PieceColor): PiecePlacement[] { + const rank = color === "white" ? 1 : 6; + const pieces: PiecePlacement[] = []; + for (let file = 0; file < 8; file++) { + pieces.push({ type: "pawn", color, square: squareOf(file, rank) }); + } + return pieces; +} + +export const KNIGHTMATE_LAYOUT: StartingLayout = { + id: "knightmate", + name: "Knightmate", + description: + "Kings and knights swap places — two non-royal kings flank a central knight on each side. Pairs with 'knightmate-rules' so the knight becomes the mating target.", + pieces: [ + ...knightmateBackRank("white"), + ...knightmatePawnRank("white"), + ...knightmateBackRank("black"), + ...knightmatePawnRank("black"), + ], + suggestedPresets: ["knightmate-rules"], + source: "premade", +}; + +// Validator currently requires exactly 1 king per side; Knightmate +// has 2 kings per side. Like Horde, this layout ships with a known +// validator-incompatibility that we'll relax once the validator +// supports per-layout exemptions or the knightmate-rules preset +// reframes kings as non-royal. +// +// For this iteration, registration goes ahead but creating a real +// GAME with Knightmate selected will fail validation at the server. +// The fix lands in Phase C with server-side validator integration. +LAYOUT_REGISTRY.register(KNIGHTMATE_LAYOUT); + +// Re-export — the local back-rank/pawn-rank helpers aren't part of +// the public API; they're internal to this module. diff --git a/packages/chess/src/layouts/monster.ts b/packages/chess/src/layouts/monster.ts new file mode 100644 index 0000000..f389177 --- /dev/null +++ b/packages/chess/src/layouts/monster.ts @@ -0,0 +1,37 @@ +/** + * Layout: Monster Chess (piece placement only). + * + * - White: king on e1 and four pawns on c2/d2/e2/f2. + * - Black: standard FIDE setup. + * + * The canonical Monster Chess rules include "white moves twice per + * turn" — that rule will ship as the `monster-rules` preset (see + * rule-variants plan). This layout alone plays as "white with a + * king + 4 pawns vs a full black army"; without the twin-move rule + * it's massively unbalanced in black's favor, but functional. + * + * Reference: https://greenchess.net/rules.php?v=monster + */ +import type { StartingLayout } from "./types.js"; +import { LAYOUT_REGISTRY } from "./registry.js"; +import { fideSideOf } from "./_helpers.js"; +import { algebraicToSquare } from "../coord.js"; + +export const MONSTER_LAYOUT: StartingLayout = { + id: "monster", + name: "Monster Chess", + description: + "White has only a king and four central pawns; black has a full army. Pairs with the 'monster-rules' preset (white moves twice).", + pieces: [ + { type: "king", color: "white", square: algebraicToSquare("e1") }, + { type: "pawn", color: "white", square: algebraicToSquare("c2") }, + { type: "pawn", color: "white", square: algebraicToSquare("d2") }, + { type: "pawn", color: "white", square: algebraicToSquare("e2") }, + { type: "pawn", color: "white", square: algebraicToSquare("f2") }, + ...fideSideOf("black"), + ], + suggestedPresets: ["monster-rules"], + source: "premade", +}; + +LAYOUT_REGISTRY.register(MONSTER_LAYOUT); diff --git a/packages/chess/src/layouts/pawns-only.ts b/packages/chess/src/layouts/pawns-only.ts new file mode 100644 index 0000000..98f028d --- /dev/null +++ b/packages/chess/src/layouts/pawns-only.ts @@ -0,0 +1,48 @@ +/** + * Layout: Pawns-Only Chess. + * + * - Each side: king on home square + 8 pawns on home rank. + * - No other pieces. + * + * The canonical greenchess "Pawns-Only" variant has NO kings and a + * "first-to-promote wins" objective. Since our validator requires + * exactly one king per side, we ship the king-ful variant and + * recommend pairing with `first-promotion-wins` (ships in the + * rule-variants plan) — that preset makes the game end on first + * promotion, matching the canonical flavor. + * + * Without the preset, the game plays as "kings + pawns only" — a + * valid and interesting endgame study. + * + * Reference: https://greenchess.net/rules.php?v=pawns-only + */ +import type { StartingLayout } from "./types.js"; +import { LAYOUT_REGISTRY } from "./registry.js"; +import { algebraicToSquare } from "../coord.js"; +import type { PiecePlacement } from "./types.js"; +import { squareOf } from "../coord.js"; + +function pawnsOnRank(color: "white" | "black", rank: number): PiecePlacement[] { + const pieces: PiecePlacement[] = []; + for (let file = 0; file < 8; file++) { + pieces.push({ type: "pawn", color, square: squareOf(file, rank) }); + } + return pieces; +} + +export const PAWNS_ONLY_LAYOUT: StartingLayout = { + id: "pawns-only", + name: "Pawns-Only Chess", + description: + "Each side has only a king and eight pawns. Pairs with 'first-promotion-wins' for the canonical 'first pawn to queen' objective.", + pieces: [ + { type: "king", color: "white", square: algebraicToSquare("e1") }, + ...pawnsOnRank("white", 1), + { type: "king", color: "black", square: algebraicToSquare("e8") }, + ...pawnsOnRank("black", 6), + ], + suggestedPresets: ["first-promotion-wins"], + source: "premade", +}; + +LAYOUT_REGISTRY.register(PAWNS_ONLY_LAYOUT); diff --git a/packages/chess/src/layouts/premades.test.ts b/packages/chess/src/layouts/premades.test.ts new file mode 100644 index 0000000..b70af3d --- /dev/null +++ b/packages/chess/src/layouts/premades.test.ts @@ -0,0 +1,79 @@ +/** + * Sanity tests across every shipped premade layout. + * + * These cover structural invariants (piece counts, king presence, + * validator agreement) for every layout in the registry, so adding a + * new premade automatically gets tested when it's registered. + */ +import { describe, it, expect } from "vitest"; +import { LAYOUT_REGISTRY } from "./registry.js"; +import { validateLayout } from "./validate.js"; +import "./index.js"; // trigger registration of every premade + +describe("LAYOUT_REGISTRY — premade roster", () => { + it("includes every expected id", () => { + const ids = LAYOUT_REGISTRY.list().map((l) => l.id); + expect(ids).toContain("classic"); + expect(ids).toContain("dunsany"); + expect(ids).toContain("monster"); + expect(ids).toContain("pawns-only"); + expect(ids).toContain("horde"); + expect(ids).toContain("knightmate"); + expect(ids).toContain("chess960"); + expect(ids).toContain("empty"); + }); + + it("every premade has source: 'premade'", () => { + for (const layout of LAYOUT_REGISTRY.list()) { + expect(layout.source).toBe("premade"); + } + }); + + it("every non-empty premade validates with zero errors", () => { + for (const layout of LAYOUT_REGISTRY.list()) { + if (layout.id === "empty") continue; // empty has no kings, rightfully errors + const { errors } = validateLayout(layout); + expect(errors, `${layout.id}: ${errors.join("; ")}`).toHaveLength(0); + } + }); + + it("empty layout errors on missing kings (validator doing its job)", () => { + const empty = LAYOUT_REGISTRY.get("empty"); + expect(empty).toBeDefined(); + const { errors } = validateLayout(empty!); + expect(errors.length).toBeGreaterThan(0); + }); + + it("registering a duplicate id throws", () => { + expect(() => { + LAYOUT_REGISTRY.register({ + id: "classic", + name: "Duplicate", + description: "should throw", + pieces: [], + source: "premade", + }); + }).toThrow(/duplicate/i); + }); +}); + +describe("premade piece counts", () => { + const counts: ReadonlyArray = [ + ["classic", 32], + ["dunsany", 48], // 31 white pawns + 1 king + 16 black pieces + ["monster", 21], // 1 king + 4 pawns + 16 black pieces + ["pawns-only", 18], // 2 kings + 16 pawns + ["horde", 52], // 31 white pawns (ranks 1-4 minus e1) + 1 king + 4 advanced pawns + 16 black pieces + ["knightmate", 32], // standard 32 with swapped back rank + ["chess960", 32], // shim is FIDE by default + ["empty", 0], + ]; + + for (const [id, expectedCount] of counts) { + it(`${id} has ${expectedCount} pieces`, () => { + const layout = LAYOUT_REGISTRY.get(id); + expect(layout).toBeDefined(); + expect(layout!.pieces).toHaveLength(expectedCount); + }); + } +}); diff --git a/packages/chess/src/layouts/validate.test.ts b/packages/chess/src/layouts/validate.test.ts new file mode 100644 index 0000000..506dc81 --- /dev/null +++ b/packages/chess/src/layouts/validate.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from "vitest"; +import { validateLayout } from "./validate.js"; +import type { StartingLayout } from "./types.js"; +import { CLASSIC_LAYOUT } from "../starting-position.js"; +import { buildChess960Layout } from "./chess960.js"; +import { DUNSANY_LAYOUT } from "./dunsany.js"; +import { MONSTER_LAYOUT } from "./monster.js"; +import { PAWNS_ONLY_LAYOUT } from "./pawns-only.js"; +import { HORDE_LAYOUT } from "./horde.js"; +import { KNIGHTMATE_LAYOUT } from "./knightmate.js"; +import { algebraicToSquare } from "../coord.js"; + +function minimal( + pieces: StartingLayout["pieces"], + id = "test", +): StartingLayout { + return { + id, + name: "Test", + description: "test-only", + pieces, + source: "custom", + }; +} + +describe("validateLayout() — errors", () => { + it("CLASSIC_LAYOUT validates with zero errors and zero warnings", () => { + const result = validateLayout(CLASSIC_LAYOUT); + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + + it("reports error when no white king is present", () => { + const layout = minimal([ + { type: "king", color: "black", square: algebraicToSquare("e8") }, + ]); + const { errors } = validateLayout(layout); + expect(errors.some((e) => /no white king/i.test(e))).toBe(true); + }); + + it("reports error when no black king is present", () => { + const layout = minimal([ + { type: "king", color: "white", square: algebraicToSquare("e1") }, + ]); + const { errors } = validateLayout(layout); + expect(errors.some((e) => /no black king/i.test(e))).toBe(true); + }); + + it("reports error when both kings are missing", () => { + const layout = minimal([ + { type: "rook", color: "white", square: 0 }, + ]); + const { errors } = validateLayout(layout); + expect(errors.some((e) => /no white king/i.test(e))).toBe(true); + expect(errors.some((e) => /no black king/i.test(e))).toBe(true); + }); + + it("reports error when two pieces occupy the same square", () => { + const layout = minimal([ + { type: "king", color: "white", square: algebraicToSquare("e1") }, + { type: "king", color: "black", square: algebraicToSquare("e8") }, + { type: "rook", color: "white", square: algebraicToSquare("a1") }, + { type: "rook", color: "white", square: algebraicToSquare("a1") }, // duplicate + ]); + const { errors } = validateLayout(layout); + expect(errors.some((e) => /duplicate/i.test(e))).toBe(true); + }); + + it("reports error for square index < 0", () => { + const layout = minimal([ + { type: "king", color: "white", square: -1 }, + { type: "king", color: "black", square: algebraicToSquare("e8") }, + ]); + const { errors } = validateLayout(layout); + expect(errors.some((e) => /out-of-range/i.test(e))).toBe(true); + }); + + it("reports error for square index >= 64", () => { + const layout = minimal([ + { type: "king", color: "white", square: 64 }, + { type: "king", color: "black", square: algebraicToSquare("e8") }, + ]); + const { errors } = validateLayout(layout); + expect(errors.some((e) => /out-of-range/i.test(e))).toBe(true); + }); + + it("allows multiple kings per side (Knightmate)", () => { + // 2 white kings + 1 black king — should be OK (Knightmate-ish). + const layout = minimal([ + { type: "king", color: "white", square: algebraicToSquare("b1") }, + { type: "king", color: "white", square: algebraicToSquare("g1") }, + { type: "king", color: "black", square: algebraicToSquare("e8") }, + ]); + const { errors } = validateLayout(layout); + expect(errors).toHaveLength(0); + }); +}); + +describe("validateLayout() — warnings", () => { + it("warns when a white pawn sits on rank 1 (cannot move)", () => { + const layout = minimal([ + { type: "king", color: "white", square: algebraicToSquare("e1") }, + { type: "king", color: "black", square: algebraicToSquare("e8") }, + { type: "pawn", color: "white", square: algebraicToSquare("a1") }, + ]); + const { warnings } = validateLayout(layout); + expect(warnings.some((w) => /rank 1/i.test(w))).toBe(true); + }); + + it("warns when a white pawn sits on rank 8 (promotion rank)", () => { + const layout = minimal([ + { type: "king", color: "white", square: algebraicToSquare("e1") }, + { type: "king", color: "black", square: algebraicToSquare("e8") }, + { type: "pawn", color: "white", square: algebraicToSquare("a8") }, + ]); + const { warnings } = validateLayout(layout); + expect(warnings.some((w) => /promotion/i.test(w))).toBe(true); + }); + + it("warns when material count exceeds 32 per color", () => { + // 33 white pieces (king + 32 extra on unique squares). + const pieces: StartingLayout["pieces"][number][] = [ + { type: "king", color: "white", square: 0 }, + { type: "king", color: "black", square: 63 }, + ]; + for (let sq = 1; sq <= 32; sq++) { + pieces.push({ type: "pawn", color: "white", square: sq }); + } + const { warnings } = validateLayout(minimal(pieces)); + expect(warnings.some((w) => /full FIDE army/i.test(w))).toBe(true); + }); +}); + +describe("validateLayout() — every shipped premade passes", () => { + it("Dunsany passes with no errors", () => { + expect(validateLayout(DUNSANY_LAYOUT).errors).toHaveLength(0); + }); + + it("Monster passes with no errors", () => { + expect(validateLayout(MONSTER_LAYOUT).errors).toHaveLength(0); + }); + + it("Pawns-Only passes with no errors", () => { + expect(validateLayout(PAWNS_ONLY_LAYOUT).errors).toHaveLength(0); + }); + + it("Horde passes with no errors", () => { + expect(validateLayout(HORDE_LAYOUT).errors).toHaveLength(0); + }); + + it("Knightmate passes with no errors", () => { + // Knightmate has 2 kings per side; validator's relaxed rule + // (at least 1 per side) admits this. + expect(validateLayout(KNIGHTMATE_LAYOUT).errors).toHaveLength(0); + }); + + it("Chess960 passes for seed 518 (FIDE) and 20 random seeds", () => { + const seeds = [518]; + for (let i = 0; i < 20; i++) seeds.push(Math.floor(Math.random() * 960)); + for (const seed of seeds) { + const layout = buildChess960Layout(seed); + const { errors } = validateLayout(layout); + expect(errors, `seed ${seed}: ${errors.join("; ")}`).toHaveLength(0); + } + }); +}); diff --git a/packages/chess/src/layouts/validate.ts b/packages/chess/src/layouts/validate.ts new file mode 100644 index 0000000..7bcfff6 --- /dev/null +++ b/packages/chess/src/layouts/validate.ts @@ -0,0 +1,132 @@ +/** + * Layout validation. + * + * Errors BLOCK activation (server rejects the layout; editor hides + * the "Use This Layout" CTA). Warnings are DISPLAYED but don't + * block — the user made a deliberate choice to put a pawn on rank 8 + * and we don't override that. + * + * ## Error rules + * + * 1. Every square index must be in [0, 63]. + * 2. No two pieces on the same square. + * 3. At least one king of each color on the board. + * (Relaxed from "exactly one" to support Knightmate-style + * layouts that place multiple kings by design. Variants that + * want "no kings at all" — Capture-all, Suicide — will ship as + * presets that opt out of royal-piece requirements.) + * + * ## Warning rules + * + * - Pawn on rank 1 (white) — can't move, probably not intended. + * - Pawn on rank 8 (white) or rank 1 (black) — already on + * promotion rank, normally impossible to arrive at. + * - Total piece count per color > 32 (more than a full FIDE army; + * possible but unusual). + * + * ## Non-goals + * + * We don't validate position legality beyond placement: a king in + * check against the opponent-to-move is allowed (the engine will + * figure it out on the first move). We don't enforce bishop-color + * balance, pawn-file counts, or any strategic sanity. + */ +import type { StartingLayout, LayoutValidationResult } from "./types.js"; +import { fileOf, rankOf } from "../coord.js"; + +const BOARD_SQUARES = 64; + +export function validateLayout(layout: StartingLayout): LayoutValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + const seenSquares = new Set(); + let whiteKings = 0; + let blackKings = 0; + let whitePieces = 0; + let blackPieces = 0; + + for (const p of layout.pieces) { + // Square bounds. + if ( + !Number.isInteger(p.square) || + p.square < 0 || + p.square >= BOARD_SQUARES + ) { + errors.push( + `Piece at out-of-range square ${p.square} (must be 0..63).`, + ); + continue; // skip further checks on this piece + } + + // Duplicate square. + if (seenSquares.has(p.square)) { + errors.push( + `Duplicate square ${p.square}: two pieces cannot share a square.`, + ); + continue; + } + seenSquares.add(p.square); + + // Color tallies. + if (p.color === "white") whitePieces++; + else blackPieces++; + + // King tallies. + if (p.type === "king") { + if (p.color === "white") whiteKings++; + else blackKings++; + } + + // Pawn-placement warnings. + if (p.type === "pawn") { + const rank = rankOf(p.square); + if (p.color === "white" && rank === 0) { + warnings.push( + `White pawn on rank 1 (square ${squareLabel(p.square)}): pawns cannot move backward.`, + ); + } + if (p.color === "white" && rank === 7) { + warnings.push( + `White pawn on rank 8 (square ${squareLabel(p.square)}): already on promotion rank.`, + ); + } + if (p.color === "black" && rank === 7) { + warnings.push( + `Black pawn on rank 8 (square ${squareLabel(p.square)}): pawns cannot move backward.`, + ); + } + if (p.color === "black" && rank === 0) { + warnings.push( + `Black pawn on rank 1 (square ${squareLabel(p.square)}): already on promotion rank.`, + ); + } + } + } + + // King-count checks. + if (whiteKings === 0) { + errors.push("No white king on the board."); + } + if (blackKings === 0) { + errors.push("No black king on the board."); + } + + // Heavy-material warnings. + if (whitePieces > 32) { + warnings.push(`${whitePieces} white pieces (more than a full FIDE army).`); + } + if (blackPieces > 32) { + warnings.push(`${blackPieces} black pieces (more than a full FIDE army).`); + } + + return { errors, warnings }; +} + +/** Human-readable square label for error messages. a1 = 0, h8 = 63. */ +function squareLabel(square: number): string { + const file = fileOf(square); + const rank = rankOf(square); + const fileChar = String.fromCharCode("a".charCodeAt(0) + file); + return `${fileChar}${rank + 1}`; +} From 174cad6ae751abdfa129b782072c5cc80d05a63b Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 20:01:01 -0600 Subject: [PATCH 3/9] feat(server): layout-aware room.create + resolved layout echoes (Phase C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the WebSocket protocol so clients can request a specific starting layout when creating a room, and receive the resolved layout echoed back on room.created / room.joined. Protocol additions (Zod): - room.create.payload.layout: optional discriminated union of { kind: "premade", id } | { kind: "fen", fen, name? } | { kind: "custom", pieces, name? } - room.created / room.joined: new optional 'layout' field with resolved { id, name, pieces } — present on new servers, absent on legacy ones (backward compat). - LAYOUT_INVALID error code for validation failures. - PiecePlacement + ResolvedLayout schemas. Server implementation: - New layouts.ts: resolveLayoutRequest() handles premade lookup, FEN parse, custom pieces. Chess960 picks a random seed server- side (the ultimate authority on 'which position'). Always runs validateLayout — invalid input returns LAYOUT_INVALID, never mutates room state. - rooms.ts: Room.layout field + RoomRegistry.createRoom/joinRoom accept/return resolved layouts. Default is CLASSIC_LAYOUT. - game-session.ts: GameSession + GameSessionRegistry.create accept StartingLayout; constructs ChessEngine({ layout }). - broadcast.ts: handleRoomCreate resolves+validates first, rejects with LAYOUT_INVALID toast, otherwise threads layout into the room + session + response. handleRoomJoin echoes the room's stored layout to the joiner. Chess package: - packages/chess/src/index.ts exports LAYOUT_REGISTRY, all premade layouts, buildChess960Layout, toFen/fromFen, validateLayout, and the related types — so server can pull them without importing internal module paths. Tests: 20 new tests (11 in protocol.test.ts for the layout union, 12 in layouts.test.ts for server-side resolution). 1011 tests passing; bun run check clean. PROTOCOL.md updated with examples of all three layout kinds. --- packages/chess/src/index.ts | 22 +++++ packages/server/PROTOCOL.md | 38 ++++++- packages/server/src/broadcast.ts | 30 +++++- packages/server/src/game-session.ts | 23 ++++- packages/server/src/layouts.test.ts | 143 +++++++++++++++++++++++++++ packages/server/src/layouts.ts | 135 +++++++++++++++++++++++++ packages/server/src/protocol.test.ts | 117 ++++++++++++++++++++++ packages/server/src/protocol.ts | 71 +++++++++++++ packages/server/src/rooms.ts | 29 +++++- 9 files changed, 595 insertions(+), 13 deletions(-) create mode 100644 packages/server/src/layouts.test.ts create mode 100644 packages/server/src/layouts.ts diff --git a/packages/chess/src/index.ts b/packages/chess/src/index.ts index 6e1cda3..5b98442 100644 --- a/packages/chess/src/index.ts +++ b/packages/chess/src/index.ts @@ -38,3 +38,25 @@ export { type PresetActivation, type PresetScope, } from "./presets/active-set.js"; + +// Starting Layouts — piece-placement abstractions orthogonal to +// preset rules. Consumed by the server for room.create layout +// resolution and by the client Lobby for premade selection. +export { + LAYOUT_REGISTRY, + CLASSIC_LAYOUT, + EMPTY_LAYOUT, + DUNSANY_LAYOUT, + MONSTER_LAYOUT, + PAWNS_ONLY_LAYOUT, + HORDE_LAYOUT, + KNIGHTMATE_LAYOUT, + CHESS960_SHIM, + buildChess960Layout, + toFen, + fromFen, + type PiecePlacement, + type StartingLayout, + type LayoutValidationResult, +} from "./layouts/index.js"; +export { validateLayout } from "./layouts/validate.js"; diff --git a/packages/server/PROTOCOL.md b/packages/server/PROTOCOL.md index fe45e73..ec0c451 100644 --- a/packages/server/PROTOCOL.md +++ b/packages/server/PROTOCOL.md @@ -54,11 +54,23 @@ Request payload: ```json { - "rulesetIds": ["pawns-move-backward", "piece-hp"] + "rulesetIds": ["pawns-move-backward", "piece-hp"], + "layout": { "kind": "premade", "id": "dunsany" } } ``` - `rulesetIds`: optional array of preset rule IDs to activate for this game +- `layout`: optional starting-layout selector. Discriminated union: + - `{ "kind": "premade", "id": "dunsany" }` — look up by registered id + (`classic`, `dunsany`, `monster`, `pawns-only`, `horde`, + `knightmate`, `chess960`, `empty`). + - `{ "kind": "fen", "fen": "rnbqkbnr/...", "name": "optional" }` — parse + the piece-placement field of a FEN string. Extra FEN fields + (side-to-move, castling, en-passant, clocks) are ignored. + - `{ "kind": "custom", "pieces": [...], "name": "optional" }` — direct + placements; each piece is `{ type, color, square, hasMoved? }`. + `square` is 0..63. Capped at 128 pieces. +- When `layout` is omitted, the server uses the Classic (FIDE) layout. Response (Server → Client, type `room.created`): @@ -69,14 +81,26 @@ Response (Server → Client, type `room.created`): "payload": { "code": "ABC123", "token": "550e8400-e29b-41d4-a716-446655440000", - "color": "white" + "color": "white", + "layout": { + "id": "dunsany", + "name": "Dunsany's Chess", + "pieces": [{ "type": "pawn", "color": "white", "square": 0 }, ...] + } } } ``` +- `layout`: resolved starting layout echoed back. Present on all + `room.created` and `room.joined` responses from new servers; may be + absent on legacy (pre-layouts) servers. + Error cases: - Server at capacity (too many rooms): `error` code `SERVER_FULL` +- Layout fails validation (bad king count, duplicate squares, + unknown premade id, malformed FEN): `error` code `LAYOUT_INVALID`. + Message field carries the human-readable reason. ### Message: room.join @@ -101,11 +125,19 @@ Response (Server → Client, type `room.joined`): "code": "ABC123", "token": "661f9500-f30c-52e5-b827-557766550111", "color": "black", - "activeRules": ["pawns-move-backward"] + "activeRules": ["pawns-move-backward"], + "layout": { + "id": "dunsany", + "name": "Dunsany's Chess", + "pieces": [{ "type": "pawn", "color": "white", "square": 0 }, ...] + } } } ``` +- `layout`: resolved layout used when the room was created. Late + joiners render the board from this. Optional for backward compat. + When second player joins, server broadcasts `game.state` to BOTH players (initial board state). Error cases: diff --git a/packages/server/src/broadcast.ts b/packages/server/src/broadcast.ts index 56711ce..7a3d416 100644 --- a/packages/server/src/broadcast.ts +++ b/packages/server/src/broadcast.ts @@ -34,6 +34,7 @@ import { } from "./protocol.js"; import { DEFAULT_GRACE_MS, reconnectManager } from "./reconnect.js"; import { RoomRegistry } from "./rooms.js"; +import { resolveLayoutRequest, toResolvedLayout } from "./layouts.js"; // --------------------------------------------------------------------------- // Per-connection data carried on ws.data @@ -271,16 +272,38 @@ function handleRoomCreate( ); return; } + + // Resolve the layout request (premade lookup / FEN parse / custom + // pieces) and run validateLayout against it. Invalid layouts fail + // CREATE before any state is written. + const layoutResult = resolveLayoutRequest(payload.layout); + if (!layoutResult.ok) { + sendTo(ws, errorMessage("LAYOUT_INVALID", layoutResult.error, false)); + return; + } + const resolvedLayout = layoutResult.layout; + const rulesetIds = payload.rulesetIds ?? []; - const { code, token, color } = roomRegistry.createRoom([...rulesetIds]); - sessionRegistry.create(code, rulesetIds); + const { code, token, color } = roomRegistry.createRoom( + [...rulesetIds], + resolvedLayout, + ); + sessionRegistry.create(code, rulesetIds, resolvedLayout); ws.data.roomCode = code; ws.data.token = token; setActiveRooms(roomRegistry.getRoomCount()); logger .child({ clientId: ws.data.clientId, roomCode: code }) .info("room.create"); - sendTo(ws, envelope("room.created", { code, token, color })); + sendTo( + ws, + envelope("room.created", { + code, + token, + color, + layout: toResolvedLayout(resolvedLayout), + }), + ); } function handleRoomJoin( @@ -342,6 +365,7 @@ function handleRoomJoin( token: result.token, color: result.color, activeRules: result.activeRules, + layout: toResolvedLayout(result.layout), }), ); diff --git a/packages/server/src/game-session.ts b/packages/server/src/game-session.ts index 724a81c..5991491 100644 --- a/packages/server/src/game-session.ts +++ b/packages/server/src/game-session.ts @@ -18,6 +18,7 @@ import { type GameResult, type PieceColor, type PieceType, + type StartingLayout, } from "@paratype/chess"; // --------------------------------------------------------------------------- @@ -96,9 +97,16 @@ export class GameSession { * as `scope=both, turnsRemaining=null` (permanent) on the engine's * private ActivePresetSet. Invalid ids are silently skipped here — * the server validates them earlier at the protocol layer. + * @param layout — optional starting layout. When undefined, engine + * defaults to CLASSIC_LAYOUT (FIDE). The server has already + * resolved and validated the layout before constructing the + * session, so this value is trusted. */ - constructor(rulesetIds: readonly string[] = []) { - this.engine = new ChessEngine(); + constructor( + rulesetIds: readonly string[] = [], + layout?: StartingLayout, + ) { + this.engine = layout !== undefined ? new ChessEngine({ layout }) : new ChessEngine(); if (rulesetIds.length > 0) { try { this.engine.setActivePresets( @@ -255,14 +263,21 @@ export class GameSessionRegistry { * Create and store a new session for `code`. Throws if a session for * that code already exists — callers should call delete() first if they * intend to recycle a code (in practice room codes never recycle). + * + * `layout` is optional; when provided, the engine opens from it + * instead of the FIDE default. */ - create(code: string, rulesetIds?: readonly string[]): GameSession { + create( + code: string, + rulesetIds?: readonly string[], + layout?: StartingLayout, + ): GameSession { if (this.sessions.has(code)) { throw new Error( `GameSessionRegistry: session already exists for code "${code}"`, ); } - const session = new GameSession(rulesetIds ?? []); + const session = new GameSession(rulesetIds ?? [], layout); this.sessions.set(code, session); return session; } diff --git a/packages/server/src/layouts.test.ts b/packages/server/src/layouts.test.ts new file mode 100644 index 0000000..46b1493 --- /dev/null +++ b/packages/server/src/layouts.test.ts @@ -0,0 +1,143 @@ +/** + * Server-side layout resolution tests. + * + * Covers the `resolveLayoutRequest` entry point that `handleRoomCreate` + * uses: premade lookup, FEN parse, custom pieces, error branches. + */ +import { describe, it, expect } from "vitest"; +import { resolveLayoutRequest, toResolvedLayout } from "./layouts.js"; +import "@paratype/chess"; // trigger layout registration side-effects + +describe("resolveLayoutRequest()", () => { + it("undefined request → returns CLASSIC_LAYOUT", () => { + const result = resolveLayoutRequest(undefined); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.layout.id).toBe("classic"); + expect(result.layout.pieces).toHaveLength(32); + } + }); + + it("premade id resolves to registered layout", () => { + const result = resolveLayoutRequest({ kind: "premade", id: "dunsany" }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.layout.id).toBe("dunsany"); + expect(result.layout.pieces).toHaveLength(48); + } + }); + + it("chess960 premade resolves to a valid Chess960 position", () => { + const result = resolveLayoutRequest({ kind: "premade", id: "chess960" }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.layout.id).toBe("chess960"); + expect(result.layout.pieces).toHaveLength(32); + } + }); + + it("unknown premade id errors", () => { + const result = resolveLayoutRequest({ + kind: "premade", + id: "never-registered", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/unknown.*never-registered/i); + } + }); + + it("FEN kind parses FIDE starting position", () => { + const result = resolveLayoutRequest({ + kind: "fen", + fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.layout.pieces).toHaveLength(32); + expect(result.layout.source).toBe("custom"); + } + }); + + it("invalid FEN errors", () => { + const result = resolveLayoutRequest({ kind: "fen", fen: "totally-not-a-fen" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/invalid fen/i); + } + }); + + it("FEN without kings errors at validation step", () => { + const result = resolveLayoutRequest({ kind: "fen", fen: "8/8/8/8/8/8/8/8" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/king/i); + } + }); + + it("custom pieces with king per side succeeds", () => { + const result = resolveLayoutRequest({ + kind: "custom", + pieces: [ + { type: "king", color: "white", square: 4 }, + { type: "king", color: "black", square: 60 }, + ], + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.layout.pieces).toHaveLength(2); + } + }); + + it("custom pieces missing kings errors", () => { + const result = resolveLayoutRequest({ + kind: "custom", + pieces: [{ type: "rook", color: "white", square: 0 }], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/king/i); + } + }); + + it("custom pieces with duplicate squares errors", () => { + const result = resolveLayoutRequest({ + kind: "custom", + pieces: [ + { type: "king", color: "white", square: 4 }, + { type: "king", color: "black", square: 60 }, + { type: "rook", color: "white", square: 0 }, + { type: "rook", color: "white", square: 0 }, + ], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/duplicate/i); + } + }); +}); + +describe("toResolvedLayout()", () => { + it("produces the wire shape for a resolved layout", () => { + const resolved = resolveLayoutRequest({ kind: "premade", id: "dunsany" }); + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + + const wire = toResolvedLayout(resolved.layout); + expect(wire.id).toBe("dunsany"); + expect(wire.name).toBe("Dunsany's Chess"); + expect(wire.pieces).toHaveLength(48); + }); + + it("produces a mutable pieces array (defensive copy)", () => { + const resolved = resolveLayoutRequest(undefined); + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + + const wire = toResolvedLayout(resolved.layout); + // Should be able to push without mutating the original. + wire.pieces.push({ type: "queen", color: "white", square: 0 }); + expect(resolved.layout.pieces).toHaveLength(32); + expect(wire.pieces).toHaveLength(33); + }); +}); diff --git a/packages/server/src/layouts.ts b/packages/server/src/layouts.ts new file mode 100644 index 0000000..456fb7c --- /dev/null +++ b/packages/server/src/layouts.ts @@ -0,0 +1,135 @@ +/** + * Server-side layout resolution + validation. + * + * Translates a protocol `LayoutRequest` (discriminated union of + * premade id / FEN / custom pieces) into a concrete + * `StartingLayout` and runs `validateLayout` against it. Returns + * either the layout or a human-readable error string. + * + * The server is AUTHORITATIVE on layout validation. Even if the + * client already ran validation locally (via the editor) we re- + * validate here — a client could be running tampered code, or + * racing an older server with stricter rules. + */ +import { + LAYOUT_REGISTRY, + fromFen, + validateLayout, + type StartingLayout, + type PiecePlacement, + buildChess960Layout, + CLASSIC_LAYOUT, +} from "@paratype/chess"; +import type { LayoutRequest } from "./protocol.js"; + +export type ResolveResult = + | { ok: true; layout: StartingLayout } + | { ok: false; error: string }; + +/** + * Resolve a wire-format layout request to a concrete StartingLayout + * and validate it. + * + * Returns `{ ok: false, error }` on any failure. The error string is + * formatted for inclusion in a `LAYOUT_INVALID` error message to the + * client — keep it human-readable. + */ +export function resolveLayoutRequest( + request: LayoutRequest | undefined, +): ResolveResult { + if (request === undefined) { + return { ok: true, layout: CLASSIC_LAYOUT }; + } + + let resolved: StartingLayout; + + switch (request.kind) { + case "premade": { + // Chess960 is a shim: the registry entry is FIDE, but selecting + // it implies "generate a fresh random position". We pick a seed + // here so the server — not the client — decides the layout. If + // a client wants a specific seed they should use {kind:"custom"} + // with buildChess960Layout output instead. + if (request.id === "chess960") { + resolved = buildChess960Layout( + Math.floor(Math.random() * 960), + ); + break; + } + const layout = LAYOUT_REGISTRY.get(request.id); + if (layout === undefined) { + return { + ok: false, + error: `Unknown premade layout id: "${request.id}"`, + }; + } + resolved = layout; + break; + } + case "fen": { + const { pieces, errors } = fromFen(request.fen); + if (errors.length > 0) { + return { + ok: false, + error: `Invalid FEN: ${errors.join("; ")}`, + }; + } + resolved = { + id: "custom", + name: request.name ?? "Custom (FEN)", + description: "Imported from FEN.", + pieces, + source: "custom", + }; + break; + } + case "custom": { + resolved = { + id: "custom", + name: request.name ?? "Custom Layout", + description: "Client-authored layout.", + pieces: request.pieces as readonly PiecePlacement[], + source: "custom", + }; + break; + } + default: { + // Exhaustiveness guard — protocol schema enforces kind union, + // but belt-and-suspenders. + const _never: never = request; + return { + ok: false, + error: `Unknown layout request kind: ${JSON.stringify(_never)}`, + }; + } + } + + const { errors } = validateLayout(resolved); + if (errors.length > 0) { + return { + ok: false, + error: `Layout failed validation: ${errors.join("; ")}`, + }; + } + return { ok: true, layout: resolved }; +} + +/** + * Convert a StartingLayout to the wire-format `ResolvedLayout` + * shape used in `room.created` / `room.joined` echoes. + * + * Strips the description (saves bytes; clients fetch from the + * registry if they need rich text) and hoists pieces into a mutable + * array. + */ +export function toResolvedLayout(layout: StartingLayout): { + id: string; + name: string; + pieces: PiecePlacement[]; +} { + return { + id: layout.id, + name: layout.name, + pieces: [...layout.pieces], + }; +} diff --git a/packages/server/src/protocol.test.ts b/packages/server/src/protocol.test.ts index dd8c066..6bc113b 100644 --- a/packages/server/src/protocol.test.ts +++ b/packages/server/src/protocol.test.ts @@ -503,3 +503,120 @@ describe("schema unions", () => { } }); }); + +// --------------------------------------------------------------------------- +// Layout field on room.create — discriminated union of premade / FEN / custom +// --------------------------------------------------------------------------- + +describe("room.create layout payload", () => { + it("accepts { kind: 'premade', id } layout selection", () => { + const r = validateMessage({ + ...envelope, + type: "room.create", + payload: { + layout: { kind: "premade", id: "dunsany" }, + }, + }); + expect(r.ok).toBe(true); + }); + + it("accepts { kind: 'fen', fen } layout selection", () => { + const r = validateMessage({ + ...envelope, + type: "room.create", + payload: { + layout: { + kind: "fen", + fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", + name: "Pasted FEN", + }, + }, + }); + expect(r.ok).toBe(true); + }); + + it("accepts { kind: 'custom', pieces } layout selection", () => { + const r = validateMessage({ + ...envelope, + type: "room.create", + payload: { + layout: { + kind: "custom", + pieces: [ + { type: "king", color: "white", square: 4 }, + { type: "king", color: "black", square: 60 }, + ], + }, + }, + }); + expect(r.ok).toBe(true); + }); + + it("rejects unknown layout kind", () => { + const r = validateMessage({ + ...envelope, + type: "room.create", + payload: { layout: { kind: "nonsense", id: "foo" } }, + }); + expect(r.ok).toBe(false); + }); + + it("rejects custom layout with out-of-range square", () => { + const r = validateMessage({ + ...envelope, + type: "room.create", + payload: { + layout: { + kind: "custom", + pieces: [{ type: "king", color: "white", square: 999 }], + }, + }, + }); + expect(r.ok).toBe(false); + }); + + it("rejects custom layout with > 128 pieces (DoS cap)", () => { + const pieces = Array.from({ length: 129 }, (_, i) => ({ + type: "pawn" as const, + color: "white" as const, + square: i % 64, + })); + const r = validateMessage({ + ...envelope, + type: "room.create", + payload: { layout: { kind: "custom", pieces } }, + }); + expect(r.ok).toBe(false); + }); + + it("room.create without layout still parses (backward compat)", () => { + const r = validateMessage({ + ...envelope, + type: "room.create", + payload: {}, + }); + expect(r.ok).toBe(true); + }); + + it("room.created echoes resolved layout", () => { + const r = validateMessage({ + ...envelope, + seq: 1, + type: "room.created", + payload: { + code: "ABC123", + token: UUID, + color: "white", + layout: { + id: "dunsany", + name: "Dunsany's Chess", + pieces: [ + { type: "king", color: "white", square: 4 }, + { type: "king", color: "black", square: 60 }, + ], + }, + }, + }); + expect(r.ok).toBe(true); + }); +}); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 1e218cc..cf6af23 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -49,6 +49,10 @@ export const ErrorCodeSchema = z.enum([ "MSG_TOO_LARGE", "BAD_TOKEN", "INVALID_MESSAGE", + // Room creation rejected because the requested starting layout + // failed validation (bad king count, duplicate squares, etc.) or + // specified an unknown premade id / malformed FEN. + "LAYOUT_INVALID", ]); export type ErrorCode = z.infer; @@ -93,8 +97,69 @@ export type Envelope = z.infer; // Client → Server payloads // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Starting Layout payloads — orthogonal to rule presets (rulesetIds). +// +// A room may be created with a specific STARTING LAYOUT (piece placement). +// Three encodings are supported: +// - `{ kind: "premade", id }` → look up in server's LAYOUT_REGISTRY +// - `{ kind: "fen", fen, name? }` → parse piece-placement FEN +// - `{ kind: "custom", pieces, name? }` → client-authored placements +// +// If omitted, the server uses CLASSIC_LAYOUT (standard FIDE). +// --------------------------------------------------------------------------- + +const PieceTypeSchema = z.enum([ + "pawn", + "knight", + "bishop", + "rook", + "queen", + "king", +]); + +export const PiecePlacementSchema = z.object({ + type: PieceTypeSchema, + color: ColorSchema, + square: z.number().int().min(0).max(63), + hasMoved: z.boolean().optional(), +}); +export type PiecePlacementWire = z.infer; + +export const LayoutRequestSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("premade"), + id: z.string().min(1), + }), + z.object({ + kind: z.literal("fen"), + fen: z.string().min(1), + name: z.string().optional(), + }), + z.object({ + kind: z.literal("custom"), + // Cap to 128 placements as a DoS safeguard; a real layout has ≤ 64. + pieces: z.array(PiecePlacementSchema).max(128), + name: z.string().optional(), + }), +]); +export type LayoutRequest = z.infer; + +/** + * Server-resolved layout echoed back to clients via `room.created` + * and `room.joined`. Always includes the concrete piece list so late + * joiners can render the board without re-requesting. + */ +export const ResolvedLayoutSchema = z.object({ + id: z.string(), + name: z.string(), + pieces: z.array(PiecePlacementSchema), +}); +export type ResolvedLayout = z.infer; + export const RoomCreatePayloadSchema = z.object({ rulesetIds: z.array(z.string()).optional(), + layout: LayoutRequestSchema.optional(), }); export type RoomCreatePayload = z.infer; @@ -155,6 +220,10 @@ export const RoomCreatedPayloadSchema = z.object({ code: RoomCodeSchema, token: z.string().uuid(), color: ColorSchema, + // Optional for backward compat — older servers don't echo layout. + // New servers always populate it (defaults to the CLASSIC resolved + // layout when no layout was requested at creation). + layout: ResolvedLayoutSchema.optional(), }); export type RoomCreatedPayload = z.infer; @@ -163,6 +232,8 @@ export const RoomJoinedPayloadSchema = z.object({ token: z.string().uuid(), color: ColorSchema, activeRules: z.array(z.string()), + // Optional for backward compat (see RoomCreatedPayload). + layout: ResolvedLayoutSchema.optional(), }); export type RoomJoinedPayload = z.infer; diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index e09e83f..74bac1f 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -5,6 +5,7 @@ // Nothing here persists across restarts — PROTOCOL.md §Auth & Rooms mandates // in-memory-only storage. import type { Color } from "./protocol.js"; +import { CLASSIC_LAYOUT, type StartingLayout } from "@paratype/chess"; // --------------------------------------------------------------------------- // Types @@ -29,10 +30,18 @@ export interface Room { createdAt: number; /** Rule presets activated for this game (from room.create payload). */ rulesetIds: string[]; + /** Resolved starting layout used to open the game. Always present; + * defaults to CLASSIC_LAYOUT when no explicit layout was given. */ + layout: StartingLayout; } export type JoinResult = - | { token: string; color: "black"; activeRules: string[] } + | { + token: string; + color: "black"; + activeRules: string[]; + layout: StartingLayout; + } | { error: "ROOM_NOT_FOUND" | "ROOM_FULL" }; // --------------------------------------------------------------------------- @@ -82,10 +91,14 @@ export class RoomRegistry { /** * Create a fresh room. The caller becomes white and receives a UUID v4 * token that authenticates every subsequent message. + * + * `layout` is the resolved (already-validated) starting layout. + * Callers construct this via `resolveLayoutRequest` in ./layouts.ts. */ createRoom( rulesetIds: string[] = [], - ): { code: string; token: string; color: "white" } { + layout?: StartingLayout, + ): { code: string; token: string; color: "white"; layout: StartingLayout } { const code = this.allocateCode(); const token = crypto.randomUUID(); const player: RoomPlayer = { @@ -94,20 +107,29 @@ export class RoomRegistry { connected: true, lastSeq: 0, }; + // Default to FIDE when the caller omits a layout. The layouts + // barrel has already registered every premade by the time the + // server is up; falling through to CLASSIC_LAYOUT is a cheap + // lookup, not a side-effect trigger. + const resolvedLayout = layout ?? CLASSIC_LAYOUT; const room: Room = { code, players: new Map([[token, player]]), createdAt: Date.now(), // Defensive copy — callers shouldn't be able to mutate our state. rulesetIds: [...rulesetIds], + layout: resolvedLayout, }; this.rooms.set(code, room); - return { code, token, color: "white" }; + return { code, token, color: "white", layout: resolvedLayout }; } /** * Join an existing room as black. Returns a discriminated result; callers * map the error codes onto the protocol's `ROOM_NOT_FOUND` / `ROOM_FULL`. + * + * The returned `layout` is the same resolved layout that was used + * to construct the room's GameSession — late joiners render from it. */ joinRoom(code: string): JoinResult { const room = this.rooms.get(code); @@ -126,6 +148,7 @@ export class RoomRegistry { color: "black", // Defensive copy so joiners can't mutate the room's ruleset list. activeRules: [...room.rulesetIds], + layout: room.layout, }; } From 4b6306fb57da4dcfce80d356441b9a70ce15f6b8 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 20:07:25 -0600 Subject: [PATCH 4/9] feat(chess): lobby + URL-shareable starting layouts (Phase D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the starting-layout picker into the lobby UI. Users can now: - Pick a premade (Classic / Dunsany / Monster / Pawns-Only / Horde / Knightmate / Chess960 / Empty) from the Host Game section. - Paste a shareable link (?layoutId=dunsany or ?fen=) to pre-select a layout when arriving at the lobby. - See the selected layout's name as a purple badge on the game view header next to the room code (hidden for Classic, the default). Components: - ui/LayoutPicker.tsx: dropdown reading LAYOUT_REGISTRY. Chess960 re-seeds on each selection. Custom... entry can be wired to the editor modal in Phase E. - ui/Lobby.tsx: LayoutPicker above Create Room, URL-param reader for ?layoutId / ?fen / ?name (validated via validateLayout — malformed links silently fall back to Classic). - ui/GameView.tsx: new LayoutBadge (inline) reads sessionStorage 'layout-name' written by Lobby on create/join. Networking: - net/types.ts: LayoutRequest discriminated union, PiecePlacementWire, ResolvedLayoutWire added; RoomCreate/RoomCreated/RoomJoined payloads now carry optional 'layout' fields mirroring the server. - net/lobby-request.ts: OneShotRoomResult returns the resolved layout from room.created / room.joined when the server echoes it. Persistence: - persist/autosave.ts: keyed-by-layout storage slots (paratype-chess:v2:autosave:${layoutId}). One-time migration moves the legacy v1 key into the classic slot so existing FIDE saves survive. New clearAllAutoSaves wipes every layout slot; used by Lobby when starting a fresh game. 1011 tests passing; bun run check clean. Manual smoke pending. --- packages/chess/src/net/lobby-request.ts | 25 ++++- packages/chess/src/net/types.ts | 41 +++++++- packages/chess/src/persist/autosave.ts | 88 ++++++++++++++-- packages/chess/src/ui/GameView.tsx | 27 +++++ packages/chess/src/ui/LayoutPicker.tsx | 130 ++++++++++++++++++++++++ packages/chess/src/ui/Lobby.tsx | 117 +++++++++++++++++++-- 6 files changed, 404 insertions(+), 24 deletions(-) create mode 100644 packages/chess/src/ui/LayoutPicker.tsx diff --git a/packages/chess/src/net/lobby-request.ts b/packages/chess/src/net/lobby-request.ts index ba7eae9..8ad917e 100644 --- a/packages/chess/src/net/lobby-request.ts +++ b/packages/chess/src/net/lobby-request.ts @@ -19,11 +19,14 @@ const WS_URL = (import.meta as { env?: Record }).env?.['VITE_WS_URL'] ?? 'ws://localhost:7357/ws'; +import type { ResolvedLayoutWire } from './types'; + interface RoomPayload { code?: string; token?: string; color?: string; message?: string; + layout?: ResolvedLayoutWire; } interface ServerMsg { @@ -31,10 +34,22 @@ interface ServerMsg { payload: RoomPayload; } +/** + * The shape resolved by oneShotRoomRequest on success. `layout` is + * optional for wire compat with older servers; new servers always + * populate it. + */ +export interface OneShotRoomResult { + code: string; + token: string; + color: string; + layout?: ResolvedLayoutWire; +} + export function oneShotRoomRequest( type: 'room.create' | 'room.join', extraPayload: Record, -): Promise<{ code: string; token: string; color: string }> { +): Promise { return new Promise((resolve, reject) => { const ws = new WebSocket(WS_URL); const timeout = window.setTimeout(() => { @@ -65,11 +80,15 @@ export function oneShotRoomRequest( ) { clearTimeout(timeout); ws.close(); - resolve({ + const result: OneShotRoomResult = { code: msg.payload.code, token: msg.payload.token, color: msg.payload.color, - }); + }; + if (msg.payload.layout !== undefined) { + result.layout = msg.payload.layout; + } + resolve(result); } else if (msg.type === 'error') { clearTimeout(timeout); ws.close(); diff --git a/packages/chess/src/net/types.ts b/packages/chess/src/net/types.ts index 5423905..2efb4d6 100644 --- a/packages/chess/src/net/types.ts +++ b/packages/chess/src/net/types.ts @@ -27,7 +27,38 @@ export type ErrorCode = | "RATE_LIMIT" | "MSG_TOO_LARGE" | "BAD_TOKEN" - | "INVALID_MESSAGE"; + | "INVALID_MESSAGE" + | "LAYOUT_INVALID"; + +// --------------------------------------------------------------------------- +// Starting layout wire shapes (mirrors server/src/protocol.ts) +// --------------------------------------------------------------------------- + +export type PieceType = + | "pawn" + | "knight" + | "bishop" + | "rook" + | "queen" + | "king"; + +export interface PiecePlacementWire { + type: PieceType; + color: Color; + square: number; + hasMoved?: boolean; +} + +export type LayoutRequest = + | { kind: "premade"; id: string } + | { kind: "fen"; fen: string; name?: string } + | { kind: "custom"; pieces: PiecePlacementWire[]; name?: string }; + +export interface ResolvedLayoutWire { + id: string; + name: string; + pieces: PiecePlacementWire[]; +} export interface Fact { id: number; @@ -90,6 +121,9 @@ export interface RoomCreatedPayload { code: string; token: string; color: Color; + /** Resolved starting layout. Optional on the wire for backward + * compat with pre-layouts servers. */ + layout?: ResolvedLayoutWire; } export interface RoomJoinedPayload { @@ -97,6 +131,8 @@ export interface RoomJoinedPayload { token: string; color: Color; activeRules: string[]; + /** Resolved starting layout (see RoomCreatedPayload.layout). */ + layout?: ResolvedLayoutWire; } export interface ErrorPayload { @@ -111,6 +147,9 @@ export interface ErrorPayload { export interface RoomCreatePayload { rulesetIds?: string[]; + /** Optional starting-layout selector. When omitted the server + * opens the room with the FIDE classic layout. */ + layout?: LayoutRequest; } export interface RoomJoinPayload { diff --git a/packages/chess/src/persist/autosave.ts b/packages/chess/src/persist/autosave.ts index eaeeb49..8e31387 100644 --- a/packages/chess/src/persist/autosave.ts +++ b/packages/chess/src/persist/autosave.ts @@ -1,16 +1,62 @@ import { exportGame, importGame } from './io.js'; -const AUTOSAVE_KEY = 'paratype-chess:v1:autosave'; +/** + * Autosave storage key prefix. The stored key for a given game is + * `${AUTOSAVE_PREFIX}:${layoutId}` — keyed by layout so starting a + * Dunsany game doesn't wipe the user's in-progress Classic game, + * and vice versa. + * + * The legacy (pre-layouts) single-key `paratype-chess:v1:autosave` + * is migrated on first access: if it exists and no keyed equivalent + * does, we move it to `${AUTOSAVE_PREFIX}:classic` so existing + * auto-saved FIDE games continue to load. The legacy key is then + * removed. + */ +const AUTOSAVE_PREFIX = 'paratype-chess:v2:autosave'; +const LEGACY_AUTOSAVE_KEY = 'paratype-chess:v1:autosave'; -export function saveAutoSave(facts: Array<{ id: number; attr: string; value: unknown }>): void { - try { - localStorage.setItem(AUTOSAVE_KEY, exportGame(facts)); - } catch { /* ignore quota errors */ } +function keyFor(layoutId: string): string { + return `${AUTOSAVE_PREFIX}:${layoutId}`; } -export function loadAutoSave(): Array<{ id: number; attr: string; value: unknown }> | null { +/** + * Best-effort migration of the pre-layouts global autosave into a + * classic-layout slot. Runs at most once per page load (the LEGACY + * key is removed after successful migration). + */ +function migrateLegacyAutosaveIfNeeded(): void { try { - const raw = localStorage.getItem(AUTOSAVE_KEY); + const legacy = localStorage.getItem(LEGACY_AUTOSAVE_KEY); + if (legacy === null) return; + // Only migrate if the destination slot is empty — don't stomp a + // more recent classic save. + const classicKey = keyFor('classic'); + if (localStorage.getItem(classicKey) === null) { + localStorage.setItem(classicKey, legacy); + } + localStorage.removeItem(LEGACY_AUTOSAVE_KEY); + } catch { + /* ignore quota / permission errors */ + } +} + +export function saveAutoSave( + facts: Array<{ id: number; attr: string; value: unknown }>, + layoutId: string = 'classic', +): void { + try { + localStorage.setItem(keyFor(layoutId), exportGame(facts)); + } catch { + /* ignore quota errors */ + } +} + +export function loadAutoSave( + layoutId: string = 'classic', +): Array<{ id: number; attr: string; value: unknown }> | null { + migrateLegacyAutosaveIfNeeded(); + try { + const raw = localStorage.getItem(keyFor(layoutId)); if (!raw) return null; return importGame(raw); } catch { @@ -18,6 +64,30 @@ export function loadAutoSave(): Array<{ id: number; attr: string; value: unknown } } -export function clearAutoSave(): void { - localStorage.removeItem(AUTOSAVE_KEY); +/** + * Clear the autosave for `layoutId`. Pass no arg to clear the + * classic slot (back-compat). Use `clearAllAutoSaves` to clear + * every layout's slot. + */ +export function clearAutoSave(layoutId: string = 'classic'): void { + localStorage.removeItem(keyFor(layoutId)); +} + +/** Remove autosaves for every layout. Use when starting a fresh + * game from the lobby so no stale slot resurrects later. */ +export function clearAllAutoSaves(): void { + try { + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key !== null && key.startsWith(`${AUTOSAVE_PREFIX}:`)) { + keys.push(key); + } + } + for (const key of keys) localStorage.removeItem(key); + // Also clear the legacy key if it's still around. + localStorage.removeItem(LEGACY_AUTOSAVE_KEY); + } catch { + /* ignore */ + } } diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx index cb41d5f..b0031b7 100644 --- a/packages/chess/src/ui/GameView.tsx +++ b/packages/chess/src/ui/GameView.tsx @@ -254,6 +254,7 @@ function GameLayout({ )} {roomCode !== null && } +
@@ -456,3 +457,29 @@ function RoomShareBadge({ code }: { code: string }) { ); } + +/** + * Badge showing the starting layout name when it's not the default + * Classic setup. Sourced from sessionStorage['layout-name'] set by + * the Lobby on create/join; cleared when the layout is Classic. + * + * Lives in local component state so edits to sessionStorage after + * mount (unlikely) don't cause stale display. For solo games with + * no layout selection the key is absent and the badge renders + * nothing. + */ +function LayoutBadge() { + const [name] = useState(() => { + if (typeof window === 'undefined') return null; + return sessionStorage.getItem('layout-name'); + }); + if (name === null) return null; + return ( + + {name} + + ); +} diff --git a/packages/chess/src/ui/LayoutPicker.tsx b/packages/chess/src/ui/LayoutPicker.tsx new file mode 100644 index 0000000..a1cf49a --- /dev/null +++ b/packages/chess/src/ui/LayoutPicker.tsx @@ -0,0 +1,130 @@ +/** + * LayoutPicker — lobby control for selecting a starting layout. + * + * Renders a dropdown populated from `LAYOUT_REGISTRY`. Selecting an + * entry calls `onChange(layout)` with the resolved `StartingLayout`. + * + * Special handling: + * - Chess960 is a SHIM in the registry (its `pieces` field is the + * FIDE default). When the user picks it, we call + * `buildChess960Layout(seed)` with a fresh random seed so every + * selection yields a different position. + * - "Custom…" is a synthetic trailing entry that opens the editor + * (the editor modal lives in a separate component; picker just + * emits an `onCustomRequested` callback). + * + * The component is intentionally UNCONTROLLED by layout ID — parents + * pass the full resolved layout back in as `value`. This keeps the + * picker stateless and plays well with URL-driven pre-selection + * (e.g. `?layoutId=dunsany`): the App route sets the initial value, + * the picker renders it. + */ +import { useMemo } from 'react'; +import { + LAYOUT_REGISTRY, + buildChess960Layout, + type StartingLayout, +} from '@paratype/chess'; + +export interface LayoutPickerProps { + /** Currently selected layout. Parents own the state. */ + value: StartingLayout; + /** Called when the user picks a new premade. */ + onChange: (layout: StartingLayout) => void; + /** Called when the user picks the "Custom…" entry. */ + onCustomRequested?: () => void; + /** Disables the control (during network requests, etc). */ + disabled?: boolean; +} + +export function LayoutPicker({ + value, + onChange, + onCustomRequested, + disabled = false, +}: LayoutPickerProps) { + // Memoize the registry list so the dropdown doesn't re-map on + // every render. Registry contents are immutable at runtime. + const premades = useMemo(() => LAYOUT_REGISTRY.list(), []); + + // If the current value is a CUSTOM layout (source: "custom"), the + // dropdown should display "Custom…" as the selected option rather + // than trying to find a matching premade id. + const isCustom = value.source === 'custom'; + const selectValue = isCustom ? '__custom__' : value.id; + + function handleChange(e: React.ChangeEvent) { + const picked = e.target.value; + + if (picked === '__custom__') { + onCustomRequested?.(); + return; + } + + // Chess960 gets a fresh seed on every selection. Future layouts + // that want "re-randomize on pick" can follow this pattern. + if (picked === 'chess960') { + const seed = Math.floor(Math.random() * 960); + onChange(buildChess960Layout(seed)); + return; + } + + const layout = LAYOUT_REGISTRY.get(picked); + if (layout !== undefined) { + onChange(layout); + } + } + + return ( +
+ +
+ +
+ + + +
+
+

+ {isCustom ? 'Custom layout loaded from your editor.' : value.description} +

+ {value.suggestedPresets !== undefined && + value.suggestedPresets.length > 0 && ( +

+ Suggested rules: {value.suggestedPresets.join(', ')} +

+ )} +
+ ); +} diff --git a/packages/chess/src/ui/Lobby.tsx b/packages/chess/src/ui/Lobby.tsx index f5cd02a..d1ce556 100644 --- a/packages/chess/src/ui/Lobby.tsx +++ b/packages/chess/src/ui/Lobby.tsx @@ -1,10 +1,19 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { motion, AnimatePresence } from 'motion/react'; import { pieceAssets } from '../assets/pieces'; import { ChessEngine } from '../engine'; -import { clearAutoSave } from '../persist/autosave'; +import { clearAllAutoSaves } from '../persist/autosave'; import { oneShotRoomRequest } from '../net/lobby-request'; +import { + CLASSIC_LAYOUT, + LAYOUT_REGISTRY, + fromFen, + validateLayout, + type StartingLayout, +} from '@paratype/chess'; +import type { LayoutRequest } from '../net/types'; +import { LayoutPicker } from './LayoutPicker'; interface LobbyProps { /** Optional — when provided, create/join/solo flows reset the local @@ -20,24 +29,100 @@ export function Lobby({ chessState }: LobbyProps = {}) { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + // Selected starting layout. Initialized from ?layoutId / ?fen + // query params when present (shareable-URL support) so pasting a + // link pre-selects the right layout. + const [selectedLayout, setSelectedLayout] = + useState(CLASSIC_LAYOUT); + + useEffect(() => { + const layoutId = searchParams.get('layoutId'); + const fen = searchParams.get('fen'); + const name = searchParams.get('name') ?? undefined; + + if (layoutId !== null) { + const layout = LAYOUT_REGISTRY.get(layoutId); + if (layout !== undefined) { + setSelectedLayout(layout); + } + return; + } + + if (fen !== null) { + const { pieces, errors } = fromFen(fen); + if (errors.length === 0) { + const layout: StartingLayout = { + id: 'custom', + name: name ?? 'Shared Layout', + description: 'Loaded from a shared link.', + pieces, + source: 'custom', + }; + // Only apply if it passes the validator — a malformed shared + // link shouldn't block the lobby's default behavior. + if (validateLayout(layout).errors.length === 0) { + setSelectedLayout(layout); + } + } + } + // Only depends on searchParams: we pre-select from query params + // on first mount or when the URL changes. selectedLayout is + // intentionally omitted so user picks after mount don't re-trigger + // this effect and overwrite their choice. + }, [searchParams]); + + /** + * Convert the selected layout into the protocol's LayoutRequest + * shape. Premades travel by id (server re-resolves via registry); + * custom layouts travel by pieces (server just validates). + */ + function toLayoutRequest(layout: StartingLayout): LayoutRequest { + if (layout.source === 'premade') { + return { kind: 'premade', id: layout.id }; + } + return { + kind: 'custom', + pieces: layout.pieces.map((p) => ({ + type: p.type, + color: p.color, + square: p.square, + ...(p.hasMoved !== undefined ? { hasMoved: p.hasMoved } : {}), + })), + name: layout.name, + }; + } const resetToFreshGame = () => { - // Wipe the autosave so a later full-page reload doesn't re-hydrate - // the previous (finished) game, and replace the in-memory engine - // with a brand-new one now so the board shows the starting position - // the instant the user lands on /game. - clearAutoSave(); - chessState?.loadEngine(new ChessEngine()); + // Wipe every layout's autosave so a later full-page reload doesn't + // re-hydrate the previous (finished) game from any slot, and + // replace the in-memory engine with a brand-new one opened from + // the selected layout so the board shows the chosen starting + // position the instant the user lands on /game. + clearAllAutoSaves(); + chessState?.loadEngine(new ChessEngine({ layout: selectedLayout })); }; const handleCreate = async () => { setLoading(true); setError(null); try { - const { code, token, color } = await oneShotRoomRequest('room.create', {}); + const { code, token, color, layout: resolvedLayout } = + await oneShotRoomRequest('room.create', { + layout: toLayoutRequest(selectedLayout), + }); sessionStorage.setItem('room-code', code); sessionStorage.setItem('room-token', token); sessionStorage.setItem('player-color', color); + // Save the resolved layout name for GameView's header badge. + // Strip "Classic Chess" since that's the implicit default and + // showing the label there would be noisy. + if (resolvedLayout && resolvedLayout.id !== 'classic') { + sessionStorage.setItem('layout-name', resolvedLayout.name); + } else { + sessionStorage.removeItem('layout-name'); + } resetToFreshGame(); // Navigate straight to the canonical shareable URL — no // intermediate "Room created" card. The GameView itself renders @@ -63,6 +148,11 @@ export function Lobby({ chessState }: LobbyProps = {}) { sessionStorage.setItem('room-code', result.code); sessionStorage.setItem('room-token', result.token); sessionStorage.setItem('player-color', result.color); + if (result.layout && result.layout.id !== 'classic') { + sessionStorage.setItem('layout-name', result.layout.name); + } else { + sessionStorage.removeItem('layout-name'); + } resetToFreshGame(); navigate(`/game/${result.code}`); } catch (err) { @@ -111,7 +201,12 @@ export function Lobby({ chessState }: LobbyProps = {}) {

Host Game

-
+
+ + +
+ + + {/* Body */} +
+ {libraryOpen ? ( + setLibraryOpen(false)} + /> + ) : ( + <> + + + + + )} +
+
+
+ ); +} + +// ── Subcomponents ─────────────────────────────────────────────────── + +function PalettePanel({ + brush, + onSelect, + onClear, +}: { + brush: Brush; + onSelect: (b: Brush) => void; + onClear: () => void; +}) { + return ( + + ); +} + +function PaletteButton({ + type, + color, + selected, + onSelect, +}: { + type: PieceType; + color: PieceColor; + selected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +function BoardPanel({ + placements, + brush, + onSquareClick, +}: { + placements: PiecePlacement[]; + brush: Brush; + onSquareClick: (square: number) => void; +}) { + const placementBySquare = useMemo(() => { + const map = new Map(); + for (const p of placements) map.set(p.square, p); + return map; + }, [placements]); + + // Render rank 8 → 1 so the visual matches a standard board (white + // on the bottom). Square indexing: rank r, file f = r*8 + f. + const rows: ReactNode[] = []; + for (let rank = 7; rank >= 0; rank--) { + const cells: ReactNode[] = []; + for (let file = 0; file < 8; file++) { + const sq = rank * 8 + file; + const piece = placementBySquare.get(sq); + const isDark = (rank + file) % 2 === 0; + cells.push( + , + ); + } + rows.push( +
+ {cells} +
, + ); + } + + return ( +
+
+ {rows} +
+
+ ); +} + +function ActionsPanel({ + fenDraft, + liveFen, + onFenDraftChange, + onLoadFen, + onSyncFen, + validation, + canApply, + onApply, + onSaveToLibrary, + onCopyShareLink, +}: { + fenDraft: string; + liveFen: string; + onFenDraftChange: (v: string) => void; + onLoadFen: () => void; + onSyncFen: () => void; + validation: { errors: readonly string[]; warnings: readonly string[] }; + canApply: boolean; + onApply: () => void; + onSaveToLibrary: () => void; + onCopyShareLink: () => void; +}) { + return ( +