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.
This commit is contained in:
parent
7c4c942938
commit
cc30545ced
32 changed files with 3611 additions and 304 deletions
572
.sisyphus/plans/preset-flexibility-architecture.md
Normal file
572
.sisyphus/plans/preset-flexibility-architecture.md
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
# 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`:
|
||||
```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
|
||||
|
||||
```bash
|
||||
# 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue