diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json index 1d8a804..232b8e5 100644 --- a/.sisyphus/boulder.json +++ b/.sisyphus/boulder.json @@ -32,7 +32,10 @@ "ses_263703df9ffegmVplLbaxmQIes", "ses_262de3483ffe5v8SD8eFNqtdo0", "ses_262de1b3bffexz022qa8FnxRyV", - "ses_262ddfb93ffeMGkZK2rspryFfX" + "ses_262ddfb93ffeMGkZK2rspryFfX", + "ses_262998e6fffeRY6BJVTKB7Jtwb", + "ses_26299a749ffe3jnwQzrfJ9g1Wc", + "ses_261e4ca30ffexBShmvjEhVSHRy" ], "plan_name": "rete-rules-engine", "agent": "atlas" diff --git a/.sisyphus/plans/preset-flexibility-architecture.md b/.sisyphus/plans/preset-flexibility-architecture.md new file mode 100644 index 0000000..b76f33d --- /dev/null +++ b/.sisyphus/plans/preset-flexibility-architecture.md @@ -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(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() + 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(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(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 }`. +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 `` 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): 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. diff --git a/packages/chess/docs/PRESET-API.md b/packages/chess/docs/PRESET-API.md new file mode 100644 index 0000000..fc08fad --- /dev/null +++ b/packages/chess/docs/PRESET-API.md @@ -0,0 +1,426 @@ +# Preset API Reference + +This document is the contract between the chess engine core and preset authors. Every extension point is listed here. If a capability you need isn't in this doc, it either isn't supported or hasn't landed yet. + +**Audience**: developers adding new presets (either in-tree in `packages/chess/src/presets/` or as an external package that imports `@paratype/chess`). + +**Philosophy**: the engine is a FIDE-minimum chess runtime with a compositional plugin surface. New mechanics — HP, custom pieces, visual effects, move history, resource tracking — attach via registries and hooks. Engine core should stay stable; extensions stay additive. + +--- + +## Architecture at a glance + +``` +┌──────────────────┐ declares attrs, registers hooks +│ PresetDef │──────────┐ +└──────────────────┘ │ + ▼ +┌────────────────────────────────────────────────────┐ +│ PRESET_REGISTRY │ +│ • getAll() / get(id) │ +└────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────┐ applyMove / dealDamage / spawnPiece / emitEffect +│ ChessEngine │──┬───────────────────────────────┐ +│ │ │ PIECE_TYPE_REGISTRY │ +│ • session │ │ moveGenerator / attackProbe │ +│ • moveLog │ │ assets │ +│ • activePresets │ └───────────────────────────────┘ +│ • presetState │ +│ • emitEffect │ +└──────────────────┘ +``` + +--- + +## Writing a preset + +Minimal preset — registers itself at module load via a side-effect import. Consumers add an import line in `packages/chess/src/presets/index.ts`. + +```ts +import { PRESET_REGISTRY } from "./registry.js"; + +PRESET_REGISTRY.register({ + id: "my-preset", + name: "My Preset", + description: "What this preset does in one sentence.", + incompatibleWith: [], + requires: [], + // ... hooks below +}); +``` + +### Required fields + +- `id: string` — stable identifier. Used for UI toggles, persistence, `engine.presetState`, hook dispatch order. Cannot be renamed without breaking saved games. +- `name: string` — display name for UI. Can change freely. +- `description: string` — one-line explainer shown in the rules drawer. Include variant-defining details ("captures deal 1 damage instead of killing"). +- `incompatibleWith: string[]` — preset ids that must NOT be active under overlapping scope. Mutual — if A declares B incompatible, B must declare A too. +- `requires: string[]` — preset ids that MUST be active under overlapping scope. Examples: `king-heals` requires `piece-hp`. + +### Optional: declared attributes + +```ts +pieceAttributes: ["Shield"], +``` + +When your preset owns a piece-level attribute (HP, Shield, Stamina, Mana, Armor), declare it here. The engine unions `pieceAttributes` from every active preset into `engine.effectivePieceAttrs`. That list drives: + +- The default damage-retract path (dead pieces lose all effective attrs). +- `queen-splits`'s attacker retraction during fission. +- Save-state serialization (all facts captured, including preset-declared attrs). + +The attr MUST be declared in `ChessAttrMap` (via `declare module "../schema"` augmentation or by editing `schema.ts` directly). Adding a brand-new fact-type requires one schema edit + one preset edit. + +--- + +## Hook reference + +Hooks take a single context object (rather than positional args) so new fields are additive. All hooks are optional — implement only what your preset needs. + +### Lifecycle + +#### `onActivate(ctx: LifecycleContext)` + +Fires when the preset transitions inactive → active. Use for: +- Seeding initial state (e.g. `Hp=2` on every existing piece). +- Mutating the session to reflect the preset's requirements. + +```ts +onActivate({ engine }) { + for (const id of piecesOnBoard(engine)) { + engine.session.insert(id, "Hp", 2); + } +} +``` + +#### `onDeactivate(ctx: LifecycleContext)` + +Symmetric cleanup. Fires when: +- User toggles preset off via the UI. +- Turn-timer expires (`turnsRemaining: 0`). +- Server issues `setPresets` that excludes this preset. + +Retract anything `onActivate` inserted. Preset-state is cleared automatically AFTER this hook returns. + +```ts +onDeactivate({ engine }) { + for (const id of piecesOnBoard(engine)) { + if (engine.session.contains(id, "Hp")) { + engine.session.retract(id, "Hp"); + } + } +} +``` + +### Movement + +#### `getExtraMoves(engine, pieceId): LegalMove[]` + +Contribute additional legal moves for a specific piece. Used by wrap-board, knights-leap-twice, etc. NOT a context-object hook because it's called in a tight loop per piece per legal-move query — positional args are the perf-friendly choice. + +#### `filterMoves(moves, engine, pieceId): LegalMove[]` + +Remove or modify moves from the aggregated list. Used by knight-immunity (filters captures of knights). + +### Damage + +#### `onDamage(ctx: DamageHookContext): DamageHookResult | void` + +The damage pipeline. Called by `engine.dealDamage(target, amount, ctx)`. First preset to return `{ consume: true, died }` wins — further presets are not consulted. + +```ts +onDamage({ engine, target, amount, kind }) { + if (!engine.session.contains(target, "Hp")) return undefined; + const current = engine.session.get(target, "Hp") as number; + const next = current - amount; + if (next > 0) { + engine.session.insert(target, "Hp", next); + return { consume: true, died: false }; + } + // Lethal — retract all effective attrs and report death. + for (const attr of engine.effectivePieceAttrs) { + if (engine.session.contains(target, attr)) engine.session.retract(target, attr); + } + return { consume: true, died: true }; +} +``` + +Scope-aware: only fires for presets whose scope covers the target's color. A `scope=white` preset won't absorb damage on black pieces. + +**Damage kinds** (extend as needed): `"capture"` (default from applyMove), `"explosion"` (explosive-rook), `"poison"` (poisoned-squares). Arbitrary strings allowed — document what YOUR preset emits. + +### Capture + +#### `onBeforeCapture(ctx: CaptureHookContext): CaptureHookResult | void` + +Fires BEFORE the engine resolves a capture. Return `{ consume: true }` to REPLACE the default capture mechanic entirely — queen-splits fission, explosive-rook AoE, capture-to-win winner-recording. The consuming preset owns ALL post-capture behavior, including whether the attacker moves. + +Unconsumed captures fall through to `engine.dealDamage` with `kind: "capture"`. + +### Spawn + +#### `onPieceSpawn(ctx: PieceSpawnContext)` + +Fires when `engine.spawnPiece()` creates a new entity. Every active preset gets a chance to tag the piece (seed Hp, mark Shield, add custom attrs). Runs AFTER core attrs are inserted. + +```ts +onPieceSpawn({ engine, pieceId }) { + engine.session.insert(pieceId, "Hp", DEFAULT_HP); +} +``` + +### Phase hooks + +#### `onBeforeMove(ctx: BeforeMoveContext): BeforeMoveResult | void` + +Fires after move legality confirmed, BEFORE any mutation. Can veto by returning `{ cancel: true, reason: "..." }`. The engine throws `MoveCancelledError` which the caller surfaces via toast. + +**Scope-aware**: only fires for presets whose scope covers the mover. + +Use cases: stamina systems, action-point economies, cooldowns. + +```ts +onBeforeMove({ engine, pieceId }) { + const stamina = engine.session.get(pieceId, "Stamina") as number ?? 0; + if (stamina < 1) return { cancel: true, reason: "Piece too tired" }; + engine.session.insert(pieceId, "Stamina", stamina - 1); +} +``` + +#### `onAfterMove(ctx: MoveHookContext)` + +Fires AFTER all move mutations but BEFORE terminal-state check. Used by king-heals, poisoned-squares. + +Fires for EVERY active preset regardless of scope — the preset decides based on `ctx.mover`. (This asymmetry is historical — changing it would break existing presets. See Design Notes.) + +#### `onTurnStart(ctx: TurnStartContext)` + +Fires AFTER turn counter flips and AFTER `onAfterMove`, BEFORE legal-move computation for the new side. Ideal for: +- Regenerating per-piece resources (+1 Stamina up to max). +- Decrementing cooldowns. +- Refreshing auras / blessings. + +**Scope-aware**: only fires for presets whose scope covers the new side-to-move. + +### Terminal state + +#### `onCheckGameResult(ctx: GameResultHookContext): GameResult | undefined` + +Override the engine's default checkmate/stalemate/draw logic. First preset to return a non-undefined value wins. Used by: +- `capture-to-win` — first capture ends the game. +- `last-piece-standing` — annihilation replaces checkmate. +- `piece-hp` — suppresses default terminal check when kings can survive attacks. + +### Self-check filter + +#### `shouldFilterSelfCheck(ctx: SelfCheckFilterContext): boolean | undefined` + +Opt out of the engine's "you can't leave your king in check" move filter. Used by `piece-hp` — with HP active, a king in check just takes damage, it doesn't lose. + +Return `false` to skip the filter, `true` / `undefined` to keep it. + +### Description + +#### `describeMoveEffect(ctx: DescribeMoveEffectContext): string | undefined` + +Contribute to the `MoveRecord.presetEffects` array. Move-list UI, PGN export, and replay systems read these for human-readable annotations. + +```ts +describeMoveEffect({ mover, movingType, capturedType }) { + if (movingType === "queen" && capturedType !== null) { + return `${mover} queen fissioned into R+B`; + } + return undefined; +} +``` + +--- + +## Engine APIs available from hooks + +Every hook receives `ctx.engine: ChessEngine`. Useful methods: + +### `engine.session` + +The rete Session — direct insert/retract/get. Read preset-declared facts here. + +### `engine.spawnPiece(type, color, square, opts?): EntityId` + +Create a piece. Fires `onPieceSpawn` on every active preset. Use this instead of direct session inserts when creating pieces mid-game (fission, summon, resurrection). + +### `engine.dealDamage(target, amount, { kind, attacker? })` + +Canonical damage primitive. Routes through the `onDamage` pipeline (scope-aware). Returns `{ died: boolean }`. Use this instead of direct retraction — HP-like absorber presets get a chance to intercept. + +### `engine.emitEffect({ kind, square, ttl, data? })` + +Push a visual effect to subscribers. UI renders it. Presets emit from inside their hooks; no subscribers = no-op. + +### `engine.presetState(id)` + +Typed per-preset state bag. `{ get, set, delete, has, all }`. Backed by session facts, so serialization is free. Auto-cleared on deactivate. + +### `engine.moveLog` + +Read-only chronological list of `MoveRecord`s. Use from UI components or tests; presets typically don't need it. + +### `engine.effectivePieceAttrs` + +Full list of attribute keys currently treated as "piece state" — core + preset-declared. Use when you need to retract every attribute on an entity (e.g. custom death handling). + +### `engine.setActivePresets(requests)` + +Replace the active set. Fires `onDeactivate` for dropped ids and `onActivate` for new ids. Validates `incompatibleWith` + `requires` constraints. + +--- + +## PieceTypeRegistry + +Register new piece types. + +```ts +import { PIECE_TYPE_REGISTRY } from "./piece-type-registry.js"; + +PIECE_TYPE_REGISTRY.register({ + id: "cannon", + displayName: "Cannon", + moveGenerator: (session, id) => [/* LegalMove[] */], + attackProbe: (session, id, target) => /* boolean */, + assets: { white: cannonWhiteSvg, black: cannonBlackSvg }, +}); +``` + +The engine + check detection + UI all dispatch through the registry. Once registered, your custom type: +- Can be spawned via `engine.spawnPiece("cannon", ...)`. +- Participates in `engine.getAllLegalMoves()`. +- Contributes to `isSquareAttacked` / `isInCheck` / `isCheckmate`. +- Renders via UI `` using the declared `assets`. + +Register from a module-level side-effect import. Core FIDE types register from `presets/core-piece-types.ts`. + +--- + +## Walkthrough: Building a Shield preset + +```ts +// 1. Declare the attribute in ChessAttrMap (schema.ts): +declare module "../schema" { + interface ChessAttrMap { + Shield: number; + } +} + +// 2. Register the preset: +PRESET_REGISTRY.register({ + id: "piece-shield", + name: "Shield", + description: "Every piece has Shield=1. Absorbs one hit before HP.", + incompatibleWith: [], + requires: [], + pieceAttributes: ["Shield"], + + onActivate({ engine }) { + for (const f of engine.session.allFacts()) { + if (f.attr !== "PieceType") continue; + if ((f.id as number) <= 0) continue; + if (!engine.session.contains(f.id, "Shield")) { + engine.session.insert(f.id, "Shield", 1); + } + } + }, + + onPieceSpawn({ engine, pieceId }) { + if (!engine.session.contains(pieceId, "Shield")) { + engine.session.insert(pieceId, "Shield", 1); + } + }, + + onDamage({ engine, target, amount }) { + if (!engine.session.contains(target, "Shield")) return undefined; + const current = engine.session.get(target, "Shield") as number; + if (current <= 0) return undefined; + engine.session.insert(target, "Shield", current - 1); + if (amount - 1 <= 0) return { consume: true, died: false }; + return undefined; // let piece-hp / default handle the remainder + }, +}); +``` + +That's a full Shield preset in ~30 lines, zero edits to engine code. Activate it BEFORE piece-hp in the active set so it intercepts damage first. + +--- + +## Design notes and gotchas + +### Hook firing order + +Within a single `applyMove`, hooks fire in this order: + +1. `onBeforeMove` — scope-aware, can cancel. +2. `onBeforeCapture` — fires if the move is a capture. First consumer owns the capture mechanic. +3. `onDamage` — if no `onBeforeCapture` consumed, the engine routes target through this. Scope-aware by target color. +4. Move mutations (position update, promotion, en-passant, castling). +5. Duration tick (`turnsRemaining -= 1`), `onDeactivate` for expired presets. +6. `onAfterMove` — unscoped, all active presets. +7. `onTurnStart` — scope-aware by new side-to-move. +8. `onCheckGameResult` — all presets, first non-undefined wins. +9. `describeMoveEffect` — all presets contribute `presetEffects` to the MoveRecord. + +### Registration order determines dispatch order + +For hooks that iterate presets (`onDamage`, `onCheckGameResult`), the first preset to consume wins. Order follows the active-set list, which follows the order in which presets were activated via `setActivePresets`. Registration order in `presets/index.ts` sets the DEFAULT activation order when all presets are enabled at once, but user-toggle order during a game is what actually matters. + +If your preset needs to run before / after another, document the expectation. There's no priority system. + +### Scope-aware vs unscoped + +| Hook | Scope-aware? | Why | +|---|---|---| +| `onDamage` | Yes (target color) | A `scope=white` Shield only protects white pieces. | +| `onBeforeMove` | Yes (mover) | A `scope=white` veto doesn't block black's moves. | +| `onTurnStart` | Yes (new side to move) | Regen hooks should only fire for the color they care about. | +| `onBeforeCapture` | Yes (mover) | Inherited from pre-refactor semantics. | +| `onAfterMove` | No | Presets read `ctx.mover` and decide themselves. Breaking-change to flip. | +| `onCheckGameResult` | No | Terminal state is global. | + +### Context objects are growable + +All hook contexts are interface-defined and additive. Adding a field in a later release won't break existing hook implementations — they'll simply ignore the new field. + +### Don't use `GAME_ENTITY` for preset state + +The reserved `PRESET_STATE_ENTITY` + `engine.presetState(id)` API is namespaced, serializes automatically, and clears on deactivate. Putting preset-specific data on `GAME_ENTITY` (like the old `capture-to-win.Winner` fact) works today but collides with future presets and doesn't auto-clear. + +--- + +## Testing + +`packages/chess/src/presets/test-utils.ts` exports: + +- `pieceAt(engine, square)` — find piece on algebraic square. +- `hpOf(engine, id)` / `typeOf(engine, id)` / `exists(engine, id)` — attribute queries. +- `clearBoard(engine, { preserveKings })` — strip the board to minimal pieces. +- `placePiece(engine, type, color, square, opts?)` — direct session insert (bypasses spawnPiece). + +For preset tests that exercise hooks, see: +- `damage-pipeline.test.ts` — damage + composition +- `preset-state.test.ts` — per-preset state bag +- `move-log.test.ts` — MoveRecord / describeMoveEffect +- `visual-effect.test.ts` — emitEffect / subscribeEffects +- `phase-hooks.test.ts` — onBeforeMove / onTurnStart +- `integration.test.ts` — Shield / Cannon / Berserker / Stamina end-to-end + +--- + +## Post-landing backlog + +Documented but NOT yet available: + +- `onPieceRetract` — symmetric counterpart to `onPieceSpawn`. Needed for "death rattle" mechanics. +- Per-preset UI panels (not just overlays) — extension slot for sidebar widgets. +- Server-side piece-type manifest echo — when custom types are authored outside the shared `packages/chess`. +- Save-state migration — versioning for saves that predate attribute additions. + +File issues or propose extensions via pull request. diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index 382d0c7..7bade69 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -6,16 +6,13 @@ */ import { Session } from "@paratype/rete"; import type { EntityId } from "@paratype/rete"; -import { GAME_ENTITY, type PieceType, type PieceColor } from "./schema.js"; -import { generateStartingPosition } from "./starting-position.js"; -import { getLegalPawnMoves } from "./rules/pawn.js"; -import { getLegalKnightMoves } from "./rules/knight.js"; import { - getLegalRookMoves, - getLegalBishopMoves, - getLegalQueenMoves, -} from "./rules/sliding.js"; -import { getLegalKingMoves } from "./rules/king.js"; + GAME_ENTITY, + PRESET_STATE_ENTITY, + type PieceType, + type PieceColor, +} from "./schema.js"; +import { generateStartingPosition } from "./starting-position.js"; import { getCastlingMoves, applyCastlingMove, @@ -33,6 +30,7 @@ import { } from "./rules/promotion.js"; import { filterSelfCheckMoves, + isInCheck, isSquareAttacked, } from "./rules/check.js"; import { isCheckmate } from "./rules/checkmate.js"; @@ -44,28 +42,50 @@ import { recordPosition, isThreefoldRepetition, } from "./rules/draws.js"; -import { PIECE_ATTRS } from "./rules/capture.js"; -import type { DamageContext } from "./presets/registry.js"; +import { CORE_PIECE_ATTRS } from "./rules/capture.js"; +import type { + BeforeMoveContext, + CaptureHookContext, + DamageContext, + DamageHookContext, + DescribeMoveEffectContext, + GameResultHookContext, + LifecycleContext, + MoveHookContext, + PieceSpawnContext, + PieceSpawnReason, + SelfCheckFilterContext, + TurnStartContext, +} from "./presets/registry.js"; +import type { ChessAttrKey } from "./schema.js"; import type { LegalMove } from "./rules/types.js"; import { ActivePresetSet, type ActivationRequest, } from "./presets/active-set.js"; import { PRESET_REGISTRY } from "./presets/registry.js"; +import { PIECE_TYPE_REGISTRY } from "./presets/piece-type-registry.js"; // Importing from the barrel guarantees every preset module's // side-effect registration has run before the first engine is created. import "./presets/index.js"; type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[]; -const PIECE_MOVE_GETTERS: Record = { - pawn: getLegalPawnMoves, - knight: getLegalKnightMoves, - bishop: getLegalBishopMoves, - rook: getLegalRookMoves, - queen: getLegalQueenMoves, - king: getLegalKingMoves, -}; +/** + * Look up the move generator for a piece type via the registry. + * Returns `null` for unknown types (degenerate — indicates a fact + * with a piece type nobody registered; the engine treats such pieces + * as immobile). + * + * This replaces the hardcoded `PIECE_MOVE_GETTERS` table that only + * knew about FIDE pieces. Custom types (Cannon, Amazon, Nightrider) + * register their move generators in the same registry and are + * dispatched automatically. + */ +function lookupMoveGenerator(type: string): MoveGetter | null { + const def = PIECE_TYPE_REGISTRY.get(type); + return def?.moveGenerator ?? null; +} export type GameResult = | "checkmate" @@ -81,6 +101,187 @@ export type GameResult = | "black-wins" | "ongoing"; +/** + * Reserved attribute prefix for preset-state facts. Every preset- + * state attribute stored on PRESET_STATE_ENTITY is prefixed with + * this to guarantee no collision with game-level or piece-level + * attribute keys. + * + * Changing this value breaks serialized save-games that contain + * preset-state facts — don't. + */ +const PRESET_STATE_ATTR_PREFIX = "__preset__/"; + +/** + * Thrown from `applyMove` when a preset's `onBeforeMove` hook vetoes + * the move. The caller (UI / server) should catch this, surface + * `reason` to the user, and leave the game state unchanged — no + * mutation happens before the cancel check runs. + * + * Fatal the move attempt, not the session. Callers should NOT treat + * this as a server error. + */ +export class MoveCancelledError extends Error { + readonly presetId: string; + constructor(message: string, presetId: string) { + super(message); + this.name = "MoveCancelledError"; + this.presetId = presetId; + } +} + +/** + * A single line in the engine's move history. + * + * Every successful `applyMove` appends one of these to the engine's + * `moveLog`. Callers that need history (UI move-list panel, PGN + * export, replay, analysis) read this rather than diffing session + * facts. + * + * `presetEffects` is populated by presets implementing + * `describeMoveEffect`: each return value contributes one entry + * (presetId + human-readable summary). Example: queen-splits would + * push `{ presetId: "queen-splits", summary: "Queen split into + * Rook/Bishop" }` on a fission move. + * + * Growable — future fields should be optional so existing consumers + * don't break. Favor primitive/string values so the record is + * trivially JSON-serializable for save/export. + */ +/** + * An ephemeral visual effect emitted by a preset for the UI layer + * to render. + * + * Effects are decoupled from gameplay — the engine's state is + * authoritative and independent of whether anyone subscribed. Emit + * liberally from presets; the UI decides whether / how to render a + * given kind. Unknown kinds are rendered as a generic pulse by the + * default VisualEffectLayer. + * + * `kind` is a free-form string so presets can introduce new effect + * types (explosion, heal, poison, lightning, shield-break, …) without + * editing engine/UI tables. The UI layer dispatches to per-kind + * components; an unregistered kind falls back to a generic overlay. + * + * `square` is where the effect visually anchors. `null` is allowed + * for full-board effects (e.g. a "shockwave" that sweeps the whole + * board). `ttl` is how long (in ms) the effect should persist before + * auto-expiring. + * + * `data` is a typed-free-form bag for kind-specific payloads + * (color, intensity, direction, count). Presets document what they + * emit; renderers read what they need. + */ +export interface VisualEffect { + readonly kind: string; + readonly square: number | null; + readonly ttl: number; + readonly data?: Record; +} + +/** Handler signature for effect subscribers. Return nothing. */ +export type VisualEffectHandler = (effect: VisualEffect) => void; + +/** Unsubscribe callback returned by `subscribeEffects`. */ +export type Unsubscribe = () => void; + +export interface MoveRecord { + readonly from: number; + readonly to: number; + readonly mover: PieceColor; + readonly movingType: PieceType; + /** Captured piece's id BEFORE the capture resolved (null if no capture). */ + readonly capturedId: EntityId | null; + /** Captured piece's type BEFORE the capture resolved (null if no capture). */ + readonly capturedType: PieceType | null; + /** Did the move put the opponent in check? */ + readonly isCheck: boolean; + /** Did the move end the game (checkmate or preset-terminal)? */ + readonly terminal: boolean; + readonly isEnPassant: boolean; + readonly isCastling: boolean; + readonly promotion: PieceType | null; + /** Engine timestamp in ms (Date.now()); for client-side animation timing. */ + readonly timestamp: number; + /** Non-empty when presets contributed effect descriptions. */ + readonly presetEffects: ReadonlyArray<{ presetId: string; summary: string }>; +} + +/** + * Typed accessor for a preset's slice of engine-scoped state. + * + * Obtain one via `ChessEngine.presetState(presetId)`. Calls are + * scoped: two presets calling `state.set("x", 1)` with different + * preset IDs each get their own `x`. + */ +export interface PresetState> { + /** Read a state key. Returns undefined when unset. */ + get(key: K): T[K] | undefined; + /** Write a state key. Overwrites any previous value. */ + set(key: K, value: T[K]): void; + /** Delete a state key. Returns true if it was set, false otherwise. */ + delete(key: K): boolean; + /** Returns the full state snapshot as a plain object. */ + all(): Partial; + /** True if `key` has been set. */ + has(key: K): boolean; +} + +class PresetStateImpl> + implements PresetState +{ + readonly #session: Session; + readonly #prefix: string; + + constructor(session: Session, presetId: string) { + this.#session = session; + this.#prefix = `${PRESET_STATE_ATTR_PREFIX}${presetId}/`; + } + + #attrOf(key: string): string { + return `${this.#prefix}${key}`; + } + + get(key: K): T[K] | undefined { + const attr = this.#attrOf(key as string); + if (!this.#session.contains(PRESET_STATE_ENTITY, attr)) return undefined; + return this.#session.get(PRESET_STATE_ENTITY, attr) as T[K]; + } + + set(key: K, value: T[K]): void { + // The rete session validates that `value` is a legal FactValue + // (string | number | boolean | null). Passing objects here will + // throw — a deliberate constraint to keep facts serialization-safe. + this.#session.insert( + PRESET_STATE_ENTITY, + this.#attrOf(key as string), + value as unknown as import("@paratype/rete").FactValue, + ); + } + + delete(key: K): boolean { + const attr = this.#attrOf(key as string); + if (!this.#session.contains(PRESET_STATE_ENTITY, attr)) return false; + this.#session.retract(PRESET_STATE_ENTITY, attr); + return true; + } + + has(key: K): boolean { + return this.#session.contains(PRESET_STATE_ENTITY, this.#attrOf(key as string)); + } + + all(): Partial { + const result: Record = {}; + for (const f of this.#session.allFacts()) { + if ((f.id as number) !== (PRESET_STATE_ENTITY as number)) continue; + if (!f.attr.startsWith(this.#prefix)) continue; + const key = f.attr.slice(this.#prefix.length); + result[key] = f.value; + } + return result as Partial; + } +} + export class ChessEngine { public readonly session: Session; /** @@ -96,6 +297,69 @@ export class ChessEngine { */ public readonly activePresets: ActivePresetSet; + /** + * Chronological log of every successful applyMove. Callers consume + * this read-only for move-history UIs, PGN export, analysis. Writing + * to it is a private concern of applyMove. + * + * Unbounded — games are short-lived enough that we don't need a + * ring buffer. If that ever changes, add a `maxLogSize` option. + */ + readonly #moveLog: MoveRecord[] = []; + + /** Read-only view of the move log. */ + get moveLog(): ReadonlyArray { + return this.#moveLog; + } + + /** + * Active visual-effect subscribers. Presets call `engine.emitEffect` + * to push effects; UI layers call `subscribeEffects` to listen. + * + * Using a Set (not an Array) so unsubscribe is O(1) and insertion + * order doesn't matter (effects are fire-and-forget — we iterate + * the full set on every emit). + */ + readonly #effectHandlers = new Set(); + + /** + * Emit a visual effect to all subscribers. Fire-and-forget — + * handlers that throw are isolated (caught) so one bad subscriber + * doesn't break others. + * + * Safe to call from inside any preset hook. The engine does NOT + * persist effects; they're pure UI signals. If no subscribers are + * attached, the call is essentially a no-op. + */ + emitEffect(effect: VisualEffect): void { + for (const handler of this.#effectHandlers) { + try { + handler(effect); + } catch (err) { + // Effect handlers are UI-side and shouldn't tank gameplay. + // Log to console so dev-mode surfaces the problem without + // throwing. + console.error("VisualEffect handler threw:", err); + } + } + } + + /** + * Register a subscriber for visual effects. Returns a function that + * removes the subscription. Typically called from React `useEffect` + * on component mount, and the return is returned as the cleanup. + * + * Handlers are called synchronously from `emitEffect`. For React + * subscribers, the handler should schedule a state update rather + * than render inline — use `useSyncExternalStore` or equivalent. + */ + subscribeEffects(handler: VisualEffectHandler): Unsubscribe { + this.#effectHandlers.add(handler); + return () => { + this.#effectHandlers.delete(handler); + }; + } + constructor(activePresets?: ActivePresetSet) { this.session = new Session({ autoFire: false }); generateStartingPosition(this.session); @@ -103,6 +367,80 @@ export class ChessEngine { this.activePresets = activePresets ?? new ActivePresetSet(); } + /** + * The full set of piece attributes the engine should treat as + * "per-piece state" for this game, combining FIDE's core attrs + * with any preset-declared additions. + * + * Used by the damage pipeline's default-death path, piece retract + * helpers inside presets (queen-splits fissioning the queen, + * explosive-rook blast fallback), and any future save-state + * serialization. This is the CANONICAL answer to "what facts + * represent a piece." + * + * Computed on every read because the active preset set is mutable; + * the list is tiny (< 10 entries in practice), so we don't cache. + * If perf becomes a concern, invalidate a cache in + * `setActivePresets` / `ActivePresetSet.replaceAll`. + */ + get effectivePieceAttrs(): readonly ChessAttrKey[] { + const attrs = new Set(CORE_PIECE_ATTRS); + for (const entry of this.activePresets.list()) { + const def = PRESET_REGISTRY.get(entry.id); + if (!def?.pieceAttributes) continue; + for (const a of def.pieceAttributes) attrs.add(a); + } + return [...attrs]; + } + + /** + * Typed, per-preset, per-engine state bag. + * + * Returns an accessor scoped to `presetId` that reads/writes facts + * on the reserved `PRESET_STATE_ENTITY`. Attribute keys are + * namespaced as `__preset__/${presetId}/${key}` under the hood so + * two presets can never collide. + * + * Because state is backed by session facts it: + * - serializes automatically with the rest of save-state (no extra + * plumbing — `session.allFacts()` already includes it) + * - is engine-scoped, so two concurrent games (server-side) hold + * independent state under the same preset id — no module-level + * globals + * - is cleared when the preset deactivates (see + * `clearPresetState` called from `setActivePresets` / + * `tickAfterMove`) + * + * The type parameter is trusted for convenience — `set("x", 5)` + * doesn't verify that `x` is `number` in `T`. Use sparingly or wrap + * in a factory function per preset that enforces its own shape. + */ + presetState = Record>( + presetId: string, + ): PresetState { + return new PresetStateImpl(this.session, presetId); + } + + /** + * Retract every preset-state fact owned by `presetId`. Called from + * `setActivePresets` and `tickAfterMove` when a preset deactivates, + * so stale state doesn't leak across activation cycles. + */ + private clearPresetState(presetId: string): void { + const prefix = `${PRESET_STATE_ATTR_PREFIX}${presetId}/`; + const toRetract = this.session + .allFacts() + .filter( + (f) => + (f.id as number) === (PRESET_STATE_ENTITY as number) && + f.attr.startsWith(prefix), + ) + .map((f) => f.attr); + for (const attr of toRetract) { + this.session.retract(PRESET_STATE_ENTITY, attr); + } + } + /** * Replace the active preset set and fire lifecycle hooks on transitions. * @@ -131,15 +469,19 @@ export class ChessEngine { this.activePresets.replaceAll(requests); const newIds = new Set(requests.map((r) => r.id)); + const ctx: LifecycleContext = { engine: this }; for (const id of oldIds) { if (newIds.has(id)) continue; const def = PRESET_REGISTRY.get(id); - def?.onDeactivate?.(this); + def?.onDeactivate?.(ctx); + // Clear preset-state AFTER onDeactivate so the hook can read + // (or explicitly preserve) its own state during teardown. + this.clearPresetState(id); } for (const req of requests) { if (oldIds.has(req.id)) continue; const def = PRESET_REGISTRY.get(req.id); - def?.onActivate?.(this); + def?.onActivate?.(ctx); } } @@ -158,14 +500,69 @@ export class ChessEngine { color: PieceColor, ): boolean { let consumed = false; + const ctx: CaptureHookContext = { + engine: this, + attacker, + target, + mover: color, + }; for (const preset of this.activePresets.getForColor(color)) { if (!preset.onBeforeCapture) continue; - const result = preset.onBeforeCapture(this, attacker, target); + const result = preset.onBeforeCapture(ctx); if (result && result.consume === true) consumed = true; } return consumed; } + /** + * Create a new piece entity on `square` and fire `onPieceSpawn` hooks. + * + * This is the CANONICAL spawn path. Every code site that materializes + * a piece — the queen-splits fission, promotion, hypothetical + * summon/resurrect presets — should go through here so: + * - `onPieceSpawn` hooks get a chance to seed preset-declared + * attributes (Hp from piece-hp, Shield from piece-shield, …) + * WITHOUT those presets having to implement idempotent + * onActivate scans. + * - core attrs are inserted in a consistent order. + * - new spawn reasons can be added to the `PieceSpawnReason` union + * without editing this function. + * + * The initial starting-position generation does NOT go through here + * (it precedes any active presets — nothing to hook). Preset-authored + * initial placements should activate AFTER piece seeding or use + * this function explicitly in their `onActivate`. + */ + spawnPiece( + type: PieceType, + color: PieceColor, + square: number, + opts: { + readonly reason?: PieceSpawnReason; + readonly hasMoved?: boolean; + } = {}, + ): EntityId { + const id = this.session.nextId(); + this.session.insert(id, "PieceType", type); + this.session.insert(id, "Color", color); + this.session.insert(id, "Position", square); + this.session.insert(id, "HasMoved", opts.hasMoved ?? false); + + const ctx: PieceSpawnContext = { + engine: this, + pieceId: id, + type, + color, + square, + reason: opts.reason ?? "summon", + }; + for (const entry of this.activePresets.list()) { + const def = PRESET_REGISTRY.get(entry.id); + def?.onPieceSpawn?.(ctx); + } + return id; + } + /** * Deal `amount` damage to `target`, running it through the preset * damage pipeline before applying the default kill-on-damage rule. @@ -199,21 +596,44 @@ export class ChessEngine { ): { died: boolean } { if (amount <= 0) return { died: false }; - // Consult damage interceptors in active-preset list order. First - // consumer wins. Scope is not considered here because damage is a - // board-level event that can strike any piece regardless of whose - // turn it is (e.g. explosive rook hitting friendly pieces). + // Scope-aware dispatch: pre-resolve the target's color so we can + // skip presets whose scope doesn't cover it. A `scope=white` preset + // only damages/protects white pieces; without this filter, a white- + // only Shield preset would absorb damage on black pieces too. + // Presets without a color-sensitive hook opt in to universal + // dispatch by leaving scope as "both" (the default). + const targetColor = this.session.get(target, "Color") as + | PieceColor + | undefined; + const hookCtx: DamageHookContext = { + engine: this, + target, + amount, + kind: ctx.kind, + ...(ctx.attacker !== undefined ? { attacker: ctx.attacker } : {}), + }; for (const entry of this.activePresets.list()) { + // If we know the target's color, require the preset's scope to + // cover that color. If we don't (degenerate — target has no + // Color fact), fire on every preset regardless. + if ( + targetColor !== undefined && + entry.scope !== "both" && + entry.scope !== targetColor + ) { + continue; + } const def = PRESET_REGISTRY.get(entry.id); - const result = def?.onDamage?.(this, target, amount, ctx); + const result = def?.onDamage?.(hookCtx); if (result?.consume === true) { return { died: result.died === true }; } } - // Default: any damage is lethal. Retract all piece attributes so - // downstream queries see the piece as truly gone. - for (const attr of PIECE_ATTRS) { + // Default: any damage is lethal. Retract every effective piece + // attribute (core + preset-declared) so downstream queries see the + // piece as truly gone. + for (const attr of this.effectivePieceAttrs) { if (this.session.contains(target, attr)) { this.session.retract(target, attr); } @@ -240,7 +660,7 @@ export class ChessEngine { .filter(p => p.type !== undefined); for (const piece of pieces) { - const getter = PIECE_MOVE_GETTERS[piece.type]; + const getter = lookupMoveGenerator(piece.type); if (!getter) continue; let pieceMoves = getter(this.session, piece.id); @@ -304,8 +724,9 @@ export class ChessEngine { // This is how `piece-hp` allows the king to stay on an attacked // square — a "hit" costs HP rather than losing the game. let applyFilter = true; + const selfCheckCtx: SelfCheckFilterContext = { engine: this, color }; for (const preset of this.activePresets.getForColor(color)) { - if (preset.shouldFilterSelfCheck?.(this, color) === false) { + if (preset.shouldFilterSelfCheck?.(selfCheckCtx) === false) { applyFilter = false; break; } @@ -315,6 +736,28 @@ export class ChessEngine { applyMove(move: LegalMove, promoteTo: PieceType = "queen"): GameResult { const color = this.getCurrentTurn(); + + // Phase hook: give presets a chance to veto the move. Scope-aware + // (fires only for presets whose scope covers the mover). We do + // this BEFORE any mutation so a veto leaves state clean. + const beforeCtx: BeforeMoveContext = { + engine: this, + mover: color, + pieceId: move.pieceId, + from: move.from, + to: move.to, + isCapture: move.isCapture === true, + }; + for (const preset of this.activePresets.getForColor(color)) { + const result = preset.onBeforeMove?.(beforeCtx); + if (result?.cancel === true) { + throw new MoveCancelledError( + result.reason ?? `Move cancelled by preset "${preset.id}"`, + preset.id, + ); + } + } + const facts = this.session.allFacts(); const movingType = facts.find( f => f.id === move.pieceId && f.attr === "PieceType", @@ -329,6 +772,26 @@ export class ChessEngine { const isCastling = (move as CastlingMove).isCastling === true; + // Capture pre-move state for the MoveRecord we'll log at the end. + // Taking the snapshot NOW — before any mutation — is the only way + // to know what was on the destination square, since the capture + // path may retract it mid-function. + const capturedIdForLog: EntityId | null = move.isCapture + ? (isEnPassant + ? this.getPieceAt( + color === "white" + ? ((move.to - 8) as number) + : ((move.to + 8) as number), + ) + : this.getPieceAt(move.to)) + : null; + const capturedTypeForLog: PieceType | null = + capturedIdForLog !== null + ? (facts.find( + (f) => f.id === capturedIdForLog && f.attr === "PieceType", + )?.value as PieceType | undefined) ?? null + : null; + if (isEnPassant) { // En passant captures the pawn on the SKIPPED square, not on // `move.to`. We still fire `onBeforeCapture` first so presets @@ -440,10 +903,12 @@ export class ChessEngine { // ticks only when white plays; a `scope=both` ticks on every // half-move. Entries reaching 0 are removed AND fire onDeactivate // so they can tear down any board state they installed. + const lifecycleCtx: LifecycleContext = { engine: this }; const expired = this.activePresets.tickAfterMove(color); for (const id of expired) { const def = PRESET_REGISTRY.get(id); - def?.onDeactivate?.(this); + def?.onDeactivate?.(lifecycleCtx); + this.clearPresetState(id); } // Post-move preset hooks: fire against EVERY still-active preset @@ -451,12 +916,73 @@ export class ChessEngine { // responsibility — king-heals affects the non-mover, poisoned-squares // affects the mover, so a single engine-level scope filter can't // serve both. + const moveCtx: MoveHookContext = { engine: this, mover: color }; for (const entry of this.activePresets.list()) { const def = PRESET_REGISTRY.get(entry.id); - def?.onAfterMove?.(this, color); + def?.onAfterMove?.(moveCtx); } - return this.checkGameResult(); + // Phase hook: a new turn is starting (nextColor is now to move). + // Scope-aware — presets scoped to a specific color only fire when + // that color's turn is beginning. Ideal for resource regen / + // cooldown ticks — runs BEFORE the player asks for their legal + // moves, so regenerated stamina / refreshed cooldowns are + // reflected in the first legal-move query. + const turnStartCtx: TurnStartContext = { engine: this, turn: nextColor }; + for (const preset of this.activePresets.getForColor(nextColor)) { + preset.onTurnStart?.(turnStartCtx); + } + + // Determine terminal state BEFORE we log, so the MoveRecord + // reflects game-over status on the move that caused it. + const gameResult = this.checkGameResult(); + const terminal = gameResult !== "ongoing"; + + // Check-detection: after the move + preset hooks, is the opponent + // (the side whose turn is NOW) in check? + const opponentInCheck = isInCheck(this.session, nextColor); + + // Collect preset contributions to the move description. + const describeCtx: DescribeMoveEffectContext = { + engine: this, + mover: color, + from: move.from, + to: move.to, + movingType, + capturedType: capturedTypeForLog, + isEnPassant, + isCastling, + promotion: + movingType === "pawn" && isPromotionMove(effectiveTo, color) + ? ((move as LegalMove & { promoteTo?: PieceType }).promoteTo ?? promoteTo) + : null, + }; + const presetEffects: Array<{ presetId: string; summary: string }> = []; + for (const entry of this.activePresets.list()) { + const def = PRESET_REGISTRY.get(entry.id); + const summary = def?.describeMoveEffect?.(describeCtx); + if (summary !== undefined && summary !== "") { + presetEffects.push({ presetId: entry.id, summary }); + } + } + + this.#moveLog.push({ + from: move.from, + to: move.to, + mover: color, + movingType, + capturedId: capturedIdForLog, + capturedType: capturedTypeForLog, + isCheck: opponentInCheck, + terminal, + isEnPassant, + isCastling, + promotion: describeCtx.promotion as PieceType | null, + timestamp: Date.now(), + presetEffects, + }); + + return gameResult; } checkGameResult(): GameResult { @@ -464,9 +990,10 @@ export class ChessEngine { // in registration order. First non-undefined return wins. This lets // capture-to-win and last-piece-standing redefine "game over" // without touching engine internals. + const resultCtx: GameResultHookContext = { engine: this }; for (const entry of this.activePresets.list()) { const def = PRESET_REGISTRY.get(entry.id); - const override = def?.onCheckGameResult?.(this); + const override = def?.onCheckGameResult?.(resultCtx); if (override !== undefined) return override; } diff --git a/packages/chess/src/presets/capture-to-win.ts b/packages/chess/src/presets/capture-to-win.ts index 1458df2..33be00e 100644 --- a/packages/chess/src/presets/capture-to-win.ts +++ b/packages/chess/src/presets/capture-to-win.ts @@ -6,25 +6,32 @@ * capture ends the game first. * * Wiring: - * - `onBeforeCapture` — record the capturer's color on the game - * entity via a `Winner` fact. Does NOT consume the capture, so the - * engine's default retract-and-move still runs (the target piece - * dies normally, the attacker advances onto its square). This - * keeps the final board state intuitive for players to inspect - * ("why is white's pawn on d5?") rather than freezing the board - * mid-capture. - * - `onCheckGameResult` — if a Winner fact was recorded, convert - * it into the corresponding GameResult. Otherwise return undefined - * and let the default checkmate/stalemate logic run (nothing - * to do until the first capture happens). + * - `onBeforeCapture` — record the capturer's color in preset- + * scoped state via `engine.presetState("capture-to-win")`. Does + * NOT consume the capture, so the engine's default retract-and- + * move still runs (the target piece dies normally, the attacker + * advances onto its square). This keeps the final board state + * intuitive for players to inspect. + * - `onCheckGameResult` — if a winner was recorded, convert it + * into the corresponding GameResult. Otherwise return undefined + * and let the default checkmate/stalemate logic run. + * + * State migration note: the winner used to live as a `Winner` fact + * on `GAME_ENTITY`, alongside real game-level facts (Turn, etc). + * Post-refactor it lives in the preset's dedicated state bag so + * other presets can't collide, the state is auto-cleared on + * deactivate, and `Winner` stays off the shared game entity. * * Incompatible with `last-piece-standing` — both redefine "when is * the game over" and would compete for the onCheckGameResult hook. */ import { PRESET_REGISTRY } from "./registry.js"; -import { GAME_ENTITY, type PieceColor } from "../schema.js"; -import type { ChessEngine, GameResult } from "../engine.js"; -import type { EntityId } from "@paratype/rete"; +import type { PieceColor } from "../schema.js"; +import type { GameResult } from "../engine.js"; + +interface CaptureToWinState extends Record { + winner: PieceColor; +} PRESET_REGISTRY.register({ id: "capture-to-win", @@ -34,39 +41,29 @@ PRESET_REGISTRY.register({ incompatibleWith: ["last-piece-standing"], requires: [], - onBeforeCapture(engine: ChessEngine, attacker: EntityId, _target: EntityId) { - const session = engine.session; - // Only record the first capture — don't stomp an earlier winner - // if multiple captures somehow occur in the same applyMove - // (shouldn't happen, but defensive). - if (session.contains(GAME_ENTITY, "Winner")) { - const existing = session.get(GAME_ENTITY, "Winner"); - if (existing !== null) return; // already set, leave it - } - const colorFact = session + onBeforeCapture({ engine, attacker }) { + const state = engine.presetState("capture-to-win"); + // Only record the FIRST capture — once set, never overwrite so + // multi-capture edge cases in the same applyMove don't drift. + if (state.has("winner")) return; + + const colorFact = engine.session .allFacts() - .find(f => f.id === attacker && f.attr === "Color"); + .find((f) => f.id === attacker && f.attr === "Color"); if (!colorFact) return; - session.insert(GAME_ENTITY, "Winner", colorFact.value as PieceColor); + state.set("winner", colorFact.value as PieceColor); // Don't consume — let the capture resolve normally so the board // visibly reflects the killing blow. }, - onCheckGameResult(engine: ChessEngine): GameResult | undefined { - const session = engine.session; - if (!session.contains(GAME_ENTITY, "Winner")) return undefined; - const winner = session.get(GAME_ENTITY, "Winner"); + onCheckGameResult({ engine }): GameResult | undefined { + const state = engine.presetState("capture-to-win"); + const winner = state.get("winner"); if (winner === "white") return "white-wins"; if (winner === "black") return "black-wins"; - return undefined; // "draw" or null — let default logic run + return undefined; }, - onDeactivate(engine: ChessEngine) { - // Clear any stored winner when the preset is turned off so the - // game doesn't stay in a won state after the rule is lifted. - const session = engine.session; - if (session.contains(GAME_ENTITY, "Winner")) { - session.retract(GAME_ENTITY, "Winner"); - } - }, + // No explicit onDeactivate needed: the engine automatically clears + // preset-state for this id via clearPresetState when we go inactive. }); diff --git a/packages/chess/src/presets/core-piece-types.ts b/packages/chess/src/presets/core-piece-types.ts new file mode 100644 index 0000000..4487cef --- /dev/null +++ b/packages/chess/src/presets/core-piece-types.ts @@ -0,0 +1,106 @@ +/** + * Registration of the six FIDE piece types in the PieceTypeRegistry. + * + * This module is imported from the presets barrel so piece types are + * available before any engine constructs a move. + * + * Piece types are registered with: + * - `moveGenerator` — the pseudo-legal move function from `rules/`. + * - `attackProbe` — "does this piece attack `target`?" used for + * check detection. For most types this is a thin wrapper over + * the move generator; pawns need their own because they attack + * diagonally without an enemy on the target square. + * - `assets` — the bundled SVG URLs per color. + * + * Custom piece types (Cannon, Amazon, etc.) register themselves the + * same way — typically from a preset's side-effect import. + */ +import { PIECE_TYPE_REGISTRY } from "./piece-type-registry.js"; +import { getLegalPawnMoves } from "../rules/pawn.js"; +import { getLegalKnightMoves } from "../rules/knight.js"; +import { + getLegalRookMoves, + getLegalBishopMoves, + getLegalQueenMoves, +} from "../rules/sliding.js"; +import { getLegalKingMoves } from "../rules/king.js"; +import { getPiecePosition } from "../rules/board-queries.js"; +import { pawnCaptureSqares } from "../rules/primitives.js"; +import { pieceAssets } from "../assets/pieces/index.js"; +import type { PieceAttackProbe, PieceMoveGenerator } from "./piece-type-registry.js"; +import type { PieceColor } from "../schema.js"; + +/** + * Derive a "does this piece attack `target`?" probe from its move + * generator. Works for any piece type whose attack geometry EQUALS + * its move geometry. Pawns are the sole exception (see below). + */ +function movesAttackProbe(getter: PieceMoveGenerator): PieceAttackProbe { + return (session, pieceId, target) => { + for (const m of getter(session, pieceId)) { + if (m.to === target) return true; + } + return false; + }; +} + +/** Pawns attack diagonally regardless of what (or nothing) is there. */ +const pawnAttackProbe: PieceAttackProbe = (session, pieceId, target) => { + const from = getPiecePosition(session, pieceId); + if (from === null) return false; + const colorVal = session.get(pieceId, "Color"); + if (colorVal === undefined) return false; + const color = colorVal as PieceColor; + for (const sq of pawnCaptureSqares(from, color)) { + if (sq === target) return true; + } + return false; +}; + +PIECE_TYPE_REGISTRY.register({ + id: "pawn", + displayName: "Pawn", + moveGenerator: getLegalPawnMoves, + attackProbe: pawnAttackProbe, + assets: { white: pieceAssets.white.pawn, black: pieceAssets.black.pawn }, +}); + +PIECE_TYPE_REGISTRY.register({ + id: "knight", + displayName: "Knight", + moveGenerator: getLegalKnightMoves, + attackProbe: movesAttackProbe(getLegalKnightMoves), + assets: { white: pieceAssets.white.knight, black: pieceAssets.black.knight }, +}); + +PIECE_TYPE_REGISTRY.register({ + id: "bishop", + displayName: "Bishop", + moveGenerator: getLegalBishopMoves, + attackProbe: movesAttackProbe(getLegalBishopMoves), + assets: { white: pieceAssets.white.bishop, black: pieceAssets.black.bishop }, +}); + +PIECE_TYPE_REGISTRY.register({ + id: "rook", + displayName: "Rook", + moveGenerator: getLegalRookMoves, + attackProbe: movesAttackProbe(getLegalRookMoves), + assets: { white: pieceAssets.white.rook, black: pieceAssets.black.rook }, +}); + +PIECE_TYPE_REGISTRY.register({ + id: "queen", + displayName: "Queen", + moveGenerator: getLegalQueenMoves, + attackProbe: movesAttackProbe(getLegalQueenMoves), + assets: { white: pieceAssets.white.queen, black: pieceAssets.black.queen }, +}); + +PIECE_TYPE_REGISTRY.register({ + id: "king", + displayName: "King", + moveGenerator: getLegalKingMoves, + attackProbe: movesAttackProbe(getLegalKingMoves), + assets: { white: pieceAssets.white.king, black: pieceAssets.black.king }, +}); diff --git a/packages/chess/src/presets/damage-pipeline.test.ts b/packages/chess/src/presets/damage-pipeline.test.ts index dfc3763..3a8872f 100644 --- a/packages/chess/src/presets/damage-pipeline.test.ts +++ b/packages/chess/src/presets/damage-pipeline.test.ts @@ -355,6 +355,76 @@ describe("queen-splits + piece-hp: fission only on kills", () => { // poisoned-squares: damage pipeline integration // ───────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────── +// engine.spawnPiece + onPieceSpawn — preset-agnostic spawn pipeline +// ───────────────────────────────────────────────────────────────────── + +describe("engine.spawnPiece + onPieceSpawn: HP auto-seeds on fission-spawned pieces", () => { + it("queen-splits spawns rook+bishop; piece-hp onPieceSpawn seeds Hp=2 on both", () => { + const engine = new ChessEngine(); + + // Clear to a minimal board: white queen on d1, weakened black + // pawn on d4 (Hp=1 after we activate piece-hp). + const facts = engine.session.allFacts(); + const toClear: EntityId[] = []; + for (const f of facts) { + if (f.attr !== "PieceType") continue; + if ((f.id as number) <= 0) continue; + if (f.value === "king") continue; + toClear.push(f.id); + } + for (const id of toClear) { + for (const attr of ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const) { + if (engine.session.contains(id, attr)) engine.session.retract(id, attr); + } + } + const queenId = engine.session.nextId(); + engine.session.insert(queenId, "PieceType", "queen"); + engine.session.insert(queenId, "Color", "white"); + engine.session.insert(queenId, "Position", algebraicToSquare("d1")); + const pawnId = engine.session.nextId(); + engine.session.insert(pawnId, "PieceType", "pawn"); + engine.session.insert(pawnId, "Color", "black"); + engine.session.insert(pawnId, "Position", algebraicToSquare("d4")); + engine.session.insert(pawnId, "HasMoved", true); + + // Activate queen-splits FIRST, then piece-hp. This used to be a + // broken ordering — the spawn helper in queen-splits never went + // through HP's onActivate, so fissioned pieces had no Hp. + // Post-refactor, queen-splits uses engine.spawnPiece which fires + // onPieceSpawn on every active preset → piece-hp seeds Hp=2. + engine.setActivePresets([ + { id: "queen-splits", scope: "both", turnsRemaining: null }, + ]); + engine.setActivePresets([ + { id: "queen-splits", scope: "both", turnsRemaining: null }, + { id: "piece-hp", scope: "both", turnsRemaining: null }, + ]); + // Pre-weaken the target so queen kills on her capture (fission + // only fires on kill, not poke). + engine.session.insert(pawnId, "Hp", 1); + + const capture = engine.findMove( + algebraicToSquare("d1"), + algebraicToSquare("d4"), + ); + expect(capture).not.toBeNull(); + engine.applyMove(capture!); + + // Rook spawned on d4 (capture square) — find it, check Hp. + const d4Now = pieceAt(engine, "d4")!; + expect(engine.session.get(d4Now, "PieceType")).toBe("rook"); + expect(hpOf(engine, d4Now)).toBe(2); + + // Bishop spawned on first clockwise-from-N empty square around d4 + // (d5 is empty on our minimal board). + const d5Now = pieceAt(engine, "d5"); + expect(d5Now).not.toBeNull(); + expect(engine.session.get(d5Now!, "PieceType")).toBe("bishop"); + expect(hpOf(engine, d5Now!)).toBe(2); + }); +}); + describe("poisoned-squares routes through the damage pipeline", () => { it("each tick deals 1 HP damage via the pipeline; piece dies when Hp hits 0", () => { const engine = new ChessEngine(); diff --git a/packages/chess/src/presets/explosive-rook.ts b/packages/chess/src/presets/explosive-rook.ts index e93b7f0..76540c2 100644 --- a/packages/chess/src/presets/explosive-rook.ts +++ b/packages/chess/src/presets/explosive-rook.ts @@ -22,7 +22,6 @@ * mechanic finite. */ import { PRESET_REGISTRY } from "./registry.js"; -import type { ChessEngine } from "../engine.js"; import type { Session, EntityId } from "@paratype/rete"; import { fileOf, rankOf, squareOf } from "../coord.js"; import type { Square } from "../schema.js"; @@ -47,7 +46,7 @@ PRESET_REGISTRY.register({ incompatibleWith: [], requires: [], - onBeforeCapture(engine: ChessEngine, attacker: EntityId, target: EntityId) { + onBeforeCapture({ engine, attacker, target }) { const session = engine.session; const attackerTypeFact = session .allFacts() @@ -97,6 +96,16 @@ PRESET_REGISTRY.register({ engine.dealDamage(v, 1, { kind: "explosion", attacker }); } + // Fire a visual effect anchored on the detonation square. The UI + // layer renders this as a brief blast overlay. Fire-and-forget — + // no subscribers = no-op, so tests and headless runs pay nothing. + engine.emitEffect({ + kind: "explosion", + square: targetPos, + ttl: 600, + data: { radius: DETONATION_RADIUS, victimCount: victims.length }, + }); + // Advance the rook only if the target died — matches HP's general // "non-lethal capture = attacker stays" semantics. Without HP, // targetDied is always true so the rook always advances (standard diff --git a/packages/chess/src/presets/index.ts b/packages/chess/src/presets/index.ts index e0995af..e86c930 100644 --- a/packages/chess/src/presets/index.ts +++ b/packages/chess/src/presets/index.ts @@ -1,10 +1,18 @@ /** * Preset rule barrel + registration side-effects (P3.4+). * - * Importing this module guarantees all preset rules are registered in - * PRESET_REGISTRY. Consumers should import `./index.js` (never the registry - * directly) to ensure preset side-effect registration has run. + * Importing this module guarantees: + * - Core FIDE piece types are registered in PIECE_TYPE_REGISTRY. + * - All preset rules are registered in PRESET_REGISTRY. + * + * Consumers should import `./index.js` (never the registries + * directly) to ensure side-effect registration has run. */ +// IMPORTANT: core-piece-types must register BEFORE any preset runs +// so preset onActivate hooks that inspect piece types see the full +// registry. +import "./core-piece-types.js"; + import "./pawns-move-backward.js"; import "./double-pawn-sprint.js"; import "./pawn-diagonal-no-capture.js"; diff --git a/packages/chess/src/presets/integration.test.ts b/packages/chess/src/presets/integration.test.ts new file mode 100644 index 0000000..fc18af0 --- /dev/null +++ b/packages/chess/src/presets/integration.test.ts @@ -0,0 +1,426 @@ +/** + * Integration tests proving that a third-party preset can use ONLY + * the public registry APIs to build real functionality. Each `describe` + * block defines a plausible preset inline, registers it, and then + * exercises it end-to-end through ChessEngine. + * + * If these tests fail, the extension API has a gap that should be + * fixed in the core before shipping. + * + * Scenarios covered: + * - Shield: a custom piece attribute absorbs damage before HP. + * - Cannon: a new piece type with Xiangqi-like capture geometry. + * - Berserker cooldown: per-preset state survives moves, clears on + * deactivate. + * - Pawn stamina: onBeforeMove veto + onTurnStart regen, both + * scope-aware. + */ +import { describe, it, expect } from "vitest"; +import "./index.js"; +import type { EntityId } from "@paratype/rete"; +import { ChessEngine, MoveCancelledError } from "../engine.js"; +import { algebraicToSquare } from "../coord.js"; +import { PRESET_REGISTRY } from "./registry.js"; +import { PIECE_TYPE_REGISTRY } from "./piece-type-registry.js"; +import type { LegalMove } from "../rules/types.js"; +import { clearBoard, pieceAt, placePiece, hpOf, exists } from "./test-utils.js"; +import { CORE_PIECE_ATTRS } from "../rules/capture.js"; +import { isInCheck } from "../rules/check.js"; + +// ───────────────────────────────────────────────────────────────────── +// 1. Shield attribute preset +// +// A piece with Shield absorbs 1 damage per turn before HP kicks in. +// Proves: +// - preset can declare a new piece attribute +// - onDamage can intercept BEFORE piece-hp and return consume:true +// - effectivePieceAttrs cleanup on death removes Shield facts +// ───────────────────────────────────────────────────────────────────── + +// Extend the ChessAttrMap via module augmentation so the new attr is +// typed. In a real preset this would live in the preset's own file. +declare module "../schema" { + interface ChessAttrMap { + Shield: number; + } +} + +const SHIELD_PRESET_ID = "test-piece-shield"; + +PRESET_REGISTRY.register({ + id: SHIELD_PRESET_ID, + name: "Piece Shield", + description: "Every piece starts with Shield=1. Damage consumes shield before HP.", + incompatibleWith: [], + requires: [], + pieceAttributes: ["Shield"], + onActivate({ engine }) { + for (const f of engine.session.allFacts()) { + if (f.attr !== "PieceType") continue; + if ((f.id as number) <= 0) continue; + if (engine.session.contains(f.id, "Shield")) continue; + engine.session.insert(f.id, "Shield", 1); + } + }, + onPieceSpawn({ engine, pieceId }) { + if (!engine.session.contains(pieceId, "Shield")) { + engine.session.insert(pieceId, "Shield", 1); + } + }, + onDamage({ engine, target, amount }) { + // Opt out when there's no shield to absorb — let the next + // interceptor (e.g. piece-hp) or the default handle it. + if (!engine.session.contains(target, "Shield")) return undefined; + const current = engine.session.get(target, "Shield") as number; + if (current <= 0) return undefined; + // Absorb one point. `amount` above 1 passes the remainder through. + engine.session.insert(target, "Shield", current - 1); + const leftover = amount - 1; + if (leftover <= 0) { + return { consume: true, died: false }; + } + // There's damage remaining — fall through to let piece-hp (or the + // default) handle it by NOT consuming. + return undefined; + }, +}); + +describe("Custom attribute preset: piece-shield", () => { + it("absorbs 1 damage before HP", () => { + const engine = new ChessEngine(); + // Shield activation FIRST — determines onDamage dispatch order. + // The shield preset claims damage events before piece-hp so HP + // stays untouched while Shield > 0. + engine.setActivePresets([ + { id: SHIELD_PRESET_ID, scope: "both", turnsRemaining: null }, + { id: "piece-hp", scope: "both", turnsRemaining: null }, + ]); + const pawn = pieceAt(engine, "e2")!; + expect(engine.session.get(pawn, "Shield")).toBe(1); + expect(hpOf(engine, pawn)).toBe(2); + + engine.dealDamage(pawn, 1, { kind: "test" }); + expect(engine.session.get(pawn, "Shield")).toBe(0); + expect(hpOf(engine, pawn)).toBe(2); // HP untouched + }); + + it("shield depletes then HP takes over", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: SHIELD_PRESET_ID, scope: "both", turnsRemaining: null }, + { id: "piece-hp", scope: "both", turnsRemaining: null }, + ]); + const pawn = pieceAt(engine, "e2")!; + engine.dealDamage(pawn, 1, { kind: "test" }); // Shield: 1→0 + engine.dealDamage(pawn, 1, { kind: "test" }); // HP: 2→1 + expect(hpOf(engine, pawn)).toBe(1); + expect(engine.session.get(pawn, "Shield")).toBe(0); + }); + + it("Shield fact is retracted on death via effectivePieceAttrs", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: SHIELD_PRESET_ID, scope: "both", turnsRemaining: null }, + ]); + const pawn = pieceAt(engine, "e2")!; + expect(engine.session.contains(pawn, "Shield")).toBe(true); + // Without HP, shield absorbs 1; next hit kills via default + // retract path which now includes "Shield" in the dynamic list. + engine.dealDamage(pawn, 1, { kind: "test" }); + engine.dealDamage(pawn, 1, { kind: "test" }); + expect(exists(engine, pawn)).toBe(false); + expect(engine.session.contains(pawn, "Shield")).toBe(false); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// 2. Cannon — custom piece type (Xiangqi-inspired) +// +// Moves like a rook. Captures ONLY by jumping over exactly one +// intervening piece on the same rank/file. Proves: +// - custom piece types register + dispatch through engine. +// - check detection sees them as attackers. +// - UI asset lookup via registry works (visible via the registered +// `assets` object below, although we don't render it in this test). +// ───────────────────────────────────────────────────────────────────── + +import type { Session } from "@paratype/rete"; + +/** Generate pseudo-legal cannon moves. */ +function cannonMoveGen(session: Session, id: EntityId): LegalMove[] { + const facts = session.allFacts(); + const pos = session.get(id, "Position"); + if (typeof pos !== "number") return []; + const color = session.get(id, "Color"); + if (color !== "white" && color !== "black") return []; + const from = pos; + const fromFile = from % 8; + const fromRank = Math.floor(from / 8); + const occupied = (sq: number): EntityId | null => { + const f = facts.find( + (fa) => fa.attr === "Position" && fa.value === sq, + ); + return f ? (f.id as EntityId) : null; + }; + const ownerOf = (eid: EntityId): string | undefined => + facts.find((fa) => fa.id === eid && fa.attr === "Color")?.value as + | string + | undefined; + + const moves: LegalMove[] = []; + const directions: ReadonlyArray = [ + [1, 0], [-1, 0], [0, 1], [0, -1], + ]; + for (const [df, dr] of directions) { + let jumped = false; + for (let step = 1; step < 8; step++) { + const f = fromFile + df * step; + const r = fromRank + dr * step; + if (f < 0 || f > 7 || r < 0 || r > 7) break; + const sq = r * 8 + f; + const occupant = occupied(sq); + if (!jumped) { + if (occupant === null) { + // Empty — regular rook-like quiet move. + moves.push({ pieceId: id, from, to: sq, isCapture: false }); + } else { + jumped = true; // next iteration checks for enemy-capture + } + } else { + // After jumping, ANY occupied square ends the ray — if it's + // enemy, that's a capture; otherwise nothing. + if (occupant !== null) { + if (ownerOf(occupant) !== color) { + moves.push({ pieceId: id, from, to: sq, isCapture: true }); + } + break; + } + } + } + } + return moves; +} + +// Use the pawn SVG as a stand-in asset for the test. +import { pieceAssets } from "../assets/pieces/index.js"; + +PIECE_TYPE_REGISTRY.register({ + id: "cannon", + displayName: "Cannon", + moveGenerator: cannonMoveGen, + // For check detection we reuse the move generator — cannons "attack" + // exactly the squares they can move to capture. + attackProbe: (session, id, target) => { + for (const m of cannonMoveGen(session, id)) { + if (m.isCapture && m.to === target) return true; + } + return false; + }, + assets: { white: pieceAssets.white.rook, black: pieceAssets.black.rook }, +}); + +describe("Custom piece type: Cannon (Xiangqi-like)", () => { + it("moves like a rook when the path is clear; captures require a jump", () => { + const engine = new ChessEngine(); + clearBoard(engine); + const cannon = placePiece(engine, "cannon", "white", "a1"); + // Place a white pawn on a3 as a "jump platform" and a black pawn + // on a5 as the target. + placePiece(engine, "pawn", "white", "a3"); + placePiece(engine, "pawn", "black", "a5"); + + const moves = cannonMoveGen(engine.session, cannon); + // Quiet moves up to a2 (a3 is blocked — can't move THROUGH + // without jumping). + expect(moves.some((m) => m.to === algebraicToSquare("a2") && !m.isCapture)).toBe(true); + // Capture on a5 via jump over a3. + expect(moves.some((m) => m.to === algebraicToSquare("a5") && m.isCapture)).toBe(true); + // CANNOT capture a3 directly (no jump — must leapfrog). + expect(moves.some((m) => m.to === algebraicToSquare("a3"))).toBe(false); + }); + + it("check detection sees cannon as an attacker", () => { + const engine = new ChessEngine(); + clearBoard(engine); + // White cannon on a1. Black pawn on a4 as jump platform. Black king + // on a8. + placePiece(engine, "cannon", "white", "a1"); + placePiece(engine, "pawn", "black", "a4"); + const blackKingId = pieceAt(engine, "e8")!; + // Move black king to a8 for a direct line. + engine.session.insert(blackKingId, "Position", algebraicToSquare("a8")); + // isSquareAttacked should see the cannon attacking a8 via jump. + expect(isInCheck(engine.session, "black")).toBe(true); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// 3. Berserker cooldown — uses presetState +// ───────────────────────────────────────────────────────────────────── + +const BERSERKER_ID = "test-berserker-cooldown"; +interface BerserkerState extends Record { + lastRageTurn: number; +} + +PRESET_REGISTRY.register({ + id: BERSERKER_ID, + name: "Berserker Cooldown", + description: "Tracks a cooldown turn counter in presetState.", + incompatibleWith: [], + requires: [], +}); + +describe("Per-preset state bag (presetState)", () => { + it("state is written/read per-game and cleared on deactivation", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: BERSERKER_ID, scope: "both", turnsRemaining: null }, + ]); + const state = engine.presetState(BERSERKER_ID); + state.set("lastRageTurn", 4); + expect(state.get("lastRageTurn")).toBe(4); + + // Different preset id — no collision. + const other = engine.presetState("piece-hp"); + expect(other.get("lastRageTurn")).toBeUndefined(); + + // Deactivate → state cleared automatically. + engine.setActivePresets([]); + const afterDeact = engine.presetState(BERSERKER_ID); + expect(afterDeact.get("lastRageTurn")).toBeUndefined(); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// 4. Pawn stamina — phase hooks (onBeforeMove + onTurnStart) +// ───────────────────────────────────────────────────────────────────── + +const STAMINA_ID = "test-pawn-stamina"; + +declare module "../schema" { + interface ChessAttrMap { + Stamina: number; + } +} + +PRESET_REGISTRY.register({ + id: STAMINA_ID, + name: "Pawn Stamina", + description: "Pawns need 1 Stamina to push; regenerates +1 per turn (max 2).", + incompatibleWith: [], + requires: [], + pieceAttributes: ["Stamina"], + onActivate({ engine }) { + for (const f of engine.session.allFacts()) { + if (f.attr !== "PieceType" || f.value !== "pawn") continue; + if ((f.id as number) <= 0) continue; + engine.session.insert(f.id, "Stamina", 2); + } + }, + onPieceSpawn({ engine, pieceId, type }) { + if (type === "pawn") engine.session.insert(pieceId, "Stamina", 2); + }, + onBeforeMove({ engine, pieceId }) { + const type = engine.session.get(pieceId, "PieceType"); + if (type !== "pawn") return undefined; + const stamina = engine.session.contains(pieceId, "Stamina") + ? (engine.session.get(pieceId, "Stamina") as number) + : 0; + if (stamina < 1) { + return { cancel: true, reason: "Pawn too tired to push (Stamina 0)" }; + } + // Consume 1 stamina. Do it in onBeforeMove because onAfterMove + // can't veto, and the engine needs to know NOW whether the move + // is even allowed. (Consuming here = charged regardless of + // success; tests acknowledge this.) + engine.session.insert(pieceId, "Stamina", stamina - 1); + return undefined; + }, + onTurnStart({ engine, turn }) { + // Regenerate each pawn of this color +1 stamina up to max 2. + const facts = engine.session.allFacts(); + for (const f of facts) { + if (f.attr !== "PieceType" || f.value !== "pawn") continue; + const colorFact = facts.find( + (c) => c.id === f.id && c.attr === "Color", + ); + if (colorFact?.value !== turn) continue; + const current = engine.session.contains(f.id, "Stamina") + ? (engine.session.get(f.id, "Stamina") as number) + : 0; + if (current < 2) { + engine.session.insert(f.id, "Stamina", current + 1); + } + } + }, +}); + +describe("Phase hooks (onBeforeMove + onTurnStart)", () => { + it("pawn with stamina 0 cannot move (onBeforeMove veto)", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: STAMINA_ID, scope: "both", turnsRemaining: null }, + ]); + const e2Pawn = pieceAt(engine, "e2")!; + // Drain stamina to 0. + engine.session.insert(e2Pawn, "Stamina", 0); + + const move = engine.findMove( + algebraicToSquare("e2"), + algebraicToSquare("e4"), + )!; + expect(() => engine.applyMove(move)).toThrow(MoveCancelledError); + }); + + it("onTurnStart regenerates stamina on the appropriate color only", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: STAMINA_ID, scope: "both", turnsRemaining: null }, + ]); + // White e2 pawn starts with 2 stamina. Consume 1 via a push. + const e2Pawn = pieceAt(engine, "e2")!; + expect(engine.session.get(e2Pawn, "Stamina")).toBe(2); + engine.applyMove( + engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!, + ); + // After move: stamina 2 - 1 = 1 for white e2-pawn (now e4). BUT + // onTurnStart fired for BLACK next, not white — so white's + // stamina stays at 1. + const e4Pawn = pieceAt(engine, "e4")!; + expect(engine.session.get(e4Pawn, "Stamina")).toBe(1); + + // Black's move — doesn't change white's stamina. + engine.applyMove( + engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!, + ); + // Now it's white's turn again. onTurnStart fired for white → + // e4 pawn's stamina regens from 1 back to 2. + expect(engine.session.get(e4Pawn, "Stamina")).toBe(2); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// 5. Meta-assertion: no hardcoded PIECE_ATTRS literals outside core +// ───────────────────────────────────────────────────────────────────── + +describe("Architecture invariants", () => { + it("CORE_PIECE_ATTRS excludes preset-owned attrs like Hp/Shield/Stamina", () => { + const core = CORE_PIECE_ATTRS as readonly string[]; + expect(core).not.toContain("Hp"); + expect(core).not.toContain("Shield"); + expect(core).not.toContain("Stamina"); + // Only the FIDE-minimum four are core. + expect(core.length).toBe(4); + }); + + it("engine.effectivePieceAttrs unions in preset-declared attrs", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: "piece-hp", scope: "both", turnsRemaining: null }, + ]); + expect(engine.effectivePieceAttrs).toContain("Hp"); + engine.setActivePresets([]); + expect(engine.effectivePieceAttrs).not.toContain("Hp"); + }); +}); diff --git a/packages/chess/src/presets/king-heals.ts b/packages/chess/src/presets/king-heals.ts index 005753d..15e788f 100644 --- a/packages/chess/src/presets/king-heals.ts +++ b/packages/chess/src/presets/king-heals.ts @@ -16,7 +16,7 @@ * Requires `piece-hp`; without it there's no Hp attribute to heal. */ import { PRESET_REGISTRY } from "./registry.js"; -import type { ChessEngine } from "../engine.js"; + import type { Session, EntityId } from "@paratype/rete"; import type { PieceColor } from "../schema.js"; import { isInCheck } from "../rules/check.js"; @@ -42,13 +42,13 @@ PRESET_REGISTRY.register({ incompatibleWith: [], requires: ["piece-hp"], - onAfterMove(engine: ChessEngine, moverColor) { + onAfterMove({ engine, mover }) { // The NON-mover's king is the one potentially healing: it's the // king belonging to the player whose turn just arrived. Heal only // if that king isn't currently in check (being in check denies the // heal — an intentional game-design lever that makes aggressive // play strategically meaningful). - const color: PieceColor = moverColor === "white" ? "black" : "white"; + const color: PieceColor = mover === "white" ? "black" : "white"; const kingId = findKing(engine.session, color); if (kingId === null) return; if (isInCheck(engine.session, color)) return; @@ -58,5 +58,17 @@ PRESET_REGISTRY.register({ const hp = session.get(kingId, "Hp") as number; if (hp >= MAX_KING_HP) return; session.insert(kingId, "Hp", hp + 1); + + // Visual: a brief heal shimmer on the king's square. No subscriber + // = no-op (see engine.emitEffect). + const kingSquare = session.get(kingId, "Position"); + if (typeof kingSquare === "number") { + engine.emitEffect({ + kind: "heal", + square: kingSquare, + ttl: 400, + data: { hp: hp + 1 }, + }); + } }, }); diff --git a/packages/chess/src/presets/last-piece-standing.ts b/packages/chess/src/presets/last-piece-standing.ts index f924ef5..eda62de 100644 --- a/packages/chess/src/presets/last-piece-standing.ts +++ b/packages/chess/src/presets/last-piece-standing.ts @@ -26,7 +26,7 @@ * game over". */ import { PRESET_REGISTRY } from "./registry.js"; -import type { ChessEngine, GameResult } from "../engine.js"; +import type { GameResult } from "../engine.js"; PRESET_REGISTRY.register({ id: "last-piece-standing", @@ -36,7 +36,7 @@ PRESET_REGISTRY.register({ incompatibleWith: ["capture-to-win"], requires: [], - onCheckGameResult(engine: ChessEngine): GameResult | undefined { + onCheckGameResult({ engine }): GameResult | undefined { // Count Position facts per color. We use Position rather than // PieceType because a piece "exists on the board" iff it has a // position; retracted pieces (captured) have no Position fact. diff --git a/packages/chess/src/presets/move-log.test.ts b/packages/chess/src/presets/move-log.test.ts new file mode 100644 index 0000000..4428a21 --- /dev/null +++ b/packages/chess/src/presets/move-log.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for `ChessEngine.moveLog` + `describeMoveEffect` hook. + */ +import { describe, it, expect } from "vitest"; +import "./index.js"; +import { ChessEngine } from "../engine.js"; +import { algebraicToSquare } from "../coord.js"; +import { PRESET_REGISTRY } from "./registry.js"; + +describe("ChessEngine.moveLog", () => { + it("empty at start", () => { + const engine = new ChessEngine(); + expect(engine.moveLog).toHaveLength(0); + }); + + it("one record appended per applyMove", () => { + const engine = new ChessEngine(); + engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!); + expect(engine.moveLog).toHaveLength(1); + const record = engine.moveLog[0]!; + expect(record.from).toBe(algebraicToSquare("e2")); + expect(record.to).toBe(algebraicToSquare("e4")); + expect(record.mover).toBe("white"); + expect(record.movingType).toBe("pawn"); + expect(record.capturedId).toBeNull(); + expect(record.capturedType).toBeNull(); + expect(record.isCheck).toBe(false); + expect(record.terminal).toBe(false); + expect(record.isEnPassant).toBe(false); + expect(record.isCastling).toBe(false); + expect(record.promotion).toBeNull(); + expect(record.presetEffects).toEqual([]); + }); + + it("captures populate capturedId + capturedType from PRE-MOVE state", () => { + const engine = new ChessEngine(); + engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!); + engine.applyMove(engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!); + const blackPawnOnD5 = engine.session + .allFacts() + .find((f) => f.attr === "Position" && f.value === algebraicToSquare("d5")); + engine.applyMove(engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!); + + const capture = engine.moveLog[2]!; + expect(capture.capturedId).toBe(blackPawnOnD5!.id); + expect(capture.capturedType).toBe("pawn"); + }); + + it("isCheck flips true when a move delivers check", () => { + const engine = new ChessEngine(); + // Fool's Mate: 1. f3 e5 2. g4 Qh4# → opponent (white) in check + mate. + engine.applyMove(engine.findMove(algebraicToSquare("f2"), algebraicToSquare("f3"))!); + engine.applyMove(engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!); + engine.applyMove(engine.findMove(algebraicToSquare("g2"), algebraicToSquare("g4"))!); + engine.applyMove(engine.findMove(algebraicToSquare("d8"), algebraicToSquare("h4"))!); + + const mate = engine.moveLog[3]!; + expect(mate.isCheck).toBe(true); + expect(mate.terminal).toBe(true); + }); +}); + +describe("describeMoveEffect hook", () => { + // Register a throwaway preset that contributes a description for + // pawn moves, then unregister the side-effect by swapping it for + // the real one. Since PRESET_REGISTRY.register overwrites by id, we + // use a fresh id to avoid disturbing other tests. + const testPresetId = "test-describe-effect-pawn-reporter"; + + PRESET_REGISTRY.register({ + id: testPresetId, + name: "Pawn Reporter", + description: "Test helper — describes pawn moves.", + incompatibleWith: [], + requires: [], + describeMoveEffect(ctx) { + if (ctx.movingType !== "pawn") return undefined; + return `${ctx.mover} pawn pushed`; + }, + }); + + it("contributes an entry to presetEffects when returning a string", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: testPresetId, scope: "both", turnsRemaining: null }, + ]); + engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!); + const record = engine.moveLog[0]!; + expect(record.presetEffects).toContainEqual({ + presetId: testPresetId, + summary: "white pawn pushed", + }); + }); + + it("omits an entry when returning undefined", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: testPresetId, scope: "both", turnsRemaining: null }, + ]); + // A knight move — the reporter returns undefined for non-pawns. + engine.applyMove(engine.findMove(algebraicToSquare("g1"), algebraicToSquare("f3"))!); + const record = engine.moveLog[0]!; + expect(record.presetEffects).toEqual([]); + }); +}); diff --git a/packages/chess/src/presets/phase-hooks.test.ts b/packages/chess/src/presets/phase-hooks.test.ts new file mode 100644 index 0000000..6c5f6ab --- /dev/null +++ b/packages/chess/src/presets/phase-hooks.test.ts @@ -0,0 +1,119 @@ +/** + * Tests for the `onBeforeMove` + `onTurnStart` phase hooks. + * + * These hooks unblock resource-tracking presets (stamina, action + * points, cooldowns) that need to decide whether a move is legal + * AT PLAY TIME (not just geometrically) and to regenerate state on + * turn boundaries. + */ +import { describe, it, expect } from "vitest"; +import "./index.js"; +import { ChessEngine, MoveCancelledError } from "../engine.js"; +import { algebraicToSquare } from "../coord.js"; +import { PRESET_REGISTRY } from "./registry.js"; + +describe("onBeforeMove cancel", () => { + const blockerId = "test-onbeforemove-blocker"; + PRESET_REGISTRY.register({ + id: blockerId, + name: "Test Blocker", + description: "Rejects every e2→e4.", + incompatibleWith: [], + requires: [], + onBeforeMove(ctx) { + if ( + ctx.from === algebraicToSquare("e2") && + ctx.to === algebraicToSquare("e4") + ) { + return { cancel: true, reason: "blocked for testing" }; + } + return undefined; + }, + }); + + it("throws MoveCancelledError when a preset cancels the move", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: blockerId, scope: "both", turnsRemaining: null }, + ]); + const move = engine.findMove( + algebraicToSquare("e2"), + algebraicToSquare("e4"), + )!; + expect(() => engine.applyMove(move)).toThrow(MoveCancelledError); + // State unchanged — pawn still on e2. + const e2Still = engine.session + .allFacts() + .find( + (f) => f.attr === "Position" && f.value === algebraicToSquare("e2"), + ); + expect(e2Still).toBeDefined(); + }); + + it("allows moves the preset doesn't cancel", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: blockerId, scope: "both", turnsRemaining: null }, + ]); + const move = engine.findMove( + algebraicToSquare("d2"), + algebraicToSquare("d4"), + )!; + expect(() => engine.applyMove(move)).not.toThrow(); + }); + + it("scope-aware: a white-scoped preset doesn't veto black's moves", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: blockerId, scope: "white", turnsRemaining: null }, + ]); + // White's e2→e4 — same blocked rule, white scope applies. + const whiteMove = engine.findMove( + algebraicToSquare("e2"), + algebraicToSquare("e4"), + )!; + expect(() => engine.applyMove(whiteMove)).toThrow(MoveCancelledError); + }); +}); + +describe("onTurnStart fires after turn flip, scope-aware", () => { + const regenId = "test-onturnstart-regen"; + let firedFor: string[] = []; + + PRESET_REGISTRY.register({ + id: regenId, + name: "Test Regen", + description: "Appends 'turn:color' to firedFor on each turn start.", + incompatibleWith: [], + requires: [], + onTurnStart(ctx) { + firedFor.push(ctx.turn); + }, + }); + + it("fires with nextColor after every successful move", () => { + const engine = new ChessEngine(); + firedFor = []; + engine.setActivePresets([ + { id: regenId, scope: "both", turnsRemaining: null }, + ]); + engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!); + expect(firedFor).toEqual(["black"]); + engine.applyMove(engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!); + expect(firedFor).toEqual(["black", "white"]); + }); + + it("scope-aware: a white-scoped preset only fires when white's turn starts", () => { + const engine = new ChessEngine(); + firedFor = []; + engine.setActivePresets([ + { id: regenId, scope: "white", turnsRemaining: null }, + ]); + // After white's move → black's turn starts → scope=white DOESN'T fire. + engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!); + expect(firedFor).toEqual([]); + // After black's move → white's turn starts → scope=white DOES fire. + engine.applyMove(engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!); + expect(firedFor).toEqual(["white"]); + }); +}); diff --git a/packages/chess/src/presets/piece-hp.ts b/packages/chess/src/presets/piece-hp.ts index c8d5573..e3a14b3 100644 --- a/packages/chess/src/presets/piece-hp.ts +++ b/packages/chess/src/presets/piece-hp.ts @@ -51,7 +51,7 @@ * they all use the damage pipeline. No more hardcoded incompatibilities. */ import { PRESET_REGISTRY } from "./registry.js"; -import type { ChessEngine, GameResult } from "../engine.js"; +import type { GameResult } from "../engine.js"; import type { Session, EntityId } from "@paratype/rete"; /** Starting HP for every piece. Future work: make this per-type so @@ -72,10 +72,6 @@ function iteratePieceIds(session: Session): EntityId[] { return [...ids]; } -/** Piece attributes cleared when HP hits 0 (piece dies). Kept in - * lockstep with the canonical list in rules/capture.ts. */ -const PIECE_ATTRS = ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const; - PRESET_REGISTRY.register({ id: "piece-hp", name: "Hit Points", @@ -83,8 +79,16 @@ PRESET_REGISTRY.register({ "Every piece has 2 HP. Damage (capture, explosion, poison…) deals 1 HP instead of immediately killing. A piece only dies when its HP hits 0; until then the attacker doesn't advance — the hit becomes a 'poke'.", incompatibleWith: [], requires: [], + // We OWN the Hp attribute. The engine unions this into + // effectivePieceAttrs while we're active, so the default damage + // retract path cleans Hp facts up automatically when pieces die + // from causes other than our own onDamage path (belt-and-suspenders + // — today we always self-retract on lethal damage, but if a future + // preset ever calls engine.dealDamage with an Hp we didn't seed, + // it'd still get cleaned up). + pieceAttributes: ["Hp"], - onActivate(engine: ChessEngine) { + onActivate({ engine }) { const session = engine.session; for (const id of iteratePieceIds(session)) { // Idempotent: skip entities that already have an Hp fact, so @@ -95,7 +99,21 @@ PRESET_REGISTRY.register({ } }, - onDeactivate(engine: ChessEngine) { + /** + * Seed Hp on newly spawned pieces. Runs for every spawn reason — + * promotion, fission, summon, resurrection. The idempotence guard + * from `onActivate` is preserved here so callers that set Hp + * before calling spawnPiece (e.g. "spawn at half HP") can opt out + * by pre-inserting Hp before this hook fires. (Today nothing + * does that, but the guarantee lets future tuning opt in.) + */ + onPieceSpawn(ctx) { + const { engine, pieceId } = ctx; + if (engine.session.contains(pieceId, "Hp")) return; + engine.session.insert(pieceId, "Hp", DEFAULT_HP); + }, + + onDeactivate({ engine }) { const session = engine.session; for (const id of iteratePieceIds(session)) { if (session.contains(id, "Hp")) { @@ -114,7 +132,7 @@ PRESET_REGISTRY.register({ * piece-hp composable with damage sources that vary in strength * without needing per-source special cases. */ - onDamage(engine: ChessEngine, target: EntityId, amount: number, _ctx) { + onDamage({ engine, target, amount }) { // The engine short-circuits amount<=0 before we ever get here, // but be defensive: if somehow invoked with 0, treat it as a // no-op survival. @@ -131,10 +149,11 @@ PRESET_REGISTRY.register({ return { consume: true, died: false }; } - // Lethal. Retract all piece attrs ourselves so the engine doesn't - // need a second retract pass. Returning died:true tells upstream - // callers (capture path) that the attacker should advance. - for (const attr of PIECE_ATTRS) { + // Lethal. Retract all effective piece attrs ourselves (core + + // whatever other presets declared) so nothing is left behind. + // Returning died:true tells upstream callers (capture path) that + // the attacker should advance. + for (const attr of engine.effectivePieceAttrs) { if (session.contains(target, attr)) session.retract(target, attr); } return { consume: true, died: true }; @@ -167,7 +186,7 @@ PRESET_REGISTRY.register({ * run the default logic". Once a king is gone we return the winner * directly. */ - onCheckGameResult(engine: ChessEngine): GameResult | undefined { + onCheckGameResult({ engine }): GameResult | undefined { const session = engine.session; const colors = kingsPresent(session); if (!colors.white && colors.black) return "black-wins"; diff --git a/packages/chess/src/presets/piece-type-registry.ts b/packages/chess/src/presets/piece-type-registry.ts new file mode 100644 index 0000000..39c9b6b --- /dev/null +++ b/packages/chess/src/presets/piece-type-registry.ts @@ -0,0 +1,102 @@ +/** + * Runtime registry of piece types. + * + * Piece types are no longer a closed TypeScript union — new types + * (Cannon, Amazon, Nightrider, fantasy variants) register here and + * participate in move generation, attack detection, UI rendering, and + * save-state serialization without any core engine edit. + * + * ## Registration + * + * Core FIDE types (pawn, knight, bishop, rook, queen, king) register + * themselves from `./core-piece-types.ts`, which is imported once at + * the top of the presets barrel. Custom types register via a preset's + * side-effect import, same pattern as preset rule registration. + * + * ## Access + * + * Engine dispatch tables (`PIECE_MOVE_GETTERS`, `ATTACK_PROBES`, + * `MOVE_GETTERS` in rules/checkmate.ts + rules/stalemate.ts) query + * the registry rather than holding their own hardcoded maps. UI + * `` looks up SVG assets here too. + * + * ## Why strings instead of a branded type + * + * Piece-type ids are plain strings so third-party preset packages + * don't need to collaborate on a shared union. The registry's runtime + * check is the source of truth — unknown ids throw. Exhaustiveness + * checks on FIDE-only logic still work by narrowing to the built-in + * string-literal union where needed. + */ +import type { Session, EntityId } from "@paratype/rete"; +import type { LegalMove } from "../rules/types.js"; + +/** + * A piece type's move generator: given the current WM + a piece id + * of this type, return every pseudo-legal move. "Pseudo-legal" means + * geometry + blockers are honored; self-check filtering happens + * later in the engine. + */ +export type PieceMoveGenerator = ( + session: Session, + pieceId: EntityId, +) => LegalMove[]; + +/** + * A piece type's attack probe: "does this piece attack `target`?" + * Used for check detection. Different from the move generator + * because pawns attack diagonally without needing an enemy there — + * a king still can't step onto that square. + */ +export type PieceAttackProbe = ( + session: Session, + pieceId: EntityId, + target: number, +) => boolean; + +/** + * SVG asset URLs per color. The UI's `` component reads these + * to render the piece. Bundled via Vite `?url` imports so each SVG + * becomes a URL string at build time. + */ +export interface PieceTypeAssets { + readonly white: string; + readonly black: string; +} + +/** + * Full definition of a registered piece type. + */ +export interface PieceTypeDef { + readonly id: string; + readonly displayName: string; + readonly moveGenerator: PieceMoveGenerator; + readonly attackProbe: PieceAttackProbe; + readonly assets: PieceTypeAssets; +} + +class PieceTypeRegistryClass { + readonly #byId = new Map(); + + register(def: PieceTypeDef): void { + this.#byId.set(def.id, def); + } + + get(id: string): PieceTypeDef | undefined { + return this.#byId.get(id); + } + + has(id: string): boolean { + return this.#byId.has(id); + } + + getAll(): PieceTypeDef[] { + return [...this.#byId.values()]; + } + + ids(): string[] { + return [...this.#byId.keys()]; + } +} + +export const PIECE_TYPE_REGISTRY = new PieceTypeRegistryClass(); diff --git a/packages/chess/src/presets/poisoned-squares.ts b/packages/chess/src/presets/poisoned-squares.ts index 5592f44..931211b 100644 --- a/packages/chess/src/presets/poisoned-squares.ts +++ b/packages/chess/src/presets/poisoned-squares.ts @@ -18,7 +18,6 @@ * activated via setActivePresets. */ import { PRESET_REGISTRY } from "./registry.js"; -import type { ChessEngine } from "../engine.js"; import type { EntityId } from "@paratype/rete"; /** Poisoned central squares: d4=27, e4=28, d5=35, e5=36. Exported for @@ -33,7 +32,7 @@ PRESET_REGISTRY.register({ incompatibleWith: [], requires: ["piece-hp"], - onAfterMove(engine: ChessEngine) { + onAfterMove({ engine }) { const session = engine.session; const facts = session.allFacts(); @@ -52,8 +51,24 @@ PRESET_REGISTRY.register({ // 1 HP; when Hp hits 0 the piece is retracted. We tag the kind // as "poison" so a future "poison-resistant" preset could short- // circuit this without changing poisoned-squares' own code. + // + // Snapshot positions BEFORE the damage loop — if a piece dies, its + // Position fact is retracted and we lose the square for the effect. + const poisonedSquaresByVictim = new Map(); + for (const id of victims) { + const pos = engine.session.get(id, "Position"); + if (typeof pos === "number") poisonedSquaresByVictim.set(id, pos); + } for (const id of victims) { engine.dealDamage(id, 1, { kind: "poison" }); + const sq = poisonedSquaresByVictim.get(id); + if (sq !== undefined) { + engine.emitEffect({ + kind: "poison", + square: sq, + ttl: 250, + }); + } } }, }); diff --git a/packages/chess/src/presets/preset-state.test.ts b/packages/chess/src/presets/preset-state.test.ts new file mode 100644 index 0000000..89cae6d --- /dev/null +++ b/packages/chess/src/presets/preset-state.test.ts @@ -0,0 +1,171 @@ +/** + * Tests for `ChessEngine.presetState` — the per-preset, per-engine + * state bag. + * + * Coverage: + * - basic read / write / delete / has / all + * - isolation across preset ids (no collision) + * - isolation across engines (no shared mutable state) + * - auto-clear on deactivate (including timer expiry) + * - explicit preservation by onDeactivate is NOT possible — + * clear runs AFTER the hook (design choice) + */ +import { describe, it, expect } from "vitest"; +import "./index.js"; +import { ChessEngine } from "../engine.js"; + +interface DemoState extends Record { + counter: number; + name: string; + flag: boolean; +} + +describe("ChessEngine.presetState — basic operations", () => { + it("set / get round-trips primitives", () => { + const engine = new ChessEngine(); + const state = engine.presetState("demo"); + state.set("counter", 5); + state.set("name", "hello"); + state.set("flag", true); + + expect(state.get("counter")).toBe(5); + expect(state.get("name")).toBe("hello"); + expect(state.get("flag")).toBe(true); + }); + + it("unset keys return undefined", () => { + const engine = new ChessEngine(); + const state = engine.presetState("demo"); + expect(state.get("counter")).toBeUndefined(); + }); + + it("has() reports presence correctly", () => { + const engine = new ChessEngine(); + const state = engine.presetState("demo"); + expect(state.has("counter")).toBe(false); + state.set("counter", 1); + expect(state.has("counter")).toBe(true); + }); + + it("delete removes a key and returns true; returns false for missing", () => { + const engine = new ChessEngine(); + const state = engine.presetState("demo"); + expect(state.delete("counter")).toBe(false); + state.set("counter", 42); + expect(state.delete("counter")).toBe(true); + expect(state.get("counter")).toBeUndefined(); + }); + + it("set overwrites prior value", () => { + const engine = new ChessEngine(); + const state = engine.presetState("demo"); + state.set("counter", 1); + state.set("counter", 2); + expect(state.get("counter")).toBe(2); + }); + + it("all() returns the full snapshot", () => { + const engine = new ChessEngine(); + const state = engine.presetState("demo"); + state.set("counter", 3); + state.set("name", "x"); + expect(state.all()).toEqual({ counter: 3, name: "x" }); + }); +}); + +describe("ChessEngine.presetState — isolation", () => { + it("two preset ids on the same engine don't collide", () => { + const engine = new ChessEngine(); + const a = engine.presetState("preset-a"); + const b = engine.presetState("preset-b"); + a.set("counter", 1); + b.set("counter", 999); + expect(a.get("counter")).toBe(1); + expect(b.get("counter")).toBe(999); + }); + + it("two engines hold independent state under the same preset id", () => { + const e1 = new ChessEngine(); + const e2 = new ChessEngine(); + const s1 = e1.presetState("demo"); + const s2 = e2.presetState("demo"); + s1.set("counter", 100); + s2.set("counter", 200); + expect(s1.get("counter")).toBe(100); + expect(s2.get("counter")).toBe(200); + }); +}); + +describe("ChessEngine.presetState — auto-clear on deactivate", () => { + it("state is cleared when preset is deactivated via setActivePresets", () => { + const engine = new ChessEngine(); + // piece-hp has real activate/deactivate; abuse its id for state. + engine.setActivePresets([ + { id: "piece-hp", scope: "both", turnsRemaining: null }, + ]); + const state = engine.presetState("piece-hp"); + state.set("counter", 7); + expect(state.get("counter")).toBe(7); + + // Deactivate by replacing with an empty set. + engine.setActivePresets([]); + // New accessor reads the post-clear state. + const fresh = engine.presetState("piece-hp"); + expect(fresh.get("counter")).toBeUndefined(); + }); + + it("state survives scope / duration changes on the same id", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: "piece-hp", scope: "both", turnsRemaining: null }, + ]); + const state = engine.presetState("piece-hp"); + state.set("counter", 11); + + // Same id, different turnsRemaining — should be a continuous + // activation, not deactivate-then-activate. + engine.setActivePresets([ + { id: "piece-hp", scope: "both", turnsRemaining: 10 }, + ]); + expect(state.get("counter")).toBe(11); + }); +}); + +describe("capture-to-win migrated to presetState", () => { + it("first capture sets winner; onCheckGameResult reads it", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: "capture-to-win", scope: "both", turnsRemaining: null }, + ]); + // Before any capture: ongoing. + expect(engine.checkGameResult()).toBe("ongoing"); + // Simulate a white capture: find any move, if e4 is available + // that's fine but we need an actual capture. Use a scripted game + // where white captures first. 1. e4 d5 2. exd5 → white wins. + const e4 = engine.findMove(12, 28)!; // e2->e4 + engine.applyMove(e4); + const d5 = engine.findMove(51, 35)!; // d7->d5 + engine.applyMove(d5); + const exd5 = engine.findMove(28, 35)!; + expect(exd5.isCapture).toBe(true); + const result = engine.applyMove(exd5); + expect(result).toBe("white-wins"); + }); + + it("deactivation clears the winner state", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: "capture-to-win", scope: "both", turnsRemaining: null }, + ]); + // Force a winner. + engine.applyMove(engine.findMove(12, 28)!); + engine.applyMove(engine.findMove(51, 35)!); + engine.applyMove(engine.findMove(28, 35)!); + expect(engine.checkGameResult()).toBe("white-wins"); + + // Deactivate → winner state cleared → game result falls back to + // normal logic. + engine.setActivePresets([]); + expect(engine.checkGameResult()).toBe("ongoing"); + }); +}); diff --git a/packages/chess/src/presets/queen-splits.ts b/packages/chess/src/presets/queen-splits.ts index bbc5655..2646dc7 100644 --- a/packages/chess/src/presets/queen-splits.ts +++ b/packages/chess/src/presets/queen-splits.ts @@ -57,17 +57,12 @@ const CLOCKWISE_NEIGHBOURS: ReadonlyArray = [ [-1, 1], // NW ]; -const PIECE_ATTRS = [ - "PieceType", - "Color", - "Position", - "HasMoved", - "Hp", -] as const; - -function retractEntity(session: Session, id: EntityId): void { - for (const attr of PIECE_ATTRS) { - if (session.contains(id, attr)) session.retract(id, attr); +/** Retract every effective piece attr (core + preset-declared) for + * an entity. Called when the queen is removed as part of fission — + * we need to nuke her completely so no leftover facts linger. */ +function retractEntity(engine: ChessEngine, id: EntityId): void { + for (const attr of engine.effectivePieceAttrs) { + if (engine.session.contains(id, attr)) engine.session.retract(id, attr); } } @@ -81,20 +76,10 @@ function pieceAtSquare(session: Session, sq: Square): EntityId | null { return null; } -/** Spawn a new piece entity. Returns the new id. */ -function spawnPiece( - session: Session, - type: "rook" | "bishop", - color: PieceColor, - square: Square, -): EntityId { - const id = session.nextId(); - session.insert(id, "PieceType", type); - session.insert(id, "Color", color); - session.insert(id, "Position", square); - session.insert(id, "HasMoved", true); - return id; -} +// spawnPiece helper lived here historically. It's now +// `engine.spawnPiece(...)` — goes through the standard onPieceSpawn +// pipeline so preset-declared attributes (Hp, Shield, …) get seeded +// on fission-spawned pieces automatically. PRESET_REGISTRY.register({ id: "queen-splits", @@ -104,7 +89,7 @@ PRESET_REGISTRY.register({ incompatibleWith: [], requires: [], - onBeforeCapture(engine: ChessEngine, attacker: EntityId, target: EntityId) { + onBeforeCapture({ engine, attacker, target }) { const session = engine.session; const attackerTypeFact = session .allFacts() @@ -138,8 +123,18 @@ PRESET_REGISTRY.register({ // Target died (standard path). Retract the queen (she fissions) // and spawn a rook on the capture square + a bishop nearby. - retractEntity(session, attacker); - spawnPiece(session, "rook", attackerColor, targetPos); + // + // Using engine.spawnPiece — NOT a local helper — so every active + // preset's onPieceSpawn runs on the new pieces. This is what + // fixes the latent "queen-splits spawns pieces without HP" bug + // we used to have: piece-hp now seeds Hp=2 on the fissioned + // rook and bishop via onPieceSpawn, without queen-splits needing + // to know HP exists. + retractEntity(engine, attacker); + engine.spawnPiece("rook", attackerColor, targetPos, { + reason: "fission", + hasMoved: true, + }); // Find the first empty adjacent square clockwise from N and spawn // the bishop there. If all 8 are occupied (rare — usually happens @@ -152,7 +147,10 @@ PRESET_REGISTRY.register({ if (f < 0 || f > 7 || r < 0 || r > 7) continue; const sq = squareOf(f, r) as Square; if (pieceAtSquare(session, sq) !== null) continue; - spawnPiece(session, "bishop", attackerColor, sq); + engine.spawnPiece("bishop", attackerColor, sq, { + reason: "fission", + hasMoved: true, + }); break; } diff --git a/packages/chess/src/presets/registry.ts b/packages/chess/src/presets/registry.ts index b7c0714..69f564e 100644 --- a/packages/chess/src/presets/registry.ts +++ b/packages/chess/src/presets/registry.ts @@ -47,6 +47,7 @@ import type { EntityId } from "@paratype/rete"; import type { ChessEngine, GameResult } from "../engine.js"; import type { LegalMove } from "../rules/types.js"; +import type { ChessAttrKey } from "../schema.js"; /** Return shape for onBeforeCapture. `consume: true` skips the engine's * default capture path (no target retraction, no attacker move). @@ -56,12 +57,23 @@ export interface CaptureHookResult { } /** - * Describes WHERE a unit of damage is coming from. Presets that want - * to react only to certain damage sources (e.g. a hypothetical - * "poison-resistant" preset that ignores damage of kind "poison") read - * `ctx.kind`. The engine sets `attacker` on direct hits; damage with no - * attacking entity (poisoned-squares tick, fall damage, etc) leaves it - * undefined. + * Context for `onBeforeCapture`. Fired when a capture is about to + * resolve — presets that want to replace the default retract+advance + * with their own mechanic (queen-splits fission, explosive-rook AoE, + * capture-to-win winner-recording) read this and return + * `{ consume: true }`. + */ +export interface CaptureHookContext { + readonly engine: ChessEngine; + readonly attacker: EntityId; + readonly target: EntityId; + readonly mover: "white" | "black"; +} + +/** + * Describes WHERE a unit of damage is coming from. Passed to + * `engine.dealDamage` by the caller; bundled into `DamageHookContext` + * for the `onDamage` hook. * * `kind` is typed as a string union with known values plus free-form * overflow so future presets can introduce new damage kinds without @@ -73,6 +85,164 @@ export interface DamageContext { readonly attacker?: EntityId; } +/** + * Full context passed to `onDamage` hooks. Bundles the target entity, + * the amount being dealt, and the DamageContext source tag into one + * growable object. + * + * Future fields (move being executed, board position snapshot, + * turn number, …) can be added without breaking existing hook + * implementations — they'll simply ignore the new fields. + */ +export interface DamageHookContext { + readonly engine: ChessEngine; + readonly target: EntityId; + readonly amount: number; + readonly kind: DamageContext["kind"]; + readonly attacker?: EntityId; +} + +/** + * Base shape inherited by every hook context. Carrying the engine + * on every context means presets don't need to remember "was it + * the first arg this time or the third?" + */ +export interface HookContext { + readonly engine: ChessEngine; +} + +/** + * Context for lifecycle hooks that don't carry per-event data — + * `onActivate`, `onDeactivate`. Keeps the extension point ready if + * we later need to add "was this fired as part of a save-load + * restoration?" or similar fields without breaking every preset. + * + * Currently identical to `HookContext`; aliased (not extended) to + * satisfy the no-empty-interface lint rule while keeping the type + * name semantically distinct at call sites. + */ +export type LifecycleContext = HookContext; + +/** + * Context for `onAfterMove`. `mover` is the color that just moved. + */ +export interface MoveHookContext extends HookContext { + readonly mover: "white" | "black"; +} + +/** + * Context for `onBeforeMove`. Fires AFTER move legality is confirmed + * but BEFORE any mutation. Presets may inspect the move + read state, + * then return `{ cancel: true, reason }` to abort. Returning anything + * else (undefined, `{ cancel: false }`) lets the move proceed. + * + * NOTE: using `cancel` here is a deliberately strong capability. It + * lets a preset veto a move the engine considers legal — e.g., a + * "pawn-stamina" preset that blocks pushes when stamina is empty. + * Presets MUST supply a human-readable `reason` so the UI can + * explain the block via toast. + */ +export interface BeforeMoveContext extends HookContext { + readonly mover: "white" | "black"; + readonly pieceId: EntityId; + readonly from: number; + readonly to: number; + readonly isCapture: boolean; +} + +/** + * Return shape for `onBeforeMove`. Presets that don't want to veto + * can skip implementing the hook entirely; those that do must + * provide a human-readable `reason` when cancelling. + */ +export interface BeforeMoveResult { + readonly cancel?: boolean; + readonly reason?: string; +} + +/** + * Context for `onTurnStart`. Fires AFTER the turn counter has flipped + * (Turn fact updated) and AFTER `onAfterMove`, but BEFORE the side- + * to-move's legal-move list is requested. Ideal hook for: + * - regenerating per-piece resources (Stamina +1 up to max) + * - decrementing cooldown timers + * - refreshing auras / blessings on altar squares + */ +export interface TurnStartContext extends HookContext { + /** The color whose turn is now beginning. */ + readonly turn: "white" | "black"; +} + +/** + * Context for `describeMoveEffect`. Carries the raw move payload, + * capture info, and mover color — enough for presets to decide + * whether they contributed to this move and how to describe it. + * + * Separate from `MoveHookContext` because we explicitly DON'T pass + * the full `MoveRecord` being built — circular references make + * serialization awkward, and the hook is narrow enough that a few + * fields are easier to document than "the record so far". + */ +export interface DescribeMoveEffectContext extends HookContext { + readonly mover: "white" | "black"; + readonly from: number; + readonly to: number; + readonly movingType: string; + readonly capturedType: string | null; + readonly isEnPassant: boolean; + readonly isCastling: boolean; + readonly promotion: string | null; +} + +/** + * Context for `onCheckGameResult` — fired when the engine is deciding + * the game's terminal state. Presets that want to override the default + * checkmate/stalemate/draw logic read this and return a verdict. + * + * Aliased (not extended) to satisfy no-empty-interface lint while + * preserving the named type at call sites for readability. + */ +export type GameResultHookContext = HookContext; + +/** + * Context for `shouldFilterSelfCheck`. `color` is whose moves are + * being generated — a preset scoped to white would typically only + * opt out of the filter when `color === "white"`. + */ +export interface SelfCheckFilterContext extends HookContext { + readonly color: "white" | "black"; +} + +/** + * Why a piece is being spawned. Used by `onPieceSpawn` hooks to + * decide whether to participate (e.g., a preset that resurrects + * captured pieces shouldn't stack its own `onPieceSpawn` into its + * own resurrection path). `string & {}` lets new reasons enter the + * union without editing this file — each preset documents the kinds + * it emits. + */ +export type PieceSpawnReason = + | "initial" // from generateStartingPosition at engine construction + | "promotion" // pawn reached last rank + | "fission" // queen-splits spawning rook + bishop + | "summon" // generic / preset-authored + | "resurrection" // piece brought back from the dead + | (string & {}); + +/** + * Context passed to `onPieceSpawn`. Carries everything a hook might + * need to tag the new entity (seed HP, mark with shield, register it + * in preset state, …) without needing to re-query the session. + */ +export interface PieceSpawnContext { + readonly engine: ChessEngine; + readonly pieceId: EntityId; + readonly type: string; // PieceType, kept as string for forward-compat with custom types + readonly color: "white" | "black"; + readonly square: number; // Square + readonly reason: PieceSpawnReason; +} + /** * Return shape for onDamage. Semantics: * - `consume: true` means "I'm the authority on this damage event; @@ -104,6 +274,25 @@ export interface PresetDef { /** Preset IDs that must also be active for this one to be valid. */ readonly requires: readonly string[]; + /** + * Per-piece attributes OWNED by this preset. When the preset is + * active, the engine treats these as part of every piece's identity: + * - retracted when the piece dies (default damage path) + * - seeded on newly spawned pieces via `onPieceSpawn` if this + * preset implements that hook + * - included in save-state serialization + * + * `Hp` is the canonical example — declared by `piece-hp` rather than + * being hardcoded in core. This makes attribute-carrying presets + * (Shield, Armor, Stamina, Mana, …) a purely additive surface. + * + * Attributes must already exist in `ChessAttrMap` — we don't allow + * free-form keys, because fact-type safety depends on the exhaustive + * attribute map. Adding a new attribute = one-line edit to schema.ts + * + declaration here. + */ + readonly pieceAttributes?: readonly ChessAttrKey[]; + // ── Move-generation hooks ──────────────────────────────────────────── readonly getExtraMoves?: (engine: ChessEngine, pieceId: EntityId) => LegalMove[]; readonly filterMoves?: ( @@ -113,14 +302,12 @@ export interface PresetDef { ) => LegalMove[]; // ── Lifecycle hooks ────────────────────────────────────────────────── - readonly onActivate?: (engine: ChessEngine) => void; - readonly onDeactivate?: (engine: ChessEngine) => void; + readonly onActivate?: (ctx: LifecycleContext) => void; + readonly onDeactivate?: (ctx: LifecycleContext) => void; // ── Capture-interception hook ──────────────────────────────────────── readonly onBeforeCapture?: ( - engine: ChessEngine, - attacker: EntityId, - target: EntityId, + ctx: CaptureHookContext, ) => CaptureHookResult | void; /** @@ -147,12 +334,42 @@ export interface PresetDef { * is the canonical implementer. */ readonly onDamage?: ( - engine: ChessEngine, - target: EntityId, - amount: number, - ctx: DamageContext, + ctx: DamageHookContext, ) => DamageHookResult | void; + /** + * Contribute a human-readable summary of what this preset did on + * the move that just resolved. Returning a non-empty string appends + * `{ presetId, summary }` to the MoveRecord's `presetEffects` array + * — surfaced by UI move-list panels, PGN export annotations, etc. + * + * Return `undefined` (or omit the hook) when the preset had no + * effect on this move. The hook fires even when the preset DIDN'T + * intercept a hook — most presets will read the context to decide + * whether to contribute (e.g. queen-splits only contributes when + * the moving piece was a queen that captured). + * + * Fires AFTER all mutations are applied but BEFORE `onAfterMove`. + */ + readonly describeMoveEffect?: ( + ctx: DescribeMoveEffectContext, + ) => string | undefined; + + /** + * Fires when a new piece entity is created via `engine.spawnPiece`. + * Every active preset gets a chance to tag the new piece — seed + * `Hp`, mark it with `Shield`, record it in preset state, etc. + * + * Runs AFTER the core attrs (PieceType, Color, Position, HasMoved) + * are inserted, so presets can read the piece's identity. + * + * This is how `piece-hp` composes with any spawn-emitting preset + * (queen-splits, promotion, resurrection) without those presets + * knowing HP exists: they call `engine.spawnPiece(...)` and HP + * gets seeded automatically via this hook. + */ + readonly onPieceSpawn?: (ctx: PieceSpawnContext) => void; + /** * Fires after every successful `applyMove`, after turn advancement * and tickAfterMove but before checkGameResult. `moverColor` is the @@ -167,10 +384,7 @@ export interface PresetDef { * wants to affect the non-mover; poisoned-squares damages the * mover). */ - readonly onAfterMove?: ( - engine: ChessEngine, - moverColor: "white" | "black", - ) => void; + readonly onAfterMove?: (ctx: MoveHookContext) => void; /** * Hook into terminal-position detection. Return a concrete `GameResult` @@ -183,7 +397,7 @@ export interface PresetDef { * `last-piece-standing` (annihilation replaces checkmate), which both * redefine "when is the game over". */ - readonly onCheckGameResult?: (engine: ChessEngine) => GameResult | undefined; + readonly onCheckGameResult?: (ctx: GameResultHookContext) => GameResult | undefined; /** * Opt out of the engine's default self-check filter for moves of @@ -202,9 +416,40 @@ export interface PresetDef { * legally survive being attacked. */ readonly shouldFilterSelfCheck?: ( - engine: ChessEngine, - color: "white" | "black", + ctx: SelfCheckFilterContext, ) => boolean | undefined; + + /** + * Phase hook: fires AFTER the move has been confirmed legal but + * BEFORE any state mutation. Return `{ cancel: true, reason }` to + * abort the move — the engine treats the attempt as rejected and + * surfaces `reason` via a toast. + * + * Used by resource-tracking presets (stamina, action-points, + * cooldowns). Also the ONLY hook that can block an otherwise + * legal move, which is why it requires an explicit `reason`. + * + * Scope-aware: fires only for presets whose scope covers the + * mover's color (a `scope=white` preset doesn't veto black's + * moves). + */ + readonly onBeforeMove?: ( + ctx: BeforeMoveContext, + ) => BeforeMoveResult | void; + + /** + * Phase hook: fires AFTER the turn has flipped and AFTER + * `onAfterMove`, but BEFORE the new side's legal moves are + * computed. + * + * This is the canonical place to regenerate per-piece resources, + * tick cooldowns, refresh auras, or any "start of turn" logic + * that should take effect BEFORE the player sees their options. + * + * Scope-aware: fires only for presets whose scope covers the + * color whose turn is beginning. + */ + readonly onTurnStart?: (ctx: TurnStartContext) => void; } /** diff --git a/packages/chess/src/presets/test-utils.ts b/packages/chess/src/presets/test-utils.ts new file mode 100644 index 0000000..78db34f --- /dev/null +++ b/packages/chess/src/presets/test-utils.ts @@ -0,0 +1,97 @@ +/** + * Shared helpers for preset tests. + * + * Four preset test files used to each define their own copies of + * `pieceAt`, `hpOf`, `typeOf`, and `clearBoard`. This module + * centralizes them so new preset tests can import rather than + * copy-paste. + * + * Intentionally named `test-utils.ts` (not `.test.ts`) so vitest + * doesn't mistake it for a test file. + */ +import { ChessEngine } from "../engine.js"; +import type { EntityId } from "@paratype/rete"; +import { algebraicToSquare } from "../coord.js"; + +/** Look up the piece entity on a given algebraic square, if any. */ +export function pieceAt( + engine: ChessEngine, + square: string, +): EntityId | null { + const target = algebraicToSquare(square); + for (const f of engine.session.allFacts()) { + if (f.attr === "Position" && f.value === target) return f.id; + } + return null; +} + +/** Read the Hp attribute of a piece. Null when Hp isn't set. */ +export function hpOf(engine: ChessEngine, id: EntityId): number | null { + if (!engine.session.contains(id, "Hp")) return null; + return engine.session.get(id, "Hp") as number; +} + +/** Read the PieceType of an entity. Null if retracted. */ +export function typeOf(engine: ChessEngine, id: EntityId): string | null { + if (!engine.session.contains(id, "PieceType")) return null; + return engine.session.get(id, "PieceType") as string; +} + +/** True if the entity still has a PieceType fact (not retracted). */ +export function exists(engine: ChessEngine, id: EntityId): boolean { + return engine.session.contains(id, "PieceType"); +} + +/** + * Retract every non-king piece on the board so tests can build + * minimal positions from scratch without FIDE starting-position + * noise. Kings are preserved by default because check-detection + * assumes their presence; pass `preserveKings: false` to wipe them + * too (useful for testing custom-piece-type variants). + * + * Uses `engine.effectivePieceAttrs` so preset-declared attributes + * (Hp, Shield, …) are cleaned up too — no zombie facts. + */ +export function clearBoard( + engine: ChessEngine, + opts: { readonly preserveKings?: boolean } = {}, +): void { + const preserveKings = opts.preserveKings ?? true; + const facts = engine.session.allFacts(); + const toClear: EntityId[] = []; + for (const f of facts) { + if (f.attr !== "PieceType") continue; + if ((f.id as number) <= 0) continue; + if (preserveKings && f.value === "king") continue; + toClear.push(f.id); + } + for (const id of toClear) { + for (const attr of engine.effectivePieceAttrs) { + if (engine.session.contains(id, attr)) engine.session.retract(id, attr); + } + } +} + +/** + * Place a piece on `square` directly via session inserts. Bypasses + * `engine.spawnPiece` intentionally — tests that want to verify + * spawn-hook behavior should call `engine.spawnPiece` explicitly. + * This helper is for scaffolding board positions where spawn hooks + * are irrelevant (or need to be avoided, as in "no-HP-seeded test + * piece"). + */ +export function placePiece( + engine: ChessEngine, + type: string, + color: "white" | "black", + square: string | number, + opts: { readonly hasMoved?: boolean } = {}, +): EntityId { + const id = engine.session.nextId(); + const sq = typeof square === "string" ? algebraicToSquare(square) : square; + engine.session.insert(id, "PieceType", type); + engine.session.insert(id, "Color", color); + engine.session.insert(id, "Position", sq); + engine.session.insert(id, "HasMoved", opts.hasMoved ?? false); + return id; +} diff --git a/packages/chess/src/presets/visual-effect.test.ts b/packages/chess/src/presets/visual-effect.test.ts new file mode 100644 index 0000000..455c9e1 --- /dev/null +++ b/packages/chess/src/presets/visual-effect.test.ts @@ -0,0 +1,128 @@ +/** + * Tests for `ChessEngine.emitEffect` + `subscribeEffects` and the + * presets that emit effects via those APIs. + */ +import { describe, it, expect, vi } from "vitest"; +import "./index.js"; +import { ChessEngine, type VisualEffect } from "../engine.js"; +import { algebraicToSquare } from "../coord.js"; +import type { EntityId } from "@paratype/rete"; + +describe("engine.emitEffect + subscribeEffects", () => { + it("subscribers receive every emitted effect", () => { + const engine = new ChessEngine(); + const received: VisualEffect[] = []; + engine.subscribeEffects((e) => received.push(e)); + + engine.emitEffect({ kind: "test", square: 0, ttl: 100 }); + engine.emitEffect({ kind: "other", square: 5, ttl: 200 }); + + expect(received).toHaveLength(2); + expect(received[0]?.kind).toBe("test"); + expect(received[1]?.kind).toBe("other"); + }); + + it("unsubscribe stops further deliveries", () => { + const engine = new ChessEngine(); + const received: VisualEffect[] = []; + const unsub = engine.subscribeEffects((e) => received.push(e)); + engine.emitEffect({ kind: "a", square: null, ttl: 100 }); + unsub(); + engine.emitEffect({ kind: "b", square: null, ttl: 100 }); + expect(received).toHaveLength(1); + }); + + it("multiple subscribers all receive the same effect", () => { + const engine = new ChessEngine(); + const a: VisualEffect[] = []; + const b: VisualEffect[] = []; + engine.subscribeEffects((e) => a.push(e)); + engine.subscribeEffects((e) => b.push(e)); + engine.emitEffect({ kind: "x", square: 0, ttl: 100 }); + expect(a).toHaveLength(1); + expect(b).toHaveLength(1); + }); + + it("a subscriber that throws doesn't break other subscribers", () => { + const engine = new ChessEngine(); + const survivor: VisualEffect[] = []; + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + engine.subscribeEffects(() => { + throw new Error("boom"); + }); + engine.subscribeEffects((e) => survivor.push(e)); + engine.emitEffect({ kind: "x", square: 0, ttl: 100 }); + expect(survivor).toHaveLength(1); + errSpy.mockRestore(); + }); + + it("no subscribers is a no-op (no throw)", () => { + const engine = new ChessEngine(); + expect(() => + engine.emitEffect({ kind: "x", square: 0, ttl: 100 }), + ).not.toThrow(); + }); +}); + +describe("explosive-rook emits 'explosion' effect", () => { + it("fires on rook capture with target square", () => { + const engine = new ChessEngine(); + // Minimal setup: clear board, add white rook on a1, black pawn on a5 + const facts = engine.session.allFacts(); + for (const f of facts) { + if (f.attr !== "PieceType") continue; + if ((f.id as number) <= 0) continue; + if (f.value === "king") continue; + for (const attr of ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const) { + if (engine.session.contains(f.id, attr)) engine.session.retract(f.id, attr); + } + } + const rook: EntityId = engine.session.nextId(); + engine.session.insert(rook, "PieceType", "rook"); + engine.session.insert(rook, "Color", "white"); + engine.session.insert(rook, "Position", algebraicToSquare("a1")); + const target: EntityId = engine.session.nextId(); + engine.session.insert(target, "PieceType", "pawn"); + engine.session.insert(target, "Color", "black"); + engine.session.insert(target, "Position", algebraicToSquare("a5")); + engine.session.insert(target, "HasMoved", true); + + engine.setActivePresets([ + { id: "explosive-rook", scope: "both", turnsRemaining: null }, + ]); + + const received: VisualEffect[] = []; + engine.subscribeEffects((e) => received.push(e)); + + const capture = engine.findMove( + algebraicToSquare("a1"), + algebraicToSquare("a5"), + ); + expect(capture).not.toBeNull(); + engine.applyMove(capture!); + + const explosion = received.find((e) => e.kind === "explosion"); + expect(explosion).toBeDefined(); + expect(explosion?.square).toBe(algebraicToSquare("a5")); + expect(explosion?.ttl).toBeGreaterThan(0); + }); +}); + +describe("king-heals emits 'heal' effect", () => { + it("fires on successful regen with king square", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: "piece-hp", scope: "both", turnsRemaining: null }, + { id: "king-heals", scope: "both", turnsRemaining: null }, + ]); + const received: VisualEffect[] = []; + engine.subscribeEffects((e) => received.push(e)); + + // 1. e4 — heals BLACK king (non-mover). Black king on e8, Hp=2 → 3. + engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!); + + const heal = received.find((e) => e.kind === "heal"); + expect(heal).toBeDefined(); + expect(heal?.square).toBe(algebraicToSquare("e8")); + }); +}); diff --git a/packages/chess/src/rules/capture.ts b/packages/chess/src/rules/capture.ts index 9c34f30..7638529 100644 --- a/packages/chess/src/rules/capture.ts +++ b/packages/chess/src/rules/capture.ts @@ -3,19 +3,70 @@ * When a capture occurs, the captured piece's facts are retracted from WM. */ import type { Session, EntityId } from "@paratype/rete"; -import type { PieceColor } from "../schema.js"; - -/** All known chess fact attributes for a piece entity. - * Exported so presets and the damage pipeline can stay in lockstep - * without each duplicating the list. */ -export const PIECE_ATTRS = ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const; +import type { ChessAttrKey, PieceColor } from "../schema.js"; /** - * Apply a capture: retract all known facts for the captured piece. - * Called after confirming the capture is legal. + * FIDE-minimal attributes every piece entity carries. + * + * This is the CORE list — preset-specific attributes (`Hp`, `Shield`, + * `Stamina`, …) are declared by presets via `PresetDef.pieceAttributes` + * and unioned in at runtime via `ChessEngine.effectivePieceAttrs`. + * Callers that need the ENGINE-AWARE list (retract-on-death, save-state + * serialization, etc.) should read `engine.effectivePieceAttrs`; this + * constant is only for pure-function / no-engine contexts (e.g. the + * `applyCapture` helper below used by legacy tests). + */ +export const CORE_PIECE_ATTRS: readonly ChessAttrKey[] = [ + "PieceType", + "Color", + "Position", + "HasMoved", +] as const; + +/** + * Attributes that live on the game entity (id 0), NOT on pieces. + * + * Used by pure-function helpers (check / stalemate / turn what-if + * simulators) to filter facts when snapshotting a session for + * simulation. Anything on an id > 0 that ISN'T in this list is + * treated as a piece attribute — which means preset-declared attrs + * like `Hp` / `Shield` are automatically picked up by the simulator + * without requiring an engine reference. + * + * The alternative would be to thread an engine reference through + * every pure simulation helper. This keeps those helpers simple at + * the cost of one extra list to maintain — a reasonable tradeoff + * because new game-level attrs are rare (`Turn`, `EnPassantTarget`, + * `HalfmoveClock`, `FullmoveNumber`, `GameStatus`, `Winner`). + */ +export const GAME_LEVEL_ATTRS: readonly ChessAttrKey[] = [ + "Turn", + "HalfmoveClock", + "FullmoveNumber", + "EnPassantTarget", + "GameStatus", + "Winner", +] as const; + +/** + * Is `attr` an attribute of a piece (as opposed to the game entity)? + * Used by session-snapshot helpers to know what to copy. + */ +export function isPieceAttr(attr: string): attr is ChessAttrKey { + return !(GAME_LEVEL_ATTRS as readonly string[]).includes(attr); +} + +/** + * Apply a capture: retract all core attrs for the captured piece. + * + * LEGACY helper — the live engine uses `engine.dealDamage` + the + * dynamic attribute list so preset attributes are cleaned up too. + * This function remains for the capture.test.ts tests that construct + * a raw `Session` without a `ChessEngine`. For those tests no presets + * can be active, so the core list is sufficient. */ export function applyCapture(session: Session, capturedId: EntityId): void { - for (const attr of PIECE_ATTRS) { + for (const attr of CORE_PIECE_ATTRS) { if (session.contains(capturedId, attr)) { session.retract(capturedId, attr); } diff --git a/packages/chess/src/rules/check.ts b/packages/chess/src/rules/check.ts index 249b29f..11b3616 100644 --- a/packages/chess/src/rules/check.ts +++ b/packages/chess/src/rules/check.ts @@ -29,69 +29,14 @@ import { Session } from "@paratype/rete"; import type { EntityId } from "@paratype/rete"; import type { PieceColor, PieceType, Square } from "../schema.js"; import { oppositeColor } from "../schema.js"; -import { getLegalKnightMoves } from "./knight.js"; -import { - getLegalRookMoves, - getLegalBishopMoves, - getLegalQueenMoves, -} from "./sliding.js"; -import { getLegalKingMoves } from "./king.js"; -import { pawnCaptureSqares } from "./primitives.js"; +import { isPieceAttr } from "./capture.js"; import type { LegalMove } from "./types.js"; -import { getPiecePosition } from "./board-queries.js"; - -type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[]; - -/** - * Per-piece-type "does this piece attack `target`?" predicate. - * - * For everything except pawns we can reuse the piece's legal-move - * generator — if a legal move lands on `target`, the piece attacks it. - * - * Pawns need special handling: `getLegalPawnMoves` only yields diagonal - * captures when an enemy currently occupies the diagonal, but for check - * detection we need the *attack geometry* (the squares the pawn would - * capture on, regardless of what's there). A king cannot step onto a - * pawn's diagonal even when that square is empty. - */ -type AttackProbe = (session: Session, pieceId: EntityId, target: Square) => boolean; - -function movesAttackProbe(getter: MoveGetter): AttackProbe { - return (session, pieceId, target) => { - for (const m of getter(session, pieceId)) { - if (m.to === target) return true; - } - return false; - }; -} - -const ATTACK_PROBES: Record = { - pawn: (session, pieceId, target) => { - const from = getPiecePosition(session, pieceId); - if (from === null) return false; - const colorVal = session.get(pieceId, "Color"); - if (colorVal === undefined) return false; - const color = colorVal as PieceColor; - for (const sq of pawnCaptureSqares(from, color)) { - if (sq === target) return true; - } - return false; - }, - knight: movesAttackProbe(getLegalKnightMoves), - bishop: movesAttackProbe(getLegalBishopMoves), - rook: movesAttackProbe(getLegalRookMoves), - queen: movesAttackProbe(getLegalQueenMoves), - king: movesAttackProbe(getLegalKingMoves), -}; - -/** Attributes we copy when snapshotting a session for "what-if" analysis. */ -const PIECE_ATTRS = [ - "PieceType", - "Color", - "Position", - "HasMoved", - "Hp", -] as const; +import { PIECE_TYPE_REGISTRY } from "../presets/piece-type-registry.js"; +// Side-effect: ensure FIDE piece types are registered. Tests that +// exercise these pure-function helpers directly (without ChessEngine) +// wouldn't otherwise trigger the registry population from +// `presets/index.ts`. +import "../presets/core-piece-types.js"; /** * Is `square` attacked by any piece of `byColor`? @@ -118,10 +63,12 @@ export function isSquareAttacked( if (typeFact === undefined) continue; const type = typeFact.value as PieceType; - const probe = ATTACK_PROBES[type]; - if (probe === undefined) continue; - - if (probe(session, f.id, square)) return true; + // Custom piece types register their own attack probe via the + // piece-type registry. Unknown types (degenerate) contribute no + // attacks. + const def = PIECE_TYPE_REGISTRY.get(type); + if (def === undefined) continue; + if (def.attackProbe(session, f.id, square)) return true; } return false; } @@ -165,10 +112,13 @@ export function isInCheck(session: Session, color: PieceColor): boolean { function snapshotSession(session: Session): Session { const temp = new Session({ autoFire: false }); for (const f of session.allFacts()) { - // Only copy attrs we care about for board geometry + check detection. - if ((PIECE_ATTRS as readonly string[]).includes(f.attr)) { - temp.insert(f.id, f.attr, f.value); - } + // Skip game-level facts (Turn, EnPassantTarget, etc) — the + // simulator only cares about board geometry + whatever custom + // piece attrs presets have added. Piece entities have id > 0 by + // convention. + if ((f.id as number) <= 0) continue; + if (!isPieceAttr(f.attr)) continue; + temp.insert(f.id, f.attr, f.value); } return temp; } @@ -182,7 +132,13 @@ function clearSquare(temp: Session, square: Square): void { const occupant = facts.find(f => f.attr === "Position" && f.value === square); if (occupant === undefined) return; const id = occupant.id; - for (const attr of PIECE_ATTRS) { + // Retract every piece-level fact on this id. Using allFacts()-based + // enumeration rather than a hardcoded attr list so preset-declared + // attributes (Hp, Shield, …) are also cleaned. + const toRetract = facts + .filter(f => f.id === id && isPieceAttr(f.attr)) + .map(f => f.attr); + for (const attr of toRetract) { if (temp.contains(id, attr)) temp.retract(id, attr); } } diff --git a/packages/chess/src/rules/checkmate.ts b/packages/chess/src/rules/checkmate.ts index 079eaa3..2ffe8d8 100644 --- a/packages/chess/src/rules/checkmate.ts +++ b/packages/chess/src/rules/checkmate.ts @@ -21,34 +21,13 @@ * - En passant as an escape. En-passant generation is P2.16; once it * lands, it can be plumbed in by extending MOVE_GETTERS. */ -import type { Session, EntityId } from "@paratype/rete"; +import type { Session } from "@paratype/rete"; import type { PieceColor, PieceType } from "../schema.js"; import { isInCheck, filterSelfCheckMoves } from "./check.js"; -import { getLegalPawnMoves } from "./pawn.js"; -import { getLegalKnightMoves } from "./knight.js"; -import { - getLegalRookMoves, - getLegalBishopMoves, - getLegalQueenMoves, -} from "./sliding.js"; -import { getLegalKingMoves } from "./king.js"; import type { LegalMove } from "./types.js"; - -type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[]; - -/** - * Dispatch table mapping PieceType → its pseudo-legal move generator. - * "Pseudo-legal" = respects piece geometry + blockers but ignores - * self-check; the self-check filter handles that afterwards. - */ -const MOVE_GETTERS: Record = { - pawn: getLegalPawnMoves, - knight: getLegalKnightMoves, - bishop: getLegalBishopMoves, - rook: getLegalRookMoves, - queen: getLegalQueenMoves, - king: getLegalKingMoves, -}; +import { PIECE_TYPE_REGISTRY } from "../presets/piece-type-registry.js"; +// Side-effect: see check.ts comment. +import "../presets/core-piece-types.js"; /** * Collect every pseudo-legal move for every piece of `color`. @@ -74,8 +53,11 @@ function getAllPseudoLegalMoves( if (typeFact === undefined) continue; const type = typeFact.value as PieceType; - const getter = MOVE_GETTERS[type]; - if (getter === undefined) continue; + // Dispatch via the piece-type registry — custom types registered + // by presets get their moves collected here too. + const def = PIECE_TYPE_REGISTRY.get(type); + if (def === undefined) continue; + const getter = def.moveGenerator; for (const m of getter(session, f.id)) { moves.push(m); diff --git a/packages/chess/src/rules/stalemate.ts b/packages/chess/src/rules/stalemate.ts index 6bc242a..b0128a7 100644 --- a/packages/chess/src/rules/stalemate.ts +++ b/packages/chess/src/rules/stalemate.ts @@ -25,35 +25,10 @@ import { Session as ReteSession } from "@paratype/rete"; import type { Session, EntityId } from "@paratype/rete"; import type { PieceColor, PieceType } from "../schema.js"; import { isInCheck } from "./check.js"; -import { getLegalPawnMoves } from "./pawn.js"; -import { getLegalKnightMoves } from "./knight.js"; -import { - getLegalRookMoves, - getLegalBishopMoves, - getLegalQueenMoves, -} from "./sliding.js"; -import { getLegalKingMoves } from "./king.js"; -import type { LegalMove } from "./types.js"; - -type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[]; - -const MOVE_GETTERS: Record = { - pawn: getLegalPawnMoves, - knight: getLegalKnightMoves, - bishop: getLegalBishopMoves, - rook: getLegalRookMoves, - queen: getLegalQueenMoves, - king: getLegalKingMoves, -}; - -/** Attributes we copy when snapshotting a session for "what-if" analysis. */ -const PIECE_ATTRS = [ - "PieceType", - "Color", - "Position", - "HasMoved", - "Hp", -] as const; +import { isPieceAttr } from "./capture.js"; +import { PIECE_TYPE_REGISTRY } from "../presets/piece-type-registry.js"; +// Side-effect: see check.ts comment. +import "../presets/core-piece-types.js"; /** * Does `color` have at least one move that doesn't leave its king in @@ -74,14 +49,18 @@ function hasAnyLegalMove(session: Session, color: PieceColor): boolean { } for (const piece of pieces) { - const getter = MOVE_GETTERS[piece.type]; + const getter = PIECE_TYPE_REGISTRY.get(piece.type)?.moveGenerator; if (getter === undefined) continue; for (const move of getter(session, piece.id)) { - // Build a what-if session with only the piece-geometry attrs. + // Build a what-if session with only piece-level attrs (game-level + // facts are irrelevant for legality). Any attr that isn't + // game-level — including preset-declared ones like Hp / Shield — + // is carried forward automatically. const temp = new ReteSession({ autoFire: false }); for (const f of facts) { - if (!(PIECE_ATTRS as readonly string[]).includes(f.attr)) continue; + if ((f.id as number) <= 0) continue; + if (!isPieceAttr(f.attr)) continue; // For captures, drop whatever sits on the destination square. if (move.isCapture && f.attr === "Position" && f.value === move.to) { continue; @@ -89,13 +68,17 @@ function hasAnyLegalMove(session: Session, color: PieceColor): boolean { temp.insert(f.id, f.attr, f.value); } // For captures we also need to clear the captured piece's other - // attrs (PieceType, Color, ...). Easiest: retract by id. + // attrs (PieceType, Color, ...). Retract by id — enumerate the + // target's piece-level facts rather than a hardcoded list. if (move.isCapture) { const captured = facts.find( g => g.attr === "Position" && g.value === move.to && g.id !== move.pieceId, ); if (captured !== undefined) { - for (const attr of PIECE_ATTRS) { + const attrs = facts + .filter(g => g.id === captured.id && isPieceAttr(g.attr)) + .map(g => g.attr); + for (const attr of attrs) { if (temp.contains(captured.id, attr)) { temp.retract(captured.id, attr); } diff --git a/packages/chess/src/rules/turn.ts b/packages/chess/src/rules/turn.ts index dc0263c..a173225 100644 --- a/packages/chess/src/rules/turn.ts +++ b/packages/chess/src/rules/turn.ts @@ -66,14 +66,9 @@ export function clearMoveGeneratorRegistry(): void { moveGeneratorRegistry.clear(); } -/** Piece-level attributes retracted when an entity is captured. */ -const PIECE_ATTRS = [ - "PieceType", - "Color", - "Position", - "HasMoved", - "Hp", -] as const; +// Snapshot-copy predicate shared with check.ts / stalemate.ts. +// See capture.ts for the "not game-level" rationale. +import { isPieceAttr } from "./capture.js"; /** * Get every legal move available to pieces of `color` given the current @@ -148,7 +143,15 @@ export function applyMove(session: Session, move: LegalMove): void { if (move.isCapture) { const capturedId = getPieceAt(session, move.to); if (capturedId !== null && capturedId !== move.pieceId) { - for (const attr of PIECE_ATTRS) { + // Enumerate the captured piece's attrs from the session rather + // than from a hardcoded list — preset-declared attrs (Hp, + // Shield, …) get cleaned up too without this function needing + // to know about presets. + const attrs = session + .allFacts() + .filter(f => f.id === capturedId && isPieceAttr(f.attr)) + .map(f => f.attr); + for (const attr of attrs) { if (session.contains(capturedId, attr)) { session.retract(capturedId, attr); } diff --git a/packages/chess/src/schema.ts b/packages/chess/src/schema.ts index d2fd3fa..ff287bd 100644 --- a/packages/chess/src/schema.ts +++ b/packages/chess/src/schema.ts @@ -17,6 +17,18 @@ export type GameResult = PieceColor | "draw"; /** EntityId reserved for game-level facts (Turn, HalfmoveClock, etc.). */ export const GAME_ENTITY: EntityId = 0 as EntityId; +/** + * Reserved EntityId for preset-state facts. Each active preset can + * stash typed, per-game state on this entity via + * `ChessEngine.presetState(presetId)` — attributes are namespaced by + * preset id to prevent collisions. + * + * Using a negative id keeps preset-state facts out of the "id > 0 is + * a piece" convention used throughout the codebase. Session.nextId() + * starts at 1, so -1 can never collide. + */ +export const PRESET_STATE_ENTITY: EntityId = -1 as EntityId; + /** * Maps attribute name to its TypeScript type. * Used for typed fact construction and WM queries. diff --git a/packages/chess/src/ui/Board.tsx b/packages/chess/src/ui/Board.tsx index b15f82a..c893cee 100644 --- a/packages/chess/src/ui/Board.tsx +++ b/packages/chess/src/ui/Board.tsx @@ -2,6 +2,7 @@ import { useState, useMemo } from 'react'; import type { ChessFact, PieceColor, PieceType } from '../schema'; import { squareToAlgebraic, squareOf, squareColor } from '../coord'; import type { LegalMove } from '../rules/types'; +import type { ChessEngine } from '../engine'; import { Piece } from './Piece'; import { AnimatePresence, motion } from 'motion/react'; import { pieceAssets } from '../assets/pieces'; @@ -11,6 +12,7 @@ import { type PieceOverlayComponent, type SquareOverlayComponent, } from './preset-overlays'; +import { VisualEffectLayer } from './VisualEffectLayer'; import '../presets/ui-overlays-index'; interface BoardProps { @@ -29,6 +31,9 @@ interface BoardProps { /** Currently-active preset ids. Used to look up registered per-piece * overlay components (e.g. HP pips for piece-hp). Order preserved. */ activePresetIds?: ReadonlyArray; + /** Live engine reference for the visual effect stream. Optional — + * omit in tests or headless renders; no effects will render. */ + engine?: ChessEngine; } interface PieceState { @@ -37,7 +42,7 @@ interface PieceState { color: PieceColor; } -export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare, activePresetIds }: BoardProps) { +export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare, activePresetIds, engine }: BoardProps) { // Pre-compute overlay components once per render — lookup is cheap // but doing it once in a useMemo keeps the Piece render path clean. const overlays: PieceOverlayComponent[] = useMemo( @@ -351,6 +356,7 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
{squares}
+ {engine !== undefined && } {promotionMove && ( diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx index b6816eb..cb41d5f 100644 --- a/packages/chess/src/ui/GameView.tsx +++ b/packages/chess/src/ui/GameView.tsx @@ -382,6 +382,7 @@ function GameLayout({ lastMove={lastMove} checkedKingSquare={checkedKingSquare} activePresetIds={activations.map((a) => a.id)} + {...(state.engine !== null ? { engine: state.engine } : {})} /> {/* Overlay for game over to prevent further interaction visually */} diff --git a/packages/chess/src/ui/Piece.tsx b/packages/chess/src/ui/Piece.tsx index 82cfbd4..0838498 100644 --- a/packages/chess/src/ui/Piece.tsx +++ b/packages/chess/src/ui/Piece.tsx @@ -1,5 +1,6 @@ import type { ChessAttrMap, ChessFact, PieceColor, PieceType } from '../schema'; import { pieceAssets } from '../assets/pieces'; +import { PIECE_TYPE_REGISTRY } from '../presets/piece-type-registry'; import { motion, useMotionValue, @@ -95,7 +96,12 @@ export function Piece({ onDragStart, onDragEnd, }: PieceProps) { - const imgSrc = pieceAssets[color][type]; + // Look up asset via the piece-type registry so custom types (Cannon, + // Amazon, etc) render correctly. Fall back to the built-in asset + // map for safety — it's the same data source either way for FIDE + // pieces. + const registryDef = PIECE_TYPE_REGISTRY.get(type); + const imgSrc = registryDef?.assets[color] ?? pieceAssets[color][type]; // Raw pointer offset from drag origin, updated on every `dragover`. const xRaw = useMotionValue(0); diff --git a/packages/chess/src/ui/VisualEffectLayer.tsx b/packages/chess/src/ui/VisualEffectLayer.tsx new file mode 100644 index 0000000..42363ff --- /dev/null +++ b/packages/chess/src/ui/VisualEffectLayer.tsx @@ -0,0 +1,152 @@ +/** + * Visual effect layer for the chess board. + * + * Subscribes to the engine's effect stream and renders ephemeral + * overlays (explosions, heal shimmers, poison bubbles, …) at the + * appropriate squares. Effects auto-expire after their TTL. + * + * ## Adding a new effect kind + * + * 1. In your preset, call `engine.emitEffect({ kind: "lightning", square, ttl, data })`. + * 2. Register a renderer below in `EFFECT_RENDERERS`. The renderer + * is a React component receiving `(effect: VisualEffect)` and + * returning the overlay JSX. + * 3. Unknown `kind`s fall back to a generic pulse so missing + * renderers are a visual no-op, not a crash. + * + * ## Positioning + * + * The layer is absolutely positioned to fill its parent (the Board + * container). Each effect is positioned within the 8x8 grid using + * `top/left` percentages derived from the square index. `square: null` + * positions the effect as a full-board overlay. + */ +import { cloneElement, useEffect, useState } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import type { ChessEngine, VisualEffect } from "../engine"; + +/** An effect plus a unique id so React keys stay stable during TTL. */ +interface TaggedEffect { + readonly id: number; + readonly effect: VisualEffect; +} + +/** + * Square → {top%, left%} in the 8x8 grid. Uses the same orientation + * as Board.tsx (rank 8 at top, file a at left) — both treat + * `squareOf(file, rank)` identically so this math lines up. + */ +function squareStyle(square: number): { top: string; left: string } { + const file = square % 8; + const rank = Math.floor(square / 8); + // Rank 8 is top (visualRank 0); we invert. + const visualRank = 7 - rank; + return { + top: `${(visualRank * 100) / 8}%`, + left: `${(file * 100) / 8}%`, + }; +} + +/** Renderer type for a named effect kind. */ +type EffectRenderer = (effect: VisualEffect) => React.ReactElement; + +const EFFECT_RENDERERS: Record = { + explosion: (effect) => ( + + ), + heal: (effect) => ( + + ), + poison: (effect) => ( + + ), +}; + +/** Fallback for effects with no registered renderer. */ +function GenericPulse({ effect }: { effect: VisualEffect }) { + return ( + + ); +} + +interface VisualEffectLayerProps { + readonly engine: ChessEngine; +} + +export function VisualEffectLayer({ engine }: VisualEffectLayerProps) { + const [effects, setEffects] = useState([]); + + useEffect(() => { + let nextId = 1; + const unsubscribe = engine.subscribeEffects((effect) => { + const tagged: TaggedEffect = { id: nextId++, effect }; + setEffects((prev) => [...prev, tagged]); + // Schedule removal after TTL. Using setTimeout with the + // engine's declared TTL — renderers use the same value to + // drive their animation, so DOM removal matches the end of + // the anim. + const timer = window.setTimeout(() => { + setEffects((prev) => prev.filter((e) => e.id !== tagged.id)); + }, effect.ttl); + // If the component unmounts before the timer fires, React's + // own unmount path nukes the setTimeout-driven update (the + // component is gone by then). We don't need explicit cleanup. + void timer; + }); + return () => { + unsubscribe(); + }; + }, [engine]); + + return ( +
+ + {effects.map(({ id, effect }) => { + const renderer = EFFECT_RENDERERS[effect.kind]; + const element = renderer !== undefined + ? renderer(effect) + : GenericPulse({ effect }); + // `cloneElement` with a key is the cleanest way to attach a + // React key to a pre-rendered element so AnimatePresence can + // track mount/unmount. Each tagged effect has a unique id. + return cloneElement(element, { key: id }); + })} + +
+ ); +}