feat(chess): preset-flexibility architecture — decouple HP, damage, piece-types, state, effects
Decouples five cross-cutting concerns from engine core so new presets compose without special-casing: - Piece attributes: CORE_PIECE_ATTRS + PresetDef.pieceAttributes; engine.effectivePieceAttrs unions them. HP is now preset-owned. - Spawn pipeline: engine.spawnPiece() + onPieceSpawn hook replaces hand-rolled materialization. Queen-splits seeds HP via the hook, not via direct knowledge of piece-hp. - Hook signatures: all hooks now take single context objects (LifecycleContext, MoveHookContext, CaptureHookContext, DamageHookContext, SelfCheckFilterContext, GameResultHookContext, PieceSpawnContext, BeforeMoveContext, TurnStartContext, DescribeMoveEffectContext). - Piece-type registry: PIECE_TYPE_REGISTRY + core-piece-types.ts replaces hardcoded FIDE switch-tables in engine/check/checkmate/ stalemate/Piece.tsx. Custom piece types plug in without engine edits. - Preset-scoped state: engine.presetState<T>(id) backed by facts on PRESET_STATE_ENTITY. Auto-cleared on deactivate. capture-to-win migrated off GAME_ENTITY.Winner. - Move log: engine.moveLog + MoveRecord + describeMoveEffect hook. - Visual effects: engine.emitEffect/subscribeEffects + VisualEffect + VisualEffectLayer. Explosion, heal, poison renderers. No-op when no subscribers. - Phase hooks: onBeforeMove (with cancel), onTurnStart. Scope-aware dispatch routes onDamage/onBeforeMove/onTurnStart by target/mover color. All 5 production presets migrated. 4 prototype presets (Shield, Cannon, Berserker, PawnStamina) in integration.test.ts exercise every hook. Added test-utils.ts and PRESET-API.md for preset authors. 930 tests passing; bun run check clean.
This commit is contained in:
parent
7c4c942938
commit
cc30545ced
32 changed files with 3611 additions and 304 deletions
|
|
@ -32,7 +32,10 @@
|
||||||
"ses_263703df9ffegmVplLbaxmQIes",
|
"ses_263703df9ffegmVplLbaxmQIes",
|
||||||
"ses_262de3483ffe5v8SD8eFNqtdo0",
|
"ses_262de3483ffe5v8SD8eFNqtdo0",
|
||||||
"ses_262de1b3bffexz022qa8FnxRyV",
|
"ses_262de1b3bffexz022qa8FnxRyV",
|
||||||
"ses_262ddfb93ffeMGkZK2rspryFfX"
|
"ses_262ddfb93ffeMGkZK2rspryFfX",
|
||||||
|
"ses_262998e6fffeRY6BJVTKB7Jtwb",
|
||||||
|
"ses_26299a749ffe3jnwQzrfJ9g1Wc",
|
||||||
|
"ses_261e4ca30ffexBShmvjEhVSHRy"
|
||||||
],
|
],
|
||||||
"plan_name": "rete-rules-engine",
|
"plan_name": "rete-rules-engine",
|
||||||
"agent": "atlas"
|
"agent": "atlas"
|
||||||
|
|
|
||||||
572
.sisyphus/plans/preset-flexibility-architecture.md
Normal file
572
.sisyphus/plans/preset-flexibility-architecture.md
Normal file
|
|
@ -0,0 +1,572 @@
|
||||||
|
# Preset Flexibility — Architecture Pass
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
Nine architectural improvements plus two phase hooks that unblock the next generation of chess-variant presets without a single engine touch per preset.
|
||||||
|
|
||||||
|
**Core problems today**:
|
||||||
|
1. `PIECE_ATTRS` is hardcoded — new attribute-carrying presets (Shield, Armor, Stamina) leave zombie facts on death.
|
||||||
|
2. No piece-spawn primitive — `queen-splits` has a latent HP-coupling bug waiting for a mid-game activation order to trigger.
|
||||||
|
3. Piece types are a closed union — no Cannon, Amazon, Nightrider without editing core.
|
||||||
|
4. No visual effect bus — explosions/heals/poison ticks are silent.
|
||||||
|
5. No move log — history panels, PGN export, analysis tools all blocked.
|
||||||
|
6. Per-preset state is ad-hoc (global modules or game-entity facts); breaks concurrent games and save/load.
|
||||||
|
7. Hook signatures are positional — growing them breaks every implementer.
|
||||||
|
8. `onAfterMove`, `onDamage`, `onCheckGameResult` ignore scope; presets re-derive it.
|
||||||
|
9. Test harness pattern duplicated across every preset test file.
|
||||||
|
|
||||||
|
**Deliverable**: after this work, a preset can add a new piece type, a new piece attribute, new damage kinds, new visual effects, and new per-game state — all without changing a single line of engine/core code. Every existing preset continues to behave identically in isolation; new compositions (HP + any new damage source) work automatically.
|
||||||
|
|
||||||
|
**Phases**: 4 phases, 23 tasks, hard-gated by `bun run check` green + behavioral tests green between each.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
### What Shipped Last
|
||||||
|
|
||||||
|
- Damage pipeline landed. `engine.dealDamage(target, amount, ctx)` is the canonical damage primitive; `piece-hp.onDamage` is the sole damage→death arbiter.
|
||||||
|
- `explosive-rook` + `piece-hp` now compose naturally (blast deals 1 HP each; pieces with HP>1 survive). The previous hard incompatibility was removed.
|
||||||
|
- `queen-splits` and `poisoned-squares` route captures/ticks through the pipeline.
|
||||||
|
- 889 unit tests pass, including 12 new damage-pipeline tests.
|
||||||
|
|
||||||
|
### What Still Couples Presets to Core
|
||||||
|
|
||||||
|
Audited the live code for every preset-visible engine surface. The remaining friction points:
|
||||||
|
|
||||||
|
- `packages/chess/src/rules/capture.ts:10` — `PIECE_ATTRS` is a frozen 5-element tuple. Adding an attribute requires editing this file.
|
||||||
|
- `packages/chess/src/presets/piece-hp.ts:77` — `const PIECE_ATTRS = [...]` duplicates the list. Another edit point.
|
||||||
|
- `packages/chess/src/presets/queen-splits.ts:60` + `:84-97` — owns a `PIECE_ATTRS` copy plus a private `spawnPiece` helper. Spawned pieces aren't passed through any hook — if `piece-hp` activates AFTER queen-splits spawns pieces, those pieces have no `Hp`.
|
||||||
|
- `packages/chess/src/schema.ts:PieceType` — fixed union of 6 types. Hardcoded in `PIECE_MOVE_GETTERS` (`engine.ts:61`), `ATTACK_PROBES` (`check.ts:68`), `MOVE_GETTERS` (`checkmate.ts:44`), `stalemate.ts`, plus UI asset maps. Five synchronized tables.
|
||||||
|
- `packages/chess/src/engine.ts:applyMove` — emits nothing. Clients reconstruct history from fact diffs. No per-move summary, no notation, no event stream.
|
||||||
|
- `packages/chess/src/presets/capture-to-win.ts:46` + others — use `GAME_ENTITY` facts as ad-hoc state storage. Mixed with real game-level facts (`Turn`, `EnPassantTarget`, `HalfmoveClock`). Collision surface grows with each preset.
|
||||||
|
- `packages/chess/src/presets/registry.ts` — every hook is `(engine, ...positional args) => Result | void`. Every signature change is a breaking change to every preset.
|
||||||
|
- `packages/chess/src/presets/*.test.ts` — `pieceAt`, `hpOf`, `typeOf`, `clearRank` are copy-pasted across at least 4 files.
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
|
||||||
|
- Any new **presets** (piece types, rules). This plan is pure infrastructure.
|
||||||
|
- Save-file forward compatibility for saved games that predate this refactor. Autosave is dev-only; we can ship a one-line version bump + clear stale saves.
|
||||||
|
- Server-protocol version bump beyond what structured move emissions require. We keep protocol v1 and add new optional fields.
|
||||||
|
- UI redesigns beyond the minimum needed to render new visual effects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Work Objectives
|
||||||
|
|
||||||
|
### Core Objective
|
||||||
|
|
||||||
|
Decouple every preset-reachable engine surface so the `PresetDef` interface + registry is the ONLY thing a new preset touches.
|
||||||
|
|
||||||
|
### Concrete Deliverables
|
||||||
|
|
||||||
|
1. **Dynamic piece-attribute registry** — presets declare their attrs; retract / damage / save paths consult the union.
|
||||||
|
2. **`spawnPiece` primitive** + **`onPieceSpawn` hook** — single entry point for creating pieces; every active preset gets a chance to tag new entities.
|
||||||
|
3. **Custom piece-type registry** — presets register `(id, moveGenerator, attackProbe, assets)`; engine + UI look them up dynamically.
|
||||||
|
4. **Per-preset state bag** — `engine.presetState<T>(id)` with typed, engine-scoped, serialization-included state.
|
||||||
|
5. **Visual effect bus** — `engine.emitEffect({...})` + React subscriber; explosions/heals/poison ticks animate.
|
||||||
|
6. **Structured move log** — `applyMove` populates `MoveRecord`; `engine.moveLog` is the authoritative history stream.
|
||||||
|
7. **Hook-context objects** — all hooks take a single growable context object; future fields are additive.
|
||||||
|
8. **Scope-aware hook dispatch** — engine skips hooks whose scope doesn't cover the current color.
|
||||||
|
9. **Test harness utilities** — shared `test-utils.ts` for `engineWithPosition`, `placePiece`, `clearBoard`, etc.
|
||||||
|
10. **Phase hooks** — `onBeforeMove` (pre-validation, pre-mutation) and `onTurnStart` (after turn flip, before legal moves are regenerated) for cooldown/regen/resource-tick presets.
|
||||||
|
11. **Tests** — each new API gets unit tests; one integration test verifies a hypothetical "Shield" preset can be written from the registry alone without engine edits.
|
||||||
|
|
||||||
|
### Definition of Done
|
||||||
|
|
||||||
|
- `bun run check` green (typecheck + lint + 900+ tests).
|
||||||
|
- Every existing preset still works with no behavioral change in isolation (regression).
|
||||||
|
- The three prototype presets built in test code to exercise the new surfaces (Cannon piece type, Shield attribute, DamageCooldown state) all work.
|
||||||
|
- No references to literal `["PieceType", "Color", "Position", "HasMoved", "Hp"]` outside `engine.ts` / `registry.ts` / `schema.ts`.
|
||||||
|
- New `packages/chess/docs/PRESET-API.md` documenting the complete extension surface.
|
||||||
|
|
||||||
|
### Must Have
|
||||||
|
|
||||||
|
- All 9 items from the architecture review + 2 phase hooks.
|
||||||
|
- Backward-compatible hook signatures via the context-object refactor (old positional signatures accepted as a deprecation layer, removed in a single sweep after all in-tree callers migrate).
|
||||||
|
- Zero behavior regressions for existing presets (piece-hp, explosive-rook, queen-splits, king-heals, poisoned-squares, capture-to-win, last-piece-standing, all geometry presets).
|
||||||
|
|
||||||
|
### Must NOT Have (Guardrails)
|
||||||
|
|
||||||
|
- No event-bus abstraction for cross-preset communication. Presets talk to the ENGINE, not each other. If two presets need to coordinate, it's a design smell requiring review.
|
||||||
|
- No topological sort / priority system for preset ordering. Registration order + first-consumer-wins is sufficient. If ordering matters for a future pair, that's a design conversation, not a generic engine feature.
|
||||||
|
- No preset-defined alternative move validators. Move generation stays in `rules/`; presets *contribute extra moves* or *filter existing moves* via `getExtraMoves` / `filterMoves`, not replace the validator.
|
||||||
|
- No runtime piece-type registration through the UI / save files. Piece types are dev-authored TS modules that `register` at module-load time. User-uploaded code remains forbidden (RCE vector).
|
||||||
|
- No breaking changes to the WebSocket protocol shape. New optional fields only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Strategy
|
||||||
|
|
||||||
|
### Test Decision
|
||||||
|
|
||||||
|
**TDD for each engine API surface**. Unit tests written alongside (or before) each primitive. Integration-style tests for composite behaviors (e.g., "HP + custom-type Cannon composes correctly").
|
||||||
|
|
||||||
|
### Regression Net
|
||||||
|
|
||||||
|
The existing 889 tests cover every shipped preset. They stay green through the entire refactor. `bun run check` runs at the end of every phase.
|
||||||
|
|
||||||
|
### Integration scenarios (run in the final verification wave)
|
||||||
|
|
||||||
|
1. **Shield attribute, from nothing**: Write a `piece-shield` preset using only public APIs — declares `pieceAttributes: ["Shield"]`, seeds on `onActivate`, absorbs damage via `onDamage` with higher priority than HP. Confirm retraction cleans up `Shield` facts (dynamic `PIECE_ATTRS`).
|
||||||
|
2. **Cannon piece type**: Write a preset that spawns a Cannon on d1 (Chinese-chess semantics — moves like a rook, captures only by jumping one piece). Register the piece type via the new API. Confirm UI renders it; move generation works; it's a legal target for checks; it saves/loads.
|
||||||
|
3. **Per-preset state survival**: Write a `berserker-cooldown` preset that uses `engine.presetState` to track last-rage-turn per piece. Save → restore → state is preserved. Activate → deactivate → state is cleared.
|
||||||
|
4. **Visual effect smoke test**: Playwright test — enable explosive-rook, trigger a capture, assert a DOM element with `data-effect="explosion"` appears briefly on the target square.
|
||||||
|
5. **Phase hook cooldown preset**: Write a `pawn-stamina` preset that uses `onTurnStart` to regenerate a `Stamina` attribute up to 2. Consuming it via `onBeforeMove` when pawns push. Proves the new phase hooks fire at the right times.
|
||||||
|
|
||||||
|
### QA Policy
|
||||||
|
|
||||||
|
- After every task: run focused tests for the touched file(s).
|
||||||
|
- After every phase: `bun run check` must be clean.
|
||||||
|
- After the full plan: Playwright smoke (`packages/chess/e2e/*.spec.ts --workers=1`), verify the 3 existing E2E specs + any new visual-effect one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Strategy
|
||||||
|
|
||||||
|
### Phase Structure
|
||||||
|
|
||||||
|
Four phases, strictly sequential. Within each phase, tasks with no mutual dependency can run in parallel.
|
||||||
|
|
||||||
|
- **Phase A — Foundations**: dynamic attributes, spawn primitive, context objects. Touches `engine.ts` + `registry.ts` + per-preset cleanup. Everything else in this plan builds on this.
|
||||||
|
- **Phase B — State & Events**: per-preset state bag, move log, visual effect bus. These add emission points to `applyMove`; need A's context objects to be clean.
|
||||||
|
- **Phase C — Extension Surfaces**: custom piece types, scope-aware dispatch, phase hooks. New hooks declared in PresetDef; engine dispatches them; prototype presets in tests verify they fire.
|
||||||
|
- **Phase D — Developer Experience**: test harness, docs, final integration scenarios.
|
||||||
|
|
||||||
|
### Parallel Execution Waves
|
||||||
|
|
||||||
|
Phase A (serial foundation, then parallel cleanup):
|
||||||
|
|
||||||
|
Wave A.1 (sequential — core APIs):
|
||||||
|
A.1 PIECE_ATTRS → dynamic registry (engine + capture.ts)
|
||||||
|
A.2 engine.spawnPiece + onPieceSpawn hook
|
||||||
|
A.3 Hook context objects (HookContext, MoveContext, DamageContext renames)
|
||||||
|
|
||||||
|
Wave A.2 (parallel — cleanup existing presets using A.1-A.3):
|
||||||
|
A.4 piece-hp: migrate to declared attrs + context args
|
||||||
|
A.5 queen-splits: migrate to engine.spawnPiece, drop PIECE_ATTRS copy
|
||||||
|
A.6 explosive-rook: drop retractEntity helper, use dynamic PIECE_ATTRS
|
||||||
|
|
||||||
|
GATE: bun run check; all 889 existing tests green.
|
||||||
|
|
||||||
|
Phase B (parallel — state / events / log):
|
||||||
|
|
||||||
|
Wave B.1 (parallel):
|
||||||
|
B.1 engine.presetState<T>() + session-backed storage
|
||||||
|
B.2 MoveRecord + engine.moveLog + applyMove emission
|
||||||
|
B.3 engine.emitEffect() + EffectStream subscriber API
|
||||||
|
B.4 UI: VisualEffectLayer reads the stream, renders with Motion
|
||||||
|
|
||||||
|
GATE: bun run check; 3 new unit test suites (state, log, effects) pass.
|
||||||
|
|
||||||
|
Phase C (parallel except C.1 which gates C.2-C.3):
|
||||||
|
|
||||||
|
Wave C.1 (sequential foundation):
|
||||||
|
C.1 PieceTypeRegistry (engine + UI asset lookup)
|
||||||
|
|
||||||
|
Wave C.2 (parallel, depends on C.1):
|
||||||
|
C.2 registerPieceType API + engine move/attack tables become dynamic
|
||||||
|
C.3 Scope-aware hook dispatch (onAfterMove/onDamage/onCheckGameResult/new phase hooks)
|
||||||
|
C.4 onBeforeMove + onTurnStart phase hooks
|
||||||
|
C.5 Server-protocol: piece-type registry echoed on game.state
|
||||||
|
|
||||||
|
GATE: bun run check; Cannon prototype preset works end-to-end.
|
||||||
|
|
||||||
|
Phase D:
|
||||||
|
|
||||||
|
Wave D.1 (parallel):
|
||||||
|
D.1 packages/chess/src/presets/test-utils.ts + migration of existing tests
|
||||||
|
D.2 packages/chess/docs/PRESET-API.md (complete extension surface)
|
||||||
|
D.3 Integration scenarios (Shield / Cannon / Berserker / Stamina prototypes in test code)
|
||||||
|
|
||||||
|
FINAL GATE: bun run check + Playwright E2E + all integration scenarios green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODOs
|
||||||
|
|
||||||
|
### Phase A — Foundations
|
||||||
|
|
||||||
|
#### A.1 — Dynamic piece-attribute registry
|
||||||
|
|
||||||
|
**Where**: `packages/chess/src/rules/capture.ts`, `packages/chess/src/engine.ts`, `packages/chess/src/presets/registry.ts`
|
||||||
|
**Why**: Preset-declared attributes (Shield, Stamina, Armor) must be retracted on death / serialized on save. Hardcoded list = silent bug.
|
||||||
|
**How**:
|
||||||
|
|
||||||
|
1. Rename existing `PIECE_ATTRS` to `CORE_PIECE_ATTRS` in `capture.ts`; keep it as the FIDE minimum.
|
||||||
|
2. In `registry.ts` add `readonly pieceAttributes?: readonly AttrKey[]` to `PresetDef`.
|
||||||
|
3. In `engine.ts` add a private computed getter `get effectivePieceAttrs(): readonly AttrKey[]` that returns `CORE_PIECE_ATTRS ∪ union(active preset pieceAttributes)`.
|
||||||
|
4. Replace every in-tree reference to literal `["PieceType", "Color", "Position", "HasMoved", "Hp"]` with `engine.effectivePieceAttrs` (or `CORE_PIECE_ATTRS` for pure-function contexts).
|
||||||
|
5. `dealDamage`'s default retract path uses the dynamic list. Same for piece-hp's lethal retract path. Same for queen-splits' queen retract.
|
||||||
|
6. `Hp` attribute moves OFF `CORE_PIECE_ATTRS` — it's owned by `piece-hp` via `pieceAttributes: ["Hp"]`.
|
||||||
|
|
||||||
|
**Expected result**: `PIECE_ATTRS` no longer appears as a literal in any preset. Adding a new attribute-carrying preset requires only a `pieceAttributes` declaration. Existing HP tests continue to pass — `Hp` is cleaned up when pieces die because `piece-hp` contributes it to the dynamic list.
|
||||||
|
|
||||||
|
**Tests**: Unit — a test preset that declares `pieceAttributes: ["TestMark"]`, seeds it on onActivate, then gets retracted on piece death. Verify no `TestMark` fact remains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### A.2 — `engine.spawnPiece()` primitive + `onPieceSpawn` hook
|
||||||
|
|
||||||
|
**Where**: `packages/chess/src/engine.ts`, `packages/chess/src/presets/registry.ts`, `packages/chess/src/presets/queen-splits.ts`
|
||||||
|
**Why**: `queen-splits` has a latent bug — pieces it spawns don't get `Hp` seeded if HP was activated *after* queen-splits started having reason to spawn. Plus future presets (promotion variants, summoning, phoenix) need the same thing.
|
||||||
|
**How**:
|
||||||
|
|
||||||
|
1. Add to `PresetDef`: `readonly onPieceSpawn?: (ctx: PieceSpawnContext) => void;`. `PieceSpawnContext = { engine, pieceId, type, color, square, reason: "promotion" | "fission" | "summon" | (string & {}) }`.
|
||||||
|
2. Add `engine.spawnPiece(type, color, square, opts?: { reason?: string; hasMoved?: boolean }): EntityId`. Creates entity, inserts CORE attrs, then iterates `activePresets.list()` calling each `onPieceSpawn`. Returns the new id.
|
||||||
|
3. `piece-hp` implements `onPieceSpawn` → `engine.session.insert(id, "Hp", DEFAULT_HP)`.
|
||||||
|
4. `queen-splits` deletes its private `spawnPiece` helper, calls `engine.spawnPiece("rook", color, square, { reason: "fission" })` twice.
|
||||||
|
5. (Future) promotion rule in `rules/promotion.ts` migrates to `spawnPiece` — tracked as stretch for this task, do only if trivial.
|
||||||
|
|
||||||
|
**Expected result**: Activating queen-splits in any order with piece-hp produces fission-spawned pieces with `Hp = 2` as expected. No more "idempotent onActivate" duct tape.
|
||||||
|
|
||||||
|
**Tests**: Unit — activate queen-splits, then piece-hp, then trigger fission. Verify spawned rook + bishop both have `Hp = 2`. Activate in reverse order. Verify same.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### A.3 — Hook context objects
|
||||||
|
|
||||||
|
**Where**: `packages/chess/src/presets/registry.ts`, `packages/chess/src/engine.ts`, all `packages/chess/src/presets/*.ts`
|
||||||
|
**Why**: Positional signatures break every preset when we add a field. Context objects are growable.
|
||||||
|
**How**:
|
||||||
|
|
||||||
|
1. Define per-hook context types in `registry.ts`:
|
||||||
|
```ts
|
||||||
|
interface HookContext { readonly engine: ChessEngine; }
|
||||||
|
interface MoveHookContext extends HookContext { readonly moverColor: PieceColor; readonly move: MoveRecord; /* added in B.2 */ }
|
||||||
|
interface DamageHookContext extends HookContext {
|
||||||
|
readonly target: EntityId;
|
||||||
|
readonly amount: number;
|
||||||
|
readonly kind: string;
|
||||||
|
readonly attacker?: EntityId;
|
||||||
|
}
|
||||||
|
interface PieceSpawnContext extends HookContext {
|
||||||
|
readonly pieceId: EntityId;
|
||||||
|
readonly type: PieceType;
|
||||||
|
readonly color: PieceColor;
|
||||||
|
readonly square: Square;
|
||||||
|
readonly reason: string;
|
||||||
|
}
|
||||||
|
// etc for every hook
|
||||||
|
```
|
||||||
|
2. Migrate every `PresetDef` hook signature to take a single context object.
|
||||||
|
3. Migrate every preset implementation. Engine dispatchers construct context objects and pass them.
|
||||||
|
4. Temporarily keep positional form for `onDamage` and `onBeforeCapture` accepted via an adapter, emit a `console.warn` in dev-mode. Remove the adapter in the same PR once all callers migrate (since this is all in-tree).
|
||||||
|
|
||||||
|
**Expected result**: Future hook additions are purely additive — `MoveHookContext` gains a `lastCaptureSquare?: Square` field, zero presets break.
|
||||||
|
|
||||||
|
**Tests**: Migrate existing hook tests to new signature; add a single assertion that unknown context fields are silently ignored by presets that don't read them (typechecker covers this).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### A.4 — Migrate `piece-hp` to declared attrs + context args
|
||||||
|
|
||||||
|
Depends on A.1, A.2, A.3.
|
||||||
|
**Why**: Prove the new pattern works on the most-used preset first.
|
||||||
|
**How**:
|
||||||
|
|
||||||
|
- Add `pieceAttributes: ["Hp"]` to the PresetDef.
|
||||||
|
- Drop the in-file `PIECE_ATTRS` duplicate.
|
||||||
|
- Hook signatures converted to context args.
|
||||||
|
- `onPieceSpawn` implementation added (seeds Hp=2 on new entities).
|
||||||
|
- `onActivate` becomes slightly simpler (no need to iterate and check; pieces spawned after activation auto-get Hp via `onPieceSpawn`).
|
||||||
|
|
||||||
|
**Tests**: Existing piece-hp tests + fleshed-presets tests stay green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### A.5 — Migrate `queen-splits` to `engine.spawnPiece`
|
||||||
|
|
||||||
|
Depends on A.2.
|
||||||
|
**Why**: Removes queen-splits's private spawn helper; removes the dead `// ... relies on piece-hp onActivate idempotence` comment.
|
||||||
|
**How**: Replace the two `spawnPiece(session, ...)` calls with `engine.spawnPiece(...)`. Delete the private helper. Drop the `PIECE_ATTRS` constant.
|
||||||
|
**Tests**: Existing queen-splits tests stay green. Add one explicit test for the "HP activated after queen-splits mid-game" scenario.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### A.6 — Migrate `explosive-rook`, `poisoned-squares` to dynamic attrs
|
||||||
|
|
||||||
|
Depends on A.1.
|
||||||
|
**Why**: These carry their own retraction helpers.
|
||||||
|
**How**: Replace local `PIECE_ATTRS` with `engine.effectivePieceAttrs` where they retract directly (explosive-rook's fallback path if it's not using dealDamage for some future reason; poisoned-squares already uses dealDamage so may need zero edits — audit). Keep behaviour identical.
|
||||||
|
**Tests**: Existing tests stay green.
|
||||||
|
|
||||||
|
**GATE A**: `bun run check` green. Every existing test passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase B — State & Events
|
||||||
|
|
||||||
|
#### B.1 — `engine.presetState<T>(id)`
|
||||||
|
|
||||||
|
**Where**: `packages/chess/src/engine.ts`, `packages/chess/src/schema.ts`
|
||||||
|
**Why**: Per-game, per-preset state with zero collision, included in save-state.
|
||||||
|
**How**:
|
||||||
|
|
||||||
|
1. Define `PRESET_STATE_ENTITY` symbol in `schema.ts` (new entity id, below `GAME_ENTITY`).
|
||||||
|
2. `engine.presetState<T>(presetId: string): T` returns a typed Proxy backed by session facts of the form `(PRESET_STATE_ENTITY, `${presetId}::${key}`, value)`.
|
||||||
|
3. `get` reads the fact; `set` inserts/retracts; `delete` retracts.
|
||||||
|
4. On preset deactivate, engine retracts every fact with attr matching `${presetId}::*`.
|
||||||
|
5. Fact serialization already covers these — save/load works automatically.
|
||||||
|
|
||||||
|
**Tests**:
|
||||||
|
- Write + read + save + load round-trip.
|
||||||
|
- Activate → write state → deactivate → state cleared → reactivate → empty.
|
||||||
|
- Two concurrent engines with the same preset ID have independent state (server concurrency).
|
||||||
|
|
||||||
|
**Expected result**: Migrate `capture-to-win`'s `Winner` fact from `GAME_ENTITY` to its preset-state bag as a proof point.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### B.2 — `MoveRecord` + `engine.moveLog` + `applyMove` emission
|
||||||
|
|
||||||
|
**Where**: `packages/chess/src/engine.ts`, `packages/chess/src/presets/registry.ts` (new hook `describeMoveEffect`)
|
||||||
|
**Why**: History UI, PGN export, replay — all need a per-move summary. Currently requires diffing facts.
|
||||||
|
**How**:
|
||||||
|
|
||||||
|
1. Add `MoveRecord` interface in `engine.ts`: `{ from, to, moverColor, movingType, capturedId, capturedType, isCheck, isCheckmate, isEnPassant, isCastling, promotion, presetEffects, timestamp }`.
|
||||||
|
2. Private `this.#moveLog: MoveRecord[] = []`; public getter.
|
||||||
|
3. Populate inside `applyMove` at the very end, before return.
|
||||||
|
4. Optional hook on `PresetDef`: `describeMoveEffect?(ctx): { presetId: string; summary: string } | void`. Engine iterates, collects non-void returns into `presetEffects`.
|
||||||
|
5. Update `MoveHookContext` (from A.3) to carry the MoveRecord being built (or the finalized one, depending on hook timing).
|
||||||
|
|
||||||
|
**Tests**:
|
||||||
|
- Move sequence produces correct log entries.
|
||||||
|
- Capture is logged with `capturedType`.
|
||||||
|
- Checkmate is logged with `isCheckmate: true`.
|
||||||
|
- `describeMoveEffect` is called and results show up in `presetEffects`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### B.3 — `engine.emitEffect()` + effect stream API
|
||||||
|
|
||||||
|
**Where**: `packages/chess/src/engine.ts`, `packages/chess/src/ui/VisualEffectLayer.tsx` (new)
|
||||||
|
**Why**: Visual feedback. Currently explosions, heals, poison ticks happen silently.
|
||||||
|
**How**:
|
||||||
|
|
||||||
|
1. `VisualEffect` interface: `{ kind: string; square: Square | null; ttl: number; data?: Record<string, unknown> }`.
|
||||||
|
2. `engine.emitEffect(effect: VisualEffect)` pushes onto an internal ring buffer; notifies subscribers.
|
||||||
|
3. `engine.subscribeEffects(handler): Unsubscribe`.
|
||||||
|
4. UI layer mounts a fixed-position overlay inside `Board.tsx`; subscribes once; renders effects with Motion (fade/scale/slide based on kind).
|
||||||
|
5. Presets call it directly: `explosive-rook` emits `{ kind: "explosion", square: targetPos, ttl: 600 }`; `king-heals` emits `{ kind: "heal", square: kingSq, ttl: 400 }`; `poisoned-squares` emits per-tick `{ kind: "poison", square, ttl: 250 }`.
|
||||||
|
|
||||||
|
**Tests**: Unit — emit + subscribe; unit — TTL cleanup; Playwright — trigger explosive-rook capture, assert `[data-effect="explosion"]` briefly present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### B.4 — UI: VisualEffectLayer integration
|
||||||
|
|
||||||
|
Depends on B.3.
|
||||||
|
**Where**: `packages/chess/src/ui/Board.tsx`, `packages/chess/src/ui/VisualEffectLayer.tsx` (new)
|
||||||
|
**Why**: Ship B.3 as a visible feature.
|
||||||
|
**How**: Mount layer inside board viewport. Per-kind effect component (ExplosionBurst, HealPulse, PoisonBubble). Use Motion's `AnimatePresence` for entry/exit; auto-remove after TTL.
|
||||||
|
|
||||||
|
**Tests**: Playwright smoke (see B.3).
|
||||||
|
|
||||||
|
**GATE B**: `bun run check` green. Visual smoke test green. Capture-to-win's Winner fact has migrated to presetState.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase C — Extension Surfaces
|
||||||
|
|
||||||
|
#### C.1 — PieceTypeRegistry (core)
|
||||||
|
|
||||||
|
**Where**: `packages/chess/src/presets/piece-type-registry.ts` (new), `packages/chess/src/schema.ts`
|
||||||
|
**Why**: Foundation for custom piece types.
|
||||||
|
**How**:
|
||||||
|
|
||||||
|
1. New class `PieceTypeRegistry` with methods `register(def: PieceTypeDef)`, `get(id)`, `getAll()`, `has(id)`.
|
||||||
|
2. `PieceTypeDef { id, displayName, moveGenerator, attackProbe, assets: { whiteSvg, blackSvg } }`.
|
||||||
|
3. `PieceType` becomes `string` (not a fixed union). Runtime validation via registry membership.
|
||||||
|
4. Built-in types (pawn/knight/etc) register at module load via a `core-piece-types.ts` that's imported from the barrel.
|
||||||
|
|
||||||
|
**Tests**: Unit — register new type, iterate, lookup by id.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### C.2 — Engine + UI consume PieceTypeRegistry
|
||||||
|
|
||||||
|
Depends on C.1.
|
||||||
|
**Where**: `packages/chess/src/engine.ts`, `packages/chess/src/rules/check.ts`, `packages/chess/src/rules/checkmate.ts`, `packages/chess/src/ui/Piece.tsx`, `packages/chess/src/assets/pieces/index.ts`
|
||||||
|
**Why**: Swap the five hardcoded dispatch tables (`PIECE_MOVE_GETTERS`, `ATTACK_PROBES`, `MOVE_GETTERS`, asset map, stalemate's table) for registry lookups.
|
||||||
|
**How**:
|
||||||
|
|
||||||
|
1. `engine.getLegalMovesForPiece(id)` resolves move generator via registry.
|
||||||
|
2. `isSquareAttacked` + `isCheckmate` + `isStalemate` do the same.
|
||||||
|
3. UI `<Piece>` looks up SVG assets from registry's `assets` field.
|
||||||
|
4. Starting-position fact generator stays as-is (still uses hardcoded 32-piece FIDE layout).
|
||||||
|
|
||||||
|
**Tests**: Unit — register a Cannon piece type; place a Cannon entity; verify it appears as a legal attacker in check detection; verify the UI renders its asset (Playwright).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### C.3 — Scope-aware hook dispatch
|
||||||
|
|
||||||
|
**Where**: `packages/chess/src/engine.ts`, `packages/chess/src/presets/active-set.ts`
|
||||||
|
**Why**: Reduces per-preset scope boilerplate; makes scope semantics consistent.
|
||||||
|
**How**:
|
||||||
|
|
||||||
|
1. Every hook that has a "for whom" semantics gets a `scopeFilter: PieceColor` context field (set by the engine based on who's moving / whose piece is taking damage / etc).
|
||||||
|
2. `ActivePresetSet` gains `getForColorAndHook(color, hookName)` returning only presets whose scope covers `color`.
|
||||||
|
3. `onAfterMove` fires only for presets scoped to mover color (+ `both` which is default). Same for `onDamage` based on target's color.
|
||||||
|
4. `onCheckGameResult` fires for all (no scope — terminal state is global).
|
||||||
|
|
||||||
|
**Tests**: Test preset scoped `white`; apply a black move; verify preset's onAfterMove did NOT fire. Apply a white move; verify it DID.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### C.4 — Phase hooks: `onBeforeMove` + `onTurnStart`
|
||||||
|
|
||||||
|
**Where**: `packages/chess/src/presets/registry.ts`, `packages/chess/src/engine.ts`
|
||||||
|
**Why**: Enable cooldown / stamina / resource-regen presets without shoehorning into `onAfterMove`.
|
||||||
|
**How**:
|
||||||
|
|
||||||
|
1. `onBeforeMove(ctx: BeforeMoveContext): { cancel?: boolean; reason?: string } | void` fires after move legality is confirmed but before any state mutation. Returning `{cancel: true}` aborts the move — engine treats as illegal and calls `toast.error` with `ctx.reason`. (This is a strong capability; fencing it with `cancel` vs a generic `consume` because we want explicit semantics.)
|
||||||
|
2. `onTurnStart(ctx: TurnStartContext): void` fires after the turn has flipped, before the new side-to-move's legal moves are computed. Good place for "regenerate stamina", "decrement cooldowns", "re-bless pieces on altar squares".
|
||||||
|
3. Engine dispatches both at the right points in `applyMove`.
|
||||||
|
|
||||||
|
**Tests**: Stamina-regen preset in test fixture — set Stamina to 0; run a turn; verify `onTurnStart` regenerated it to 1; run a pawn push; verify `onBeforeMove` consumes 1 stamina; if Stamina < cost, preset cancels move.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### C.5 — Server-protocol: piece-type registry echoed on `game.state`
|
||||||
|
|
||||||
|
**Where**: `packages/server/src/broadcast.ts`, `packages/server/src/protocol.ts`, `packages/chess/src/net/types.ts`
|
||||||
|
**Why**: Clients that join mid-game need the piece-type registry to render a custom type correctly. Currently not an issue (all types hardcoded) but will be as soon as C.2 ships.
|
||||||
|
**How**:
|
||||||
|
|
||||||
|
1. Add optional `pieceTypes?: PieceTypeManifest[]` to `GameStatePayload`.
|
||||||
|
2. Server computes from `PRESET_REGISTRY` at room creation; sends on `room.joined` + `game.state`.
|
||||||
|
3. Client compares manifest to its local registry; if mismatch, shows a connection error ("server uses unknown piece types").
|
||||||
|
4. Protocol version stays `v: 1` — new optional field.
|
||||||
|
|
||||||
|
**Tests**: Unit — server sends manifest; client parses; mismatch rejected cleanly.
|
||||||
|
|
||||||
|
**GATE C**: `bun run check` green. Cannon piece type end-to-end test works (Playwright): create room with cannon preset, place cannon, move it, see it on both clients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase D — Developer Experience
|
||||||
|
|
||||||
|
#### D.1 — `packages/chess/src/presets/test-utils.ts`
|
||||||
|
|
||||||
|
**Where**: new file.
|
||||||
|
**Why**: Four preset test files copy-paste the same helpers.
|
||||||
|
**How**:
|
||||||
|
|
||||||
|
1. Export `pieceAt(engine, square)`, `hpOf(engine, id)`, `typeOf(engine, id)`, `exists(engine, id)`.
|
||||||
|
2. Export `clearBoard(engine, { preserveKings?: boolean })`.
|
||||||
|
3. Export `placePiece(engine, type, color, square, opts?): EntityId`.
|
||||||
|
4. Export `engineWithPosition(partial: Record<Square, { type, color }>): ChessEngine`.
|
||||||
|
5. Migrate the 4 existing preset test files to import from here; delete local copies.
|
||||||
|
|
||||||
|
**Tests**: `test-utils.test.ts` — cover each helper's happy path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### D.2 — `packages/chess/docs/PRESET-API.md`
|
||||||
|
|
||||||
|
**Where**: new file.
|
||||||
|
**Why**: The `PresetDef` interface is now the public API. Needs reference-quality docs.
|
||||||
|
**How**: Structured reference doc:
|
||||||
|
|
||||||
|
- Overview + philosophy (engine-as-minimal-core, presets-as-composition)
|
||||||
|
- `PresetDef` interface with every field + hook documented
|
||||||
|
- Hook firing order (chronology within an applyMove)
|
||||||
|
- Context objects — type reference for every one
|
||||||
|
- Piece attribute registration
|
||||||
|
- Piece type registration
|
||||||
|
- Preset state bag usage
|
||||||
|
- Effect bus usage
|
||||||
|
- Scope semantics
|
||||||
|
- Phase hooks with cooldown/regen examples
|
||||||
|
- "Your first custom preset" walkthrough — builds the Shield preset from scratch in ~50 lines
|
||||||
|
|
||||||
|
**Tests**: Linked from `packages/chess/README.md`. Manual proofread.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### D.3 — Integration scenarios (prototype presets in tests)
|
||||||
|
|
||||||
|
**Where**: `packages/chess/src/presets/integration.test.ts` (new).
|
||||||
|
**Why**: Proves the APIs are sufficient end-to-end. Each scenario builds a plausible preset using ONLY registry-level APIs.
|
||||||
|
**How**: Four scenarios described in Verification Strategy above, each as its own `describe` block.
|
||||||
|
|
||||||
|
1. Shield attribute preset.
|
||||||
|
2. Cannon piece-type preset.
|
||||||
|
3. Berserker cooldown (presetState).
|
||||||
|
4. Pawn stamina (phase hooks).
|
||||||
|
|
||||||
|
Each prototype preset lives in-file; they're test fixtures, not shipped. They validate that a third-party preset author could accomplish the same.
|
||||||
|
|
||||||
|
**Tests**: The scenarios themselves ARE the tests.
|
||||||
|
|
||||||
|
**FINAL GATE**:
|
||||||
|
- `bun run check` green (target ~920 tests).
|
||||||
|
- Playwright E2E green.
|
||||||
|
- `packages/chess/docs/PRESET-API.md` exists and mentions every new API.
|
||||||
|
- Zero hardcoded `PIECE_ATTRS` / `PIECE_MOVE_GETTERS` / `ATTACK_PROBES` literals outside engine/registry/schema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit Strategy
|
||||||
|
|
||||||
|
One commit per task (23 commits). Each:
|
||||||
|
|
||||||
|
- Body explains root cause + fix + impact.
|
||||||
|
- References the task id (e.g., `A.2: engine.spawnPiece primitive`).
|
||||||
|
- Pre-commit hook runs `bun run check` — must be green.
|
||||||
|
|
||||||
|
Phase boundaries get a merge-commit summary with an updated progress checklist in the PR description.
|
||||||
|
|
||||||
|
No batch commits. No squashes. Easy bisect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
- A new preset that declares a new piece attribute, a new piece type, uses per-preset state, emits visual effects, and participates in scope-aware hooks can be written entirely within `packages/chess/src/presets/` — zero edits to anything under `packages/chess/src/engine.ts`, `packages/chess/src/rules/`, or `packages/chess/src/ui/` (other than `VisualEffectLayer`'s per-kind component, which is acceptable — new visual styles naturally live with the renderer).
|
||||||
|
- All existing presets behave identically in isolation.
|
||||||
|
- All existing E2E flows pass.
|
||||||
|
|
||||||
|
### Verification Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From repo root
|
||||||
|
bun run check # typecheck + lint + unit tests
|
||||||
|
bun x playwright test packages/chess/e2e/ --workers=1
|
||||||
|
|
||||||
|
# Spot-checks
|
||||||
|
grep -rn '\["PieceType", "Color"' packages/chess/src/ | wc -l # expect 0 outside engine.ts / registry.ts
|
||||||
|
grep -rn "const PIECE_MOVE_GETTERS" packages/chess/src/ | wc -l # expect 1 (engine.ts) or 0 if fully dynamic
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stretch (not required)
|
||||||
|
|
||||||
|
- Migrate `capture-to-win.Winner` facts from `GAME_ENTITY` to `presetState`.
|
||||||
|
- Migrate `rules/promotion.ts` to use `engine.spawnPiece`.
|
||||||
|
- Server broadcasts visual effects on each delta so clients stay in sync.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Log
|
||||||
|
|
||||||
|
| Risk | Likelihood | Mitigation |
|
||||||
|
|---|---|---|
|
||||||
|
| Dynamic `PIECE_ATTRS` breaks serialization of saved games | Medium | Autosave is dev-only; bump save-version; clear old on mismatch. |
|
||||||
|
| Piece-type runtime string-union relaxation causes implicit `any` in exhaustiveness checks | Medium | Add `satisfies` assertions at registry edges; lint rule for `PieceType === "king"` comparisons |
|
||||||
|
| Effect bus leaks memory (subscribers never unsubscribe) | Low | `subscribeEffects` returns `Unsubscribe`; `Board` unmounts cleanly; add dev-mode warning on > 20 active subscribers. |
|
||||||
|
| `onBeforeMove.cancel` misused to silently break chess | Medium | Require `reason: string` when cancelling; surface via toast; document as "use sparingly". |
|
||||||
|
| Protocol changes cause client/server version drift | Low | New fields are optional; existing clients ignore; piece-type manifest check is advisory only (warn, don't disconnect) in first release. |
|
||||||
|
| Context-object migration misses a preset and drops hook | Low | Typechecker enforces; grep for bare `onDamage:` in every preset file as final sweep. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-Landing Backlog (not in this plan)
|
||||||
|
|
||||||
|
- `onPieceRetract` hook — symmetric to `onPieceSpawn`, fires when a piece dies. Needed for "death rattle" presets (e.g., a piece explodes on death). Defer until a concrete preset wants it.
|
||||||
|
- Undo/redo for moves — requires `MoveRecord` (B.2) as prerequisite; currently implemented as full state snapshots, which the MoveRecord stream can replace.
|
||||||
|
- Preset-authored UI panels — e.g., a Stamina-bar side panel. Needs a standard extension slot in `GameView`. Low priority.
|
||||||
|
- Server-side preset validation DSL — presets currently live in `packages/chess`; server imports the same registry. If presets ever become user-authored (disallowed in v1), server needs a validation/sandbox layer.
|
||||||
426
packages/chess/docs/PRESET-API.md
Normal file
426
packages/chess/docs/PRESET-API.md
Normal 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.
|
||||||
|
|
@ -6,16 +6,13 @@
|
||||||
*/
|
*/
|
||||||
import { Session } from "@paratype/rete";
|
import { Session } from "@paratype/rete";
|
||||||
import type { EntityId } 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 {
|
import {
|
||||||
getLegalRookMoves,
|
GAME_ENTITY,
|
||||||
getLegalBishopMoves,
|
PRESET_STATE_ENTITY,
|
||||||
getLegalQueenMoves,
|
type PieceType,
|
||||||
} from "./rules/sliding.js";
|
type PieceColor,
|
||||||
import { getLegalKingMoves } from "./rules/king.js";
|
} from "./schema.js";
|
||||||
|
import { generateStartingPosition } from "./starting-position.js";
|
||||||
import {
|
import {
|
||||||
getCastlingMoves,
|
getCastlingMoves,
|
||||||
applyCastlingMove,
|
applyCastlingMove,
|
||||||
|
|
@ -33,6 +30,7 @@ import {
|
||||||
} from "./rules/promotion.js";
|
} from "./rules/promotion.js";
|
||||||
import {
|
import {
|
||||||
filterSelfCheckMoves,
|
filterSelfCheckMoves,
|
||||||
|
isInCheck,
|
||||||
isSquareAttacked,
|
isSquareAttacked,
|
||||||
} from "./rules/check.js";
|
} from "./rules/check.js";
|
||||||
import { isCheckmate } from "./rules/checkmate.js";
|
import { isCheckmate } from "./rules/checkmate.js";
|
||||||
|
|
@ -44,28 +42,50 @@ import {
|
||||||
recordPosition,
|
recordPosition,
|
||||||
isThreefoldRepetition,
|
isThreefoldRepetition,
|
||||||
} from "./rules/draws.js";
|
} from "./rules/draws.js";
|
||||||
import { PIECE_ATTRS } from "./rules/capture.js";
|
import { CORE_PIECE_ATTRS } from "./rules/capture.js";
|
||||||
import type { DamageContext } from "./presets/registry.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 type { LegalMove } from "./rules/types.js";
|
||||||
import {
|
import {
|
||||||
ActivePresetSet,
|
ActivePresetSet,
|
||||||
type ActivationRequest,
|
type ActivationRequest,
|
||||||
} from "./presets/active-set.js";
|
} from "./presets/active-set.js";
|
||||||
import { PRESET_REGISTRY } from "./presets/registry.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
|
// Importing from the barrel guarantees every preset module's
|
||||||
// side-effect registration has run before the first engine is created.
|
// side-effect registration has run before the first engine is created.
|
||||||
import "./presets/index.js";
|
import "./presets/index.js";
|
||||||
|
|
||||||
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
|
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
|
||||||
|
|
||||||
const PIECE_MOVE_GETTERS: Record<PieceType, MoveGetter> = {
|
/**
|
||||||
pawn: getLegalPawnMoves,
|
* Look up the move generator for a piece type via the registry.
|
||||||
knight: getLegalKnightMoves,
|
* Returns `null` for unknown types (degenerate — indicates a fact
|
||||||
bishop: getLegalBishopMoves,
|
* with a piece type nobody registered; the engine treats such pieces
|
||||||
rook: getLegalRookMoves,
|
* as immobile).
|
||||||
queen: getLegalQueenMoves,
|
*
|
||||||
king: getLegalKingMoves,
|
* 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 =
|
export type GameResult =
|
||||||
| "checkmate"
|
| "checkmate"
|
||||||
|
|
@ -81,6 +101,187 @@ export type GameResult =
|
||||||
| "black-wins"
|
| "black-wins"
|
||||||
| "ongoing";
|
| "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 {
|
export class ChessEngine {
|
||||||
public readonly session: Session;
|
public readonly session: Session;
|
||||||
/**
|
/**
|
||||||
|
|
@ -96,6 +297,69 @@ export class ChessEngine {
|
||||||
*/
|
*/
|
||||||
public readonly activePresets: ActivePresetSet;
|
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) {
|
constructor(activePresets?: ActivePresetSet) {
|
||||||
this.session = new Session({ autoFire: false });
|
this.session = new Session({ autoFire: false });
|
||||||
generateStartingPosition(this.session);
|
generateStartingPosition(this.session);
|
||||||
|
|
@ -103,6 +367,80 @@ export class ChessEngine {
|
||||||
this.activePresets = activePresets ?? new ActivePresetSet();
|
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.
|
* Replace the active preset set and fire lifecycle hooks on transitions.
|
||||||
*
|
*
|
||||||
|
|
@ -131,15 +469,19 @@ export class ChessEngine {
|
||||||
this.activePresets.replaceAll(requests);
|
this.activePresets.replaceAll(requests);
|
||||||
const newIds = new Set(requests.map((r) => r.id));
|
const newIds = new Set(requests.map((r) => r.id));
|
||||||
|
|
||||||
|
const ctx: LifecycleContext = { engine: this };
|
||||||
for (const id of oldIds) {
|
for (const id of oldIds) {
|
||||||
if (newIds.has(id)) continue;
|
if (newIds.has(id)) continue;
|
||||||
const def = PRESET_REGISTRY.get(id);
|
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) {
|
for (const req of requests) {
|
||||||
if (oldIds.has(req.id)) continue;
|
if (oldIds.has(req.id)) continue;
|
||||||
const def = PRESET_REGISTRY.get(req.id);
|
const def = PRESET_REGISTRY.get(req.id);
|
||||||
def?.onActivate?.(this);
|
def?.onActivate?.(ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,14 +500,69 @@ export class ChessEngine {
|
||||||
color: PieceColor,
|
color: PieceColor,
|
||||||
): boolean {
|
): boolean {
|
||||||
let consumed = false;
|
let consumed = false;
|
||||||
|
const ctx: CaptureHookContext = {
|
||||||
|
engine: this,
|
||||||
|
attacker,
|
||||||
|
target,
|
||||||
|
mover: color,
|
||||||
|
};
|
||||||
for (const preset of this.activePresets.getForColor(color)) {
|
for (const preset of this.activePresets.getForColor(color)) {
|
||||||
if (!preset.onBeforeCapture) continue;
|
if (!preset.onBeforeCapture) continue;
|
||||||
const result = preset.onBeforeCapture(this, attacker, target);
|
const result = preset.onBeforeCapture(ctx);
|
||||||
if (result && result.consume === true) consumed = true;
|
if (result && result.consume === true) consumed = true;
|
||||||
}
|
}
|
||||||
return consumed;
|
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
|
* Deal `amount` damage to `target`, running it through the preset
|
||||||
* damage pipeline before applying the default kill-on-damage rule.
|
* damage pipeline before applying the default kill-on-damage rule.
|
||||||
|
|
@ -199,21 +596,44 @@ export class ChessEngine {
|
||||||
): { died: boolean } {
|
): { died: boolean } {
|
||||||
if (amount <= 0) return { died: false };
|
if (amount <= 0) return { died: false };
|
||||||
|
|
||||||
// Consult damage interceptors in active-preset list order. First
|
// Scope-aware dispatch: pre-resolve the target's color so we can
|
||||||
// consumer wins. Scope is not considered here because damage is a
|
// skip presets whose scope doesn't cover it. A `scope=white` preset
|
||||||
// board-level event that can strike any piece regardless of whose
|
// only damages/protects white pieces; without this filter, a white-
|
||||||
// turn it is (e.g. explosive rook hitting friendly pieces).
|
// 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()) {
|
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 def = PRESET_REGISTRY.get(entry.id);
|
||||||
const result = def?.onDamage?.(this, target, amount, ctx);
|
const result = def?.onDamage?.(hookCtx);
|
||||||
if (result?.consume === true) {
|
if (result?.consume === true) {
|
||||||
return { died: result.died === true };
|
return { died: result.died === true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: any damage is lethal. Retract all piece attributes so
|
// Default: any damage is lethal. Retract every effective piece
|
||||||
// downstream queries see the piece as truly gone.
|
// attribute (core + preset-declared) so downstream queries see the
|
||||||
for (const attr of PIECE_ATTRS) {
|
// piece as truly gone.
|
||||||
|
for (const attr of this.effectivePieceAttrs) {
|
||||||
if (this.session.contains(target, attr)) {
|
if (this.session.contains(target, attr)) {
|
||||||
this.session.retract(target, attr);
|
this.session.retract(target, attr);
|
||||||
}
|
}
|
||||||
|
|
@ -240,7 +660,7 @@ export class ChessEngine {
|
||||||
.filter(p => p.type !== undefined);
|
.filter(p => p.type !== undefined);
|
||||||
|
|
||||||
for (const piece of pieces) {
|
for (const piece of pieces) {
|
||||||
const getter = PIECE_MOVE_GETTERS[piece.type];
|
const getter = lookupMoveGenerator(piece.type);
|
||||||
if (!getter) continue;
|
if (!getter) continue;
|
||||||
|
|
||||||
let pieceMoves = getter(this.session, piece.id);
|
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
|
// This is how `piece-hp` allows the king to stay on an attacked
|
||||||
// square — a "hit" costs HP rather than losing the game.
|
// square — a "hit" costs HP rather than losing the game.
|
||||||
let applyFilter = true;
|
let applyFilter = true;
|
||||||
|
const selfCheckCtx: SelfCheckFilterContext = { engine: this, color };
|
||||||
for (const preset of this.activePresets.getForColor(color)) {
|
for (const preset of this.activePresets.getForColor(color)) {
|
||||||
if (preset.shouldFilterSelfCheck?.(this, color) === false) {
|
if (preset.shouldFilterSelfCheck?.(selfCheckCtx) === false) {
|
||||||
applyFilter = false;
|
applyFilter = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -315,6 +736,28 @@ export class ChessEngine {
|
||||||
|
|
||||||
applyMove(move: LegalMove, promoteTo: PieceType = "queen"): GameResult {
|
applyMove(move: LegalMove, promoteTo: PieceType = "queen"): GameResult {
|
||||||
const color = this.getCurrentTurn();
|
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 facts = this.session.allFacts();
|
||||||
const movingType = facts.find(
|
const movingType = facts.find(
|
||||||
f => f.id === move.pieceId && f.attr === "PieceType",
|
f => f.id === move.pieceId && f.attr === "PieceType",
|
||||||
|
|
@ -329,6 +772,26 @@ export class ChessEngine {
|
||||||
|
|
||||||
const isCastling = (move as CastlingMove).isCastling === true;
|
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) {
|
if (isEnPassant) {
|
||||||
// En passant captures the pawn on the SKIPPED square, not on
|
// En passant captures the pawn on the SKIPPED square, not on
|
||||||
// `move.to`. We still fire `onBeforeCapture` first so presets
|
// `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
|
// ticks only when white plays; a `scope=both` ticks on every
|
||||||
// half-move. Entries reaching 0 are removed AND fire onDeactivate
|
// half-move. Entries reaching 0 are removed AND fire onDeactivate
|
||||||
// so they can tear down any board state they installed.
|
// so they can tear down any board state they installed.
|
||||||
|
const lifecycleCtx: LifecycleContext = { engine: this };
|
||||||
const expired = this.activePresets.tickAfterMove(color);
|
const expired = this.activePresets.tickAfterMove(color);
|
||||||
for (const id of expired) {
|
for (const id of expired) {
|
||||||
const def = PRESET_REGISTRY.get(id);
|
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
|
// 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
|
// responsibility — king-heals affects the non-mover, poisoned-squares
|
||||||
// affects the mover, so a single engine-level scope filter can't
|
// affects the mover, so a single engine-level scope filter can't
|
||||||
// serve both.
|
// serve both.
|
||||||
|
const moveCtx: MoveHookContext = { engine: this, mover: color };
|
||||||
for (const entry of this.activePresets.list()) {
|
for (const entry of this.activePresets.list()) {
|
||||||
const def = PRESET_REGISTRY.get(entry.id);
|
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 {
|
checkGameResult(): GameResult {
|
||||||
|
|
@ -464,9 +990,10 @@ export class ChessEngine {
|
||||||
// in registration order. First non-undefined return wins. This lets
|
// in registration order. First non-undefined return wins. This lets
|
||||||
// capture-to-win and last-piece-standing redefine "game over"
|
// capture-to-win and last-piece-standing redefine "game over"
|
||||||
// without touching engine internals.
|
// without touching engine internals.
|
||||||
|
const resultCtx: GameResultHookContext = { engine: this };
|
||||||
for (const entry of this.activePresets.list()) {
|
for (const entry of this.activePresets.list()) {
|
||||||
const def = PRESET_REGISTRY.get(entry.id);
|
const def = PRESET_REGISTRY.get(entry.id);
|
||||||
const override = def?.onCheckGameResult?.(this);
|
const override = def?.onCheckGameResult?.(resultCtx);
|
||||||
if (override !== undefined) return override;
|
if (override !== undefined) return override;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,32 @@
|
||||||
* capture ends the game first.
|
* capture ends the game first.
|
||||||
*
|
*
|
||||||
* Wiring:
|
* Wiring:
|
||||||
* - `onBeforeCapture` — record the capturer's color on the game
|
* - `onBeforeCapture` — record the capturer's color in preset-
|
||||||
* entity via a `Winner` fact. Does NOT consume the capture, so the
|
* scoped state via `engine.presetState("capture-to-win")`. Does
|
||||||
* engine's default retract-and-move still runs (the target piece
|
* NOT consume the capture, so the engine's default retract-and-
|
||||||
* dies normally, the attacker advances onto its square). This
|
* move still runs (the target piece dies normally, the attacker
|
||||||
* keeps the final board state intuitive for players to inspect
|
* advances onto its square). This keeps the final board state
|
||||||
* ("why is white's pawn on d5?") rather than freezing the board
|
* intuitive for players to inspect.
|
||||||
* mid-capture.
|
* - `onCheckGameResult` — if a winner was recorded, convert it
|
||||||
* - `onCheckGameResult` — if a Winner fact was recorded, convert
|
* into the corresponding GameResult. Otherwise return undefined
|
||||||
* it into the corresponding GameResult. Otherwise return undefined
|
* and let the default checkmate/stalemate logic run.
|
||||||
* and let the default checkmate/stalemate logic run (nothing
|
*
|
||||||
* to do until the first capture happens).
|
* 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
|
* Incompatible with `last-piece-standing` — both redefine "when is
|
||||||
* the game over" and would compete for the onCheckGameResult hook.
|
* the game over" and would compete for the onCheckGameResult hook.
|
||||||
*/
|
*/
|
||||||
import { PRESET_REGISTRY } from "./registry.js";
|
import { PRESET_REGISTRY } from "./registry.js";
|
||||||
import { GAME_ENTITY, type PieceColor } from "../schema.js";
|
import type { PieceColor } from "../schema.js";
|
||||||
import type { ChessEngine, GameResult } from "../engine.js";
|
import type { GameResult } from "../engine.js";
|
||||||
import type { EntityId } from "@paratype/rete";
|
|
||||||
|
interface CaptureToWinState extends Record<string, unknown> {
|
||||||
|
winner: PieceColor;
|
||||||
|
}
|
||||||
|
|
||||||
PRESET_REGISTRY.register({
|
PRESET_REGISTRY.register({
|
||||||
id: "capture-to-win",
|
id: "capture-to-win",
|
||||||
|
|
@ -34,39 +41,29 @@ PRESET_REGISTRY.register({
|
||||||
incompatibleWith: ["last-piece-standing"],
|
incompatibleWith: ["last-piece-standing"],
|
||||||
requires: [],
|
requires: [],
|
||||||
|
|
||||||
onBeforeCapture(engine: ChessEngine, attacker: EntityId, _target: EntityId) {
|
onBeforeCapture({ engine, attacker }) {
|
||||||
const session = engine.session;
|
const state = engine.presetState<CaptureToWinState>("capture-to-win");
|
||||||
// Only record the first capture — don't stomp an earlier winner
|
// Only record the FIRST capture — once set, never overwrite so
|
||||||
// if multiple captures somehow occur in the same applyMove
|
// multi-capture edge cases in the same applyMove don't drift.
|
||||||
// (shouldn't happen, but defensive).
|
if (state.has("winner")) return;
|
||||||
if (session.contains(GAME_ENTITY, "Winner")) {
|
|
||||||
const existing = session.get(GAME_ENTITY, "Winner");
|
const colorFact = engine.session
|
||||||
if (existing !== null) return; // already set, leave it
|
|
||||||
}
|
|
||||||
const colorFact = session
|
|
||||||
.allFacts()
|
.allFacts()
|
||||||
.find(f => f.id === attacker && f.attr === "Color");
|
.find((f) => f.id === attacker && f.attr === "Color");
|
||||||
if (!colorFact) return;
|
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
|
// Don't consume — let the capture resolve normally so the board
|
||||||
// visibly reflects the killing blow.
|
// visibly reflects the killing blow.
|
||||||
},
|
},
|
||||||
|
|
||||||
onCheckGameResult(engine: ChessEngine): GameResult | undefined {
|
onCheckGameResult({ engine }): GameResult | undefined {
|
||||||
const session = engine.session;
|
const state = engine.presetState<CaptureToWinState>("capture-to-win");
|
||||||
if (!session.contains(GAME_ENTITY, "Winner")) return undefined;
|
const winner = state.get("winner");
|
||||||
const winner = session.get(GAME_ENTITY, "Winner");
|
|
||||||
if (winner === "white") return "white-wins";
|
if (winner === "white") return "white-wins";
|
||||||
if (winner === "black") return "black-wins";
|
if (winner === "black") return "black-wins";
|
||||||
return undefined; // "draw" or null — let default logic run
|
return undefined;
|
||||||
},
|
},
|
||||||
|
|
||||||
onDeactivate(engine: ChessEngine) {
|
// No explicit onDeactivate needed: the engine automatically clears
|
||||||
// Clear any stored winner when the preset is turned off so the
|
// preset-state for this id via clearPresetState when we go inactive.
|
||||||
// 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");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
106
packages/chess/src/presets/core-piece-types.ts
Normal file
106
packages/chess/src/presets/core-piece-types.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
|
@ -355,6 +355,76 @@ describe("queen-splits + piece-hp: fission only on kills", () => {
|
||||||
// poisoned-squares: damage pipeline integration
|
// 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", () => {
|
describe("poisoned-squares routes through the damage pipeline", () => {
|
||||||
it("each tick deals 1 HP damage via the pipeline; piece dies when Hp hits 0", () => {
|
it("each tick deals 1 HP damage via the pipeline; piece dies when Hp hits 0", () => {
|
||||||
const engine = new ChessEngine();
|
const engine = new ChessEngine();
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@
|
||||||
* mechanic finite.
|
* mechanic finite.
|
||||||
*/
|
*/
|
||||||
import { PRESET_REGISTRY } from "./registry.js";
|
import { PRESET_REGISTRY } from "./registry.js";
|
||||||
import type { ChessEngine } from "../engine.js";
|
|
||||||
import type { Session, EntityId } from "@paratype/rete";
|
import type { Session, EntityId } from "@paratype/rete";
|
||||||
import { fileOf, rankOf, squareOf } from "../coord.js";
|
import { fileOf, rankOf, squareOf } from "../coord.js";
|
||||||
import type { Square } from "../schema.js";
|
import type { Square } from "../schema.js";
|
||||||
|
|
@ -47,7 +46,7 @@ PRESET_REGISTRY.register({
|
||||||
incompatibleWith: [],
|
incompatibleWith: [],
|
||||||
requires: [],
|
requires: [],
|
||||||
|
|
||||||
onBeforeCapture(engine: ChessEngine, attacker: EntityId, target: EntityId) {
|
onBeforeCapture({ engine, attacker, target }) {
|
||||||
const session = engine.session;
|
const session = engine.session;
|
||||||
const attackerTypeFact = session
|
const attackerTypeFact = session
|
||||||
.allFacts()
|
.allFacts()
|
||||||
|
|
@ -97,6 +96,16 @@ PRESET_REGISTRY.register({
|
||||||
engine.dealDamage(v, 1, { kind: "explosion", attacker });
|
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
|
// Advance the rook only if the target died — matches HP's general
|
||||||
// "non-lethal capture = attacker stays" semantics. Without HP,
|
// "non-lethal capture = attacker stays" semantics. Without HP,
|
||||||
// targetDied is always true so the rook always advances (standard
|
// targetDied is always true so the rook always advances (standard
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
/**
|
/**
|
||||||
* Preset rule barrel + registration side-effects (P3.4+).
|
* Preset rule barrel + registration side-effects (P3.4+).
|
||||||
*
|
*
|
||||||
* Importing this module guarantees all preset rules are registered in
|
* Importing this module guarantees:
|
||||||
* PRESET_REGISTRY. Consumers should import `./index.js` (never the registry
|
* - Core FIDE piece types are registered in PIECE_TYPE_REGISTRY.
|
||||||
* directly) to ensure preset side-effect registration has run.
|
* - 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 "./pawns-move-backward.js";
|
||||||
import "./double-pawn-sprint.js";
|
import "./double-pawn-sprint.js";
|
||||||
import "./pawn-diagonal-no-capture.js";
|
import "./pawn-diagonal-no-capture.js";
|
||||||
|
|
|
||||||
426
packages/chess/src/presets/integration.test.ts
Normal file
426
packages/chess/src/presets/integration.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
* Requires `piece-hp`; without it there's no Hp attribute to heal.
|
* Requires `piece-hp`; without it there's no Hp attribute to heal.
|
||||||
*/
|
*/
|
||||||
import { PRESET_REGISTRY } from "./registry.js";
|
import { PRESET_REGISTRY } from "./registry.js";
|
||||||
import type { ChessEngine } from "../engine.js";
|
|
||||||
import type { Session, EntityId } from "@paratype/rete";
|
import type { Session, EntityId } from "@paratype/rete";
|
||||||
import type { PieceColor } from "../schema.js";
|
import type { PieceColor } from "../schema.js";
|
||||||
import { isInCheck } from "../rules/check.js";
|
import { isInCheck } from "../rules/check.js";
|
||||||
|
|
@ -42,13 +42,13 @@ PRESET_REGISTRY.register({
|
||||||
incompatibleWith: [],
|
incompatibleWith: [],
|
||||||
requires: ["piece-hp"],
|
requires: ["piece-hp"],
|
||||||
|
|
||||||
onAfterMove(engine: ChessEngine, moverColor) {
|
onAfterMove({ engine, mover }) {
|
||||||
// The NON-mover's king is the one potentially healing: it's the
|
// The NON-mover's king is the one potentially healing: it's the
|
||||||
// king belonging to the player whose turn just arrived. Heal only
|
// king belonging to the player whose turn just arrived. Heal only
|
||||||
// if that king isn't currently in check (being in check denies the
|
// if that king isn't currently in check (being in check denies the
|
||||||
// heal — an intentional game-design lever that makes aggressive
|
// heal — an intentional game-design lever that makes aggressive
|
||||||
// play strategically meaningful).
|
// play strategically meaningful).
|
||||||
const color: PieceColor = moverColor === "white" ? "black" : "white";
|
const color: PieceColor = mover === "white" ? "black" : "white";
|
||||||
const kingId = findKing(engine.session, color);
|
const kingId = findKing(engine.session, color);
|
||||||
if (kingId === null) return;
|
if (kingId === null) return;
|
||||||
if (isInCheck(engine.session, color)) return;
|
if (isInCheck(engine.session, color)) return;
|
||||||
|
|
@ -58,5 +58,17 @@ PRESET_REGISTRY.register({
|
||||||
const hp = session.get(kingId, "Hp") as number;
|
const hp = session.get(kingId, "Hp") as number;
|
||||||
if (hp >= MAX_KING_HP) return;
|
if (hp >= MAX_KING_HP) return;
|
||||||
session.insert(kingId, "Hp", hp + 1);
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
* game over".
|
* game over".
|
||||||
*/
|
*/
|
||||||
import { PRESET_REGISTRY } from "./registry.js";
|
import { PRESET_REGISTRY } from "./registry.js";
|
||||||
import type { ChessEngine, GameResult } from "../engine.js";
|
import type { GameResult } from "../engine.js";
|
||||||
|
|
||||||
PRESET_REGISTRY.register({
|
PRESET_REGISTRY.register({
|
||||||
id: "last-piece-standing",
|
id: "last-piece-standing",
|
||||||
|
|
@ -36,7 +36,7 @@ PRESET_REGISTRY.register({
|
||||||
incompatibleWith: ["capture-to-win"],
|
incompatibleWith: ["capture-to-win"],
|
||||||
requires: [],
|
requires: [],
|
||||||
|
|
||||||
onCheckGameResult(engine: ChessEngine): GameResult | undefined {
|
onCheckGameResult({ engine }): GameResult | undefined {
|
||||||
// Count Position facts per color. We use Position rather than
|
// Count Position facts per color. We use Position rather than
|
||||||
// PieceType because a piece "exists on the board" iff it has a
|
// PieceType because a piece "exists on the board" iff it has a
|
||||||
// position; retracted pieces (captured) have no Position fact.
|
// position; retracted pieces (captured) have no Position fact.
|
||||||
|
|
|
||||||
105
packages/chess/src/presets/move-log.test.ts
Normal file
105
packages/chess/src/presets/move-log.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
119
packages/chess/src/presets/phase-hooks.test.ts
Normal file
119
packages/chess/src/presets/phase-hooks.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
* they all use the damage pipeline. No more hardcoded incompatibilities.
|
* they all use the damage pipeline. No more hardcoded incompatibilities.
|
||||||
*/
|
*/
|
||||||
import { PRESET_REGISTRY } from "./registry.js";
|
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";
|
import type { Session, EntityId } from "@paratype/rete";
|
||||||
|
|
||||||
/** Starting HP for every piece. Future work: make this per-type so
|
/** Starting HP for every piece. Future work: make this per-type so
|
||||||
|
|
@ -72,10 +72,6 @@ function iteratePieceIds(session: Session): EntityId[] {
|
||||||
return [...ids];
|
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({
|
PRESET_REGISTRY.register({
|
||||||
id: "piece-hp",
|
id: "piece-hp",
|
||||||
name: "Hit Points",
|
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'.",
|
"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: [],
|
incompatibleWith: [],
|
||||||
requires: [],
|
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;
|
const session = engine.session;
|
||||||
for (const id of iteratePieceIds(session)) {
|
for (const id of iteratePieceIds(session)) {
|
||||||
// Idempotent: skip entities that already have an Hp fact, so
|
// 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;
|
const session = engine.session;
|
||||||
for (const id of iteratePieceIds(session)) {
|
for (const id of iteratePieceIds(session)) {
|
||||||
if (session.contains(id, "Hp")) {
|
if (session.contains(id, "Hp")) {
|
||||||
|
|
@ -114,7 +132,7 @@ PRESET_REGISTRY.register({
|
||||||
* piece-hp composable with damage sources that vary in strength
|
* piece-hp composable with damage sources that vary in strength
|
||||||
* without needing per-source special cases.
|
* 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,
|
// The engine short-circuits amount<=0 before we ever get here,
|
||||||
// but be defensive: if somehow invoked with 0, treat it as a
|
// but be defensive: if somehow invoked with 0, treat it as a
|
||||||
// no-op survival.
|
// no-op survival.
|
||||||
|
|
@ -131,10 +149,11 @@ PRESET_REGISTRY.register({
|
||||||
return { consume: true, died: false };
|
return { consume: true, died: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lethal. Retract all piece attrs ourselves so the engine doesn't
|
// Lethal. Retract all effective piece attrs ourselves (core +
|
||||||
// need a second retract pass. Returning died:true tells upstream
|
// whatever other presets declared) so nothing is left behind.
|
||||||
// callers (capture path) that the attacker should advance.
|
// Returning died:true tells upstream callers (capture path) that
|
||||||
for (const attr of PIECE_ATTRS) {
|
// the attacker should advance.
|
||||||
|
for (const attr of engine.effectivePieceAttrs) {
|
||||||
if (session.contains(target, attr)) session.retract(target, attr);
|
if (session.contains(target, attr)) session.retract(target, attr);
|
||||||
}
|
}
|
||||||
return { consume: true, died: true };
|
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
|
* run the default logic". Once a king is gone we return the winner
|
||||||
* directly.
|
* directly.
|
||||||
*/
|
*/
|
||||||
onCheckGameResult(engine: ChessEngine): GameResult | undefined {
|
onCheckGameResult({ engine }): GameResult | undefined {
|
||||||
const session = engine.session;
|
const session = engine.session;
|
||||||
const colors = kingsPresent(session);
|
const colors = kingsPresent(session);
|
||||||
if (!colors.white && colors.black) return "black-wins";
|
if (!colors.white && colors.black) return "black-wins";
|
||||||
|
|
|
||||||
102
packages/chess/src/presets/piece-type-registry.ts
Normal file
102
packages/chess/src/presets/piece-type-registry.ts
Normal 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();
|
||||||
|
|
@ -18,7 +18,6 @@
|
||||||
* activated via setActivePresets.
|
* activated via setActivePresets.
|
||||||
*/
|
*/
|
||||||
import { PRESET_REGISTRY } from "./registry.js";
|
import { PRESET_REGISTRY } from "./registry.js";
|
||||||
import type { ChessEngine } from "../engine.js";
|
|
||||||
import type { EntityId } from "@paratype/rete";
|
import type { EntityId } from "@paratype/rete";
|
||||||
|
|
||||||
/** Poisoned central squares: d4=27, e4=28, d5=35, e5=36. Exported for
|
/** Poisoned central squares: d4=27, e4=28, d5=35, e5=36. Exported for
|
||||||
|
|
@ -33,7 +32,7 @@ PRESET_REGISTRY.register({
|
||||||
incompatibleWith: [],
|
incompatibleWith: [],
|
||||||
requires: ["piece-hp"],
|
requires: ["piece-hp"],
|
||||||
|
|
||||||
onAfterMove(engine: ChessEngine) {
|
onAfterMove({ engine }) {
|
||||||
const session = engine.session;
|
const session = engine.session;
|
||||||
const facts = session.allFacts();
|
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
|
// 1 HP; when Hp hits 0 the piece is retracted. We tag the kind
|
||||||
// as "poison" so a future "poison-resistant" preset could short-
|
// as "poison" so a future "poison-resistant" preset could short-
|
||||||
// circuit this without changing poisoned-squares' own code.
|
// 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) {
|
for (const id of victims) {
|
||||||
engine.dealDamage(id, 1, { kind: "poison" });
|
engine.dealDamage(id, 1, { kind: "poison" });
|
||||||
|
const sq = poisonedSquaresByVictim.get(id);
|
||||||
|
if (sq !== undefined) {
|
||||||
|
engine.emitEffect({
|
||||||
|
kind: "poison",
|
||||||
|
square: sq,
|
||||||
|
ttl: 250,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
171
packages/chess/src/presets/preset-state.test.ts
Normal file
171
packages/chess/src/presets/preset-state.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -57,17 +57,12 @@ const CLOCKWISE_NEIGHBOURS: ReadonlyArray<readonly [number, number]> = [
|
||||||
[-1, 1], // NW
|
[-1, 1], // NW
|
||||||
];
|
];
|
||||||
|
|
||||||
const PIECE_ATTRS = [
|
/** Retract every effective piece attr (core + preset-declared) for
|
||||||
"PieceType",
|
* an entity. Called when the queen is removed as part of fission —
|
||||||
"Color",
|
* we need to nuke her completely so no leftover facts linger. */
|
||||||
"Position",
|
function retractEntity(engine: ChessEngine, id: EntityId): void {
|
||||||
"HasMoved",
|
for (const attr of engine.effectivePieceAttrs) {
|
||||||
"Hp",
|
if (engine.session.contains(id, attr)) engine.session.retract(id, attr);
|
||||||
] as const;
|
|
||||||
|
|
||||||
function retractEntity(session: Session, id: EntityId): void {
|
|
||||||
for (const attr of PIECE_ATTRS) {
|
|
||||||
if (session.contains(id, attr)) session.retract(id, attr);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,20 +76,10 @@ function pieceAtSquare(session: Session, sq: Square): EntityId | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Spawn a new piece entity. Returns the new id. */
|
// spawnPiece helper lived here historically. It's now
|
||||||
function spawnPiece(
|
// `engine.spawnPiece(...)` — goes through the standard onPieceSpawn
|
||||||
session: Session,
|
// pipeline so preset-declared attributes (Hp, Shield, …) get seeded
|
||||||
type: "rook" | "bishop",
|
// on fission-spawned pieces automatically.
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
PRESET_REGISTRY.register({
|
PRESET_REGISTRY.register({
|
||||||
id: "queen-splits",
|
id: "queen-splits",
|
||||||
|
|
@ -104,7 +89,7 @@ PRESET_REGISTRY.register({
|
||||||
incompatibleWith: [],
|
incompatibleWith: [],
|
||||||
requires: [],
|
requires: [],
|
||||||
|
|
||||||
onBeforeCapture(engine: ChessEngine, attacker: EntityId, target: EntityId) {
|
onBeforeCapture({ engine, attacker, target }) {
|
||||||
const session = engine.session;
|
const session = engine.session;
|
||||||
const attackerTypeFact = session
|
const attackerTypeFact = session
|
||||||
.allFacts()
|
.allFacts()
|
||||||
|
|
@ -138,8 +123,18 @@ PRESET_REGISTRY.register({
|
||||||
|
|
||||||
// Target died (standard path). Retract the queen (she fissions)
|
// Target died (standard path). Retract the queen (she fissions)
|
||||||
// and spawn a rook on the capture square + a bishop nearby.
|
// 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
|
// Find the first empty adjacent square clockwise from N and spawn
|
||||||
// the bishop there. If all 8 are occupied (rare — usually happens
|
// 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;
|
if (f < 0 || f > 7 || r < 0 || r > 7) continue;
|
||||||
const sq = squareOf(f, r) as Square;
|
const sq = squareOf(f, r) as Square;
|
||||||
if (pieceAtSquare(session, sq) !== null) continue;
|
if (pieceAtSquare(session, sq) !== null) continue;
|
||||||
spawnPiece(session, "bishop", attackerColor, sq);
|
engine.spawnPiece("bishop", attackerColor, sq, {
|
||||||
|
reason: "fission",
|
||||||
|
hasMoved: true,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
import type { EntityId } from "@paratype/rete";
|
import type { EntityId } from "@paratype/rete";
|
||||||
import type { ChessEngine, GameResult } from "../engine.js";
|
import type { ChessEngine, GameResult } from "../engine.js";
|
||||||
import type { LegalMove } from "../rules/types.js";
|
import type { LegalMove } from "../rules/types.js";
|
||||||
|
import type { ChessAttrKey } from "../schema.js";
|
||||||
|
|
||||||
/** Return shape for onBeforeCapture. `consume: true` skips the engine's
|
/** Return shape for onBeforeCapture. `consume: true` skips the engine's
|
||||||
* default capture path (no target retraction, no attacker move).
|
* 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
|
* Context for `onBeforeCapture`. Fired when a capture is about to
|
||||||
* to react only to certain damage sources (e.g. a hypothetical
|
* resolve — presets that want to replace the default retract+advance
|
||||||
* "poison-resistant" preset that ignores damage of kind "poison") read
|
* with their own mechanic (queen-splits fission, explosive-rook AoE,
|
||||||
* `ctx.kind`. The engine sets `attacker` on direct hits; damage with no
|
* capture-to-win winner-recording) read this and return
|
||||||
* attacking entity (poisoned-squares tick, fall damage, etc) leaves it
|
* `{ consume: true }`.
|
||||||
* undefined.
|
*/
|
||||||
|
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
|
* `kind` is typed as a string union with known values plus free-form
|
||||||
* overflow so future presets can introduce new damage kinds without
|
* overflow so future presets can introduce new damage kinds without
|
||||||
|
|
@ -73,6 +85,164 @@ export interface DamageContext {
|
||||||
readonly attacker?: EntityId;
|
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:
|
* Return shape for onDamage. Semantics:
|
||||||
* - `consume: true` means "I'm the authority on this damage event;
|
* - `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. */
|
/** Preset IDs that must also be active for this one to be valid. */
|
||||||
readonly requires: readonly string[];
|
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 ────────────────────────────────────────────
|
// ── Move-generation hooks ────────────────────────────────────────────
|
||||||
readonly getExtraMoves?: (engine: ChessEngine, pieceId: EntityId) => LegalMove[];
|
readonly getExtraMoves?: (engine: ChessEngine, pieceId: EntityId) => LegalMove[];
|
||||||
readonly filterMoves?: (
|
readonly filterMoves?: (
|
||||||
|
|
@ -113,14 +302,12 @@ export interface PresetDef {
|
||||||
) => LegalMove[];
|
) => LegalMove[];
|
||||||
|
|
||||||
// ── Lifecycle hooks ──────────────────────────────────────────────────
|
// ── Lifecycle hooks ──────────────────────────────────────────────────
|
||||||
readonly onActivate?: (engine: ChessEngine) => void;
|
readonly onActivate?: (ctx: LifecycleContext) => void;
|
||||||
readonly onDeactivate?: (engine: ChessEngine) => void;
|
readonly onDeactivate?: (ctx: LifecycleContext) => void;
|
||||||
|
|
||||||
// ── Capture-interception hook ────────────────────────────────────────
|
// ── Capture-interception hook ────────────────────────────────────────
|
||||||
readonly onBeforeCapture?: (
|
readonly onBeforeCapture?: (
|
||||||
engine: ChessEngine,
|
ctx: CaptureHookContext,
|
||||||
attacker: EntityId,
|
|
||||||
target: EntityId,
|
|
||||||
) => CaptureHookResult | void;
|
) => CaptureHookResult | void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -147,12 +334,42 @@ export interface PresetDef {
|
||||||
* is the canonical implementer.
|
* is the canonical implementer.
|
||||||
*/
|
*/
|
||||||
readonly onDamage?: (
|
readonly onDamage?: (
|
||||||
engine: ChessEngine,
|
ctx: DamageHookContext,
|
||||||
target: EntityId,
|
|
||||||
amount: number,
|
|
||||||
ctx: DamageContext,
|
|
||||||
) => DamageHookResult | void;
|
) => 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
|
* Fires after every successful `applyMove`, after turn advancement
|
||||||
* and tickAfterMove but before checkGameResult. `moverColor` is the
|
* 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
|
* wants to affect the non-mover; poisoned-squares damages the
|
||||||
* mover).
|
* mover).
|
||||||
*/
|
*/
|
||||||
readonly onAfterMove?: (
|
readonly onAfterMove?: (ctx: MoveHookContext) => void;
|
||||||
engine: ChessEngine,
|
|
||||||
moverColor: "white" | "black",
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook into terminal-position detection. Return a concrete `GameResult`
|
* Hook into terminal-position detection. Return a concrete `GameResult`
|
||||||
|
|
@ -183,7 +397,7 @@ export interface PresetDef {
|
||||||
* `last-piece-standing` (annihilation replaces checkmate), which both
|
* `last-piece-standing` (annihilation replaces checkmate), which both
|
||||||
* redefine "when is the game over".
|
* 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
|
* Opt out of the engine's default self-check filter for moves of
|
||||||
|
|
@ -202,9 +416,40 @@ export interface PresetDef {
|
||||||
* legally survive being attacked.
|
* legally survive being attacked.
|
||||||
*/
|
*/
|
||||||
readonly shouldFilterSelfCheck?: (
|
readonly shouldFilterSelfCheck?: (
|
||||||
engine: ChessEngine,
|
ctx: SelfCheckFilterContext,
|
||||||
color: "white" | "black",
|
|
||||||
) => boolean | undefined;
|
) => 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
97
packages/chess/src/presets/test-utils.ts
Normal file
97
packages/chess/src/presets/test-utils.ts
Normal 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;
|
||||||
|
}
|
||||||
128
packages/chess/src/presets/visual-effect.test.ts
Normal file
128
packages/chess/src/presets/visual-effect.test.ts
Normal 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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,19 +3,70 @@
|
||||||
* When a capture occurs, the captured piece's facts are retracted from WM.
|
* When a capture occurs, the captured piece's facts are retracted from WM.
|
||||||
*/
|
*/
|
||||||
import type { Session, EntityId } from "@paratype/rete";
|
import type { Session, EntityId } from "@paratype/rete";
|
||||||
import type { PieceColor } from "../schema.js";
|
import type { ChessAttrKey, 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;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a capture: retract all known facts for the captured piece.
|
* FIDE-minimal attributes every piece entity carries.
|
||||||
* Called after confirming the capture is legal.
|
*
|
||||||
|
* 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 {
|
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)) {
|
if (session.contains(capturedId, attr)) {
|
||||||
session.retract(capturedId, attr);
|
session.retract(capturedId, attr);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,69 +29,14 @@ import { Session } from "@paratype/rete";
|
||||||
import type { EntityId } from "@paratype/rete";
|
import type { EntityId } from "@paratype/rete";
|
||||||
import type { PieceColor, PieceType, Square } from "../schema.js";
|
import type { PieceColor, PieceType, Square } from "../schema.js";
|
||||||
import { oppositeColor } from "../schema.js";
|
import { oppositeColor } from "../schema.js";
|
||||||
import { getLegalKnightMoves } from "./knight.js";
|
import { isPieceAttr } from "./capture.js";
|
||||||
import {
|
|
||||||
getLegalRookMoves,
|
|
||||||
getLegalBishopMoves,
|
|
||||||
getLegalQueenMoves,
|
|
||||||
} from "./sliding.js";
|
|
||||||
import { getLegalKingMoves } from "./king.js";
|
|
||||||
import { pawnCaptureSqares } from "./primitives.js";
|
|
||||||
import type { LegalMove } from "./types.js";
|
import type { LegalMove } from "./types.js";
|
||||||
import { getPiecePosition } from "./board-queries.js";
|
import { PIECE_TYPE_REGISTRY } from "../presets/piece-type-registry.js";
|
||||||
|
// Side-effect: ensure FIDE piece types are registered. Tests that
|
||||||
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
|
// exercise these pure-function helpers directly (without ChessEngine)
|
||||||
|
// wouldn't otherwise trigger the registry population from
|
||||||
/**
|
// `presets/index.ts`.
|
||||||
* Per-piece-type "does this piece attack `target`?" predicate.
|
import "../presets/core-piece-types.js";
|
||||||
*
|
|
||||||
* 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;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is `square` attacked by any piece of `byColor`?
|
* Is `square` attacked by any piece of `byColor`?
|
||||||
|
|
@ -118,10 +63,12 @@ export function isSquareAttacked(
|
||||||
if (typeFact === undefined) continue;
|
if (typeFact === undefined) continue;
|
||||||
const type = typeFact.value as PieceType;
|
const type = typeFact.value as PieceType;
|
||||||
|
|
||||||
const probe = ATTACK_PROBES[type];
|
// Custom piece types register their own attack probe via the
|
||||||
if (probe === undefined) continue;
|
// piece-type registry. Unknown types (degenerate) contribute no
|
||||||
|
// attacks.
|
||||||
if (probe(session, f.id, square)) return true;
|
const def = PIECE_TYPE_REGISTRY.get(type);
|
||||||
|
if (def === undefined) continue;
|
||||||
|
if (def.attackProbe(session, f.id, square)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -165,10 +112,13 @@ export function isInCheck(session: Session, color: PieceColor): boolean {
|
||||||
function snapshotSession(session: Session): Session {
|
function snapshotSession(session: Session): Session {
|
||||||
const temp = new Session({ autoFire: false });
|
const temp = new Session({ autoFire: false });
|
||||||
for (const f of session.allFacts()) {
|
for (const f of session.allFacts()) {
|
||||||
// Only copy attrs we care about for board geometry + check detection.
|
// Skip game-level facts (Turn, EnPassantTarget, etc) — the
|
||||||
if ((PIECE_ATTRS as readonly string[]).includes(f.attr)) {
|
// simulator only cares about board geometry + whatever custom
|
||||||
temp.insert(f.id, f.attr, f.value);
|
// 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;
|
return temp;
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +132,13 @@ function clearSquare(temp: Session, square: Square): void {
|
||||||
const occupant = facts.find(f => f.attr === "Position" && f.value === square);
|
const occupant = facts.find(f => f.attr === "Position" && f.value === square);
|
||||||
if (occupant === undefined) return;
|
if (occupant === undefined) return;
|
||||||
const id = occupant.id;
|
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);
|
if (temp.contains(id, attr)) temp.retract(id, attr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,34 +21,13 @@
|
||||||
* - En passant as an escape. En-passant generation is P2.16; once it
|
* - En passant as an escape. En-passant generation is P2.16; once it
|
||||||
* lands, it can be plumbed in by extending MOVE_GETTERS.
|
* 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 type { PieceColor, PieceType } from "../schema.js";
|
||||||
import { isInCheck, filterSelfCheckMoves } from "./check.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";
|
import type { LegalMove } from "./types.js";
|
||||||
|
import { PIECE_TYPE_REGISTRY } from "../presets/piece-type-registry.js";
|
||||||
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
|
// Side-effect: see check.ts comment.
|
||||||
|
import "../presets/core-piece-types.js";
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect every pseudo-legal move for every piece of `color`.
|
* Collect every pseudo-legal move for every piece of `color`.
|
||||||
|
|
@ -74,8 +53,11 @@ function getAllPseudoLegalMoves(
|
||||||
if (typeFact === undefined) continue;
|
if (typeFact === undefined) continue;
|
||||||
const type = typeFact.value as PieceType;
|
const type = typeFact.value as PieceType;
|
||||||
|
|
||||||
const getter = MOVE_GETTERS[type];
|
// Dispatch via the piece-type registry — custom types registered
|
||||||
if (getter === undefined) continue;
|
// 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)) {
|
for (const m of getter(session, f.id)) {
|
||||||
moves.push(m);
|
moves.push(m);
|
||||||
|
|
|
||||||
|
|
@ -25,35 +25,10 @@ import { Session as ReteSession } from "@paratype/rete";
|
||||||
import type { Session, EntityId } from "@paratype/rete";
|
import type { Session, EntityId } from "@paratype/rete";
|
||||||
import type { PieceColor, PieceType } from "../schema.js";
|
import type { PieceColor, PieceType } from "../schema.js";
|
||||||
import { isInCheck } from "./check.js";
|
import { isInCheck } from "./check.js";
|
||||||
import { getLegalPawnMoves } from "./pawn.js";
|
import { isPieceAttr } from "./capture.js";
|
||||||
import { getLegalKnightMoves } from "./knight.js";
|
import { PIECE_TYPE_REGISTRY } from "../presets/piece-type-registry.js";
|
||||||
import {
|
// Side-effect: see check.ts comment.
|
||||||
getLegalRookMoves,
|
import "../presets/core-piece-types.js";
|
||||||
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;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Does `color` have at least one move that doesn't leave its king in
|
* 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) {
|
for (const piece of pieces) {
|
||||||
const getter = MOVE_GETTERS[piece.type];
|
const getter = PIECE_TYPE_REGISTRY.get(piece.type)?.moveGenerator;
|
||||||
if (getter === undefined) continue;
|
if (getter === undefined) continue;
|
||||||
|
|
||||||
for (const move of getter(session, piece.id)) {
|
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 });
|
const temp = new ReteSession({ autoFire: false });
|
||||||
for (const f of facts) {
|
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.
|
// For captures, drop whatever sits on the destination square.
|
||||||
if (move.isCapture && f.attr === "Position" && f.value === move.to) {
|
if (move.isCapture && f.attr === "Position" && f.value === move.to) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -89,13 +68,17 @@ function hasAnyLegalMove(session: Session, color: PieceColor): boolean {
|
||||||
temp.insert(f.id, f.attr, f.value);
|
temp.insert(f.id, f.attr, f.value);
|
||||||
}
|
}
|
||||||
// For captures we also need to clear the captured piece's other
|
// 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) {
|
if (move.isCapture) {
|
||||||
const captured = facts.find(
|
const captured = facts.find(
|
||||||
g => g.attr === "Position" && g.value === move.to && g.id !== move.pieceId,
|
g => g.attr === "Position" && g.value === move.to && g.id !== move.pieceId,
|
||||||
);
|
);
|
||||||
if (captured !== undefined) {
|
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)) {
|
if (temp.contains(captured.id, attr)) {
|
||||||
temp.retract(captured.id, attr);
|
temp.retract(captured.id, attr);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,14 +66,9 @@ export function clearMoveGeneratorRegistry(): void {
|
||||||
moveGeneratorRegistry.clear();
|
moveGeneratorRegistry.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Piece-level attributes retracted when an entity is captured. */
|
// Snapshot-copy predicate shared with check.ts / stalemate.ts.
|
||||||
const PIECE_ATTRS = [
|
// See capture.ts for the "not game-level" rationale.
|
||||||
"PieceType",
|
import { isPieceAttr } from "./capture.js";
|
||||||
"Color",
|
|
||||||
"Position",
|
|
||||||
"HasMoved",
|
|
||||||
"Hp",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get every legal move available to pieces of `color` given the current
|
* 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) {
|
if (move.isCapture) {
|
||||||
const capturedId = getPieceAt(session, move.to);
|
const capturedId = getPieceAt(session, move.to);
|
||||||
if (capturedId !== null && capturedId !== move.pieceId) {
|
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)) {
|
if (session.contains(capturedId, attr)) {
|
||||||
session.retract(capturedId, attr);
|
session.retract(capturedId, attr);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,18 @@ export type GameResult = PieceColor | "draw";
|
||||||
/** EntityId reserved for game-level facts (Turn, HalfmoveClock, etc.). */
|
/** EntityId reserved for game-level facts (Turn, HalfmoveClock, etc.). */
|
||||||
export const GAME_ENTITY: EntityId = 0 as EntityId;
|
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.
|
* Maps attribute name to its TypeScript type.
|
||||||
* Used for typed fact construction and WM queries.
|
* Used for typed fact construction and WM queries.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState, useMemo } from 'react';
|
||||||
import type { ChessFact, PieceColor, PieceType } from '../schema';
|
import type { ChessFact, PieceColor, PieceType } from '../schema';
|
||||||
import { squareToAlgebraic, squareOf, squareColor } from '../coord';
|
import { squareToAlgebraic, squareOf, squareColor } from '../coord';
|
||||||
import type { LegalMove } from '../rules/types';
|
import type { LegalMove } from '../rules/types';
|
||||||
|
import type { ChessEngine } from '../engine';
|
||||||
import { Piece } from './Piece';
|
import { Piece } from './Piece';
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
import { pieceAssets } from '../assets/pieces';
|
import { pieceAssets } from '../assets/pieces';
|
||||||
|
|
@ -11,6 +12,7 @@ import {
|
||||||
type PieceOverlayComponent,
|
type PieceOverlayComponent,
|
||||||
type SquareOverlayComponent,
|
type SquareOverlayComponent,
|
||||||
} from './preset-overlays';
|
} from './preset-overlays';
|
||||||
|
import { VisualEffectLayer } from './VisualEffectLayer';
|
||||||
import '../presets/ui-overlays-index';
|
import '../presets/ui-overlays-index';
|
||||||
|
|
||||||
interface BoardProps {
|
interface BoardProps {
|
||||||
|
|
@ -29,6 +31,9 @@ interface BoardProps {
|
||||||
/** Currently-active preset ids. Used to look up registered per-piece
|
/** Currently-active preset ids. Used to look up registered per-piece
|
||||||
* overlay components (e.g. HP pips for piece-hp). Order preserved. */
|
* overlay components (e.g. HP pips for piece-hp). Order preserved. */
|
||||||
activePresetIds?: ReadonlyArray<string>;
|
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 {
|
interface PieceState {
|
||||||
|
|
@ -37,7 +42,7 @@ interface PieceState {
|
||||||
color: PieceColor;
|
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
|
// Pre-compute overlay components once per render — lookup is cheap
|
||||||
// but doing it once in a useMemo keeps the Piece render path clean.
|
// but doing it once in a useMemo keeps the Piece render path clean.
|
||||||
const overlays: PieceOverlayComponent[] = useMemo(
|
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">
|
<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}
|
{squares}
|
||||||
</div>
|
</div>
|
||||||
|
{engine !== undefined && <VisualEffectLayer engine={engine} />}
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{promotionMove && (
|
{promotionMove && (
|
||||||
|
|
|
||||||
|
|
@ -382,6 +382,7 @@ function GameLayout({
|
||||||
lastMove={lastMove}
|
lastMove={lastMove}
|
||||||
checkedKingSquare={checkedKingSquare}
|
checkedKingSquare={checkedKingSquare}
|
||||||
activePresetIds={activations.map((a) => a.id)}
|
activePresetIds={activations.map((a) => a.id)}
|
||||||
|
{...(state.engine !== null ? { engine: state.engine } : {})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Overlay for game over to prevent further interaction visually */}
|
{/* Overlay for game over to prevent further interaction visually */}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { ChessAttrMap, ChessFact, PieceColor, PieceType } from '../schema';
|
import type { ChessAttrMap, ChessFact, PieceColor, PieceType } from '../schema';
|
||||||
import { pieceAssets } from '../assets/pieces';
|
import { pieceAssets } from '../assets/pieces';
|
||||||
|
import { PIECE_TYPE_REGISTRY } from '../presets/piece-type-registry';
|
||||||
import {
|
import {
|
||||||
motion,
|
motion,
|
||||||
useMotionValue,
|
useMotionValue,
|
||||||
|
|
@ -95,7 +96,12 @@ export function Piece({
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
}: PieceProps) {
|
}: 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`.
|
// Raw pointer offset from drag origin, updated on every `dragover`.
|
||||||
const xRaw = useMotionValue(0);
|
const xRaw = useMotionValue(0);
|
||||||
|
|
|
||||||
152
packages/chess/src/ui/VisualEffectLayer.tsx
Normal file
152
packages/chess/src/ui/VisualEffectLayer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue