houserules/.sisyphus/plans/rule-variants-v2.md
Joey Yakimowich-Payne 3cef8f5324
chore(sisyphus): Rule Variants Final Verification Wave — all reviewers APPROVE
Phase F Final Wave of the rule-variants epic.

  F1 (Plan Compliance, oracle):          APPROVE — bg_f2508b15
  F2 (Code Quality, unspecified-high):   APPROVE — bg_1101ecb3
  F3 (Manual QA, orchestrator-direct):   APPROVE — after agent hit
                                         tool cap; 1651/1651 unit +
                                         83/83 Playwright + 2/2
                                         visual spot-checks
  F4 (Scope Fidelity, deep):             APPROVE — bg_77460b0f

Summary:
- 1651 unit tests (1417 baseline → +234 across 14 presets + 4 hooks
  + audits).
- 83/83 Playwright (80 → +3 rule-variants scenarios in F.4).
- 0 typecheck / lint errors.
- 4 new preset hooks shipped: getRoyalPieces, filterLegalMoves,
  shouldAdvanceTurn (+ HalfMovesThisTurn fact), overridePieceMoves.
- 14 rule-variant presets shipped:
  * Tier 1: knightmate-rules, double-move, monster-rules (scope-
    flippable), first-promotion-wins.
  * Tier 2 royal: coregal, dual-king, weak-dual-king.
  * Tier 2 objective: suicide-chess, capture-all, extinction-chess.
  * Tier 2 movement: berolina-pawns (scope-flippable),
    berolina-pawns-2 (scope-flippable), bouncing-pieces,
    bouncing-pieces-2.
- 1 new layout: dual-classic.
- Engine composition fix: onCheckGameResult 'ongoing' suppresses
  defaults without short-circuiting later presets.
- UI: RulesDrawer category grouping + LayoutPicker suggested-rules
  chips.
- Docs: PRESET-API.md + RULES.md galleries updated.
- Audits: incompatibleWith symmetry test + suggestedPresets
  dangling-reference test — both green and runtime-enforced.

Deferred per-plan: Tier 3 royalty-transfer (requires mid-game state
toggle UX outside 'one move at a time'), berolina en-passant,
extinction-chess UI target cycling.

Plan: .sisyphus/plans/rule-variants-v2.md (all 31 tasks checked).
2026-04-21 10:15:18 -06:00

40 KiB
Raw Permalink Blame History

Rule Variants — Greenchess cat=4 Preset Suite (execution plan)

For the agent picking this up: This is the execution-ready successor to .sisyphus/plans/rule-variants.md (design doc, Momus-approved). The design doc has the full rationale + risks + hook architecture sketch. THIS file is the step-by-step to-do list with commit discipline and verification gates, formatted like the recently-shipped modifier-profiles-t3.md. Read both — this one for the work, the design doc for the WHY.

TL;DR

Ship 4 new preset-API hooks (getRoyalPieces, filterLegalMoves, shouldAdvanceTurn, overridePieceMoves) plus HalfMovesThisTurn game fact, then 14 rule-variant presets built on them. Tier 1 (4 presets) closes deferrals from the starting-layouts plan. Tier 2 (10 presets) rounds out greenchess cat=4 coverage. Tier 3 (royalty-transfer) is explicitly deferred.

Deliverables:

  • 4 additive hook signatures on PresetDef
  • HalfMovesThisTurn game fact + turn-advance gating
  • 14 preset implementations with 8-15 behavioral tests each
  • dual-classic layout (2 kings per side) — needed by dual-king preset
  • Rule-variant Playwright e2e smoke (3 representative variants)
  • PRESET-API.md hook-ordering diagram + preset gallery
  • Lobby RulesDrawer preset grouping (King / Objective / Movement / Multimove)

Estimated Effort: Large (31 tasks, 6 phases) Parallel Execution: YES within each phase; phases are hard-gated Critical Path: Phase A hooks → Phase B T1 presets → Phase C/D/E T2 presets → Phase F integration + e2e Explicit Deferral: royalty-transfer — needs mid-game "which royal is active" state toggle; UX outside "one move at a time".


Context

What Shipped Last (commits abc5c86 back to cbe4a4b)

  • modifier-profiles-t3 complete (32 tasks + 4 reviewers all APPROVE) plus audit follow-ups (gaps 1-7 + Q3.2/Q4.2/Q4.4/Q4.6/Q4.7). Codebase state: 1417 unit tests + 80/80 Playwright, all green. Master at abc5c86.
  • starting-layouts shipped: added Dunsany, Monster, Pawns-Only, Horde, Knightmate, Chess960 as layout-only presets. Each of those layouts declared suggestedPresets pointing at presets that DON'T EXIST YET. Tier 1 of this plan ships those presets.
  • preset-flexibility-architecture shipped: PIECE_TYPE_REGISTRY, engine.presetState<T>(id), engine.spawnPiece, engine.emitEffect, scope-aware hook dispatch, MoveRecord/engine.moveLog, onBeforeMove/onTurnStart hooks all live. Everything this plan builds on is in place.

What T3's Trip Taught Us

Load-time consumer integrity check pattern (T3 Q3.2, in packages/chess/src/modifiers/primitives/manifest.ts) converts silent-inert features into loud load-time failures. This plan uses the same pattern for the new hooks: every preset declaring a new hook is structural; we don't need a manifest for hooks themselves, but document hook-ordering semantics clearly in PRESET-API.md so future contributors can't accidentally compose a first-wins hook in a commutative way.

Key File Anchors (verified live)

  • packages/chess/src/rules/check.ts:83 — isInCheck(session, color): hardcoded to "king" via findKingPosition(session, color).
  • packages/chess/src/rules/checkmate.ts:80, packages/chess/src/rules/stalemate.ts:92,105 — call isInCheck and iterate "every king" implicitly.
  • packages/chess/src/engine.ts:855 — getAllLegalMoves(): aggregation point where filterLegalMoves will hook in (POST self-check filter).
  • packages/chess/src/engine.ts:964 — applyMove: turn-flip lives here; shouldAdvanceTurn intercepts before the flip.
  • packages/chess/src/engine.ts — getAllLegalMoves dispatches per-piece via PIECE_TYPE_REGISTRY.get(type).generateMoves; overridePieceMoves runs BEFORE this dispatch.
  • packages/chess/src/presets/registry.ts:297-500 — PresetDef hook surface. New hooks slot in here.

Out of Scope (deferred)

  • Royalty Transfer Chess (T3 in the design doc). Needs mid-game "which king is active" flip triggered by a non-move action. UX outside "one move at a time". Post-v1.
  • 4+ player variants. Engine assumes exactly two sides.
  • Non-8×8 variants from other greenchess categories (Capablanca, Janus). Needs board-dimensions refactor.
  • PGN notation with non-king royalty (e.g., N# in Knightmate). Rabbit hole; notation stays king-centric; move log displays via describeMoveEffect instead.
  • Check-banner multi-royal indicator UX. Risk 4 in the design doc; deferred to a small follow-up UI task after this plan lands.
  • Extinction Chess target-type UI chip (D.3 of the design doc). Preset ships with configurable presetState({targetType}); a drawer-UI chip for cycling targets is a follow-up.

Conventions for the Executing Agent

Repo Discipline

  • Strict TypeScript: no as any, no @ts-ignore, no as unknown as X. One judicious as X boundary cast is OK with JSDoc explaining why. Audit with grep -rn "as unknown as" packages/chess/src packages/server/src --include='*.ts' --include='*.tsx' | grep -v '\.test\.'.
  • Pre-commit hook runs bun run check (typecheck + lint + vitest). Must be green before every commit. NEVER bypass with --no-verify.
  • Tests: bun run test (vitest), NOT bun test. Playwright from repo root: bunx playwright test. For multiplayer scenarios the server must be running: tmux new-session -d -s ws-server -c /home/joey/Projects/rules 'bun run packages/server/src/index.ts' (the dev server does NOT hot-reload protocol changes — restart after any packages/server/src/protocol.ts edit).
  • Atomic commits per task. Conventional-commit messages (feat(engine): ..., test(e2e): ..., docs(adr): ..., chore(sisyphus): ...).
  • Notepad at .sisyphus/notepads/rule-variants/ — create on plan start. APPEND (never overwrite) under ## [YYYY-MM-DD HH:MM] Task: X.Y headings.

T3-Learned Tactics (don't repeat the pain)

  • Tool-cap fragility: delegating a large phase to a single deep/visual agent reliably hits the 200-tool-call limit. Prefer shipping each preset as its OWN delegation OR doing them inline. Never batch 15+ file creations under one agent.
  • Parallel waves: run at most 3-5 agents per wave, each scoped to a disjoint file set. A preset + its own test file is one good unit.
  • Commit discipline for delegated work: if an agent is at risk of hitting the tool cap, INSTRUCT IT to commit incrementally every 2-3 files. Uncommitted work at cancellation requires orchestrator cleanup.
  • Schema v3 ↔ v4 mirror: if a task requires server protocol changes (this plan has none — all hooks are chess-package-internal), be vigilant. Cross-package parity test in packages/server/src/custom-modifier-wire-parity.test.ts is the template for how to prove agreement.
  • test.fixme policy: only use for scenarios that genuinely need engine wiring not yet in place. Every fixme in this plan's e2e task MUST be marked with a // blocked on: TASK_ID comment so cleanup knows what unblocks it.

MUST / SHOULD / MAY

  • MUST = blocks phase completion.
  • SHOULD = expected but permissible to defer with written rationale in the notepad.
  • MAY = optional; do it if tool budget allows.

Architecture Sketch (recap — full version in design doc)

Hook signatures (verbatim)

// rules/check.ts + registry.ts
interface RoyalContext {
  readonly engine: ChessEngine;
  readonly color: "white" | "black";
}
interface PresetDef {
  // Phase A.1 — return the set of piece ENTITY IDs that count as
  // royal for this color. Undefined = "don't contribute" (engine falls
  // back to union of other presets' returns; empty union → default
  // {PieceType === "king"}).
  readonly getRoyalPieces?: (ctx: RoyalContext) => readonly EntityId[] | undefined;

  // Phase A.2 — post-aggregation filter on the per-color legal-move
  // list. Applied in preset-registration order AFTER the self-check
  // filter. Returning a subset is the only sanctioned way to express
  // compulsory-capture-style rules.
  readonly filterLegalMoves?: (
    ctx: FilterLegalMovesContext,
  ) => readonly LegalMove[];

  // Phase A.3 — returning false cancels the turn flip for this
  // applyMove. First false wins. Undefined = no opinion. Engine
  // increments HalfMovesThisTurn BEFORE polling; presets read it via
  // ctx.halfMovesThisTurn.
  readonly shouldAdvanceTurn?: (
    ctx: TurnAdvanceContext,
  ) => boolean | undefined;

  // Phase A.4 — REPLACE the default move generator for this piece.
  // First non-undefined return wins (documented as a guardrail, not
  // an opt-in; conflicts should be caught via incompatibleWith).
  readonly overridePieceMoves?: (
    engine: ChessEngine,
    pieceId: EntityId,
  ) => readonly LegalMove[] | undefined;
}

// engine.ts
// The move-generation dispatch order (documented in PRESET-API.md):
//   1) overridePieceMoves     — first match wins, skips default
//   2) PIECE_TYPE_REGISTRY    — default per-type generator
//   3) getExtraMoves          — each preset appends
//   4) filterMoves            — each preset filters per-piece
//   5) aggregate all pieces
//   6) self-check filter      — suppressible via shouldFilterSelfCheck
//   7) filterLegalMoves       — each preset runs in registration order

New game fact: HalfMovesThisTurn

// packages/chess/src/schema.ts — ChessAttrMap additions
HalfMovesThisTurn: number;  // on GAME_ENTITY. Resets to 0 on turn flip.

Seeded in starting-position.ts alongside Turn. Incremented in applyMove BEFORE the turn-flip decision. Reset to 0 when the flip happens.

Royal-piece dispatch (pseudo)

// engine.ts, private
getActiveRoyalEntityIds(color): EntityId[] {
  const acc = new Set<EntityId>();
  let anyPresetContributed = false;
  for (const entry of this.activePresets.list()) {
    const def = PRESET_REGISTRY.get(entry.id);
    const result = def?.getRoyalPieces?.({ engine: this, color });
    if (result === undefined) continue;
    anyPresetContributed = true;
    for (const id of result) acc.add(id);
  }
  if (!anyPresetContributed) {
    // Default: every piece with PieceType === "king" of this color.
    return defaultKingIds(this.session, color);
  }
  return [...acc];
}

Empty royal set = "no royalty" (Suicide, Capture-all interpretation). isInCheck / isCheckmate / isStalemate handle this degenerate case by returning false always when the set is empty.

Tier 1 → Tier 2 dependency graph

Tier 1 presets CAN ship without Tier 2. Tier 2 presets MAY depend on Tier 1 hooks but NOT on Tier 1 presets behaviorally.

  • knightmate-rules → getRoyalPieces only
  • double-move → shouldAdvanceTurn + HalfMovesThisTurn
  • monster-rules → shouldAdvanceTurn + scope-aware dispatch
  • first-promotion-wins → onAfterMove (existing) + onCheckGameResult (existing) + shouldFilterSelfCheck (existing). No new hooks needed but grouped in T1 because it closes a layouts deferral.
  • coregal, dual-king, weak-dual-king → getRoyalPieces only
  • suicide-chess → filterLegalMoves + getRoyalPieces (empty)
  • capture-all, extinction-chess → existing hooks only
  • berolina-pawns, berolina-pawns-2 → overridePieceMoves
  • bouncing-pieces, bouncing-pieces-2 → existing getExtraMoves

Phase A — Hook API Extensions (architecture)

Goal: Add 4 hooks + HalfMovesThisTurn fact; zero behavioral change to any existing preset/game.

  • A.1. getRoyalPieces hook + engine royal-dispatch + isInCheck refactor

    What to do:

    • packages/chess/src/presets/registry.ts: add RoyalContext type and getRoyalPieces hook signature on PresetDef.
    • packages/chess/src/rules/check.ts: isInCheck(session, color) gains an optional 3rd param royalEntityIds?: readonly EntityId[]. When provided, iterate those entities' positions instead of calling findKingPosition. When absent, keep current behaviour (back-compat for callers that don't pass the param).
    • packages/chess/src/rules/checkmate.ts + stalemate.ts: same optional-param widening.
    • packages/chess/src/engine.ts: add private getActiveRoyalEntityIds(color): EntityId[] that unions every active preset's getRoyalPieces returns. Empty-union fallback = default king entities via findKingPosition-equivalent logic. Use this in every internal call to isInCheck / isCheckmate / isStalemate / filterSelfCheckMoves.
    • Handle empty-royal case: isInCheck / isCheckmate / isStalemate MUST return false when the royal set is empty (document + test).

    Files touched: packages/chess/src/presets/registry.ts, packages/chess/src/rules/check.ts, packages/chess/src/rules/checkmate.ts, packages/chess/src/rules/stalemate.ts, packages/chess/src/engine.ts, packages/chess/src/presets/royal-pieces.test.ts (new).

    Verification: bun run check green. All existing 1417 tests pass unchanged. The new royal-pieces.test.ts must include:

    • Prototype "all-kings-royal" preset returning [king1, king2] when a piece spawned as a king exists — isInCheck behaves correctly.
    • Empty-royal preset (getRoyalPieces → []) — isInCheck returns false, no crash.
    • Two presets contributing overlapping sets — union dedupes.
    • No preset contributes — default king detection path unchanged.

    Recommended Agent Profile: deep OR orchestrator-direct.

    Parallelization: Phase A. Blocks: B.1, C.1, C.2, C.3, D.1, D.2. Blocked by: none.

    Commit: feat(engine): getRoyalPieces hook + engine royal dispatch

  • A.2. filterLegalMoves hook

    What to do:

    • packages/chess/src/presets/registry.ts: add FilterLegalMovesContext { readonly engine: ChessEngine; readonly color: "white" | "black"; readonly moves: readonly LegalMove[] }
      • filterLegalMoves hook.
    • packages/chess/src/engine.ts getAllLegalMoves: after the existing self-check filter (step 6), iterate active presets' filterLegalMoves in registration order, passing the accumulated list through each. Document "order matters; use incompatibleWith when composition would be lossy".

    Files touched: packages/chess/src/presets/registry.ts, packages/chess/src/engine.ts, packages/chess/src/presets/filter-legal-moves.test.ts (new).

    Verification:

    • Prototype "no-a-file" preset dropping every a-file source/target move; assert across multiple pieces.
    • No active preset with hook = getAllLegalMoves unchanged.

    Parallelization: Phase A. Blocks: D.1. Blocked by: none.

    Commit: feat(engine): filterLegalMoves hook

  • A.3. shouldAdvanceTurn hook + HalfMovesThisTurn tracking

    What to do:

    • packages/chess/src/schema.ts: add HalfMovesThisTurn: number to ChessAttrMap.
    • packages/chess/src/starting-position.ts: seed HalfMovesThisTurn = 0 on GAME_ENTITY wherever Turn is seeded.
    • packages/chess/src/engine.ts applyMove:
      1. BEFORE the turn-flip decision, increment HalfMovesThisTurn.
      2. Build TurnAdvanceContext { mover, halfMovesThisTurn }.
      3. Iterate active presets' shouldAdvanceTurn. First false wins → skip the flip. Otherwise flip Turn and reset HalfMovesThisTurn = 0.
    • packages/chess/src/presets/registry.ts: new TurnAdvanceContext + hook.
    • Snapshot/restore: verify HalfMovesThisTurn round-trips via session.allFacts (save/load, game.state snapshot).

    Files touched: packages/chess/src/schema.ts, packages/chess/src/starting-position.ts, packages/chess/src/engine.ts, packages/chess/src/presets/registry.ts, packages/chess/src/presets/turn-advance.test.ts (new).

    Verification:

    • Prototype "never-flip" preset; white keeps moving forever.
    • Fact resets on flip; increments on each non-cancelled move.
    • Existing 1417 tests unchanged (no preset implements the hook).

    Parallelization: Phase A. Blocks: B.2, B.3. Blocked by: none.

    Commit: feat(engine): shouldAdvanceTurn hook + HalfMovesThisTurn fact

  • A.4. overridePieceMoves hook

    What to do:

    • packages/chess/src/presets/registry.ts: new overridePieceMoves hook on PresetDef.
    • packages/chess/src/engine.ts: in per-piece move generation, BEFORE PIECE_TYPE_REGISTRY.get(type).generateMoves runs, iterate presets' overridePieceMoves(engine, pieceId). First non-undefined → replace the generator output for this piece and skip steps 2-3 (default generator + getExtraMoves). Still apply steps 4 (filterMoves) and 7 (filterLegalMoves).
      • Collision handling: when a second preset also returns non-undefined for the same piece, log a dev-mode console warning with both preset ids. Don't throw (let incompatibleWith handle this at config time).

    Files touched: packages/chess/src/presets/registry.ts, packages/chess/src/engine.ts, packages/chess/src/presets/override-piece-moves.test.ts (new).

    Verification:

    • Prototype "lame-knight" preset replacing knight moves with single-square orthogonal; knight behaves differently only with preset active.
    • getExtraMoves still contributes when no override is declared.
    • Collision warning fires in dev mode (verify via console spy).

    Parallelization: Phase A. Blocks: E.1, E.2. Blocked by: none.

    Commit: feat(engine): overridePieceMoves hook

  • A.5. Phase A verification gate + PRESET-API.md hook-ordering doc

    What to do:

    • Run bun run check — expect 1417 + new prototype tests green.
    • Run full Playwright suite — expect 80/80 (no behavioral change).
    • packages/chess/docs/PRESET-API.md: add "Hook ordering" section with the 7-step dispatch diagram from the design doc. Worked example for each of the 4 new hooks.
    • Append phase summary to .sisyphus/notepads/rule-variants/learnings.md.

    Parallelization: Phase A finale. Blocks: Phase B onward.

    Commit: docs(preset-api): hook ordering + Phase A summary


Phase B — Tier 1 Presets (close layouts-plan deferrals)

Goal: 4 T1 presets, each unblocks its matching layout's suggestedPresets chip.

  • B.1. knightmate-rules preset

    What to do:

    • packages/chess/src/presets/knightmate-rules.ts: implements getRoyalPieces returning every Knight entity of color. Declares requires: [] (works with any layout, best with Knightmate). incompatibleWith: ["coregal", "dual-king", "weak-dual-king"].
    • packages/chess/src/presets/knightmate-rules.test.ts: 10+ tests: (a) king moves into attacked square legally, (b) knight in check restricts moves to resolving check, (c) checkmating the last knight ends the game, (d) losing all knights-but-one is not game-over if any knight survives but IS game-over if ALL royals captured, (e) composition with piece-hp — attacking a knight drops HP instead of killing.
    • packages/chess/src/layouts/knightmate.ts: populate suggestedPresets: ["knightmate-rules"].

    Parallelization: Phase B, runs with B.2/B.3/B.4 concurrently (disjoint files). Blocks: F.2, F.4. Blocked by: A.1.

    Commit: feat(presets): knightmate-rules

  • B.2. double-move preset

    What to do:

    • packages/chess/src/presets/double-move.ts: implements shouldAdvanceTurn returning false when halfMovesThisTurn < 2. No royal override — uses default king logic. incompatibleWith: ["monster-rules"].
    • packages/chess/src/presets/double-move.test.ts: 12+ tests: (a) white plays two moves before black, (b) halfMovesThisTurn resets on flip, (c) check on first half-move — opponent must still respond on their first half-move (design call: check is allowed, opponent is NOT forced to block on move 1 — they can play any legal move including an intermediate move, and check filtering applies on each half-move separately; document this interpretation), (d) composition with piece-hp, (e) composition with king-heals (heal fires on full-turn-flip only).

    Parallelization: Phase B concurrent. Blocks: F.4. Blocked by: A.3.

    Commit: feat(presets): double-move

  • B.3. monster-rules preset

    What to do:

    • packages/chess/src/presets/monster-rules.ts: scope-aware. For scope: "white", shouldAdvanceTurn returns false when mover === "white" and halfMovesThisTurn < 2. For scope: "black", behaves as default. incompatibleWith: ["double-move", "suicide-chess", "capture-all"].
    • packages/chess/src/presets/monster-rules.test.ts: 10+ tests. White moves twice, black moves once. Combined with Monster layout → white plays king + 4 pawns; verify both moves resolve before black responds.
    • packages/chess/src/layouts/monster.ts: populate suggestedPresets: ["monster-rules"].

    Parallelization: Phase B concurrent. Blocks: F.2, F.4. Blocked by: A.3.

    Commit: feat(presets): monster-rules

  • B.4. first-promotion-wins preset

    What to do:

    • packages/chess/src/presets/first-promotion-wins.ts: onAfterMove inspects engine.moveLog[last].promotion; if non-null, records winner via engine.presetState. onCheckGameResult returns winner if recorded, else undefined. Also implements shouldFilterSelfCheck → false so a pawn under check can still promote. incompatibleWith: ["capture-to-win", "last-piece-standing", "extinction-chess", "suicide-chess", "capture-all"].
    • packages/chess/src/presets/first-promotion-wins.test.ts: 8+ tests: (a) first pawn promotion wins, (b) works with Pawns-Only layout, (c) composition with double-move (second half-move promotion also wins), (d) composition with piece-hp.
    • packages/chess/src/layouts/pawns-only.ts: populate suggestedPresets: ["first-promotion-wins"].

    Parallelization: Phase B concurrent. Blocks: F.2, F.4. Blocked by: A.1, A.3.

    Commit: feat(presets): first-promotion-wins

  • B.5. Phase B verification gate

    What to do: bun run check green. Manual Playwright smoke: open lobby, pick Knightmate layout → chip for knightmate-rules appears → enabling it + Play Solo starts a game with knight as the royal.

    Commit: chore(sisyphus): Phase B (Tier 1 presets) complete


Phase C — Tier 2 Royal-Variant Presets

Goal: 3 multi-royal presets exercising getRoyalPieces in broader ways.

  • C.1. coregal preset

    What to do:

    • packages/chess/src/presets/coregal.ts: getRoyalPieces returns union of king + queen entities for color. incompatibleWith: ["knightmate-rules", "dual-king", "weak-dual-king", "suicide-chess"].
    • packages/chess/src/presets/coregal.test.ts: 10+ tests: (a) mating only the king ends the game (queen can't save), (b) mating only the queen ends the game, (c) no queen on board → behaves as FIDE, (d) pins affect the queen (moving pinned queen leaves king in check → illegal), (e) composition with piece-hp — queen loses HP on capture attempts.

    Parallelization: Phase C concurrent. Blocks: F.1. Blocked by: A.1.

    Commit: feat(presets): coregal

  • C.2. dual-classic layout + dual-king preset

    What to do:

    • packages/chess/src/layouts/dual-classic.ts: new layout based on FIDE but with 2 kings per side (e.g., kings on e1 + d1 for white; queens moved to a file or omitted; document the choice). Register in layouts barrel.
    • packages/chess/src/presets/dual-king.ts: getRoyalPieces returns ALL king entities of color. Works with any layout but designed for dual-classic. incompatibleWith: ["weak-dual-king", "coregal", "knightmate-rules"].
    • packages/chess/src/presets/dual-king.test.ts: 8+ tests. Mating EITHER king ends the game (strong variant). Pawn underpromotion-to-king increases the royal count — integration test with 3 kings.

    Parallelization: Phase C concurrent. Blocks: F.1, F.2. Blocked by: A.1.

    Commit: feat(presets): dual-king + dual-classic layout

  • C.3. weak-dual-king preset

    What to do:

    • packages/chess/src/presets/weak-dual-king.ts: getRoyalPieces returns ALL king entities of color. Adds onCheckGameResult override: game-over only when EVERY royal of one side is captured OR all remaining royals are mated simultaneously. First mate of ONE king does NOT end the game — the other king plays on. incompatibleWith: ["dual-king", "coregal", "knightmate-rules"].
    • packages/chess/src/presets/weak-dual-king.test.ts: 8+ tests: (a) one king lost → game continues, (b) both lost → game ends, (c) last king mated → game ends.

    Parallelization: Phase C concurrent. Blocks: F.1. Blocked by: A.1.

    Commit: feat(presets): weak-dual-king

  • C.4. Phase C verification gate

    What to do: bun run check green. Integration test: coregal

    • piece-hp — queen loses HP but king is still the primary royal; attacker must checkmate AT LEAST ONE royal to end.

    Commit: chore(sisyphus): Phase C (royal variants) complete


Phase D — Tier 2 Objective-Variant Presets

Goal: 3 win-condition-override presets.

  • D.1. suicide-chess preset

    What to do:

    • packages/chess/src/presets/suicide-chess.ts:
      • filterLegalMoves: if any move in the aggregated list has isCapture === true, return only captures.
      • shouldFilterSelfCheck → false (king is not royal).
      • getRoyalPieces → [] (explicit empty).
      • onCheckGameResult: if one side has 0 pieces on the board, that side WINS (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"].
    • packages/chess/src/presets/suicide-chess.test.ts: 14+ tests: (a) captures compulsory when any capture available, (b) non-captures legal otherwise, (c) losing all pieces wins, (d) pawn promotion still legal, (e) composition with piece-hp — an HP-tagged capture that doesn't kill still satisfies the compulsion (design call documented in-file).

    Parallelization: Phase D concurrent. Blocks: F.1, F.4. Blocked by: A.1, A.2.

    Commit: feat(presets): suicide-chess

  • D.2. capture-all preset

    What to do:

    • packages/chess/src/presets/capture-all.ts:
      • 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"].
    • packages/chess/src/presets/capture-all.test.ts: 8+ tests.

    Parallelization: Phase D concurrent. Blocks: F.1. Blocked by: A.1.

    Commit: feat(presets): capture-all

  • D.3. extinction-chess preset (configurable target type)

    What to do:

    • packages/chess/src/presets/extinction-chess.ts: Configurable target type via engine.presetState<{ targetType: PieceType }>("extinction-chess"). Default: "pawn" (common, exercises attrition). onCheckGameResult returns winner when opposite side has 0 pieces of targetType. No royal override; king can still be captured freely. incompatibleWith: ["suicide-chess", "capture-all", "first-promotion-wins"].
    • packages/chess/src/presets/extinction-chess.test.ts: 10+ tests covering each target type (pawn, knight, bishop, rook, queen, king).
    • UI follow-up note: Cycling target via drawer chip is out of scope; document as a post-landing follow-up.

    Parallelization: Phase D concurrent. Blocks: F.1. Blocked by: none.

    Commit: feat(presets): extinction-chess

  • D.4. Phase D verification gate

    What to do: bun run check green.

    Commit: chore(sisyphus): Phase D (objective variants) complete


Phase E — Tier 2 Movement-Variant Presets

Goal: 4 move-generation-override presets.

  • E.1. berolina-pawns preset

    What to do:

    • packages/chess/src/presets/berolina-pawns.ts: overridePieceMoves for pawn pieces. Generates:
      • Forward diagonals → non-capture push (one square; two from home rank)
      • Forward orthogonal → capture only
      • En passant reinterpreted: if opponent just double-diagonal- pushed adjacent to yours, capture orthogonally to the skipped square (document the rule choice in-file — several variants exist).
      • Promotion: diagonal push to promotion rank still promotes. incompatibleWith: ["pawn-diagonal-no-capture", "berolina-pawns-2", "pawns-move-backward"].
    • packages/chess/src/presets/berolina-pawns.test.ts: 15+ tests covering double-push, capture, en-passant, promotion, block detection, composition with piece-hp.

    Parallelization: Phase E concurrent. Blocks: E.2, F.4. Blocked by: A.4.

    Commit: feat(presets): berolina-pawns

  • E.2. berolina-pawns-2 preset

    What to do:

    • packages/chess/src/presets/berolina-pawns-2.ts: identical to berolina-pawns but also allows sideways captures (same-rank, adjacent file). incompatibleWith: ["berolina-pawns", ...same list].
    • packages/chess/src/presets/berolina-pawns-2.test.ts: 6+ tests targeting the DIFF from berolina-pawns.

    Parallelization: Phase E concurrent. Blocks: F.4. Blocked by: E.1 (for shared helpers).

    Commit: feat(presets): berolina-pawns-2

  • E.3. bouncing-pieces preset

    What to do:

    • packages/chess/src/presets/bouncing-pieces.ts: getExtraMoves for bishop + queen. Computes diagonal rays that reflect off left/right file edges. 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; bouncing is redundant).
    • packages/chess/src/presets/bouncing-pieces.test.ts: 10+ tests: bishop on e4 reaches squares via right-edge bounce, queen rays reflect and stop on block, capture on reflected ray works, friendly block stops before the reflection.

    Parallelization: Phase E concurrent. Blocks: E.4. Blocked by: none.

    Commit: feat(presets): bouncing-pieces

  • E.4. bouncing-pieces-2 preset

    What to do:

    • packages/chess/src/presets/bouncing-pieces-2.ts: like bouncing-pieces but reflects off ALL four edges. Max reflection count capped at 2 (prevents infinite-loop ray computation on empty board; document). Extends/replaces bouncing-pieces; both shouldn't be active simultaneously.
    • packages/chess/src/presets/bouncing-pieces-2.test.ts: 8+ tests.

    Parallelization: Phase E concurrent. Blocks: F.1. Blocked by: E.3.

    Commit: feat(presets): bouncing-pieces-2

  • E.5. Phase E verification gate

    What to do: bun run check green.

    Commit: chore(sisyphus): Phase E (movement variants) complete


Phase F — Lobby Integration + E2E

  • F.1. Audit incompatibleWith graph

    What to do: Add packages/chess/src/presets/presets.test.ts (if missing) or extend the existing preset-registry test. Reflexive

    • symmetric check: if A declares B incompatible, B MUST declare A incompatible (or explicitly opt out via an allowedOneWay: ["B"] field with a comment — prefer mutual). Runs across every registered preset. Failure message names the pair.

    Parallelization: Phase F. Blocks: F.5. Blocked by: B-E.

    Commit: test(presets): enforce incompatibleWith symmetry

  • F.2. suggestedPresets wiring in premade layouts

    What to do:

    • packages/chess/src/layouts/knightmate.ts: suggestedPresets: ["knightmate-rules"] (done in B.1 task; this step is the audit that every layout's suggestions point at real presets).
    • packages/chess/src/layouts/monster.ts: ["monster-rules"].
    • packages/chess/src/layouts/pawns-only.ts: ["first-promotion-wins"].
    • packages/chess/src/layouts/dunsany.ts: [] (no canonical rules).
    • packages/chess/src/layouts/horde.ts: [].
    • packages/chess/src/layouts/dual-classic.ts: ["dual-king"].
    • packages/chess/src/ui/LayoutPicker.tsx (from layouts plan): render a small "Suggested rules" badge group next to each layout when suggestedPresets.length > 0. Tap to enable.
    • Add a unit test verifying every suggestedPresets entry references a registered preset id (prevents dangling links).

    Parallelization: Phase F. Blocks: F.4, F.5. Blocked by: B.1-B.4, C.2.

    Commit: feat(layouts-ui): suggested-preset chips

  • F.3. Lobby RulesDrawer preset grouping

    What to do:

    • packages/chess/src/ui/RulesDrawer.tsx: group presets by category (King Variants, Objectives, Movement, Multi-move, Pieces, Misc). Each preset declares a category: "king" | "objective" | "movement" | "multi-move" | "pieces" | "misc" field (new, optional for legacy presets — default to "misc"). Render a section header per category; preserve existing filter/ search UI.
    • Visual separator between categories (thin border-neutral-200).
    • Keep the existing enable/disable chip semantics; no functional change, purely organizational.

    Parallelization: Phase F. Blocked by: B-E.

    Commit: feat(ui): group rules drawer presets by category

  • F.4. Rule-variant e2e smoke

    What to do: packages/chess/e2e/rule-variants.spec.ts (new). Three representative scenarios end-to-end:

    1. Knightmate: open lobby → pick Knightmate layout → enable knightmate-rules chip → Play Solo → make any move → attack a knight → check banner appears → attack the king → no check banner (king not royal).
    2. Double-move: enable double-move → white plays two moves → black plays one → turn indicator flips correctly.
    3. Suicide-chess: enable suicide-chess → play pieces until a capture is available → verify only captures are offered in the legal-move UI.

    Parallelization: Phase F. Blocks: F.5. Blocked by: B.1, B.2, D.1, F.2.

    Commit: test(e2e): rule-variant vertical slice

  • F.5. Docs + final regression

    What to do:

    • packages/chess/docs/PRESET-API.md: add a "Rule Variants Gallery" section with a one-paragraph description of each of the 14 new presets.
    • packages/chess/RULES.md: cross-reference each preset with the greenchess.net entry, link back to https://greenchess.net/variants.php?cat=4 for authority.
    • Run full regression: bun run check + full Playwright suite (server up for multiplayer scenarios).

    Parallelization: Phase F finale. Blocks: Final Wave. Blocked by: F.1, F.2, F.3, F.4.

    Commit: docs(preset-api): rule variants gallery + RULES.md cross-refs


Final Verification Wave

Run every reviewer AFTER Phase F lands. All must return APPROVE before marking the plan complete. Pattern mirrors T3's F1-F4.

  • F1. Plan compliance audit — oracle — APPROVE (bg_f2508b15)

    • All 31 top-level tasks checked.
    • 4 new hooks declared in packages/chess/src/presets/registry.ts.
    • 14 preset .ts files + matching .test.ts exist.
    • HalfMovesThisTurn in ChessAttrMap.
    • dual-classic.ts layout file present and registered.
    • Tier 3 (royalty-transfer) explicitly NOT present.
  • F2. Code quality review — unspecified-high — APPROVE (bg_1101ecb3)

    • Zero as any, @ts-ignore, @ts-expect-error in non-test source.
    • as unknown as X count does not increase vs baseline.
    • Every new preset declares incompatibleWith.
    • incompatibleWith graph is symmetric (F.1 test proves).
    • Every new preset has ≥ 8 behavioral tests.
    • Hook dispatch in engine.ts is documented with inline comments referencing the step numbers from PRESET-API.md.
  • F3. Manual QA — unspecified-high with playwright — APPROVE (orchestrator-direct after agent hit tool cap; 1651/1651 unit + 83/83 Playwright + 2/2 visual spot-checks)

    • Full Playwright regression green.
    • Manual walkthrough: enable knightmate-rules, double-move, suicide-chess one at a time via the drawer → each plays correctly.
    • Visual check: RulesDrawer category grouping renders cleanly.
  • F4. Scope fidelity — deep — APPROVE (bg_77460b0f)

    • No hook added outside the 4 declared (getRoyalPieces, filterLegalMoves, shouldAdvanceTurn, overridePieceMoves).
    • No royalty-transfer preset smuggled in.
    • No PGN notation changes.
    • No ranking/ELO code.
    • Non-8×8 variants absent.

Approval workflow: Each reviewer returns APPROVE or REJECT. On reject, fix in the same session via task(session_id=..., prompt= "REJECT reason: ... fix by ..."). Re-run after fix. Do NOT mark the plan complete until all four report APPROVE.

Commit: chore(sisyphus): Rule Variants Final Verification Wave — all reviewers APPROVE


Parallel Execution Map

Phase A (architecture) — 5 tasks, A.1-A.4 parallel, A.5 gate
  A.1 getRoyalPieces ──┐
  A.2 filterLegalMoves ─┤
  A.3 shouldAdvanceTurn ┤  → A.5 docs + gate
  A.4 overridePieceMoves┘

Phase B (Tier 1) — 5 tasks, B.1-B.4 parallel, B.5 gate
  B.1 knightmate ──────┐
  B.2 double-move ─────┤
  B.3 monster ─────────┤  → B.5 gate
  B.4 first-promotion ─┘

Phase C (royal T2) — 4 tasks, C.1-C.3 parallel, C.4 gate
Phase D (objective T2) — 4 tasks, D.1-D.3 parallel, D.4 gate
Phase E (movement T2) — 5 tasks, E.1+E.3 parallel, E.2 after E.1,
                         E.4 after E.3, E.5 gate

Phase F (integration) — 5 tasks, F.1-F.3 parallel, F.4 after F.2,
                         F.5 finale

Final Wave — F1-F4 parallel

Recommended wave size: 3-5 tasks per parallel batch. Never > 5. Every delegated agent runs with its own disjoint file set.


Risks (recap — full rationale in design doc)

  1. Hook ordering. filterLegalMoves is pipeline-composable (safe), but shouldAdvanceTurn and overridePieceMoves are first-match-wins. Mitigation: document in PRESET-API.md + incompatibleWith between any two presets that both override the same hook for the same subject.
  2. Suicide + HP edge case. Capture-compulsory + non-lethal HP capture: DOES the no-kill hit satisfy compulsion? YES (the move IS a capture). Documented in D.1.
  3. Multi-royal performance. Check detection iterates royals. With dual-king (2) + coregal (1 king + N queens), cost is linear in royals. Realistic max ~5 pieces. Profile only if it shows up.
  4. Check banner UX with multi-royal. Which royal is in check? Deferred to a small follow-up UI task (Risks 4 of design doc).
  5. Promotion to king (dual-king underpromotion). 3+ kings per side possible. Rules stay sound; verify getAllLegalMoves doesn't explode via an integration test with 3 kings.
  6. Berolina en passant interpretation. Multiple valid reads. Pick one (reflected en passant to the skipped square) and document in-file.
  7. Override-piece-moves collisions. Two presets overriding pawns = undefined behavior. Mitigation: incompatibleWith declarations + dev-mode console warning (implemented in A.4).

Glossary

  • Royal: a piece whose capture/mate ends the game. FIDE default = PieceType king. Knightmate = knight. Coregal = king + queen.
  • Half-move: one player's single move. Turn = typically 1 half-move; Monster/Double-move turn = 2 half-moves.
  • Compulsory capture: Suicide Chess — if any capture is legal, the mover MUST choose a capture.
  • Royal set: the EntityId[] returned from getRoyalPieces. Can be empty (Suicide) for "no royalty".

When to Stop Mid-Plan

If tool budget or context pressure forces an early stop:

  • After Phase A: foundation lands; no new presets. Document the hooks as "available, no active users" — Tier 1 can ship in a follow-up.
  • After Phase B: T1 presets close the layouts deferrals. Ship. Tier 2 is a future epic.
  • After Phase C or D: Tier 2 royal/objective variants ship. The remaining tier (movement variants E.1-E.4) becomes its own follow-up mini-epic.
  • After Phase E but before F: feature-complete but UI integration (category grouping, suggestion chips) is a polish follow-up.

In every case: update this plan's checkboxes to reflect actual landed state, append a ## Early Stop section with rationale, and do NOT run the Final Verification Wave — reserve that for the full plan.