chore(sisyphus): execution plan for rule-variants epic (v2)

Writes the step-by-step to-do list successor to the Momus-approved
design doc at .sisyphus/plans/rule-variants.md. The design doc has
the full hook-API rationale, risk analysis, and architecture sketch;
rule-variants-v2.md is the agent-executable task list.

Structure mirrors the modifier-profiles-t3 plan the repo just
shipped: 6 phases (A-F) + Final Verification Wave, 32 top-level
checkboxes, parallel-execution map, T3-learned tactics section for
the incoming agent (tool-cap fragility, commit discipline,
v3/v4 mirror pitfalls), and explicit 'when to stop mid-plan'
branching so an early-exit delivers a coherent subset.

Scope (recap from design doc):
  - Phase A: 4 new PresetDef hooks (getRoyalPieces,
    filterLegalMoves, shouldAdvanceTurn, overridePieceMoves) +
    HalfMovesThisTurn game fact.
  - Phase B: 4 Tier-1 presets closing starting-layouts deferrals
    (knightmate-rules, double-move, monster-rules,
    first-promotion-wins).
  - Phase C: 3 royal-variant Tier-2 presets (coregal, dual-king,
    weak-dual-king) + dual-classic layout.
  - Phase D: 3 objective-variant Tier-2 presets (suicide-chess,
    capture-all, extinction-chess).
  - Phase E: 4 movement-variant Tier-2 presets (berolina-pawns ×2,
    bouncing-pieces ×2).
  - Phase F: lobby integration (incompatibleWith audit,
    suggestedPresets wiring, drawer category grouping), rule-
    variant e2e, docs.
  - Final Wave: 4 reviewers (oracle/deep/unspecified-high).

Explicit deferrals: royalty-transfer (T3 in design doc; needs
mid-game state toggle), PGN notation changes, ELO/ranking,
non-8×8 variants, multi-royal check-banner UX (follow-up).

Each task carries 'what to do', file anchors, verification
criteria, parallelization map, recommended agent profile, and
commit message template — designed so an agent can pick up task
X.Y without re-reading the whole plan.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-20 18:15:36 -06:00
commit a159299818
No known key found for this signature in database

View file

@ -0,0 +1,978 @@
# 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.
- [ ] F1. **Plan compliance audit** — oracle
- 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
- 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
- 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
- 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.