plan(sisyphus): post-epic deferrals (MP color + extinction UI + berolina ep + royalty transfer)
Saves the planning doc covering all 4 deferred items from the
rule-variants epic close-out. Momus review: [OKAY].
Sequencing:
1. MP color choice — ~1 session, isolated
2. Extinction-chess target UI — ~1/2 session, solo-only v1
3. Berolina en-passant — ~1/2 session, Parton 1952 variant
4. Tier 3 royalty-transfer — 1-2 sessions, REQUIRES stakeholder
alignment before start (new engine
surface for PlayerAction vs
LegalMove).
Each feature is fully specified with protocol / server / UI / test
surface, commit message, and per-feature verification gate. Plan
includes a pre-implementation decision log (7 open questions) and
early-stop triggers so any of F1/F1+2/F1+2+3 is a valid ship
point.
Path: .sisyphus/plans/post-epic-deferrals.md
This commit is contained in:
parent
3cef8f5324
commit
773bf53fab
1 changed files with 665 additions and 0 deletions
665
.sisyphus/plans/post-epic-deferrals.md
Normal file
665
.sisyphus/plans/post-epic-deferrals.md
Normal file
|
|
@ -0,0 +1,665 @@
|
|||
# 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`**:
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```tsx
|
||||
<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:
|
||||
|
||||
```tsx
|
||||
{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:
|
||||
|
||||
```typescript
|
||||
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`
|
||||
|
||||
```typescript
|
||||
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 → (pause for 4 decision) → 4.
|
||||
|
||||
Features 1-3 are INDEPENDENT and can be done in any order. Feature 4
|
||||
requires more thought; don't start until the first three ship and
|
||||
demand is validated.
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
Before starting any feature, confirm:
|
||||
|
||||
| Feature | Decision needed | Default / Recommendation |
|
||||
|---|---|---|
|
||||
| 1 | Joiner-side color choice? | NO — defer. Creator picks, joiner takes what's left. |
|
||||
| 1 | Re-pick colors mid-session? | NO — defer. Fixed at room creation. |
|
||||
| 2 | Multiplayer target-sync in v1? | NO — solo-only. Room-creation-time target fixed for MP. |
|
||||
| 3 | Which Berolina ep variant? | Parton 1952 (most common). Document authoritative choice. |
|
||||
| 4 | Turn-consumption of royalty transfer? | Consumes a turn (balance). |
|
||||
| 4 | UI for target selection? | Dedicated "Actions" button > right-click (discoverability). |
|
||||
| 4 | Stakeholder validated? | **REQUIRED before starting**. Don't implement on spec. |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue