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).
40 KiB
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-shippedmodifier-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) plusHalfMovesThisTurngame 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
PresetDefHalfMovesThisTurngame fact + turn-advance gating- 14 preset implementations with 8-15 behavioral tests each
dual-classiclayout (2 kings per side) — needed bydual-kingpreset- Rule-variant Playwright e2e smoke (3 representative variants)
PRESET-API.mdhook-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
suggestedPresetspointing 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/onTurnStarthooks 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"viafindKingPosition(session, color).packages/chess/src/rules/checkmate.ts:80,packages/chess/src/rules/stalemate.ts:92,105— callisInCheckand iterate "every king" implicitly.packages/chess/src/engine.ts:855—getAllLegalMoves(): aggregation point wherefilterLegalMoveswill hook in (POST self-check filter).packages/chess/src/engine.ts:964—applyMove: turn-flip lives here;shouldAdvanceTurnintercepts before the flip.packages/chess/src/engine.ts—getAllLegalMovesdispatches per-piece viaPIECE_TYPE_REGISTRY.get(type).generateMoves;overridePieceMovesruns BEFORE this dispatch.packages/chess/src/presets/registry.ts:297-500—PresetDefhook 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 viadescribeMoveEffectinstead. - 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, noas unknown as X. One judiciousas Xboundary cast is OK with JSDoc explaining why. Audit withgrep -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), NOTbun 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 anypackages/server/src/protocol.tsedit). - 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.Yheadings.
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.tsis the template for how to prove agreement. test.fixmepolicy: only use for scenarios that genuinely need engine wiring not yet in place. Everyfixmein this plan's e2e task MUST be marked with a// blocked on: TASK_IDcomment 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 onlydouble-move→ shouldAdvanceTurn + HalfMovesThisTurnmonster-rules→ shouldAdvanceTurn + scope-aware dispatchfirst-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 onlysuicide-chess→ filterLegalMoves + getRoyalPieces (empty)capture-all,extinction-chess→ existing hooks onlyberolina-pawns,berolina-pawns-2→ overridePieceMovesbouncing-pieces,bouncing-pieces-2→ existinggetExtraMoves
Phase A — Hook API Extensions (architecture)
Goal: Add 4 hooks + HalfMovesThisTurn fact; zero behavioral change
to any existing preset/game.
-
A.1.
getRoyalPieceshook + engine royal-dispatch + isInCheck refactorWhat to do:
packages/chess/src/presets/registry.ts: addRoyalContexttype andgetRoyalPieceshook signature onPresetDef.packages/chess/src/rules/check.ts:isInCheck(session, color)gains an optional 3rd paramroyalEntityIds?: readonly EntityId[]. When provided, iterate those entities' positions instead of callingfindKingPosition. 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 privategetActiveRoyalEntityIds(color): EntityId[]that unions every active preset'sgetRoyalPiecesreturns. Empty-union fallback = default king entities viafindKingPosition-equivalent logic. Use this in every internal call toisInCheck/isCheckmate/isStalemate/filterSelfCheckMoves.- Handle empty-royal case:
isInCheck/isCheckmate/isStalemateMUST returnfalsewhen 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 checkgreen. All existing 1417 tests pass unchanged. The newroyal-pieces.test.tsmust 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:
deepOR 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.
filterLegalMoveshookWhat to do:
packages/chess/src/presets/registry.ts: addFilterLegalMovesContext { readonly engine: ChessEngine; readonly color: "white" | "black"; readonly moves: readonly LegalMove[] }filterLegalMoveshook.
packages/chess/src/engine.tsgetAllLegalMoves: after the existing self-check filter (step 6), iterate active presets'filterLegalMovesin 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.
shouldAdvanceTurnhook +HalfMovesThisTurntrackingWhat to do:
packages/chess/src/schema.ts: addHalfMovesThisTurn: numbertoChessAttrMap.packages/chess/src/starting-position.ts: seedHalfMovesThisTurn = 0onGAME_ENTITYwhereverTurnis seeded.packages/chess/src/engine.tsapplyMove:- BEFORE the turn-flip decision, increment
HalfMovesThisTurn. - Build
TurnAdvanceContext { mover, halfMovesThisTurn }. - Iterate active presets'
shouldAdvanceTurn. Firstfalsewins → skip the flip. Otherwise flip Turn and resetHalfMovesThisTurn = 0.
- BEFORE the turn-flip decision, increment
packages/chess/src/presets/registry.ts: newTurnAdvanceContext+ hook.- Snapshot/restore: verify
HalfMovesThisTurnround-trips viasession.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.
overridePieceMoveshookWhat to do:
packages/chess/src/presets/registry.ts: newoverridePieceMoveshook onPresetDef.packages/chess/src/engine.ts: in per-piece move generation, BEFOREPIECE_TYPE_REGISTRY.get(type).generateMovesruns, 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
incompatibleWithhandle this at config time).
- 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
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 - Run
Phase B — Tier 1 Presets (close layouts-plan deferrals)
Goal: 4 T1 presets, each unblocks its matching layout's
suggestedPresets chip.
-
B.1.
knightmate-rulespresetWhat to do:
packages/chess/src/presets/knightmate-rules.ts: implementsgetRoyalPiecesreturning every Knight entity ofcolor. Declaresrequires: [](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: populatesuggestedPresets: ["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-movepresetWhat to do:
packages/chess/src/presets/double-move.ts: implementsshouldAdvanceTurnreturningfalsewhenhalfMovesThisTurn < 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-rulespresetWhat to do:
packages/chess/src/presets/monster-rules.ts: scope-aware. Forscope: "white",shouldAdvanceTurnreturnsfalsewhenmover === "white"andhalfMovesThisTurn < 2. Forscope: "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: populatesuggestedPresets: ["monster-rules"].
Parallelization: Phase B concurrent. Blocks: F.2, F.4. Blocked by: A.3.
Commit:
feat(presets): monster-rules -
B.4.
first-promotion-winspresetWhat to do:
packages/chess/src/presets/first-promotion-wins.ts:onAfterMoveinspectsengine.moveLog[last].promotion; if non-null, records winner viaengine.presetState.onCheckGameResultreturns winner if recorded, elseundefined. Also implementsshouldFilterSelfCheck → falseso 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: populatesuggestedPresets: ["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 checkgreen. 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.
coregalpresetWhat to do:
packages/chess/src/presets/coregal.ts:getRoyalPiecesreturns union of king + queen entities forcolor.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-classiclayout +dual-kingpresetWhat 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:getRoyalPiecesreturns ALL king entities ofcolor. 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-kingpresetWhat to do:
packages/chess/src/presets/weak-dual-king.ts:getRoyalPiecesreturns ALL king entities ofcolor. AddsonCheckGameResultoverride: 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 checkgreen. 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-chesspresetWhat to do:
packages/chess/src/presets/suicide-chess.ts:filterLegalMoves: if any move in the aggregated list hasisCapture === 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-allpresetWhat 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-chesspreset (configurable target type)What to do:
packages/chess/src/presets/extinction-chess.ts: Configurable target type viaengine.presetState<{ targetType: PieceType }>("extinction-chess"). Default:"pawn"(common, exercises attrition).onCheckGameResultreturns winner when opposite side has 0 pieces oftargetType. 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 checkgreen.Commit:
chore(sisyphus): Phase D (objective variants) complete
Phase E — Tier 2 Movement-Variant Presets
Goal: 4 move-generation-override presets.
-
E.1.
berolina-pawnspresetWhat to do:
packages/chess/src/presets/berolina-pawns.ts:overridePieceMovesfor 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-2presetWhat to do:
packages/chess/src/presets/berolina-pawns-2.ts: identical toberolina-pawnsbut 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-piecespresetWhat to do:
packages/chess/src/presets/bouncing-pieces.ts:getExtraMovesfor 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-2presetWhat 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 checkgreen.Commit:
chore(sisyphus): Phase E (movement variants) complete
Phase F — Lobby Integration + E2E
-
F.1. Audit
incompatibleWithgraphWhat 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 - symmetric check: if A declares B incompatible, B MUST declare A
incompatible (or explicitly opt out via an
-
F.2.
suggestedPresetswiring in premade layoutsWhat 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 whensuggestedPresets.length > 0. Tap to enable.- Add a unit test verifying every
suggestedPresetsentry 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 acategory: "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:- 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).
- Double-move: enable double-move → white plays two moves → black plays one → turn indicator flips correctly.
- 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
.tsfiles + matching.test.tsexist. HalfMovesThisTurninChessAttrMap.dual-classic.tslayout 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-errorin non-test source. as unknown as Xcount does not increase vs baseline.- Every new preset declares
incompatibleWith. incompatibleWithgraph is symmetric (F.1 test proves).- Every new preset has ≥ 8 behavioral tests.
- Hook dispatch in
engine.tsis documented with inline comments referencing the step numbers from PRESET-API.md.
- Zero
-
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)
- Hook ordering.
filterLegalMovesis pipeline-composable (safe), butshouldAdvanceTurnandoverridePieceMovesare first-match-wins. Mitigation: document in PRESET-API.md +incompatibleWithbetween any two presets that both override the same hook for the same subject. - 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.
- 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.
- Check banner UX with multi-royal. Which royal is in check? Deferred to a small follow-up UI task (Risks 4 of design doc).
- Promotion to king (dual-king underpromotion). 3+ kings per side
possible. Rules stay sound; verify
getAllLegalMovesdoesn't explode via an integration test with 3 kings. - Berolina en passant interpretation. Multiple valid reads. Pick one (reflected en passant to the skipped square) and document in-file.
- Override-piece-moves collisions. Two presets overriding pawns =
undefined behavior. Mitigation:
incompatibleWithdeclarations + 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 fromgetRoyalPieces. 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.