Compare commits

...

9 commits

Author SHA1 Message Date
8828908a59
feat(chess): live-sync FEN textarea with board edits in the editor
Previously the FEN textarea held an independent draft that only
synced to the board when the user clicked Reset. Placing a piece
left the FEN showing stale content, which was confusing — the
field looked like a proxy for the board but wasn't.

Now the textarea mirrors the live board FEN by default:
- Placing, erasing, or clearing pieces updates the textarea
  immediately.
- Starting to type into the textarea marks it 'dirty' (via a ref
  so we don't rerender), pausing the auto-sync until the user
  Loads (accept the draft) or Discards (restore the live FEN).
- The 'Unsaved edit' indicator appears while dirty.
- Load / Discard buttons disable when draft equals live FEN —
  nothing to apply.
- Library load also resets the dirty flag, as that's a clean
  state reset.
- Removed the redundant 'Current: <fen>' helper line below the
  textarea; the textarea itself is the live FEN now.

E2E: 2 new tests
- FEN field updates live as pieces are placed.
- Typing into FEN field pauses live sync until Load or Discard.

25/25 Playwright tests passing. 1025 unit tests green.
2026-04-18 20:43:11 -06:00
c6c79c678b
refactor(chess): unregister Empty layout — keep as internal fixture only
The Empty layout had no real user need:
- Can't Play Solo (board with no pieces, nothing to click).
- Server rejects it for Create Room (validator requires >=1 king
  per side).
- The 'blank canvas' use case is already handled by the
  LayoutEditor's Custom... flow, which opens with an empty piece
  list and lets the user compose.

It was UI clutter in the picker dropdown — users would see
'Empty Board' alongside real layouts and have no way to actually
use it.

EMPTY_LAYOUT stays exported for unit tests that need a zero-piece
starting state, but it no longer registers in LAYOUT_REGISTRY, is
not imported by the layouts barrel side-effects, and no longer
appears in the picker. Source flipped to 'custom' to signal it's
not a user-selectable premade.

Tests updated:
- premades.test.ts asserts 'empty' is NOT in the registry.
- e2e/layouts.spec.ts removes the Empty-solo test and asserts the
  'empty' option is absent from the picker dropdown.

1025 unit tests + 23 e2e tests green.
2026-04-18 20:39:08 -06:00
ee2c175963
test(chess): expand layouts e2e with 10 new scenarios
Covers gaps in the original Phase E spec:

- Solo play reaches the engine: picking Pawns-Only and Classic and
  asserting the rendered board matches the layout's piece placement
  (not just that the UI dropdown says so).
- Empty layout solo: documents current behavior (solo lets users
  poke at any layout, validator only gates multiplayer).
- Chess960 re-randomizes: takes 8 consecutive snapshots of the
  back rank and asserts they're not all identical (p(collision)^7
  is effectively zero).
- Valid ?fen query pre-selects Custom (inverse of the existing
  malformed-FEN test).
- Library operations: full save → star → unstar → delete round
  trip via the library drawer's aria-labeled buttons.
- Layout badge hidden for classic solo games.
- Multiplayer with Dunsany: full pipeline proof — picker →
  Create Room UI → server resolves + validates → room.created
  echo → joiner renders same Dunsany setup via room.joined echo.
  Compares rank-1 and rank-8 snapshots between two browser
  contexts to verify bit-identical rendering.
- Server-side rejection: sends a kingless custom layout via raw
  WebSocket and asserts LAYOUT_INVALID error with a human-readable
  king-related message.

New server-spawn block mirrors multiplayer.spec.ts so the layouts
tests can run in isolation or against an existing dev server.

24/24 Playwright tests passing (21 layouts + 2 multiplayer +
1 full-flow) in ~40s.
2026-04-18 20:28:56 -06:00
89c22d6bd5
fix(chess): global Esc handler + stable test selectors for layouts e2e
- LayoutEditor: move Esc handling to a window keydown listener so
  the modal closes regardless of where focus currently sits (the
  prior onKeyDown on the modal div only fired when the root div
  itself held focus).
- LayoutPicker: add data-testid on the description <p> so e2e
  assertions can target it unambiguously instead of relying on
  free-text matches that also select the hidden <option> labels.
- e2e: use the new testid.

All 12 layouts e2e tests pass; existing multiplayer + full-flow
e2e suites still green.
2026-04-18 20:23:07 -06:00
832ebe9cd6
feat(chess): custom-layout editor + saved library (Phase E)
Adds a full in-browser editor for authoring custom starting
layouts and a persistent library to save them across sessions.

persist/layout-library.ts:
- SavedLayout shape with id/name/pieces/starred/updatedAt.
- saveToLibrary / loadLibrary / deleteFromLibrary / setStarred /
  duplicateEntry / makeId helpers.
- Capacity cap at 20 entries; oldest non-starred is evicted on
  overflow; all-starred + full returns { ok: false, reason }
  so the UI can surface an actionable message.
- Localstorage key is versioned (houserules:layouts:v1) for a
  future migration path.
- 14 unit tests cover eviction, shape validation, star toggles,
  duplicate flow, and the crypto.randomUUID fallback.

ui/LayoutEditor.tsx:
- Modal overlay with three panels — palette (white/black pieces +
  Erase + Clear), interactive 8x8 board with click-to-place
  brushes, and an actions panel.
- Live FEN textarea (toFen/fromFen round-trips) with a Load
  button that replaces the board and a Reset-to-board sync.
- Live validation panel shows errors (block CTA) and warnings
  (inform but don't block). The 'Use This Layout' CTA is disabled
  until errors clear.
- Save to Library writes a SavedLayout; the integrated Library
  drawer lists saved layouts sorted by starred-first + most-recent,
  with Load / Star / Duplicate / Delete actions.
- Copy Share Link writes a \${origin}/?fen=...&name=... URL to the
  clipboard — pastes straight into the lobby's existing query-param
  pre-select flow from Phase D.
- Esc closes the modal.

ui/Lobby.tsx:
- Custom... entry in the layout picker opens the editor.
- onApply(layout) commits the custom layout as the lobby's
  current selection so Create Room ships it to the server.

e2e/layouts.spec.ts:
- Picker renders every premade + Custom entry.
- Selecting Dunsany updates description; ?layoutId=dunsany and
  malformed ?fen behave correctly.
- Editor opens on Custom, validates king count, erases pieces,
  loads FEN, commits via 'Use This Layout', saves to library
  with cross-reload persistence, closes on Esc.

1025 unit tests passing; bun run check clean. Playwright suite
requires dev server — run with \`bun run --filter @paratype/chess e2e\`
when needed.
2026-04-18 20:20:41 -06:00
4b6306fb57
feat(chess): lobby + URL-shareable starting layouts (Phase D)
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=<encoded>) 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.
2026-04-18 20:07:25 -06:00
174cad6ae7
feat(server): layout-aware room.create + resolved layout echoes (Phase C)
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.
2026-04-18 20:01:01 -06:00
f7099d754d
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.
2026-04-18 19:52:22 -06:00
d93bcf6c81
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.
2026-04-18 19:44:01 -06:00
44 changed files with 5634 additions and 63 deletions

View file

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

View file

@ -0,0 +1,389 @@
# Rule Variants — Greenchess cat=4 Preset Suite
## TL;DR
Ship the rule-variant presets from [greenchess.net cat=4](https://greenchess.net/variants.php?cat=4). Adds **four new preset-API hooks** (royal piece override, global legal-move filter, turn-advancement override, per-piece-move override), then **12 new presets** built on them.
**Deliverable**: users toggle any of 12 new rule-variant presets in the lobby, which compose cleanly with existing presets (HP, queen-splits, etc.) and the layouts system from the prior plan. Existing 930+ tests stay green; each new preset ships with 8-15 behavioral tests.
**Tiered** so the user can stop mid-execution. Tier 1 (T1) must ship to close the deferred rules from the layouts plan; Tier 2 (T2) rounds out greenchess cat=4 coverage; Tier 3 (T3) is explicitly deferred (Royalty Transfer needs mid-game state we don't want to build yet).
**Phases**: 6 phases, 31 tasks, hard-gated by `bun run check` between each.
---
## Context
### What Shipped Last
- Preset-flexibility architecture (commit `cc30545`): hook context objects, `PIECE_TYPE_REGISTRY`, damage pipeline, preset-state storage, visual-effect bus.
- Starting Layouts plan (`.sisyphus/plans/starting-layouts.md`, queued): introduces Dunsany, Monster, Pawns-Only, Horde, Knightmate, Chess960 as layout-only presets — layout descriptions explicitly flag that the *rules* for Monster/Knightmate/Pawns-Only ship here.
- Existing relevant presets: `wrap-board` (cylinder), `pawns-move-backward`, `double-pawn-sprint`, `knights-leap-twice`, `knight-immunity`, `bishops-ignore-color`, `rook-warp`, `explosive-rook`, `piece-hp`, `king-heals`, `poisoned-squares`, `queen-splits`, `capture-to-win`, `last-piece-standing`, `pawn-diagonal-no-capture`.
### What the Current Preset API Can't Express
Audited every greenchess cat=4 variant against the current `PresetDef` surface. Gaps found:
1. **Royal piece override** — `isInCheck`, `isCheckmate`, `filterSelfCheckMoves`, `isStalemate` all hardcode `"king"` as the royal piece type. Knightmate (knight is royal), Coregal (king AND queen royal), Dual (two kings, either mates) can't be expressed without a hook.
- File anchors: `rules/check.ts:83-97`, `rules/checkmate.ts`, `rules/stalemate.ts`, `rules/check.ts:filterSelfCheckMoves`.
2. **Global legal-move filter** — current `filterMoves(moves, engine, pieceId)` is per-piece. Suicide Chess's "compulsory capture" rule needs "if ANY move on the board is a capture, remove every non-capture". Requires post-aggregation filter.
- File anchor: `engine.ts:getAllLegalMoves` — aggregation happens but no per-color post-filter hook exists.
3. **Turn-advancement override** — `applyMove` unconditionally flips Turn. Double-move and Monster Chess need "don't flip until N half-moves this turn".
- File anchor: `engine.ts:applyMove` turn flip near line ~900.
4. **Piece-move replacement** — `getExtraMoves` adds to a piece's move list; `filterMoves` subtracts. Neither cleanly REPLACES. Berolina's pawns move diagonally and capture orthogonally — you don't want to intersect with FIDE pawn rules, you want to swap them wholesale.
- Today possible via `filterMoves` returning `[]` + `getExtraMoves` returning the new set, but that's a foot-gun (two hooks must stay in sync; other presets may still contribute extras).
### What DOES Work Already
- Bouncing bishops/queens: `getExtraMoves` on bishop/queen PieceType that computes reflected rays. Adds, doesn't replace — great.
- Extinction Chess (capture-all-of-a-type-wins): `onCheckGameResult` + `presetState` for tracking. Pure existing surface.
- Capture-all (no check/checkmate): `shouldFilterSelfCheck → false` + `onCheckGameResult` that reads "any opponent pieces left?".
- Pawns-only first-promotion-wins: `describeMoveEffect` detects promotion + `onCheckGameResult` reads promotion count.
- Knightmate / Coregal / Dual ROYAL-PIECE handling needs the new hook; the actual "checkmate the royal" flow is then automatic via existing machinery.
### Out of Scope (Deferred Hard)
- **Royalty Transfer Chess** (two kings but only one is active at a time). Needs mid-game "which king is active" state flip triggered by a non-move action. This UX doesn't fit current "one move at a time" plumbing. Deferred to a post-v1 plan.
- **Any 4-player / multi-side variants**. Engine assumes exactly two sides; changing that is not in cat=4 anyway.
- **Non-8×8 board variants from other greenchess cats** (Capablanca, Janus, etc.). Needs board-dimensions refactor — see the layouts plan's deferrals section.
- **Castling interactions with Knightmate**. Knightmate says "regular pieces capture normally; knight is royal." Castling rights conceptually apply to the royal piece today (king); under Knightmate we treat castling as FIDE-normal (involves the non-royal king on e1/e8 — unchanged). Document this as a design call, not a feature gap.
- **PGN notation updates**. Algebraic notation with non-king royalty (`N+`, `N#`?) is a rabbit hole. The move-log will display effects via `describeMoveEffect`, not via FIDE notation. Defer.
---
## Work Objectives
### Core Objective
Extend the preset API with four additive hooks that unblock 12 new variants, then ship the variants themselves, each fully tested in isolation and in composition with at least one compatible existing preset.
### Primary Deliverables
1. **Hook: `getRoyalPieces(ctx): EntityId[] | undefined`** — overrides "which pieces of this color count as royal" for check/checkmate/self-check-filter detection. Default (undefined from every preset) → engine uses `PieceType === "king"`.
2. **Hook: `filterLegalMoves(ctx): LegalMove[]`** — post-aggregation filter on the full per-color legal-move list. Engine applies these in preset-registration order.
3. **Hook: `shouldAdvanceTurn(ctx): boolean | undefined`** — preset intervenes in the turn-flip decision. First `false` wins; default (all undefined) → turn advances normally.
4. **Hook: `overridePieceMoves(engine, pieceId): LegalMove[] | undefined`** — return non-undefined to REPLACE the default generator's output for that piece. First match wins.
5. **Tier 1 presets** (close the layouts-plan deferrals):
- `knightmate-rules` — knight is royal piece.
- `double-move` — both players move twice per turn (last move of turn flips sides).
- `monster-rules` — white moves twice; black moves once (asymmetric scope).
- `first-promotion-wins` — first player to promote a pawn wins.
6. **Tier 2 presets** (cat=4 completeness):
- `coregal` — king AND queen are royal.
- `dual-king` — two kings per side; mating either wins (strong).
- `weak-dual-king` — two kings per side; must mate both (weak).
- `suicide-chess` — captures are compulsory; lose-all-pieces wins; no check/checkmate.
- `capture-all` — no check/checkmate; capture every opposing piece to win.
- `extinction-chess` — capture every opposing piece of a chosen type to win.
- `berolina-pawns` — pawns push diagonally, capture orthogonally forward.
- `berolina-pawns-2` — like berolina plus sideways capture.
- `bouncing-pieces` — bishop/queen bounce off left/right edges.
- `bouncing-pieces-2` — bounce off all four edges.
7. **Test utilities updates**: `test-utils.ts` helpers extended for multi-royal assertions.
### Secondary Deliverables
- Lobby UI: the RulesDrawer's dependency-chip UI already handles `requires` / `incompatibleWith`. Each new preset declares these correctly — no UI change needed.
- Auto-suggest in layouts picker: when a user picks "Monster" layout, show a dimmed chip "Suggested: monster-rules" with a one-click enable. (Implemented in layouts plan; this plan just populates the `suggestedPresets` field for each layout.)
- E2E smoke: create room with Dunsany layout + suicide-chess preset; verify compulsory capture enforces server-side.
### Non-Goals (clarifying)
- No Chess960 rule changes (Chess960 is a layout, not a rule, per the layouts plan).
- No UI mini-animations for royal-piece-in-check unique to each variant — reuse the existing check banner.
- No ranking / ELO / SPRT tuning. These presets are opt-in novelty.
---
## Tier Ordering
Each variant is labeled T1 (must-ship) / T2 (should-ship) / T3 (deferred).
| Variant | Tier | Rationale |
|---|---|---|
| knightmate-rules | T1 | Closes layouts-plan deferral for Knightmate. |
| double-move | T1 | Closes layouts-plan deferral for Monster / Double-move. |
| monster-rules | T1 | Asymmetric double-move + Monster layout. |
| first-promotion-wins | T1 | Closes layouts-plan deferral for Pawns-Only. |
| coregal | T2 | Cheap once royal hook exists. |
| dual-king | T2 | Same. |
| weak-dual-king | T2 | Same. |
| suicide-chess | T2 | Popular variant; exercises filterLegalMoves. |
| capture-all | T2 | Pure existing-API composition. |
| extinction-chess | T2 | Pure existing-API. |
| berolina-pawns | T2 | Exercises overridePieceMoves. |
| berolina-pawns-2 | T2 | Trivial once berolina exists. |
| bouncing-pieces | T2 | Uses existing getExtraMoves. |
| bouncing-pieces-2 | T2 | Extension of bouncing. |
| royalty-transfer | T3 | Mid-game state flip — deferred. |
---
## Architecture Sketch
### Hook additions to `PresetDef`
```ts
// rules/check.ts and callers
interface RoyalContext extends HookContext {
readonly color: "white" | "black";
}
// Returned array = the royal pieces for this color. Engine uses the
// UNION of all active presets' returns (for Coregal: king + queens).
// Returning undefined = "I don't override, use default (king)."
readonly getRoyalPieces?: (ctx: RoyalContext) => readonly EntityId[] | undefined;
// Returns the filtered legal-move list. Engine runs each hook in
// preset-registration order, piping the result into the next.
interface FilterLegalMovesContext extends HookContext {
readonly moves: readonly LegalMove[];
readonly color: "white" | "black";
}
readonly filterLegalMoves?: (ctx: FilterLegalMovesContext) => LegalMove[];
// Returns false to KEEP the current turn (same side to move).
// Engine iterates in preset-registration order; first false wins.
interface TurnAdvanceContext extends HookContext {
readonly mover: "white" | "black";
readonly halfMovesThisTurn: number; // tracked by engine
}
readonly shouldAdvanceTurn?: (ctx: TurnAdvanceContext) => boolean | undefined;
// Return non-undefined to REPLACE default moves for `pieceId`.
// First preset to return wins.
readonly overridePieceMoves?: (
engine: ChessEngine,
pieceId: EntityId,
) => LegalMove[] | undefined;
```
### Engine integration
```ts
// rules/check.ts — isInCheck becomes:
export function isInCheck(session: Session, color: PieceColor, royalTypes?: Set<PieceType>): boolean
// With royalTypes undefined: uses {"king"} (current behavior).
// ChessEngine wraps this: computes royalTypes from preset returns, passes in.
// Engine state: tracks halfMovesThisTurn for shouldAdvanceTurn.
// Resets to 0 on every turn flip. Increments on each applyMove.
// Piece move generation dispatch order:
// 1) overridePieceMoves (first match wins)
// 2) PIECE_TYPE_REGISTRY default
// 3) getExtraMoves (each preset contributes)
// 4) filterMoves (each preset filters)
// 5) aggregate all pieces' moves
// 6) self-check filter (suppressible via shouldFilterSelfCheck)
// 7) filterLegalMoves (each preset runs in order)
```
### New session fact: `HalfMovesThisTurn` on GAME_ENTITY
Used by `shouldAdvanceTurn` hook. Serialized naturally with other game-entity facts. Reset to 0 on turn flip.
---
## Phase A — Hook API Extensions (architecture)
### A.1 — `getRoyalPieces` hook
- `[packages/chess/src/presets/registry.ts]` Add: `RoyalContext` type, `getRoyalPieces` hook signature on `PresetDef` — expect: compiles; existing presets continue to declare no royal override (all undefined).
- `[packages/chess/src/rules/check.ts]` Refactor: Add optional `royalTypes?: Set<PieceType>` parameter to `isInCheck`; when present, find royal pieces by those types, else default `{"king"}`. Same for `isSquareAttacked` callers that need it — expect: tests still green without passing the new param (default maintained).
- `[packages/chess/src/rules/checkmate.ts]` Refactor: Accept royal-pieces set; treat loss as "no legal move AND at least one royal in check". Document behavior under multi-royal: with Coregal, game ends if ANY royal is checkmated (OR), not all.
- `[packages/chess/src/rules/stalemate.ts]` Refactor: Accept royal-pieces set; stalemate = "no legal move AND no royal in check".
- `[packages/chess/src/engine.ts]` Refactor: Add private `getActiveRoyalTypes(color): Set<PieceType>` that unions returns from every active preset's `getRoyalPieces` hook, maps EntityId → PieceType via session facts. When union is empty → default `{"king"}`. Use this in all check/mate/stalemate calls — expect: no active royal-override preset = engine behavior unchanged; 930+ tests green.
- `[packages/chess/src/presets/royal-pieces.test.ts]` Create: Prototype "double-king" preset that returns all kings (should typically be one) for each side; assert `isInCheck` still works — expect: green.
### A.2 — `filterLegalMoves` hook
- `[packages/chess/src/presets/registry.ts]` Add: `FilterLegalMovesContext`, `filterLegalMoves` hook — expect: compiles.
- `[packages/chess/src/engine.ts]` Refactor: After existing self-check filter, iterate active presets and pipe each's `filterLegalMoves` through the list. Document: "order matters; preset authors should commute where possible or declare `incompatibleWith`" — expect: no-op when no preset implements.
- `[packages/chess/src/presets/filter-legal-moves.test.ts]` Create: Prototype "no-a-file moves" preset filtering out all moves from/to a-file; assert engine respects it across multiple pieces — expect: green.
### A.3 — `shouldAdvanceTurn` hook + `HalfMovesThisTurn` tracking
- `[packages/chess/src/schema.ts]` Add: `HalfMovesThisTurn` to `ChessAttrMap` with value type `number` — expect: compiles.
- `[packages/chess/src/starting-position.ts]` Update: Seed `HalfMovesThisTurn = 0` on GAME_ENTITY alongside Turn — expect: every new engine/layout has the fact.
- `[packages/chess/src/layouts/classic.ts]` and all layouts in the layouts plan: no change needed because layouts call the shared starting-position helper for game facts.
- `[packages/chess/src/presets/registry.ts]` Add: `TurnAdvanceContext`, `shouldAdvanceTurn` hook — expect: compiles.
- `[packages/chess/src/engine.ts]` Refactor: In `applyMove`, increment `HalfMovesThisTurn` before the turn-flip decision. Iterate active presets' `shouldAdvanceTurn`; if any returns `false`, skip the turn flip. When flipping, reset `HalfMovesThisTurn = 0` — expect: no active turn-override = behavior unchanged.
- `[packages/chess/src/presets/turn-advance.test.ts]` Create: Prototype "never-flip" preset; assert white keeps moving indefinitely — expect: green.
### A.4 — `overridePieceMoves` hook
- `[packages/chess/src/presets/registry.ts]` Add: `overridePieceMoves` hook — expect: compiles.
- `[packages/chess/src/engine.ts]` Refactor: In `getAllLegalMoves`, before calling `lookupMoveGenerator`, iterate active presets' `overridePieceMoves(engine, pieceId)`. First non-undefined wins; cache result and skip default generator. Log collision warnings (two presets override same piece) in dev — expect: no preset overrides = default generator used.
- `[packages/chess/src/presets/override-piece-moves.test.ts]` Create: Prototype "lame-knight" preset that overrides knight moves to single-square orthogonal; assert knight behaves differently only with preset active — expect: green.
### A.5 — Phase A verification gate
- `[repo root]` Verify: `bun run check` — expect: 930+ tests green; no behavioral change from any new hook being undefined.
- `[packages/chess/docs/PRESET-API.md]` Update: Document the four new hooks with worked examples. Add a "Hook ordering" diagram covering all 10 preset hooks.
---
## Phase B — Tier 1 Presets (layouts deferrals)
### B.1 — `knightmate-rules`
- `[packages/chess/src/presets/knightmate-rules.ts]` Create: Implements `getRoyalPieces` returning all knight entities of `color`. Since Knightmate's layout has kings replacing knights (non-royal), this preset treats knights as the royal type. Declares `requires: []` (works with any layout that has knights). Comment: "works best with the Knightmate layout but not strictly dependent — with FIDE layout, kings become non-royal and knights become royal, which is playable but weird" — expect: registers, typechecks.
- `[packages/chess/src/presets/knightmate-rules.test.ts]` Create: 10+ tests. Load Knightmate layout; assert (a) moving a non-royal king into attack is legal, (b) a knight in check restricts moves to resolving the check, (c) checkmating the last knight ends the game, (d) capture-all-knights is NOT game-over if any knight survives, (e) composition with piece-hp — knight loses HP on capture attempts rather than dying — expect: green.
- `[packages/chess/src/layouts/knightmate.ts]` (from layouts plan) Update: `suggestedPresets: ["knightmate-rules"]` now points at a real preset — expect: layout picker shows enabled chip.
### B.2 — `double-move`
- `[packages/chess/src/presets/double-move.ts]` Create: Implements `shouldAdvanceTurn` returning `false` when `halfMovesThisTurn < 2`. Also implements `getRoyalPieces`? No — use default king logic. Notes: "cannot be in check mid-turn" is the classic Double-move rule; we enforce it via `shouldFilterSelfCheck` — second half-move still considers king safety. Declares `incompatibleWith: ["monster-rules"]` (both override turn-advance for white). Scope defaults to `"both"` — expect: registers, typechecks.
- `[packages/chess/src/presets/double-move.test.ts]` Create: 12+ tests. (a) white plays two moves before black, (b) halfMovesThisTurn resets on flip, (c) checking opponent on first move ends opponent's turn immediately (edge case: some Double-move rulesets forbid check on move 1 — we go with the common "check allowed, opponent must respond on both of their moves" interpretation and document), (d) composition with piece-hp, (e) composition with king-heals (heal fires on second-move turn-flip only) — expect: green.
### B.3 — `monster-rules`
- `[packages/chess/src/presets/monster-rules.ts]` Create: Scope-aware. For scope=white, `shouldAdvanceTurn` returns false when `mover === "white"` and `halfMovesThisTurn < 2`. For scope=black, black behaves normally. Declares `incompatibleWith: ["double-move", "suicide-chess"]` — expect: registers.
- `[packages/chess/src/presets/monster-rules.test.ts]` Create: 10+ tests. White moves twice, black moves once. Combined with Monster layout → white plays king + 4 pawns twice each turn — expect: green.
- `[packages/chess/src/layouts/monster.ts]` (from layouts plan) Update: `suggestedPresets: ["monster-rules"]`.
### B.4 — `first-promotion-wins`
- `[packages/chess/src/presets/first-promotion-wins.ts]` Create: `onAfterMove` inspects the just-applied move via `engine.moveLog[last]`; if `promotion !== null`, records winner via `presetState`. `onCheckGameResult` returns the winner if set, else undefined. Also implements `shouldFilterSelfCheck → false` and overrides checkmate logic so the game doesn't end prematurely on check. Declares `incompatibleWith: ["capture-to-win", "last-piece-standing", "extinction-chess", "suicide-chess", "capture-all"]` — expect: registers.
- `[packages/chess/src/presets/first-promotion-wins.test.ts]` Create: 8+ tests. (a) first pawn promotion wins, (b) works with Pawns-Only layout, (c) composition with double-move (second move can also trigger win), (d) composition with piece-hp — expect: green.
- `[packages/chess/src/layouts/pawns-only.ts]` (from layouts plan) Update: `suggestedPresets: ["first-promotion-wins"]`.
### B.5 — Phase B verification gate
- `[repo root]` Verify: `bun run check` — expect: green. Manual smoke: open lobby, pick Knightmate layout + knightmate-rules, play a short game via Playwright script.
---
## Phase C — Tier 2 Royal-Variant Presets
### C.1 — `coregal`
- `[packages/chess/src/presets/coregal.ts]` Create: `getRoyalPieces` returns union of kings + queens for `color`. `incompatibleWith: ["knightmate-rules", "dual-king", "weak-dual-king"]` — expect: registers.
- `[packages/chess/src/presets/coregal.test.ts]` Create: 10+ tests. (a) mating only the king ends the game (queen can't save), (b) mating only the queen ends the game, (c) with no queen on board, behaves as FIDE, (d) pins now affect the queen too (moving a pinned queen into attack leaves another royal in check → illegal) — expect: green.
### C.2 — `dual-king`
- `[packages/chess/src/presets/dual-king.ts]` Create: `getRoyalPieces` returns ALL king entities for `color` (any king in check that can't escape = checkmate). Works best with a 2-king layout, but also makes pawn-underpromotion-to-king meaningful. `incompatibleWith: ["weak-dual-king", "coregal", "knightmate-rules"]` — expect: registers.
- `[packages/chess/src/presets/dual-king.test.ts]` Create: 8+ tests. Use a custom test layout with 2 kings per side. Assert mating either king ends the game — expect: green.
- **Note**: Premade "Dual Chess" layout not shipped in layouts plan. For this preset, ship a `dual-classic` layout (2 kings each, otherwise FIDE) in the layouts plan OR recommend users construct one via the custom editor. Add a new layout file `packages/chess/src/layouts/dual-classic.ts` here.
### C.3 — `weak-dual-king`
- `[packages/chess/src/presets/weak-dual-king.ts]` Create: Implements both `getRoyalPieces` (returns all kings) AND an override to `onCheckGameResult`. Game-over only when ALL kings of one side are captured/mated. First mate of one king does NOT end the game. `incompatibleWith: ["dual-king", "coregal", "knightmate-rules"]` — expect: registers.
- `[packages/chess/src/presets/weak-dual-king.test.ts]` Create: 8+ tests. (a) one king lost → game continues, (b) both kings lost → game ends, (c) last king in check with mate → game ends — expect: green.
### C.4 — Phase C verification gate
- `[repo root]` Verify: `bun run check` — expect: green. Add an integration test: player runs coregal + piece-hp; asserts queen loses HP but king is still the primary royal.
---
## Phase D — Tier 2 Objective-Variant Presets
### D.1 — `suicide-chess`
- `[packages/chess/src/presets/suicide-chess.ts]` Create:
- `filterLegalMoves`: if any move in the list has `isCapture === true`, return only captures.
- `shouldFilterSelfCheck`: returns false (king is a regular piece in Suicide).
- `getRoyalPieces`: returns `[]` empty array (no royalty — interpret: "no pieces are royal, check detection is a no-op"). This requires the engine's isInCheck to handle "empty royal set" → returns false always. Add this edge-case handling in the Phase A refactor if not already.
- `onCheckGameResult`: if one side has 0 pieces, declares that side the WINNER (Suicide inverts).
- `incompatibleWith: ["capture-to-win", "last-piece-standing", "monster-rules", "knightmate-rules", "coregal", "dual-king", "weak-dual-king", "capture-all", "extinction-chess", "first-promotion-wins"]` — expect: registers.
- `[packages/chess/src/presets/suicide-chess.test.ts]` Create: 14+ tests. (a) captures compulsory when available, (b) non-captures legal otherwise, (c) losing all pieces wins, (d) pawn promotion still legal (promotion to queen is fine), (e) composition with piece-hp — captures only count when piece actually dies (capture with HP remaining doesn't satisfy compulsion? — design call: YES it does, because the attempt IS a capture move even if the target survives. Document) — expect: green.
### D.2 — `capture-all`
- `[packages/chess/src/presets/capture-all.ts]` Create:
- `shouldFilterSelfCheck → false`
- `getRoyalPieces → []`
- `onCheckGameResult`: winner = side whose opponent has 0 pieces.
- `incompatibleWith: ["suicide-chess", "capture-to-win", "last-piece-standing", "extinction-chess", "first-promotion-wins"]` — expect: registers.
- `[packages/chess/src/presets/capture-all.test.ts]` Create: 8+ tests — expect: green.
### D.3 — `extinction-chess`
- `[packages/chess/src/presets/extinction-chess.ts]` Create: Configurable target type via `presetState({targetType: PieceType})`. Default: `"pawn"` (common extinction target — game about pawn attrition). UI chip shows "Extinction: pawns". `onCheckGameResult` returns winner when opposite side has 0 pieces of target type — expect: registers.
- `[packages/chess/src/presets/extinction-chess.test.ts]` Create: 10+ tests covering each target type — expect: green.
- **UI stretch**: Cycle target type via chip click in RulesDrawer. Out-of-scope for this phase; add as a follow-up UI task after the plan lands.
### D.4 — Phase D verification gate
- `[repo root]` Verify: `bun run check` — expect: green.
---
## Phase E — Tier 2 Movement-Variant Presets
### E.1 — `berolina-pawns`
- `[packages/chess/src/presets/berolina-pawns.ts]` Create: `overridePieceMoves` for pawn pieces only. Generates: forward diagonals (non-capture push), forward orthogonal (capture only). Double-move from home rank = two diagonal pushes. En passant reinterpreted: if the opponent's pawn just made a double diagonal push landing on a square adjacent to yours, you can capture it orthogonally to the skipped square. Declares `incompatibleWith: ["pawn-diagonal-no-capture", "berolina-pawns-2", "pawns-move-backward"]` — expect: registers.
- `[packages/chess/src/presets/berolina-pawns.test.ts]` Create: 15+ tests. Double-push, capture, en passant, promotion (yes — diagonal push to promotion rank still promotes), block detection — expect: green.
### E.2 — `berolina-pawns-2`
- `[packages/chess/src/presets/berolina-pawns-2.ts]` Create: Identical to berolina-pawns but also allows sideways captures (same-rank, adjacent file). `incompatibleWith: ["berolina-pawns", ...same list]` — expect: registers.
- `[packages/chess/src/presets/berolina-pawns-2.test.ts]` Create: 6+ tests (diff from berolina-pawns) — expect: green.
### E.3 — `bouncing-pieces`
- `[packages/chess/src/presets/bouncing-pieces.ts]` Create: `getExtraMoves` for bishop + queen. Computes diagonal rays that reflect off the left/right file edges (file wraps to the opposite side with reflected direction). Stops on capture or friendly block. Respects the existing move-generator blocking logic. Stays within rank bounds. `incompatibleWith: ["bouncing-pieces-2", "wrap-board"]` (cylinder already wraps, so bouncing is redundant) — expect: registers.
- `[packages/chess/src/presets/bouncing-pieces.test.ts]` Create: 10+ tests. Bishop on e4 can reach squares via right-edge bounce; queen rays reflect and stop on block; capture on reflected ray works — expect: green.
### E.4 — `bouncing-pieces-2`
- `[packages/chess/src/presets/bouncing-pieces-2.ts]` Create: Like bouncing-pieces but reflects off all four edges. Max reflection count capped at 2 (prevents infinite-loop ray computation on empty board). Document the cap — expect: registers.
- `[packages/chess/src/presets/bouncing-pieces-2.test.ts]` Create: 8+ tests — expect: green.
### E.5 — Phase E verification gate
- `[repo root]` Verify: `bun run check` — expect: green.
---
## Phase F — Lobby Integration + E2E
### F.1 — Audit `incompatibleWith` graph
- `[packages/chess/src/presets/presets.test.ts]` Add: Reflexive + symmetric check — if A declares B incompatible, assert B declares A incompatible (or explicitly opt out with a comment). Automate across all presets — expect: all 12 new presets pair correctly.
### F.2 — `suggestedPresets` wiring in layouts
- `[packages/chess/src/layouts/*.ts]` (each premade) Update: Populate `suggestedPresets` for Knightmate → knightmate-rules, Monster → monster-rules, Pawns-Only → first-promotion-wins, Dunsany → [] (no canonical rules), Horde → [] — expect: lobby shows suggestion chips.
- `[packages/chess/src/ui/LayoutPicker.tsx]` (from layouts plan) Extend: Render a small "Suggested rules" badge group next to each layout when `suggestedPresets.length > 0`. Tapping a badge enables that preset — expect: click-to-enable works.
### F.3 — Lobby rule filter
- `[packages/chess/src/ui/RulesDrawer.tsx]` Extend: Group the 12+existing presets into sections (King Variants, Objectives, Movement, Multimove, Pieces, …). Use a visual separator. Existing filtering/search still works — expect: drawer is navigable with 20+ presets.
### F.4 — E2E: rule-variant smoke
- `[packages/chess/e2e/rule-variants.spec.ts]` Create: Playwright test covering 3 representative variants end-to-end. (a) Knightmate: attack king, make a non-king move, attack knight → receive check banner; (b) Double-move: white plays two moves, black plays one, repeat; (c) Suicide-chess: capture becomes compulsory when available — expect: green.
### F.5 — Docs + final check
- `[packages/chess/docs/PRESET-API.md]` Update: New hooks section with worked example for each. Add a "Rule Variants Gallery" with one-paragraph descriptions of all 12.
- `[packages/chess/RULES.md]` Update: Cross-reference each new preset with greenchess's description. Link back to greenchess.net/variants.php?cat=4 for authority.
- `[repo root]` Verify: `bun run check` + Playwright — expect: full green.
---
## Risks
1. **Hook ordering matters more than before.** `filterLegalMoves` is pipeline-composable, but `shouldAdvanceTurn` and `overridePieceMoves` use first-match-wins. Mitigation: document ordering in PRESET-API.md; add incompatibleWith between presets that both override the same hook for the same subject (e.g., double-move vs monster-rules).
2. **Suicide + HP edge case.** Capture-is-compulsory + non-lethal-capture (HP): does "any capture available" check count moves that DEAL damage even if target survives? Design call in the plan: YES (the move IS a capture). Documented in D.1 notes.
3. **Multi-royal performance.** Check detection iterates all royal pieces. With Dual-king (2 kings) and Coregal (1 king + N queens), per-move cost grows linearly. Mitigation: cap the royal set effectively at ~5 pieces (realistic max: king + all queens on a mid-game board). Profile only if it shows up on the critical path.
4. **Check banner UX with multi-royal.** Current UI shows "Check!" on the king. With Coregal, which royal is attacked? Mitigation: adjust the banner to say "Check!" with a highlighted piece indicator rendered via the existing check-indicator component; deferred to a small follow-up UI task — not in this plan.
5. **Promotion to king (`dual-king` + underpromotion).** Possible to pile up 3+ kings per side via promotion. Game rules stay sound; just verify `getAllLegalMoves` doesn't explode. Mitigation: integration test with 3 kings per side.
6. **Berolina en passant interpretation.** Several competing interpretations exist; we pick one (reflected en passant to the passed-over square) and document it in the preset's comments. If users disagree, open follow-up issue.
7. **Override-piece-moves collisions.** Two presets overriding pawns: undefined behavior. Mitigation: `incompatibleWith` declarations + dev-mode console warning.
---
## Deferred / Post-Landing
- **Royalty Transfer Chess** (T3). Needs mid-game "which king is active" toggle — UX outside "one move at a time".
- **PGN notation with non-king royalty**. E.g., `+` on knight-in-check in Knightmate. Rabbit hole.
- **Ranked / ELO tracking per variant**. Out of scope; variants are opt-in novelty.
- **"Fairy chess" pieces** (Amazon, Nightrider, Archbishop). Registered via `PIECE_TYPE_REGISTRY` easily; but these are a separate piece-type plan, not rule variants.
- **Per-variant tutorials / help popovers**. Static text live in RULES.md and RulesDrawer descriptions today; a tutorial overlay is nice-to-have.
- **Allow Extinction Chess target type to be picked via UI chip**. Noted inside D.3; small follow-up UI task.

View file

@ -0,0 +1,370 @@
# Starting Layouts — Custom Board Setups Before Game Start
## TL;DR
Add a **Starting Layout** concept, orthogonal to rule presets, that controls which pieces start where. Ship 7 premades (Classic, Dunsany, Monster, Pawns-Only, Horde, Knightmate, Chess960) plus an **Empty board** sandbox, a **visual drag-and-drop editor**, **FEN import/export**, a **shareable code**, and a **localStorage library** of user-saved layouts.
Architecture is layered so a future "board shape/dimensions" extension (non-8×8, removed squares, hex) can land without re-plumbing the wiring installed here.
**Deliverable**: from the lobby, a user can pick "Dunsany" from a Layout dropdown (or paste a FEN, or draw their own) BEFORE creating a room. The selected layout is server-authoritative, broadcast on `room.create`, persisted through reconnect, and shown to both players in multiplayer.
**Phases**: 5 phases, 22 tasks, hard-gated by `bun run check` green between each.
---
## Context
### What Shipped Last
- Preset-flexibility architecture (commit `cc30545`): decoupled piece-type registry, damage pipeline, preset-state storage, visual-effect bus, hook context objects.
- Custom piece types plug in via `PIECE_TYPE_REGISTRY`; new piece attributes via `PresetDef.pieceAttributes`.
- Engine construction: `new ChessEngine(activePresets?)` — piece placement is **always** FIDE via `generateStartingPosition(session)` called unconditionally in the constructor.
### Where "8×8" Is Hardcoded (inventory)
A parallel explore pass found the following coupling. This is a **board-layouts** plan, not a board-dimensions plan — we deliberately scope around these so a future shape plan can ride on top without double-plumbing.
**Board-shape-tight (NOT touched by this plan):**
- `coord.ts` — `fileOf/rankOf/squareOf` assume `rank*8+file`.
- `rules/castling.ts` — king/rook home squares hardcoded (e1=4, h1=7, a1=0, e8=60, h8=63, a8=56).
- `rules/promotion.ts` — `PROMOTION_RANK = {white:7, black:0}`.
- `rules/primitives.ts` — pawn home ranks (1, 6), double-advance distance (16).
- `rules/enpassant.ts` — ±8 rank offset for captured pawn.
- `ui/Board.tsx` — `grid-cols-8 grid-rows-8` and 0..7 loops.
- `protocol.ts` — algebraic regex `/^[a-h][1-8]$/`.
**Layout-tight (WILL be touched):**
- `engine.ts:365` — unconditional `generateStartingPosition(this.session)` call in constructor.
- `starting-position.ts` — the function itself; must become configurable.
- `server/rooms.ts` + `PROTOCOL.md` — `room.create` payload gains optional `layoutId` and/or `fen`.
- `ui/Lobby.tsx` — "Create Room" flow currently passes `{}`; must pass layout selection.
- `net/types.ts` + `net/client.ts` — request/response shapes for layout.
### What Still Decouples Cleanly
- `engine.spawnPiece(type, color, square, {reason: "initial"})` already exists and fires `onPieceSpawn`. A configurable starting-position generator uses this directly — so `piece-hp` will seed `Hp` on every piece regardless of which layout is chosen, no preset edits needed.
- `PIECE_TYPE_REGISTRY` already dispatches move generation by type string — so if a layout places Nightriders or Amazons at start (via a future piece-types preset), the engine handles them.
### Out of Scope (Deferred)
- **Non-8×8 boards, irregular shapes, hex.** Layouts are piece-placement only; the board is always 64 squares of the same topology the engine already understands. A future "Board Shape" plan rides on top and adds a `BoardConfig` beside `StartingLayout`.
- **New rule-variant presets** (Berolina pawns, Bouncing bishop, Coregal, Suicide, Knightmate's king-is-a-knight rule, Double-move, Monster's white-moves-twice). Those are preset work. This plan ONLY adds layouts. **Exception**: Knightmate layout ships without its rule variant; user can enable a hypothetical `knightmate-rules` preset later, or just play it as "two kings, no knights" for now. The layout selector shows a "Suggested rules" hint (read-only) alongside each premade.
- **Mid-game position editing.** Editor is pre-game only. Editing a running game would need a whole analysis-mode workflow.
- **Server protocol v2.** We add OPTIONAL fields to `room.create`; omission = classic layout (backward compatible).
- **Save-file migration for FIDE autosaves.** Autosave is per-layout keyed; old FIDE saves keep working under the "Classic" layout.
---
## Work Objectives
### Core Objective
Introduce a `StartingLayout` abstraction the engine consumes on construction, split the existing `generateStartingPosition` into a registry + default, and build the UI + networking that lets users pick, create, save, share, and import layouts.
### Primary Deliverables
1. **`StartingLayout` type** — `{ id: string; name: string; description: string; pieces: PiecePlacement[]; suggestedPresets?: string[] }`.
2. **`LAYOUT_REGISTRY`** with the 7 premades (Classic, Dunsany, Monster, Pawns-Only, Horde, Knightmate, Empty) + one pseudo-entry (Chess960) that generates a seeded random layout on each `.build(seed)`.
3. **`engine.ts` accepts `layout` option**; `generateStartingPosition` parametrized.
4. **`ui/LayoutPicker`** — lobby dropdown of premades with preview thumbnails and "Custom…" entry opening the editor.
5. **`ui/LayoutEditor`** — drag-and-drop piece placement, piece palette sidebar, FEN import/export, save to library, share-code generation, minimum-king validation.
6. **`net/protocol`** — `room.create` payload gains optional `layout: { id: string } | { fen: string } | { pieces: PiecePlacement[] }`. Server validates, echoes via `room.created`.
7. **Shareable layout URL** — `?layout=ENCODED_FEN` or `?layoutId=dunsany`; lobby pre-selects if present.
8. **localStorage library** — `houserules:layouts:v1` key, up to 20 recent + starred.
9. **FEN utilities** — `toFen(pieces)`, `fromFen(string)` round-trip with validation.
10. **Autosave isolation** — current autosave key `chess-autosave` becomes `chess-autosave:${layoutId}` so selecting a different layout doesn't resurrect a finished game.
### Secondary Deliverables
- Solo mode respects layout too (Play Solo from lobby honors the selector).
- Playwright E2E: create room with Dunsany; both clients render identical position.
- Unit tests for every layout (piece counts, king-per-side, no-pawn-on-promo-rank warnings).
### Non-Goals (clarifying)
- Editor operating on 10×10 board. Editor is 8×8 only this iteration.
- Undo/redo in the editor beyond natural drag-drop "pick up, put down".
- Auto-suggest of compatible presets (display only; no auto-enable).
---
## Architecture Sketch
### New file: `packages/chess/src/layouts/types.ts`
```ts
export interface PiecePlacement {
readonly type: PieceType;
readonly color: PieceColor;
readonly square: Square;
readonly hasMoved?: boolean; // default false
}
export interface StartingLayout {
readonly id: string; // kebab-case; stable for URLs
readonly name: string; // display label
readonly description: string;
readonly pieces: readonly PiecePlacement[];
/** Optional: preset IDs suggested alongside this layout.
* UI displays as a hint; user decides whether to enable. */
readonly suggestedPresets?: readonly string[];
/** "premade" — shipped in LAYOUT_REGISTRY.
* "custom" — user-authored, not in registry, carried by-value. */
readonly source: "premade" | "custom";
}
```
### New file: `packages/chess/src/layouts/registry.ts`
```ts
class LayoutRegistry {
register(layout: StartingLayout): void;
get(id: string): StartingLayout | undefined;
list(): readonly StartingLayout[];
}
export const LAYOUT_REGISTRY = new LayoutRegistry();
```
Registry is registry-pattern identical to `PRESET_REGISTRY` and `PIECE_TYPE_REGISTRY`. Side-effect registration from a `layouts/index.ts` barrel.
### New file: `packages/chess/src/layouts/fen.ts`
Implements standard Forsyth-Edwards piece placement (fields 1-2 of full FEN: `"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w"`). Ignores castling rights / en passant / halfmove for v1 (engine doesn't need them from the layout — they come from gameplay).
### Refactor: `packages/chess/src/starting-position.ts`
```ts
// NEW signature (old one becomes a thin wrapper over CLASSIC layout)
export function applyLayout(session: Session, layout: StartingLayout): EntityId[];
// Back-compat wrapper — existing callers in tests stay working.
export function generateStartingPosition(session: Session): EntityId[] {
return applyLayout(session, CLASSIC_LAYOUT);
}
```
### Refactor: `packages/chess/src/engine.ts`
```ts
interface EngineOptions {
readonly activePresets?: ActivePresetSet;
readonly layout?: StartingLayout; // defaults to CLASSIC
}
constructor(opts: EngineOptions = {}) {
this.session = new Session({ autoFire: false });
applyLayout(this.session, opts.layout ?? CLASSIC_LAYOUT);
this.activePresets = opts.activePresets ?? new ActivePresetSet();
}
```
**Backward compat**: existing call sites `new ChessEngine()` and `new ChessEngine(presets)` continue to work — we overload the constructor signature.
### Protocol extension
`room.create` payload gains OPTIONAL `layout` field:
```ts
type RoomCreatePayload = {
rulesetIds?: string[];
layout?:
| { kind: "premade"; id: string }
| { kind: "custom"; pieces: PiecePlacement[]; name?: string }
| { kind: "fen"; fen: string; name?: string };
};
```
Server resolves to a `StartingLayout` value, validates (king count, square bounds, piece counts), stores in room, passes to `new ChessEngine({ layout })`. Protocol stays at v1.
`room.created` response and `room.joined` event gain `layout` field (resolved form, always `{kind:"custom"|"premade", name, pieces}`) so late joiners can display the name.
### UI shape
- **Lobby**: Layout selector above "Create Room" button. Dropdown with premades; last entry "Custom…" opens the editor modal. Selected layout encoded in the Create button's outgoing request.
- **Editor modal**: Full-screen overlay. Left sidebar = piece palette (drag source) + FEN field + "Save to Library" + "Copy Share Link". Center = interactive 8×8 board (drop target, click-to-delete). Right sidebar = validation panel + "Use This Layout" CTA.
- **Library drawer**: Opens from within the editor; lists saved layouts with name, piece count, last-used date, star/unstar, delete, duplicate.
---
## Phase A — Foundations (engine + types)
### A.1 — Add `StartingLayout` types and `LAYOUT_REGISTRY`
- `[packages/chess/src/layouts/types.ts]` Create: Define `PiecePlacement`, `StartingLayout`, `LayoutValidationResult` — expect: new exported types compile under strict TS.
- `[packages/chess/src/layouts/registry.ts]` Create: Implement registry class mirroring `PRESET_REGISTRY` shape — expect: `register / get / list` work; duplicate-id registration throws.
- `[packages/chess/src/layouts/index.ts]` Create: Barrel that imports every premade module for side-effect registration — expect: one import line = all layouts registered.
### A.2 — Parametrize `starting-position.ts`
- `[packages/chess/src/starting-position.ts]` Refactor: Extract `applyLayout(session, layout): EntityId[]` that inserts piece facts via `spawnPiece`-equivalent (use `session.nextId()` + `insert` sequence matching the current loop). Add `CLASSIC_LAYOUT` const built from existing hardcoded arrays — expect: new `applyLayout` + old `generateStartingPosition` wrapper both return identical EntityId arrays for the default layout.
- `[packages/chess/src/starting-position.test.ts]` Update: Add tests that `applyLayout(session, CLASSIC_LAYOUT)` matches the existing 32-piece expectation — expect: green.
### A.3 — Register Classic + Empty
- `[packages/chess/src/layouts/classic.ts]` Create: Re-exports `CLASSIC_LAYOUT` from `starting-position.ts` under `{id: "classic", name: "Classic Chess", ..., source: "premade"}` and registers with `LAYOUT_REGISTRY` — expect: `LAYOUT_REGISTRY.get("classic")` returns the FIDE setup.
- `[packages/chess/src/layouts/empty.ts]` Create: Empty-board layout with `pieces: []` — expect: registered as `id: "empty"`.
### A.4 — Engine accepts `layout` option
- `[packages/chess/src/engine.ts]` Refactor: Change constructor to `constructor(opts: EngineOptions = {})` where `EngineOptions = {activePresets?, layout?}`. Default layout to `CLASSIC_LAYOUT`. Support legacy `new ChessEngine(activePresets)` call via overload signature — expect: all existing callers keep working unchanged (tests green).
- `[packages/chess/src/engine-presets.test.ts]` Sanity: Add one test instantiating `new ChessEngine({ layout: EMPTY_LAYOUT })` and asserting `session.allFacts().filter(f => f.attr === "PieceType").length === 0` — expect: green.
### A.5 — Phase A verification gate
- `[repo root]` Verify: Run `bun run check` — expect: typecheck + lint + all 930+ tests green. No functional behavior change.
---
## Phase B — Premade Layouts + FEN
### B.1 — FEN round-trip utility
- `[packages/chess/src/layouts/fen.ts]` Create: `toFen(pieces: readonly PiecePlacement[]): string` and `fromFen(fen: string): {pieces: PiecePlacement[], errors: string[]}`. Support only the piece-placement field (first space-delimited token); ignore side-to-move/castling/en-passant/halfmove — expect: round-trip on CLASSIC_LAYOUT produces `"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"`.
- `[packages/chess/src/layouts/fen.test.ts]` Create: 10+ tests covering classic round-trip, empty board (`"8/8/8/8/8/8/8/8"`), partial positions, malformed input (returns errors, not throws), non-FIDE pieces (use `PIECE_TYPE_REGISTRY` glyph mapping — fall back to placeholder `*` for unknown types) — expect: green.
### B.2 — Premade: Dunsany
- `[packages/chess/src/layouts/dunsany.ts]` Create: White = 32 pawns on squares 0..31 (ranks 1-4). Black = standard FIDE back rank on rank 8 + pawns on rank 7 — expect: `pieces.length === 32 + 16 = 48`; registered as `id: "dunsany"`, name `"Dunsany's Chess"`; `suggestedPresets: []`.
### B.3 — Premade: Monster
- `[packages/chess/src/layouts/monster.ts]` Create: White = king on e1 + 4 pawns on c2/d2/e2/f2. Black = full FIDE setup — expect: `pieces.length === 5 + 16 = 21`; `suggestedPresets: ["double-move"]` (commented as not-yet-implemented preset, displayed as dimmed hint in UI).
### B.4 — Premade: Pawns-Only
- `[packages/chess/src/layouts/pawns-only.ts]` Create: Each side = king + 8 pawns on second/seventh ranks. No other pieces — expect: `pieces.length === 2 + 16 = 18`. (We keep 1 king per side to satisfy the "min 1 king" validator; the greenchess variant uses no king + first-promotion-wins — that's a rule-preset concern. Users enable `capture-to-win` alongside if they want that flavor.)
### B.5 — Premade: Horde
- `[packages/chess/src/layouts/horde.ts]` Create: White = 36 pawns (all of ranks 1-4 except a4/b4/g4/h4 which are empty — standard Horde pattern per lichess). Black = full FIDE setup — expect: `pieces.length === 36 + 16 = 52`.
### B.6 — Premade: Knightmate layout
- `[packages/chess/src/layouts/knightmate.ts]` Create: Both sides = knights replace king on e1/e8; kings replace knights on b1/g1/b8/g8. (Per greenchess: "knights become royal". Since we don't ship the royalty rule yet, this is just a piece-swap — play as "no king, two knights to defend". The file comment notes this limitation.) — expect: `pieces.length === 32`; `suggestedPresets: ["knightmate-rules"]` (future).
### B.7 — Premade: Chess960 (Fischer random)
- `[packages/chess/src/layouts/chess960.ts]` Create: Export function `buildChess960Layout(seed: number): StartingLayout` that follows the Chess960 generation rules (bishops on opposite-color squares, king between the two rooks, other pieces uniformly random). Register a SPECIAL SHIM in the registry with `id: "chess960"` whose name is `"Chess960 (Fischer Random)"` and whose `pieces` is a placeholder — the `LayoutPicker` detects this id and, on selection, calls `buildChess960Layout(Math.floor(Math.random() * 960))` to produce the actual layout — expect: generator produces valid 960 positions; bishop-color and king-between-rooks invariants hold for all 960 seeds; unit test asserts 20 random seeds all satisfy invariants.
### B.8 — Layout validator
- `[packages/chess/src/layouts/validate.ts]` Create: `validateLayout(layout): {errors: string[]; warnings: string[]}`. Errors (block): not exactly 1 white king, not exactly 1 black king, duplicate square, square out of [0, 63]. Warnings (allow-but-flag): pawn on rank 1 or rank 8 (can't move / impossible end state), wildly unequal material, missing opponent's pieces — expect: unit tests cover every branch.
- `[packages/chess/src/layouts/validate.test.ts]` Create: 12+ tests — expect: green.
### B.9 — Phase B verification gate
- `[repo root]` Verify: Run `bun run check` — expect: green. Premade layouts loadable via registry, FEN round-trips, validator agrees with manually-verified cases.
---
## Phase C — Server & Protocol
### C.1 — Protocol schema for layout payload
- `[packages/server/src/protocol.ts]` Extend: Add optional `layout` discriminated union to `RoomCreatePayload` schema (Valibot) — expect: messages without `layout` field still validate (backward compat); messages with `{kind: "premade", id: "dunsany"}` validate; malformed entries rejected with typed error.
- `[packages/server/src/protocol.test.ts]` Add tests for each kind — expect: green.
### C.2 — Server resolves layout + validates
- `[packages/server/src/rooms.ts]` Update: On `room.create`, resolve the incoming `layout` field to a concrete `StartingLayout` (registry lookup for `kind:"premade"`, `fromFen` for `kind:"fen"`, direct use for `kind:"custom"`), run `validateLayout`, reject with `LAYOUT_INVALID` on validation errors — expect: server accepts premade ids, valid FENs, valid custom piece arrays; rejects invalid.
- `[packages/server/src/game-session.ts]` Update: Pass resolved layout into `new ChessEngine({ layout, activePresets })` — expect: the engine instance starts from the requested layout.
- `[packages/server/src/rooms.test.ts]` Add 5+ test cases covering premade id, FEN, custom pieces, invalid FEN, invalid king count — expect: green.
### C.3 — Echo resolved layout to clients
- `[packages/server/src/broadcast.ts]` Update: `room.created` and `room.joined` responses include `layout: {id, name, pieces}` field so clients can display the name and re-render if they arrived via shared URL without local layout state — expect: protocol test updated; server unit test verifies both events carry the field.
### C.4 — Update PROTOCOL.md
- `[packages/server/PROTOCOL.md]` Update: Document the new `layout` field on `room.create`, its discriminated union, and the echo in `room.created` / `room.joined`. Include example payloads — expect: docs accurate.
### C.5 — Phase C verification gate
- `[repo root]` Verify: Run `bun run check` and manually smoke-test via `bun run packages/server/src/index.ts` + two browser tabs creating/joining rooms with different layouts — expect: green; smoke test clean.
---
## Phase D — Lobby Integration + Client Wiring
### D.1 — LayoutPicker component
- `[packages/chess/src/ui/LayoutPicker.tsx]` Create: Dropdown + a mini-preview thumbnail. Lists all registry layouts + "Custom…" entry. Controlled via `value: string` / `onChange: (layoutId, resolvedLayout) => void`. For `chess960`, regenerates seed on every selection — expect: renders all premades; selecting one fires change handler with the resolved layout.
### D.2 — Wire picker into Lobby
- `[packages/chess/src/ui/Lobby.tsx]` Update: Add `LayoutPicker` above Create Room button. Store selected layout in component state. Pass to `oneShotRoomRequest('room.create', { layout: selectedLayout })`. `handlePlaySolo` uses it too, passing to `new ChessEngine({ layout })` — expect: E2E-visible: dropdown appears, selecting Dunsany then Create → server receives the layout.
### D.3 — Protocol client updates
- `[packages/chess/src/net/types.ts]` Extend: Mirror server's `RoomCreatePayload` type — expect: compile-clean.
- `[packages/chess/src/net/lobby-request.ts]` Update: Payload type for `room.create` includes optional `layout` — expect: typed end-to-end.
### D.4 — GameView displays layout name
- `[packages/chess/src/ui/GameView.tsx]` Update: Show a subtle label near the room code pill: "Dunsany's Chess" / "Custom layout" etc. Sourced from the `layout` field on the room-state broadcast — expect: label renders; hidden when classic.
### D.5 — Autosave keyed by layout
- `[packages/chess/src/persist/autosave.ts]` Refactor: Change autosave key from `chess-autosave` to `chess-autosave:${layoutId}`. Add migration reading the old key as `chess-autosave:classic` — expect: existing FIDE saves still load under Classic; a custom layout has its own autosave slot.
### D.6 — Shareable layout URL
- `[packages/chess/src/app/App.tsx]` Extend: `/` route reads `?layoutId=<id>` or `?fen=<fen>` query params and pre-selects the picker. `/game/:code` unchanged (room state already carries layout). Layout URL share button in Lobby: copies `${origin}/?layoutId=dunsany` or for custom `${origin}/?fen=<encoded>` — expect: pasting a layout URL pre-selects; copying regenerates on demand.
### D.7 — Phase D verification gate
- `[repo root]` Verify: Run `bun run check` — expect: green. E2E smoke: lobby picker, premade selection, multiplayer flow each work.
---
## Phase E — Custom Layout Editor + Library
### E.1 — LayoutEditor component (shell)
- `[packages/chess/src/ui/LayoutEditor.tsx]` Create: Full-screen modal overlay with three panels: piece palette (left), 8×8 board (center), actions panel (right). Uses existing `Board` in "editor mode" — pieces draggable without move-legality checks, tap-to-remove — expect: modal opens from LayoutPicker's "Custom…" entry; closes via Esc or Cancel.
### E.2 — Editor drag-and-drop placement
- `[packages/chess/src/ui/LayoutEditor.tsx]` Extend: Palette items draggable onto squares; placing on an occupied square replaces; dragging off the board removes. Works on mobile via long-press + tap-target-to-place — expect: placing a queen on d4 yields `{type: "queen", color: "white", square: 27}` in the editor state.
### E.3 — Editor validation panel
- `[packages/chess/src/ui/LayoutEditor.tsx]` Extend: Live validation display using `validateLayout`. Green check when valid; red errors block "Use This Layout"; amber warnings display but don't block — expect: removing a king shows "Missing white king" error, hides on re-add.
### E.4 — FEN import/export in editor
- `[packages/chess/src/ui/LayoutEditor.tsx]` Extend: Textarea bound to live FEN string via `toFen`; "Load FEN" button calls `fromFen` and overwrites editor state; editing the field updates the board — expect: typing a valid FEN re-populates the board.
### E.5 — Save to library
- `[packages/chess/src/ui/LayoutEditor.tsx]` Extend: "Save to Library" button prompts for name, writes to `localStorage[houserules:layouts:v1]` as an array of saved layouts (max 20, FIFO eviction) with timestamp + starred bool. Duplicates (same piece set) replace-in-place — expect: saved layouts appear in LayoutPicker under a "Saved" section.
### E.6 — Library drawer
- `[packages/chess/src/ui/LayoutEditor.tsx]` Extend: "Library" button toggles a side drawer listing saved layouts: name, piece count, last-used, actions (load, star, duplicate, delete) — expect: drawer opens; actions persist to localStorage.
### E.7 — Share-code export
- `[packages/chess/src/ui/LayoutEditor.tsx]` Extend: "Copy Share Link" button writes `${origin}/?fen=${encodeURIComponent(fen)}&name=${encodeURIComponent(name)}` to clipboard. Toast confirms — expect: pasted link pre-selects custom layout with matching FEN in a fresh browser.
### E.8 — "Use This Layout" commits to Lobby
- `[packages/chess/src/ui/LayoutEditor.tsx]` Extend: CTA disabled until validation green. On click, calls `onApply(layoutAsStartingLayout)` passed from Lobby; editor closes; Lobby's LayoutPicker now reflects "Custom" selection — expect: Create Room after this uses the custom layout.
### E.9 — E2E + final check
- `[packages/chess/e2e/layout-editor.spec.ts]` Create: Playwright test for full flow — open editor, drag pieces, save, load, create multiplayer room, verify both clients render the same position — expect: green on first run.
- `[repo root]` Verify: `bun run check` + Playwright — expect: green.
---
## Risks
1. **Layout-data size over the wire.** A full custom layout is ~32 piece placements ≈ 500 bytes. Well under the 64KB WS limit. FENs are under 80 bytes. Non-issue.
2. **Mobile drag-and-drop UX** in the editor. Mitigation: tap-select-then-tap-square fallback plus haptic on place. Ship both.
3. **Chess960 bishop-color invariant bugs.** Mitigation: unit-test all 960 seeds (cheap — generation is pure).
4. **Autosave migration.** Moving the key is one-time; we read the old key on startup and rewrite under the new namespace. A single test covers round-trip.
5. **"Knightmate without its rule" confusion.** Mitigation: layout description text spells out "Layout only — Knight-as-royal rule coming later; today this plays as kingless".
6. **Server-side DoS via huge custom layouts.** Mitigation: validator caps piece count at 64 (board size), rejects duplicate squares, enforces square bounds. Already part of `validateLayout`.
7. **Preset compat regressions.** Piece-hp, queen-splits etc. all run through `spawnPiece` which layouts also use. Mitigation: keep `spawnPiece` as the single spawn path; add a direct unit test (start from Dunsany with piece-hp active → every pawn has Hp=2).
---
## Deferred / Post-Landing
- **Preset suggestions auto-apply toggle** — "Enable suggested rules for Monster? (adds double-move)".
- **Board shape/dimensions refactor** — non-8×8 boards, hex, removed squares. Rides on `StartingLayout.board?: BoardConfig` extension.
- **Monster / Knightmate / Berolina rule presets** — new preset work to make those layouts play their intended way. Orthogonal to this plan.
- **Mid-game position editor** — analysis-mode workflow.
- **Thumbnail preview in LayoutPicker** — render actual mini-board SVG instead of piece-count badge. Nice-to-have.
- **Layout import from lichess/chess.com PGN headers** — nice-to-have.

View file

@ -0,0 +1,653 @@
/**
* E2E — starting layouts feature (picker + editor + shareable URLs).
*
* Covers Phase A-E user-visible flows:
* 1. Lobby shows a LayoutPicker with every registered premade.
* 2. Picking a premade changes the selection (description text
* updates to match).
* 3. Opening the "Custom…" entry opens the editor modal.
* 4. Placing pieces in the editor via the palette + board clicks.
* 5. Live validation flips the CTA's disabled state.
* 6. Saving to library round-trips through localStorage.
* 7. "Use This Layout" commits a custom layout back to the lobby.
* 8. Query-string ?fen= pre-selects a custom layout on page load.
*
* Does NOT exercise the multiplayer flow (server round-trip) because
* the broadcast-level contract is covered in server unit tests and
* packages/chess/e2e/multiplayer.spec.ts. This spec runs against a
* local dev server only — no WS server needed.
*/
import { test, expect, type Page } from '@playwright/test';
import { spawn, type ChildProcess } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
// ─────────────────────────────────────────────────────────────────────
// WebSocket server lifecycle — reused across multiplayer test cases.
// Mirrors the pattern from e2e/multiplayer.spec.ts so tests can run
// against a fresh server or a local dev server.
// ─────────────────────────────────────────────────────────────────────
let wsServerProcess: ChildProcess | null = null;
async function isWsServerRunning(): Promise<boolean> {
try {
const res = await fetch('http://localhost:7357/healthz');
return res.ok;
} catch {
return false;
}
}
test.beforeAll(async () => {
if (await isWsServerRunning()) return;
wsServerProcess = spawn('bun', ['run', 'packages/server/src/index.ts'], {
stdio: 'pipe',
env: { ...process.env, PORT: '7357' },
});
for (let i = 0; i < 20; i++) {
await sleep(250);
if (await isWsServerRunning()) break;
}
});
test.afterAll(async () => {
if (wsServerProcess !== null) {
wsServerProcess.kill('SIGINT');
await sleep(200);
wsServerProcess = null;
}
});
test.describe('LayoutPicker (lobby)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId('page-home')).toBeVisible();
});
test('renders the picker with Classic selected by default', async ({ page }) => {
const picker = page.getByTestId('layout-picker');
await expect(picker).toBeVisible();
await expect(picker).toHaveValue('classic');
});
test('picker includes every expected premade', async ({ page }) => {
const picker = page.getByTestId('layout-picker');
// All premades + Custom entry. "Empty" is intentionally absent —
// it's a test fixture only, not a selectable starting position
// (fails validation, serves no player need).
for (const id of [
'classic',
'dunsany',
'monster',
'pawns-only',
'horde',
'knightmate',
'chess960',
]) {
await expect(picker.locator(`option[value="${id}"]`)).toHaveCount(1);
}
await expect(picker.locator('option[value="empty"]')).toHaveCount(0);
await expect(picker.locator('option[value="__custom__"]')).toHaveCount(1);
});
test('selecting Dunsany updates the description', async ({ page }) => {
const picker = page.getByTestId('layout-picker');
await picker.selectOption('dunsany');
await expect(
page.getByTestId('layout-picker-description'),
).toContainText(/pawn tide/i);
});
test('?layoutId=dunsany query param pre-selects the layout', async ({ page }) => {
await page.goto('/?layoutId=dunsany');
const picker = page.getByTestId('layout-picker');
await expect(picker).toHaveValue('dunsany');
});
test('malformed ?fen query silently falls back to Classic', async ({ page }) => {
await page.goto('/?fen=not-a-valid-fen');
const picker = page.getByTestId('layout-picker');
// Custom layout from malformed FEN isn't applied; Classic stays
// selected.
await expect(picker).toHaveValue('classic');
});
});
test.describe('LayoutEditor modal', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.getByTestId('layout-picker').selectOption('__custom__');
await expect(page.getByTestId('layout-editor')).toBeVisible();
});
test('opens with an empty board — validation errors show', async ({ page }) => {
// Empty board = no kings → validation errors.
await expect(page.getByTestId('validation-errors')).toBeVisible();
// Primary CTA disabled.
await expect(page.locator('[data-action="apply-layout"]')).toBeDisabled();
});
test('placing one king per side makes the layout valid', async ({ page }) => {
// Select white king from palette, click e1.
await page.getByTestId('palette-white-king').click();
await page.getByTestId('editor-square-4').click(); // e1 = square 4
await page.getByTestId('palette-black-king').click();
await page.getByTestId('editor-square-60').click(); // e8 = square 60
// Validation flips to "ok".
await expect(page.getByTestId('validation-ok')).toBeVisible();
await expect(page.locator('[data-action="apply-layout"]')).toBeEnabled();
});
test('erase brush removes a placed piece', async ({ page }) => {
await page.getByTestId('palette-white-king').click();
await page.getByTestId('editor-square-4').click();
// Piece rendered in the square.
await expect(
page.getByTestId('editor-square-4').locator('img'),
).toBeVisible();
await page.locator('[data-action="brush-erase"]').click();
await page.getByTestId('editor-square-4').click();
await expect(
page.getByTestId('editor-square-4').locator('img'),
).toHaveCount(0);
});
test('FEN Load replaces the board', async ({ page }) => {
const fen = '8/8/8/8/8/8/8/4K2k'; // white king e1, black king h1 (not a realistic position but valid)
await page.getByTestId('editor-fen').fill(fen);
await page.locator('[data-action="load-fen"]').click();
// White king at square 4 (e1).
await expect(
page.getByTestId('editor-square-4').locator('img'),
).toBeVisible();
});
test('FEN field updates live as pieces are placed on the board', async ({
page,
}) => {
const fenField = page.getByTestId('editor-fen');
// Empty board → all-eights FEN.
await expect(fenField).toHaveValue('8/8/8/8/8/8/8/8');
// Place a white king on e1.
await page.getByTestId('palette-white-king').click();
await page.getByTestId('editor-square-4').click();
// Textarea now reflects the board without any Load/Reset step.
await expect(fenField).toHaveValue('8/8/8/8/8/8/8/4K3');
// Place a black king on e8.
await page.getByTestId('palette-black-king').click();
await page.getByTestId('editor-square-60').click();
await expect(fenField).toHaveValue('4k3/8/8/8/8/8/8/4K3');
});
test('typing into FEN field stops live sync until Load or Discard', async ({
page,
}) => {
// Place a king so the live FEN has something meaningful.
await page.getByTestId('palette-white-king').click();
await page.getByTestId('editor-square-4').click();
const fenField = page.getByTestId('editor-fen');
await expect(fenField).toHaveValue('8/8/8/8/8/8/8/4K3');
// Start editing the textarea — live sync should now pause.
await fenField.fill('garbage in progress');
// "Unsaved edit" indicator appears; the Load button is enabled.
await expect(page.getByTestId('editor-fen-dirty')).toBeVisible();
// Adding more pieces to the board does NOT clobber the draft.
await page.getByTestId('palette-black-king').click();
await page.getByTestId('editor-square-60').click();
await expect(fenField).toHaveValue('garbage in progress');
// Discard restores the live FEN and clears the dirty indicator.
await page.locator('[data-action="sync-fen"]').click();
await expect(fenField).toHaveValue('4k3/8/8/8/8/8/8/4K3');
await expect(page.getByTestId('editor-fen-dirty')).toHaveCount(0);
});
test('Use This Layout commits a custom layout to the lobby', async ({ page }) => {
// Build minimal valid layout.
await page.getByTestId('palette-white-king').click();
await page.getByTestId('editor-square-4').click();
await page.getByTestId('palette-black-king').click();
await page.getByTestId('editor-square-60').click();
// Rename to something identifiable.
await page.getByTestId('layout-name').fill('My Mate-in-0');
await page.locator('[data-action="apply-layout"]').click();
// Editor closes, picker now reflects the custom layout.
await expect(page.getByTestId('layout-editor')).not.toBeVisible();
const picker = page.getByTestId('layout-picker');
await expect(picker).toHaveValue('__custom__');
});
test('Save to Library persists across reloads', async ({ page, context }) => {
await page.getByTestId('palette-white-king').click();
await page.getByTestId('editor-square-4').click();
await page.getByTestId('palette-black-king').click();
await page.getByTestId('editor-square-60').click();
await page.getByTestId('layout-name').fill('Persisted');
await page.locator('[data-action="save-to-library"]').click();
// Open the library drawer.
await page.locator('[data-action="library-toggle"]').click();
await expect(page.getByTestId('library-list')).toBeVisible();
await expect(page.locator('text=Persisted').first()).toBeVisible();
// Reload and re-open the editor; library should still have the entry.
await page.reload();
await page.getByTestId('layout-picker').selectOption('__custom__');
await page.locator('[data-action="library-toggle"]').click();
await expect(page.locator('text=Persisted').first()).toBeVisible();
// Cleanup — remove the entry so parallel test runs stay clean.
// (Library is localStorage-scoped per browser context; this block
// is belt-and-suspenders.)
await context.clearCookies();
await page.evaluate(() => {
localStorage.removeItem('houserules:layouts:v1');
});
});
test('Esc closes the editor', async ({ page }) => {
await page.keyboard.press('Escape');
await expect(page.getByTestId('layout-editor')).not.toBeVisible();
});
});
// ─────────────────────────────────────────────────────────────────────
// Solo play — proves the engine actually opens from the selected layout
// (not just that the UI claims it).
// ─────────────────────────────────────────────────────────────────────
test.describe('Solo play — layout selection reaches the engine', () => {
test.beforeEach(async ({ page }) => {
// Clear any autosaved game so we start from a clean slate.
await page.goto('/');
await page.evaluate(() => {
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (key !== null && key.startsWith('paratype-chess:v2:autosave:')) {
localStorage.removeItem(key);
}
}
localStorage.removeItem('paratype-chess:v1:autosave');
});
});
test('Classic (default) Play Solo opens with 32 pieces on standard squares', async ({
page,
}) => {
await page.goto('/');
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
// a1 should have a white rook — standard FIDE setup.
const a1 = page.locator('[data-square="a1"] [data-piece]');
await expect(a1).toBeVisible();
// e1 = white king; use the data-piece attribute to verify type.
const e1 = page.locator('[data-square="e1"] [data-piece]');
await expect(e1).toBeVisible();
});
test('Pawns-Only Play Solo opens with no pieces on back ranks', async ({
page,
}) => {
await page.goto('/');
await page.getByTestId('layout-picker').selectOption('pawns-only');
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
// e1 has the white king (pawns-only always gives each side a king).
await expect(
page.locator('[data-square="e1"] [data-piece]'),
).toBeVisible();
// a1 / h1 should be EMPTY — no rooks in pawns-only.
await expect(
page.locator('[data-square="a1"] [data-piece]'),
).toHaveCount(0);
await expect(
page.locator('[data-square="h1"] [data-piece]'),
).toHaveCount(0);
// Rank 2 is all pawns.
for (const file of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) {
await expect(
page.locator(`[data-square="${file}2"] [data-piece]`),
).toBeVisible();
}
});
});
// ─────────────────────────────────────────────────────────────────────
// Chess960 — re-randomizes on each selection
// ─────────────────────────────────────────────────────────────────────
test('Chess960 generates a fresh random position on each pick', async ({
page,
}) => {
// Snapshot the back-rank piece order for a Chess960 Solo game. Two
// consecutive selections should (with very high probability) yield
// different back ranks — if the picker isn't re-seeding, both runs
// would produce the identical default ordering.
//
// There are 960 legal Chess960 positions; the probability of any
// two random draws colliding is 1/960. Over 8 independent
// selections the chance of all matching some reference is
// (1/960)^7 ≈ 1e-21. Asserting 'not all identical' across 8 picks
// is effectively deterministic.
async function snapshotBackRank(): Promise<string> {
const pieces: string[] = [];
for (const file of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) {
const el = page.locator(`[data-square="${file}1"] [data-piece]`);
const attr = await el.getAttribute('data-piece');
pieces.push(attr ?? '');
}
return pieces.join(',');
}
const snapshots = new Set<string>();
for (let i = 0; i < 8; i++) {
await page.goto('/');
// Clear any stale autosave so every Solo game starts from the
// fresh selected layout rather than a hydrated in-progress board.
await page.evaluate(() => {
for (let j = localStorage.length - 1; j >= 0; j--) {
const key = localStorage.key(j);
if (key !== null && key.startsWith('paratype-chess:v2:autosave:')) {
localStorage.removeItem(key);
}
}
});
await page.getByTestId('layout-picker').selectOption('chess960');
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
snapshots.add(await snapshotBackRank());
}
expect(snapshots.size).toBeGreaterThan(1);
});
// ─────────────────────────────────────────────────────────────────────
// Valid ?fen URL pre-selects Custom
// ─────────────────────────────────────────────────────────────────────
test('?fen query pre-selects the layout (valid FEN path)', async ({ page }) => {
// Minimal valid layout: just two kings on e1 / e8.
const fen = '4k3/8/8/8/8/8/8/4K3';
await page.goto(`/?fen=${encodeURIComponent(fen)}&name=Lone+Kings`);
// Picker shows Custom selected.
await expect(page.getByTestId('layout-picker')).toHaveValue('__custom__');
// Description mentions "Custom layout loaded from your editor."
await expect(
page.getByTestId('layout-picker-description'),
).toContainText(/custom layout/i);
});
// ─────────────────────────────────────────────────────────────────────
// Library operations — delete + star toggle
// ─────────────────────────────────────────────────────────────────────
test.describe('Library operations', () => {
test.beforeEach(async ({ page }) => {
// Start from a known-clean library.
await page.goto('/');
await page.evaluate(() => {
localStorage.removeItem('houserules:layouts:v1');
});
});
test('save → star → unstar → delete round-trips', async ({ page }) => {
await page.goto('/');
await page.getByTestId('layout-picker').selectOption('__custom__');
await expect(page.getByTestId('layout-editor')).toBeVisible();
// Place minimal kings and save.
await page.getByTestId('palette-white-king').click();
await page.getByTestId('editor-square-4').click();
await page.getByTestId('palette-black-king').click();
await page.getByTestId('editor-square-60').click();
await page.getByTestId('layout-name').fill('Star Target');
await page.locator('[data-action="save-to-library"]').click();
await page.locator('[data-action="library-toggle"]').click();
const list = page.getByTestId('library-list');
await expect(list).toBeVisible();
// Find the Star button (the only button with aria-label="Star" or
// "Unstar" inside the list).
const starBtn = list.locator('button[aria-label="Star"]').first();
await expect(starBtn).toBeVisible();
await starBtn.click();
// After toggle, aria-label flips to "Unstar".
const unstarBtn = list.locator('button[aria-label="Unstar"]').first();
await expect(unstarBtn).toBeVisible();
// Toggle back to unstarred.
await unstarBtn.click();
await expect(list.locator('button[aria-label="Star"]').first()).toBeVisible();
// Now delete.
await list
.locator('button[aria-label="Delete"]')
.first()
.click();
// List re-renders with no entries.
await expect(page.locator('text=/no saved layouts/i')).toBeVisible();
});
});
// ─────────────────────────────────────────────────────────────────────
// Layout badge on GameView — shown for non-classic, hidden for classic
// ─────────────────────────────────────────────────────────────────────
test.describe('Layout badge on game view', () => {
test('no badge for classic solo games', async ({ page }) => {
await page.goto('/');
// Ensure no stale layout-name in sessionStorage from earlier tests.
await page.evaluate(() => sessionStorage.removeItem('layout-name'));
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
await expect(page.getByTestId('layout-badge')).toHaveCount(0);
});
// Solo games do NOT set sessionStorage['layout-name'] (that only
// happens on multiplayer create/join paths). For solo-mode badge
// support we'd need to wire selectedLayout through resetToFreshGame
// into sessionStorage too. That's a small follow-up — for now,
// solo games of any layout show no badge, which is why only the
// classic case is asserted here. The multiplayer badge path is
// exercised in the multiplayer-layout test below.
});
// ─────────────────────────────────────────────────────────────────────
// Multiplayer — server-authoritative layout propagation
// Proves the end-to-end pipeline: picker → room.create with layout →
// server resolves + validates + constructs engine → room.created
// echoes layout → client stores name → creator AND joiner render the
// same non-FIDE position.
// ─────────────────────────────────────────────────────────────────────
/**
* Read the back-rank piece order from a page's board. Returns a
* comma-joined snapshot like "white-rook,white-knight,..." suitable
* for equality comparison across pages.
*/
async function snapshotBackRank(
page: Page,
rank: 1 | 8,
): Promise<string> {
const pieces: string[] = [];
for (const file of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) {
const el = page.locator(`[data-square="${file}${rank}"] [data-piece]`);
const attr = await el.getAttribute('data-piece').catch(() => null);
pieces.push(attr ?? '');
}
return pieces.join(',');
}
test('multiplayer: creating a room with Dunsany layout propagates to both players', async ({
browser,
}) => {
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const pageA = await ctxA.newPage();
const pageB = await ctxB.newPage();
// Page A: pick Dunsany and Create Room via the normal Lobby UI
// (not raw WS — we want to prove the client-side request shape
// correctly carries the layout selection).
await pageA.goto('/');
await pageA.getByTestId('layout-picker').selectOption('dunsany');
await pageA.locator('[data-action="create-room"]').click();
// Wait for navigation to the game view.
await pageA.waitForURL(/\/game\//);
await expect(
pageA.locator('[data-testid="turn-indicator"]'),
).toBeVisible();
// Extract the room code from sessionStorage (mirrors what the Lobby
// wrote). This avoids parsing the URL path.
const roomCode = await pageA.evaluate(
() => sessionStorage.getItem('room-code'),
);
expect(roomCode).not.toBeNull();
// Layout badge visible on page A (Dunsany != classic).
await expect(pageA.getByTestId('layout-badge')).toBeVisible();
await expect(pageA.getByTestId('layout-badge')).toContainText(/dunsany/i);
// Page B: join the room via the code input.
await pageB.goto('/');
await pageB.locator('[data-testid="room-code-input"]').fill(roomCode!);
await pageB.locator('[data-action="join-room"]').click();
await pageB.waitForURL(/\/game\//);
// Page B also shows the Dunsany badge — echoed by the server.
await expect(pageB.getByTestId('layout-badge')).toBeVisible();
await expect(pageB.getByTestId('layout-badge')).toContainText(/dunsany/i);
// Both boards render the SAME Dunsany setup. Dunsany has a white
// king on e1 and pawns everywhere on ranks 1-4 (except e1 which
// holds the king). Compare rank-1 snapshots between pages.
const rankA = await snapshotBackRank(pageA, 1);
const rankB = await snapshotBackRank(pageB, 1);
expect(rankA).toBe(rankB);
// Specifically assert rank 1 has a white king at e1 and white
// pawns elsewhere.
expect(rankA).toContain('white-king');
expect(rankA).toContain('white-pawn');
expect(rankA).not.toContain('white-rook'); // rook belongs to standard FIDE, not Dunsany white
// Black's back rank is standard FIDE.
const blackBack = await snapshotBackRank(pageA, 8);
expect(blackBack).toContain('black-rook');
expect(blackBack).toContain('black-king');
await ctxA.close();
await ctxB.close();
});
test('multiplayer: invalid custom layout is rejected with LAYOUT_INVALID toast', async ({
page,
}) => {
// Use the editor to build a KINGLESS custom layout — the server
// should reject it when Create Room is clicked (validator requires
// at least one king per side). The client's normal Lobby flow
// disables validation only for PREMADE selections; custom layouts
// that pass the editor's validation won't trigger this, so we
// forge the request via the Lobby state directly.
//
// Approach: set localStorage entry to pre-populate a "saved" bad
// layout, load it, then wrap the onCustomRequested path. Simpler:
// use the FEN field to inject a kingless board and then use the
// "Copy Share Link" → paste flow.
//
// Easiest approach: bypass the editor entirely by crafting a URL
// with a FEN that parses but validates at 0 kings. The Lobby's
// client-side validateLayout call will filter this out (only
// applying it if errors.length === 0), so the picker stays on
// Classic. Which means a kingless URL-loaded layout never reaches
// the server — already-safe behavior.
//
// Instead assert: attempting to Create Room while the picker
// claims a layout the server would reject does NOT happen because
// every premade passes server validation. This test guards the
// property by firing a create request with custom pieces missing
// a black king directly via raw WebSocket.
await page.goto('/');
const errorMessage = await page.evaluate(async () => {
return new Promise<string>((resolve, reject) => {
const ws = new WebSocket('ws://localhost:7357/ws');
const timer = setTimeout(
() => reject(new Error('timeout')),
5000,
);
ws.onopen = () => {
ws.send(
JSON.stringify({
v: 1,
seq: 1,
ts: Date.now(),
type: 'room.create',
payload: {
layout: {
kind: 'custom',
pieces: [
{ type: 'king', color: 'white', square: 4 },
// No black king — should fail validation.
],
},
},
}),
);
};
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data as string) as {
type: string;
payload: { code?: string; message?: string };
};
if (msg.type === 'error') {
clearTimeout(timer);
ws.close();
resolve(msg.payload.message ?? '(no message)');
} else if (msg.type === 'room.created') {
clearTimeout(timer);
ws.close();
reject(new Error('server accepted kingless layout'));
}
};
ws.onerror = () => {
clearTimeout(timer);
reject(new Error('ws error'));
};
});
});
// Server rejects with a human-readable message containing "king".
expect(errorMessage).toMatch(/king/i);
});

View file

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

View file

@ -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<T extends Record<string, unknown>>
}
}
/**
* 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();
}
/**

View file

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

View file

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

View file

@ -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<typeof buildChess960Layout>["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<typeof buildChess960Layout>["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),
);
});
});

View file

@ -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<readonly [number, number]> = [
[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);

View file

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

View file

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

View file

@ -0,0 +1,28 @@
/**
* Layout: Empty board (internal test fixture).
*
* Zero pieces. NOT registered in LAYOUT_REGISTRY — it fails the
* validator (requires ≥ 1 king per side) so exposing it in the
* lobby picker would be a dead-end UX: the server would reject
* Create Room, and solo play would drop the user onto a blank
* board with nothing to click.
*
* The "blank canvas" use case is handled by the LayoutEditor's
* Custom… flow — the editor already opens from an empty piece
* list and lets the user compose freely.
*
* EMPTY_LAYOUT stays exported for:
* - Unit tests that need a zero-piece starting state.
* - `new ChessEngine({ layout: EMPTY_LAYOUT })` as a minimal
* fixture in preset/rule tests.
*/
import type { StartingLayout } from "./types.js";
export const EMPTY_LAYOUT: StartingLayout = {
id: "empty",
name: "Empty Board",
description:
"Internal test fixture — not registered in LAYOUT_REGISTRY.",
pieces: [],
source: "custom",
};

View file

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

View file

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

View file

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

View file

@ -0,0 +1,42 @@
/**
* 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";
import "./dunsany.js";
import "./monster.js";
import "./pawns-only.js";
import "./horde.js";
import "./knightmate.js";
import "./chess960.js";
export { LAYOUT_REGISTRY } from "./registry.js";
export { CLASSIC_LAYOUT } from "./classic.js";
// EMPTY_LAYOUT is NOT auto-registered — it's an internal test
// fixture (see layouts/empty.ts). Exported here so tests can
// construct an engine around it.
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,
LayoutValidationResult,
} from "./types.js";

View file

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

View file

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

View file

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

View file

@ -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 { EMPTY_LAYOUT } from "./empty.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");
});
it("does NOT include 'empty' — it's an internal test fixture only", () => {
expect(LAYOUT_REGISTRY.has("empty")).toBe(false);
});
it("every premade has source: 'premade'", () => {
for (const layout of LAYOUT_REGISTRY.list()) {
expect(layout.source).toBe("premade");
}
});
it("every registered premade validates with zero errors", () => {
for (const layout of LAYOUT_REGISTRY.list()) {
const { errors } = validateLayout(layout);
expect(errors, `${layout.id}: ${errors.join("; ")}`).toHaveLength(0);
}
});
it("EMPTY_LAYOUT test fixture still fails validation (sanity)", () => {
const { errors } = validateLayout(EMPTY_LAYOUT);
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<readonly [string, number]> = [
["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
];
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);
});
}
});

View file

@ -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<string, StartingLayout>();
/**
* 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();

View file

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

View file

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

View file

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

View file

@ -19,11 +19,14 @@ const WS_URL =
(import.meta as { env?: Record<string, string> }).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<string, unknown>,
): Promise<{ code: string; token: string; color: string }> {
): Promise<OneShotRoomResult> {
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();

View file

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

View file

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

View file

@ -0,0 +1,205 @@
import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest";
import {
loadLibrary,
saveToLibrary,
deleteFromLibrary,
setStarred,
duplicateEntry,
makeId,
__test__,
type SavedLayout,
} from "./layout-library.js";
// happy-dom provides a localStorage object but its methods are
// bound to a prototype that doesn't survive certain destructuring
// patterns; we install a simple Map-backed shim unconditionally so
// the tests have predictable behavior.
beforeAll(() => {
const store = new Map<string, string>();
const shim: Storage = {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(k: string) {
return store.get(k) ?? null;
},
setItem(k: string, v: string) {
store.set(k, v);
},
removeItem(k: string) {
store.delete(k);
},
key(i: number) {
return [...store.keys()][i] ?? null;
},
};
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: shim,
});
});
// Clear between tests so each one starts fresh.
beforeEach(() => {
localStorage.clear();
});
function seed(entry: Partial<SavedLayout> = {}): SavedLayout {
return {
id: makeId(),
name: "Test",
pieces: [
{ type: "king", color: "white", square: 4 },
{ type: "king", color: "black", square: 60 },
],
starred: false,
updatedAt: Date.now(),
...entry,
};
}
describe("loadLibrary()", () => {
it("returns [] when no key is set", () => {
expect(loadLibrary()).toEqual([]);
});
it("returns [] when storage contains malformed JSON", () => {
localStorage.setItem(__test__.STORAGE_KEY, "not json");
expect(loadLibrary()).toEqual([]);
});
it("filters out entries that fail shape validation", () => {
localStorage.setItem(
__test__.STORAGE_KEY,
JSON.stringify([
{ id: "ok", name: "ok", pieces: [], starred: false, updatedAt: 0 },
{ not: "a valid entry" },
]),
);
const library = loadLibrary();
expect(library).toHaveLength(1);
expect(library[0]?.id).toBe("ok");
});
});
describe("saveToLibrary()", () => {
it("appends a new entry", () => {
const result = saveToLibrary(seed({ name: "First" }));
expect(result.ok).toBe(true);
const library = loadLibrary();
expect(library).toHaveLength(1);
expect(library[0]?.name).toBe("First");
});
it("updates in place when id matches", () => {
const entry = seed({ name: "Original" });
saveToLibrary(entry);
saveToLibrary({ ...entry, name: "Renamed" });
const library = loadLibrary();
expect(library).toHaveLength(1);
expect(library[0]?.name).toBe("Renamed");
});
it("evicts the oldest non-starred when MAX_ENTRIES is hit", () => {
// Seed with MAX_ENTRIES entries, incrementing updatedAt.
for (let i = 0; i < __test__.MAX_ENTRIES; i++) {
saveToLibrary(seed({ name: `E${String(i)}`, updatedAt: i }));
}
expect(loadLibrary()).toHaveLength(__test__.MAX_ENTRIES);
// Save one more — oldest (E0) should be evicted.
saveToLibrary(seed({ name: "newest", updatedAt: 9999 }));
const library = loadLibrary();
expect(library).toHaveLength(__test__.MAX_ENTRIES);
expect(library.map((e) => e.name)).not.toContain("E0");
expect(library.some((e) => e.name === "newest")).toBe(true);
});
it("refuses save when every entry is starred and library is full", () => {
for (let i = 0; i < __test__.MAX_ENTRIES; i++) {
saveToLibrary(seed({ name: `E${String(i)}`, starred: true }));
}
const result = saveToLibrary(seed({ name: "newest" }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toMatch(/unstar/i);
}
expect(loadLibrary()).toHaveLength(__test__.MAX_ENTRIES);
});
});
describe("deleteFromLibrary()", () => {
it("removes the matching entry", () => {
const entry = seed();
saveToLibrary(entry);
deleteFromLibrary(entry.id);
expect(loadLibrary()).toHaveLength(0);
});
it("is a no-op for unknown id", () => {
saveToLibrary(seed());
deleteFromLibrary("not-real");
expect(loadLibrary()).toHaveLength(1);
});
});
describe("setStarred()", () => {
it("toggles starred and updates updatedAt", () => {
const entry = seed({ starred: false, updatedAt: 100 });
saveToLibrary(entry);
const before = Date.now();
setStarred(entry.id, true);
const library = loadLibrary();
expect(library[0]?.starred).toBe(true);
expect(library[0]?.updatedAt).toBeGreaterThanOrEqual(before);
});
});
describe("duplicateEntry()", () => {
it("creates a copy with a new id and '(copy)' name suffix", () => {
const entry = seed({ name: "Original" });
saveToLibrary(entry);
const newId = duplicateEntry(entry.id);
expect(newId).toBeDefined();
expect(newId).not.toBe(entry.id);
const library = loadLibrary();
expect(library).toHaveLength(2);
const copy = library.find((e) => e.id === newId);
expect(copy?.name).toBe("Original (copy)");
expect(copy?.starred).toBe(false);
});
it("returns undefined for unknown id", () => {
expect(duplicateEntry("not-real")).toBeUndefined();
});
});
describe("makeId()", () => {
it("produces unique ids", () => {
const ids = new Set<string>();
for (let i = 0; i < 100; i++) ids.add(makeId());
expect(ids.size).toBe(100);
});
it("falls back when crypto.randomUUID is unavailable", () => {
const original = crypto.randomUUID;
// @ts-expect-error — intentional override for test
crypto.randomUUID = undefined;
try {
const id = makeId();
expect(id).toMatch(/^layout-/);
} finally {
crypto.randomUUID = original;
}
});
});
// Silence React testing-library warnings if this file runs in a mixed env.
vi.mock("react", async () => await vi.importActual("react"));

View file

@ -0,0 +1,180 @@
/**
* Saved-layout library — localStorage-backed store of user-authored
* starting layouts.
*
* Each entry:
* - `id` — local-only UUID (NOT the server-side layout id which is
* always "custom" for user layouts). Used to identify the entry
* for update/delete/star operations.
* - `name` — user-provided label, shown in the library drawer.
* - `pieces` — the piece placements.
* - `starred` — true when the user has pinned this layout. Starred
* entries are exempt from FIFO eviction and sort first.
* - `updatedAt` — unix ms of last write. Drives display order for
* non-starred entries (newest first) and FIFO eviction (oldest
* non-starred entry is removed when capacity is hit).
*
* Capacity: MAX_ENTRIES (20). When exceeded, the oldest non-starred
* entry is evicted. If every entry is starred, we refuse the save
* and the caller surfaces a "library full — unstar something" error.
*
* Storage key is versioned (`houserules:layouts:v1`). A schema bump
* would ship a new key + migration; v1 entries are kept on best-
* effort and re-hydrated read-only if they can't be migrated.
*/
import type { PiecePlacement } from "../layouts/types.js";
const STORAGE_KEY = "houserules:layouts:v1";
const MAX_ENTRIES = 20;
export interface SavedLayout {
readonly id: string;
readonly name: string;
readonly pieces: readonly PiecePlacement[];
readonly starred: boolean;
readonly updatedAt: number;
}
/**
* Read every saved layout from storage. Returns an empty array on
* empty/missing/corrupt storage — silently discarding unparseable
* data is preferable to blocking the UI.
*/
export function loadLibrary(): SavedLayout[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
// Shallow shape validation — anything that fails is dropped.
return parsed.filter(isSavedLayout);
} catch {
return [];
}
}
/** Write the full library array back to storage. */
function writeLibrary(entries: SavedLayout[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
} catch {
/* quota exceeded / storage disabled — best effort */
}
}
/**
* Save a new layout or update an existing one (matched by `id`).
*
* Returns `{ ok: true }` on success. Returns `{ ok: false, reason }`
* when the library is full of starred entries — caller surfaces a
* message telling the user to unstar something.
*/
export function saveToLibrary(
entry: SavedLayout,
): { ok: true } | { ok: false; reason: string } {
const library = loadLibrary();
const existingIdx = library.findIndex((e) => e.id === entry.id);
if (existingIdx >= 0) {
// Update in place — no capacity check needed.
library[existingIdx] = entry;
writeLibrary(library);
return { ok: true };
}
// New entry — enforce capacity.
if (library.length >= MAX_ENTRIES) {
// Find the oldest non-starred entry and evict it.
const evictable = library
.filter((e) => !e.starred)
.sort((a, b) => a.updatedAt - b.updatedAt);
if (evictable.length === 0) {
return {
ok: false,
reason:
"Library full (20 layouts). Unstar one to make room, or delete an entry.",
};
}
const oldestNonStarred = evictable[0]!;
const pruned = library.filter((e) => e.id !== oldestNonStarred.id);
pruned.push(entry);
writeLibrary(pruned);
return { ok: true };
}
library.push(entry);
writeLibrary(library);
return { ok: true };
}
/** Remove a layout by id. No-op if the id is unknown. */
export function deleteFromLibrary(id: string): void {
const library = loadLibrary();
writeLibrary(library.filter((e) => e.id !== id));
}
/** Toggle the starred flag on a layout. */
export function setStarred(id: string, starred: boolean): void {
const library = loadLibrary();
const idx = library.findIndex((e) => e.id === id);
if (idx < 0) return;
const updated: SavedLayout = {
...library[idx]!,
starred,
updatedAt: Date.now(),
};
library[idx] = updated;
writeLibrary(library);
}
/**
* Duplicate a library entry. The copy gets a fresh id, "(copy)"
* appended to the name, and starred=false regardless of the
* original's state. Returns the new entry's id so the caller can
* select it.
*/
export function duplicateEntry(id: string): string | undefined {
const library = loadLibrary();
const entry = library.find((e) => e.id === id);
if (entry === undefined) return undefined;
const newId = makeId();
const copy: SavedLayout = {
id: newId,
name: `${entry.name} (copy)`,
pieces: entry.pieces,
starred: false,
updatedAt: Date.now(),
};
const result = saveToLibrary(copy);
if (!result.ok) return undefined;
return newId;
}
/** Generate a local-only id for a library entry. */
export function makeId(): string {
// crypto.randomUUID is available in every browser we target (and
// in Node 19+). Fall back to a Math.random-based id only on
// ancient runtimes.
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `layout-${Math.random().toString(36).slice(2, 12)}`;
}
// ── Internal helpers ──────────────────────────────────────────────────
function isSavedLayout(value: unknown): value is SavedLayout {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v["id"] === "string" &&
typeof v["name"] === "string" &&
Array.isArray(v["pieces"]) &&
typeof v["starred"] === "boolean" &&
typeof v["updatedAt"] === "number"
);
}
// Exported for tests only. Prefer the high-level helpers above.
export const __test__ = { STORAGE_KEY, MAX_ENTRIES };

View file

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

View file

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

View file

@ -254,6 +254,7 @@ function GameLayout({
</span>
)}
{roomCode !== null && <RoomShareBadge code={roomCode} />}
<LayoutBadge />
</div>
<div className="flex items-center gap-3">
@ -456,3 +457,29 @@ function RoomShareBadge({ code }: { code: string }) {
</button>
);
}
/**
* 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<string | null>(() => {
if (typeof window === 'undefined') return null;
return sessionStorage.getItem('layout-name');
});
if (name === null) return null;
return (
<span
data-testid="layout-badge"
className="inline-flex items-center text-xs font-semibold uppercase tracking-wide text-purple-700 bg-purple-50 border border-purple-100 rounded px-2 py-1"
>
{name}
</span>
);
}

View file

@ -0,0 +1,770 @@
/**
* Layout Editor — modal overlay for composing custom starting layouts.
*
* Three main panels:
* - LEFT: piece palette + actions (Save, Copy Link, Library drawer toggle).
* - CENTER: interactive 8×8 board. Click a palette item to select it,
* click a square to place. Click an occupied square to remove.
* Drag-and-drop also works (palette piece → square, or square → off
* the board to remove).
* - RIGHT: live FEN textarea + validation panel + "Use This Layout"
* primary CTA.
*
* The editor is UNCONTROLLED from the parent's perspective: it owns its
* own piece-placement state, and emits `onApply(layout)` when the user
* confirms. The parent decides what to do with the applied layout
* (typically: set it as the Lobby's selected layout).
*
* Design decisions:
* - Click-to-place is the PRIMARY interaction (works on mobile with a
* single tap after selecting from the palette, and on desktop with
* no ambiguity).
* - Drag-and-drop is layered on top for power users; it uses HTML5
* drag events (no custom pointer tracking — the plain API is
* good enough for an 8×8 grid).
* - Validation is live via `validateLayout`; errors block the CTA
* but warnings do not.
* - FEN text is ALWAYS derived from the current placements. Edits to
* the FEN field replace the placements on "Load FEN" (not
* incrementally — simpler and avoids ambiguity about which source
* of truth wins).
*/
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { toast } from 'sonner';
import {
fromFen,
toFen,
validateLayout,
type PiecePlacement,
type PieceColor,
type PieceType,
type StartingLayout,
} from '@paratype/chess';
import {
deleteFromLibrary,
duplicateEntry,
loadLibrary,
makeId,
saveToLibrary,
setStarred,
type SavedLayout,
} from '../persist/layout-library';
import { pieceAssets } from '../assets/pieces';
import { squareToAlgebraic } from '../coord';
export interface LayoutEditorProps {
/** Optional initial placements. Omitted = empty board. */
initialPieces?: readonly PiecePlacement[];
/** Emitted when the user clicks "Use This Layout". The caller
* decides how to surface it (typically: set Lobby state). */
onApply: (layout: StartingLayout) => void;
/** Emitted when the user clicks Close/Cancel or hits Esc. */
onClose: () => void;
}
type Brush =
| { type: 'place'; piece: { type: PieceType; color: PieceColor } }
| { type: 'erase' }
| { type: 'none' };
const PIECE_PALETTE: ReadonlyArray<{ type: PieceType; label: string }> = [
{ type: 'king', label: 'King' },
{ type: 'queen', label: 'Queen' },
{ type: 'rook', label: 'Rook' },
{ type: 'bishop', label: 'Bishop' },
{ type: 'knight', label: 'Knight' },
{ type: 'pawn', label: 'Pawn' },
];
export function LayoutEditor({
initialPieces,
onApply,
onClose,
}: LayoutEditorProps) {
const [placements, setPlacements] = useState<PiecePlacement[]>(
() => (initialPieces ? [...initialPieces] : []),
);
const [brush, setBrush] = useState<Brush>({ type: 'none' });
const [fenDraft, setFenDraft] = useState<string>(() =>
toFen(initialPieces ?? []),
);
const [name, setName] = useState<string>('My Layout');
const [libraryOpen, setLibraryOpen] = useState(false);
const [libraryVersion, setLibraryVersion] = useState(0); // bump to force reload
/**
* Whether the FEN textarea is currently being edited by the user.
* While true, we DON'T overwrite its contents with the derived
* liveFen — that would clobber typing mid-edit. It flips off when
* the user clicks Load (accept) or Reset (discard) or blurs out.
*
* A ref rather than state because we only need to gate an effect;
* we don't render based on it.
*/
const fenDirtyRef = useRef(false);
// Global Esc listener: close the editor regardless of where focus
// currently is. We can't rely on the modal div's onKeyDown because
// the user might have focused a textarea / button inside a nested
// panel, and keyboard events on those don't bubble to the modal
// root in a way that works universally.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
// Derived FEN whenever placements change. The textarea mirrors
// this so placing pieces on the board updates the FEN field live
// — except when the user is actively editing the field (see
// fenDirtyRef), in which case we leave their draft alone until
// they explicitly Load or Reset.
const liveFen = useMemo(() => toFen(placements), [placements]);
useEffect(() => {
if (fenDirtyRef.current) return;
setFenDraft(liveFen);
}, [liveFen]);
const validation = useMemo(
() =>
validateLayout({
id: 'editor-in-progress',
name,
description: '',
pieces: placements,
source: 'custom',
}),
[placements, name],
);
const canApply = validation.errors.length === 0;
// ── Placement actions ─────────────────────────────────────────────
function placeAt(square: number) {
if (brush.type === 'none') return;
setPlacements((prev) => {
const filtered = prev.filter((p) => p.square !== square);
if (brush.type === 'erase') return filtered;
return [
...filtered,
{ type: brush.piece.type, color: brush.piece.color, square },
];
});
}
function clearBoard() {
setPlacements([]);
}
function loadFen() {
const { pieces, errors } = fromFen(fenDraft);
if (errors.length > 0) {
toast.error(`FEN error: ${errors[0]!}`);
return;
}
setPlacements(pieces);
// Successful load re-syncs the two sources of truth — the
// textarea is no longer "dirty" vs. the board.
fenDirtyRef.current = false;
toast.success('FEN loaded');
}
function syncFenDraft() {
// Discard in-progress edits, restore the textarea to the current
// board state.
fenDirtyRef.current = false;
setFenDraft(liveFen);
}
function handleApply() {
if (!canApply) {
toast.error(validation.errors[0] ?? 'Layout invalid');
return;
}
const layout: StartingLayout = {
id: 'custom',
name: name.trim() || 'Custom Layout',
description: 'Custom layout from editor.',
pieces: placements,
source: 'custom',
};
onApply(layout);
}
// ── Library actions ────────────────────────────────────────────────
function handleSaveToLibrary() {
if (!canApply) {
toast.error('Fix validation errors before saving.');
return;
}
const entry: SavedLayout = {
id: makeId(),
name: name.trim() || 'Untitled Layout',
pieces: placements,
starred: false,
updatedAt: Date.now(),
};
const result = saveToLibrary(entry);
if (!result.ok) {
toast.error(result.reason);
return;
}
toast.success(`Saved "${entry.name}" to library`);
setLibraryVersion((n) => n + 1);
}
function handleLoadFromLibrary(entry: SavedLayout) {
setPlacements([...entry.pieces]);
setName(entry.name);
// Library load is a clean state reset — clear any in-progress
// FEN edits. The useEffect that mirrors liveFen → fenDraft will
// sync the textarea on the next render.
fenDirtyRef.current = false;
setLibraryOpen(false);
toast.success(`Loaded "${entry.name}"`);
}
function handleDeleteFromLibrary(id: string) {
deleteFromLibrary(id);
setLibraryVersion((n) => n + 1);
}
function handleStarToggle(id: string, starred: boolean) {
setStarred(id, starred);
setLibraryVersion((n) => n + 1);
}
function handleDuplicate(id: string) {
duplicateEntry(id);
setLibraryVersion((n) => n + 1);
}
function handleCopyShareLink() {
if (!canApply) {
toast.error('Fix validation errors before sharing.');
return;
}
const fen = toFen(placements);
const base =
typeof window !== 'undefined' ? window.location.origin : '';
const url = `${base}/?fen=${encodeURIComponent(fen)}&name=${encodeURIComponent(name)}`;
void navigator.clipboard
.writeText(url)
.then(() => toast.success('Share link copied to clipboard'))
.catch(() => toast.error(`Copy failed — link is ${url}`));
}
// ── Render ─────────────────────────────────────────────────────────
return (
<div
data-testid="layout-editor"
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
}}
>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-6xl max-h-[95vh] overflow-hidden flex flex-col">
{/* Header */}
<header className="flex items-center justify-between px-6 py-4 border-b border-neutral-200">
<div className="flex items-center gap-3">
<h2 className="text-lg font-bold text-neutral-900">
Custom Layout Editor
</h2>
<input
data-testid="layout-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Layout name"
className="px-3 py-1 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
maxLength={40}
/>
</div>
<div className="flex items-center gap-2">
<button
data-action="library-toggle"
onClick={() => setLibraryOpen((v) => !v)}
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
>
{libraryOpen ? 'Hide Library' : 'Library'}
</button>
<button
onClick={onClose}
aria-label="Close editor"
className="p-2 text-neutral-500 hover:bg-neutral-100 rounded transition-colors"
>
×
</button>
</div>
</header>
{/* Body */}
<div className="flex-1 overflow-hidden flex">
{libraryOpen ? (
<LibraryDrawer
// Force reload by changing key when library mutates.
key={libraryVersion}
onLoad={handleLoadFromLibrary}
onDelete={handleDeleteFromLibrary}
onStarToggle={handleStarToggle}
onDuplicate={handleDuplicate}
onClose={() => setLibraryOpen(false)}
/>
) : (
<>
<PalettePanel brush={brush} onSelect={setBrush} onClear={clearBoard} />
<BoardPanel
placements={placements}
brush={brush}
onSquareClick={placeAt}
/>
<ActionsPanel
fenDraft={fenDraft}
liveFen={liveFen}
onFenDraftChange={(v) => {
// User is typing: mark the textarea as dirty so
// the board→FEN auto-sync effect doesn't clobber
// their edit until they Load or Reset.
fenDirtyRef.current = true;
setFenDraft(v);
}}
onLoadFen={loadFen}
onSyncFen={syncFenDraft}
validation={validation}
canApply={canApply}
onApply={handleApply}
onSaveToLibrary={handleSaveToLibrary}
onCopyShareLink={handleCopyShareLink}
/>
</>
)}
</div>
</div>
</div>
);
}
// ── Subcomponents ───────────────────────────────────────────────────
function PalettePanel({
brush,
onSelect,
onClear,
}: {
brush: Brush;
onSelect: (b: Brush) => void;
onClear: () => void;
}) {
return (
<aside className="w-52 border-r border-neutral-200 bg-neutral-50 p-4 overflow-y-auto space-y-4">
<div>
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest mb-2">
White Pieces
</h3>
<div className="grid grid-cols-3 gap-2">
{PIECE_PALETTE.map(({ type }) => (
<PaletteButton
key={`white-${type}`}
type={type}
color="white"
selected={
brush.type === 'place' &&
brush.piece.type === type &&
brush.piece.color === 'white'
}
onSelect={() =>
onSelect({ type: 'place', piece: { type, color: 'white' } })
}
/>
))}
</div>
</div>
<div>
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest mb-2">
Black Pieces
</h3>
<div className="grid grid-cols-3 gap-2">
{PIECE_PALETTE.map(({ type }) => (
<PaletteButton
key={`black-${type}`}
type={type}
color="black"
selected={
brush.type === 'place' &&
brush.piece.type === type &&
brush.piece.color === 'black'
}
onSelect={() =>
onSelect({ type: 'place', piece: { type, color: 'black' } })
}
/>
))}
</div>
</div>
<div className="border-t border-neutral-200 pt-4 space-y-2">
<button
data-action="brush-erase"
onClick={() => onSelect({ type: 'erase' })}
className={`w-full px-3 py-2 text-sm font-semibold rounded transition-colors ${
brush.type === 'erase'
? 'bg-red-100 text-red-700 border border-red-300'
: 'bg-white text-neutral-700 border border-neutral-200 hover:bg-neutral-50'
}`}
>
Erase
</button>
<button
data-action="brush-clear"
onClick={onClear}
className="w-full px-3 py-2 text-sm font-semibold text-neutral-700 bg-white border border-neutral-200 rounded hover:bg-neutral-50 transition-colors"
>
Clear Board
</button>
</div>
</aside>
);
}
function PaletteButton({
type,
color,
selected,
onSelect,
}: {
type: PieceType;
color: PieceColor;
selected: boolean;
onSelect: () => void;
}) {
return (
<button
data-testid={`palette-${color}-${type}`}
data-selected={selected}
onClick={onSelect}
className={`aspect-square flex items-center justify-center rounded transition-all ${
selected
? 'bg-blue-100 border-2 border-blue-500 shadow-inner'
: 'bg-white border border-neutral-200 hover:border-neutral-300'
}`}
>
<img
src={pieceAssets[color][type]}
alt={`${color} ${type}`}
className="w-10 h-10 drop-shadow-sm"
draggable={false}
/>
</button>
);
}
function BoardPanel({
placements,
brush,
onSquareClick,
}: {
placements: PiecePlacement[];
brush: Brush;
onSquareClick: (square: number) => void;
}) {
const placementBySquare = useMemo(() => {
const map = new Map<number, PiecePlacement>();
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(
<button
key={sq}
data-testid={`editor-square-${String(sq)}`}
data-square={squareToAlgebraic(sq)}
onClick={() => onSquareClick(sq)}
disabled={brush.type === 'none'}
className={`aspect-square flex items-center justify-center transition-colors ${
isDark ? 'bg-neutral-400' : 'bg-neutral-100'
} ${
brush.type !== 'none'
? 'hover:brightness-110 cursor-pointer'
: 'cursor-default'
} disabled:cursor-default`}
>
{piece !== undefined && (
<img
src={pieceAssets[piece.color][piece.type]}
alt={`${piece.color} ${piece.type}`}
className="w-full h-full object-contain p-1"
draggable={false}
/>
)}
</button>,
);
}
rows.push(
<div key={rank} className="grid grid-cols-8">
{cells}
</div>,
);
}
return (
<main className="flex-1 flex items-center justify-center p-6 bg-white">
<div
data-testid="editor-board"
className="w-full max-w-lg aspect-square border-2 border-neutral-800 shadow-xl"
>
{rows}
</div>
</main>
);
}
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 (
<aside className="w-80 border-l border-neutral-200 bg-neutral-50 p-4 overflow-y-auto space-y-4">
<section>
<div className="flex items-baseline justify-between mb-2">
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest">
FEN
</h3>
{fenDraft !== liveFen && (
<span
data-testid="editor-fen-dirty"
className="text-[10px] font-semibold text-amber-600 uppercase tracking-wide"
>
Unsaved edit
</span>
)}
</div>
<textarea
data-testid="editor-fen"
value={fenDraft}
onChange={(e) => onFenDraftChange(e.target.value)}
rows={3}
className="w-full px-3 py-2 text-xs font-mono border border-neutral-300 rounded resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Paste FEN here…"
/>
<p className="mt-1 text-[10px] text-neutral-400 leading-snug">
Updates live as you place pieces. Paste a FEN and hit Load
to replace the board.
</p>
<div className="flex gap-2 mt-2">
<button
data-action="load-fen"
onClick={onLoadFen}
disabled={fenDraft === liveFen}
className="flex-1 px-3 py-1.5 text-xs font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
Load
</button>
<button
data-action="sync-fen"
onClick={onSyncFen}
title="Discard unsaved FEN edits and re-sync to the current board"
disabled={fenDraft === liveFen}
className="flex-1 px-3 py-1.5 text-xs font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
Discard
</button>
</div>
</section>
<ValidationPanel validation={validation} />
<section className="space-y-2">
<button
data-action="save-to-library"
onClick={onSaveToLibrary}
disabled={!canApply}
className="w-full px-3 py-2 text-sm font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
Save to Library
</button>
<button
data-action="copy-share-link"
onClick={onCopyShareLink}
disabled={!canApply}
className="w-full px-3 py-2 text-sm font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
Copy Share Link
</button>
<button
data-action="apply-layout"
onClick={onApply}
disabled={!canApply}
className="w-full px-3 py-2.5 text-sm font-bold text-white bg-neutral-900 rounded hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed shadow"
>
Use This Layout
</button>
</section>
</aside>
);
}
function ValidationPanel({
validation,
}: {
validation: { errors: readonly string[]; warnings: readonly string[] };
}) {
const { errors, warnings } = validation;
if (errors.length === 0 && warnings.length === 0) {
return (
<div
data-testid="validation-ok"
className="px-3 py-2 text-xs font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 rounded"
>
✓ Layout valid
</div>
);
}
return (
<div className="space-y-2">
{errors.length > 0 && (
<ul
data-testid="validation-errors"
className="px-3 py-2 text-xs text-red-800 bg-red-50 border border-red-200 rounded space-y-1"
>
{errors.map((e, i) => (
<li key={i}>⚠ {e}</li>
))}
</ul>
)}
{warnings.length > 0 && (
<ul
data-testid="validation-warnings"
className="px-3 py-2 text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded space-y-1"
>
{warnings.map((w, i) => (
<li key={i}>ℹ {w}</li>
))}
</ul>
)}
</div>
);
}
function LibraryDrawer({
onLoad,
onDelete,
onStarToggle,
onDuplicate,
onClose,
}: {
onLoad: (entry: SavedLayout) => void;
onDelete: (id: string) => void;
onStarToggle: (id: string, starred: boolean) => void;
onDuplicate: (id: string) => void;
onClose: () => void;
}) {
const entries = loadLibrary();
// Sort: starred first (both groups by most-recent-first).
const sorted = [...entries].sort((a, b) => {
if (a.starred !== b.starred) return a.starred ? -1 : 1;
return b.updatedAt - a.updatedAt;
});
return (
<div className="flex-1 p-6 overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-neutral-900">Saved Layouts</h3>
<button
onClick={onClose}
className="text-sm text-neutral-500 hover:text-neutral-900"
>
← Back to editor
</button>
</div>
{sorted.length === 0 ? (
<p className="text-sm text-neutral-500 italic">
No saved layouts yet. Use the "Save to Library" button to stash
your current board for later.
</p>
) : (
<ul data-testid="library-list" className="space-y-2">
{sorted.map((entry) => (
<li
key={entry.id}
data-testid={`library-entry-${entry.id}`}
className="flex items-center gap-3 p-3 bg-white border border-neutral-200 rounded hover:border-neutral-300"
>
<button
onClick={() => onStarToggle(entry.id, !entry.starred)}
aria-label={entry.starred ? 'Unstar' : 'Star'}
className={
entry.starred ? 'text-amber-500' : 'text-neutral-300'
}
>
★
</button>
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm text-neutral-900 truncate">
{entry.name}
</div>
<div className="text-xs text-neutral-500">
{entry.pieces.length} pieces •{' '}
{new Date(entry.updatedAt).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => onLoad(entry)}
className="px-2 py-1 text-xs font-semibold text-blue-700 hover:bg-blue-50 rounded"
>
Load
</button>
<button
onClick={() => onDuplicate(entry.id)}
className="px-2 py-1 text-xs font-semibold text-neutral-700 hover:bg-neutral-100 rounded"
>
Duplicate
</button>
<button
onClick={() => onDelete(entry.id)}
aria-label="Delete"
className="px-2 py-1 text-xs font-semibold text-red-700 hover:bg-red-50 rounded"
>
Delete
</button>
</div>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -0,0 +1,133 @@
/**
* 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<HTMLSelectElement>) {
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 (
<div className="space-y-2">
<label className="block text-xs font-bold text-neutral-500 uppercase tracking-widest">
Starting Layout
</label>
<div className="relative">
<select
data-testid="layout-picker"
value={selectValue}
onChange={handleChange}
disabled={disabled}
className="w-full appearance-none bg-white/80 border border-neutral-300 rounded-lg py-3 pl-4 pr-10 text-sm font-semibold text-neutral-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{premades.map((layout) => (
<option key={layout.id} value={layout.id}>
{layout.name}
{layout.pieces.length > 0
? ` — ${String(layout.pieces.length)} pieces`
: ''}
</option>
))}
{onCustomRequested !== undefined && (
<option value="__custom__">Custom…</option>
)}
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-neutral-400">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
<p
data-testid="layout-picker-description"
className="text-xs text-neutral-500 leading-relaxed"
>
{isCustom ? 'Custom layout loaded from your editor.' : value.description}
</p>
{value.suggestedPresets !== undefined &&
value.suggestedPresets.length > 0 && (
<p className="text-xs text-neutral-400 italic">
Suggested rules: {value.suggestedPresets.join(', ')}
</p>
)}
</div>
);
}

View file

@ -1,10 +1,20 @@
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';
import { LayoutEditor } from './LayoutEditor';
interface LobbyProps {
/** Optional — when provided, create/join/solo flows reset the local
@ -20,24 +30,101 @@ export function Lobby({ chessState }: LobbyProps = {}) {
const [error, setError] = useState<string | null>(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<StartingLayout>(CLASSIC_LAYOUT);
const [editorOpen, setEditorOpen] = useState(false);
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 +150,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 +203,13 @@ export function Lobby({ chessState }: LobbyProps = {}) {
<h2 className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
Host Game
</h2>
<div className="bg-white/50 rounded-xl p-5 border border-neutral-200/60 shadow-inner">
<div className="bg-white/50 rounded-xl p-5 border border-neutral-200/60 shadow-inner space-y-4">
<LayoutPicker
value={selectedLayout}
onChange={setSelectedLayout}
onCustomRequested={() => setEditorOpen(true)}
disabled={loading}
/>
<button
data-action="create-room"
onClick={handleCreate}
@ -173,6 +271,19 @@ export function Lobby({ chessState }: LobbyProps = {}) {
)}
</AnimatePresence>
</div>
{editorOpen && (
<LayoutEditor
initialPieces={
selectedLayout.source === 'custom' ? selectedLayout.pieces : []
}
onApply={(layout) => {
setSelectedLayout(layout);
setEditorOpen(false);
}}
onClose={() => setEditorOpen(false)}
/>
)}
</main>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<typeof ErrorCodeSchema>;
@ -93,8 +97,69 @@ export type Envelope = z.infer<typeof EnvelopeSchema>;
// 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<typeof PiecePlacementSchema>;
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<typeof LayoutRequestSchema>;
/**
* 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<typeof ResolvedLayoutSchema>;
export const RoomCreatePayloadSchema = z.object({
rulesetIds: z.array(z.string()).optional(),
layout: LayoutRequestSchema.optional(),
});
export type RoomCreatePayload = z.infer<typeof RoomCreatePayloadSchema>;
@ -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<typeof RoomCreatedPayloadSchema>;
@ -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<typeof RoomJoinedPayloadSchema>;

View file

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