houserules/.sisyphus/plans/post-epic-deferrals.md
Joey Yakimowich-Payne 4789a479ad
plan(sisyphus): lock 7 design decisions for post-epic deferrals
All 7 decisions locked 2026-04-21:

  Feature 1 (MP color):
    - Joiner-side choice: NO (creator picks, joiner takes rest)
    - Re-pick mid-session: NO (fixed at room creation)

  Feature 2 (Extinction UI):
    - Multiplayer target-sync: NO in v1 (solo-only; MP fixed at
      preset-activation time)

  Feature 3 (Berolina ep):
    - Variant: Parton 1952 (standard ep through Berolina geometry)

  Feature 4 (Royalty transfer) — cleared to execute after 1-3:
    - Turn-consumption: YES (prevents 'abdicate out of mate')
    - UI: dedicated 'Actions' button in GameView
    - Stakeholder demand: YES, validated
2026-04-21 10:33:14 -06:00

25 KiB

Post-Epic Deferrals — Design + Execution Plan

Scope: Four independent features previously deferred during or after the rule-variants epic. Each ships as its own mini-epic with verification gate. The features are sequenced so the SMALLEST / HIGHEST-VALUE item lands first.

Prior art: .sisyphus/plans/rule-variants-v2.md for execution discipline, commit conventions, and the Final Verification Wave pattern. This plan mirrors that structure.


TL;DR

Ship 4 deferred items in sequence:

  1. Multiplayer color choice — Lobby UI + protocol field + server assignment logic. High user value, isolated surface, ~1 session.
  2. Extinction-chess UI target cycling — chip UI inside RulesDrawer to pick which piece type is the extinction target, without opening the preset-state API. ~½ session.
  3. Berolina en-passant — rule decision + implementation for pawn en-passant under berolina-pawns and berolina-pawns-2. Pure overridePieceMoves extension. ~½ session.
  4. Tier 3 royalty-transfer — new preset family for "crown" moves that relocate royalty mid-game. Requires new engine surface (likely a lifecycle hook) + UI for target selection. Biggest risk + longest. ~1-2 sessions.

Recommendation: work items 1-3 sequentially (each is small and independent). Defer item 4 into its own follow-up session after 1-3 ship. The plan below fully specifies all four so any of them can be started independently.


Feature 1 — Multiplayer Color Choice

Current state (audited)

  • Server hardcodes creator=white, joiner=black in packages/server/src/rooms.ts:212,255.
  • Protocol has NO color preference field on room.create / room.join; the server only RETURNS the assigned color in room.created / room.joined.
  • Lobby UI has no color selector — creating a room gives you white, joining gives you black.
  • Tests (packages/chess/e2e/multiplayer.spec.ts) assume the hardcoded order: pageA=white, pageB=black.
  • No rematch / swap mechanism exists.

Design

Add a 3-option host-side preference: white | black | random. The creator picks; the server resolves random at room-creation time; the joiner gets whatever color the creator didn't take.

Explicitly OUT of scope for this feature:

  • Joiner-side preference (complex: what if both want white?). Defer.
  • Rematch-with-swapped-colors. Defer — separate feature.
  • Color re-selection after room creation. Defer — needs server-side swap handling for an existing WS session.

Protocol changes

  1. packages/server/src/protocol.ts:

    • Add preferredColor: z.enum(["white", "black", "random"]).optional() to RoomCreatePayloadSchema. Omitting it = "white" (current behaviour, backward compat).
    • No change to RoomJoinPayloadSchema (joiner takes the remaining color).
    • No change to response payloads — they already return the resolved color in room.created / room.joined.
  2. packages/chess/src/net/types.ts: mirror the new field on the client RoomCreatePayload interface.

  3. Zod v3↔v4 parity test: add to packages/server/src/custom-modifier-wire-parity.test.ts sibling (or create lobby-wire-parity.test.ts) — ensures the new field round-trips across the server/client schema boundary. Pattern: T3's Q4.2 parity test.

Server changes

packages/server/src/rooms.ts:

createRoom(
  rulesetIds: string[] = [],
  layout?: StartingLayout,
  profile?: ModifierProfile,
  preferredColor: "white" | "black" | "random" = "white",
): { code, token, color, layout, profile? } {
  // Resolve random at creation time.
  const creatorColor: "white" | "black" =
    preferredColor === "random"
      ? (Math.random() < 0.5 ? "white" : "black")
      : preferredColor;

  // Store both the resolved creator color AND the joiner color
  // on the room so joinRoom() returns the right one.
  const joinerColor: "white" | "black" =
    creatorColor === "white" ? "black" : "white";

  // ... rest of createRoom, using creatorColor instead of the
  // hardcoded "white" literal. Room now has a `joinerColor` field.
}

joinRoom(code: string): JoinResult {
  // Read `room.joinerColor` instead of hardcoding "black".
  const player: RoomPlayer = {
    token,
    color: room.joinerColor,
    // ...
  };
}

Random-seed determinism: use crypto.randomBytes or Math.random? Math.random is fine for non-security randomness here. No test flakiness concern — the color is known AFTER room creation.

Backward compat: clients sending the old payload (no preferredColor) get the default "white", matching current hardcoded behaviour exactly.

UI changes

packages/chess/src/ui/Lobby.tsx:

Add a color selector in the "Host Game" section, between the LayoutPicker and the Modifier Profile picker:

<div className="space-y-1.5">
  <label className="text-xs font-semibold text-neutral-600">
    Play as
  </label>
  <div className="flex gap-2">
    {(["white", "black", "random"] as const).map((color) => (
      <button
        key={color}
        data-testid={`color-preference-${color}`}
        type="button"
        onClick={() => setPreferredColor(color)}
        className={/* active/inactive styling */}
      >
        {color === "white" ? "White" : color === "black" ? "Black" : "Random"}
      </button>
    ))}
  </div>
</div>

State: const [preferredColor, setPreferredColor] = useState<"white" | "black" | "random">("white");

handleCreate() includes createPayload.preferredColor = preferredColor.

Visual style matches the existing modifier-profile chip selector pattern for consistency.

Tests

Unit tests in packages/server/src/rooms.test.ts:

  • Default (no preferredColor) → creator=white, joiner=black (back-compat).
  • preferredColor: "white" → creator=white.
  • preferredColor: "black" → creator=black, joiner=white.
  • preferredColor: "random" → both outcomes sampled across 20 rooms.

E2E tests in packages/chess/e2e/multiplayer.spec.ts:

  • Update existing "Scholar's Mate checkmate" test to still pass with the new default — should need zero changes.
  • Add NEW test: host picks "black" → pageA=black, pageB=white. Verify color assignment end-to-end.
  • Add NEW test: host picks "random" → one of the two valid assignments; test asserts "each client has exactly one of {white, black}" and they differ.

Playwright UI test: verify the color selector renders, all three options clickable, default is "white".

Execution checklist

  • F1.1. Add preferredColor to RoomCreatePayloadSchema (zod server) + client type mirror + parity test.
  • F1.2. Update RoomRegistry.createRoom signature; store joinerColor on the room.
  • F1.3. Update RoomRegistry.joinRoom to read room.joinerColor.
  • F1.4. Add color preference UI to Lobby.tsx with data-testid on each option.
  • F1.5. Update Lobby.handleCreate() to include preferredColor in the payload.
  • F1.6. Unit tests on rooms.ts (4 scenarios).
  • F1.7. E2E tests: add 2 new multiplayer scenarios for black-host + random-host.
  • F1.8. Run full Playwright (expect 85/85).
  • F1.9. Commit: feat(multiplayer): host color preference (white/black/random)

Risk register

  • Test flakiness from random: ZERO risk — tests assert either color assignment works, not a specific outcome.
  • Backward compat: backfilled default = "white" makes old clients indistinguishable.
  • UI footprint: 3-button group; no new dependencies; matches existing Tailwind patterns.

Feature 2 — Extinction-Chess UI Target Cycling

Current state

extinction-chess preset (shipped commit 08b8e0f) uses engine.presetState<{ targetType: PieceType }>("extinction-chess") for configuration. Default seeded to "pawn". No UI exposure — users must set via code or dev console.

Design

Add a chip cycler inside the RulesDrawer's extinction-chess card. Appears ONLY when extinction-chess is active. Clicking cycles through the 6 piece types: pawn → knight → bishop → rook → queen → king → pawn.

The card should visually indicate "Target: Pawns" (or similar) above the chip so inactive-mode users still see the default.

UI surface

packages/chess/src/ui/RulesDrawer.tsx: inside the data-preset="extinction-chess" block, after the normal description/toggle/deps rendering, insert a configuration row:

{preset.id === "extinction-chess" && isOn && (
  <div className="mt-3 flex items-center gap-2">
    <span className="text-xs font-semibold text-neutral-600">
      Target:
    </span>
    <button
      data-testid="extinction-target-cycler"
      type="button"
      onClick={cycleExtinctionTarget}
      className="px-2.5 py-1 rounded-md bg-neutral-100 hover:bg-neutral-200 text-xs font-medium text-neutral-700 capitalize"
    >
      {currentExtinctionTarget}
    </button>
  </div>
)}

The cycleExtinctionTarget callback is passed in as a prop (parent owns the state). RulesDrawer reads the current target from engine.presetState<{ targetType: PieceType }>("extinction-chess") via a new prop extinctionTarget: PieceType and calls setExtinctionTarget(next) on click.

State flow

The host manages extinctionTarget in GameView or via a dedicated hook:

  • On preset activation: read current from engine.presetState.
  • On cycler click: compute next type in the sequence, update preset state, and force a re-render of the RulesDrawer.
  • In multiplayer: changing the target is an authoritative action that must propagate via a new WS message (see below).

Protocol changes (multiplayer support)

Adding a custom-modifier-config.update style message would over-engineer this. Piggyback on the existing preset-activation rebroadcast by including the target type in a new lightweight message, OR bake it into the existing preset activation request.

Recommended approach: Reuse the modifier-profile.propose / modifier-profile.update mechanism's pattern but for preset state. This is a bigger lift. ALTERNATIVE: defer multiplayer sync of the target to follow-up. For this feature, support is local-only (solo play) and multiplayer games use whatever target was set at room-creation time.

DECISION: ship solo-only target cycling in v1. If user demand follows, add a preset-config.update WS message in v2. Document the solo-only limitation in-UI ("Target: Pawns (set before multiplayer game starts)" hint).

Tests

Unit: none needed — the preset logic is already covered.

Component/integration: a RulesDrawer test verifying the cycler renders when extinction-chess is active, cycles through all 6 types, and updates the engine's preset state.

E2E (Playwright): solo scenario — activate extinction-chess, click cycler 3 times, verify displayed target is now "rook" (or whatever 3 clicks from pawn yields).

Execution checklist

  • F2.1. Add extinctionTarget prop + onExtinctionTargetCycle callback to RulesDrawer props.
  • F2.2. Render cycler inside extinction-chess card when the preset is active.
  • F2.3. GameView manages the state and threads props to RulesDrawer.
  • F2.4. E2E test in rule-variants.spec.ts (additive).
  • F2.5. Documentation: add a "Target cycling" note to extinction-chess.ts docblock referencing the UI.
  • F2.6. Commit: feat(ui): extinction-chess target cycler in rules drawer

Risk register

  • Multiplayer sync gap: shipped solo-only. Room-creation-time target is fixed for the session. Acceptable because changing extinction targets mid-game is unusual.
  • Chip state-sync bug: engine preset state is the source of truth. RulesDrawer reads on every render; no local component state.

Feature 3 — Berolina En-Passant

Current state

berolina-pawns + berolina-pawns-2 (shipped commits 5393b96, ee08e20) explicitly defer en-passant. Current behavior: no en-passant captures exist for Berolina pawns. Pawns that double-diagonal-push past a square an enemy pawn could have captured it on cannot be ep-captured — the opportunity is lost.

Design

Classic Berolina rule variant: if a Berolina pawn double-pushes via a diagonal, and an enemy Berolina pawn is adjacent on the SKIPPED square's file (the middle square of the diagonal), the enemy can capture orthogonally forward onto the skipped square, removing the double-pushed pawn (standard en-passant semantics reflected through Berolina geometry).

Rule authority: multiple Berolina rule sets exist; we're picking the most common (Parton 1952 variant). Document explicitly in the preset docblock that other variants exist and can be added as additional presets if demand emerges (e.g. berolina-pawns-3 with different ep semantics).

Implementation path

Since berolina-pawns uses overridePieceMoves (not composes with default en-passant), we must synthesize ep moves in the override itself. The engine already tracks EnPassantTarget in the game-level facts; our override can read it and emit the ep move.

packages/chess/src/presets/berolina-pawns.ts:

  1. In overridePieceMoves, after computing push/capture/promotion:
    • Read engine.session.get(GAME_ENTITY, "EnPassantTarget").
    • If non-null and this pawn can capture (orthogonally forward) to the ep square → emit the ep capture move.
  2. Engine's applyMove already updates EnPassantTarget on every move; no engine change needed. BUT — the engine sets it based on FIDE-style pawn double-push (rank+2 orthogonal). For Berolina double-push (diagonal), we need to override this.

Engine-side extension (minor, in-scope): add a hook or expose setEnPassantTarget so the preset can update the target on its own double-push path. OR: handle the ep target update inside the preset's onAfterMove — cleaner since the preset owns the mechanics.

Recommended path: handle both emission AND target-update in the preset:

  • overridePieceMoves emits the ep capture when EnPassantTarget is set.
  • onAfterMove detects when the preset's own pawn just did a double-diagonal-push, and calls engine.session.insert(GAME_ENTITY, "EnPassantTarget", skippedSquare).
  • Add a tag / metadata to the LegalMove (e.g. isEnPassant: true, already a field on LegalMove per existing FIDE en-passant handling) so the engine's capture-resolution path removes the captured pawn.

Edge cases

  • Berolina + FIDE pawn mix: impossible in practice (the preset replaces pawn rules for all pawns of the scoped color(s)). But with scope: "white", black pawns follow FIDE ep rules — the engine's default ep handling covers black. Verify the hybrid case.
  • Promotion + ep: ep capture landing on the 1st/8th rank is theoretically possible but extremely unusual on a fresh board. Keep the ep capture non-promoting; document.
  • berolina-pawns-2 shares the same ep rule: the sideways captures don't change ep geometry.

Tests

Add to berolina-pawns.test.ts:

  • ep capture available after enemy double-diagonal-push (4 positions).
  • ep capture NOT available after single-push (no ep target).
  • ep capture is lost if not taken on the next turn (standard ep semantics).
  • ep captured pawn is correctly removed from the board.
  • Scope interaction: scope: "white" means white gets Berolina ep, black still uses FIDE ep.

Similar additions to berolina-pawns-2.test.ts.

Execution checklist

  • F3.1. Add ep-capture emission to berolina-pawns.ts overridePieceMoves.
  • F3.2. Add onAfterMove handler that sets EnPassantTarget on Berolina double-push.
  • F3.3. Copy/reuse ep logic into berolina-pawns-2.ts.
  • F3.4. Add 5+ ep-specific tests to each preset's test file.
  • F3.5. Update the preset docblock: remove the "deferred" language, document the authoritative rule choice.
  • F3.6. Update RULES.md — remove the en-passant deferred note from the Berolina gallery entries.
  • F3.7. Commit: feat(presets): berolina-pawns en-passant (both variants)

Risk register

  • Ambiguity of Berolina ep: 2-3 published variants exist. We pick the most common (Parton). Document authoritatively; other variants are their own presets.
  • EnPassantTarget collision with FIDE ep: on a scope-flipped activation, both sides might set the target — but only one side can double-push per turn. No real collision.

Feature 4 — Tier 3 Royalty-Transfer

Current state

Tier 3 presets from the rule-variants design doc (explicitly deferred in commit 3cef8f5 closing the epic). The canonical example: Abdication / Knight-Queen Transfer — at any time, the king can "abdicate" to a chosen friendly piece, transferring royalty to that piece (and possibly vice versa).

Why it was deferred

  • Requires a mid-game player action beyond "one move at a time". The existing engine only processes LegalMove; there's no "action" channel for non-move actions.
  • Requires UI for target selection — click a piece → "transfer royalty here".
  • Multiplayer semantics: a royalty transfer is a turn-consuming action? Or free? How do both clients agree?

Design

Introduce a new category: player actions that are not LegalMove but consume a turn (or not, configurable).

Engine surface

Add a new public method engine.performAction(action: PlayerAction): ActionResult. PlayerAction is a tagged union:

type PlayerAction =
  | { kind: "transferRoyalty"; fromPieceId: EntityId; toPieceId: EntityId };

Add a new preset hook performAction(ctx: PlayerActionContext): ActionResult | undefined. First non-undefined wins. Context includes action, mover, engine.

Engine dispatch: similar to applyMove but for actions. Fires onBeforeAction → resolver → onAfterAction → turn advance decision → onTurnStart / onCheckGameResult.

Turn advance semantics: same shouldAdvanceTurn hook. Presets can veto as they do for moves.

Preset: transferable-royalty

PRESET_REGISTRY.register({
  id: "transferable-royalty",
  name: "Transferable Royalty",
  description: "Royal pieces can transfer their royalty to another friendly piece once per game.",
  // ...
  performAction({ engine, mover, action }) {
    if (action.kind !== "transferRoyalty") return undefined;
    // Validate: fromPiece is currently royal, toPiece is friendly + not already royal.
    // Mark state: `transferredFrom[mover] = fromPieceId`; future `getRoyalPieces`
    // returns `toPieceId` instead of `fromPieceId`.
    return { consumed: true, turnAdvances: true };
  },

  getRoyalPieces({ engine, color }) {
    // Read preset state — if a transfer has occurred for this color,
    // return the new royal set (original royals minus transferred-from,
    // plus transferred-to).
  },
});

UI

  • Trigger: right-click on a friendly piece → context menu with "Transfer royalty to here" (requires a royal piece already selected OR a 2-click flow).
  • Indicator: selected royal → hover other friendlies → candidate targets highlighted in a distinct color (e.g. gold outline).
  • Confirmation: modal "Transfer royalty from King (e1) to Queen (d1)?" — prevents accidental triggers.
  • Turn consumed: the action ticks the turn; show in the moveLog as a "royalty-transfer" event (new MoveRecord tag).

Protocol (multiplayer)

Add a new WS message: game.action with payload { token, action: PlayerAction }. Server validates and broadcasts the resulting state via the existing state-update path.

Prerequisites

This is a bigger lift than the first three features. Before starting:

  1. Confirm stakeholder demand — is there a real user need for this?
  2. Decide turn-consumption semantics (a free action would be wildly unbalanced).
  3. Nail the UI interaction (2-click vs context menu vs modal).

Execution checklist (draft — to be refined)

  • F4.1. Engine surface: PlayerAction type + performAction method.
  • F4.2. Preset hook: performAction.
  • F4.3. MoveRecord extended with actionKind? field for logging.
  • F4.4. transferable-royalty preset implementation + tests.
  • F4.5. Protocol: game.action WS message (server + client parity).
  • F4.6. UI: target selection + confirmation modal in GameView.
  • F4.7. E2E: solo scenario + multiplayer scenario.
  • F4.8. Docs: PRESET-API.md section on actions-not-moves; RULES.md gallery entry.
  • F4.9. Commit: feat(engine): player actions + transferable-royalty preset

Risk register

  • Engine surface expansion: significant. Every applyMove-adjacent assumption (move-generates-next-turn, move-log, checkGameResult timing) needs to be re-audited for the action path. Mitigation: keep the performAction path structurally parallel to applyMove; reuse the same hook dispatch order.
  • UI interaction ambiguity: "right-click is not discoverable". Alternative: a dedicated "Actions" button in GameView that opens a menu. Safer default for touch devices too.
  • Multiplayer sync: action takes effect server-side first; client receives the state update. Same pattern as moves — no new consistency issues.
  • State lifecycle: the "transfer once per game" constraint lives in preset state. Ensure it's serialized in GameStatePayload (same as custom modifiers — pattern from T3 Q4.1).

Parallel Execution Map

Feature 1 (MP color choice) — 9 tasks — 1 session
  F1.1-F1.8 serial (each builds on previous)  →  F1.9 commit

Feature 2 (Extinction-chess UI) — 6 tasks — ½ session
  F2.1-F2.5 serial                             →  F2.6 commit

Feature 3 (Berolina ep) — 7 tasks — ½ session
  F3.1-F3.6 serial                             →  F3.7 commit

Feature 4 (Royalty-transfer) — 9 tasks — 1-2 sessions
  Needs stakeholder alignment FIRST.

Recommended sequencing: 1 → 2 → 3 → 4. All decisions locked as of 2026-04-21 — Feature 4 is cleared to execute after 1-3 ship.

Features 1-3 are INDEPENDENT and can be done in any order. Feature 4 should come last because its engine-surface expansion is the largest and is least likely to conflict with the others if deferred.


Verification Gate (applies to every feature)

Pattern mirrors the rule-variants epic's Final Verification Wave:

  • F1 (oracle): plan compliance — every declared task checked.
  • F2 (unspecified-high): code quality — 0 anti-patterns, tests ≥ 8 per new preset, incompatibleWith graph symmetric.
  • F3 (manual QA + playwright): end-to-end smoke.
  • F4 (deep): scope fidelity — no drive-by refactors, no unrelated protocol changes.

Scale the wave down for Feature 2 (no new preset; just UI) and Feature 3 (no new preset; just preset extension): F2 + F3 reviewers only, F1 + F4 skipped.


Glossary

  • PlayerAction: a turn-consuming event that is NOT a LegalMove. Introduced in Feature 4 (deferred).
  • Royalty transfer: a PlayerAction that reassigns the "royal" flag between friendly pieces. Core of Feature 4.
  • En-passant target: GAME_ENTITY.EnPassantTarget fact tracking the square a pawn skipped on its most recent double-push; valid for exactly one following half-move. Feature 3 extends this to Berolina geometry.
  • Target cycling: the UI pattern Feature 2 introduces for preset-state configuration without requiring a dedicated editor.

Decision log (pre-implementation)

All 7 decisions LOCKED 2026-04-21. Below is the authoritative answer for each — planners / executors use these unless a separate signed-off change supersedes.

Feature Decision LOCKED
1 Joiner-side color choice? NO — creator picks, joiner takes the remaining color. Eliminates "both want white" deadlock class.
1 Re-pick colors mid-session? NO — fixed at room creation. Swap-colors rematch is a separate future feature.
2 Multiplayer target-sync in v1? NO — solo-only. Multiplayer target is whatever was set when the preset was activated (usually room-creation time). v2 can add a preset-config.update WS message if demand follows.
3 Which Berolina ep variant? Parton 1952 — standard ep semantics reflected through Berolina's diagonal-push / orthogonal-capture geometry. Other variants ship as separate presets (berolina-pawns-3 etc.) if demand emerges.
4 Turn-consumption of royalty transfer? YES — consumes a turn (balance). Prevents the "abdicate out of mate" degenerate case. Reflected in performAction returning turnAdvances: true.
4 UI for target selection? Dedicated "Actions" button in GameView opens a menu (Transfer royalty…). Better discoverability than right-click; works on touch devices.
4 Stakeholder validated? YES — demand confirmed 2026-04-21. Cleared to execute.

Early-stop / scope-reduction triggers

If tool budget or context pressure intrudes:

  • After Feature 1: ship MP color choice alone. Highest user value. Features 2-4 become their own follow-ups.
  • After Feature 1 + 2: ship both polish items. Deferred gallery note updates land later.
  • After Feature 1 + 2 + 3: feature-complete short of Tier 3. Royalty-transfer becomes its own epic with its own plan.

In every case: update this plan's checkboxes, append a ## Early Stop section, and do NOT declare the full plan complete.


Momus review instructions

When this plan is saved, invoke Momus via:

task(subagent_type="momus", prompt=".sisyphus/plans/post-epic-deferrals.md")

Momus will evaluate against rigorous clarity / verifiability / completeness standards. Incorporate feedback before starting any feature.