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

978 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)
```ts
// 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`
```ts
// 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)
```ts
// 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.
- [x] 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.
- [x] 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.
- [x] 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.
- [x] 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.