houserules/.sisyphus/plans/starting-layouts.md
Joey Yakimowich-Payne 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

26 KiB
Raw Permalink Blame History

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

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

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

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

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:

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.