houserules/.sisyphus/plans/preset-flexibility-architecture.md
Joey Yakimowich-Payne cc30545ced
feat(chess): preset-flexibility architecture — decouple HP, damage, piece-types, state, effects
Decouples five cross-cutting concerns from engine core so new presets
compose without special-casing:

- Piece attributes: CORE_PIECE_ATTRS + PresetDef.pieceAttributes;
  engine.effectivePieceAttrs unions them. HP is now preset-owned.
- Spawn pipeline: engine.spawnPiece() + onPieceSpawn hook replaces
  hand-rolled materialization. Queen-splits seeds HP via the hook,
  not via direct knowledge of piece-hp.
- Hook signatures: all hooks now take single context objects
  (LifecycleContext, MoveHookContext, CaptureHookContext, DamageHookContext,
  SelfCheckFilterContext, GameResultHookContext, PieceSpawnContext,
  BeforeMoveContext, TurnStartContext, DescribeMoveEffectContext).
- Piece-type registry: PIECE_TYPE_REGISTRY + core-piece-types.ts
  replaces hardcoded FIDE switch-tables in engine/check/checkmate/
  stalemate/Piece.tsx. Custom piece types plug in without engine edits.
- Preset-scoped state: engine.presetState<T>(id) backed by facts on
  PRESET_STATE_ENTITY. Auto-cleared on deactivate. capture-to-win
  migrated off GAME_ENTITY.Winner.
- Move log: engine.moveLog + MoveRecord + describeMoveEffect hook.
- Visual effects: engine.emitEffect/subscribeEffects + VisualEffect +
  VisualEffectLayer. Explosion, heal, poison renderers. No-op when
  no subscribers.
- Phase hooks: onBeforeMove (with cancel), onTurnStart. Scope-aware
  dispatch routes onDamage/onBeforeMove/onTurnStart by target/mover
  color.

All 5 production presets migrated. 4 prototype presets (Shield, Cannon,
Berserker, PawnStamina) in integration.test.ts exercise every hook.
Added test-utils.ts and PRESET-API.md for preset authors.

930 tests passing; bun run check clean.
2026-04-18 16:26:17 -06:00

32 KiB
Raw Permalink Blame History

Preset Flexibility — Architecture Pass

TL;DR

Nine architectural improvements plus two phase hooks that unblock the next generation of chess-variant presets without a single engine touch per preset.

Core problems today:

  1. PIECE_ATTRS is hardcoded — new attribute-carrying presets (Shield, Armor, Stamina) leave zombie facts on death.
  2. No piece-spawn primitive — queen-splits has a latent HP-coupling bug waiting for a mid-game activation order to trigger.
  3. Piece types are a closed union — no Cannon, Amazon, Nightrider without editing core.
  4. No visual effect bus — explosions/heals/poison ticks are silent.
  5. No move log — history panels, PGN export, analysis tools all blocked.
  6. Per-preset state is ad-hoc (global modules or game-entity facts); breaks concurrent games and save/load.
  7. Hook signatures are positional — growing them breaks every implementer.
  8. onAfterMove, onDamage, onCheckGameResult ignore scope; presets re-derive it.
  9. Test harness pattern duplicated across every preset test file.

Deliverable: after this work, a preset can add a new piece type, a new piece attribute, new damage kinds, new visual effects, and new per-game state — all without changing a single line of engine/core code. Every existing preset continues to behave identically in isolation; new compositions (HP + any new damage source) work automatically.

Phases: 4 phases, 23 tasks, hard-gated by bun run check green + behavioral tests green between each.


Context

What Shipped Last

  • Damage pipeline landed. engine.dealDamage(target, amount, ctx) is the canonical damage primitive; piece-hp.onDamage is the sole damage→death arbiter.
  • explosive-rook + piece-hp now compose naturally (blast deals 1 HP each; pieces with HP>1 survive). The previous hard incompatibility was removed.
  • queen-splits and poisoned-squares route captures/ticks through the pipeline.
  • 889 unit tests pass, including 12 new damage-pipeline tests.

What Still Couples Presets to Core

Audited the live code for every preset-visible engine surface. The remaining friction points:

  • packages/chess/src/rules/capture.ts:10 — PIECE_ATTRS is a frozen 5-element tuple. Adding an attribute requires editing this file.
  • packages/chess/src/presets/piece-hp.ts:77 — const PIECE_ATTRS = [...] duplicates the list. Another edit point.
  • packages/chess/src/presets/queen-splits.ts:60 + :84-97 — owns a PIECE_ATTRS copy plus a private spawnPiece helper. Spawned pieces aren't passed through any hook — if piece-hp activates AFTER queen-splits spawns pieces, those pieces have no Hp.
  • packages/chess/src/schema.ts:PieceType — fixed union of 6 types. Hardcoded in PIECE_MOVE_GETTERS (engine.ts:61), ATTACK_PROBES (check.ts:68), MOVE_GETTERS (checkmate.ts:44), stalemate.ts, plus UI asset maps. Five synchronized tables.
  • packages/chess/src/engine.ts:applyMove — emits nothing. Clients reconstruct history from fact diffs. No per-move summary, no notation, no event stream.
  • packages/chess/src/presets/capture-to-win.ts:46 + others — use GAME_ENTITY facts as ad-hoc state storage. Mixed with real game-level facts (Turn, EnPassantTarget, HalfmoveClock). Collision surface grows with each preset.
  • packages/chess/src/presets/registry.ts — every hook is (engine, ...positional args) => Result | void. Every signature change is a breaking change to every preset.
  • packages/chess/src/presets/*.test.ts — pieceAt, hpOf, typeOf, clearRank are copy-pasted across at least 4 files.

Out of Scope

  • Any new presets (piece types, rules). This plan is pure infrastructure.
  • Save-file forward compatibility for saved games that predate this refactor. Autosave is dev-only; we can ship a one-line version bump + clear stale saves.
  • Server-protocol version bump beyond what structured move emissions require. We keep protocol v1 and add new optional fields.
  • UI redesigns beyond the minimum needed to render new visual effects.

Work Objectives

Core Objective

Decouple every preset-reachable engine surface so the PresetDef interface + registry is the ONLY thing a new preset touches.

Concrete Deliverables

  1. Dynamic piece-attribute registry — presets declare their attrs; retract / damage / save paths consult the union.
  2. spawnPiece primitive + onPieceSpawn hook — single entry point for creating pieces; every active preset gets a chance to tag new entities.
  3. Custom piece-type registry — presets register (id, moveGenerator, attackProbe, assets); engine + UI look them up dynamically.
  4. Per-preset state bag — engine.presetState<T>(id) with typed, engine-scoped, serialization-included state.
  5. Visual effect bus — engine.emitEffect({...}) + React subscriber; explosions/heals/poison ticks animate.
  6. Structured move log — applyMove populates MoveRecord; engine.moveLog is the authoritative history stream.
  7. Hook-context objects — all hooks take a single growable context object; future fields are additive.
  8. Scope-aware hook dispatch — engine skips hooks whose scope doesn't cover the current color.
  9. Test harness utilities — shared test-utils.ts for engineWithPosition, placePiece, clearBoard, etc.
  10. Phase hooks — onBeforeMove (pre-validation, pre-mutation) and onTurnStart (after turn flip, before legal moves are regenerated) for cooldown/regen/resource-tick presets.
  11. Tests — each new API gets unit tests; one integration test verifies a hypothetical "Shield" preset can be written from the registry alone without engine edits.

Definition of Done

  • bun run check green (typecheck + lint + 900+ tests).
  • Every existing preset still works with no behavioral change in isolation (regression).
  • The three prototype presets built in test code to exercise the new surfaces (Cannon piece type, Shield attribute, DamageCooldown state) all work.
  • No references to literal ["PieceType", "Color", "Position", "HasMoved", "Hp"] outside engine.ts / registry.ts / schema.ts.
  • New packages/chess/docs/PRESET-API.md documenting the complete extension surface.

Must Have

  • All 9 items from the architecture review + 2 phase hooks.
  • Backward-compatible hook signatures via the context-object refactor (old positional signatures accepted as a deprecation layer, removed in a single sweep after all in-tree callers migrate).
  • Zero behavior regressions for existing presets (piece-hp, explosive-rook, queen-splits, king-heals, poisoned-squares, capture-to-win, last-piece-standing, all geometry presets).

Must NOT Have (Guardrails)

  • No event-bus abstraction for cross-preset communication. Presets talk to the ENGINE, not each other. If two presets need to coordinate, it's a design smell requiring review.
  • No topological sort / priority system for preset ordering. Registration order + first-consumer-wins is sufficient. If ordering matters for a future pair, that's a design conversation, not a generic engine feature.
  • No preset-defined alternative move validators. Move generation stays in rules/; presets contribute extra moves or filter existing moves via getExtraMoves / filterMoves, not replace the validator.
  • No runtime piece-type registration through the UI / save files. Piece types are dev-authored TS modules that register at module-load time. User-uploaded code remains forbidden (RCE vector).
  • No breaking changes to the WebSocket protocol shape. New optional fields only.

Verification Strategy

Test Decision

TDD for each engine API surface. Unit tests written alongside (or before) each primitive. Integration-style tests for composite behaviors (e.g., "HP + custom-type Cannon composes correctly").

Regression Net

The existing 889 tests cover every shipped preset. They stay green through the entire refactor. bun run check runs at the end of every phase.

Integration scenarios (run in the final verification wave)

  1. Shield attribute, from nothing: Write a piece-shield preset using only public APIs — declares pieceAttributes: ["Shield"], seeds on onActivate, absorbs damage via onDamage with higher priority than HP. Confirm retraction cleans up Shield facts (dynamic PIECE_ATTRS).
  2. Cannon piece type: Write a preset that spawns a Cannon on d1 (Chinese-chess semantics — moves like a rook, captures only by jumping one piece). Register the piece type via the new API. Confirm UI renders it; move generation works; it's a legal target for checks; it saves/loads.
  3. Per-preset state survival: Write a berserker-cooldown preset that uses engine.presetState to track last-rage-turn per piece. Save → restore → state is preserved. Activate → deactivate → state is cleared.
  4. Visual effect smoke test: Playwright test — enable explosive-rook, trigger a capture, assert a DOM element with data-effect="explosion" appears briefly on the target square.
  5. Phase hook cooldown preset: Write a pawn-stamina preset that uses onTurnStart to regenerate a Stamina attribute up to 2. Consuming it via onBeforeMove when pawns push. Proves the new phase hooks fire at the right times.

QA Policy

  • After every task: run focused tests for the touched file(s).
  • After every phase: bun run check must be clean.
  • After the full plan: Playwright smoke (packages/chess/e2e/*.spec.ts --workers=1), verify the 3 existing E2E specs + any new visual-effect one.

Execution Strategy

Phase Structure

Four phases, strictly sequential. Within each phase, tasks with no mutual dependency can run in parallel.

  • Phase A — Foundations: dynamic attributes, spawn primitive, context objects. Touches engine.ts + registry.ts + per-preset cleanup. Everything else in this plan builds on this.
  • Phase B — State & Events: per-preset state bag, move log, visual effect bus. These add emission points to applyMove; need A's context objects to be clean.
  • Phase C — Extension Surfaces: custom piece types, scope-aware dispatch, phase hooks. New hooks declared in PresetDef; engine dispatches them; prototype presets in tests verify they fire.
  • Phase D — Developer Experience: test harness, docs, final integration scenarios.

Parallel Execution Waves

Phase A (serial foundation, then parallel cleanup):

Wave A.1 (sequential — core APIs):
  A.1 PIECE_ATTRS → dynamic registry (engine + capture.ts)
  A.2 engine.spawnPiece + onPieceSpawn hook
  A.3 Hook context objects (HookContext, MoveContext, DamageContext renames)

Wave A.2 (parallel — cleanup existing presets using A.1-A.3):
  A.4 piece-hp: migrate to declared attrs + context args
  A.5 queen-splits: migrate to engine.spawnPiece, drop PIECE_ATTRS copy
  A.6 explosive-rook: drop retractEntity helper, use dynamic PIECE_ATTRS

GATE: bun run check; all 889 existing tests green.

Phase B (parallel — state / events / log):

Wave B.1 (parallel):
  B.1 engine.presetState<T>() + session-backed storage
  B.2 MoveRecord + engine.moveLog + applyMove emission
  B.3 engine.emitEffect() + EffectStream subscriber API
  B.4 UI: VisualEffectLayer reads the stream, renders with Motion

GATE: bun run check; 3 new unit test suites (state, log, effects) pass.

Phase C (parallel except C.1 which gates C.2-C.3):

Wave C.1 (sequential foundation):
  C.1 PieceTypeRegistry (engine + UI asset lookup)

Wave C.2 (parallel, depends on C.1):
  C.2 registerPieceType API + engine move/attack tables become dynamic
  C.3 Scope-aware hook dispatch (onAfterMove/onDamage/onCheckGameResult/new phase hooks)
  C.4 onBeforeMove + onTurnStart phase hooks
  C.5 Server-protocol: piece-type registry echoed on game.state

GATE: bun run check; Cannon prototype preset works end-to-end.

Phase D:

Wave D.1 (parallel):
  D.1 packages/chess/src/presets/test-utils.ts + migration of existing tests
  D.2 packages/chess/docs/PRESET-API.md (complete extension surface)
  D.3 Integration scenarios (Shield / Cannon / Berserker / Stamina prototypes in test code)

FINAL GATE: bun run check + Playwright E2E + all integration scenarios green.

TODOs

Phase A — Foundations

A.1 — Dynamic piece-attribute registry

Where: packages/chess/src/rules/capture.ts, packages/chess/src/engine.ts, packages/chess/src/presets/registry.ts Why: Preset-declared attributes (Shield, Stamina, Armor) must be retracted on death / serialized on save. Hardcoded list = silent bug. How:

  1. Rename existing PIECE_ATTRS to CORE_PIECE_ATTRS in capture.ts; keep it as the FIDE minimum.
  2. In registry.ts add readonly pieceAttributes?: readonly AttrKey[] to PresetDef.
  3. In engine.ts add a private computed getter get effectivePieceAttrs(): readonly AttrKey[] that returns CORE_PIECE_ATTRS ∪ union(active preset pieceAttributes).
  4. Replace every in-tree reference to literal ["PieceType", "Color", "Position", "HasMoved", "Hp"] with engine.effectivePieceAttrs (or CORE_PIECE_ATTRS for pure-function contexts).
  5. dealDamage's default retract path uses the dynamic list. Same for piece-hp's lethal retract path. Same for queen-splits' queen retract.
  6. Hp attribute moves OFF CORE_PIECE_ATTRS — it's owned by piece-hp via pieceAttributes: ["Hp"].

Expected result: PIECE_ATTRS no longer appears as a literal in any preset. Adding a new attribute-carrying preset requires only a pieceAttributes declaration. Existing HP tests continue to pass — Hp is cleaned up when pieces die because piece-hp contributes it to the dynamic list.

Tests: Unit — a test preset that declares pieceAttributes: ["TestMark"], seeds it on onActivate, then gets retracted on piece death. Verify no TestMark fact remains.


A.2 — engine.spawnPiece() primitive + onPieceSpawn hook

Where: packages/chess/src/engine.ts, packages/chess/src/presets/registry.ts, packages/chess/src/presets/queen-splits.ts Why: queen-splits has a latent bug — pieces it spawns don't get Hp seeded if HP was activated after queen-splits started having reason to spawn. Plus future presets (promotion variants, summoning, phoenix) need the same thing. How:

  1. Add to PresetDef: readonly onPieceSpawn?: (ctx: PieceSpawnContext) => void;. PieceSpawnContext = { engine, pieceId, type, color, square, reason: "promotion" | "fission" | "summon" | (string & {}) }.
  2. Add engine.spawnPiece(type, color, square, opts?: { reason?: string; hasMoved?: boolean }): EntityId. Creates entity, inserts CORE attrs, then iterates activePresets.list() calling each onPieceSpawn. Returns the new id.
  3. piece-hp implements onPieceSpawn → engine.session.insert(id, "Hp", DEFAULT_HP).
  4. queen-splits deletes its private spawnPiece helper, calls engine.spawnPiece("rook", color, square, { reason: "fission" }) twice.
  5. (Future) promotion rule in rules/promotion.ts migrates to spawnPiece — tracked as stretch for this task, do only if trivial.

Expected result: Activating queen-splits in any order with piece-hp produces fission-spawned pieces with Hp = 2 as expected. No more "idempotent onActivate" duct tape.

Tests: Unit — activate queen-splits, then piece-hp, then trigger fission. Verify spawned rook + bishop both have Hp = 2. Activate in reverse order. Verify same.


A.3 — Hook context objects

Where: packages/chess/src/presets/registry.ts, packages/chess/src/engine.ts, all packages/chess/src/presets/*.ts Why: Positional signatures break every preset when we add a field. Context objects are growable. How:

  1. Define per-hook context types in registry.ts:
    interface HookContext { readonly engine: ChessEngine; }
    interface MoveHookContext extends HookContext { readonly moverColor: PieceColor; readonly move: MoveRecord; /* added in B.2 */ }
    interface DamageHookContext extends HookContext {
      readonly target: EntityId;
      readonly amount: number;
      readonly kind: string;
      readonly attacker?: EntityId;
    }
    interface PieceSpawnContext extends HookContext {
      readonly pieceId: EntityId;
      readonly type: PieceType;
      readonly color: PieceColor;
      readonly square: Square;
      readonly reason: string;
    }
    // etc for every hook
    
  2. Migrate every PresetDef hook signature to take a single context object.
  3. Migrate every preset implementation. Engine dispatchers construct context objects and pass them.
  4. Temporarily keep positional form for onDamage and onBeforeCapture accepted via an adapter, emit a console.warn in dev-mode. Remove the adapter in the same PR once all callers migrate (since this is all in-tree).

Expected result: Future hook additions are purely additive — MoveHookContext gains a lastCaptureSquare?: Square field, zero presets break.

Tests: Migrate existing hook tests to new signature; add a single assertion that unknown context fields are silently ignored by presets that don't read them (typechecker covers this).


A.4 — Migrate piece-hp to declared attrs + context args

Depends on A.1, A.2, A.3. Why: Prove the new pattern works on the most-used preset first. How:

  • Add pieceAttributes: ["Hp"] to the PresetDef.
  • Drop the in-file PIECE_ATTRS duplicate.
  • Hook signatures converted to context args.
  • onPieceSpawn implementation added (seeds Hp=2 on new entities).
  • onActivate becomes slightly simpler (no need to iterate and check; pieces spawned after activation auto-get Hp via onPieceSpawn).

Tests: Existing piece-hp tests + fleshed-presets tests stay green.


A.5 — Migrate queen-splits to engine.spawnPiece

Depends on A.2. Why: Removes queen-splits's private spawn helper; removes the dead // ... relies on piece-hp onActivate idempotence comment. How: Replace the two spawnPiece(session, ...) calls with engine.spawnPiece(...). Delete the private helper. Drop the PIECE_ATTRS constant. Tests: Existing queen-splits tests stay green. Add one explicit test for the "HP activated after queen-splits mid-game" scenario.


A.6 — Migrate explosive-rook, poisoned-squares to dynamic attrs

Depends on A.1. Why: These carry their own retraction helpers. How: Replace local PIECE_ATTRS with engine.effectivePieceAttrs where they retract directly (explosive-rook's fallback path if it's not using dealDamage for some future reason; poisoned-squares already uses dealDamage so may need zero edits — audit). Keep behaviour identical. Tests: Existing tests stay green.

GATE A: bun run check green. Every existing test passes.


Phase B — State & Events

B.1 — engine.presetState<T>(id)

Where: packages/chess/src/engine.ts, packages/chess/src/schema.ts Why: Per-game, per-preset state with zero collision, included in save-state. How:

  1. Define PRESET_STATE_ENTITY symbol in schema.ts (new entity id, below GAME_ENTITY).
  2. engine.presetState<T>(presetId: string): T returns a typed Proxy backed by session facts of the form (PRESET_STATE_ENTITY, ${presetId}::${key}, value).
  3. get reads the fact; set inserts/retracts; delete retracts.
  4. On preset deactivate, engine retracts every fact with attr matching ${presetId}::*.
  5. Fact serialization already covers these — save/load works automatically.

Tests:

  • Write + read + save + load round-trip.
  • Activate → write state → deactivate → state cleared → reactivate → empty.
  • Two concurrent engines with the same preset ID have independent state (server concurrency).

Expected result: Migrate capture-to-win's Winner fact from GAME_ENTITY to its preset-state bag as a proof point.


B.2 — MoveRecord + engine.moveLog + applyMove emission

Where: packages/chess/src/engine.ts, packages/chess/src/presets/registry.ts (new hook describeMoveEffect) Why: History UI, PGN export, replay — all need a per-move summary. Currently requires diffing facts. How:

  1. Add MoveRecord interface in engine.ts: { from, to, moverColor, movingType, capturedId, capturedType, isCheck, isCheckmate, isEnPassant, isCastling, promotion, presetEffects, timestamp }.
  2. Private this.#moveLog: MoveRecord[] = []; public getter.
  3. Populate inside applyMove at the very end, before return.
  4. Optional hook on PresetDef: describeMoveEffect?(ctx): { presetId: string; summary: string } | void. Engine iterates, collects non-void returns into presetEffects.
  5. Update MoveHookContext (from A.3) to carry the MoveRecord being built (or the finalized one, depending on hook timing).

Tests:

  • Move sequence produces correct log entries.
  • Capture is logged with capturedType.
  • Checkmate is logged with isCheckmate: true.
  • describeMoveEffect is called and results show up in presetEffects.

B.3 — engine.emitEffect() + effect stream API

Where: packages/chess/src/engine.ts, packages/chess/src/ui/VisualEffectLayer.tsx (new) Why: Visual feedback. Currently explosions, heals, poison ticks happen silently. How:

  1. VisualEffect interface: { kind: string; square: Square | null; ttl: number; data?: Record<string, unknown> }.
  2. engine.emitEffect(effect: VisualEffect) pushes onto an internal ring buffer; notifies subscribers.
  3. engine.subscribeEffects(handler): Unsubscribe.
  4. UI layer mounts a fixed-position overlay inside Board.tsx; subscribes once; renders effects with Motion (fade/scale/slide based on kind).
  5. Presets call it directly: explosive-rook emits { kind: "explosion", square: targetPos, ttl: 600 }; king-heals emits { kind: "heal", square: kingSq, ttl: 400 }; poisoned-squares emits per-tick { kind: "poison", square, ttl: 250 }.

Tests: Unit — emit + subscribe; unit — TTL cleanup; Playwright — trigger explosive-rook capture, assert [data-effect="explosion"] briefly present.


B.4 — UI: VisualEffectLayer integration

Depends on B.3. Where: packages/chess/src/ui/Board.tsx, packages/chess/src/ui/VisualEffectLayer.tsx (new) Why: Ship B.3 as a visible feature. How: Mount layer inside board viewport. Per-kind effect component (ExplosionBurst, HealPulse, PoisonBubble). Use Motion's AnimatePresence for entry/exit; auto-remove after TTL.

Tests: Playwright smoke (see B.3).

GATE B: bun run check green. Visual smoke test green. Capture-to-win's Winner fact has migrated to presetState.


Phase C — Extension Surfaces

C.1 — PieceTypeRegistry (core)

Where: packages/chess/src/presets/piece-type-registry.ts (new), packages/chess/src/schema.ts Why: Foundation for custom piece types. How:

  1. New class PieceTypeRegistry with methods register(def: PieceTypeDef), get(id), getAll(), has(id).
  2. PieceTypeDef { id, displayName, moveGenerator, attackProbe, assets: { whiteSvg, blackSvg } }.
  3. PieceType becomes string (not a fixed union). Runtime validation via registry membership.
  4. Built-in types (pawn/knight/etc) register at module load via a core-piece-types.ts that's imported from the barrel.

Tests: Unit — register new type, iterate, lookup by id.


C.2 — Engine + UI consume PieceTypeRegistry

Depends on C.1. Where: packages/chess/src/engine.ts, packages/chess/src/rules/check.ts, packages/chess/src/rules/checkmate.ts, packages/chess/src/ui/Piece.tsx, packages/chess/src/assets/pieces/index.ts Why: Swap the five hardcoded dispatch tables (PIECE_MOVE_GETTERS, ATTACK_PROBES, MOVE_GETTERS, asset map, stalemate's table) for registry lookups. How:

  1. engine.getLegalMovesForPiece(id) resolves move generator via registry.
  2. isSquareAttacked + isCheckmate + isStalemate do the same.
  3. UI <Piece> looks up SVG assets from registry's assets field.
  4. Starting-position fact generator stays as-is (still uses hardcoded 32-piece FIDE layout).

Tests: Unit — register a Cannon piece type; place a Cannon entity; verify it appears as a legal attacker in check detection; verify the UI renders its asset (Playwright).


C.3 — Scope-aware hook dispatch

Where: packages/chess/src/engine.ts, packages/chess/src/presets/active-set.ts Why: Reduces per-preset scope boilerplate; makes scope semantics consistent. How:

  1. Every hook that has a "for whom" semantics gets a scopeFilter: PieceColor context field (set by the engine based on who's moving / whose piece is taking damage / etc).
  2. ActivePresetSet gains getForColorAndHook(color, hookName) returning only presets whose scope covers color.
  3. onAfterMove fires only for presets scoped to mover color (+ both which is default). Same for onDamage based on target's color.
  4. onCheckGameResult fires for all (no scope — terminal state is global).

Tests: Test preset scoped white; apply a black move; verify preset's onAfterMove did NOT fire. Apply a white move; verify it DID.


C.4 — Phase hooks: onBeforeMove + onTurnStart

Where: packages/chess/src/presets/registry.ts, packages/chess/src/engine.ts Why: Enable cooldown / stamina / resource-regen presets without shoehorning into onAfterMove. How:

  1. onBeforeMove(ctx: BeforeMoveContext): { cancel?: boolean; reason?: string } | void fires after move legality is confirmed but before any state mutation. Returning {cancel: true} aborts the move — engine treats as illegal and calls toast.error with ctx.reason. (This is a strong capability; fencing it with cancel vs a generic consume because we want explicit semantics.)
  2. onTurnStart(ctx: TurnStartContext): void fires after the turn has flipped, before the new side-to-move's legal moves are computed. Good place for "regenerate stamina", "decrement cooldowns", "re-bless pieces on altar squares".
  3. Engine dispatches both at the right points in applyMove.

Tests: Stamina-regen preset in test fixture — set Stamina to 0; run a turn; verify onTurnStart regenerated it to 1; run a pawn push; verify onBeforeMove consumes 1 stamina; if Stamina < cost, preset cancels move.


C.5 — Server-protocol: piece-type registry echoed on game.state

Where: packages/server/src/broadcast.ts, packages/server/src/protocol.ts, packages/chess/src/net/types.ts Why: Clients that join mid-game need the piece-type registry to render a custom type correctly. Currently not an issue (all types hardcoded) but will be as soon as C.2 ships. How:

  1. Add optional pieceTypes?: PieceTypeManifest[] to GameStatePayload.
  2. Server computes from PRESET_REGISTRY at room creation; sends on room.joined + game.state.
  3. Client compares manifest to its local registry; if mismatch, shows a connection error ("server uses unknown piece types").
  4. Protocol version stays v: 1 — new optional field.

Tests: Unit — server sends manifest; client parses; mismatch rejected cleanly.

GATE C: bun run check green. Cannon piece type end-to-end test works (Playwright): create room with cannon preset, place cannon, move it, see it on both clients.


Phase D — Developer Experience

D.1 — packages/chess/src/presets/test-utils.ts

Where: new file. Why: Four preset test files copy-paste the same helpers. How:

  1. Export pieceAt(engine, square), hpOf(engine, id), typeOf(engine, id), exists(engine, id).
  2. Export clearBoard(engine, { preserveKings?: boolean }).
  3. Export placePiece(engine, type, color, square, opts?): EntityId.
  4. Export engineWithPosition(partial: Record<Square, { type, color }>): ChessEngine.
  5. Migrate the 4 existing preset test files to import from here; delete local copies.

Tests: test-utils.test.ts — cover each helper's happy path.


D.2 — packages/chess/docs/PRESET-API.md

Where: new file. Why: The PresetDef interface is now the public API. Needs reference-quality docs. How: Structured reference doc:

  • Overview + philosophy (engine-as-minimal-core, presets-as-composition)
  • PresetDef interface with every field + hook documented
  • Hook firing order (chronology within an applyMove)
  • Context objects — type reference for every one
  • Piece attribute registration
  • Piece type registration
  • Preset state bag usage
  • Effect bus usage
  • Scope semantics
  • Phase hooks with cooldown/regen examples
  • "Your first custom preset" walkthrough — builds the Shield preset from scratch in ~50 lines

Tests: Linked from packages/chess/README.md. Manual proofread.


D.3 — Integration scenarios (prototype presets in tests)

Where: packages/chess/src/presets/integration.test.ts (new). Why: Proves the APIs are sufficient end-to-end. Each scenario builds a plausible preset using ONLY registry-level APIs. How: Four scenarios described in Verification Strategy above, each as its own describe block.

  1. Shield attribute preset.
  2. Cannon piece-type preset.
  3. Berserker cooldown (presetState).
  4. Pawn stamina (phase hooks).

Each prototype preset lives in-file; they're test fixtures, not shipped. They validate that a third-party preset author could accomplish the same.

Tests: The scenarios themselves ARE the tests.

FINAL GATE:

  • bun run check green (target ~920 tests).
  • Playwright E2E green.
  • packages/chess/docs/PRESET-API.md exists and mentions every new API.
  • Zero hardcoded PIECE_ATTRS / PIECE_MOVE_GETTERS / ATTACK_PROBES literals outside engine/registry/schema.

Commit Strategy

One commit per task (23 commits). Each:

  • Body explains root cause + fix + impact.
  • References the task id (e.g., A.2: engine.spawnPiece primitive).
  • Pre-commit hook runs bun run check — must be green.

Phase boundaries get a merge-commit summary with an updated progress checklist in the PR description.

No batch commits. No squashes. Easy bisect.


Success Criteria

Objective

  • A new preset that declares a new piece attribute, a new piece type, uses per-preset state, emits visual effects, and participates in scope-aware hooks can be written entirely within packages/chess/src/presets/ — zero edits to anything under packages/chess/src/engine.ts, packages/chess/src/rules/, or packages/chess/src/ui/ (other than VisualEffectLayer's per-kind component, which is acceptable — new visual styles naturally live with the renderer).
  • All existing presets behave identically in isolation.
  • All existing E2E flows pass.

Verification Commands

# From repo root
bun run check                      # typecheck + lint + unit tests
bun x playwright test packages/chess/e2e/ --workers=1

# Spot-checks
grep -rn '\["PieceType", "Color"' packages/chess/src/ | wc -l  # expect 0 outside engine.ts / registry.ts
grep -rn "const PIECE_MOVE_GETTERS" packages/chess/src/ | wc -l  # expect 1 (engine.ts) or 0 if fully dynamic

Stretch (not required)

  • Migrate capture-to-win.Winner facts from GAME_ENTITY to presetState.
  • Migrate rules/promotion.ts to use engine.spawnPiece.
  • Server broadcasts visual effects on each delta so clients stay in sync.

Risk Log

Risk Likelihood Mitigation
Dynamic PIECE_ATTRS breaks serialization of saved games Medium Autosave is dev-only; bump save-version; clear old on mismatch.
Piece-type runtime string-union relaxation causes implicit any in exhaustiveness checks Medium Add satisfies assertions at registry edges; lint rule for PieceType === "king" comparisons
Effect bus leaks memory (subscribers never unsubscribe) Low subscribeEffects returns Unsubscribe; Board unmounts cleanly; add dev-mode warning on > 20 active subscribers.
onBeforeMove.cancel misused to silently break chess Medium Require reason: string when cancelling; surface via toast; document as "use sparingly".
Protocol changes cause client/server version drift Low New fields are optional; existing clients ignore; piece-type manifest check is advisory only (warn, don't disconnect) in first release.
Context-object migration misses a preset and drops hook Low Typechecker enforces; grep for bare onDamage: in every preset file as final sweep.

Post-Landing Backlog (not in this plan)

  • onPieceRetract hook — symmetric to onPieceSpawn, fires when a piece dies. Needed for "death rattle" presets (e.g., a piece explodes on death). Defer until a concrete preset wants it.
  • Undo/redo for moves — requires MoveRecord (B.2) as prerequisite; currently implemented as full state snapshots, which the MoveRecord stream can replace.
  • Preset-authored UI panels — e.g., a Stamina-bar side panel. Needs a standard extension slot in GameView. Low priority.
  • Server-side preset validation DSL — presets currently live in packages/chess; server imports the same registry. If presets ever become user-authored (disallowed in v1), server needs a validation/sandbox layer.