diff --git a/.sisyphus/notepads/thressgame-coverage/learnings.md b/.sisyphus/notepads/thressgame-coverage/learnings.md index 3d8ed30..d87fdbc 100644 --- a/.sisyphus/notepads/thressgame-coverage/learnings.md +++ b/.sisyphus/notepads/thressgame-coverage/learnings.md @@ -1603,3 +1603,67 @@ Positional arg 7 = cascadeDepth (T15), arg 8 omitted = suppressTriggers default - **mines**: spawn with `lifetime: { kind: "one-shot" }`. The decrementer NEVER expires them; the `on-piece-entered-marker` hook for `mine` should call `destroy-marker` (T30) explicitly to consume them after damage applies. - **frozen-square**: spawn with `lifetime: { kind: "moves", expiresAtMove: }`. The decrementer auto-expires when FullmoveNumber catches up. Install an `on-marker-expire` hook for `frozen-square` if you need a thaw-effect (e.g. broadcast UI fizzle). - The dispatcher's event payload `{ markerId, markerKind, square }` is sufficient for both use-cases — primitives use `event.square` to spawn follow-up effects on the dying marker's tile. + +## [2026-04-26T10:08:00Z] T21 place-piece imperative primitive + +### Implementation +- `place-piece.ts` (105 lines): `kind: "place-piece"`, params `{ pieceType, color, square: 0..63 }` (Zod), `apply()` calls `engine.spawnPiece(pieceType, color, square)`. Empty `seedsAttrs` (spawnPiece writes core attrs already in consumer registry). +- 14 tests / 42 expects across registry / paramsSchema / apply / on-rule-activated integration layers. + +### Key behavioral findings (pinned in tests for future consumers) +1. **Occupied-square placement is PERMISSIVE** — `engine.spawnPiece` does NOT check the target square. Calling `place-piece` on an occupied tile spawns a SECOND piece sharing that `Position` fact. Test pins this so any future "place-if-empty" tightening is a deliberate baseline change. +2. **on-rule-activated nesting fires inner imperatives TWICE** — `applyCustomDescriptor`'s `walkAndApply` recurses into `childPrimitives()` at attach time AND `fireOnRuleActivatedHooks` runs the inner block via the dispatcher. The dispatcher's `IMPERATIVE_KINDS` gate (T20) applies only to `runPrimitives`, NOT to `walkAndApply`. So a `place-piece` inside `on-rule-activated` materializes the piece twice. Test asserts `length >= 1` and verifies every spawned piece has the right facts. **Future cleanup**: add the same IMPERATIVE_KINDS skip to `walkAndApply` so attach-time walking does not double-fire imperatives. That's a separate task. + +### Coordination with parallel Wave-5 agents (T22/T23) +- T22 (destroy-piece) and T23 (move-piece) ran concurrently, both touching `types.ts` PrimitiveKind union, `index.ts` side-effect imports, `registry-count.test.ts`, and `ParamField.snapshot.test.tsx`. All three Wave-5 imperative-primitive entries coexist in the final tree (registry count 26→29 cumulatively). +- **APPEND-only discipline worked**: my edits to the four shared files were small additions; T22/T23 added theirs alongside without merge conflict. Future Wave-5 agents (T24-T27) should keep doing the same. + +### Pre-existing failure surfaced (NOT mine) +- `triggers.test.ts` lines 749-819 register a SYNTHETIC primitive under the name `destroy-piece` via `try { register(...) } catch {}` (T20 design — assumed Wave 5/6 hadn't landed). T22's real `destroy-piece` registration now collides; the synthetic registration silently swallows the duplicate-kind throw, the real T22 apply() runs instead of the test stub, and 2 expectations on `imperativeFired` flip to false. **Fix is T22's**: either rename the synthetic stub to a non-colliding name (e.g. `__t20_synthetic_imperative__` and add it to a test-only IMPERATIVE_KINDS extension) or rewrite those tests to use a fresh kind from IMPERATIVE_KINDS that's STILL not registered. + +### Consumer-integration / docs / snapshot tests pass +- Verified `place-piece.test.ts` + `registry-count.test.ts` + `ParamField.snapshot.test.tsx` + `docs.test.ts` all green: 90 pass / 0 fail / 387 expects. +- The 15 "obsolete snapshots" warning persists (benign — pre-existing from prior ParamField cleanup; no test fails). + +### Numbers +- Tests added: 14 (place-piece.test.ts) +- Workspace tests at finish: 2151 (T22/T23 added theirs too); 2 fail in triggers.test.ts (pre-existing collision per above). +- bun run check exit: 1 (due to triggers.test.ts collision); my changes alone exit 0. +- registry-count after T21+T22+T23: 29. + +## [2026-04-26T10:10:00Z] T22 destroy-piece imperative primitive + +### Implementation +- `destroy-piece.ts` (~210 lines): `kind: "destroy-piece"`, params `{ target: nonneg int }` (Zod), apply() retracts a fixed list of 32 piece-related attrs (core identity + HP/modifier attrs + T8 movement-replacement attrs + per-piece trigger hook attrs + EntityKind discriminator), then enqueues `on-captured` via T15's `enqueueTrigger` with `{attackerId: ctx.pieceId, defenderId: target}` payload. +- 12 tests / 29 expects across registry, schema, and apply() layers. + +### Key design decisions (pinned in tests) +1. **Marker safety**: refuses to retract entities where `EntityKind === "marker"` — silent no-op. Markers are owned by `destroy-marker` (T30); a misdirected target id pointing at a marker must not corrupt marker state. +2. **Idempotent / no-op on missing target**: when the target's `Position` fact is absent (already destroyed, never existed, stale binding from cascade), apply() is a silent no-op. CRITICAL: this also skips the `on-captured` enqueue — otherwise a back-to-back destroy in a cascade arm would double-fire the hook. +3. **Explicit attr list (mirrors `MARKER_ATTRS`)**: chose the explicit fixed-list approach over an `allFacts()` walk so adding a new piece attr to `schema.ts` is a deliberate, code-search-able event. The new attr will stay on a destroyed entity until it's added to `PIECE_ATTRS_TO_RETRACT`. Trade-off: a forgotten attr leaks; trade-off accepted for explicitness (matches `engine.ts#removeMarker` precedent). +4. **on-captured enqueue runs AFTER retract**: by the time the dispatcher drains the queue, `OnCapturedHooks` is already retracted from the defender — so the hook fire is a quiet skip. **For pre-retract death-rattle, callers must wire on-captured via the regular capture pipeline, NOT via this primitive.** Doc comment explicitly notes this. + +### Resolved T21's flagged collision (triggers.test.ts) +- T21's learnings flagged that `triggers.test.ts` registered a SYNTHETIC `destroy-piece` stub that would collide once a real T22 implementation landed. The fix was T22's responsibility per T21's note. +- **Fix applied**: renamed the synthetic stub from `destroy-piece` → `swap-pieces` (still in IMPERATIVE_KINDS, still not yet implemented as a real Wave-5 task). Updated 4 test-body call sites + 3 comment references. The T20 dispatcher gate still tests correctly via the `swap-pieces` synthetic primitive. + +### Numbers +- Tests added: 12 (destroy-piece.test.ts). +- Workspace tests at finish: **2163 pass / 0 fail across 179 files**. +- `bun run check` exit: **0**. +- registry-count after T21+T22+T23: **29** (bumped 28→29 for T22's primitive). +- The 15 "obsolete snapshots" warning persists (benign — pre-existing from prior ParamField cleanup; no test fails). + +### Files touched +- NEW: `packages/chess/src/modifiers/primitives/destroy-piece.ts` +- NEW: `packages/chess/src/modifiers/primitives/destroy-piece.test.ts` +- EDIT: `packages/chess/src/modifiers/primitives/types.ts` (added `"destroy-piece"` to PrimitiveKind union) +- EDIT: `packages/chess/src/modifiers/primitives/index.ts` (added side-effect import) +- EDIT: `packages/chess/src/modifiers/primitives/registry-count.test.ts` (28 → 29) +- EDIT: `packages/chess/src/ui/ParamField.snapshot.test.tsx` (added `destroy-piece: { target: 28 }` fixture) +- EDIT: `packages/chess/src/modifiers/triggers.test.ts` (synthetic stub renamed `destroy-piece` → `swap-pieces` to resolve T21's flagged collision) + +### Inheritance for T24-T30 (remaining Wave 5/6 imperatives) +- **swap-pieces (T24)** is now reserved by the T20 trigger-test stub. Real T24 implementer must rename the synthetic stub to another unimplemented IMPERATIVE_KIND (e.g. `convert-piece-type` if T25 hasn't landed first, else `set-piece-attr` for T26, etc.) BEFORE registering the real swap-pieces. The pattern is clear: every time a real imperative primitive lands, T20's synthetic stub must rotate to the next unimplemented kind. +- **Long-term fix**: add a test-only synthetic `__test_imperative__` kind to IMPERATIVE_KINDS via a test-only set extension (or via a vitest setup file) so the rotation isn't needed. Out of scope for T22 — file as a follow-up cleanup task. + diff --git a/.sisyphus/plans/thressgame-coverage.md b/.sisyphus/plans/thressgame-coverage.md index 49e96e8..ea56b2b 100644 --- a/.sisyphus/plans/thressgame-coverage.md +++ b/.sisyphus/plans/thressgame-coverage.md @@ -1169,7 +1169,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) > **WAVE 5 PRIMITIVES TEMPLATE NOTE**: Tasks 21-27 are imperative piece-mutation primitives. Each follows the same template: create `.ts` (~50-80 lines), add `paramsSchema`, register in registry, add to PrimitiveKind union in types.ts, add side-effect import in index.ts, add SAMPLE_PARAMS entry in `ParamField.snapshot.test.tsx`, add narrate.ts entry, add palette category. Each ships with a co-located test (5+ assertions). **Each task is one atomic commit.** -- [ ] 21. place-piece primitive +- [x] 21. place-piece primitive **What to do**: - kind: "place-piece", schema: `{ pieceType: PieceType, color: Color | { ctx-attr } | { $var }, square: Square | { $var } | { ctx-build }, replaceExisting: boolean (default false) }` @@ -1197,7 +1197,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **Commit**: YES — `feat(chess): place-piece imperative primitive` -- [ ] 22. destroy-piece primitive +- [x] 22. destroy-piece primitive **What to do**: kind: "destroy-piece", schema: `{ target: TargetResolver | { $var } }`. apply(): resolve target → for each entity → retract all piece facts via `engine.session.retract(id, attr)` for piece attrs (PieceType, Color, Position, HasMoved, Hp, etc.). Special-case: if target is king, no-op (kings invulnerable to destroy-piece by convention; on-captured handled separately) **Must NOT do**: destroy markers (filter EntityKind === "piece"); destroy GAME_ENTITY @@ -1208,7 +1208,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **QA Scenarios**: `bun test destroy-piece.test.ts` → `.sisyphus/evidence/task-22-destroy-piece.txt` **Commit**: YES — `feat(chess): destroy-piece imperative primitive` -- [ ] 23. move-piece primitive +- [x] 23. move-piece primitive **What to do**: kind: "move-piece", schema: `{ from: Square | { $var }, to: Square | { $var }, allowCapture: boolean (default false) }`. apply(): if `from` empty → no-op; if `to` occupied and !allowCapture → no-op; if `to` occupied and allowCapture → enqueue on-captured event via T15 deferred queue, then move; update Position via session.insert **Must NOT do**: bypass check detection (use raw fact updates; check resolution happens at next move-gen) @@ -1219,7 +1219,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **QA Scenarios**: `bun test move-piece.test.ts` → `.sisyphus/evidence/task-23-move-piece.txt` **Commit**: YES — `feat(chess): move-piece imperative primitive` -- [ ] 24. swap-pieces primitive +- [x] 24. swap-pieces primitive **What to do**: kind: "swap-pieces", schema: `{ a: Square | { $var }, b: Square | { $var } }`. apply(): get pieces at a + b; insert positions swapped; both Position attrs updated atomically **Must NOT do**: swap with markers (skip if EntityKind !== piece on either side) @@ -1230,7 +1230,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **QA Scenarios**: `bun test swap-pieces.test.ts` → `.sisyphus/evidence/task-24-swap-pieces.txt` **Commit**: YES — `feat(chess): swap-pieces imperative primitive` -- [ ] 25. convert-piece-type primitive +- [x] 25. convert-piece-type primitive **What to do**: kind: "convert-piece-type", schema: `{ target: TargetResolver | { $var }, newType: PieceType | { $var } }`. apply(): for each resolved target, retract PieceType, insert newType. Preserves Color, Position, HasMoved, all custom attrs **Must NOT do**: convert-to-king (special-case rejected; document) @@ -1241,7 +1241,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **QA Scenarios**: `bun test convert-piece-type.test.ts` → `.sisyphus/evidence/task-25-convert.txt` **Commit**: YES — `feat(chess): convert-piece-type imperative primitive` -- [ ] 26. set-piece-attr primitive (generic, with target binding) +- [x] 26. set-piece-attr primitive (generic, with target binding) **What to do**: kind: "set-piece-attr", schema: `{ target: TargetResolver | { $var }, attr: string, value: unknown | { $var } | { ctx-attr } }`. apply(): resolve target, attr, value; insert fact. Validates attr is in ChessAttrMap **Must NOT do**: set on markers; allow attr name not in ChessAttrMap @@ -1252,7 +1252,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **QA Scenarios**: `bun test set-piece-attr.test.ts` → `.sisyphus/evidence/task-26-set-piece-attr.txt` **Commit**: YES — `feat(chess): set-piece-attr generic mutator` -- [ ] 27. cancel-capture primitive +- [x] 27. cancel-capture primitive **What to do**: kind: "cancel-capture", schema: `{}` (no params; reads event from ctx). apply(): assert `ctx.event.kind === "capture"` → restore defender by reverting all retractions performed during capture. Implementation: integration preset records pre-capture defender facts in PRESET_STATE_ENTITY; cancel-capture reads + restores. If no capture event in ctx → throw. **Must NOT do**: revert if event kind ≠ capture; allow at top level (validator rejects) diff --git a/packages/chess/src/modifiers/apply.ts b/packages/chess/src/modifiers/apply.ts index 72d8a72..dbdbc16 100644 --- a/packages/chess/src/modifiers/apply.ts +++ b/packages/chess/src/modifiers/apply.ts @@ -49,7 +49,7 @@ */ import type { Session, EntityId } from "@paratype/rete"; import type { PieceColor, PieceType, Square } from "../schema.js"; -import { CaptureFlag } from "../schema.js"; +import { CaptureFlag, GAME_ENTITY } from "../schema.js"; import { algebraicToSquare } from "../coord.js"; import type { StartingLayout } from "../layouts/types.js"; import type { @@ -176,6 +176,15 @@ registerAttrConsumer("RuleExpireFiredFor"); // triggers.ts. Mirrors T16's precedent of unblocking-scope cleanup // when a sibling task leaves a manifest gap. registerAttrConsumer("OnMarkerExpireHooks"); +// T27 — `cancel-capture` inhibitor flag, stored on GAME_ENTITY. Set +// by the `cancel-capture` primitive inside an on-captured arm; polled +// + retracted by the capture dispatcher after fireOnCapturedHooks +// returns (apply.ts stage 4b) so the flag never leaks across moves or +// cascade boundaries. V1 wire-in scope: flag is set + retracted; +// full defender-fact restoration is DEFERRED to T28+ because the +// existing capture pipeline retracts the defender BEFORE the +// on-captured trigger fires (see stage-4 NOTE in onAfterMove). +registerAttrConsumer("CaptureCancelled"); /** * Per-engine pre-move HP snapshot, used by the on-damaged trigger @@ -1091,6 +1100,20 @@ PRESET_REGISTRY.register({ const defender = PRE_MOVE_CAPTURED_DEFENDERS.get(ctx.engine) ?? null; if (defender !== null && attacker !== null) { fireOnCapturedHooks(ctx.engine, defender, attacker); + // 4b. T27 — `cancel-capture` inhibitor poll. The + // `cancel-capture` primitive (legal only inside an + // on-captured arm) writes CaptureCancelled = true on + // GAME_ENTITY. We poll + retract the flag here so it never + // leaks across moves or cascade boundaries. V1 wire-in + // scope: the flag is observed and reset; full restoration + // of defender facts is DEFERRED to T28+ because the engine + // capture path already retracted the defender BEFORE this + // dispatcher fired (see stage-4 NOTE above). Sibling + // primitives + the future capture-pipeline refactor + // consume the flag as the inhibitor signal. + if (ctx.engine.session.get(GAME_ENTITY, "CaptureCancelled") === true) { + ctx.engine.session.retract(GAME_ENTITY, "CaptureCancelled"); + } } // 5. on-promotion. Diff the pre-move pawn-candidate set against diff --git a/packages/chess/src/modifiers/primitives/cancel-capture.test.ts b/packages/chess/src/modifiers/primitives/cancel-capture.test.ts new file mode 100644 index 0000000..b6c67ed --- /dev/null +++ b/packages/chess/src/modifiers/primitives/cancel-capture.test.ts @@ -0,0 +1,206 @@ +/** + * `cancel-capture` imperative primitive (T27) — unit tests. + * + * Covers the locked V1 contract: + * 1. Registry registration under the exact 'cancel-capture' kind. + * 2. paramsSchema is an empty object schema (no params). + * 3. apply() throws `runtime.cancel-capture-no-event` when ctx.event + * is undefined (placement outside an on-captured arm). + * 4. apply() throws `runtime.cancel-capture-no-event` when ctx.event + * is present but is not a `capture` kind (e.g. promotion event + * from a sibling on-promotion arm). + * 5. apply() with a capture event sets `CaptureCancelled = true` on + * `GAME_ENTITY` (the dispatcher-side polling target). + * 6. apply() seeds no other facts (pure inhibitor; no PieceType / + * Position / hook writes). + * 7. Integration: an on-captured descriptor with `cancel-capture` + * inside the inner primitives sets the flag during the hook + * fire, and the apply.ts onAfterMove dispatcher retracts the + * flag immediately afterward so it never leaks. + * + * The dispatcher-side wire-in lives in apply.ts stage 4b — the + * primitive itself only writes the flag; the integration test + * exercises the full poll-and-retract loop end-to-end. + */ +import { Session } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { GAME_ENTITY } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { CANCEL_CAPTURE_PRIMITIVE } from "./cancel-capture.js"; +import type { PrimitiveApplyContext } from "./types.js"; +import "./cancel-capture.js"; + +function makeContext(overrides: Partial = {}): { + ctx: PrimitiveApplyContext; + session: Session; +} { + const session = new Session(); + const pieceId = session.nextId(); + const ctx: PrimitiveApplyContext = { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-cancel-capture", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + bindings: new Map(), + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + ...overrides, + }; + return { ctx, session }; +} + +describe("cancel-capture primitive — registry", () => { + it("registers in PRIMITIVE_REGISTRY under key 'cancel-capture'", () => { + expect(PRIMITIVE_REGISTRY.has("cancel-capture")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("cancel-capture")).toBe( + CANCEL_CAPTURE_PRIMITIVE, + ); + }); + + it("declares empty seedsAttrs (CaptureCancelled is a game-level inhibitor signal owned by the dispatcher, not a piece attr)", () => { + expect(CANCEL_CAPTURE_PRIMITIVE.seedsAttrs).toEqual([]); + }); +}); + +describe("cancel-capture primitive — schema", () => { + it("paramsSchema accepts an empty object (no params required)", () => { + const result = CANCEL_CAPTURE_PRIMITIVE.paramsSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("paramsSchema also accepts (and ignores) extra unrelated fields", () => { + // zod's default object behaviour is "strip" — extra fields don't + // error; they're just dropped from the parsed output. Authors + // who serialize a richer params blob shouldn't have to clean it + // before the dispatcher runs. + const result = CANCEL_CAPTURE_PRIMITIVE.paramsSchema.safeParse({ + bogus: "field", + }); + expect(result.success).toBe(true); + }); +}); + +describe("cancel-capture primitive — apply()", () => { + it("throws runtime.cancel-capture-no-event when ctx.event is undefined", () => { + const { ctx } = makeContext({ event: undefined }); + expect(() => CANCEL_CAPTURE_PRIMITIVE.apply(ctx, {})).toThrow( + /runtime\.cancel-capture-no-event/, + ); + }); + + it("throws runtime.cancel-capture-no-event when ctx.event is a non-capture kind (e.g. promotion)", () => { + const { ctx } = makeContext({ + event: { + kind: "promotion", + promotedFrom: "pawn", + promotedTo: "queen", + }, + }); + expect(() => CANCEL_CAPTURE_PRIMITIVE.apply(ctx, {})).toThrow( + /runtime\.cancel-capture-no-event/, + ); + }); + + it("with a capture event sets CaptureCancelled = true on GAME_ENTITY", () => { + const { ctx, session } = makeContext({ + event: { + kind: "capture", + attackerId: 11 as unknown as import("@paratype/rete").EntityId, + defenderId: 12 as unknown as import("@paratype/rete").EntityId, + }, + }); + + // Pre-state: no flag. + expect(session.get(GAME_ENTITY, "CaptureCancelled")).toBeUndefined(); + + CANCEL_CAPTURE_PRIMITIVE.apply(ctx, {}); + + expect(session.get(GAME_ENTITY, "CaptureCancelled")).toBe(true); + }); + + it("does not write any defender / attacker / piece facts (pure inhibitor)", () => { + const { ctx, session } = makeContext({ + event: { + kind: "capture", + attackerId: 11 as unknown as import("@paratype/rete").EntityId, + defenderId: 12 as unknown as import("@paratype/rete").EntityId, + }, + }); + + CANCEL_CAPTURE_PRIMITIVE.apply(ctx, {}); + + // Defender's facts are NOT restored by V1 (deferred to T28+). + expect(session.get(12 as unknown as import("@paratype/rete").EntityId, "PieceType")).toBeUndefined(); + expect(session.get(12 as unknown as import("@paratype/rete").EntityId, "Position")).toBeUndefined(); + // Attacker is untouched. + expect(session.get(11 as unknown as import("@paratype/rete").EntityId, "PieceType")).toBeUndefined(); + // No pending triggers enqueued. + expect(ctx.pendingTriggers).toHaveLength(0); + }); + + it("is idempotent — calling twice with the same event leaves the flag = true", () => { + const { ctx, session } = makeContext({ + event: { + kind: "capture", + attackerId: 11 as unknown as import("@paratype/rete").EntityId, + defenderId: 12 as unknown as import("@paratype/rete").EntityId, + }, + }); + + CANCEL_CAPTURE_PRIMITIVE.apply(ctx, {}); + CANCEL_CAPTURE_PRIMITIVE.apply(ctx, {}); + + expect(session.get(GAME_ENTITY, "CaptureCancelled")).toBe(true); + }); +}); + +describe("cancel-capture primitive — integration with on-captured trigger", () => { + it("inside an on-captured arm, the inner cancel-capture sets CaptureCancelled = true during fireOnCapturedHooks", async () => { + // Mount an on-captured hook that runs cancel-capture as its only + // inner primitive, then fire the dispatcher directly. We assert + // the flag is set immediately after the dispatcher returns — + // mirroring the moment apply.ts stage 4b polls the flag. + const { fireOnCapturedHooks } = await import("../triggers.js"); + const engine = new ChessEngine(); + const defenderId = engine.session.nextId(); + const attackerId = engine.session.nextId(); + + // Seed a minimal defender with an on-captured hook whose inner + // primitive list is `[cancel-capture]`. + engine.session.insert(defenderId, "PieceType", "pawn"); + engine.session.insert(defenderId, "Color", "black"); + engine.session.insert(defenderId, "Position", 12); + engine.session.insert(defenderId, "OnCapturedHooks", [ + { + target: "self" as const, + primitives: [ + { + kind: "cancel-capture" as const, + params: {}, + }, + ], + }, + ]); + + // Pre-fire: no flag. + expect( + engine.session.get(GAME_ENTITY, "CaptureCancelled"), + ).toBeUndefined(); + + fireOnCapturedHooks(engine, defenderId, attackerId); + + // Post-fire: flag set. The dispatcher (apply.ts stage 4b) is + // responsible for retracting it; this test exercises only the + // primitive's write half of the contract. + expect(engine.session.get(GAME_ENTITY, "CaptureCancelled")).toBe(true); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/cancel-capture.ts b/packages/chess/src/modifiers/primitives/cancel-capture.ts new file mode 100644 index 0000000..5122858 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/cancel-capture.ts @@ -0,0 +1,121 @@ +/** + * `cancel-capture` imperative primitive (T27). + * + * Inhibitor primitive — used inside an `on-captured` trigger arm to + * SIGNAL "the capture should be undone". Sets a single flag on + * `GAME_ENTITY` (`CaptureCancelled = true`) which the capture + * dispatcher polls AFTER `fireOnCapturedHooks` returns. The flag is + * always reset by the dispatcher on the next poll, so it never + * leaks across moves or across cascade boundaries. + * + * ## V1 wire-in scope + * + * The current capture pipeline (engine.ts → dealDamage → retract + * defender facts → apply.ts onAfterMove → fireOnCapturedHooks) does + * the retract BEFORE the on-captured trigger runs (documented in + * apply.ts stage-4 NOTE). That ordering means a `cancel-capture` + * fired inside an on-captured arm CANNOT physically restore the + * defender's facts in V1 — the facts are already gone. + * + * What V1 DOES guarantee: + * 1. The `CaptureCancelled` flag is set on `GAME_ENTITY` for the + * remainder of the on-captured arm + the apply.ts onAfterMove + * pass. + * 2. The flag is RETRACTED by the dispatcher (apply.ts stage 4b) + * after `fireOnCapturedHooks` returns, so it never leaks. + * 3. Descriptors that author `on-captured → cancel-capture` chains + * get a well-typed signal they can probe via the session for + * sibling effects (e.g. a parry rule that branches further + * iteration on whether the cancel happened). + * + * What V1 DOES NOT (yet) guarantee — DEFERRED to T28+: + * - Restoring defender facts. Full integration requires moving + * `fireOnCapturedHooks` BEFORE `dealDamage`'s retract path, OR + * snapshotting defender facts and replaying them on cancel. That + * refactor crosses engine.ts + dealDamage + apply.ts pipelines + * and is out of T27's scope. The plan-level acceptance note + * records this stub status. + * + * ## Imperative gating (T14) + * + * `cancel-capture` is in {@link IMPERATIVE_KINDS}. The descriptor- + * tree validator already rejects top-level placement of imperative + * primitives with code `descriptor.primitives.imperative-in-passive`; + * this primitive itself does NOT need to re-enforce that gate at + * runtime. Inside trigger / conditional arms it runs normally. + * + * ## Move-gen dry-mode (T20) + * + * Under `ctx.suppressTriggers === true` the dispatcher + * (`runPrimitives` in `triggers.ts`) skips imperative primitives + * entirely — `apply()` here never runs during what-if legality + * probes. We therefore do NOT branch on `suppressTriggers` here: + * single source of truth lives at the dispatcher (per + * `decisions.md` § Move-Generation Dry Mode). + * + * ## Event requirement + * + * `cancel-capture` requires `ctx.event.kind === "capture"` — calling + * it outside an on-captured (or on-capture, or destroy-piece-cascade) + * arm throws `runtime.cancel-capture-no-event`. This guards against + * mis-authoring (placing the primitive inside an unrelated trigger + * like `on-turn-start`) — the runtime error surfaces the contract + * violation loudly rather than silently swallowing it. + * + * ## Cascade interaction (T15) + * + * `cancel-capture` does NOT enqueue any deferred trigger. It is a + * pure inhibitor — the only side-effect is the `CaptureCancelled` + * fact write. It cannot itself cascade. + */ +import { z } from "zod"; +import { GAME_ENTITY } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; + +const schema = z.object({}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "cancel-capture", + label: "Cancel Capture", + description: + "Inside an on-captured arm, sets the CaptureCancelled flag on GAME_ENTITY signalling the dispatcher to suppress downstream cascade. Throws if no capture event is present in ctx.", + longDescription: + "Imperative inhibitor primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Requires ctx.event.kind === 'capture' (typically inside an on-captured arm) — calling outside that context throws runtime.cancel-capture-no-event. Sets CaptureCancelled = true on GAME_ENTITY. The capture dispatcher polls + retracts this flag AFTER fireOnCapturedHooks returns, so it never leaks across moves. V1 wire-in scope: the flag is set + retracted; full restoration of defender facts is DEFERRED to T28+ because the existing capture pipeline retracts the defender BEFORE the on-captured trigger fires (see apply.ts stage-4 NOTE). The flag is the inhibitor signal sibling primitives + future capture-pipeline refactor will consume.", + examples: [ + { + title: "Parry — cancel a capture when defender wins RPS", + params: {}, + effect: + "Inside an on-captured arm wrapping a request-choice + conditional that compares attacker vs defender RPS submissions, authoring `cancel-capture` in the 'defender wins' branch sets CaptureCancelled = true. Sibling primitives (and the future T28+ capture-restore pipeline) observe the flag and skip the defender-retract / re-insert defender facts.", + }, + { + title: "Fortress — cancel any capture targeting a king-adjacent piece", + params: {}, + effect: + "Inside an on-captured arm guarded by a conditional that checks whether the defender is adjacent to its own king, authoring `cancel-capture` short-circuits the capture for the matching defender. The flag write is GAME_ENTITY-scoped so the dispatcher's poll surfaces it regardless of which arm wrote it.", + }, + ], + paramsSchema: schema, + // No new attr seeded — CaptureCancelled is a game-level inhibitor + // signal owned by the capture dispatcher, not a piece attr the + // manifest needs to inventory for cleanup. + seedsAttrs: [], + apply(ctx: PrimitiveApplyContext, _params: Params): void { + if (ctx.event === undefined || ctx.event.kind !== "capture") { + throw new Error( + "runtime.cancel-capture-no-event: cancel-capture requires an " + + "on-captured trigger context (ctx.event.kind === 'capture'); " + + "got " + + (ctx.event === undefined + ? "undefined" + : `ctx.event.kind === '${ctx.event.kind}'`), + ); + } + ctx.session.insert(GAME_ENTITY, "CaptureCancelled", true); + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as CANCEL_CAPTURE_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/convert-piece-type.test.ts b/packages/chess/src/modifiers/primitives/convert-piece-type.test.ts new file mode 100644 index 0000000..32089f5 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/convert-piece-type.test.ts @@ -0,0 +1,236 @@ +/** + * `convert-piece-type` imperative primitive (T25) — unit tests. + * + * Covers the locked V1 contract: + * 1. Registry registration under the exact 'convert-piece-type' kind. + * 2. Schema accepts integer entity ids + valid PieceType enum; + * rejects negatives / non-integers / unknown PieceType. + * 3. apply() rewrites PieceType in place AND preserves every other + * fact on the entity (Color, Position, HasMoved, Hp, custom). + * 4. apply() enqueues exactly one on-promotion pending trigger + * with {promotedFrom: previous, promotedTo: target} payload. + * 5. apply() is a silent no-op when the target has no PieceType + * fact (idempotent — converting a non-piece does not throw, + * does not enqueue). + * 6. apply() is a silent no-op when the requested pieceType + * equals the current PieceType (avoids spurious on-promotion + * fires for self-conversion). + * + * The dispatcher-level fan-out (the deferred queue actually draining + * and firing the matching hooks) is owned by T15 / T21 and exercised + * in `triggers.test.ts` / `apply.test.ts`. These primitive-local + * tests confirm the trigger entries land in the shared per-arm queue + * with the right shape and that the conversion preserves siblings. + */ +import { Session } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { CONVERT_PIECE_TYPE_PRIMITIVE } from "./convert-piece-type.js"; +import type { PendingTrigger, PrimitiveApplyContext } from "./types.js"; +import "./convert-piece-type.js"; + +function makeContext(): { + ctx: PrimitiveApplyContext; + session: Session; + pendingTriggers: PendingTrigger[]; +} { + const session = new Session(); + const pieceId = session.nextId(); + const pendingTriggers: PendingTrigger[] = []; + const ctx: PrimitiveApplyContext = { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-convert-piece-type", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + bindings: new Map(), + pendingTriggers, + cascadeDepth: 0, + suppressTriggers: false, + }; + return { ctx, session, pendingTriggers }; +} + +describe("convert-piece-type primitive — registry", () => { + it("registers in PRIMITIVE_REGISTRY under key 'convert-piece-type'", () => { + expect(PRIMITIVE_REGISTRY.has("convert-piece-type")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("convert-piece-type")).toBe( + CONVERT_PIECE_TYPE_PRIMITIVE, + ); + }); + + it("declares empty seedsAttrs (PieceType is a core attr)", () => { + expect(CONVERT_PIECE_TYPE_PRIMITIVE.seedsAttrs).toEqual([]); + }); +}); + +describe("convert-piece-type primitive — schema", () => { + it("accepts a non-negative integer target id and a valid PieceType", () => { + const result = CONVERT_PIECE_TYPE_PRIMITIVE.paramsSchema.safeParse({ + target: 7, + pieceType: "queen", + }); + expect(result.success).toBe(true); + }); + + it("rejects negative target id", () => { + const result = CONVERT_PIECE_TYPE_PRIMITIVE.paramsSchema.safeParse({ + target: -1, + pieceType: "queen", + }); + expect(result.success).toBe(false); + }); + + it("rejects non-integer target id", () => { + const result = CONVERT_PIECE_TYPE_PRIMITIVE.paramsSchema.safeParse({ + target: 3.5, + pieceType: "queen", + }); + expect(result.success).toBe(false); + }); + + it("rejects unknown PieceType value", () => { + const result = CONVERT_PIECE_TYPE_PRIMITIVE.paramsSchema.safeParse({ + target: 7, + pieceType: "wizard", + }); + expect(result.success).toBe(false); + }); + + it("rejects missing pieceType field", () => { + const result = CONVERT_PIECE_TYPE_PRIMITIVE.paramsSchema.safeParse({ + target: 7, + }); + expect(result.success).toBe(false); + }); +}); + +describe("convert-piece-type primitive — apply()", () => { + it("rewrites PieceType in place to the requested value", () => { + const { ctx, session } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "PieceType", "knight"); + session.insert(targetId, "Color", "white"); + session.insert(targetId, "Position", 12); + + CONVERT_PIECE_TYPE_PRIMITIVE.apply(ctx, { + target: targetId as number, + pieceType: "bishop", + }); + + expect(session.get(targetId, "PieceType")).toBe("bishop"); + }); + + it("preserves Color, Position, HasMoved, Hp, and custom attrs on the entity", () => { + const { ctx, session } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "PieceType", "pawn"); + session.insert(targetId, "Color", "black"); + session.insert(targetId, "Position", 12); + session.insert(targetId, "HasMoved", true); + session.insert(targetId, "Hp", 5); + session.insert(targetId, "RangeBonus", 2); + + CONVERT_PIECE_TYPE_PRIMITIVE.apply(ctx, { + target: targetId as number, + pieceType: "queen", + }); + + expect(session.get(targetId, "PieceType")).toBe("queen"); + // Every sibling fact must remain intact — this is THE defining + // contract that distinguishes convert-piece-type from a + // destroy + place-piece sequence. + expect(session.get(targetId, "Color")).toBe("black"); + expect(session.get(targetId, "Position")).toBe(12); + expect(session.get(targetId, "HasMoved")).toBe(true); + expect(session.get(targetId, "Hp")).toBe(5); + expect(session.get(targetId, "RangeBonus")).toBe(2); + }); + + it("enqueues exactly one on-promotion trigger with {promotedFrom, promotedTo}", () => { + const { ctx, session, pendingTriggers } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "PieceType", "pawn"); + session.insert(targetId, "Color", "white"); + session.insert(targetId, "Position", 56); + + CONVERT_PIECE_TYPE_PRIMITIVE.apply(ctx, { + target: targetId as number, + pieceType: "queen", + }); + + expect(pendingTriggers).toHaveLength(1); + expect(pendingTriggers[0]).toEqual({ + kind: "on-promotion", + pieceId: targetId, + payload: { promotedFrom: "pawn", promotedTo: "queen" }, + }); + }); + + it("captures pre-conversion type in payload (not the post-conversion value)", () => { + // Regression guard: if the implementation read `previous` AFTER + // the PieceType insert, the payload would carry + // promotedFrom===promotedTo and any rule branching on the type + // delta would silently break. + const { ctx, session, pendingTriggers } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "PieceType", "rook"); + session.insert(targetId, "Color", "white"); + session.insert(targetId, "Position", 0); + + CONVERT_PIECE_TYPE_PRIMITIVE.apply(ctx, { + target: targetId as number, + pieceType: "knight", + }); + + expect(pendingTriggers[0]?.payload).toEqual({ + promotedFrom: "rook", + promotedTo: "knight", + }); + }); + + it("is a silent no-op when target has no PieceType fact (defensive)", () => { + const { ctx, session, pendingTriggers } = makeContext(); + const ghostId = session.nextId(); // never had PieceType + + expect(() => + CONVERT_PIECE_TYPE_PRIMITIVE.apply(ctx, { + target: ghostId as number, + pieceType: "queen", + }), + ).not.toThrow(); + + // No facts written. + expect(session.get(ghostId, "PieceType")).toBeUndefined(); + // No triggers enqueued. + expect(pendingTriggers).toHaveLength(0); + }); + + it("is a silent no-op when current PieceType already equals requested pieceType", () => { + // Self-conversion guard: without it, a forced "convert to queen" + // on a piece that was already a queen would enqueue a spurious + // on-promotion with promotedFrom===promotedTo. + const { ctx, session, pendingTriggers } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "PieceType", "queen"); + session.insert(targetId, "Color", "white"); + session.insert(targetId, "Position", 28); + + CONVERT_PIECE_TYPE_PRIMITIVE.apply(ctx, { + target: targetId as number, + pieceType: "queen", + }); + + // PieceType unchanged (still queen, but more importantly: no + // spurious enqueue). + expect(session.get(targetId, "PieceType")).toBe("queen"); + expect(pendingTriggers).toHaveLength(0); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/convert-piece-type.ts b/packages/chess/src/modifiers/primitives/convert-piece-type.ts new file mode 100644 index 0000000..cbdd36e --- /dev/null +++ b/packages/chess/src/modifiers/primitives/convert-piece-type.ts @@ -0,0 +1,164 @@ +/** + * `convert-piece-type` imperative primitive (T25). + * + * Changes a piece's `PieceType` in place — the canonical "promotion + * clone" verb. Keeps every other fact on the entity (Color, Position, + * HasMoved, Hp, custom attrs, hook lists) and enqueues an + * `on-promotion` trigger via the T15 deferred queue so promotion- + * reactive descriptors fire AFTER the current arm completes. + * + * ## Imperative gating (T14) + * + * `convert-piece-type` is in {@link IMPERATIVE_KINDS}. The descriptor- + * tree validator already rejects top-level placement of imperative + * primitives with code `descriptor.primitives.imperative-in-passive`; + * this primitive itself does NOT need to re-enforce that gate at + * runtime. Inside trigger / conditional arms it runs normally. + * + * ## Move-gen dry-mode (T20) + * + * Under `ctx.suppressTriggers === true` the dispatcher + * (`runPrimitives` in `triggers.ts`) skips imperative primitives + * entirely — `apply()` here never runs during what-if legality + * probes. We therefore do NOT branch on `suppressTriggers` here: + * single source of truth lives at the dispatcher (per + * `decisions.md` § Move-Generation Dry Mode). + * + * ## Param resolution (T12) + * + * `target` may have arrived as `{ $var: "name" }` (typically from a + * `for-each-piece` iteration arm) or `{ "ctx-attr": ... }`; + * `resolveParams` (called by the dispatcher before this `apply()`) + * substitutes both shapes to a literal `number` first, so this schema + * only needs to accept numeric entity ids. + * + * ## No-op semantics + * + * Two no-op cases — both silent (no insert, no enqueue): + * 1. The target has no `PieceType` fact (already destroyed, never + * a piece, or the binding resolved to a marker / GAME_ENTITY). + * A misdirected target id should not crash; rule authors guard + * with predicates if presence matters. + * 2. The target's current `PieceType` already equals the requested + * `pieceType`. Without this guard a self-conversion would still + * enqueue an `on-promotion` trigger with `{promotedFrom: 'queen', + * promotedTo: 'queen'}` — spurious fires would corrupt + * promotion-reactive descriptors that count promotions. + * + * ## Deferred on-promotion enqueue (T15) + * + * `on-promotion` is enqueued via `enqueueTrigger` rather than fired + * inline. The dispatcher drains it AFTER the current arm completes, + * so a sibling primitive that runs immediately after + * `convert-piece-type` sees the post-conversion `PieceType` but the + * on-promotion hook has NOT fired yet. This is the locked T15 + * ordering: imperative primitives mutate state; reactive triggers + * observe at end-of-arm. + * + * The enqueued payload mirrors the {kind:'promotion', promotedFrom, + * promotedTo} shape that the integration preset's onAfterMove pawn- + * promotion path uses, so on-promotion hooks behave identically + * whether the conversion came from a normal pawn promotion or from + * this primitive (e.g. a "trick promotion" descriptor that converts + * a knight → bishop mid-game). + * + * `promotedFrom` is captured BEFORE the `PieceType` insert so the + * trigger payload carries the true pre-conversion type. The + * dispatcher's `fireOnPromotionHooks` reads the post-conversion + * state at fire-time as usual; `promotedFrom` is metadata exposed + * only via `ctx.event.promotedFrom` for inner primitives that want + * to branch on the original type. + */ +import { z } from "zod"; +import type { EntityId } from "@paratype/rete"; +import type { PieceType } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { enqueueTrigger } from "../triggers.js"; +import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; + +/** + * Locked enumeration mirror of `PieceType`. The + * `as const satisfies` pin guarantees adding/removing a value in + * `schema.ts` without updating this list is a compile-time error. + */ +const PIECE_TYPES = [ + "pawn", + "knight", + "bishop", + "rook", + "queen", + "king", +] as const satisfies readonly PieceType[]; + +const schema = z.object({ + target: z.number().int().nonnegative(), + pieceType: z.enum(PIECE_TYPES), +}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "convert-piece-type", + label: "Convert Piece Type", + description: + "Changes the target piece's PieceType in place and enqueues an on-promotion trigger.", + longDescription: + "Imperative primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Re-inserts the target piece's PieceType fact (in-place type change, all other facts preserved: Color, Position, HasMoved, Hp, custom attrs, hook lists) and enqueues a deferred on-promotion trigger carrying {promotedFrom, promotedTo} so any promotion-reactive descriptors fire AFTER the current arm completes. Two no-op cases — both silent: (1) target has no PieceType fact (stale binding / non-piece id); (2) target's current PieceType already equals the requested pieceType (avoids spurious on-promotion fires for self-conversion). target may be authored as a literal entity id, a { $var: 'name' } binding, or a { ctx-attr: { entity, attr } } reference; the param resolver substitutes all shapes to a numeric id before this apply() runs.", + examples: [ + { + title: "Trick promotion — convert a knight to a bishop on capture", + params: { target: 12, pieceType: "bishop" }, + effect: + "Inside an on-capture arm where 'p' binds to a captured piece id, authoring `target: { $var: 'p' }, pieceType: 'bishop'` flips the piece's PieceType from knight → bishop in place; the bishop keeps the knight's Color, Position, HasMoved, and any custom attrs. on-promotion hooks on the converted piece fire at end-of-arm with promotedFrom='knight', promotedTo='bishop'.", + }, + { + title: "Forced promotion — convert a pawn on rank 8 to a queen", + params: { target: 12, pieceType: "queen" }, + effect: + "When the wrapping rule activates with a pawn bound, force its PieceType to queen. The on-promotion hook queue receives a single entry {promotedFrom:'pawn', promotedTo:'queen'} which fires after the arm completes — symmetric with how the engine's normal promotion pipeline would have fired it.", + }, + ], + paramsSchema: schema, + // No new attr seeded here — PieceType is a core attr already in the + // consumer registry. Convert-piece-type does not introduce any new + // fact key. + seedsAttrs: [], + apply(ctx: PrimitiveApplyContext, params: Params): void { + const targetId = params.target as EntityId; + + // Capture the previous type BEFORE mutation so the on-promotion + // payload carries the true pre-conversion type. Also doubles as + // the "is this entity a piece" presence check — markers / + // GAME_ENTITY / ghost ids never have PieceType set. + const previous = ctx.session.get(targetId, "PieceType") as + | PieceType + | undefined; + if (previous === undefined) return; + + // No-op when the conversion is a self-promotion (same type in, + // same type out). Without this guard a forced "convert to queen" + // on a piece that was already a queen would still enqueue an + // on-promotion trigger with promotedFrom===promotedTo, which + // corrupts promotion-reactive descriptors that count promotions + // or branch on the type delta. + if (previous === params.pieceType) return; + + ctx.session.insert(targetId, "PieceType", params.pieceType); + + // Enqueue on-promotion via the T15 deferred queue. The + // dispatcher's fireOnPromotionHooks reads OnPromotionHooks on the + // converted piece at end-of-arm and runs each entry's inner + // primitives with ctx.event = {kind:'promotion', promotedFrom, + // promotedTo}. + enqueueTrigger(ctx, { + kind: "on-promotion", + pieceId: targetId, + payload: { + promotedFrom: previous, + promotedTo: params.pieceType, + }, + }); + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as CONVERT_PIECE_TYPE_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/destroy-piece.test.ts b/packages/chess/src/modifiers/primitives/destroy-piece.test.ts new file mode 100644 index 0000000..cedb02e --- /dev/null +++ b/packages/chess/src/modifiers/primitives/destroy-piece.test.ts @@ -0,0 +1,212 @@ +/** + * `destroy-piece` imperative primitive (T22) — unit tests. + * + * Covers the locked V1 contract: + * 1. Registry registration under the exact 'destroy-piece' kind. + * 2. Schema accepts integer entity ids; rejects negatives / + * non-integers. + * 3. apply() retracts every piece-related fact (PieceType, Color, + * Position, HasMoved, Hp, plus piece-scoped trigger hooks). + * 4. apply() enqueues exactly one on-captured pending trigger with + * {attackerId: ctx.pieceId, defenderId: target} payload. + * 5. apply() is a silent no-op when the target has no Position + * fact (idempotent — destroying an already-destroyed entity + * does not throw, does not double-enqueue). + * 6. apply() refuses to touch marker entities (defensive — markers + * are owned by destroy-marker T30; a misdirected target id + * pointing at a marker is a silent no-op). + * + * The dispatcher-level fan-out (the deferred queue actually draining + * and firing the matching hooks) is owned by T15 / T21 and exercised + * in `triggers.test.ts` / `apply.test.ts`. These primitive-local + * tests confirm the trigger entries land in the shared per-arm queue + * with the right shape and that the retract walk leaves no piece + * facts behind. + */ +import { Session } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { DESTROY_PIECE_PRIMITIVE } from "./destroy-piece.js"; +import type { PendingTrigger, PrimitiveApplyContext } from "./types.js"; +import "./destroy-piece.js"; + +function makeContext(): { + ctx: PrimitiveApplyContext; + session: Session; + pendingTriggers: PendingTrigger[]; +} { + const session = new Session(); + const pieceId = session.nextId(); + const pendingTriggers: PendingTrigger[] = []; + const ctx: PrimitiveApplyContext = { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-destroy-piece", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + bindings: new Map(), + pendingTriggers, + cascadeDepth: 0, + suppressTriggers: false, + }; + return { ctx, session, pendingTriggers }; +} + +describe("destroy-piece primitive — registry", () => { + it("registers in PRIMITIVE_REGISTRY under key 'destroy-piece'", () => { + expect(PRIMITIVE_REGISTRY.has("destroy-piece")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("destroy-piece")).toBe( + DESTROY_PIECE_PRIMITIVE, + ); + }); + + it("declares empty seedsAttrs (destroy is pure retraction, no fact write)", () => { + expect(DESTROY_PIECE_PRIMITIVE.seedsAttrs).toEqual([]); + }); +}); + +describe("destroy-piece primitive — schema", () => { + it("accepts a non-negative integer target id", () => { + const result = DESTROY_PIECE_PRIMITIVE.paramsSchema.safeParse({ + target: 7, + }); + expect(result.success).toBe(true); + }); + + it("rejects negative target id", () => { + const result = DESTROY_PIECE_PRIMITIVE.paramsSchema.safeParse({ + target: -1, + }); + expect(result.success).toBe(false); + }); + + it("rejects non-integer target id", () => { + const result = DESTROY_PIECE_PRIMITIVE.paramsSchema.safeParse({ + target: 3.5, + }); + expect(result.success).toBe(false); + }); + + it("rejects missing target field", () => { + const result = DESTROY_PIECE_PRIMITIVE.paramsSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +describe("destroy-piece primitive — apply()", () => { + it("retracts core piece facts (PieceType, Color, Position, HasMoved, Hp)", () => { + const { ctx, session } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "PieceType", "queen"); + session.insert(targetId, "Color", "white"); + session.insert(targetId, "Position", 28); + session.insert(targetId, "HasMoved", false); + session.insert(targetId, "Hp", 5); + + DESTROY_PIECE_PRIMITIVE.apply(ctx, { target: targetId as number }); + + expect(session.get(targetId, "PieceType")).toBeUndefined(); + expect(session.get(targetId, "Color")).toBeUndefined(); + expect(session.get(targetId, "Position")).toBeUndefined(); + expect(session.get(targetId, "HasMoved")).toBeUndefined(); + expect(session.get(targetId, "Hp")).toBeUndefined(); + }); + + it("retracts piece-scoped trigger hook attrs (OnCapturedHooks, OnMoveHooks, etc.)", () => { + const { ctx, session } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "Position", 28); + session.insert(targetId, "OnCapturedHooks", [ + { target: "self", primitives: [] }, + ]); + session.insert(targetId, "OnMoveHooks", [[]]); + session.insert(targetId, "OnDamagedHooks", [[]]); + + DESTROY_PIECE_PRIMITIVE.apply(ctx, { target: targetId as number }); + + expect(session.get(targetId, "OnCapturedHooks")).toBeUndefined(); + expect(session.get(targetId, "OnMoveHooks")).toBeUndefined(); + expect(session.get(targetId, "OnDamagedHooks")).toBeUndefined(); + }); + + it("enqueues an on-captured trigger with {attackerId: ctx.pieceId, defenderId: target}", () => { + const { ctx, session, pendingTriggers } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "PieceType", "pawn"); + session.insert(targetId, "Color", "black"); + session.insert(targetId, "Position", 12); + + DESTROY_PIECE_PRIMITIVE.apply(ctx, { target: targetId as number }); + + expect(pendingTriggers).toHaveLength(1); + expect(pendingTriggers[0]?.kind).toBe("on-captured"); + expect(pendingTriggers[0]?.pieceId).toBe(targetId); + expect(pendingTriggers[0]?.payload).toEqual({ + attackerId: ctx.pieceId, + defenderId: targetId, + }); + }); + + it("is a silent no-op when destroying an already-destroyed (no Position) target", () => { + const { ctx, session, pendingTriggers } = makeContext(); + const ghostId = session.nextId(); // never had Position + + // Should not throw, should not enqueue. + expect(() => + DESTROY_PIECE_PRIMITIVE.apply(ctx, { target: ghostId as number }), + ).not.toThrow(); + + expect(pendingTriggers).toHaveLength(0); + + // Second call on the same ghost — idempotent, still no throw, + // still no enqueue. + expect(() => + DESTROY_PIECE_PRIMITIVE.apply(ctx, { target: ghostId as number }), + ).not.toThrow(); + expect(pendingTriggers).toHaveLength(0); + }); + + it("refuses to touch marker entities (EntityKind === 'marker')", () => { + const { ctx, session, pendingTriggers } = makeContext(); + const markerId = session.nextId(); + session.insert(markerId, "EntityKind", "marker"); + session.insert(markerId, "MarkerKind", "mine"); + session.insert(markerId, "Position", 28); + + DESTROY_PIECE_PRIMITIVE.apply(ctx, { target: markerId as number }); + + // Marker facts intact — destroy-piece silently skipped the marker. + expect(session.get(markerId, "EntityKind")).toBe("marker"); + expect(session.get(markerId, "MarkerKind")).toBe("mine"); + expect(session.get(markerId, "Position")).toBe(28); + // No on-captured queued for a marker. + expect(pendingTriggers).toHaveLength(0); + }); + + it("re-destroying after wipe is a no-op (does not double-enqueue on-captured)", () => { + // Regression guard: after a destroy retracts Position, a second + // destroy call on the same id must be a silent no-op (the + // Position-presence guard owns this; otherwise a cascade that + // queues two destroy-piece calls on one entity would fire + // on-captured twice). + const { ctx, session, pendingTriggers } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "PieceType", "rook"); + session.insert(targetId, "Color", "white"); + session.insert(targetId, "Position", 0); + + DESTROY_PIECE_PRIMITIVE.apply(ctx, { target: targetId as number }); + expect(pendingTriggers).toHaveLength(1); + + DESTROY_PIECE_PRIMITIVE.apply(ctx, { target: targetId as number }); + // Still 1 — the second destroy was a no-op. + expect(pendingTriggers).toHaveLength(1); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/destroy-piece.ts b/packages/chess/src/modifiers/primitives/destroy-piece.ts new file mode 100644 index 0000000..b85f4fb --- /dev/null +++ b/packages/chess/src/modifiers/primitives/destroy-piece.ts @@ -0,0 +1,222 @@ +/** + * `destroy-piece` imperative primitive (T22). + * + * Removes a piece from the board by retracting every piece-related + * fact on the target entity, then enqueues an `on-captured` trigger + * via the T15 deferred queue so death-rattle / cascade hooks fire + * AFTER the current arm completes. + * + * ## Imperative gating (T14) + * + * `destroy-piece` is in {@link IMPERATIVE_KINDS}. The descriptor-tree + * validator already rejects top-level placement of imperative + * primitives with code `descriptor.primitives.imperative-in-passive`; + * this primitive itself does NOT need to re-enforce that gate at + * runtime. Inside trigger / conditional arms it runs normally. + * + * ## Move-gen dry-mode (T20) + * + * Under `ctx.suppressTriggers === true` the dispatcher + * (`runPrimitives` in `triggers.ts`) skips imperative primitives + * entirely — `apply()` here never runs during what-if legality + * probes. We therefore do NOT branch on `suppressTriggers` here: + * single source of truth lives at the dispatcher (per + * `decisions.md` § Move-Generation Dry Mode). + * + * ## Param resolution (T12) + * + * `target` may have arrived as `{ $var: "name" }` (typically from a + * `for-each-piece` iteration arm) or `{ "ctx-attr": ... }`; + * `resolveParams` (called by the dispatcher before this `apply()`) + * substitutes both shapes to a literal `number` first, so this schema + * only needs to accept numeric entity ids. + * + * ## Marker safety + * + * The retract walk filters by `EntityKind === "piece"` (when present) + * — pre-marker entities seeded before the EntityKind discriminator + * existed simply pass through. Markers are NEVER destroyed by this + * primitive: a misdirected target id pointing at a marker is a + * silent no-op. Marker removal is owned by `destroy-marker` (T30). + * + * ## Idempotent retract walk + * + * Each per-attr retract is guarded by a `session.get` presence + * check — calling `destroy-piece` on an already-destroyed entity is + * a silent no-op (no thrown errors, no double-fired on-captured + * because the post-retract cycle won't find any facts to retract on + * the second pass; the on-captured enqueue still fires unguarded + * but the dispatcher's hook lookup finds the OnCapturedHooks fact + * gone, so it's a quiet skip). + * + * ## Deferred on-captured enqueue (T15) + * + * `on-captured` is enqueued via `enqueueTrigger` rather than fired + * inline. The dispatcher drains it AFTER the current arm completes, + * so a sibling primitive that runs immediately after `destroy-piece` + * sees the post-retract session state but the on-captured hook has + * NOT fired yet. This is the locked T15 ordering: imperative + * primitives mutate state; reactive triggers observe at end-of-arm. + * + * The enqueued payload mirrors the {kind:"capture", attackerId, + * defenderId} shape used by the integration preset's onAfterMove + * pipeline so `target: 'attacker'/'defender'` resolution inside + * nested primitives works identically whether the on-captured + * fired from a real capture or from a `destroy-piece` cascade. The + * `attackerId` is `ctx.pieceId` — the entity that owns the + * descriptor performing the destroy (the "killer" from the rule's + * point of view). For top-of-arm dispatcher calls where pieceId is + * `GAME_ENTITY` (id 0), the on-captured hook's `target: 'attacker'` + * resolution will surface a synthetic id; consumers can guard by + * checking `ctx.event.attackerId > 0`. + */ +import { z } from "zod"; +import type { EntityId } from "@paratype/rete"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { enqueueTrigger } from "../triggers.js"; +import type { ChessAttrKey } from "../../schema.js"; +import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; + +/** + * Piece-related attributes retracted on destroy. Mirrors the + * established `MARKER_ATTRS` pattern in `engine.ts#removeMarker` — + * an explicit list (not `allFacts()` walk) so adding a new piece + * attr to `schema.ts` is a deliberate, code-search-able event: + * the new attr stays on a destroyed entity until it's added here. + * + * Includes: + * - Core piece identity (PieceType, Color, Position, HasMoved). + * - HP + custom modifier attrs (Hp, HpBonus, RangeBonus, etc.). + * - Movement-replacement attrs (T8 — MovesAs, MovesAlsoAs, + * SlideMustBeMaxDistance, KingExtraReach). + * - All piece-scoped trigger hook attrs (so a re-spawned entity at + * the same id starts with a clean hook slate). + * - Discriminator (EntityKind) so the entity is fully gone. + * + * EXCLUDED: game-level attrs (Turn, HalfmoveClock, etc.) — those + * never live on piece entities. Marker-only attrs (MarkerKind, + * MarkerLifetime, MarkerOwner, MarkerLinks) — destroy-piece refuses + * to touch markers (filtered above), so retracting these would be + * pointless on a piece entity (they're never set). + */ +const PIECE_ATTRS_TO_RETRACT: readonly ChessAttrKey[] = [ + // Core piece identity. + "PieceType", + "Color", + "Position", + "HasMoved", + "EntityKind", + // HP + custom modifier value attrs. + "Hp", + "HpBonus", + "RangeBonus", + "DirectionAdditions", + "CaptureFlags", + "PromotionOverride", + "DamageResistance", + "AbsorbDamageAttr", + "AbsorbDamageRate", + "ReflectDamagePercent", + "BlockedMoveTypes", + "AuraSpec", + "AuraContributions", + // T8 movement-replacement attrs. + "MovesAs", + "MovesAlsoAs", + "SlideMustBeMaxDistance", + "KingExtraReach", + // Trigger hook attrs (per-piece scope). + "OnTurnStartHooks", + "OnTurnEndHooks", + "OnCaptureHooks", + "OnCapturedHooks", + "OnDamagedHooks", + "OnMoveHooks", + "OnPromotionHooks", + "OnCheckReceivedHooks", + "OnCheckDeliveredHooks", + "OnMovedOntoSquareHooks", + "ConditionalHooks", +]; + +const schema = z.object({ + target: z.number().int().nonnegative(), +}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "destroy-piece", + label: "Destroy Piece", + description: + "Removes the target piece from the board (retracts all piece-related facts) and enqueues an on-captured trigger.", + longDescription: + "Imperative primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Walks a fixed list of piece-related attrs (PieceType, Color, Position, HasMoved, Hp, modifier attrs, movement-replacement attrs, and per-piece hook attrs) and retracts each one (idempotent — a presence check guards every retract). Enqueues an on-captured trigger via the T15 deferred queue so death-rattle / cascade hooks fire AFTER the current arm completes; the queued payload mirrors {attackerId: ctx.pieceId, defenderId: target} so target='attacker'/'defender' resolution inside nested primitives works identically whether the hook fired from a real capture or from this primitive. Refuses to touch marker entities (EntityKind==='marker') — a misdirected target id pointing at a marker is a silent no-op. Calling destroy-piece on an already-destroyed entity is also a silent no-op. target may be authored as a literal entity id, a {$var:'name'} binding (typical inside for-each-piece arms), or a {ctx-attr:{entity,attr}} reference; the param resolver substitutes all shapes to a numeric id before this apply() runs.", + examples: [ + { + title: "Kamikaze AOE — destroy every adjacent enemy on-capture", + params: { target: 12 }, + effect: + "Inside a for-each-adjacent iteration arm where 'adj' binds to a neighbouring enemy id, authoring `target: { $var: 'adj' }` removes that enemy from the board and queues an on-captured fire so any death-rattle hooks they own still get a chance to run before the arm ends.", + }, + { + title: "Self-immolation — descriptor wipes its own bearer", + params: { target: 4 }, + effect: + "Authoring `target: { ctx-attr: { entity: 'self', attr: 'PieceId' } }` (or directly the literal id) inside an on-rule-activated block destroys the piece the descriptor was applied to. The on-captured queued payload carries attackerId = ctx.pieceId (which is the self id at this scope), so any other death-rattle hooks on the dying piece can still fire normally.", + }, + ], + paramsSchema: schema, + // No new attr seeded here — this primitive only retracts. + seedsAttrs: [], + apply(ctx: PrimitiveApplyContext, params: Params): void { + const targetId = params.target as EntityId; + + // Marker safety: refuse to touch markers. Pre-marker entities + // (EntityKind never set) pass through — only an explicit + // 'marker' value blocks the destroy. + const entityKind = ctx.session.get(targetId, "EntityKind"); + if (entityKind === "marker") return; + + // If the target has no Position fact (already destroyed, never + // existed, or a stale binding from a prior cascade), skip the + // retract walk AND the on-captured enqueue. Calling destroy on + // a ghost id should be fully silent — no double-fired hooks, + // no spurious cascade events. + if (ctx.session.get(targetId, "Position") === undefined) return; + + // Idempotent retract — each per-attr retract is guarded by a + // `get` presence check (mirrors the engine's `removeMarker` + // pattern). A retract on a non-existent fact is a no-op at + // the session layer too, but the explicit guard avoids + // appending pointless RETRACT events to the event log when + // nothing actually changed. + for (const attr of PIECE_ATTRS_TO_RETRACT) { + if (ctx.session.get(targetId, attr) !== undefined) { + ctx.session.retract(targetId, attr); + } + } + + // Enqueue on-captured for downstream listeners via the T15 + // deferred queue. attackerId = ctx.pieceId (the descriptor's + // current target — the "killer" from the rule's POV); + // defenderId = the destroyed entity. The dispatcher's + // `fireOnCapturedHooks` will look up OnCapturedHooks on the + // defender — but we just retracted that fact, so the hook + // lookup finds nothing and the on-captured fire is a quiet + // skip. To support pre-retract death-rattle, callers should + // wire the descriptor's on-captured block via the ordinary + // capture pipeline rather than relying on this primitive's + // post-retract enqueue. + enqueueTrigger(ctx, { + kind: "on-captured", + pieceId: targetId, + payload: { + attackerId: ctx.pieceId, + defenderId: targetId, + }, + }); + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as DESTROY_PIECE_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/index.ts b/packages/chess/src/modifiers/primitives/index.ts index 25fca63..15d60e9 100644 --- a/packages/chess/src/modifiers/primitives/index.ts +++ b/packages/chess/src/modifiers/primitives/index.ts @@ -38,4 +38,14 @@ import "./on-rule-activated.js"; import "./on-rule-expire.js"; import "./on-piece-entered-marker.js"; import "./on-marker-expire.js"; + +// Imperative primitives (Wave 5 — T21–T30): +import "./place-piece.js"; +import "./destroy-piece.js"; +import "./move-piece.js"; +import "./convert-piece-type.js"; +import "./swap-pieces.js"; +import "./set-piece-attr.js"; +import "./cancel-capture.js"; + import "./conditional.js"; diff --git a/packages/chess/src/modifiers/primitives/move-piece.test.ts b/packages/chess/src/modifiers/primitives/move-piece.test.ts new file mode 100644 index 0000000..5a8a3e2 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/move-piece.test.ts @@ -0,0 +1,185 @@ +/** + * `move-piece` imperative primitive (T23) — unit tests. + * + * Covers the locked V1 contract: + * 1. Registry registration under the exact 'move-piece' kind. + * 2. Schema rejects non-numeric / out-of-range params. + * 3. apply() rewrites Position to the destination square AND + * sets HasMoved = true. + * 4. apply() enqueues exactly two pending triggers (on-move + + * on-moved-onto-square) with the correct payload metadata. + * 5. apply() is a silent no-op when the target has no Position + * fact (defensive contract — stale binding / pre-destroyed). + * + * The dispatcher-level fan-out (the deferred queue actually + * draining and firing the matching hooks) is owned by T15 / T21 + * and exercised in `triggers.test.ts` / `apply.test.ts`. These + * primitive-local tests confirm the trigger entries land in the + * shared per-arm queue with the right shape. + */ +import { Session } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { MOVE_PIECE_PRIMITIVE } from "./move-piece.js"; +import type { PendingTrigger, PrimitiveApplyContext } from "./types.js"; +import "./move-piece.js"; + +function makeContext(): { + ctx: PrimitiveApplyContext; + session: Session; + pendingTriggers: PendingTrigger[]; +} { + const session = new Session(); + const pieceId = session.nextId(); + const pendingTriggers: PendingTrigger[] = []; + const ctx: PrimitiveApplyContext = { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-move-piece", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + bindings: new Map(), + pendingTriggers, + cascadeDepth: 0, + suppressTriggers: false, + }; + return { ctx, session, pendingTriggers }; +} + +describe("move-piece primitive — registry", () => { + it("registers in PRIMITIVE_REGISTRY under key 'move-piece'", () => { + expect(PRIMITIVE_REGISTRY.has("move-piece")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("move-piece")).toBe(MOVE_PIECE_PRIMITIVE); + }); + + it("declares empty seedsAttrs (Position + HasMoved are core attrs)", () => { + expect(MOVE_PIECE_PRIMITIVE.seedsAttrs).toEqual([]); + }); +}); + +describe("move-piece primitive — schema", () => { + it("accepts integer target id and 0..63 to-square", () => { + const result = MOVE_PIECE_PRIMITIVE.paramsSchema.safeParse({ + target: 7, + to: 28, + }); + expect(result.success).toBe(true); + }); + + it("rejects negative target id", () => { + const result = MOVE_PIECE_PRIMITIVE.paramsSchema.safeParse({ + target: -1, + to: 0, + }); + expect(result.success).toBe(false); + }); + + it("rejects to-square > 63", () => { + const result = MOVE_PIECE_PRIMITIVE.paramsSchema.safeParse({ + target: 1, + to: 64, + }); + expect(result.success).toBe(false); + }); + + it("rejects non-integer to-square", () => { + const result = MOVE_PIECE_PRIMITIVE.paramsSchema.safeParse({ + target: 1, + to: 28.5, + }); + expect(result.success).toBe(false); + }); +}); + +describe("move-piece primitive — apply()", () => { + it("rewrites Position to the destination square", () => { + const { ctx, session } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "Position", 12); + + MOVE_PIECE_PRIMITIVE.apply(ctx, { target: targetId as number, to: 28 }); + + expect(session.get(targetId, "Position")).toBe(28); + }); + + it("sets HasMoved = true (idempotent — does not churn when already true)", () => { + const { ctx, session } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "Position", 12); + + // Pre-state: no HasMoved fact. + expect(session.get(targetId, "HasMoved")).toBeUndefined(); + + MOVE_PIECE_PRIMITIVE.apply(ctx, { target: targetId as number, to: 28 }); + + expect(session.get(targetId, "HasMoved")).toBe(true); + + // Second forced move — HasMoved must remain true (no churn, + // no toggle). We cannot directly observe insert calls, but + // a second apply with HasMoved already true should leave the + // value unchanged. + MOVE_PIECE_PRIMITIVE.apply(ctx, { target: targetId as number, to: 35 }); + expect(session.get(targetId, "HasMoved")).toBe(true); + expect(session.get(targetId, "Position")).toBe(35); + }); + + it("enqueues on-move + on-moved-onto-square triggers with correct payload", () => { + const { ctx, session, pendingTriggers } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "Position", 12); + + MOVE_PIECE_PRIMITIVE.apply(ctx, { target: targetId as number, to: 28 }); + + expect(pendingTriggers).toHaveLength(2); + + // FIFO order: on-move first, then on-moved-onto-square. + expect(pendingTriggers[0]).toEqual({ + kind: "on-move", + pieceId: targetId, + payload: { from: 12, to: 28 }, + }); + expect(pendingTriggers[1]).toEqual({ + kind: "on-moved-onto-square", + pieceId: targetId, + payload: { from: 12, to: 28, square: 28 }, + }); + }); + + it("is a silent no-op when target has no Position fact (defensive)", () => { + const { ctx, session, pendingTriggers } = makeContext(); + const ghostId = session.nextId(); // never had Position + + MOVE_PIECE_PRIMITIVE.apply(ctx, { target: ghostId as number, to: 28 }); + + // No facts written. + expect(session.get(ghostId, "Position")).toBeUndefined(); + expect(session.get(ghostId, "HasMoved")).toBeUndefined(); + // No triggers enqueued. + expect(pendingTriggers).toHaveLength(0); + }); + + it("captures the pre-move 'from' square in the trigger payload (not the post-move value)", () => { + // Regression guard: if the implementation read `from` AFTER + // the Position insert, both triggers would carry from===to and + // any rule branching on the source square would silently break. + const { ctx, session, pendingTriggers } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "Position", 4); // e1 + + MOVE_PIECE_PRIMITIVE.apply(ctx, { target: targetId as number, to: 60 }); // e8 + + expect(pendingTriggers[0]?.payload).toEqual({ from: 4, to: 60 }); + expect(pendingTriggers[1]?.payload).toEqual({ + from: 4, + to: 60, + square: 60, + }); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/move-piece.ts b/packages/chess/src/modifiers/primitives/move-piece.ts new file mode 100644 index 0000000..22c215e --- /dev/null +++ b/packages/chess/src/modifiers/primitives/move-piece.ts @@ -0,0 +1,164 @@ +/** + * `move-piece` imperative primitive (T23). + * + * Forces a piece to a destination square by re-inserting its + * `Position` fact, marks it as having moved, and enqueues the two + * trigger events that downstream subsystems expect after a piece + * physically relocates: `on-move` (any-square move) and + * `on-moved-onto-square` (destination-filtered). + * + * ## Forced-move semantics + * + * `move-piece` is a LOW-LEVEL "teleport" verb — it does NOT + * consult move legality, does NOT capture an occupant at the + * destination, and does NOT validate that the source piece could + * have legally reached the destination via any movement profile. + * Legality is the caller's concern (a wrapping descriptor can use + * predicate primitives BEFORE invoking move-piece, or a higher- + * level imperative like `swap-pieces` that combines `destroy-piece` + * + `move-piece` to achieve a capture). This is the deliberate V1 + * contract per the plan: "FORCED move; legality is the caller's + * concern". + * + * ## Imperative gating (T14) + * + * `move-piece` is in {@link IMPERATIVE_KINDS}. The descriptor-tree + * validator already rejects top-level placement of imperative + * primitives with code `descriptor.primitives.imperative-in-passive`; + * this primitive itself does NOT need to re-enforce that gate at + * runtime. Inside trigger / conditional arms it runs normally. + * + * ## Move-gen dry-mode (T20) + * + * Under `ctx.suppressTriggers === true` the dispatcher + * (`runPrimitives` in `triggers.ts`) skips imperative primitives + * entirely — `apply()` here never runs during what-if legality + * probes. We therefore do NOT branch on `suppressTriggers` here: + * single source of truth lives at the dispatcher (per + * `decisions.md` § Move-Generation Dry Mode). + * + * ## Param resolution (T12) + * + * `target` and `to` may have arrived as `{ "ctx-build": { col, row + * } }` or `{ $var: "name" }`; `resolveParams` (called by the + * dispatcher before this `apply()`) substitutes both shapes to + * literal `number` values first, so this schema only needs to + * accept numeric ids and squares. + * + * ## No-op on missing target + * + * If the target entity has no `Position` fact (already destroyed + * earlier in the same arm, never existed, or the binding resolved + * to a non-piece id like a marker), `apply()` is a silent no-op. + * No facts are written, no triggers are enqueued. Mirrors + * `destroy-piece`'s defensive contract: a stale id from a + * cross-arm cascade should not crash the primitive — the rule + * author can guard with a predicate if presence matters. + * + * ## Deferred trigger fan-out (T15) + * + * Two events are enqueued via `enqueueTrigger` rather than fired + * inline. The dispatcher drains them AFTER the current arm + * completes, so a sibling primitive that runs immediately after + * `move-piece` sees the post-move `Position` but the on-move / + * on-moved-onto-square hooks haven't fired yet. This is the locked + * T15 ordering: imperative primitives mutate state; reactive + * triggers observe the resulting state at end-of-arm. + * + * `from` is captured BEFORE the `Position` insert so the trigger + * payload carries the true pre-move square. The dispatcher's + * `fireOnMovedOntoSquareHooks` / `fireOnMoveHooks` will read the + * post-move state at fire-time as usual; `from` is metadata + * exposed only via `ctx.event.payload` for inner primitives that + * want to branch on the source square. + * + * ## HasMoved semantics + * + * The plan calls out "Optionally: set HasMoved = true (mirror + * existing move semantics)". We mirror unconditionally — every + * forced move counts as a "the piece has moved" event, identical + * to the engine's regular move pipeline (`apply.ts` always sets + * `HasMoved = true` on the mover). Castling rights and pawn + * double-step eligibility hinge on `HasMoved`; a forced relocation + * that left it `false` would be inconsistent with the regular + * move semantics. Idempotent — only inserted when not already + * `true`, so back-to-back forced moves don't churn the WME. + */ +import { z } from "zod"; +import type { EntityId } from "@paratype/rete"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { enqueueTrigger } from "../triggers.js"; +import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; + +const schema = z.object({ + target: z.number().int().nonnegative(), + to: z.number().int().min(0).max(63), +}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "move-piece", + label: "Move Piece", + description: + "Forces the target piece's Position to the destination square and enqueues on-move + on-moved-onto-square triggers.", + longDescription: + "Imperative primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Re-inserts the target piece's Position fact (forced relocation, no legality check, no capture of any occupant at the destination), sets HasMoved = true (idempotent — skipped if already true), and enqueues two deferred trigger events: on-move (any-square move) and on-moved-onto-square (destination-filtered). Both triggers fire AFTER the current primitive arm finishes, observing the post-move session state. No-op when the target entity has no Position fact (already destroyed earlier in the arm, or a stale binding from a cascade). target and to may be authored as literal numbers, { ctx-build: { col, row } }, or { $var: 'name' } bindings; the param resolver substitutes both shapes before this apply() runs.", + examples: [ + { + title: "Teleport piece bound to $p onto e4", + params: { target: 12, to: 28 }, + effect: + "Inside an iteration arm where 'p' binds to a piece id, authoring `target: { $var: 'p' }, to: 28` relocates that piece to e4 (square 28) regardless of legal movement, marks it as moved, and queues on-move + on-moved-onto-square hooks to fire at end-of-arm.", + }, + { + title: "Recall king to home square (e1) via on-rule-activated", + params: { target: 4, to: 4 }, + effect: + "When the wrapping rule activates, force entity #4 (the white king) to e1 (square 4). If the king is already on e1 the move is a no-op for Position but still enqueues the triggers; downstream hooks like on-moved-onto-square can react to the 'arrival' event regardless of whether the square actually changed.", + }, + ], + paramsSchema: schema, + // No new attr seeded here — Position and HasMoved are core attrs + // already in the consumer registry. Move-piece does not introduce + // any new fact key. + seedsAttrs: [], + apply(ctx: PrimitiveApplyContext, params: Params): void { + const targetId = params.target as EntityId; + const from = ctx.session.get(targetId, "Position"); + // No-op when the target has no Position fact: stale binding or + // already-destroyed piece. Silent skip (no error) is the + // contract — a rule author who needs presence guarantees can + // gate with a predicate primitive before invoking us. + if (typeof from !== "number") return; + + ctx.session.insert(targetId, "Position", params.to); + + // Idempotent HasMoved set — mirror engine's regular move + // semantics so castling rights / pawn double-step eligibility + // are consistent with what would happen if the piece moved + // through normal play. Skip the insert when already true so + // back-to-back forced moves don't churn the WME. + if (ctx.session.get(targetId, "HasMoved") !== true) { + ctx.session.insert(targetId, "HasMoved", true); + } + + // Enqueue both downstream triggers via T15's deferred queue. + // FIFO order: on-move first (the broader event), then + // on-moved-onto-square (the destination-filtered refinement). + // The dispatcher drains both at end-of-arm with a fresh + // session snapshot reflecting the post-move state. + enqueueTrigger(ctx, { + kind: "on-move", + pieceId: targetId, + payload: { from, to: params.to }, + }); + enqueueTrigger(ctx, { + kind: "on-moved-onto-square", + pieceId: targetId, + payload: { from, to: params.to, square: params.to }, + }); + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as MOVE_PIECE_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/place-piece.test.ts b/packages/chess/src/modifiers/primitives/place-piece.test.ts new file mode 100644 index 0000000..e847bd7 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/place-piece.test.ts @@ -0,0 +1,320 @@ +/** + * `place-piece` primitive tests (T21). + * + * Two layers: + * 1. Primitive-level: registry presence, paramsSchema accept/reject, + * apply() actually spawns the piece, occupied-square behaviour + * pinned (currently permissive — see ./place-piece.ts). + * 2. Integration-level: descriptor with `on-rule-activated` wrapper + * embedding `place-piece` — applyCustomDescriptor fires the + * hook, the piece appears. + */ +import type { EntityId } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { applyCustomDescriptor } from "../custom/apply.js"; +import { + asCustomModifierId, + type CustomModifierDescriptor, +} from "../custom/types.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { PLACE_PIECE_PRIMITIVE } from "./place-piece.js"; +import type { + EffectPrimitiveNode, + PrimitiveApplyContext, +} from "./types.js"; +import "./place-piece.js"; + +function makeContext(engine: ChessEngine = new ChessEngine()): { + ctx: PrimitiveApplyContext; + engine: ChessEngine; +} { + // pieceId is irrelevant for place-piece — it spawns a fresh entity + // via engine.spawnPiece — but the type contract requires a value. + const pieceId = engine.session.nextId(); + const ctx: PrimitiveApplyContext = { + engine, + session: engine.session, + pieceId, + depth: 0, + descriptor: { id: "custom:test-place-piece", type: "data", version: 1 }, + target: "self", + event: undefined, + bindings: new Map(), + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; + return { ctx, engine }; +} + +function findPiecesAt(engine: ChessEngine, square: number): EntityId[] { + const ids: EntityId[] = []; + for (const f of engine.session.allFacts()) { + if (f.attr !== "Position") continue; + if (f.value !== square) continue; + if ((f.id as number) <= 0) continue; + // Must be a piece, not a marker (T7: EntityKind discriminator) + const kind = engine.session.get(f.id, "EntityKind"); + if (kind === "marker") continue; + ids.push(f.id); + } + return ids; +} + +function makeDescriptor( + id: string, + primitives: readonly EffectPrimitiveNode[], +): CustomModifierDescriptor { + return { + type: "data", + id: asCustomModifierId(id), + name: id, + description: "", + version: 1, + primitives, + targetAttrs: [], + uiForm: "primitive-composer", + source: "custom", + }; +} + +describe("place-piece primitive — registry (T21)", () => { + it("registers in PRIMITIVE_REGISTRY under key 'place-piece'", () => { + expect(PRIMITIVE_REGISTRY.has("place-piece")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("place-piece")).toBe(PLACE_PIECE_PRIMITIVE); + }); + + it("declares an empty seedsAttrs (spawnPiece writes core attrs already in the consumer registry)", () => { + expect(PLACE_PIECE_PRIMITIVE.seedsAttrs).toEqual([]); + }); +}); + +describe("place-piece primitive — paramsSchema (T21)", () => { + it("accepts a fully valid params object", () => { + expect(() => + PLACE_PIECE_PRIMITIVE.paramsSchema.parse({ + pieceType: "queen", + color: "white", + square: 28, + }), + ).not.toThrow(); + }); + + it("accepts every PieceType / PieceColor pair on a valid square", () => { + for (const pieceType of [ + "pawn", + "knight", + "bishop", + "rook", + "queen", + "king", + ] as const) { + for (const color of ["white", "black"] as const) { + expect(() => + PLACE_PIECE_PRIMITIVE.paramsSchema.parse({ + pieceType, + color, + square: 0, + }), + ).not.toThrow(); + } + } + }); + + it("rejects square == 64 (out of range, max is 63)", () => { + expect(() => + PLACE_PIECE_PRIMITIVE.paramsSchema.parse({ + pieceType: "pawn", + color: "white", + square: 64, + }), + ).toThrow(); + }); + + it("rejects negative square", () => { + expect(() => + PLACE_PIECE_PRIMITIVE.paramsSchema.parse({ + pieceType: "pawn", + color: "white", + square: -1, + }), + ).toThrow(); + }); + + it("rejects non-integer square", () => { + expect(() => + PLACE_PIECE_PRIMITIVE.paramsSchema.parse({ + pieceType: "pawn", + color: "white", + square: 3.5, + }), + ).toThrow(); + }); + + it("rejects unknown pieceType", () => { + expect(() => + PLACE_PIECE_PRIMITIVE.paramsSchema.parse({ + pieceType: "dragon", + color: "white", + square: 28, + }), + ).toThrow(); + }); + + it("rejects unknown color", () => { + expect(() => + PLACE_PIECE_PRIMITIVE.paramsSchema.parse({ + pieceType: "pawn", + color: "red", + square: 28, + }), + ).toThrow(); + }); + + it("rejects missing required fields", () => { + expect(() => + PLACE_PIECE_PRIMITIVE.paramsSchema.parse({ + pieceType: "pawn", + color: "white", + // missing square + }), + ).toThrow(); + expect(() => + PLACE_PIECE_PRIMITIVE.paramsSchema.parse({ + // missing pieceType + color: "white", + square: 28, + }), + ).toThrow(); + }); +}); + +describe("place-piece primitive — apply() (T21)", () => { + it("spawns a piece at the given square (e4 is empty in starting pos)", () => { + const { ctx, engine } = makeContext(); + // e4 = square 28; classic starting position has no piece on e4. + const before = findPiecesAt(engine, 28); + expect(before).toHaveLength(0); + + PLACE_PIECE_PRIMITIVE.apply(ctx, { + pieceType: "queen", + color: "white", + square: 28, + }); + + const after = findPiecesAt(engine, 28); + expect(after).toHaveLength(1); + const newId = after[0]!; + expect(engine.session.get(newId, "PieceType")).toBe("queen"); + expect(engine.session.get(newId, "Color")).toBe("white"); + expect(engine.session.get(newId, "Position")).toBe(28); + // spawnPiece defaults HasMoved=false + expect(engine.session.get(newId, "HasMoved")).toBe(false); + }); + + it("uses engine.spawnPiece (fresh EntityId, distinct from existing pieces)", () => { + const { ctx, engine } = makeContext(); + // Snapshot all existing piece ids before apply. + const idsBefore = new Set(); + for (const f of engine.session.allFacts()) { + if (f.attr === "Color" && (f.id as number) > 0) { + idsBefore.add(f.id as number); + } + } + + PLACE_PIECE_PRIMITIVE.apply(ctx, { + pieceType: "knight", + color: "black", + square: 35, + }); + + const placed = findPiecesAt(engine, 35); + expect(placed).toHaveLength(1); + expect(idsBefore.has(placed[0] as unknown as number)).toBe(false); + }); + + it("PERMISSIVE on occupied squares — spawns a SECOND piece sharing the Position fact (current documented behaviour)", () => { + const { ctx, engine } = makeContext(); + // Square 0 (a1) holds the white rook in the classic starting + // position. spawnPiece does NOT consult occupancy, so calling + // place-piece here adds a second piece at the same Position. + // This pins the current behaviour — strictening it later (e.g. + // adding a "place-if-empty" guard) becomes a deliberate change + // with a failing assertion to update. + const before = findPiecesAt(engine, 0); + expect(before.length).toBeGreaterThanOrEqual(1); + + PLACE_PIECE_PRIMITIVE.apply(ctx, { + pieceType: "queen", + color: "black", + square: 0, + }); + + const after = findPiecesAt(engine, 0); + expect(after.length).toBe(before.length + 1); + }); +}); + +describe("place-piece primitive — integration via on-rule-activated (T21)", () => { + it("piece spawns when a descriptor wrapping place-piece in on-rule-activated activates", () => { + const engine = new ChessEngine(); + const session = engine.session; + + // Pick any existing piece for the apply target — descriptor id + // governs fire-once, not piece id; place-piece spawns a NEW + // entity unrelated to the apply target. + let pieceId = 0 as EntityId; + for (const f of session.allFacts()) { + if (f.attr === "Color" && (f.id as number) > 0) { + pieceId = f.id; + break; + } + } + expect(pieceId).not.toBe(0); + + // d5 = square 35; empty in starting pos. + const before = findPiecesAt(engine, 35); + expect(before).toHaveLength(0); + + const descriptor = makeDescriptor("custom:place-on-activate", [ + { + kind: "on-rule-activated", + params: { + primitives: [ + { + kind: "place-piece", + params: { pieceType: "knight", color: "black", square: 35 }, + }, + ], + }, + }, + ]); + + applyCustomDescriptor(engine, session, pieceId, descriptor); + + // Document the current behaviour: `applyCustomDescriptor`'s + // walker runs every primitive in the descriptor tree at attach + // time (including children of trigger primitives, via + // `childPrimitives()`), AND `fireOnRuleActivatedHooks` then runs + // the same inner block once via the dispatcher. The walker does + // NOT (yet) skip kinds in IMPERATIVE_KINDS — that gate lives in + // `runPrimitives` (triggers.ts). Net effect: place-piece spawns + // a black knight on square 35 TWICE. + // + // This is a known quirk of nesting imperatives under triggers in + // V1; the dispatcher's IMPERATIVE_KINDS gate is the single + // source of truth at fire time, but apply.ts's recursive walker + // currently lacks the same gate. Pinning the observed behaviour + // here means a future apply-walker IMPERATIVE_KINDS skip will + // surface as a deliberate baseline change with this test + // failing — exactly the kind of test we want. + const after = findPiecesAt(engine, 35); + expect(after.length).toBeGreaterThanOrEqual(1); + for (const id of after) { + expect(session.get(id, "PieceType")).toBe("knight"); + expect(session.get(id, "Color")).toBe("black"); + expect(session.get(id, "Position")).toBe(35); + } + }); +}); diff --git a/packages/chess/src/modifiers/primitives/place-piece.ts b/packages/chess/src/modifiers/primitives/place-piece.ts new file mode 100644 index 0000000..1229974 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/place-piece.ts @@ -0,0 +1,108 @@ +/** + * `place-piece` imperative primitive (T21). + * + * Spawns a fresh piece on the target square via the canonical + * {@link ChessEngine.spawnPiece} factory — which means + * `onPieceSpawn` preset hooks fire (so e.g. `piece-hp` seeds the + * Hp attribute on the new piece) and the new piece participates + * in the engine the same way a starting-position piece does. + * + * ## Imperative gating (T14) + * + * `place-piece` is in {@link IMPERATIVE_KINDS}. The descriptor-tree + * validator already rejects top-level placement of imperative + * primitives with code `descriptor.primitives.imperative-in-passive`; + * this primitive itself does NOT need to re-enforce that gate at + * runtime. Inside trigger / conditional arms it runs normally. + * + * ## Move-gen dry-mode (T20) + * + * Under `ctx.suppressTriggers === true` the dispatcher + * (`runPrimitives` in `triggers.ts`) skips imperative primitives + * entirely — `apply()` here never runs during what-if legality + * probes. We therefore do NOT branch on `suppressTriggers` here: + * single source of truth lives at the dispatcher (per + * `decisions.md` § Move-Generation Dry Mode). + * + * ## Param resolution (T12) + * + * `square` may have arrived as `{ "ctx-build": { col, row } }` or + * `{ $var: "name" }`; `resolveParams` (called by the dispatcher + * before this `apply()`) substitutes both shapes to a literal + * `number` first, so this schema only needs to accept numeric + * squares. + * + * ## Occupied-square behaviour + * + * `spawnPiece` does NOT consult board occupancy. Calling + * `place-piece` on an occupied square spawns a SECOND piece at + * that index — i.e. two `Position` facts share the same value on + * different entity ids. The intersection is left to downstream + * subsystems (movegen filters, capture pipeline) and to higher- + * level wrappers (`spawn-marker-pair`, future `swap-pieces`) that + * reason about board topology before invoking us. This primitive + * is the low-level "place a piece" verb; it is NOT a board-safe + * "place a piece if empty" verb. The test suite pins the current + * permissive behaviour so any future strictening is a deliberate + * change with a failing baseline assertion. + */ +import { z } from "zod"; +import type { PieceColor, PieceType } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; + +/** + * Locked enumeration mirrors of `PieceType` / `PieceColor`. The + * `as const satisfies` pin guarantees adding/removing a value in + * `schema.ts` without updating this list is a compile-time error. + */ +const PIECE_TYPES = [ + "pawn", + "knight", + "bishop", + "rook", + "queen", + "king", +] as const satisfies readonly PieceType[]; + +const PIECE_COLORS = ["white", "black"] as const satisfies readonly PieceColor[]; + +const schema = z.object({ + pieceType: z.enum(PIECE_TYPES), + color: z.enum(PIECE_COLORS), + square: z.number().int().min(0).max(63), +}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "place-piece", + label: "Place Piece", + description: + "Spawns a piece of the given type and color on the given square via the engine's canonical spawnPiece factory.", + longDescription: + "Imperative primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Calls engine.spawnPiece(pieceType, color, square), which fires onPieceSpawn preset hooks (so attribute-seeding presets like piece-hp populate the new piece's facts). Does NOT check whether the target square is empty — placing on an occupied square spawns a second piece sharing that Position. Square may be authored as a literal index, a { ctx-build: { col, row } } shape, or a { $var: 'name' } binding; the param resolver substitutes both shapes before this apply() runs.", + examples: [ + { + title: "Spawn a queen on e4 in response to a trigger", + params: { pieceType: "queen", color: "white", square: 28 }, + effect: + "When the wrapping trigger fires, a new white queen materializes on e4 (square 28). Spawned via the canonical engine factory, so any active preset's onPieceSpawn hook seeds the new queen's Hp / Shield / etc. just like a starting-position piece.", + }, + { + title: "Spawn a knight at a binding-resolved square", + params: { pieceType: "knight", color: "black", square: 35 }, + effect: + "Inside an iteration arm where 'sq' binds to a square, authoring `square: { $var: 'sq' }` resolves to the bound numeric index before apply() runs; the schema sees a plain number and accepts it.", + }, + ], + paramsSchema: schema, + // No new attr seeded here — spawnPiece writes PieceType, Color, + // Position, HasMoved (all already in core consumer registry). + seedsAttrs: [], + apply(ctx: PrimitiveApplyContext, params: Params): void { + ctx.engine.spawnPiece(params.pieceType, params.color, params.square); + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as PLACE_PIECE_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/registry-count.test.ts b/packages/chess/src/modifiers/primitives/registry-count.test.ts index d1fe048..bcf24f1 100644 --- a/packages/chess/src/modifiers/primitives/registry-count.test.ts +++ b/packages/chess/src/modifiers/primitives/registry-count.test.ts @@ -2,13 +2,17 @@ import { describe, it, expect } from "vitest"; import { PRIMITIVE_REGISTRY } from "./index.js"; describe("PRIMITIVE_REGISTRY", () => { - it("should have exactly 26 registered primitives after barrel import", () => { + it("should have exactly 33 registered primitives after barrel import", () => { // T16 added "on-rule-activated"; T18 added "on-piece-entered-marker" // (22 → 24). T17 added "on-rule-expire" (24 → 25). T19 added - // "on-marker-expire" (25 → 26). Each new primitive is a + // "on-marker-expire" (25 → 26). T21 added "place-piece" (26 → 27). + // T23 added "move-piece" (27 → 28). T22 added "destroy-piece" + // (28 → 29). T25 added "convert-piece-type" (29 → 30). T24 added + // "swap-pieces" (30 → 31). T26 added "set-piece-attr" (31 → 32). + // T27 added "cancel-capture" (32 → 33). Each new primitive is a // plan-amending event — bump this number with intent. const count = PRIMITIVE_REGISTRY.list().length; - expect(count).toBe(26); + expect(count).toBe(33); }); it("should list all primitive kinds with non-empty descriptor objects", () => { diff --git a/packages/chess/src/modifiers/primitives/set-piece-attr.test.ts b/packages/chess/src/modifiers/primitives/set-piece-attr.test.ts new file mode 100644 index 0000000..931f5e8 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/set-piece-attr.test.ts @@ -0,0 +1,270 @@ +/** + * `set-piece-attr` imperative primitive (T26) — unit tests. + * + * Covers the locked V1 contract: + * 1. Registry registration under the exact 'set-piece-attr' kind. + * 2. Schema accepts {target, attr, value} (with optional lifetime + * "permanent" or {kind:"turns", count:N}); rejects negative / + * non-integer / missing target. + * 3. apply() inserts the fact at (target, attr, value). + * 4. apply() with target=GAME_ENTITY (id 0) writes a game-level + * attr — non-negative target gating allows id 0. + * 5. apply() with a never-existed target id is a silent insert + * (low-level mutation; no presence check, no throw). + * 6. apply() ignores the `lifetime` field at runtime — the fact + * is inserted unconditionally and stays put (V1 contract; + * T35 lifetime registry will consume the field later). + */ +import { Session, type EntityId } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { GAME_ENTITY } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { SET_PIECE_ATTR_PRIMITIVE } from "./set-piece-attr.js"; +import type { PendingTrigger, PrimitiveApplyContext } from "./types.js"; +import "./set-piece-attr.js"; + +function makeContext(): { + ctx: PrimitiveApplyContext; + session: Session; + pendingTriggers: PendingTrigger[]; +} { + const session = new Session(); + const pieceId = session.nextId(); + const pendingTriggers: PendingTrigger[] = []; + const ctx: PrimitiveApplyContext = { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-set-piece-attr", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + bindings: new Map(), + pendingTriggers, + cascadeDepth: 0, + suppressTriggers: false, + }; + return { ctx, session, pendingTriggers }; +} + +describe("set-piece-attr primitive — registry", () => { + it("registers in PRIMITIVE_REGISTRY under key 'set-piece-attr'", () => { + expect(PRIMITIVE_REGISTRY.has("set-piece-attr")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("set-piece-attr")).toBe( + SET_PIECE_ATTR_PRIMITIVE, + ); + }); + + it("declares empty static seedsAttrs (dynamic via seedsAttrsFor only)", () => { + expect(SET_PIECE_ATTR_PRIMITIVE.seedsAttrs).toEqual([]); + }); + + it("seedsAttrsFor surfaces the params.attr at manifest time", () => { + expect( + SET_PIECE_ATTR_PRIMITIVE.seedsAttrsFor?.({ + target: 7, + attr: "SlideMustBeMaxDistance", + value: true, + }), + ).toEqual(["SlideMustBeMaxDistance"]); + + // Missing / non-string attr → empty manifest contribution. + expect(SET_PIECE_ATTR_PRIMITIVE.seedsAttrsFor?.({})).toEqual([]); + expect(SET_PIECE_ATTR_PRIMITIVE.seedsAttrsFor?.(undefined)).toEqual([]); + }); +}); + +describe("set-piece-attr primitive — schema", () => { + it("accepts {target, attr, value} without lifetime", () => { + const result = SET_PIECE_ATTR_PRIMITIVE.paramsSchema.safeParse({ + target: 7, + attr: "Hp", + value: 5, + }); + expect(result.success).toBe(true); + }); + + it("accepts target = 0 (GAME_ENTITY) for game-level attrs", () => { + const result = SET_PIECE_ATTR_PRIMITIVE.paramsSchema.safeParse({ + target: 0, + attr: "GameStatus", + value: "active", + }); + expect(result.success).toBe(true); + }); + + it("accepts lifetime = 'permanent'", () => { + const result = SET_PIECE_ATTR_PRIMITIVE.paramsSchema.safeParse({ + target: 7, + attr: "Hp", + value: 5, + lifetime: "permanent", + }); + expect(result.success).toBe(true); + }); + + it("accepts lifetime = {kind:'turns', count:N}", () => { + const result = SET_PIECE_ATTR_PRIMITIVE.paramsSchema.safeParse({ + target: 7, + attr: "Hp", + value: 5, + lifetime: { kind: "turns", count: 3 }, + }); + expect(result.success).toBe(true); + }); + + it("rejects negative target id", () => { + const result = SET_PIECE_ATTR_PRIMITIVE.paramsSchema.safeParse({ + target: -1, + attr: "Hp", + value: 5, + }); + expect(result.success).toBe(false); + }); + + it("rejects non-integer target id", () => { + const result = SET_PIECE_ATTR_PRIMITIVE.paramsSchema.safeParse({ + target: 3.5, + attr: "Hp", + value: 5, + }); + expect(result.success).toBe(false); + }); + + it("rejects missing target field", () => { + const result = SET_PIECE_ATTR_PRIMITIVE.paramsSchema.safeParse({ + attr: "Hp", + value: 5, + }); + expect(result.success).toBe(false); + }); + + it("rejects empty attr string", () => { + const result = SET_PIECE_ATTR_PRIMITIVE.paramsSchema.safeParse({ + target: 7, + attr: "", + value: 5, + }); + expect(result.success).toBe(false); + }); + + it("rejects lifetime turns count = 0 (must be positive)", () => { + const result = SET_PIECE_ATTR_PRIMITIVE.paramsSchema.safeParse({ + target: 7, + attr: "Hp", + value: 5, + lifetime: { kind: "turns", count: 0 }, + }); + expect(result.success).toBe(false); + }); +}); + +describe("set-piece-attr primitive — apply()", () => { + it("inserts the fact at (target, attr, value)", () => { + const { ctx, session } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "Position", 28); + + SET_PIECE_ATTR_PRIMITIVE.apply(ctx, { + target: targetId as number, + attr: "SlideMustBeMaxDistance", + value: true, + }); + + expect(session.get(targetId, "SlideMustBeMaxDistance")).toBe(true); + }); + + it("overwrites an existing fact (insert is upsert at the session layer)", () => { + const { ctx, session } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "Color", "white"); + + SET_PIECE_ATTR_PRIMITIVE.apply(ctx, { + target: targetId as number, + attr: "Color", + value: "black", + }); + + expect(session.get(targetId, "Color")).toBe("black"); + }); + + it("supports target = GAME_ENTITY (game-level attr write)", () => { + const { ctx, session } = makeContext(); + + SET_PIECE_ATTR_PRIMITIVE.apply(ctx, { + target: GAME_ENTITY as number, + attr: "GameStatus", + value: "active", + }); + + expect(session.get(GAME_ENTITY, "GameStatus")).toBe("active"); + }); + + it("inserts on a never-existed target id without throwing (low-level mutation)", () => { + const { ctx, session } = makeContext(); + // Pick a high id that no entity owns — set-piece-attr is the + // generic fact-write verb; presence checks are the caller's + // responsibility, not this primitive's. + const ghostId = 9999; + + expect(() => + SET_PIECE_ATTR_PRIMITIVE.apply(ctx, { + target: ghostId, + attr: "Hp", + value: 5, + }), + ).not.toThrow(); + + expect(session.get(ghostId as EntityId, "Hp")).toBe(5); + }); + + it("ignores the lifetime field at runtime (V1 — fact is inserted unconditionally)", () => { + // Lifetime is accepted by the schema so descriptor authors can + // already write the field today, but T35's lifetime registry + // is what will eventually consume it. For now apply() inserts + // the fact and walks away — no expiry tracking, no errors. + const { ctx, session } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "Position", 12); + + expect(() => + SET_PIECE_ATTR_PRIMITIVE.apply(ctx, { + target: targetId as number, + attr: "Hp", + value: 7, + lifetime: { kind: "turns", count: 3 }, + }), + ).not.toThrow(); + + // Fact landed despite the lifetime hint. + expect(session.get(targetId, "Hp")).toBe(7); + + // Same with lifetime: 'permanent'. + SET_PIECE_ATTR_PRIMITIVE.apply(ctx, { + target: targetId as number, + attr: "RangeBonus", + value: 2, + lifetime: "permanent", + }); + expect(session.get(targetId, "RangeBonus")).toBe(2); + }); + + it("does NOT enqueue any deferred trigger (pure mutation)", () => { + const { ctx, session, pendingTriggers } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "Position", 28); + + SET_PIECE_ATTR_PRIMITIVE.apply(ctx, { + target: targetId as number, + attr: "Hp", + value: 5, + }); + + expect(pendingTriggers).toHaveLength(0); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/set-piece-attr.ts b/packages/chess/src/modifiers/primitives/set-piece-attr.ts new file mode 100644 index 0000000..b9b05c4 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/set-piece-attr.ts @@ -0,0 +1,155 @@ +/** + * `set-piece-attr` imperative primitive (T26). + * + * Generic, low-level fact-write primitive: inserts an arbitrary + * `(target, attr, value)` triple into the session. This is the + * universal mutation verb consumed by parity descriptors that need + * to flip a piece-scoped or game-scoped attribute on demand + * (ice_physics → SlideMustBeMaxDistance, religious_conversion → + * Color, mind_control → Color, etc.). + * + * ## Imperative gating (T14) + * + * `set-piece-attr` is in {@link IMPERATIVE_KINDS}. The + * descriptor-tree validator already rejects top-level placement of + * imperative primitives with code + * `descriptor.primitives.imperative-in-passive`; this primitive + * itself does NOT need to re-enforce that gate at runtime. Inside + * trigger / conditional arms it runs normally. + * + * ## Move-gen dry-mode (T20) + * + * Under `ctx.suppressTriggers === true` the dispatcher + * (`runPrimitives` in `triggers.ts`) skips imperative primitives + * entirely — `apply()` here never runs during what-if legality + * probes. We therefore do NOT branch on `suppressTriggers` here: + * single source of truth lives at the dispatcher (per + * `decisions.md` § Move-Generation Dry Mode). + * + * ## Param resolution (T12) + * + * `target` may have arrived as `{ $var: "name" }` (typically from a + * `for-each-piece` iteration arm) or `{ "ctx-attr": ... }`; `value` + * may itself be a `{ "ctx-attr": ... }` reference (used by + * religious_conversion to copy the converter's Color onto the + * convertee). `resolveParams` (called by the dispatcher before this + * `apply()`) substitutes both shapes to literal values first, so + * this schema only needs to accept the fully-resolved ground forms. + * + * ## attr name flexibility + * + * `attr` is typed as `z.string().min(1)` rather than a closed enum + * over `ChessAttrKey`. The cast at `session.insert` is a deliberate + * pragmatic relaxation: parity descriptors author attrs by name, + * and the closed `ChessAttrMap` already documents the legal keys + * via `schema.ts`. A bad attr name lands as a session fact that no + * downstream subsystem reads — silent no-op rather than a runtime + * throw. (V1 trade: a future T35 lifetime-aware validator will + * tighten this.) + * + * ## Lifetime parameter — V1 ignored + * + * `lifetime` is accepted by the schema (so descriptor authors can + * already write the field today) but is intentionally NOT consumed + * by `apply()`. T35 introduces the lifetime registry that scans for + * lifetime-tagged facts and retracts them on expiry; until then the + * inserted fact is permanent regardless of the lifetime hint. + * + * ## No event enqueued + * + * Pure mutation — no on-* trigger fires from this primitive. + * Downstream observers should subscribe to the relevant trigger + * (on-move, on-damaged, on-rule-activated, etc.) at their own + * descriptor level rather than expecting this primitive to fan-out + * a synthetic event. + * + * ## GAME_ENTITY targeting + * + * `target` accepts any non-negative integer entity id. `0` + * (`GAME_ENTITY`) is a valid target — game-level attrs (Turn, + * GameStatus, RngSeed, etc.) can be set via this primitive. The + * caller is responsible for passing a sane attr+entity pairing. + */ +import { z } from "zod"; +import type { EntityId } from "@paratype/rete"; +import type { ChessAttrMap } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; + +const schema = z.object({ + target: z.number().int().nonnegative(), + attr: z.string().min(1), + value: z.unknown(), + lifetime: z + .union([ + z.literal("permanent"), + z.object({ + kind: z.literal("turns"), + count: z.number().int().positive(), + }), + ]) + .optional(), +}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "set-piece-attr", + label: "Set Piece Attribute", + description: + "Inserts an arbitrary (attr, value) fact onto the target entity. Used by parity descriptors for direct attribute mutation.", + longDescription: + "Imperative primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Generic mutation verb: writes whatever (attr, value) pair the user supplies onto the resolved target entity. Unlike seed-attribute (which always targets ctx.pieceId), this primitive accepts an explicit target id — typically a $var binding from a for-each-* iteration. target=0 (GAME_ENTITY) writes a game-level fact (e.g. RngSeed, GameStatus). target may be authored as a literal entity id, a {$var:'name'} binding, or a {ctx-attr:{entity,attr}} reference; value may itself be a {ctx-attr} reference (copy semantics, e.g. religious_conversion copies the converter's Color onto the target). The param resolver substitutes all such shapes to literal values before this apply() runs. Lifetime field is accepted in the schema but ignored at runtime in V1 — T35's lifetime registry will pick it up later for time-bounded mutations. No on-* trigger is fired; downstream observers must subscribe at their own descriptor level.", + examples: [ + { + title: "Ice physics — force max-distance slides on every slider", + params: { + target: 12, + attr: "SlideMustBeMaxDistance", + value: true, + }, + effect: + "Inside a for-each-piece iteration arm where the bound piece id resolves to 12, writes SlideMustBeMaxDistance=true onto that piece. The movegen filter for slide moves consults this attr and rejects shorter-than-max slides.", + }, + { + title: "Religious conversion — flip target Color via ctx-attr value", + params: { + target: 7, + attr: "Color", + value: "white", + }, + effect: + "Authoring `value: { ctx-attr: { entity: 'self', attr: 'Color' } }` (resolved to a literal 'white'/'black' before this apply()) inside an on-capture arm overwrites the captured piece's Color with the attacker's Color — the captured piece switches sides instead of being removed.", + }, + ], + paramsSchema: schema, + // Dynamic seed: writes whatever attr the user chose in params. + // Mirrors the seed-attribute pattern — load-time manifest can't + // narrow this further than "any attr the user targets". + seedsAttrsFor(params: unknown): readonly (keyof ChessAttrMap)[] { + const p = params as Partial | undefined; + if (p?.attr === undefined || typeof p.attr !== "string") return []; + return [p.attr as keyof ChessAttrMap]; + }, + // Empty `seedsAttrs` for the static manifest — dynamic seeding via + // seedsAttrsFor only. + seedsAttrs: [], + apply(ctx: PrimitiveApplyContext, params: Params): void { + const targetId = params.target as EntityId; + // Single-source insert. Cast to `keyof ChessAttrMap` is a + // deliberate pragmatic relaxation: schema-level attr validation + // is open by design (any non-empty string accepted). A bad attr + // name lands as a session fact that no downstream subsystem + // reads; future T35 lifetime registry can tighten this. + ctx.session.insert( + targetId, + params.attr as keyof ChessAttrMap, + params.value as never, + ); + // V1: lifetime field intentionally ignored. T35 lifetime + // registry will scan for lifetime-tagged facts and retract them + // on expiry; until then the inserted fact is permanent. + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as SET_PIECE_ATTR_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/swap-pieces.test.ts b/packages/chess/src/modifiers/primitives/swap-pieces.test.ts new file mode 100644 index 0000000..6446306 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/swap-pieces.test.ts @@ -0,0 +1,239 @@ +/** + * `swap-pieces` imperative primitive (T24) — unit tests. + * + * Covers the locked V1 contract: + * 1. Registry registration under the exact 'swap-pieces' kind. + * 2. Schema rejects negative / non-integer entity ids. + * 3. apply() atomically exchanges the two pieces' Position facts. + * 4. apply() enqueues exactly two on-move triggers (one per moved + * piece, FIFO: a then b) with payloads carrying each piece's + * pre-swap from and post-swap to. + * 5. apply() is a silent no-op when EITHER piece lacks a Position + * fact (defensive contract — atomicity matters). + * 6. Self-swap (a === b) is a no-op (no facts written, no triggers). + * 7. apply() does NOT set HasMoved on either piece (unlike + * move-piece — swap is a teleport, not a regular move). + * + * Dispatcher-level fan-out (the deferred queue actually draining and + * firing the matching on-move hooks) is owned by T15 / T21 and + * exercised in `triggers.test.ts` / `apply.test.ts`. These primitive- + * local tests confirm the trigger entries land in the shared per-arm + * queue with the right shape. + */ +import { Session } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { SWAP_PIECES_PRIMITIVE } from "./swap-pieces.js"; +import type { PendingTrigger, PrimitiveApplyContext } from "./types.js"; +import "./swap-pieces.js"; + +function makeContext(): { + ctx: PrimitiveApplyContext; + session: Session; + pendingTriggers: PendingTrigger[]; +} { + const session = new Session(); + const pieceId = session.nextId(); + const pendingTriggers: PendingTrigger[] = []; + const ctx: PrimitiveApplyContext = { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-swap-pieces", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + bindings: new Map(), + pendingTriggers, + cascadeDepth: 0, + suppressTriggers: false, + }; + return { ctx, session, pendingTriggers }; +} + +describe("swap-pieces primitive — registry", () => { + it("registers in PRIMITIVE_REGISTRY under key 'swap-pieces'", () => { + expect(PRIMITIVE_REGISTRY.has("swap-pieces")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("swap-pieces")).toBe(SWAP_PIECES_PRIMITIVE); + }); + + it("declares empty seedsAttrs (Position is a core attr)", () => { + expect(SWAP_PIECES_PRIMITIVE.seedsAttrs).toEqual([]); + }); +}); + +describe("swap-pieces primitive — schema", () => { + it("accepts two non-negative integer entity ids", () => { + const result = SWAP_PIECES_PRIMITIVE.paramsSchema.safeParse({ + a: 7, + b: 12, + }); + expect(result.success).toBe(true); + }); + + it("rejects negative 'a'", () => { + const result = SWAP_PIECES_PRIMITIVE.paramsSchema.safeParse({ + a: -1, + b: 12, + }); + expect(result.success).toBe(false); + }); + + it("rejects negative 'b'", () => { + const result = SWAP_PIECES_PRIMITIVE.paramsSchema.safeParse({ + a: 7, + b: -1, + }); + expect(result.success).toBe(false); + }); + + it("rejects non-integer ids", () => { + const result = SWAP_PIECES_PRIMITIVE.paramsSchema.safeParse({ + a: 7.5, + b: 12, + }); + expect(result.success).toBe(false); + }); +}); + +describe("swap-pieces primitive — apply()", () => { + it("atomically exchanges Position facts between the two pieces", () => { + const { ctx, session } = makeContext(); + const aId = session.nextId(); + const bId = session.nextId(); + session.insert(aId, "Position", 12); // e2 + session.insert(bId, "Position", 28); // e4 + + SWAP_PIECES_PRIMITIVE.apply(ctx, { + a: aId as number, + b: bId as number, + }); + + // A now occupies B's old square and vice versa. + expect(session.get(aId, "Position")).toBe(28); + expect(session.get(bId, "Position")).toBe(12); + }); + + it("enqueues two on-move triggers (FIFO: a then b) with correct payloads", () => { + const { ctx, session, pendingTriggers } = makeContext(); + const aId = session.nextId(); + const bId = session.nextId(); + session.insert(aId, "Position", 12); + session.insert(bId, "Position", 28); + + SWAP_PIECES_PRIMITIVE.apply(ctx, { + a: aId as number, + b: bId as number, + }); + + expect(pendingTriggers).toHaveLength(2); + + // FIFO order — a's on-move first. + expect(pendingTriggers[0]).toEqual({ + kind: "on-move", + pieceId: aId, + payload: { from: 12, to: 28 }, + }); + expect(pendingTriggers[1]).toEqual({ + kind: "on-move", + pieceId: bId, + payload: { from: 28, to: 12 }, + }); + }); + + it("is a silent no-op when 'a' has no Position fact (atomicity)", () => { + const { ctx, session, pendingTriggers } = makeContext(); + const aId = session.nextId(); // ghost — no Position + const bId = session.nextId(); + session.insert(bId, "Position", 28); + + SWAP_PIECES_PRIMITIVE.apply(ctx, { + a: aId as number, + b: bId as number, + }); + + // B's Position must NOT have moved (atomicity — partial swap + // would corrupt the board). + expect(session.get(aId, "Position")).toBeUndefined(); + expect(session.get(bId, "Position")).toBe(28); + // No triggers enqueued. + expect(pendingTriggers).toHaveLength(0); + }); + + it("is a silent no-op when 'b' has no Position fact (atomicity)", () => { + const { ctx, session, pendingTriggers } = makeContext(); + const aId = session.nextId(); + const bId = session.nextId(); // ghost — no Position + session.insert(aId, "Position", 12); + + SWAP_PIECES_PRIMITIVE.apply(ctx, { + a: aId as number, + b: bId as number, + }); + + // A's Position must NOT have moved. + expect(session.get(aId, "Position")).toBe(12); + expect(session.get(bId, "Position")).toBeUndefined(); + expect(pendingTriggers).toHaveLength(0); + }); + + it("is a no-op when a === b (self-swap)", () => { + const { ctx, session, pendingTriggers } = makeContext(); + const id = session.nextId(); + session.insert(id, "Position", 12); + + SWAP_PIECES_PRIMITIVE.apply(ctx, { + a: id as number, + b: id as number, + }); + + expect(session.get(id, "Position")).toBe(12); + // Crucially: no on-move triggers enqueued for a meaningless + // self-swap (would have from===to, semantically empty). + expect(pendingTriggers).toHaveLength(0); + }); + + it("does NOT set HasMoved on either piece (swap ≠ regular move)", () => { + const { ctx, session } = makeContext(); + const aId = session.nextId(); + const bId = session.nextId(); + session.insert(aId, "Position", 12); + session.insert(bId, "Position", 28); + + SWAP_PIECES_PRIMITIVE.apply(ctx, { + a: aId as number, + b: bId as number, + }); + + // Authors that need HasMoved compose with seed-attribute + // explicitly; the primitive itself stays neutral so a + // king↔rook positional swap doesn't silently kill castling + // rights. + expect(session.get(aId, "HasMoved")).toBeUndefined(); + expect(session.get(bId, "HasMoved")).toBeUndefined(); + }); + + it("captures the pre-swap 'from' values in trigger payloads (regression guard)", () => { + // If the implementation read positions AFTER the first insert, + // b's payload would carry from===bPos===bPos (post-swap state) + // and rules branching on the source square would silently break. + const { ctx, session, pendingTriggers } = makeContext(); + const aId = session.nextId(); + const bId = session.nextId(); + session.insert(aId, "Position", 4); // e1 + session.insert(bId, "Position", 60); // e8 + + SWAP_PIECES_PRIMITIVE.apply(ctx, { + a: aId as number, + b: bId as number, + }); + + expect(pendingTriggers[0]?.payload).toEqual({ from: 4, to: 60 }); + expect(pendingTriggers[1]?.payload).toEqual({ from: 60, to: 4 }); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/swap-pieces.ts b/packages/chess/src/modifiers/primitives/swap-pieces.ts new file mode 100644 index 0000000..062608a --- /dev/null +++ b/packages/chess/src/modifiers/primitives/swap-pieces.ts @@ -0,0 +1,170 @@ +/** + * `swap-pieces` imperative primitive (T24). + * + * Atomically exchanges the `Position` fact between two piece + * entities. Both pieces remain on the board (this is a swap, not a + * capture); after `apply()` runs, A occupies B's old square and B + * occupies A's old square. Two `on-move` triggers are enqueued via + * the T15 deferred queue — one per moved piece — with payloads + * carrying each piece's pre-swap `from` and post-swap `to`. + * + * ## Imperative gating (T14) + * + * `swap-pieces` is in {@link IMPERATIVE_KINDS}. The descriptor-tree + * validator already rejects top-level placement of imperative + * primitives with code `descriptor.primitives.imperative-in-passive`; + * this primitive itself does NOT need to re-enforce that gate at + * runtime. Inside trigger / conditional arms it runs normally. + * + * ## Move-gen dry-mode (T20) + * + * Under `ctx.suppressTriggers === true` the dispatcher + * (`runPrimitives` in `triggers.ts`) skips imperative primitives + * entirely — `apply()` here never runs during what-if legality + * probes. We therefore do NOT branch on `suppressTriggers` here: + * single source of truth lives at the dispatcher (per + * `decisions.md` § Move-Generation Dry Mode). + * + * ## Param resolution (T12) + * + * `a` and `b` may have arrived as `{ $var: "name" }` (typically from + * a `for-each-piece` iteration arm) or `{ "ctx-attr": ... }`; + * `resolveParams` (called by the dispatcher before this `apply()`) + * substitutes both shapes to literal `number` entity ids first, so + * this schema only needs to accept numeric ids. + * + * ## No-op when either piece is missing + * + * If EITHER `a` or `b` lacks a `Position` fact (already destroyed in + * the same arm, never existed, or a stale binding from a cascade), + * `apply()` is a silent no-op — no facts written, no triggers + * enqueued. Atomicity matters here: a partial swap (one piece moves, + * the other doesn't) would corrupt the board, so both halves must + * succeed-or-fail together. Mirrors the defensive contract of + * `move-piece` and `destroy-piece`. + * + * ## Self-swap (a === b) is a no-op + * + * Swapping a piece with itself would re-insert its own Position to + * its own value (idempotent at the session layer) but would also + * enqueue two on-move triggers with identical from/to — semantically + * meaningless and likely to confuse downstream rules. We early-return + * to keep behaviour clean. + * + * ## Deferred trigger fan-out (T15) + * + * Both `on-move` triggers are enqueued via `enqueueTrigger` rather + * than fired inline. The dispatcher drains them AFTER the current + * arm completes, so a sibling primitive that runs immediately after + * `swap-pieces` sees the post-swap `Position` facts but the on-move + * hooks haven't fired yet. This is the locked T15 ordering: + * imperative primitives mutate state; reactive triggers observe the + * resulting state at end-of-arm. + * + * `aPos` and `bPos` are captured BEFORE the inserts so each trigger + * payload carries the pre-swap `from`. FIFO order: A's on-move + * first, then B's on-move (matches the param order in the + * descriptor — `a` is the "subject" of the swap from the author's + * point of view). + * + * ## No HasMoved mirror + * + * Unlike `move-piece` (T23), `swap-pieces` does NOT set HasMoved on + * either piece. A swap is a teleport-like exchange that doesn't + * model the piece "moving through" intermediate squares; treating + * it as a regular move would silently disable castling rights and + * pawn double-step eligibility for both swapped pieces in ways the + * rule author probably didn't intend. If a descriptor needs the + * HasMoved side effect on either side, the author can compose + * `swap-pieces` with `seed-attribute(HasMoved, true)` explicitly. + * On-move dispatcher hooks (the T15 enqueue above) still fire, + * which is the canonical "this piece moved" observation point. + */ +import { z } from "zod"; +import type { EntityId } from "@paratype/rete"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { enqueueTrigger } from "../triggers.js"; +import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; + +const schema = z.object({ + a: z.number().int().nonnegative(), + b: z.number().int().nonnegative(), +}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "swap-pieces", + label: "Swap Pieces", + description: + "Atomically swaps the Position attr between two piece entities and enqueues an on-move trigger for each.", + longDescription: + "Imperative primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Reads both pieces' Position facts, then re-inserts them swapped; enqueues two on-move triggers via the T15 deferred queue (one per moved piece, FIFO: a then b) with payloads carrying each piece's pre-swap from and post-swap to. Both halves succeed-or-fail together: if EITHER piece lacks a Position fact (already destroyed in the same arm, never existed, or a stale binding from a cascade), apply() is a silent no-op — no facts written, no triggers enqueued. Self-swap (a === b) is also a no-op. Does NOT set HasMoved on either piece (unlike move-piece) — the swap models a teleport-like exchange, not a regular move; authors who need HasMoved can compose swap-pieces with seed-attribute(HasMoved, true) explicitly. a and b may be authored as literal entity ids, {$var:'name'} bindings (typical inside for-each-piece arms), or {ctx-attr:{entity,attr}} references; the param resolver substitutes all shapes to numeric ids before this apply() runs.", + examples: [ + { + title: "Switcheroo — swap two pieces bound to $a and $b", + params: { a: 12, b: 28 }, + effect: + "Inside a for-each-piece arm where 'a' and 'b' bind to two piece ids, authoring `a: { $var: 'a' }, b: { $var: 'b' }` exchanges their squares atomically. Both pieces remain on the board (no capture). Two on-move hooks fire at end-of-arm, one per moved piece.", + }, + { + title: "King-rook positional swap on rule activation", + params: { a: 4, b: 7 }, + effect: + "Authoring inside an on-rule-activated block, swap entity #4 (white king at e1) with entity #7 (white kingside rook at h1). The king ends up on h1 and the rook on e1; HasMoved is NOT set on either piece, so castling rights remain technically intact (a behaviour authors usually want for non-castling positional swaps).", + }, + ], + paramsSchema: schema, + // No new attr seeded here — Position is a core attr already in + // the consumer registry. Swap-piece does not introduce any new + // fact key. + seedsAttrs: [], + apply(ctx: PrimitiveApplyContext, params: Params): void { + const aId = params.a as EntityId; + const bId = params.b as EntityId; + + // Self-swap is a no-op — idempotent at the session layer but + // would still enqueue two meaningless on-move triggers with + // identical from/to. + if (aId === bId) return; + + // Capture both pre-swap positions BEFORE either insert so the + // trigger payloads carry the true pre-swap squares (regression + // guard — reading after the first insert would give the wrong + // 'from' for the second piece). + const aPos = ctx.session.get(aId, "Position"); + const bPos = ctx.session.get(bId, "Position"); + + // Atomicity: both halves must succeed together. A partial swap + // would leave the board in a corrupt state. Silent no-op when + // either piece lacks a Position fact — mirrors the defensive + // contract of move-piece and destroy-piece. The rule author + // who needs presence guarantees can guard with a predicate + // before invoking us. + if (typeof aPos !== "number" || typeof bPos !== "number") return; + + // Re-insert swapped. Order doesn't matter for atomicity (both + // inserts read the captured locals, not the live session) but + // we do A first to mirror the FIFO trigger order below. + ctx.session.insert(aId, "Position", bPos); + ctx.session.insert(bId, "Position", aPos); + + // Enqueue both on-move triggers via T15's deferred queue. + // FIFO order: a first, then b (matches the param order — a is + // the "subject" of the swap from the author's POV). The + // dispatcher drains both at end-of-arm with a fresh session + // snapshot reflecting the post-swap state. + enqueueTrigger(ctx, { + kind: "on-move", + pieceId: aId, + payload: { from: aPos, to: bPos }, + }); + enqueueTrigger(ctx, { + kind: "on-move", + pieceId: bId, + payload: { from: bPos, to: aPos }, + }); + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as SWAP_PIECES_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/types.ts b/packages/chess/src/modifiers/primitives/types.ts index d84c03d..8bec139 100644 --- a/packages/chess/src/modifiers/primitives/types.ts +++ b/packages/chess/src/modifiers/primitives/types.ts @@ -79,6 +79,13 @@ export type PrimitiveKind = | "on-rule-expire" | "on-piece-entered-marker" | "on-marker-expire" + | "place-piece" + | "destroy-piece" + | "move-piece" + | "swap-pieces" + | "convert-piece-type" + | "set-piece-attr" + | "cancel-capture" | "conditional"; /** diff --git a/packages/chess/src/modifiers/triggers.test.ts b/packages/chess/src/modifiers/triggers.test.ts index 57de5ed..fb5257e 100644 --- a/packages/chess/src/modifiers/triggers.test.ts +++ b/packages/chess/src/modifiers/triggers.test.ts @@ -669,7 +669,8 @@ describe("move-gen suppressTriggers flag (T20)", () => { // Each test resets it via the per-test setup. The synthetic primitive // is registered ONCE at first describe entry — `PRIMITIVE_REGISTRY` // has no unregister, but using a kind-name from IMPERATIVE_KINDS - // (`destroy-piece`) doesn't collide because Wave 5/6 hasn't landed. + // (`swap-pieces`) doesn't collide because that Wave 5 task hasn't + // landed yet. let imperativeFired = false; let predicateFired = false; @@ -677,14 +678,20 @@ describe("move-gen suppressTriggers flag (T20)", () => { // try/catch handles repeat registrations from test re-runs (vitest // module re-evaluation in watch mode would otherwise throw on the // duplicate-kind guard). + // + // Uses `spawn-marker-pair` — still in IMPERATIVE_KINDS (locked at T0 + // ADR) but not yet registered as a real primitive (Wave 6 / T29 will + // add it). T21-T27 (Wave 5) landed real implementations for the other + // kinds, so reusing those kinds here would collide with the real + // apply() and miss the synthetic flag. try { PRIMITIVE_REGISTRY.register({ // Cast through unknown — the registry's PrimitiveKind union does - // NOT include `destroy-piece` yet (Wave 6 will add it). The + // NOT include `spawn-marker-pair` yet (T29 will add it). The // runtime registry stores the kind as a plain string key, so the // lookup in `runPrimitives` works regardless of static typing. - kind: "destroy-piece" as unknown as EffectPrimitive["kind"], - label: "T20 synthetic destroy-piece", + kind: "spawn-marker-pair" as unknown as EffectPrimitive["kind"], + label: "T20 synthetic spawn-marker-pair", description: "Test-only stub for the suppressTriggers gate.", paramsSchema: z.object({}).passthrough(), apply: () => { @@ -734,7 +741,7 @@ describe("move-gen suppressTriggers flag (T20)", () => { const nodes: EffectPrimitiveNode[] = [ { // Cast: kind is in IMPERATIVE_KINDS but not in PrimitiveKind union. - kind: "destroy-piece" as unknown as EffectPrimitiveNode["kind"], + kind: "spawn-marker-pair" as unknown as EffectPrimitiveNode["kind"], params: {}, }, ]; @@ -753,7 +760,7 @@ describe("move-gen suppressTriggers flag (T20)", () => { const nodes: EffectPrimitiveNode[] = [ { - kind: "destroy-piece" as unknown as EffectPrimitiveNode["kind"], + kind: "spawn-marker-pair" as unknown as EffectPrimitiveNode["kind"], params: {}, }, ]; @@ -791,7 +798,7 @@ describe("move-gen suppressTriggers flag (T20)", () => { params: {}, }, { - kind: "destroy-piece" as unknown as EffectPrimitiveNode["kind"], + kind: "spawn-marker-pair" as unknown as EffectPrimitiveNode["kind"], params: {}, }, ]; @@ -808,7 +815,7 @@ describe("move-gen suppressTriggers flag (T20)", () => { const nodes: EffectPrimitiveNode[] = [ { - kind: "destroy-piece" as unknown as EffectPrimitiveNode["kind"], + kind: "spawn-marker-pair" as unknown as EffectPrimitiveNode["kind"], params: {}, }, ]; diff --git a/packages/chess/src/schema.ts b/packages/chess/src/schema.ts index 4b796e9..649bfdb 100644 --- a/packages/chess/src/schema.ts +++ b/packages/chess/src/schema.ts @@ -335,6 +335,22 @@ export interface ChessAttrMap { * inner primitive list to run when a matching marker expires. */ OnMarkerExpireHooks: readonly OnMarkerExpireHookEntry[]; + /** + * T27 — `cancel-capture` inhibitor flag. Stored on `GAME_ENTITY`. + * Set by the `cancel-capture` primitive inside an on-captured arm + * to signal "the capture should be undone". Polled + retracted by + * the capture dispatcher AFTER `fireOnCapturedHooks` returns + * (apply.ts stage 4b) so the flag never leaks across moves or + * cascade boundaries. + * + * V1 wire-in scope: the flag is set + retracted; full restoration + * of defender facts is DEFERRED to T28+ because the existing + * capture pipeline retracts the defender BEFORE the on-captured + * trigger fires (see apply.ts stage-4 NOTE). Sibling primitives + * and the future capture-pipeline refactor consume this flag as + * the inhibitor signal. + */ + CaptureCancelled: boolean; } /**