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.
32 KiB
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:
PIECE_ATTRSis hardcoded — new attribute-carrying presets (Shield, Armor, Stamina) leave zombie facts on death.- No piece-spawn primitive —
queen-splitshas a latent HP-coupling bug waiting for a mid-game activation order to trigger. - Piece types are a closed union — no Cannon, Amazon, Nightrider without editing core.
- No visual effect bus — explosions/heals/poison ticks are silent.
- No move log — history panels, PGN export, analysis tools all blocked.
- Per-preset state is ad-hoc (global modules or game-entity facts); breaks concurrent games and save/load.
- Hook signatures are positional — growing them breaks every implementer.
onAfterMove,onDamage,onCheckGameResultignore scope; presets re-derive it.- 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.onDamageis the sole damage→death arbiter. explosive-rook+piece-hpnow compose naturally (blast deals 1 HP each; pieces with HP>1 survive). The previous hard incompatibility was removed.queen-splitsandpoisoned-squaresroute 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_ATTRSis 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 aPIECE_ATTRScopy plus a privatespawnPiecehelper. Spawned pieces aren't passed through any hook — ifpiece-hpactivates AFTER queen-splits spawns pieces, those pieces have noHp.packages/chess/src/schema.ts:PieceType— fixed union of 6 types. Hardcoded inPIECE_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 — useGAME_ENTITYfacts 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,clearRankare 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
- Dynamic piece-attribute registry — presets declare their attrs; retract / damage / save paths consult the union.
spawnPieceprimitive +onPieceSpawnhook — single entry point for creating pieces; every active preset gets a chance to tag new entities.- Custom piece-type registry — presets register
(id, moveGenerator, attackProbe, assets); engine + UI look them up dynamically. - Per-preset state bag —
engine.presetState<T>(id)with typed, engine-scoped, serialization-included state. - Visual effect bus —
engine.emitEffect({...})+ React subscriber; explosions/heals/poison ticks animate. - Structured move log —
applyMovepopulatesMoveRecord;engine.moveLogis the authoritative history stream. - Hook-context objects — all hooks take a single growable context object; future fields are additive.
- Scope-aware hook dispatch — engine skips hooks whose scope doesn't cover the current color.
- Test harness utilities — shared
test-utils.tsforengineWithPosition,placePiece,clearBoard, etc. - Phase hooks —
onBeforeMove(pre-validation, pre-mutation) andonTurnStart(after turn flip, before legal moves are regenerated) for cooldown/regen/resource-tick presets. - 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 checkgreen (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"]outsideengine.ts/registry.ts/schema.ts. - New
packages/chess/docs/PRESET-API.mddocumenting 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 viagetExtraMoves/filterMoves, not replace the validator. - No runtime piece-type registration through the UI / save files. Piece types are dev-authored TS modules that
registerat 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)
- Shield attribute, from nothing: Write a
piece-shieldpreset using only public APIs — declarespieceAttributes: ["Shield"], seeds ononActivate, absorbs damage viaonDamagewith higher priority than HP. Confirm retraction cleans upShieldfacts (dynamicPIECE_ATTRS). - 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.
- Per-preset state survival: Write a
berserker-cooldownpreset that usesengine.presetStateto track last-rage-turn per piece. Save → restore → state is preserved. Activate → deactivate → state is cleared. - 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. - Phase hook cooldown preset: Write a
pawn-staminapreset that usesonTurnStartto regenerate aStaminaattribute up to 2. Consuming it viaonBeforeMovewhen 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 checkmust 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:
- Rename existing
PIECE_ATTRStoCORE_PIECE_ATTRSincapture.ts; keep it as the FIDE minimum. - In
registry.tsaddreadonly pieceAttributes?: readonly AttrKey[]toPresetDef. - In
engine.tsadd a private computed getterget effectivePieceAttrs(): readonly AttrKey[]that returnsCORE_PIECE_ATTRS ∪ union(active preset pieceAttributes). - Replace every in-tree reference to literal
["PieceType", "Color", "Position", "HasMoved", "Hp"]withengine.effectivePieceAttrs(orCORE_PIECE_ATTRSfor pure-function contexts). dealDamage's default retract path uses the dynamic list. Same for piece-hp's lethal retract path. Same for queen-splits' queen retract.Hpattribute moves OFFCORE_PIECE_ATTRS— it's owned bypiece-hpviapieceAttributes: ["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:
- Add to
PresetDef:readonly onPieceSpawn?: (ctx: PieceSpawnContext) => void;.PieceSpawnContext = { engine, pieceId, type, color, square, reason: "promotion" | "fission" | "summon" | (string & {}) }. - Add
engine.spawnPiece(type, color, square, opts?: { reason?: string; hasMoved?: boolean }): EntityId. Creates entity, inserts CORE attrs, then iteratesactivePresets.list()calling eachonPieceSpawn. Returns the new id. piece-hpimplementsonPieceSpawn→engine.session.insert(id, "Hp", DEFAULT_HP).queen-splitsdeletes its privatespawnPiecehelper, callsengine.spawnPiece("rook", color, square, { reason: "fission" })twice.- (Future) promotion rule in
rules/promotion.tsmigrates tospawnPiece— 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:
- 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 - Migrate every
PresetDefhook signature to take a single context object. - Migrate every preset implementation. Engine dispatchers construct context objects and pass them.
- Temporarily keep positional form for
onDamageandonBeforeCaptureaccepted via an adapter, emit aconsole.warnin 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_ATTRSduplicate. - Hook signatures converted to context args.
onPieceSpawnimplementation added (seeds Hp=2 on new entities).onActivatebecomes slightly simpler (no need to iterate and check; pieces spawned after activation auto-get Hp viaonPieceSpawn).
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:
- Define
PRESET_STATE_ENTITYsymbol inschema.ts(new entity id, belowGAME_ENTITY). engine.presetState<T>(presetId: string): Treturns a typed Proxy backed by session facts of the form(PRESET_STATE_ENTITY,${presetId}::${key}, value).getreads the fact;setinserts/retracts;deleteretracts.- On preset deactivate, engine retracts every fact with attr matching
${presetId}::*. - 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:
- Add
MoveRecordinterface inengine.ts:{ from, to, moverColor, movingType, capturedId, capturedType, isCheck, isCheckmate, isEnPassant, isCastling, promotion, presetEffects, timestamp }. - Private
this.#moveLog: MoveRecord[] = []; public getter. - Populate inside
applyMoveat the very end, before return. - Optional hook on
PresetDef:describeMoveEffect?(ctx): { presetId: string; summary: string } | void. Engine iterates, collects non-void returns intopresetEffects. - 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. describeMoveEffectis called and results show up inpresetEffects.
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:
VisualEffectinterface:{ kind: string; square: Square | null; ttl: number; data?: Record<string, unknown> }.engine.emitEffect(effect: VisualEffect)pushes onto an internal ring buffer; notifies subscribers.engine.subscribeEffects(handler): Unsubscribe.- UI layer mounts a fixed-position overlay inside
Board.tsx; subscribes once; renders effects with Motion (fade/scale/slide based on kind). - Presets call it directly:
explosive-rookemits{ kind: "explosion", square: targetPos, ttl: 600 };king-healsemits{ kind: "heal", square: kingSq, ttl: 400 };poisoned-squaresemits 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:
- New class
PieceTypeRegistrywith methodsregister(def: PieceTypeDef),get(id),getAll(),has(id). PieceTypeDef { id, displayName, moveGenerator, attackProbe, assets: { whiteSvg, blackSvg } }.PieceTypebecomesstring(not a fixed union). Runtime validation via registry membership.- Built-in types (pawn/knight/etc) register at module load via a
core-piece-types.tsthat'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:
engine.getLegalMovesForPiece(id)resolves move generator via registry.isSquareAttacked+isCheckmate+isStalematedo the same.- UI
<Piece>looks up SVG assets from registry'sassetsfield. - 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:
- Every hook that has a "for whom" semantics gets a
scopeFilter: PieceColorcontext field (set by the engine based on who's moving / whose piece is taking damage / etc). ActivePresetSetgainsgetForColorAndHook(color, hookName)returning only presets whose scope coverscolor.onAfterMovefires only for presets scoped to mover color (+bothwhich is default). Same foronDamagebased on target's color.onCheckGameResultfires 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:
onBeforeMove(ctx: BeforeMoveContext): { cancel?: boolean; reason?: string } | voidfires after move legality is confirmed but before any state mutation. Returning{cancel: true}aborts the move — engine treats as illegal and callstoast.errorwithctx.reason. (This is a strong capability; fencing it withcancelvs a genericconsumebecause we want explicit semantics.)onTurnStart(ctx: TurnStartContext): voidfires 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".- 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:
- Add optional
pieceTypes?: PieceTypeManifest[]toGameStatePayload. - Server computes from
PRESET_REGISTRYat room creation; sends onroom.joined+game.state. - Client compares manifest to its local registry; if mismatch, shows a connection error ("server uses unknown piece types").
- 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:
- Export
pieceAt(engine, square),hpOf(engine, id),typeOf(engine, id),exists(engine, id). - Export
clearBoard(engine, { preserveKings?: boolean }). - Export
placePiece(engine, type, color, square, opts?): EntityId. - Export
engineWithPosition(partial: Record<Square, { type, color }>): ChessEngine. - 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)
PresetDefinterface 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.
- Shield attribute preset.
- Cannon piece-type preset.
- Berserker cooldown (presetState).
- 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 checkgreen (target ~920 tests).- Playwright E2E green.
packages/chess/docs/PRESET-API.mdexists and mentions every new API.- Zero hardcoded
PIECE_ATTRS/PIECE_MOVE_GETTERS/ATTACK_PROBESliterals 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 underpackages/chess/src/engine.ts,packages/chess/src/rules/, orpackages/chess/src/ui/(other thanVisualEffectLayer'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.Winnerfacts fromGAME_ENTITYtopresetState. - Migrate
rules/promotion.tsto useengine.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)
onPieceRetracthook — symmetric toonPieceSpawn, 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.