Compare commits

...

3 commits

Author SHA1 Message Date
653267daf0
chore: gitignore playwright transform cache 2026-04-18 16:27:47 -06:00
a7ec140cef
fix(chess): use ref-based guard for shared-link auto-join to prevent StrictMode double-join
React 18 StrictMode runs effects twice on mount in dev. The previous
state-based guard (joining) couldn't prevent the second invocation
from firing room.join because both passes see the deferred state
as null. The server accepts the first join as black, rejects the
second with ROOM_FULL, and the user sees an incorrect error toast.

Switching to useRef gives synchronous visibility — the second pass
sees joinedCodeRef.current already set and bails. Ref is reset on
.catch so a legitimate retry from the lobby isn't permanently
blocked.
2026-04-18 16:26:35 -06:00
cc30545ced
feat(chess): preset-flexibility architecture — decouple HP, damage, piece-types, state, effects
Decouples five cross-cutting concerns from engine core so new presets
compose without special-casing:

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

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

930 tests passing; bun run check clean.
2026-04-18 16:26:17 -06:00
34 changed files with 3635 additions and 325 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@ dist/
.DS_Store
coverage/
playwright-report/
playwright-transform-cache-*/
test-results/
.vite/
*.tsbuildinfo

View file

@ -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"

View file

@ -0,0 +1,572 @@
# Preset Flexibility — Architecture Pass
## TL;DR
Nine architectural improvements plus two phase hooks that unblock the next generation of chess-variant presets without a single engine touch per preset.
**Core problems today**:
1. `PIECE_ATTRS` is hardcoded — new attribute-carrying presets (Shield, Armor, Stamina) leave zombie facts on death.
2. No piece-spawn primitive — `queen-splits` has a latent HP-coupling bug waiting for a mid-game activation order to trigger.
3. Piece types are a closed union — no Cannon, Amazon, Nightrider without editing core.
4. No visual effect bus — explosions/heals/poison ticks are silent.
5. No move log — history panels, PGN export, analysis tools all blocked.
6. Per-preset state is ad-hoc (global modules or game-entity facts); breaks concurrent games and save/load.
7. Hook signatures are positional — growing them breaks every implementer.
8. `onAfterMove`, `onDamage`, `onCheckGameResult` ignore scope; presets re-derive it.
9. Test harness pattern duplicated across every preset test file.
**Deliverable**: after this work, a preset can add a new piece type, a new piece attribute, new damage kinds, new visual effects, and new per-game state — all without changing a single line of engine/core code. Every existing preset continues to behave identically in isolation; new compositions (HP + any new damage source) work automatically.
**Phases**: 4 phases, 23 tasks, hard-gated by `bun run check` green + behavioral tests green between each.
---
## Context
### What Shipped Last
- Damage pipeline landed. `engine.dealDamage(target, amount, ctx)` is the canonical damage primitive; `piece-hp.onDamage` is the sole damage→death arbiter.
- `explosive-rook` + `piece-hp` now compose naturally (blast deals 1 HP each; pieces with HP>1 survive). The previous hard incompatibility was removed.
- `queen-splits` and `poisoned-squares` route captures/ticks through the pipeline.
- 889 unit tests pass, including 12 new damage-pipeline tests.
### What Still Couples Presets to Core
Audited the live code for every preset-visible engine surface. The remaining friction points:
- `packages/chess/src/rules/capture.ts:10` — `PIECE_ATTRS` is a frozen 5-element tuple. Adding an attribute requires editing this file.
- `packages/chess/src/presets/piece-hp.ts:77` — `const PIECE_ATTRS = [...]` duplicates the list. Another edit point.
- `packages/chess/src/presets/queen-splits.ts:60` + `:84-97` — owns a `PIECE_ATTRS` copy plus a private `spawnPiece` helper. Spawned pieces aren't passed through any hook — if `piece-hp` activates AFTER queen-splits spawns pieces, those pieces have no `Hp`.
- `packages/chess/src/schema.ts:PieceType` — fixed union of 6 types. Hardcoded in `PIECE_MOVE_GETTERS` (`engine.ts:61`), `ATTACK_PROBES` (`check.ts:68`), `MOVE_GETTERS` (`checkmate.ts:44`), `stalemate.ts`, plus UI asset maps. Five synchronized tables.
- `packages/chess/src/engine.ts:applyMove` — emits nothing. Clients reconstruct history from fact diffs. No per-move summary, no notation, no event stream.
- `packages/chess/src/presets/capture-to-win.ts:46` + others — use `GAME_ENTITY` facts as ad-hoc state storage. Mixed with real game-level facts (`Turn`, `EnPassantTarget`, `HalfmoveClock`). Collision surface grows with each preset.
- `packages/chess/src/presets/registry.ts` — every hook is `(engine, ...positional args) => Result | void`. Every signature change is a breaking change to every preset.
- `packages/chess/src/presets/*.test.ts` — `pieceAt`, `hpOf`, `typeOf`, `clearRank` are copy-pasted across at least 4 files.
### Out of Scope
- Any new **presets** (piece types, rules). This plan is pure infrastructure.
- Save-file forward compatibility for saved games that predate this refactor. Autosave is dev-only; we can ship a one-line version bump + clear stale saves.
- Server-protocol version bump beyond what structured move emissions require. We keep protocol v1 and add new optional fields.
- UI redesigns beyond the minimum needed to render new visual effects.
---
## Work Objectives
### Core Objective
Decouple every preset-reachable engine surface so the `PresetDef` interface + registry is the ONLY thing a new preset touches.
### Concrete Deliverables
1. **Dynamic piece-attribute registry** — presets declare their attrs; retract / damage / save paths consult the union.
2. **`spawnPiece` primitive** + **`onPieceSpawn` hook** — single entry point for creating pieces; every active preset gets a chance to tag new entities.
3. **Custom piece-type registry** — presets register `(id, moveGenerator, attackProbe, assets)`; engine + UI look them up dynamically.
4. **Per-preset state bag** — `engine.presetState<T>(id)` with typed, engine-scoped, serialization-included state.
5. **Visual effect bus** — `engine.emitEffect({...})` + React subscriber; explosions/heals/poison ticks animate.
6. **Structured move log** — `applyMove` populates `MoveRecord`; `engine.moveLog` is the authoritative history stream.
7. **Hook-context objects** — all hooks take a single growable context object; future fields are additive.
8. **Scope-aware hook dispatch** — engine skips hooks whose scope doesn't cover the current color.
9. **Test harness utilities** — shared `test-utils.ts` for `engineWithPosition`, `placePiece`, `clearBoard`, etc.
10. **Phase hooks** — `onBeforeMove` (pre-validation, pre-mutation) and `onTurnStart` (after turn flip, before legal moves are regenerated) for cooldown/regen/resource-tick presets.
11. **Tests** — each new API gets unit tests; one integration test verifies a hypothetical "Shield" preset can be written from the registry alone without engine edits.
### Definition of Done
- `bun run check` green (typecheck + lint + 900+ tests).
- Every existing preset still works with no behavioral change in isolation (regression).
- The three prototype presets built in test code to exercise the new surfaces (Cannon piece type, Shield attribute, DamageCooldown state) all work.
- No references to literal `["PieceType", "Color", "Position", "HasMoved", "Hp"]` outside `engine.ts` / `registry.ts` / `schema.ts`.
- New `packages/chess/docs/PRESET-API.md` documenting the complete extension surface.
### Must Have
- All 9 items from the architecture review + 2 phase hooks.
- Backward-compatible hook signatures via the context-object refactor (old positional signatures accepted as a deprecation layer, removed in a single sweep after all in-tree callers migrate).
- Zero behavior regressions for existing presets (piece-hp, explosive-rook, queen-splits, king-heals, poisoned-squares, capture-to-win, last-piece-standing, all geometry presets).
### Must NOT Have (Guardrails)
- No event-bus abstraction for cross-preset communication. Presets talk to the ENGINE, not each other. If two presets need to coordinate, it's a design smell requiring review.
- No topological sort / priority system for preset ordering. Registration order + first-consumer-wins is sufficient. If ordering matters for a future pair, that's a design conversation, not a generic engine feature.
- No preset-defined alternative move validators. Move generation stays in `rules/`; presets *contribute extra moves* or *filter existing moves* via `getExtraMoves` / `filterMoves`, not replace the validator.
- No runtime piece-type registration through the UI / save files. Piece types are dev-authored TS modules that `register` at module-load time. User-uploaded code remains forbidden (RCE vector).
- No breaking changes to the WebSocket protocol shape. New optional fields only.
---
## Verification Strategy
### Test Decision
**TDD for each engine API surface**. Unit tests written alongside (or before) each primitive. Integration-style tests for composite behaviors (e.g., "HP + custom-type Cannon composes correctly").
### Regression Net
The existing 889 tests cover every shipped preset. They stay green through the entire refactor. `bun run check` runs at the end of every phase.
### Integration scenarios (run in the final verification wave)
1. **Shield attribute, from nothing**: Write a `piece-shield` preset using only public APIs — declares `pieceAttributes: ["Shield"]`, seeds on `onActivate`, absorbs damage via `onDamage` with higher priority than HP. Confirm retraction cleans up `Shield` facts (dynamic `PIECE_ATTRS`).
2. **Cannon piece type**: Write a preset that spawns a Cannon on d1 (Chinese-chess semantics — moves like a rook, captures only by jumping one piece). Register the piece type via the new API. Confirm UI renders it; move generation works; it's a legal target for checks; it saves/loads.
3. **Per-preset state survival**: Write a `berserker-cooldown` preset that uses `engine.presetState` to track last-rage-turn per piece. Save → restore → state is preserved. Activate → deactivate → state is cleared.
4. **Visual effect smoke test**: Playwright test — enable explosive-rook, trigger a capture, assert a DOM element with `data-effect="explosion"` appears briefly on the target square.
5. **Phase hook cooldown preset**: Write a `pawn-stamina` preset that uses `onTurnStart` to regenerate a `Stamina` attribute up to 2. Consuming it via `onBeforeMove` when pawns push. Proves the new phase hooks fire at the right times.
### QA Policy
- After every task: run focused tests for the touched file(s).
- After every phase: `bun run check` must be clean.
- After the full plan: Playwright smoke (`packages/chess/e2e/*.spec.ts --workers=1`), verify the 3 existing E2E specs + any new visual-effect one.
---
## Execution Strategy
### Phase Structure
Four phases, strictly sequential. Within each phase, tasks with no mutual dependency can run in parallel.
- **Phase A — Foundations**: dynamic attributes, spawn primitive, context objects. Touches `engine.ts` + `registry.ts` + per-preset cleanup. Everything else in this plan builds on this.
- **Phase B — State & Events**: per-preset state bag, move log, visual effect bus. These add emission points to `applyMove`; need A's context objects to be clean.
- **Phase C — Extension Surfaces**: custom piece types, scope-aware dispatch, phase hooks. New hooks declared in PresetDef; engine dispatches them; prototype presets in tests verify they fire.
- **Phase D — Developer Experience**: test harness, docs, final integration scenarios.
### Parallel Execution Waves
Phase A (serial foundation, then parallel cleanup):
Wave A.1 (sequential — core APIs):
A.1 PIECE_ATTRS → dynamic registry (engine + capture.ts)
A.2 engine.spawnPiece + onPieceSpawn hook
A.3 Hook context objects (HookContext, MoveContext, DamageContext renames)
Wave A.2 (parallel — cleanup existing presets using A.1-A.3):
A.4 piece-hp: migrate to declared attrs + context args
A.5 queen-splits: migrate to engine.spawnPiece, drop PIECE_ATTRS copy
A.6 explosive-rook: drop retractEntity helper, use dynamic PIECE_ATTRS
GATE: bun run check; all 889 existing tests green.
Phase B (parallel — state / events / log):
Wave B.1 (parallel):
B.1 engine.presetState<T>() + session-backed storage
B.2 MoveRecord + engine.moveLog + applyMove emission
B.3 engine.emitEffect() + EffectStream subscriber API
B.4 UI: VisualEffectLayer reads the stream, renders with Motion
GATE: bun run check; 3 new unit test suites (state, log, effects) pass.
Phase C (parallel except C.1 which gates C.2-C.3):
Wave C.1 (sequential foundation):
C.1 PieceTypeRegistry (engine + UI asset lookup)
Wave C.2 (parallel, depends on C.1):
C.2 registerPieceType API + engine move/attack tables become dynamic
C.3 Scope-aware hook dispatch (onAfterMove/onDamage/onCheckGameResult/new phase hooks)
C.4 onBeforeMove + onTurnStart phase hooks
C.5 Server-protocol: piece-type registry echoed on game.state
GATE: bun run check; Cannon prototype preset works end-to-end.
Phase D:
Wave D.1 (parallel):
D.1 packages/chess/src/presets/test-utils.ts + migration of existing tests
D.2 packages/chess/docs/PRESET-API.md (complete extension surface)
D.3 Integration scenarios (Shield / Cannon / Berserker / Stamina prototypes in test code)
FINAL GATE: bun run check + Playwright E2E + all integration scenarios green.
---
## TODOs
### Phase A — Foundations
#### A.1 — Dynamic piece-attribute registry
**Where**: `packages/chess/src/rules/capture.ts`, `packages/chess/src/engine.ts`, `packages/chess/src/presets/registry.ts`
**Why**: Preset-declared attributes (Shield, Stamina, Armor) must be retracted on death / serialized on save. Hardcoded list = silent bug.
**How**:
1. Rename existing `PIECE_ATTRS` to `CORE_PIECE_ATTRS` in `capture.ts`; keep it as the FIDE minimum.
2. In `registry.ts` add `readonly pieceAttributes?: readonly AttrKey[]` to `PresetDef`.
3. In `engine.ts` add a private computed getter `get effectivePieceAttrs(): readonly AttrKey[]` that returns `CORE_PIECE_ATTRS ∪ union(active preset pieceAttributes)`.
4. Replace every in-tree reference to literal `["PieceType", "Color", "Position", "HasMoved", "Hp"]` with `engine.effectivePieceAttrs` (or `CORE_PIECE_ATTRS` for pure-function contexts).
5. `dealDamage`'s default retract path uses the dynamic list. Same for piece-hp's lethal retract path. Same for queen-splits' queen retract.
6. `Hp` attribute moves OFF `CORE_PIECE_ATTRS` — it's owned by `piece-hp` via `pieceAttributes: ["Hp"]`.
**Expected result**: `PIECE_ATTRS` no longer appears as a literal in any preset. Adding a new attribute-carrying preset requires only a `pieceAttributes` declaration. Existing HP tests continue to pass — `Hp` is cleaned up when pieces die because `piece-hp` contributes it to the dynamic list.
**Tests**: Unit — a test preset that declares `pieceAttributes: ["TestMark"]`, seeds it on onActivate, then gets retracted on piece death. Verify no `TestMark` fact remains.
---
#### A.2 — `engine.spawnPiece()` primitive + `onPieceSpawn` hook
**Where**: `packages/chess/src/engine.ts`, `packages/chess/src/presets/registry.ts`, `packages/chess/src/presets/queen-splits.ts`
**Why**: `queen-splits` has a latent bug — pieces it spawns don't get `Hp` seeded if HP was activated *after* queen-splits started having reason to spawn. Plus future presets (promotion variants, summoning, phoenix) need the same thing.
**How**:
1. Add to `PresetDef`: `readonly onPieceSpawn?: (ctx: PieceSpawnContext) => void;`. `PieceSpawnContext = { engine, pieceId, type, color, square, reason: "promotion" | "fission" | "summon" | (string & {}) }`.
2. Add `engine.spawnPiece(type, color, square, opts?: { reason?: string; hasMoved?: boolean }): EntityId`. Creates entity, inserts CORE attrs, then iterates `activePresets.list()` calling each `onPieceSpawn`. Returns the new id.
3. `piece-hp` implements `onPieceSpawn` → `engine.session.insert(id, "Hp", DEFAULT_HP)`.
4. `queen-splits` deletes its private `spawnPiece` helper, calls `engine.spawnPiece("rook", color, square, { reason: "fission" })` twice.
5. (Future) promotion rule in `rules/promotion.ts` migrates to `spawnPiece` — tracked as stretch for this task, do only if trivial.
**Expected result**: Activating queen-splits in any order with piece-hp produces fission-spawned pieces with `Hp = 2` as expected. No more "idempotent onActivate" duct tape.
**Tests**: Unit — activate queen-splits, then piece-hp, then trigger fission. Verify spawned rook + bishop both have `Hp = 2`. Activate in reverse order. Verify same.
---
#### A.3 — Hook context objects
**Where**: `packages/chess/src/presets/registry.ts`, `packages/chess/src/engine.ts`, all `packages/chess/src/presets/*.ts`
**Why**: Positional signatures break every preset when we add a field. Context objects are growable.
**How**:
1. Define per-hook context types in `registry.ts`:
```ts
interface HookContext { readonly engine: ChessEngine; }
interface MoveHookContext extends HookContext { readonly moverColor: PieceColor; readonly move: MoveRecord; /* added in B.2 */ }
interface DamageHookContext extends HookContext {
readonly target: EntityId;
readonly amount: number;
readonly kind: string;
readonly attacker?: EntityId;
}
interface PieceSpawnContext extends HookContext {
readonly pieceId: EntityId;
readonly type: PieceType;
readonly color: PieceColor;
readonly square: Square;
readonly reason: string;
}
// etc for every hook
```
2. Migrate every `PresetDef` hook signature to take a single context object.
3. Migrate every preset implementation. Engine dispatchers construct context objects and pass them.
4. Temporarily keep positional form for `onDamage` and `onBeforeCapture` accepted via an adapter, emit a `console.warn` in dev-mode. Remove the adapter in the same PR once all callers migrate (since this is all in-tree).
**Expected result**: Future hook additions are purely additive — `MoveHookContext` gains a `lastCaptureSquare?: Square` field, zero presets break.
**Tests**: Migrate existing hook tests to new signature; add a single assertion that unknown context fields are silently ignored by presets that don't read them (typechecker covers this).
---
#### A.4 — Migrate `piece-hp` to declared attrs + context args
Depends on A.1, A.2, A.3.
**Why**: Prove the new pattern works on the most-used preset first.
**How**:
- Add `pieceAttributes: ["Hp"]` to the PresetDef.
- Drop the in-file `PIECE_ATTRS` duplicate.
- Hook signatures converted to context args.
- `onPieceSpawn` implementation added (seeds Hp=2 on new entities).
- `onActivate` becomes slightly simpler (no need to iterate and check; pieces spawned after activation auto-get Hp via `onPieceSpawn`).
**Tests**: Existing piece-hp tests + fleshed-presets tests stay green.
---
#### A.5 — Migrate `queen-splits` to `engine.spawnPiece`
Depends on A.2.
**Why**: Removes queen-splits's private spawn helper; removes the dead `// ... relies on piece-hp onActivate idempotence` comment.
**How**: Replace the two `spawnPiece(session, ...)` calls with `engine.spawnPiece(...)`. Delete the private helper. Drop the `PIECE_ATTRS` constant.
**Tests**: Existing queen-splits tests stay green. Add one explicit test for the "HP activated after queen-splits mid-game" scenario.
---
#### A.6 — Migrate `explosive-rook`, `poisoned-squares` to dynamic attrs
Depends on A.1.
**Why**: These carry their own retraction helpers.
**How**: Replace local `PIECE_ATTRS` with `engine.effectivePieceAttrs` where they retract directly (explosive-rook's fallback path if it's not using dealDamage for some future reason; poisoned-squares already uses dealDamage so may need zero edits — audit). Keep behaviour identical.
**Tests**: Existing tests stay green.
**GATE A**: `bun run check` green. Every existing test passes.
---
### Phase B — State & Events
#### B.1 — `engine.presetState<T>(id)`
**Where**: `packages/chess/src/engine.ts`, `packages/chess/src/schema.ts`
**Why**: Per-game, per-preset state with zero collision, included in save-state.
**How**:
1. Define `PRESET_STATE_ENTITY` symbol in `schema.ts` (new entity id, below `GAME_ENTITY`).
2. `engine.presetState<T>(presetId: string): T` returns a typed Proxy backed by session facts of the form `(PRESET_STATE_ENTITY, `${presetId}::${key}`, value)`.
3. `get` reads the fact; `set` inserts/retracts; `delete` retracts.
4. On preset deactivate, engine retracts every fact with attr matching `${presetId}::*`.
5. Fact serialization already covers these — save/load works automatically.
**Tests**:
- Write + read + save + load round-trip.
- Activate → write state → deactivate → state cleared → reactivate → empty.
- Two concurrent engines with the same preset ID have independent state (server concurrency).
**Expected result**: Migrate `capture-to-win`'s `Winner` fact from `GAME_ENTITY` to its preset-state bag as a proof point.
---
#### B.2 — `MoveRecord` + `engine.moveLog` + `applyMove` emission
**Where**: `packages/chess/src/engine.ts`, `packages/chess/src/presets/registry.ts` (new hook `describeMoveEffect`)
**Why**: History UI, PGN export, replay — all need a per-move summary. Currently requires diffing facts.
**How**:
1. Add `MoveRecord` interface in `engine.ts`: `{ from, to, moverColor, movingType, capturedId, capturedType, isCheck, isCheckmate, isEnPassant, isCastling, promotion, presetEffects, timestamp }`.
2. Private `this.#moveLog: MoveRecord[] = []`; public getter.
3. Populate inside `applyMove` at the very end, before return.
4. Optional hook on `PresetDef`: `describeMoveEffect?(ctx): { presetId: string; summary: string } | void`. Engine iterates, collects non-void returns into `presetEffects`.
5. Update `MoveHookContext` (from A.3) to carry the MoveRecord being built (or the finalized one, depending on hook timing).
**Tests**:
- Move sequence produces correct log entries.
- Capture is logged with `capturedType`.
- Checkmate is logged with `isCheckmate: true`.
- `describeMoveEffect` is called and results show up in `presetEffects`.
---
#### B.3 — `engine.emitEffect()` + effect stream API
**Where**: `packages/chess/src/engine.ts`, `packages/chess/src/ui/VisualEffectLayer.tsx` (new)
**Why**: Visual feedback. Currently explosions, heals, poison ticks happen silently.
**How**:
1. `VisualEffect` interface: `{ kind: string; square: Square | null; ttl: number; data?: Record<string, unknown> }`.
2. `engine.emitEffect(effect: VisualEffect)` pushes onto an internal ring buffer; notifies subscribers.
3. `engine.subscribeEffects(handler): Unsubscribe`.
4. UI layer mounts a fixed-position overlay inside `Board.tsx`; subscribes once; renders effects with Motion (fade/scale/slide based on kind).
5. Presets call it directly: `explosive-rook` emits `{ kind: "explosion", square: targetPos, ttl: 600 }`; `king-heals` emits `{ kind: "heal", square: kingSq, ttl: 400 }`; `poisoned-squares` emits per-tick `{ kind: "poison", square, ttl: 250 }`.
**Tests**: Unit — emit + subscribe; unit — TTL cleanup; Playwright — trigger explosive-rook capture, assert `[data-effect="explosion"]` briefly present.
---
#### B.4 — UI: VisualEffectLayer integration
Depends on B.3.
**Where**: `packages/chess/src/ui/Board.tsx`, `packages/chess/src/ui/VisualEffectLayer.tsx` (new)
**Why**: Ship B.3 as a visible feature.
**How**: Mount layer inside board viewport. Per-kind effect component (ExplosionBurst, HealPulse, PoisonBubble). Use Motion's `AnimatePresence` for entry/exit; auto-remove after TTL.
**Tests**: Playwright smoke (see B.3).
**GATE B**: `bun run check` green. Visual smoke test green. Capture-to-win's Winner fact has migrated to presetState.
---
### Phase C — Extension Surfaces
#### C.1 — PieceTypeRegistry (core)
**Where**: `packages/chess/src/presets/piece-type-registry.ts` (new), `packages/chess/src/schema.ts`
**Why**: Foundation for custom piece types.
**How**:
1. New class `PieceTypeRegistry` with methods `register(def: PieceTypeDef)`, `get(id)`, `getAll()`, `has(id)`.
2. `PieceTypeDef { id, displayName, moveGenerator, attackProbe, assets: { whiteSvg, blackSvg } }`.
3. `PieceType` becomes `string` (not a fixed union). Runtime validation via registry membership.
4. Built-in types (pawn/knight/etc) register at module load via a `core-piece-types.ts` that's imported from the barrel.
**Tests**: Unit — register new type, iterate, lookup by id.
---
#### C.2 — Engine + UI consume PieceTypeRegistry
Depends on C.1.
**Where**: `packages/chess/src/engine.ts`, `packages/chess/src/rules/check.ts`, `packages/chess/src/rules/checkmate.ts`, `packages/chess/src/ui/Piece.tsx`, `packages/chess/src/assets/pieces/index.ts`
**Why**: Swap the five hardcoded dispatch tables (`PIECE_MOVE_GETTERS`, `ATTACK_PROBES`, `MOVE_GETTERS`, asset map, stalemate's table) for registry lookups.
**How**:
1. `engine.getLegalMovesForPiece(id)` resolves move generator via registry.
2. `isSquareAttacked` + `isCheckmate` + `isStalemate` do the same.
3. UI `<Piece>` looks up SVG assets from registry's `assets` field.
4. Starting-position fact generator stays as-is (still uses hardcoded 32-piece FIDE layout).
**Tests**: Unit — register a Cannon piece type; place a Cannon entity; verify it appears as a legal attacker in check detection; verify the UI renders its asset (Playwright).
---
#### C.3 — Scope-aware hook dispatch
**Where**: `packages/chess/src/engine.ts`, `packages/chess/src/presets/active-set.ts`
**Why**: Reduces per-preset scope boilerplate; makes scope semantics consistent.
**How**:
1. Every hook that has a "for whom" semantics gets a `scopeFilter: PieceColor` context field (set by the engine based on who's moving / whose piece is taking damage / etc).
2. `ActivePresetSet` gains `getForColorAndHook(color, hookName)` returning only presets whose scope covers `color`.
3. `onAfterMove` fires only for presets scoped to mover color (+ `both` which is default). Same for `onDamage` based on target's color.
4. `onCheckGameResult` fires for all (no scope — terminal state is global).
**Tests**: Test preset scoped `white`; apply a black move; verify preset's onAfterMove did NOT fire. Apply a white move; verify it DID.
---
#### C.4 — Phase hooks: `onBeforeMove` + `onTurnStart`
**Where**: `packages/chess/src/presets/registry.ts`, `packages/chess/src/engine.ts`
**Why**: Enable cooldown / stamina / resource-regen presets without shoehorning into `onAfterMove`.
**How**:
1. `onBeforeMove(ctx: BeforeMoveContext): { cancel?: boolean; reason?: string } | void` fires after move legality is confirmed but before any state mutation. Returning `{cancel: true}` aborts the move — engine treats as illegal and calls `toast.error` with `ctx.reason`. (This is a strong capability; fencing it with `cancel` vs a generic `consume` because we want explicit semantics.)
2. `onTurnStart(ctx: TurnStartContext): void` fires after the turn has flipped, before the new side-to-move's legal moves are computed. Good place for "regenerate stamina", "decrement cooldowns", "re-bless pieces on altar squares".
3. Engine dispatches both at the right points in `applyMove`.
**Tests**: Stamina-regen preset in test fixture — set Stamina to 0; run a turn; verify `onTurnStart` regenerated it to 1; run a pawn push; verify `onBeforeMove` consumes 1 stamina; if Stamina < cost, preset cancels move.
---
#### C.5 — Server-protocol: piece-type registry echoed on `game.state`
**Where**: `packages/server/src/broadcast.ts`, `packages/server/src/protocol.ts`, `packages/chess/src/net/types.ts`
**Why**: Clients that join mid-game need the piece-type registry to render a custom type correctly. Currently not an issue (all types hardcoded) but will be as soon as C.2 ships.
**How**:
1. Add optional `pieceTypes?: PieceTypeManifest[]` to `GameStatePayload`.
2. Server computes from `PRESET_REGISTRY` at room creation; sends on `room.joined` + `game.state`.
3. Client compares manifest to its local registry; if mismatch, shows a connection error ("server uses unknown piece types").
4. Protocol version stays `v: 1` — new optional field.
**Tests**: Unit — server sends manifest; client parses; mismatch rejected cleanly.
**GATE C**: `bun run check` green. Cannon piece type end-to-end test works (Playwright): create room with cannon preset, place cannon, move it, see it on both clients.
---
### Phase D — Developer Experience
#### D.1 — `packages/chess/src/presets/test-utils.ts`
**Where**: new file.
**Why**: Four preset test files copy-paste the same helpers.
**How**:
1. Export `pieceAt(engine, square)`, `hpOf(engine, id)`, `typeOf(engine, id)`, `exists(engine, id)`.
2. Export `clearBoard(engine, { preserveKings?: boolean })`.
3. Export `placePiece(engine, type, color, square, opts?): EntityId`.
4. Export `engineWithPosition(partial: Record<Square, { type, color }>): ChessEngine`.
5. Migrate the 4 existing preset test files to import from here; delete local copies.
**Tests**: `test-utils.test.ts` — cover each helper's happy path.
---
#### D.2 — `packages/chess/docs/PRESET-API.md`
**Where**: new file.
**Why**: The `PresetDef` interface is now the public API. Needs reference-quality docs.
**How**: Structured reference doc:
- Overview + philosophy (engine-as-minimal-core, presets-as-composition)
- `PresetDef` interface with every field + hook documented
- Hook firing order (chronology within an applyMove)
- Context objects — type reference for every one
- Piece attribute registration
- Piece type registration
- Preset state bag usage
- Effect bus usage
- Scope semantics
- Phase hooks with cooldown/regen examples
- "Your first custom preset" walkthrough — builds the Shield preset from scratch in ~50 lines
**Tests**: Linked from `packages/chess/README.md`. Manual proofread.
---
#### D.3 — Integration scenarios (prototype presets in tests)
**Where**: `packages/chess/src/presets/integration.test.ts` (new).
**Why**: Proves the APIs are sufficient end-to-end. Each scenario builds a plausible preset using ONLY registry-level APIs.
**How**: Four scenarios described in Verification Strategy above, each as its own `describe` block.
1. Shield attribute preset.
2. Cannon piece-type preset.
3. Berserker cooldown (presetState).
4. Pawn stamina (phase hooks).
Each prototype preset lives in-file; they're test fixtures, not shipped. They validate that a third-party preset author could accomplish the same.
**Tests**: The scenarios themselves ARE the tests.
**FINAL GATE**:
- `bun run check` green (target ~920 tests).
- Playwright E2E green.
- `packages/chess/docs/PRESET-API.md` exists and mentions every new API.
- Zero hardcoded `PIECE_ATTRS` / `PIECE_MOVE_GETTERS` / `ATTACK_PROBES` literals outside engine/registry/schema.
---
## Commit Strategy
One commit per task (23 commits). Each:
- Body explains root cause + fix + impact.
- References the task id (e.g., `A.2: engine.spawnPiece primitive`).
- Pre-commit hook runs `bun run check` — must be green.
Phase boundaries get a merge-commit summary with an updated progress checklist in the PR description.
No batch commits. No squashes. Easy bisect.
---
## Success Criteria
### Objective
- A new preset that declares a new piece attribute, a new piece type, uses per-preset state, emits visual effects, and participates in scope-aware hooks can be written entirely within `packages/chess/src/presets/` — zero edits to anything under `packages/chess/src/engine.ts`, `packages/chess/src/rules/`, or `packages/chess/src/ui/` (other than `VisualEffectLayer`'s per-kind component, which is acceptable — new visual styles naturally live with the renderer).
- All existing presets behave identically in isolation.
- All existing E2E flows pass.
### Verification Commands
```bash
# From repo root
bun run check # typecheck + lint + unit tests
bun x playwright test packages/chess/e2e/ --workers=1
# Spot-checks
grep -rn '\["PieceType", "Color"' packages/chess/src/ | wc -l # expect 0 outside engine.ts / registry.ts
grep -rn "const PIECE_MOVE_GETTERS" packages/chess/src/ | wc -l # expect 1 (engine.ts) or 0 if fully dynamic
```
### Stretch (not required)
- Migrate `capture-to-win.Winner` facts from `GAME_ENTITY` to `presetState`.
- Migrate `rules/promotion.ts` to use `engine.spawnPiece`.
- Server broadcasts visual effects on each delta so clients stay in sync.
---
## Risk Log
| Risk | Likelihood | Mitigation |
|---|---|---|
| Dynamic `PIECE_ATTRS` breaks serialization of saved games | Medium | Autosave is dev-only; bump save-version; clear old on mismatch. |
| Piece-type runtime string-union relaxation causes implicit `any` in exhaustiveness checks | Medium | Add `satisfies` assertions at registry edges; lint rule for `PieceType === "king"` comparisons |
| Effect bus leaks memory (subscribers never unsubscribe) | Low | `subscribeEffects` returns `Unsubscribe`; `Board` unmounts cleanly; add dev-mode warning on > 20 active subscribers. |
| `onBeforeMove.cancel` misused to silently break chess | Medium | Require `reason: string` when cancelling; surface via toast; document as "use sparingly". |
| Protocol changes cause client/server version drift | Low | New fields are optional; existing clients ignore; piece-type manifest check is advisory only (warn, don't disconnect) in first release. |
| Context-object migration misses a preset and drops hook | Low | Typechecker enforces; grep for bare `onDamage:` in every preset file as final sweep. |
---
## Post-Landing Backlog (not in this plan)
- `onPieceRetract` hook — symmetric to `onPieceSpawn`, fires when a piece dies. Needed for "death rattle" presets (e.g., a piece explodes on death). Defer until a concrete preset wants it.
- Undo/redo for moves — requires `MoveRecord` (B.2) as prerequisite; currently implemented as full state snapshots, which the MoveRecord stream can replace.
- Preset-authored UI panels — e.g., a Stamina-bar side panel. Needs a standard extension slot in `GameView`. Low priority.
- Server-side preset validation DSL — presets currently live in `packages/chess`; server imports the same registry. If presets ever become user-authored (disallowed in v1), server needs a validation/sandbox layer.

View file

@ -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<T>(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 `<Piece>` 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<T>(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.

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Routes, Route, useNavigate, useLocation, useParams } from 'react-router-dom'
import { Lobby } from '../ui/Lobby'
import { GameView, MultiplayerGameView } from '../ui/GameView'
@ -71,10 +71,20 @@ function GameRoute({
const { code: urlCode } = useParams<{ code?: string }>()
const navigate = useNavigate()
// `joining` gates rendering while we fire the auto-join request for
// a shared-link arrival. Null = no join in flight. During this window
// the page shows a minimal "Joining…" placeholder.
const [joining, setJoining] = useState<string | null>(null)
// Synchronous ref guarding against React StrictMode's dev-mode
// double-effect. State updates from the first effect invocation
// don't propagate to the second (both run before React commits), so
// a state guard would be insufficient — both passes would see null,
// both fire room.join, and the second gets ROOM_FULL from the
// server because the first already occupied the second slot.
//
// Refs update synchronously: setting `joinedCodeRef.current` in one
// effect is visible in the StrictMode second invocation immediately.
//
// The "Joining…" placeholder is driven off the absence of mpCreds
// plus the presence of urlCode — no separate `joining` state
// needed.
const joinedCodeRef = useRef<string | null>(null)
const [mpCreds, setMpCreds] = useState(() => {
const code = sessionStorage.getItem('room-code')
@ -94,39 +104,31 @@ function GameRoute({
}, [urlCode, mpCreds, navigate])
// Case 4: /game/:code with no matching creds → auto-join the room
// and stash the returned creds. Run once per URL code.
//
// We deliberately INCLUDE `mpCreds`/`joining` in the dep array so the
// effect is always consistent with the exhaustive-deps lint rule, and
// use the top-of-effect guards to prevent re-entry:
// - returns early if URL has no code
// - returns early once mpCreds is populated (we're done)
// - returns early if a join for this exact code is already in flight
//
// After a successful join, `setMpCreds({...})` triggers a re-render,
// the effect re-runs, and the `mpCreds !== null` guard short-circuits
// it cleanly without loops.
// and stash the returned creds. Guarded against StrictMode double-
// invocation by `joinedCodeRef`.
useEffect(() => {
if (urlCode === undefined) return
if (mpCreds !== null) return
const normalised = urlCode.toUpperCase()
if (joining === normalised) return
setJoining(normalised)
if (joinedCodeRef.current === normalised) return
joinedCodeRef.current = normalised
oneShotRoomRequest('room.join', { code: normalised })
.then(({ code, token, color }) => {
sessionStorage.setItem('room-code', code)
sessionStorage.setItem('room-token', token)
sessionStorage.setItem('player-color', color)
setMpCreds({ code, token })
setJoining(null)
})
.catch((err: unknown) => {
const msg =
err instanceof Error ? err.message : 'Could not join room'
toast.error(`Join failed: ${msg}`)
// Reset the ref so a retry from the lobby isn't permanently
// blocked by a stale "already attempted this code" flag.
joinedCodeRef.current = null
navigate('/', { replace: true })
})
}, [urlCode, mpCreds, joining, navigate])
}, [urlCode, mpCreds, navigate])
if (urlCode !== undefined && mpCreds === null) {
return (

View file

@ -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<PieceType, MoveGetter> = {
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<string, unknown>;
}
/** 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<T>(presetId)`. Calls are
* scoped: two presets calling `state.set("x", 1)` with different
* preset IDs each get their own `x`.
*/
export interface PresetState<T extends Record<string, unknown>> {
/** Read a state key. Returns undefined when unset. */
get<K extends keyof T>(key: K): T[K] | undefined;
/** Write a state key. Overwrites any previous value. */
set<K extends keyof T>(key: K, value: T[K]): void;
/** Delete a state key. Returns true if it was set, false otherwise. */
delete<K extends keyof T>(key: K): boolean;
/** Returns the full state snapshot as a plain object. */
all(): Partial<T>;
/** True if `key` has been set. */
has<K extends keyof T>(key: K): boolean;
}
class PresetStateImpl<T extends Record<string, unknown>>
implements PresetState<T>
{
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<K extends keyof T>(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<K extends keyof T>(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<K extends keyof T>(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<K extends keyof T>(key: K): boolean {
return this.#session.contains(PRESET_STATE_ENTITY, this.#attrOf(key as string));
}
all(): Partial<T> {
const result: Record<string, unknown> = {};
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<T>;
}
}
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<MoveRecord> {
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<VisualEffectHandler>();
/**
* 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<ChessAttrKey>(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<T extends Record<string, unknown> = Record<string, unknown>>(
presetId: string,
): PresetState<T> {
return new PresetStateImpl<T>(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;
}

View file

@ -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<string, unknown> {
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<CaptureToWinState>("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<CaptureToWinState>("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.
});

View file

@ -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 },
});

View file

@ -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();

View file

@ -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

View file

@ -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";

View file

@ -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<readonly [number, number]> = [
[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<string, unknown> {
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<BerserkerState>(BERSERKER_ID);
state.set("lastRageTurn", 4);
expect(state.get("lastRageTurn")).toBe(4);
// Different preset id — no collision.
const other = engine.presetState<BerserkerState>("piece-hp");
expect(other.get("lastRageTurn")).toBeUndefined();
// Deactivate → state cleared automatically.
engine.setActivePresets([]);
const afterDeact = engine.presetState<BerserkerState>(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");
});
});

View file

@ -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 },
});
}
},
});

View file

@ -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.

View file

@ -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([]);
});
});

View file

@ -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"]);
});
});

View file

@ -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";

View file

@ -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
* `<Piece>` 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 `<Piece>` 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<string, PieceTypeDef>();
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();

View file

@ -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<EntityId, number>();
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,
});
}
}
},
});

View file

@ -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<string, unknown> {
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<DemoState>("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<DemoState>("demo");
expect(state.get("counter")).toBeUndefined();
});
it("has() reports presence correctly", () => {
const engine = new ChessEngine();
const state = engine.presetState<DemoState>("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<DemoState>("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<DemoState>("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<DemoState>("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<DemoState>("preset-a");
const b = engine.presetState<DemoState>("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<DemoState>("demo");
const s2 = e2.presetState<DemoState>("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<DemoState>("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<DemoState>("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<DemoState>("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");
});
});

View file

@ -57,17 +57,12 @@ const CLOCKWISE_NEIGHBOURS: ReadonlyArray<readonly [number, number]> = [
[-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;
}

View file

@ -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;
}
/**

View file

@ -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;
}

View file

@ -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"));
});
});

View file

@ -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);
}

View file

@ -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<PieceType, AttackProbe> = {
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);
}
}

View file

@ -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<PieceType, MoveGetter> = {
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);

View file

@ -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<PieceType, MoveGetter> = {
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);
}

View file

@ -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);
}

View file

@ -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.

View file

@ -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<string>;
/** 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
<div className="grid grid-cols-8 grid-rows-8 w-full max-w-2xl mx-auto rounded shadow-[0_20px_40px_-20px_rgba(0,0,0,0.3),_0_4px_8px_-2px_rgba(0,0,0,0.1)] bg-neutral-900 border-4 border-neutral-800">
{squares}
</div>
{engine !== undefined && <VisualEffectLayer engine={engine} />}
<AnimatePresence>
{promotionMove && (

View file

@ -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 */}

View file

@ -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);

View file

@ -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<string, EffectRenderer> = {
explosion: (effect) => (
<motion.div
initial={{ scale: 0.3, opacity: 0.9 }}
animate={{ scale: 2, opacity: 0 }}
transition={{ duration: (effect.ttl ?? 600) / 1000, ease: "easeOut" }}
className="absolute w-[12.5%] aspect-square rounded-full bg-gradient-radial from-orange-300 via-red-500 to-red-900/0"
style={{ ...squareStyle(effect.square ?? 0), mixBlendMode: "screen" }}
data-effect="explosion"
data-effect-square={effect.square ?? ""}
/>
),
heal: (effect) => (
<motion.div
initial={{ scale: 0.8, opacity: 0.8 }}
animate={{ scale: 1.3, opacity: 0 }}
transition={{ duration: (effect.ttl ?? 400) / 1000, ease: "easeOut" }}
className="absolute w-[12.5%] aspect-square rounded-full bg-gradient-radial from-emerald-200 via-emerald-400/60 to-emerald-400/0"
style={{ ...squareStyle(effect.square ?? 0) }}
data-effect="heal"
data-effect-square={effect.square ?? ""}
/>
),
poison: (effect) => (
<motion.div
initial={{ scale: 0.6, opacity: 0.7, y: 4 }}
animate={{ scale: 1.1, opacity: 0, y: -6 }}
transition={{ duration: (effect.ttl ?? 250) / 1000, ease: "easeOut" }}
className="absolute w-[12.5%] aspect-square rounded-full bg-gradient-radial from-fuchsia-300 via-fuchsia-600/60 to-fuchsia-900/0"
style={{ ...squareStyle(effect.square ?? 0) }}
data-effect="poison"
data-effect-square={effect.square ?? ""}
/>
),
};
/** Fallback for effects with no registered renderer. */
function GenericPulse({ effect }: { effect: VisualEffect }) {
return (
<motion.div
initial={{ scale: 0.8, opacity: 0.6 }}
animate={{ scale: 1.4, opacity: 0 }}
transition={{ duration: (effect.ttl ?? 300) / 1000 }}
className="absolute w-[12.5%] aspect-square rounded-full bg-white/50"
style={effect.square !== null ? squareStyle(effect.square) : { inset: 0, width: "100%" }}
data-effect={effect.kind}
data-effect-square={effect.square ?? ""}
/>
);
}
interface VisualEffectLayerProps {
readonly engine: ChessEngine;
}
export function VisualEffectLayer({ engine }: VisualEffectLayerProps) {
const [effects, setEffects] = useState<TaggedEffect[]>([]);
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 (
<div
className="absolute inset-0 pointer-events-none"
data-testid="visual-effect-layer"
>
<AnimatePresence>
{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 });
})}
</AnimatePresence>
</div>
);
}