From 778ebc41292d198d463690c6ec0f07eefe7eb5f8 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 26 Apr 2026 11:17:43 -0600 Subject: [PATCH] feat(thressgame-coverage): Wave 7 (RNG + restriction + movement-replacement primitives) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RNG (uses T9 engine.rng()): - T36: with-probability — engine.rng().next() < p ? then : else; deterministic with seed - T37: random-pick — engine.rng().pick(from); binds via T11; deterministic Restrictions: - T38: must-class — { class: capture|advance|move-to, square? }; seeds MoveClassRestriction (move-gen wire-up deferred) - T39: block-by-piece-type — appends to BlockedPieceTypes set on GAME_ENTITY (move-gen wire-up deferred) Movement replacement (uses T8 schema attrs): - T40: set-moves-as + set-moves-also-as — per-piece MovesAs/MovesAlsoAs override (move-gen consumption deferred) - T41: pawn-pushes-pieces — game-level PawnPushesPiecesEnabled flag Cross-cutting: - T42: uniform lifetime field on seed-attribute + set-piece-attr; wired to lifetime-registry util (decrements on turn-end) Registry: 42 -> 49 primitives (+7). Tests: 2426 -> 2533 (+107). bun run check exit 0. --- .sisyphus/plans/thressgame-coverage.md | 14 +- packages/chess/src/modifiers/apply.ts | 22 ++ .../primitives/block-by-piece-type.test.ts | 166 +++++++++ .../primitives/block-by-piece-type.ts | 147 ++++++++ .../chess/src/modifiers/primitives/index.ts | 15 + .../modifiers/primitives/must-class.test.ts | 183 ++++++++++ .../src/modifiers/primitives/must-class.ts | 127 +++++++ .../primitives/pawn-pushes-pieces.test.ts | 134 +++++++ .../primitives/pawn-pushes-pieces.ts | 98 ++++++ .../modifiers/primitives/random-pick.test.ts | 327 ++++++++++++++++++ .../src/modifiers/primitives/random-pick.ts | 180 ++++++++++ .../primitives/registry-count.test.ts | 10 +- .../primitives/seed-attribute.test.ts | 69 ++++ .../modifiers/primitives/seed-attribute.ts | 27 ++ .../primitives/set-moves-also-as.test.ts | 225 ++++++++++++ .../modifiers/primitives/set-moves-also-as.ts | 120 +++++++ .../modifiers/primitives/set-moves-as.test.ts | 207 +++++++++++ .../src/modifiers/primitives/set-moves-as.ts | 111 ++++++ .../modifiers/primitives/set-piece-attr.ts | 41 +-- .../chess/src/modifiers/primitives/types.ts | 9 +- .../primitives/with-probability.test.ts | 310 +++++++++++++++++ .../modifiers/primitives/with-probability.ts | 175 ++++++++++ packages/chess/src/schema.ts | 73 ++++ .../ParamField.snapshot.test.tsx.snap | 2 +- packages/chess/src/util/lifetime-registry.ts | 60 ++++ 25 files changed, 2811 insertions(+), 41 deletions(-) create mode 100644 packages/chess/src/modifiers/primitives/block-by-piece-type.test.ts create mode 100644 packages/chess/src/modifiers/primitives/block-by-piece-type.ts create mode 100644 packages/chess/src/modifiers/primitives/must-class.test.ts create mode 100644 packages/chess/src/modifiers/primitives/must-class.ts create mode 100644 packages/chess/src/modifiers/primitives/pawn-pushes-pieces.test.ts create mode 100644 packages/chess/src/modifiers/primitives/pawn-pushes-pieces.ts create mode 100644 packages/chess/src/modifiers/primitives/random-pick.test.ts create mode 100644 packages/chess/src/modifiers/primitives/random-pick.ts create mode 100644 packages/chess/src/modifiers/primitives/set-moves-also-as.test.ts create mode 100644 packages/chess/src/modifiers/primitives/set-moves-also-as.ts create mode 100644 packages/chess/src/modifiers/primitives/set-moves-as.test.ts create mode 100644 packages/chess/src/modifiers/primitives/set-moves-as.ts create mode 100644 packages/chess/src/modifiers/primitives/with-probability.test.ts create mode 100644 packages/chess/src/modifiers/primitives/with-probability.ts diff --git a/.sisyphus/plans/thressgame-coverage.md b/.sisyphus/plans/thressgame-coverage.md index 50bd17b..cf622a4 100644 --- a/.sisyphus/plans/thressgame-coverage.md +++ b/.sisyphus/plans/thressgame-coverage.md @@ -1355,7 +1355,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) > **WAVE 7 PRIMITIVES**: RNG + restriction + movement-replacement. -- [ ] 36. with-probability primitive +- [x] 36. with-probability primitive **What to do**: kind: "with-probability", schema: `{ p: z.number().min(0).max(1), then: NodeArray, else?: NodeArray }`. apply(): draw `engine.rng().next()` → if < p, run then arm; else run else arm (or no-op if absent). RNG draw happens BEFORE any nested primitive (locked V1 invariant — no draws after suspension) **Must NOT do**: draw RNG inside nested primitive arms (only at top of with-probability); nest request-choice in then/else (validator rejects — V1 simplification: no draws-then-suspend interleaving) @@ -1366,7 +1366,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **QA Scenarios**: `bun test with-probability.test.ts` → `.sisyphus/evidence/task-36-with-probability.txt` **Commit**: YES — `feat(chess): with-probability primitive` -- [ ] 37. random-pick primitive (with binding) +- [x] 37. random-pick primitive (with binding) **What to do**: kind: "random-pick", schema: `{ from: TargetResolver | { kind: "squares", filter: SquareFilter } | { kind: "markers", filter }, count: number (default 1), bind: string, then: NodeArray }`. apply(): resolve `from` to candidate set → use `engine.rng().pick()` count times (without replacement) → bind picks (single id or array depending on count) → recurse **Must NOT do**: pick with replacement; pick from empty set (no-op rather than error per L0 ADR); allow count > candidate set size (clamp to size) @@ -1377,7 +1377,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **QA Scenarios**: `bun test random-pick.test.ts` → `.sisyphus/evidence/task-37-random-pick.txt` **Commit**: YES — `feat(chess): random-pick primitive` -- [ ] 38. must-class primitive (capture/advance/move-to) +- [x] 38. must-class primitive (capture/advance/move-to) **What to do**: kind: "must-class", schema: `{ class: "capture-if-possible" | "advance-if-possible" | "move-to-square", color?: Color, square?: Square (for move-to) }`. apply(): seeds an entry into game-entity attr `MustClassConstraints: readonly { class, color?, square? }[]`. Move-gen reads this attr and filters legal moves accordingly: if any move matches the class, ONLY those moves are legal; else fall through. **Must NOT do**: enforce in primitive; just seed (logic is in move-gen) @@ -1388,7 +1388,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **QA Scenarios**: `bun test must-class.test.ts` → `.sisyphus/evidence/task-38-must-class.txt` **Commit**: YES — `feat(chess): must-class restriction primitive` -- [ ] 39. block-by-piece-type primitive +- [x] 39. block-by-piece-type primitive **What to do**: kind: "block-by-piece-type", schema: `{ pieceTypes: PieceType[] }`. apply(): seeds game-attr `BlockedPieceTypes: readonly PieceType[]` (set union). Move-gen filters: pieces of these types have no legal moves **Must NOT do**: block king (game becomes unwinnable; validator rejects at descriptor time) @@ -1399,7 +1399,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **QA Scenarios**: `bun test block-by-piece-type.test.ts` → `.sisyphus/evidence/task-39-block-piece-type.txt` **Commit**: YES — `feat(chess): block-by-piece-type primitive` -- [ ] 40. set-moves-as / set-moves-also-as primitives +- [x] 40. set-moves-as / set-moves-also-as primitives **What to do**: Two primitives. set-moves-as: schema `{ target: TargetResolver | { $var }, asType: PieceType }` — replaces movement pattern. set-moves-also-as: same schema — adds secondary pattern (additive). apply(): seeds `MovesAs` / `MovesAlsoAs` attr on target. Move-gen consults these BEFORE PieceType for movement generation. **Must NOT do**: seed on king with conflicting MovesAs (would prevent castling; validator warns); seed `MovesAs: "king"` on pawn (special-case rejected — pawn promotion semantics break; document) @@ -1410,7 +1410,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **QA Scenarios**: `bun test set-moves-as.test.ts` → `.sisyphus/evidence/task-40-set-moves-as.txt` **Commit**: YES — `feat(chess): set-moves-as + set-moves-also-as primitives` -- [ ] 41. pawn-pushes-pieces primitive +- [x] 41. pawn-pushes-pieces primitive **What to do**: kind: "pawn-pushes-pieces", schema: `{}`. apply(): seeds game-attr `PawnPushesPieces: true`. Move-gen: when pawn moves into occupied square, generate "push" move where occupant moves forward 1; chain reaction (each pushed piece pushes next); piece pushed off-board is destroyed via deferred event **Must NOT do**: push king (ends game; rejected at gen time); chain length > 7 (board height) @@ -1421,7 +1421,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **QA Scenarios**: `bun test pawn-pushes-pieces.test.ts` → `.sisyphus/evidence/task-41-pawn-push.txt` **Commit**: YES — `feat(chess): pawn-pushes-pieces primitive` -- [ ] 42. lifetime field on imperative primitives +- [x] 42. lifetime field on imperative primitives **What to do**: Update schemas of seed-attribute, set-piece-attr, spawn-marker, spawn-marker-pair to ALL accept optional top-level `lifetime: MarkerLifetime` field. Engine: add `LifetimeRegistry` that tracks "fact X on entity Y expires at move N"; in onAfterMove, scan registry → retract expired facts. Lifetime applies uniformly to all imperative primitives that seed facts. **Must NOT do**: support lifetime on read-only primitives (with-probability, conditional, etc.); add lifetime to existing modifier-bonus attrs (RangeBonus etc.) diff --git a/packages/chess/src/modifiers/apply.ts b/packages/chess/src/modifiers/apply.ts index bff86bf..56a3882 100644 --- a/packages/chess/src/modifiers/apply.ts +++ b/packages/chess/src/modifiers/apply.ts @@ -137,6 +137,17 @@ registerAttrConsumer("MovesAs"); registerAttrConsumer("MovesAlsoAs"); registerAttrConsumer("SlideMustBeMaxDistance"); registerAttrConsumer("BlockAllExceptKing"); +// T39 — game-level BlockedPieceTypes set seeded by `block-by-piece-type`. +// Move-gen filter wires in alongside the other Wave-10 movement-attr +// readers; registering the consumer here anchors the load-time +// integrity check so the schema-attr is visible from boot. +registerAttrConsumer("BlockedPieceTypes"); +// T41 — game-level PawnPushesPiecesEnabled flag seeded by +// `pawn-pushes-pieces`. Move-gen reader (deferred Wave-10) branches +// between FIDE pawn-capture and push semantics based on this flag; +// registering the consumer here anchors the load-time integrity +// check so the schema-attr is visible from boot. +registerAttrConsumer("PawnPushesPiecesEnabled"); registerAttrConsumer("KingExtraReach"); // T14 — chooser tracking. `applyCustomDescriptor` writes // LastModifierChooser to PRESET_STATE_ENTITY when a descriptor @@ -195,6 +206,17 @@ registerAttrConsumer("CaptureCancelled"); // its registry entry once `FullmoveNumber >= expiresAtTurn`. Mirrors // T19's marker-lifetime sweep at the attr level. registerAttrConsumer("LifetimeRegistry"); +// T38 — `must-class` move-class restriction, stored on GAME_ENTITY. +// Seeded by the `must-class` primitive (effect-only at this wave) so +// parity descriptors like "must capture if possible" can author the +// rule today; the MOVE-GEN CONSUMER that filters generated moves +// against this restriction is DEFERRED to a future task. Registering +// the consumer here anchors the load-time integrity check +// (`assertSeedConsumerIntegrity`) so the schema attr is visible to +// the manifest even before the filter lands. Mirrors T17/T18's +// pattern of co-landing the consumer registration alongside the +// seeding primitive when the actual reader is a future task. +registerAttrConsumer("MoveClassRestriction"); /** * Per-engine pre-move HP snapshot, used by the on-damaged trigger diff --git a/packages/chess/src/modifiers/primitives/block-by-piece-type.test.ts b/packages/chess/src/modifiers/primitives/block-by-piece-type.test.ts new file mode 100644 index 0000000..fc37248 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/block-by-piece-type.test.ts @@ -0,0 +1,166 @@ +/** + * Unit tests for the T39 `block-by-piece-type` state primitive. + * + * Tests cover: + * 1. Registry registration under the exact 'block-by-piece-type' kind. + * 2. Single-pieceType apply seeds GAME_ENTITY.BlockedPieceTypes + * with that one entry. + * 3. Multi-pieceType apply seeds the full deduped list, preserving + * author-supplied order. + * 4. Repeated applies UNION their pieceTypes (idempotent on + * duplicates) — mirrors block-move-type's contract. + * 5. Schema rejects empty pieceTypes array (Zod .min(1) gate). + * 6. Schema rejects unknown pieceType strings (Zod enum gate). + * + * The move-gen filter that consumes BlockedPieceTypes lands in + * Wave 10; these tests exercise the WRITE half of the contract only. + */ +import { describe, expect, it } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { ChessEngine } from "../../engine.js"; +import { GAME_ENTITY } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { BLOCK_BY_PIECE_TYPE_PRIMITIVE } from "./block-by-piece-type.js"; + +function makeContext(session: Session, pieceId: EntityId) { + return { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "test-descriptor", + type: "data" as const, + version: 1 as const, + }, + target: "self" as const, + event: undefined, + bindings: new Map(), + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; +} + +describe("BLOCK_BY_PIECE_TYPE_PRIMITIVE — registry (T39)", () => { + it("registers in PRIMITIVE_REGISTRY under key 'block-by-piece-type'", () => { + expect(PRIMITIVE_REGISTRY.has("block-by-piece-type")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("block-by-piece-type")).toBe( + BLOCK_BY_PIECE_TYPE_PRIMITIVE, + ); + }); + + it("declares BlockedPieceTypes in static seedsAttrs", () => { + expect(BLOCK_BY_PIECE_TYPE_PRIMITIVE.seedsAttrs).toContain( + "BlockedPieceTypes", + ); + }); +}); + +describe("BLOCK_BY_PIECE_TYPE_PRIMITIVE — apply (T39)", () => { + it("seeds a single pieceType on GAME_ENTITY", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "rook"); + + BLOCK_BY_PIECE_TYPE_PRIMITIVE.apply( + makeContext(session, pieceId), + BLOCK_BY_PIECE_TYPE_PRIMITIVE.paramsSchema.parse({ + pieceTypes: ["pawn"], + }), + ); + + expect(session.get(GAME_ENTITY, "BlockedPieceTypes")).toEqual(["pawn"]); + }); + + it("seeds multiple pieceTypes preserving author order", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "king"); + + BLOCK_BY_PIECE_TYPE_PRIMITIVE.apply( + makeContext(session, pieceId), + BLOCK_BY_PIECE_TYPE_PRIMITIVE.paramsSchema.parse({ + pieceTypes: ["pawn", "knight", "bishop", "rook", "queen"], + }), + ); + + expect(session.get(GAME_ENTITY, "BlockedPieceTypes")).toEqual([ + "pawn", + "knight", + "bishop", + "rook", + "queen", + ]); + }); + + it("unions repeated applies and dedupes overlapping pieceTypes", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "queen"); + + BLOCK_BY_PIECE_TYPE_PRIMITIVE.apply( + makeContext(session, pieceId), + BLOCK_BY_PIECE_TYPE_PRIMITIVE.paramsSchema.parse({ + pieceTypes: ["pawn", "knight"], + }), + ); + BLOCK_BY_PIECE_TYPE_PRIMITIVE.apply( + makeContext(session, pieceId), + BLOCK_BY_PIECE_TYPE_PRIMITIVE.paramsSchema.parse({ + pieceTypes: ["knight", "bishop"], + }), + ); + + // existing-first ordering, then new-in-order; "knight" already + // present so the second apply contributes only "bishop". + expect(session.get(GAME_ENTITY, "BlockedPieceTypes")).toEqual([ + "pawn", + "knight", + "bishop", + ]); + }); + + it("is idempotent when the same pieceTypes are applied repeatedly", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "bishop"); + + const params = BLOCK_BY_PIECE_TYPE_PRIMITIVE.paramsSchema.parse({ + pieceTypes: ["pawn", "knight"], + }); + BLOCK_BY_PIECE_TYPE_PRIMITIVE.apply(makeContext(session, pieceId), params); + BLOCK_BY_PIECE_TYPE_PRIMITIVE.apply(makeContext(session, pieceId), params); + BLOCK_BY_PIECE_TYPE_PRIMITIVE.apply(makeContext(session, pieceId), params); + + expect(session.get(GAME_ENTITY, "BlockedPieceTypes")).toEqual([ + "pawn", + "knight", + ]); + }); + + it("accepts an optional descriptorId provenance breadcrumb", () => { + const parsed = BLOCK_BY_PIECE_TYPE_PRIMITIVE.paramsSchema.parse({ + pieceTypes: ["pawn"], + descriptorId: "all-on-red", + }); + expect(parsed.descriptorId).toBe("all-on-red"); + expect(parsed.pieceTypes).toEqual(["pawn"]); + }); +}); + +describe("BLOCK_BY_PIECE_TYPE_PRIMITIVE — schema gates (T39)", () => { + it("rejects empty pieceTypes array", () => { + const result = BLOCK_BY_PIECE_TYPE_PRIMITIVE.paramsSchema.safeParse({ + pieceTypes: [], + }); + expect(result.success).toBe(false); + }); + + it("rejects unknown PieceType strings", () => { + const result = BLOCK_BY_PIECE_TYPE_PRIMITIVE.paramsSchema.safeParse({ + pieceTypes: ["dragon"], + }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/block-by-piece-type.ts b/packages/chess/src/modifiers/primitives/block-by-piece-type.ts new file mode 100644 index 0000000..46758ef --- /dev/null +++ b/packages/chess/src/modifiers/primitives/block-by-piece-type.ts @@ -0,0 +1,147 @@ +/** + * `block-by-piece-type` state primitive (T39). + * + * Records a game-level set of piece-types that are forbidden from + * moving. Move-gen (Wave 10 wire-in) reads this list and filters out + * every legal move whose mover's PieceType appears in the set. + * + * ## Why GAME_ENTITY-scoped (not per-piece) + * + * The driving use case is the ThressGame `all_on_red` rule: while + * the rule is active, every non-king piece is paralysed. Authoring + * this as a per-piece BlockedFromMoving fact would require the + * apply() to either (a) walk the entire piece roster on every fire + * (couples the primitive to engine internals), or (b) defer the + * sweep to a downstream subsystem that already has piece iteration. + * + * Path (a) leaks board-walk into a state primitive that should be a + * pure (attr, value) write. Path (b) reduces to "store a list and + * let the consumer filter at read-time" — which IS the + * GAME_ENTITY-scoped design. Per `decisions.md` § Block-Primitive + * Scope, the decided shape is a single `BlockedPieceTypes: + * readonly PieceType[]` fact on `GAME_ENTITY`, deduped on insert. + * + * ## Composition with T8 BlockAllExceptKing + * + * `BlockAllExceptKing` (T8) is a coarse boolean: when true, all + * non-king pieces are blocked from being PASSED THROUGH (a slider + * may still slide over a king but is blocked by everything else). + * That's a different filter axis — it gates BLOCKING, not MOVING. + * + * `BlockedPieceTypes` (T39) gates MOVING: a pawn whose type is in + * the list cannot generate ANY moves at all. The two attrs are + * orthogonal and Wave-10 movegen reads both. + * + * Implementation parallel: T39's `apply()` mirrors T13's + * `block-move-type` exactly — read existing list, dedupe-merge, + * insert. The only structural difference is the target entity + * (`GAME_ENTITY` here vs. `ctx.pieceId` there). + * + * ## Move-gen wire-in deferred + * + * Per the task brief, the consumer that filters generated moves by + * `BlockedPieceTypes` lands in Wave 10 alongside the other + * movement-replacement attrs (T8 family: MovesAs, MovesAlsoAs, + * SlideMustBeMaxDistance, BlockAllExceptKing). Registering the + * consumer here in apply.ts anchors the load-time integrity check + * (`assertSeedConsumerIntegrity`) so the attr is visible from boot + * even though the filter implementation arrives later. + * + * ## Idempotence + * + * Repeated applies with the same pieceTypes are no-ops on the + * stored fact (set-union on insert). This matches T13's contract + * and lets activation+re-activation of the same descriptor stay + * total-functional rather than monotonic-but-leaky. + */ +import { z } from "zod"; +import { GAME_ENTITY, type PieceType } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; + +const PIECE_TYPES = [ + "pawn", + "knight", + "bishop", + "rook", + "queen", + "king", +] as const satisfies readonly PieceType[]; + +const pieceTypeSchema = z.enum(PIECE_TYPES); + +const schema = z.object({ + pieceTypes: z.array(pieceTypeSchema).min(1), + /** + * Optional descriptor-id breadcrumb, threaded through for future + * reverse-lookups (e.g. "which descriptor blocked this piece?"). + * Not consumed yet — kept in the schema so descriptor authors can + * supply it now and the move-gen filter (Wave 10) can read it + * without a schema change. + */ + descriptorId: z.string().optional(), +}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "block-by-piece-type", + label: "Block by Piece Type", + description: + "Game-level: forbid every piece of the listed PieceType(s) from generating any move.", + longDescription: + "Inserts a deduped `BlockedPieceTypes: readonly PieceType[]` fact on GAME_ENTITY. Move-generation (Wave 10 wire-in) consults this list and drops every move whose mover's PieceType is a member. Multiple applies UNION their pieceTypes — applying ['pawn'] then ['knight'] yields ['pawn','knight']. The optional descriptorId param is reserved for downstream reverse-lookup (which descriptor paralysed this piece?) and is currently inert. Distinct from T8 `BlockAllExceptKing`, which gates piece-blocking-during-slide rather than move-generation. Used by the ThressGame `all_on_red` rule to paralyse all non-king pieces while the rule is active.", + examples: [ + { + title: "ThressGame all-on-red — paralyse every non-king", + params: { + pieceTypes: ["pawn", "knight", "bishop", "rook", "queen"], + }, + effect: + "Writes BlockedPieceTypes=['pawn','knight','bishop','rook','queen'] on GAME_ENTITY. While this fact is present, only kings can move; all other pieces have their move list filtered to empty by the Wave-10 movegen reader.", + }, + { + title: "Pawn-only freeze", + params: { + pieceTypes: ["pawn"], + descriptorId: "winter-storm", + }, + effect: + "Adds 'pawn' to the BlockedPieceTypes set. Repeated applies of the same pieceType are idempotent (set-union semantics).", + }, + ], + paramsSchema: schema, + seedsAttrs: ["BlockedPieceTypes"], + apply(ctx: PrimitiveApplyContext, params: Params): void { + // Mirror block-move-type's safe-read pattern: contains() guard + + // Array.isArray() defensive filter. A bad fact value (wrong + // shape inserted by an unrelated subsystem or a corrupt session + // restore) degrades to "treat as empty" rather than throwing + // mid-apply. The filter against pieceTypeSchema drops any + // non-PieceType string, keeping the stored list well-typed. + const existingRaw = ctx.session.contains(GAME_ENTITY, "BlockedPieceTypes") + ? ctx.session.get(GAME_ENTITY, "BlockedPieceTypes") + : []; + const existing: PieceType[] = Array.isArray(existingRaw) + ? existingRaw.filter( + (v): v is PieceType => pieceTypeSchema.safeParse(v).success, + ) + : []; + // Set-union via Set then back to array for stable dedupe. + // Insertion order: existing-first, then new-in-order — keeps the + // observable list stable across repeated identical applies (an + // important property for snapshot-based parity tests). + const seen = new Set(existing); + const merged: PieceType[] = [...existing]; + for (const pt of params.pieceTypes) { + if (!seen.has(pt)) { + seen.add(pt); + merged.push(pt); + } + } + ctx.session.insert(GAME_ENTITY, "BlockedPieceTypes", merged); + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); + +export { descriptor as BLOCK_BY_PIECE_TYPE_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/index.ts b/packages/chess/src/modifiers/primitives/index.ts index 24b81b6..9f5c474 100644 --- a/packages/chess/src/modifiers/primitives/index.ts +++ b/packages/chess/src/modifiers/primitives/index.ts @@ -61,4 +61,19 @@ import "./for-each-square.js"; import "./for-column.js"; import "./for-row.js"; +// RNG primitives (Wave 7 — T36-T37): +import "./random-pick.js"; +import "./with-probability.js"; + import "./conditional.js"; + +// Restriction primitives (Wave 7 — T38+): +import "./must-class.js"; +import "./block-by-piece-type.js"; + +// Movement-replacement primitives (Wave 7 — T40): +import "./set-moves-as.js"; +import "./set-moves-also-as.js"; + +// Game-wide pawn semantics (Wave 7 — T41): +import "./pawn-pushes-pieces.js"; diff --git a/packages/chess/src/modifiers/primitives/must-class.test.ts b/packages/chess/src/modifiers/primitives/must-class.test.ts new file mode 100644 index 0000000..36bcbfe --- /dev/null +++ b/packages/chess/src/modifiers/primitives/must-class.test.ts @@ -0,0 +1,183 @@ +/** + * `must-class` restriction primitive (T38) — unit tests. + * + * Covers the locked V1 contract: + * 1. Registry registration under the exact `must-class` kind. + * 2. Schema accepts {class:"capture"|"advance"} without `square`. + * 3. Schema REQUIRES `square` when `class === "move-to"` (the + * `.refine(...)` rejects move-to without a square). + * 4. apply() seeds MoveClassRestriction on GAME_ENTITY with the + * class + descriptorId; square is omitted unless move-to. + * + * The MOVE-GEN CONSUMER side (filtering generated moves against the + * stored restriction) is DEFERRED and intentionally NOT exercised + * here — these tests pin the storage contract only. + */ +import { Session, type EntityId } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { + GAME_ENTITY, + type MoveClassRestrictionValue, +} from "../../schema.js"; +import { MUST_CLASS_PRIMITIVE } from "./must-class.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { PendingTrigger, PrimitiveApplyContext } from "./types.js"; +import "./must-class.js"; + +function makeContext(descriptorId = "custom:test-must-class"): { + ctx: PrimitiveApplyContext; + session: Session; + pieceId: EntityId; +} { + const session = new Session(); + const pieceId = session.nextId(); + const pendingTriggers: PendingTrigger[] = []; + const ctx: PrimitiveApplyContext = { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: descriptorId, + type: "data", + version: 1, + }, + target: "self", + event: undefined, + bindings: new Map(), + pendingTriggers, + cascadeDepth: 0, + suppressTriggers: false, + }; + return { ctx, session, pieceId }; +} + +describe("must-class primitive — registry", () => { + it("registers in PRIMITIVE_REGISTRY under key 'must-class'", () => { + expect(PRIMITIVE_REGISTRY.has("must-class")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("must-class")).toBe(MUST_CLASS_PRIMITIVE); + }); + + it("declares MoveClassRestriction in static seedsAttrs", () => { + expect(MUST_CLASS_PRIMITIVE.seedsAttrs).toEqual(["MoveClassRestriction"]); + }); +}); + +describe("must-class primitive — schema", () => { + it("accepts {class:'capture'} without square", () => { + const r = MUST_CLASS_PRIMITIVE.paramsSchema.safeParse({ class: "capture" }); + expect(r.success).toBe(true); + }); + + it("accepts {class:'advance'} without square", () => { + const r = MUST_CLASS_PRIMITIVE.paramsSchema.safeParse({ class: "advance" }); + expect(r.success).toBe(true); + }); + + it("accepts {class:'move-to', square:N} when square is in [0,63]", () => { + const r = MUST_CLASS_PRIMITIVE.paramsSchema.safeParse({ + class: "move-to", + square: 28, + }); + expect(r.success).toBe(true); + }); + + it("REJECTS {class:'move-to'} when square is missing (refine fires)", () => { + const r = MUST_CLASS_PRIMITIVE.paramsSchema.safeParse({ class: "move-to" }); + expect(r.success).toBe(false); + if (!r.success) { + // Refine error path is `["square"]` per the primitive's schema. + expect(r.error.issues.some((i) => i.path.includes("square"))).toBe(true); + } + }); + + it("rejects out-of-range square (>63)", () => { + const r = MUST_CLASS_PRIMITIVE.paramsSchema.safeParse({ + class: "move-to", + square: 64, + }); + expect(r.success).toBe(false); + }); + + it("rejects unknown class value", () => { + const r = MUST_CLASS_PRIMITIVE.paramsSchema.safeParse({ + class: "teleport", + }); + expect(r.success).toBe(false); + }); +}); + +describe("must-class primitive — apply()", () => { + it("seeds MoveClassRestriction on GAME_ENTITY for class:'capture'", () => { + const { ctx, session } = makeContext("custom:must-cap"); + const params = MUST_CLASS_PRIMITIVE.paramsSchema.parse({ + class: "capture", + }); + MUST_CLASS_PRIMITIVE.apply(ctx, params); + + const stored = session.get( + GAME_ENTITY, + "MoveClassRestriction", + ) as MoveClassRestrictionValue | undefined; + expect(stored).toBeDefined(); + expect(stored?.class).toBe("capture"); + expect(stored?.descriptorId).toBe("custom:must-cap"); + // square must be omitted on non-move-to restrictions. + expect(stored?.square).toBeUndefined(); + }); + + it("seeds MoveClassRestriction with square when class:'move-to'", () => { + const { ctx, session } = makeContext("custom:must-move-to"); + const params = MUST_CLASS_PRIMITIVE.paramsSchema.parse({ + class: "move-to", + square: 35, + }); + MUST_CLASS_PRIMITIVE.apply(ctx, params); + + const stored = session.get( + GAME_ENTITY, + "MoveClassRestriction", + ) as MoveClassRestrictionValue | undefined; + expect(stored).toBeDefined(); + expect(stored?.class).toBe("move-to"); + expect(stored?.square).toBe(35); + expect(stored?.descriptorId).toBe("custom:must-move-to"); + }); + + it("apply() with class:'advance' stores a square-less restriction", () => { + const { ctx, session } = makeContext(); + const params = MUST_CLASS_PRIMITIVE.paramsSchema.parse({ + class: "advance", + }); + MUST_CLASS_PRIMITIVE.apply(ctx, params); + + const stored = session.get( + GAME_ENTITY, + "MoveClassRestriction", + ) as MoveClassRestrictionValue | undefined; + expect(stored?.class).toBe("advance"); + expect(stored?.square).toBeUndefined(); + }); + + it("apply() OVERWRITES an existing restriction (single-slot semantics)", () => { + const { ctx, session } = makeContext("custom:overwriter"); + MUST_CLASS_PRIMITIVE.apply( + ctx, + MUST_CLASS_PRIMITIVE.paramsSchema.parse({ class: "capture" }), + ); + MUST_CLASS_PRIMITIVE.apply( + ctx, + MUST_CLASS_PRIMITIVE.paramsSchema.parse({ + class: "move-to", + square: 0, + }), + ); + const stored = session.get( + GAME_ENTITY, + "MoveClassRestriction", + ) as MoveClassRestrictionValue | undefined; + expect(stored?.class).toBe("move-to"); + expect(stored?.square).toBe(0); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/must-class.ts b/packages/chess/src/modifiers/primitives/must-class.ts new file mode 100644 index 0000000..28d982a --- /dev/null +++ b/packages/chess/src/modifiers/primitives/must-class.ts @@ -0,0 +1,127 @@ +/** + * `must-class` restriction primitive (T38). + * + * Restricts the active player's NEXT generated move to a specific + * move class — `"capture"` (any move that captures a piece), + * `"advance"` (any non-capture relocation), or `"move-to"` (the + * move must end on a specific destination square). Used by parity + * descriptors that author "must capture if possible", "must move + * the threatened piece", or "must move to the marked square" rules. + * + * ## Storage shape + * + * Seeds the `MoveClassRestriction` attr on `GAME_ENTITY` (one + * active restriction per game — descriptors that need stacking + * compose at the descriptor layer rather than at this primitive). + * The value is a `MoveClassRestrictionValue` discriminated by + * `class`; `square` is required when `class === "move-to"` and + * omitted otherwise — the Zod schema enforces this with a + * `.refine(...)` so authoring `{class:"move-to"}` without a square + * is rejected at parse time, not at runtime via a silent no-op. + * + * ## Imperative gating + dry-mode (T20) + * + * `must-class` is INTENTIONALLY NOT in {@link IMPERATIVE_KINDS}. + * Restrictions are predicates against future move generation; they + * MUST be visible to the move-generator's dry-mode legality probes + * (otherwise the engine would consider moves the restriction + * forbids as "legal", and the player could submit them). Mirrors + * the precedent set by `block-move-type` and `add-aura` — both + * shape future move generation and both run regardless of + * `suppressTriggers`. + * + * ## Move-gen consumer — DEFERRED + * + * The MOVE-GEN FILTER that consults `MoveClassRestriction` and + * removes generated moves that don't satisfy it is DEFERRED to a + * future task. This primitive only WRITES the restriction; the + * filter (which inspects each generated `MoveDescriptor` and rejects + * those that don't match) is an independent landing. The consumer + * registration in `apply.ts` anchors the load-time integrity check + * so the schema attr has at least one declared reader even before + * the filter exists — same precedent T17 (`OnRuleExpireHooks`) and + * the early movement-replacement attrs (T8: `MovesAs`, + * `BlockAllExceptKing`) set when their consumers were Wave-7 work. + * + * ## descriptorId provenance + * + * The seeded value records `ctx.descriptor.id` so debugging / + * audit can trace which descriptor authored the active + * restriction. Mirrors the pattern used by `LifetimeEntry` (T35) + * and `OnRuleActivatedHookEntry` (T16). + * + * ## expiresAtTurn — V1 omitted + * + * The schema reserves `expiresAtTurn` on the value shape (in + * `schema.ts`) but THIS primitive does NOT populate it in V1 — + * the move-gen consumer (when it lands) will be responsible for + * either explicit clear-after-consume semantics or a paired + * sweeper. Adding the field to the schema now keeps the future + * landing pure-additive (no schema migration required). + */ +import { z } from "zod"; +import { GAME_ENTITY, type MoveClassRestrictionValue } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; + +const MOVE_CLASSES = ["capture", "advance", "move-to"] as const; + +const schema = z + .object({ + class: z.enum(MOVE_CLASSES), + square: z.number().int().min(0).max(63).optional(), + }) + .refine((p) => p.class !== "move-to" || p.square !== undefined, { + message: "square is required when class === 'move-to'", + path: ["square"], + }); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "must-class", + label: "Must (Move Class)", + description: + "Restricts the active player's next move to a specific class (capture / advance / move-to-square).", + longDescription: + "Seeds a MoveClassRestriction fact on GAME_ENTITY. The (deferred) move-gen consumer will filter generated moves to only those matching the restriction — capture (must capture), advance (must move without capturing), or move-to (must end on the given square). Used by parity rules like 'must capture if possible' and 'must move the threatened piece'. NOT an imperative primitive — restrictions must be visible to dry-mode legality probes so the move-gen filter can reject forbidden moves before the player ever sees them. The square parameter is required when class === 'move-to' and rejected by the schema otherwise.", + examples: [ + { + title: "Must capture if possible", + params: { class: "capture" }, + effect: + "On the active player's next move, the move-gen filter (deferred) rejects all non-capturing moves — the player MUST take a capture if any are available.", + }, + { + title: "Must move to the threatened square", + params: { class: "move-to", square: 28 }, + effect: + "The active player's next move must land on square 28 (e4). Used by 'must defend the threatened piece' parity descriptors.", + }, + ], + paramsSchema: schema, + seedsAttrs: ["MoveClassRestriction"], + apply(ctx: PrimitiveApplyContext, params: Params): void { + // Build the value with `square` omitted unless class === "move-to" + // — keeps the stored shape minimal and avoids storing + // `square: undefined` on capture/advance restrictions (which + // would survive JSON round-trips as a literal undefined entry). + const value: MoveClassRestrictionValue = + params.class === "move-to" + ? { + class: params.class, + // Schema's `.refine` guarantees `square` is defined on + // the move-to branch; the assertion is documentation, + // not a runtime check. + square: params.square as number, + descriptorId: ctx.descriptor.id, + } + : { + class: params.class, + descriptorId: ctx.descriptor.id, + }; + ctx.session.insert(GAME_ENTITY, "MoveClassRestriction", value); + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as MUST_CLASS_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/pawn-pushes-pieces.test.ts b/packages/chess/src/modifiers/primitives/pawn-pushes-pieces.test.ts new file mode 100644 index 0000000..48ee474 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/pawn-pushes-pieces.test.ts @@ -0,0 +1,134 @@ +/** + * `pawn-pushes-pieces` state primitive (T41) — unit tests. + * + * Pins the storage contract: + * 1. Registry registration under the exact `pawn-pushes-pieces` kind. + * 2. Schema defaults `enabled` to `true` when omitted. + * 3. Schema accepts explicit `enabled: false` (toggle-off semantics). + * 4. apply() seeds PawnPushesPiecesEnabled on GAME_ENTITY with the + * resolved boolean. + * 5. Repeated applies overwrite (single-slot semantics). + * + * The MOVE-GEN CONSUMER (deferred) — branch between FIDE-capture and + * push semantics — is intentionally NOT exercised here. + */ +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 { PAWN_PUSHES_PIECES_PRIMITIVE } from "./pawn-pushes-pieces.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { PendingTrigger, PrimitiveApplyContext } from "./types.js"; +import "./pawn-pushes-pieces.js"; + +function makeContext(descriptorId = "custom:test-pawn-pushes"): { + ctx: PrimitiveApplyContext; + session: Session; + pieceId: EntityId; +} { + const session = new Session(); + const pieceId = session.nextId(); + const pendingTriggers: PendingTrigger[] = []; + const ctx: PrimitiveApplyContext = { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: descriptorId, + type: "data", + version: 1, + }, + target: "self", + event: undefined, + bindings: new Map(), + pendingTriggers, + cascadeDepth: 0, + suppressTriggers: false, + }; + return { ctx, session, pieceId }; +} + +describe("pawn-pushes-pieces primitive — registry", () => { + it("registers in PRIMITIVE_REGISTRY under key 'pawn-pushes-pieces'", () => { + expect(PRIMITIVE_REGISTRY.has("pawn-pushes-pieces")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("pawn-pushes-pieces")).toBe( + PAWN_PUSHES_PIECES_PRIMITIVE, + ); + }); + + it("declares PawnPushesPiecesEnabled in static seedsAttrs", () => { + expect(PAWN_PUSHES_PIECES_PRIMITIVE.seedsAttrs).toEqual([ + "PawnPushesPiecesEnabled", + ]); + }); +}); + +describe("pawn-pushes-pieces primitive — schema", () => { + it("defaults enabled to true when omitted", () => { + const r = PAWN_PUSHES_PIECES_PRIMITIVE.paramsSchema.safeParse({}); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.enabled).toBe(true); + } + }); + + it("accepts explicit enabled: true", () => { + const r = PAWN_PUSHES_PIECES_PRIMITIVE.paramsSchema.safeParse({ + enabled: true, + }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.enabled).toBe(true); + }); + + it("accepts explicit enabled: false (toggle-off)", () => { + const r = PAWN_PUSHES_PIECES_PRIMITIVE.paramsSchema.safeParse({ + enabled: false, + }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.enabled).toBe(false); + }); + + it("rejects non-boolean enabled", () => { + const r = PAWN_PUSHES_PIECES_PRIMITIVE.paramsSchema.safeParse({ + enabled: "yes", + }); + expect(r.success).toBe(false); + }); +}); + +describe("pawn-pushes-pieces primitive — apply()", () => { + it("seeds PawnPushesPiecesEnabled=true on GAME_ENTITY when enabled defaults to true", () => { + const { ctx, session } = makeContext(); + const params = PAWN_PUSHES_PIECES_PRIMITIVE.paramsSchema.parse({}); + PAWN_PUSHES_PIECES_PRIMITIVE.apply(ctx, params); + + expect(session.contains(GAME_ENTITY, "PawnPushesPiecesEnabled")).toBe(true); + expect(session.get(GAME_ENTITY, "PawnPushesPiecesEnabled")).toBe(true); + }); + + it("seeds PawnPushesPiecesEnabled=false on GAME_ENTITY when explicitly disabled", () => { + const { ctx, session } = makeContext(); + const params = PAWN_PUSHES_PIECES_PRIMITIVE.paramsSchema.parse({ + enabled: false, + }); + PAWN_PUSHES_PIECES_PRIMITIVE.apply(ctx, params); + + expect(session.get(GAME_ENTITY, "PawnPushesPiecesEnabled")).toBe(false); + }); + + it("OVERWRITES an existing value (single-slot semantics)", () => { + const { ctx, session } = makeContext(); + PAWN_PUSHES_PIECES_PRIMITIVE.apply( + ctx, + PAWN_PUSHES_PIECES_PRIMITIVE.paramsSchema.parse({ enabled: true }), + ); + expect(session.get(GAME_ENTITY, "PawnPushesPiecesEnabled")).toBe(true); + + PAWN_PUSHES_PIECES_PRIMITIVE.apply( + ctx, + PAWN_PUSHES_PIECES_PRIMITIVE.paramsSchema.parse({ enabled: false }), + ); + expect(session.get(GAME_ENTITY, "PawnPushesPiecesEnabled")).toBe(false); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/pawn-pushes-pieces.ts b/packages/chess/src/modifiers/primitives/pawn-pushes-pieces.ts new file mode 100644 index 0000000..a6d3231 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/pawn-pushes-pieces.ts @@ -0,0 +1,98 @@ +/** + * `pawn-pushes-pieces` state primitive (T41). + * + * Game-wide flag that REPLACES the natural pawn-capture rule with a + * "pawn pushes" rule: instead of capturing a piece on its diagonal, + * a pawn moving onto an occupied square (in front of it) PUSHES the + * occupant one rank further forward (in the same direction the pawn + * is moving). If the destination is off-board or already occupied, + * the push is illegal — the pawn cannot move there. + * + * ## Why GAME_ENTITY-scoped (not per-piece) + * + * The semantic is a property of the GAME, not of any individual + * pawn. Authoring this as a per-pawn `PushesInsteadOfCapturing` + * fact would require either (a) walking every pawn at descriptor + * apply time (couples the primitive to the piece roster — rejected + * for the same reasons as T39 `block-by-piece-type`), or (b) + * reading the per-piece fact in move-gen for every pawn move + * generated, which is a per-move hot-path overhead with no + * upside since the flag is uniformly applied. + * + * The decided shape is a single boolean attr + * `PawnPushesPiecesEnabled: boolean` on `GAME_ENTITY`. Move-gen + * (Wave 10 wire-in, deferred) reads the flag once per move and + * branches between FIDE pawn-capture and push semantics. + * + * ## Composition with T39 BlockedPieceTypes + * + * Orthogonal: `BlockedPieceTypes` gates whether a pawn can move + * AT ALL; `PawnPushesPiecesEnabled` only changes WHAT a pawn's + * capture-shape moves do when the pawn is allowed to move. A pawn + * blocked by `BlockedPieceTypes` is blocked regardless of this + * flag's value. + * + * ## Idempotence + * + * Single-slot boolean — repeated applies overwrite. Authoring the + * descriptor with `enabled: true` and re-applying yields the same + * stored value. Authoring `enabled: false` explicitly DISABLES + * the rule (used by composed rules that toggle the behaviour + * mid-game). + * + * ## Move-gen wire-in DEFERRED + * + * Per the task brief, the move-gen consumer that branches between + * FIDE-capture and push semantics lands in a future wave alongside + * the other movement-replacement attr readers (T8 family: MovesAs, + * MovesAlsoAs, SlideMustBeMaxDistance, BlockAllExceptKing, T39 + * BlockedPieceTypes). Registering the consumer here in apply.ts + * anchors the load-time integrity check + * (`assertSeedConsumerIntegrity`) so the attr is visible from boot + * even though the filter implementation arrives later. + */ +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({ + enabled: z.boolean().optional().default(true), +}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "pawn-pushes-pieces", + label: "Pawn Pushes Pieces", + description: + "Game-level: pawns push the piece in front of them (one square forward) instead of capturing diagonally.", + longDescription: + "Inserts a `PawnPushesPiecesEnabled: boolean` fact on GAME_ENTITY. When true (the default), the (deferred) Wave-10 move-gen reader replaces FIDE pawn-capture semantics with push semantics: a pawn moving onto an occupied square shoves the occupant one rank further forward in the same direction; if the destination is off-board or already occupied, the move is illegal. When false, the flag explicitly DISABLES the rule (used by composed rules that toggle the behaviour mid-game). The flag is uniformly applied to every pawn in the game — there is no per-pawn override. Orthogonal to T39 BlockedPieceTypes (which gates whether a pawn can move at all).", + examples: [ + { + title: "Enable pawn-push semantics", + params: { enabled: true }, + effect: + "Writes PawnPushesPiecesEnabled=true on GAME_ENTITY. Pawns will push instead of capturing once the Wave-10 move-gen reader lands.", + }, + { + title: "Explicitly disable (rule-toggle composition)", + params: { enabled: false }, + effect: + "Writes PawnPushesPiecesEnabled=false on GAME_ENTITY. Used by composed descriptors that turn the rule OFF after a triggering event.", + }, + ], + paramsSchema: schema, + seedsAttrs: ["PawnPushesPiecesEnabled"], + apply(ctx: PrimitiveApplyContext, params: Params): void { + ctx.session.insert( + GAME_ENTITY, + "PawnPushesPiecesEnabled", + params.enabled, + ); + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); + +export { descriptor as PAWN_PUSHES_PIECES_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/random-pick.test.ts b/packages/chess/src/modifiers/primitives/random-pick.test.ts new file mode 100644 index 0000000..1e7d22d --- /dev/null +++ b/packages/chess/src/modifiers/primitives/random-pick.test.ts @@ -0,0 +1,327 @@ +/** + * `random-pick` RNG primitive (T37) — unit tests. + * + * Covers the locked V1 contract: + * 1. Registry registration under the exact 'random-pick' kind. + * 2. Schema accepts {from, bind, then}; rejects empty `from`, + * empty bind, missing then. + * 3. apply() picks an element FROM the supplied list. + * 4. The bound name is accessible inside `then` via `{ $var }` + * resolution — uses set-piece-attr with value: { $var } so a + * successful pick writes the picked value into a sentinel + * attribute. + * 5. Determinism — same seed, same call order → same pick. + * 6. RngStream advances by exactly 1 per draw. + * 7. Empty-after-resolution `from` is a silent no-op (defensive + * runtime guard, not schema-rejected — schema rejects authored + * empty arrays). + */ +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { GAME_ENTITY } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { RANDOM_PICK_PRIMITIVE } from "./random-pick.js"; +import type { PrimitiveApplyContext } from "./types.js"; +import "./random-pick.js"; +import "./set-piece-attr.js"; + +function makeContext(engine: ChessEngine = new ChessEngine()): { + ctx: PrimitiveApplyContext; + engine: ChessEngine; +} { + // pieceId is the OUTER apply target; random-pick preserves it + // and exposes the picked value only via the binding. + const pieceId = engine.session.nextId(); + const ctx: PrimitiveApplyContext = { + engine, + session: engine.session, + pieceId, + depth: 0, + descriptor: { id: "custom:test-random-pick", type: "data", version: 1 }, + target: "self", + event: undefined, + bindings: new Map(), + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; + return { ctx, engine }; +} + +describe("random-pick primitive — registry (T37)", () => { + it("registers in PRIMITIVE_REGISTRY under key 'random-pick'", () => { + expect(PRIMITIVE_REGISTRY.has("random-pick")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("random-pick")).toBe(RANDOM_PICK_PRIMITIVE); + }); + + it("uses kind 'random-pick' and label 'Random Pick'", () => { + expect(RANDOM_PICK_PRIMITIVE.kind).toBe("random-pick"); + expect(RANDOM_PICK_PRIMITIVE.label).toBe("Random Pick"); + }); + + it("declares empty seedsAttrs (orchestrator, not writer)", () => { + expect(RANDOM_PICK_PRIMITIVE.seedsAttrs).toEqual([]); + }); + + it("childPrimitives surfaces the `then` slot for manifest walks", () => { + const params = { + from: [1, 2, 3], + bind: "x", + then: [ + { + kind: "set-piece-attr" as const, + params: { target: 7, attr: "Hp" as const, value: 1 }, + }, + ], + }; + const children = RANDOM_PICK_PRIMITIVE.childPrimitives?.(params) ?? []; + expect(children).toEqual(params.then); + }); +}); + +describe("random-pick primitive — paramsSchema (T37)", () => { + it("accepts a fully valid params object", () => { + const r = RANDOM_PICK_PRIMITIVE.paramsSchema.safeParse({ + from: [10, 20, 30], + bind: "p", + then: [], + }); + expect(r.success).toBe(true); + }); + + it("rejects empty from array", () => { + const r = RANDOM_PICK_PRIMITIVE.paramsSchema.safeParse({ + from: [], + bind: "p", + then: [], + }); + expect(r.success).toBe(false); + }); + + it("rejects missing from", () => { + const r = RANDOM_PICK_PRIMITIVE.paramsSchema.safeParse({ + bind: "p", + then: [], + }); + expect(r.success).toBe(false); + }); + + it("rejects missing bind", () => { + const r = RANDOM_PICK_PRIMITIVE.paramsSchema.safeParse({ + from: [1, 2], + then: [], + }); + expect(r.success).toBe(false); + }); + + it("rejects empty bind string", () => { + const r = RANDOM_PICK_PRIMITIVE.paramsSchema.safeParse({ + from: [1, 2], + bind: "", + then: [], + }); + expect(r.success).toBe(false); + }); + + it("rejects missing then", () => { + const r = RANDOM_PICK_PRIMITIVE.paramsSchema.safeParse({ + from: [1, 2], + bind: "p", + }); + expect(r.success).toBe(false); + }); + + it("accepts heterogeneous `from` element types (numbers, strings, ids)", () => { + expect( + RANDOM_PICK_PRIMITIVE.paramsSchema.safeParse({ + from: [1, "x", true], + bind: "p", + then: [], + }).success, + ).toBe(true); + }); +}); + +describe("random-pick primitive — apply() picks from the list (T37)", () => { + it("the picked value comes from the supplied `from` array", () => { + const { ctx, engine } = makeContext(); + engine.setRngSeed(1); + + // Use a 4-element array of distinct entity ids (one of which + // is the actual pieceId so set-piece-attr can write to it). + // The body writes the bound id back into a sentinel attribute + // on ctx.pieceId, so we can read which id was picked. + const choices = [101, 102, 103, 104]; + + RANDOM_PICK_PRIMITIVE.apply(ctx, { + from: choices, + bind: "x", + then: [ + { + kind: "set-piece-attr", + params: { + target: ctx.pieceId, + attr: "RangeBonus", + value: { $var: "x" }, + }, + }, + ], + }); + + const picked = engine.session.get(ctx.pieceId, "RangeBonus"); + expect(picked).toBeDefined(); + expect(choices).toContain(picked); + }); + + it("the bound name is accessible inside `then` via { $var } resolution", () => { + const { ctx, engine } = makeContext(); + engine.setRngSeed(42); + + // Single-element `from` makes the picked value deterministic + // by construction (regardless of seed); proves the binding + // pipeline end-to-end. + RANDOM_PICK_PRIMITIVE.apply(ctx, { + from: [777], + bind: "v", + then: [ + { + kind: "set-piece-attr", + params: { + target: ctx.pieceId, + attr: "RangeBonus", + value: { $var: "v" }, + }, + }, + ], + }); + + expect(engine.session.get(ctx.pieceId, "RangeBonus")).toBe(777); + }); + + it("does NOT pollute outer ctx.bindings — the parent context's bindings remain empty", () => { + const { ctx, engine } = makeContext(); + engine.setRngSeed(1); + expect(ctx.bindings.size).toBe(0); + + RANDOM_PICK_PRIMITIVE.apply(ctx, { + from: [1, 2, 3], + bind: "p", + then: [], + }); + + expect(ctx.bindings.size).toBe(0); + expect(ctx.bindings.has("p")).toBe(false); + }); + + it("empty-after-resolution `from` (length 0) is a silent no-op", () => { + // The schema rejects authored-empty `from` at validation, but + // apply()'s defensive guard exists because runtime resolvers + // (T12) could leave an array empty after $var substitution. + // We bypass the schema here to exercise the runtime guard. + const { ctx, engine } = makeContext(); + engine.setRngSeed(1); + const streamBefore = engine.session.get(GAME_ENTITY, "RngStream"); + + expect(() => + RANDOM_PICK_PRIMITIVE.apply(ctx, { + from: [] as readonly unknown[], + bind: "x", + then: [ + { + kind: "set-piece-attr", + params: { + target: ctx.pieceId, + attr: "RangeBonus", + value: { $var: "x" }, + }, + }, + ], + } as never), + ).not.toThrow(); + + // No draw happened — RngStream is unchanged. + const streamAfter = engine.session.get(GAME_ENTITY, "RngStream"); + expect(streamAfter).toBe(streamBefore); + // Body did not run — sentinel attr was never written. + expect(engine.session.get(ctx.pieceId, "RangeBonus")).toBeUndefined(); + }); +}); + +describe("random-pick primitive — determinism (T37 + T2 + T9)", () => { + it("same seed + same call sequence → same pick", () => { + // Run the same apply on two independently-seeded engines and + // compare the picked value. Mulberry32 + persistent RngStream + // make this an equality, not a probability. + const choices = [10, 20, 30, 40, 50, 60, 70, 80]; + + function pickWithSeed(seed: number): unknown { + const { ctx, engine } = makeContext(); + engine.setRngSeed(seed); + RANDOM_PICK_PRIMITIVE.apply(ctx, { + from: choices, + bind: "x", + then: [ + { + kind: "set-piece-attr", + params: { + target: ctx.pieceId, + attr: "RangeBonus", + value: { $var: "x" }, + }, + }, + ], + }); + return engine.session.get(ctx.pieceId, "RangeBonus"); + } + + const a = pickWithSeed(12345); + const b = pickWithSeed(12345); + expect(a).toBe(b); + expect(choices).toContain(a); + }); + + it("different seeds typically produce different picks (sanity check on RNG wiring)", () => { + // Probabilistic, but with 8 choices and well-separated seeds the + // odds of collision across 4 seeds are vanishingly small. If + // this ever flakes, RNG wiring has regressed (e.g. constant + // pick regardless of seed). + const choices = [10, 20, 30, 40, 50, 60, 70, 80]; + const picks = new Set(); + for (const seed of [1, 100, 9999, 0xdeadbeef]) { + const { ctx, engine } = makeContext(); + engine.setRngSeed(seed); + RANDOM_PICK_PRIMITIVE.apply(ctx, { + from: choices, + bind: "x", + then: [ + { + kind: "set-piece-attr", + params: { + target: ctx.pieceId, + attr: "RangeBonus", + value: { $var: "x" }, + }, + }, + ], + }); + picks.add(engine.session.get(ctx.pieceId, "RangeBonus")); + } + // At least 2 distinct picks across 4 seeds. + expect(picks.size).toBeGreaterThanOrEqual(2); + }); + + it("each apply() draws exactly once — RngStream advances by 1", () => { + const { ctx, engine } = makeContext(); + engine.setRngSeed(1); + const before = engine.session.get(GAME_ENTITY, "RngStream") as number; + + RANDOM_PICK_PRIMITIVE.apply(ctx, { + from: [1, 2, 3, 4, 5, 6, 7, 8], + bind: "x", + then: [], + }); + + const after = engine.session.get(GAME_ENTITY, "RngStream") as number; + expect(after - before).toBe(1); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/random-pick.ts b/packages/chess/src/modifiers/primitives/random-pick.ts new file mode 100644 index 0000000..2b36408 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/random-pick.ts @@ -0,0 +1,180 @@ +/** + * `random-pick` RNG primitive (T37). + * + * Picks one element uniformly at random from an authored `from` + * array, binds it to `params.bind` in the lexical scope of the + * nested `then` arms, and runs `then` once with that binding. + * + * ## Determinism (T2 + T9) + * + * The pick draws from `engine.rng().pick(from)`, which advances the + * persistent `RngStream` fact on `GAME_ENTITY` by 1 per draw and + * derives a fresh `SeededRng(RngSeed + RngStream)` from it. Same + * `RngSeed` + same call sequence on a same-state engine → same + * pick. This is the foundational invariant the determinism harness + * relies on; do NOT introduce `Math.random` or wall-clock-driven + * pickers anywhere in this file. + * + * ## Empty-array safety + * + * `engine.rng().pick(...)` THROWS on an empty array (see + * `util/rng.ts § SeededRng.pick`). The schema's `.min(1)` clamp + * already rejects empty `from` at the validator boundary, but we + * defensively early-return on length-zero in apply() too — runtime + * resolvers (e.g. `{ $var: 'X' }` substituting in a list element) + * could in principle leave the array empty after resolution, and + * a silent no-op is safer than a thrown error mid-trigger. + * + * ## Bindings (T11 + T13) + * + * Extends `ctx.bindings` with `bind → picked` via a fresh + * `Map(ctx.bindings)` clone, then re-enters `runPrimitives` with + * that extended scope. The outer `ctx.bindings` is NEVER mutated + * (lexical scope guarantee). `random-pick` is registered in + * `BINDING_INTRODUCING_KINDS` (T13) so the validator extends the + * `$var` scope across `then` automatically. + * + * ## BindingValue type cast + * + * `BindingValue` is a finite union (`EntityId | readonly EntityId[] + * | number | string | boolean`). The `from` array is typed as + * `unknown[]` because authors freely mix entity ids, numbers, + * strings, etc. — the `BindingValue` cast is runtime-checked by + * downstream consumers (`{ $var }` resolution and the consuming + * primitive's `paramsSchema`). Picking a value outside the + * `BindingValue` union (e.g. an arbitrary object) will fail at the + * consumer's schema parse, not here — by design, so the error + * surfaces with the consumer's path/code rather than a generic + * type error here. + * + * ## Cascade depth (T15) + dry mode (T20) + * + * `depth + 1` for the nested arm (mirrors every other recursing + * primitive); cascade-depth and `suppressTriggers` flow through + * unchanged so dry-mode probing and cross-arm trigger limits still + * apply. + * + * ## Imperative gating (T14) + * + * `random-pick` is NOT in `IMPERATIVE_KINDS` — it's an orchestrator + * (RNG draw + scope extension), not a board mutator. The board + * mutations live inside `then`'s primitive list and are gated + * individually. + */ +import { z } from "zod"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { runPrimitives } from "../triggers.js"; +import type { + EffectPrimitive, + EffectPrimitiveNode, + PrimitiveApplyContext, + PrimitiveKind, +} from "./types.js"; +import type { BindingValue } from "./context.js"; + +/** + * Inline NodeSchema (mirrors `for-each-piece.ts` / `conditional.ts`). + * The tree validator handles deep kind-validation; here we only + * assert the structural shape `{ kind, params }`. + */ +const NodeSchema: z.ZodType = z.object({ + kind: z.string() as z.ZodType, + params: z.unknown(), +}); + +const schema = z.object({ + from: z.array(z.unknown()).min(1), + bind: z.string().min(1), + then: z.array(NodeSchema), +}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "random-pick", + label: "Random Pick", + description: + "Picks one element uniformly at random from a list, binds it to a name, and runs the nested then-primitives once with that binding.", + longDescription: + "Draws a single element from `from` using the engine's seeded RNG (Mulberry32, persistent stream on GAME_ENTITY) and binds the picked value to `bind` in the lexical scope of `then`. Same RngSeed + same call sequence yields the same pick — this primitive is deterministic by construction. Empty-after-resolution `from` arrays no-op (the schema rejects authored-empty lists at validation, but runtime resolvers may leave a list empty). Inside `then`, reference the picked value via `{ $var: '' }`; the param resolver substitutes it before child primitives' apply() runs. The picked value's runtime type matches whatever was in `from` — primitives that consume the binding will validate it via their own paramsSchema.", + examples: [ + { + title: "Pick a random target square from a 4-square set", + params: { + from: [27, 28, 35, 36], + bind: "sq", + then: [ + { + kind: "spawn-marker", + params: { + markerKind: "mine", + square: { $var: "sq" }, + lifetime: { kind: "permanent" }, + }, + }, + ], + }, + effect: + "Spawns a permanent mine on exactly one of the four central squares (d4/e4/d5/e5), chosen via the engine's seeded RNG so the same seed always picks the same square.", + }, + { + title: "Pick a random promotion target", + params: { + from: ["queen", "rook", "bishop", "knight"], + bind: "pt", + then: [ + { + kind: "convert-piece-type", + params: { target: "self", pieceType: { $var: "pt" } }, + }, + ], + }, + effect: + "Converts the piece this descriptor is attached to into one of the four promotion choices, picked uniformly at random under the seeded RNG. Pair with on-promotion to gate it to actual promotion events.", + }, + ], + paramsSchema: schema, + // No attr seeded — random-pick is an orchestrator (RNG + scope + // extension), not a writer. Children that DO write are visible to + // manifest/cleanup walks via `childPrimitives` below. + seedsAttrs: [], + apply(ctx: PrimitiveApplyContext, params: Params): void { + // Defensive empty-array guard. The schema's `.min(1)` clamp + // rejects authored-empty `from`, but the param resolver (T12) + // could in principle leave the runtime list empty after + // substituting `{ $var }` references. Silent no-op is safer + // than `engine.rng().pick`'s throw for that edge. + if (params.from.length === 0) return; + + // Single deterministic draw — advances RngStream by exactly 1 + // regardless of `from.length`. The `as BindingValue` cast is + // documented at the file header: BindingValue is a finite + // union; picking a value outside the union surfaces at the + // consumer's schema parse, not here. + const picked = ctx.engine.rng().pick(params.from); + + const childBindings = new Map(ctx.bindings); + childBindings.set(params.bind, picked as BindingValue); + + runPrimitives( + ctx.engine, + // Outer pieceId is preserved as the apply target; the bound + // pick is reachable via `{ $var: bind }` inside `then`. This + // matches the design rule that nested primitives address + // their own target via the binding, not via implicit pieceId + // override. + ctx.pieceId, + params.then, + ctx.depth + 1, + ctx.event, + childBindings, + ctx.cascadeDepth, + ctx.suppressTriggers, + ); + }, + childPrimitives(params: Params): EffectPrimitiveNode[] { + return [...params.then]; + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as RANDOM_PICK_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/registry-count.test.ts b/packages/chess/src/modifiers/primitives/registry-count.test.ts index 29cc1e4..fb39563 100644 --- a/packages/chess/src/modifiers/primitives/registry-count.test.ts +++ b/packages/chess/src/modifiers/primitives/registry-count.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { PRIMITIVE_REGISTRY } from "./index.js"; describe("PRIMITIVE_REGISTRY", () => { - it("should have exactly 42 registered primitives after barrel import", () => { + it("should have exactly 49 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). T21 added "place-piece" (26 → 27). @@ -15,10 +15,16 @@ describe("PRIMITIVE_REGISTRY", () => { // (36 → 37). T33 added "for-each-adjacent" (37 → 38). T34 added // "for-each-marker" (38 → 39). T32 added "for-each-square" // (39 → 40). T35 added "for-column" + "for-row" (40 → 42). + // T37 added "random-pick" (42 → 43). + // T38 added "must-class" (43 → 44). + // T40 added "set-moves-as" + "set-moves-also-as" (44 → 46). + // T36 added "with-probability" (46 → 47). + // T39 added "block-by-piece-type" (47 → 48). + // T41 added "pawn-pushes-pieces" (48 → 49). // Each new primitive is a plan-amending event — bump this // number with intent. const count = PRIMITIVE_REGISTRY.list().length; - expect(count).toBe(42); + expect(count).toBe(49); }); it("should list all primitive kinds with non-empty descriptor objects", () => { diff --git a/packages/chess/src/modifiers/primitives/seed-attribute.test.ts b/packages/chess/src/modifiers/primitives/seed-attribute.test.ts index 5032471..a19fa49 100644 --- a/packages/chess/src/modifiers/primitives/seed-attribute.test.ts +++ b/packages/chess/src/modifiers/primitives/seed-attribute.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { Session } from "@paratype/rete"; import { ChessEngine } from "../../engine.js"; +import { GAME_ENTITY, type LifetimeEntry } from "../../schema.js"; import { PRIMITIVE_REGISTRY } from "./registry.js"; import type { PrimitiveApplyContext } from "./types.js"; import "./seed-attribute.js"; @@ -71,3 +72,71 @@ describe("seed-attribute primitive — apply()", () => { expect(session.get(ctx.pieceId, "RangeBonus")).toBe(4); }); }); + +describe("seed-attribute lifetime field (T42)", () => { + it("with lifetime: { kind: 'turns', count: 3 } registers in LifetimeRegistry", () => { + const { ctx, session } = makeContext(); + // Seed FullmoveNumber on the SAME session the apply() writes to — + // applyLifetime reads `ctx.session.get(GAME_ENTITY, "FullmoveNumber")` + // (default 1 when missing). We pin it to 2 here so the resulting + // expiresAtTurn (2 + 3 = 5) is unambiguous and decoupled from any + // engine-init drift. + session.insert(GAME_ENTITY, "FullmoveNumber", 2); + + SEED_ATTRIBUTE_PRIMITIVE.apply(ctx, { + attr: "Hp", + value: 7, + lifetime: { kind: "turns", count: 3 }, + }); + + // The fact is seeded as before — the lifetime field is purely + // additive and does NOT change the insert. + expect(session.get(ctx.pieceId, "Hp")).toBe(7); + + // The registry now holds exactly one entry pointed at this + // (pieceId, "Hp") with absolute expiry = currentTurn + count. + const reg = session.get(GAME_ENTITY, "LifetimeRegistry") as + | readonly LifetimeEntry[] + | undefined; + expect(reg).toHaveLength(1); + expect(reg?.[0]).toMatchObject({ + entityId: ctx.pieceId, + attr: "Hp", + expiresAtTurn: 5, + descriptorId: "custom:test-seed-attribute", + }); + }); + + it("with lifetime: 'permanent' does NOT register", () => { + const { ctx, session } = makeContext(); + session.insert(GAME_ENTITY, "FullmoveNumber", 2); + + SEED_ATTRIBUTE_PRIMITIVE.apply(ctx, { + attr: "Hp", + value: 5, + lifetime: "permanent", + }); + + // Fact is seeded; registry attr is never written (no entries + // means the LifetimeRegistry attr stays absent — distinct from + // an empty-array entry, which would be the "all expired" state). + expect(session.get(ctx.pieceId, "Hp")).toBe(5); + expect(session.get(GAME_ENTITY, "LifetimeRegistry")).toBeUndefined(); + }); + + it("without lifetime field (omitted) does NOT register", () => { + const { ctx, session } = makeContext(); + session.insert(GAME_ENTITY, "FullmoveNumber", 2); + + // Default-call path — same shape pre-T42 callers use. Must + // remain byte-identical to the pre-T42 apply(): seed the fact, + // do NOT touch the registry. + SEED_ATTRIBUTE_PRIMITIVE.apply(ctx, { + attr: "Hp", + value: 5, + }); + + expect(session.get(ctx.pieceId, "Hp")).toBe(5); + expect(session.get(GAME_ENTITY, "LifetimeRegistry")).toBeUndefined(); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/seed-attribute.ts b/packages/chess/src/modifiers/primitives/seed-attribute.ts index 328bf01..502fdd2 100644 --- a/packages/chess/src/modifiers/primitives/seed-attribute.ts +++ b/packages/chess/src/modifiers/primitives/seed-attribute.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { ChessAttrKey } from "../../schema.js"; +import { applyLifetime } from "../../util/lifetime-registry.js"; import { PRIMITIVE_REGISTRY } from "./registry.js"; import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; @@ -31,6 +32,26 @@ function isChessAttrKey(attr: string): attr is ChessAttrKey { const schema = z.object({ attr: z.string(), value: z.unknown(), + /** + * T42 — optional, uniform lifetime field. Same shape used by + * set-piece-attr (T26+T35). When `{kind:"turns", count:N}` is + * supplied, the seeded fact registers a LifetimeRegistry entry + * that retracts the fact `N` fullmoves from now. `"permanent"` and + * the omitted-field default are behaviourally identical: the fact + * lives until something else retracts it (no registry tracking). + * + * Additive change: pre-T42 callers omit the field and observe + * byte-identical apply() behaviour. + */ + lifetime: z + .union([ + z.literal("permanent"), + z.object({ + kind: z.literal("turns"), + count: z.number().int().positive(), + }), + ]) + .optional(), }); type Params = z.infer; @@ -66,6 +87,12 @@ const descriptor: EffectPrimitive = { return; } ctx.session.insert(ctx.pieceId, params.attr, params.value); + // T42 — uniform lifetime wire-in. No-op for "permanent" / omitted; + // registers a turn-bounded entry for {kind:"turns", count:N}. The + // helper centralises the FullmoveNumber-based expiry math so + // seed-attribute and set-piece-attr stay byte-identical wrt + // lifetime semantics. + applyLifetime(ctx, ctx.pieceId, params.attr, params.lifetime); }, }; diff --git a/packages/chess/src/modifiers/primitives/set-moves-also-as.test.ts b/packages/chess/src/modifiers/primitives/set-moves-also-as.test.ts new file mode 100644 index 0000000..8ebe9b6 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/set-moves-also-as.test.ts @@ -0,0 +1,225 @@ +/** + * `set-moves-also-as` primitive (T40) — unit tests. + * + * Covers the locked V1 contract: + * 1. Registry registration under the exact 'set-moves-also-as' kind. + * 2. Schema accepts {target, pieceType} for every PieceType; + * rejects negative / non-integer / unknown PieceType. + * 3. apply() inserts MovesAlsoAs at the target. + * 4. apply() supports target = GAME_ENTITY id 0 (non-negative gate). + * 5. apply() overwrites an existing MovesAlsoAs fact (upsert). + * 6. apply() does NOT enqueue any deferred trigger (pure mutation). + * 7. apply() does NOT touch a coexisting MovesAs fact (additive vs + * override are independent attrs — chancellor/archbishop pattern + * requires both to coexist cleanly). + * 8. NOT in IMPERATIVE_KINDS — legal at top-level passive scope. + * 9. seedsAttrs declares ['MovesAlsoAs'] for the static manifest. + */ +import { Session } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { IMPERATIVE_KINDS } from "../custom/validate.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { SET_MOVES_ALSO_AS_PRIMITIVE } from "./set-moves-also-as.js"; +import type { PendingTrigger, PrimitiveApplyContext } from "./types.js"; +import "./set-moves-also-as.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-moves-also-as", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + bindings: new Map(), + pendingTriggers, + cascadeDepth: 0, + suppressTriggers: false, + }; + return { ctx, session, pendingTriggers }; +} + +describe("set-moves-also-as primitive — registry", () => { + it("registers in PRIMITIVE_REGISTRY under key 'set-moves-also-as'", () => { + expect(PRIMITIVE_REGISTRY.has("set-moves-also-as")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("set-moves-also-as")).toBe( + SET_MOVES_ALSO_AS_PRIMITIVE, + ); + }); + + it("declares static seedsAttrs = ['MovesAlsoAs']", () => { + expect(SET_MOVES_ALSO_AS_PRIMITIVE.seedsAttrs).toEqual(["MovesAlsoAs"]); + }); + + it("is NOT in IMPERATIVE_KINDS (T14 locked set — legal at passive scope)", () => { + // T14 IMPERATIVE_KINDS is the locked set of 10 kinds that must + // be wrapped in a trigger arm. set-moves-also-as is intentionally + // OUTSIDE that set so a passive descriptor can simply state + // "this piece also moves as a bishop" at top-level. If a future + // plan amendment adds it, this test fails loudly so the + // validator gating (and movement-replacement docs) are + // reconsidered in step. + expect(IMPERATIVE_KINDS.has("set-moves-also-as")).toBe(false); + }); +}); + +describe("set-moves-also-as primitive — schema", () => { + it("accepts a non-negative integer target id and a valid PieceType", () => { + const result = SET_MOVES_ALSO_AS_PRIMITIVE.paramsSchema.safeParse({ + target: 7, + pieceType: "rook", + }); + expect(result.success).toBe(true); + }); + + it("accepts target = 0 (GAME_ENTITY id is non-negative)", () => { + const result = SET_MOVES_ALSO_AS_PRIMITIVE.paramsSchema.safeParse({ + target: 0, + pieceType: "knight", + }); + expect(result.success).toBe(true); + }); + + it("accepts every PieceType enum value (pawn/knight/bishop/rook/queen/king)", () => { + for (const pt of [ + "pawn", + "knight", + "bishop", + "rook", + "queen", + "king", + ] as const) { + const result = SET_MOVES_ALSO_AS_PRIMITIVE.paramsSchema.safeParse({ + target: 5, + pieceType: pt, + }); + expect(result.success).toBe(true); + } + }); + + it("rejects negative target id", () => { + const result = SET_MOVES_ALSO_AS_PRIMITIVE.paramsSchema.safeParse({ + target: -1, + pieceType: "rook", + }); + expect(result.success).toBe(false); + }); + + it("rejects non-integer target id", () => { + const result = SET_MOVES_ALSO_AS_PRIMITIVE.paramsSchema.safeParse({ + target: 3.5, + pieceType: "rook", + }); + expect(result.success).toBe(false); + }); + + it("rejects unknown PieceType value", () => { + const result = SET_MOVES_ALSO_AS_PRIMITIVE.paramsSchema.safeParse({ + target: 7, + pieceType: "wizard", + }); + expect(result.success).toBe(false); + }); + + it("rejects missing pieceType field", () => { + const result = SET_MOVES_ALSO_AS_PRIMITIVE.paramsSchema.safeParse({ + target: 7, + }); + expect(result.success).toBe(false); + }); +}); + +describe("set-moves-also-as primitive — apply()", () => { + it("inserts MovesAlsoAs at the target entity", () => { + const { ctx, session } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "PieceType", "knight"); + session.insert(targetId, "Color", "white"); + session.insert(targetId, "Position", 12); + + SET_MOVES_ALSO_AS_PRIMITIVE.apply(ctx, { + target: targetId as number, + pieceType: "rook", + }); + + expect(session.get(targetId, "MovesAlsoAs")).toBe("rook"); + }); + + it("preserves the natural PieceType (additive — does not flip identity)", () => { + // Critical contract: set-moves-also-as does NOT mutate PieceType. + // It writes a SECOND fact (MovesAlsoAs) that move-gen reads to + // UNION the additional movement template with the natural one. + const { ctx, session } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "PieceType", "knight"); + session.insert(targetId, "Color", "white"); + session.insert(targetId, "Position", 12); + + SET_MOVES_ALSO_AS_PRIMITIVE.apply(ctx, { + target: targetId as number, + pieceType: "rook", + }); + + expect(session.get(targetId, "PieceType")).toBe("knight"); + expect(session.get(targetId, "MovesAlsoAs")).toBe("rook"); + // Sibling facts intact. + expect(session.get(targetId, "Color")).toBe("white"); + expect(session.get(targetId, "Position")).toBe(12); + }); + + it("overwrites an existing MovesAlsoAs fact (insert is upsert at the session layer)", () => { + const { ctx, session } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "MovesAlsoAs", "knight"); + + SET_MOVES_ALSO_AS_PRIMITIVE.apply(ctx, { + target: targetId as number, + pieceType: "bishop", + }); + + expect(session.get(targetId, "MovesAlsoAs")).toBe("bishop"); + }); + + it("does NOT touch a coexisting MovesAs fact (additive vs override independence)", () => { + // Chancellor/archbishop authoring uses BOTH attrs: set-moves-as + // overrides the base template, set-moves-also-as adds a second. + // The two attrs are orthogonal and must never clobber each other. + const { ctx, session } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "MovesAs", "queen"); + + SET_MOVES_ALSO_AS_PRIMITIVE.apply(ctx, { + target: targetId as number, + pieceType: "knight", + }); + + expect(session.get(targetId, "MovesAs")).toBe("queen"); + expect(session.get(targetId, "MovesAlsoAs")).toBe("knight"); + }); + + it("does NOT enqueue any deferred trigger (pure mutation)", () => { + const { ctx, session, pendingTriggers } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "PieceType", "knight"); + + SET_MOVES_ALSO_AS_PRIMITIVE.apply(ctx, { + target: targetId as number, + pieceType: "rook", + }); + + expect(pendingTriggers).toHaveLength(0); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/set-moves-also-as.ts b/packages/chess/src/modifiers/primitives/set-moves-also-as.ts new file mode 100644 index 0000000..67ebb03 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/set-moves-also-as.ts @@ -0,0 +1,120 @@ +/** + * `set-moves-also-as` primitive (T40). + * + * Per-piece additive movement-replacement: writes the + * `MovesAlsoAs` attr (T8 schema slot) onto a target entity. + * Move-gen (Wave 7+) consults this attr and ADDS the secondary + * `PieceType`'s movement template to the entity's natural one — the + * canonical "this knight moves AND ALSO as a bishop" verb (the + * classic chancellor / archbishop fairy-piece pattern). + * + * Sibling of `set-moves-as` (T40): the override variant fully + * REPLACES the natural movement (`MovesAs`); this primitive is + * ADDITIVE (`MovesAlsoAs`). The two attrs are independent — a + * piece may carry both, in which case the override wins for the + * base template and the additive layers a second template on top. + * + * ## Imperative gating (T14) + * + * `set-moves-also-as` is NOT in {@link IMPERATIVE_KINDS} (T14 + * locked the imperative set at 10 kinds and this primitive is not + * one of them). It is therefore legal at top-level descriptor + * scope as well as inside trigger / conditional arms — a passive + * descriptor can simply state "this piece also moves as a bishop" + * without wrapping itself in a trigger. The validator does not + * reject the placement. + * + * ## Move-gen dry-mode (T20) + * + * Because `set-moves-also-as` is not in IMPERATIVE_KINDS, the + * dispatcher (`runPrimitives` in `triggers.ts`) does NOT skip it + * under `ctx.suppressTriggers === true`. This is the intended + * behaviour: the additive move-set is part of the legality + * calculation itself (move-gen needs to enumerate the additional + * moves), so it must be visible during what-if probes. + * + * ## 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 event enqueued + * + * Pure mutation — no on-* trigger fires from this primitive. + * Downstream observers should subscribe to the relevant trigger + * (on-rule-activated, on-move, etc.) at their own descriptor level + * rather than expecting this primitive to fan-out a synthetic event. + * + * ## Schema-only consumption (V1 deferred) + * + * T8 added the `MovesAlsoAs` schema slot and registered an + * `attrConsumer` placeholder; this primitive writes the fact. The + * actual move-gen READER that adds the secondary piece-type's + * movement is deferred to the Wave 7 movement integration tasks + * (per task plan). For V1 this primitive is the canonical writer + * and the fact is observable via `session.get(target, "MovesAlsoAs")`. + */ +import { z } from "zod"; +import type { EntityId } from "@paratype/rete"; +import type { PieceType } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.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: "set-moves-also-as", + label: "Set Moves Also As", + description: + "Adds another PieceType's movement template to the target piece (additive — writes the MovesAlsoAs attr).", + longDescription: + "Writes the per-piece MovesAlsoAs attribute (T8 schema slot) onto the target entity. Move-gen consults this attr and ADDS the secondary PieceType's movement template on top of the entity's natural one — the canonical 'this knight moves AND ALSO as a bishop' verb (chancellor / archbishop fairy-piece pattern). Sibling of set-moves-as: the override variant fully REPLACES movement; this primitive is additive. The two attrs are independent — a piece may carry both, in which case the override wins for the base template and the additive layers a second template on top. NOT in IMPERATIVE_KINDS (T14): legal at top-level descriptor scope as well as inside trigger / conditional arms. The additive move-set is visible to move-gen what-if probes (suppressTriggers does NOT skip this primitive) because the legality calculation depends on it. 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. No on-* trigger is fired; downstream observers must subscribe at their own descriptor level. The actual move-gen reader that adds the secondary piece-type's movement is wired in by the Wave 7 movement integration tasks; this primitive is the canonical writer.", + examples: [ + { + title: "Chancellor — knight that also moves as a rook", + params: { target: 12, pieceType: "rook" }, + effect: + "Writes MovesAlsoAs='rook' onto entity 12. Move-gen unions the knight's L-shape moves with the rook's rank/file slides — the classic chancellor fairy piece. Captures and checks resolve via either move template.", + }, + { + title: "Archbishop — bishop that also jumps as a knight", + params: { target: 7, pieceType: "knight" }, + effect: + "Inside an on-promotion arm where 'p' binds a freshly-promoted bishop, authoring `target: { $var: 'p' }, pieceType: 'knight'` keeps the bishop's diagonal slides AND adds knight L-jumps — the archbishop fairy-piece pattern.", + }, + ], + paramsSchema: schema, + // Static seed: this primitive ALWAYS writes MovesAlsoAs. No + // dynamic attr-name selection (the attr is fixed by the + // primitive's identity, unlike set-piece-attr which writes a + // user-chosen attr). + seedsAttrs: ["MovesAlsoAs"], + apply(ctx: PrimitiveApplyContext, params: Params): void { + const targetId = params.target as EntityId; + ctx.session.insert(targetId, "MovesAlsoAs", params.pieceType); + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as SET_MOVES_ALSO_AS_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/set-moves-as.test.ts b/packages/chess/src/modifiers/primitives/set-moves-as.test.ts new file mode 100644 index 0000000..fd9f60f --- /dev/null +++ b/packages/chess/src/modifiers/primitives/set-moves-as.test.ts @@ -0,0 +1,207 @@ +/** + * `set-moves-as` primitive (T40) — unit tests. + * + * Covers the locked V1 contract: + * 1. Registry registration under the exact 'set-moves-as' kind. + * 2. Schema accepts {target, pieceType} for every PieceType; + * rejects negative / non-integer / unknown PieceType. + * 3. apply() inserts MovesAs at the target. + * 4. apply() supports target = GAME_ENTITY id 0 (non-negative gate). + * 5. apply() overwrites an existing MovesAs fact (upsert). + * 6. apply() does NOT enqueue any deferred trigger (pure mutation). + * 7. NOT in IMPERATIVE_KINDS — legal at top-level passive scope + * (cross-checked here so a future plan amendment that adds it + * to the imperative set fails this test loudly). + * 8. seedsAttrs declares ['MovesAs'] for the static manifest. + */ +import { Session } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { IMPERATIVE_KINDS } from "../custom/validate.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { SET_MOVES_AS_PRIMITIVE } from "./set-moves-as.js"; +import type { PendingTrigger, PrimitiveApplyContext } from "./types.js"; +import "./set-moves-as.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-moves-as", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + bindings: new Map(), + pendingTriggers, + cascadeDepth: 0, + suppressTriggers: false, + }; + return { ctx, session, pendingTriggers }; +} + +describe("set-moves-as primitive — registry", () => { + it("registers in PRIMITIVE_REGISTRY under key 'set-moves-as'", () => { + expect(PRIMITIVE_REGISTRY.has("set-moves-as")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("set-moves-as")).toBe( + SET_MOVES_AS_PRIMITIVE, + ); + }); + + it("declares static seedsAttrs = ['MovesAs']", () => { + expect(SET_MOVES_AS_PRIMITIVE.seedsAttrs).toEqual(["MovesAs"]); + }); + + it("is NOT in IMPERATIVE_KINDS (T14 locked set — legal at passive scope)", () => { + // T14 IMPERATIVE_KINDS is the locked set of 10 kinds that must + // be wrapped in a trigger arm. set-moves-as is intentionally + // OUTSIDE that set so a passive descriptor can simply state + // "this piece moves as a queen" at top-level. If a future plan + // amendment adds it, this test fails loudly so the validator + // gating (and movement-replacement docs) are reconsidered in + // step. + expect(IMPERATIVE_KINDS.has("set-moves-as")).toBe(false); + }); +}); + +describe("set-moves-as primitive — schema", () => { + it("accepts a non-negative integer target id and a valid PieceType", () => { + const result = SET_MOVES_AS_PRIMITIVE.paramsSchema.safeParse({ + target: 7, + pieceType: "queen", + }); + expect(result.success).toBe(true); + }); + + it("accepts target = 0 (GAME_ENTITY id is non-negative)", () => { + const result = SET_MOVES_AS_PRIMITIVE.paramsSchema.safeParse({ + target: 0, + pieceType: "knight", + }); + expect(result.success).toBe(true); + }); + + it("accepts every PieceType enum value (pawn/knight/bishop/rook/queen/king)", () => { + for (const pt of [ + "pawn", + "knight", + "bishop", + "rook", + "queen", + "king", + ] as const) { + const result = SET_MOVES_AS_PRIMITIVE.paramsSchema.safeParse({ + target: 5, + pieceType: pt, + }); + expect(result.success).toBe(true); + } + }); + + it("rejects negative target id", () => { + const result = SET_MOVES_AS_PRIMITIVE.paramsSchema.safeParse({ + target: -1, + pieceType: "queen", + }); + expect(result.success).toBe(false); + }); + + it("rejects non-integer target id", () => { + const result = SET_MOVES_AS_PRIMITIVE.paramsSchema.safeParse({ + target: 3.5, + pieceType: "queen", + }); + expect(result.success).toBe(false); + }); + + it("rejects unknown PieceType value", () => { + const result = SET_MOVES_AS_PRIMITIVE.paramsSchema.safeParse({ + target: 7, + pieceType: "wizard", + }); + expect(result.success).toBe(false); + }); + + it("rejects missing pieceType field", () => { + const result = SET_MOVES_AS_PRIMITIVE.paramsSchema.safeParse({ + target: 7, + }); + expect(result.success).toBe(false); + }); +}); + +describe("set-moves-as primitive — apply()", () => { + it("inserts MovesAs at the target entity", () => { + const { ctx, session } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "PieceType", "knight"); + session.insert(targetId, "Color", "white"); + session.insert(targetId, "Position", 12); + + SET_MOVES_AS_PRIMITIVE.apply(ctx, { + target: targetId as number, + pieceType: "queen", + }); + + expect(session.get(targetId, "MovesAs")).toBe("queen"); + }); + + it("preserves the natural PieceType (override is additive to the fact-set)", () => { + // Critical contract: set-moves-as does NOT mutate PieceType — it + // writes a SECOND fact (MovesAs) that move-gen reads. The piece's + // visual identity stays intact; only the movement template flips. + const { ctx, session } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "PieceType", "knight"); + session.insert(targetId, "Color", "white"); + session.insert(targetId, "Position", 12); + + SET_MOVES_AS_PRIMITIVE.apply(ctx, { + target: targetId as number, + pieceType: "queen", + }); + + expect(session.get(targetId, "PieceType")).toBe("knight"); + expect(session.get(targetId, "MovesAs")).toBe("queen"); + // Sibling facts intact. + expect(session.get(targetId, "Color")).toBe("white"); + expect(session.get(targetId, "Position")).toBe(12); + }); + + it("overwrites an existing MovesAs fact (insert is upsert at the session layer)", () => { + const { ctx, session } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "MovesAs", "rook"); + + SET_MOVES_AS_PRIMITIVE.apply(ctx, { + target: targetId as number, + pieceType: "bishop", + }); + + expect(session.get(targetId, "MovesAs")).toBe("bishop"); + }); + + it("does NOT enqueue any deferred trigger (pure mutation)", () => { + const { ctx, session, pendingTriggers } = makeContext(); + const targetId = session.nextId(); + session.insert(targetId, "PieceType", "knight"); + + SET_MOVES_AS_PRIMITIVE.apply(ctx, { + target: targetId as number, + pieceType: "queen", + }); + + expect(pendingTriggers).toHaveLength(0); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/set-moves-as.ts b/packages/chess/src/modifiers/primitives/set-moves-as.ts new file mode 100644 index 0000000..cba3338 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/set-moves-as.ts @@ -0,0 +1,111 @@ +/** + * `set-moves-as` primitive (T40). + * + * Per-piece movement-replacement override: writes the `MovesAs` + * attr (T8 schema slot) onto a target entity. Move-gen (Wave 7+) + * consults this attr and substitutes the override `PieceType`'s + * movement template for the entity's natural one — the canonical + * "this knight moves like a queen" verb. + * + * ## Imperative gating (T14) + * + * `set-moves-as` is NOT in {@link IMPERATIVE_KINDS} (T14 locked + * the imperative set at 10 kinds and this primitive is not one of + * them). It is therefore legal at top-level descriptor scope as + * well as inside trigger / conditional arms — a passive descriptor + * can simply state "this piece moves as a queen" without wrapping + * itself in a trigger. The validator does not reject the placement. + * + * ## Move-gen dry-mode (T20) + * + * Because `set-moves-as` is not in IMPERATIVE_KINDS, the dispatcher + * (`runPrimitives` in `triggers.ts`) does NOT skip it under + * `ctx.suppressTriggers === true`. This is the intended behaviour: + * the override is part of the legality calculation itself (move-gen + * needs to see the override to enumerate the substitute moves), so + * it must be visible during what-if probes. + * + * ## 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 event enqueued + * + * Pure mutation — no on-* trigger fires from this primitive. + * Downstream observers should subscribe to the relevant trigger + * (on-rule-activated, on-move, etc.) at their own descriptor level + * rather than expecting this primitive to fan-out a synthetic event. + * + * ## Schema-only consumption (V1 deferred) + * + * T8 added the `MovesAs` schema slot and registered an + * `attrConsumer` placeholder; this primitive writes the fact. The + * actual move-gen READER that substitutes the override piece-type's + * movement is deferred to the Wave 7 movement integration tasks + * (per task plan). For V1 this primitive is the canonical writer + * and the fact is observable via `session.get(target, "MovesAs")`. + */ +import { z } from "zod"; +import type { EntityId } from "@paratype/rete"; +import type { PieceType } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.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: "set-moves-as", + label: "Set Moves As", + description: + "Overrides the target piece's movement template with another PieceType's movement (writes the MovesAs attr).", + longDescription: + "Writes the per-piece MovesAs attribute (T8 schema slot) onto the target entity. Move-gen consults this attr and substitutes the override PieceType's movement template for the entity's natural one — the canonical 'this knight moves like a queen' verb. NOT in IMPERATIVE_KINDS (T14): legal at top-level descriptor scope as well as inside trigger / conditional arms. The override is visible to move-gen what-if probes (suppressTriggers does NOT skip this primitive) because the legality calculation depends on the override. 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. No on-* trigger is fired; downstream observers must subscribe at their own descriptor level. The actual move-gen reader that substitutes the override piece-type's movement is wired in by the Wave 7 movement integration tasks; this primitive is the canonical writer.", + examples: [ + { + title: "Knight moves as queen — override movement template", + params: { target: 12, pieceType: "queen" }, + effect: + "Writes MovesAs='queen' onto entity 12. Move-gen substitutes the queen's movement template for the entity's natural knight movement, so the knight now slides on ranks/files/diagonals instead of jumping in L-shapes.", + }, + { + title: "Trick promotion movement — pawn moves as bishop", + params: { target: 7, pieceType: "bishop" }, + effect: + "Inside an on-promotion arm, authoring `target: { $var: 'p' }, pieceType: 'bishop'` flips the promoted pawn's movement template to bishop without changing its PieceType — useful for descriptors that want to keep the visual piece kind but borrow another's movement.", + }, + ], + paramsSchema: schema, + // Static seed: this primitive ALWAYS writes MovesAs. No dynamic + // attr-name selection (the attr is fixed by the primitive's + // identity, unlike set-piece-attr which writes a user-chosen attr). + seedsAttrs: ["MovesAs"], + apply(ctx: PrimitiveApplyContext, params: Params): void { + const targetId = params.target as EntityId; + ctx.session.insert(targetId, "MovesAs", params.pieceType); + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as SET_MOVES_AS_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/set-piece-attr.ts b/packages/chess/src/modifiers/primitives/set-piece-attr.ts index 6c6e85e..3a5c25e 100644 --- a/packages/chess/src/modifiers/primitives/set-piece-attr.ts +++ b/packages/chess/src/modifiers/primitives/set-piece-attr.ts @@ -91,8 +91,8 @@ */ import { z } from "zod"; import type { EntityId } from "@paratype/rete"; -import { GAME_ENTITY, type ChessAttrMap } from "../../schema.js"; -import { registerLifetime } from "../../util/lifetime-registry.js"; +import { type ChessAttrMap } from "../../schema.js"; +import { applyLifetime } from "../../util/lifetime-registry.js"; import { PRIMITIVE_REGISTRY } from "./registry.js"; import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; @@ -166,36 +166,17 @@ const descriptor: EffectPrimitive = { params.value as never, ); - // T35 lifetime wire-in. ONLY the `{kind:"turns", count:N}` + // T35+T42 lifetime wire-in. ONLY the `{kind:"turns", count:N}` // shape registers a decrement entry — `"permanent"` (the only // other accepted lifetime) and the omitted-field default both - // mean "fact is permanent, no registry tracking". The math - // resolves `count` to an absolute `FullmoveNumber` target - // because decisions.md rejects decremented-style lifetimes - // (mirrors T19's marker-lifetime `expiresAtMove`). Reads - // FullmoveNumber from GAME_ENTITY with default 1 — that's - // engine.ts#advanceTurnAfterMutation's init value, so - // pre-first-move applies still resolve to a sane target. - if ( - params.lifetime !== undefined && - typeof params.lifetime !== "string" && - params.lifetime.kind === "turns" - ) { - const currentTurn = - (ctx.session.get(GAME_ENTITY, "FullmoveNumber") as number | undefined) ?? - 1; - // Pass `ctx.session` (NOT `ctx.engine`) so the registry write - // lands on the SAME session the fact insert just used. In - // production these are the same object; under test scaffolding - // they may diverge, and we want the registry + fact to stay - // coupled regardless. - registerLifetime(ctx.session, { - entityId: targetId, - attr: params.attr, - expiresAtTurn: currentTurn + params.lifetime.count, - descriptorId: ctx.descriptor.id, - }); - } + // mean "fact is permanent, no registry tracking". The shared + // `applyLifetime` helper resolves `count` to an absolute + // `FullmoveNumber` target (decisions.md rejects decremented- + // style lifetimes; mirrors T19's `expiresAtMove`) and writes the + // registry entry on `ctx.session` so the fact insert + registry + // write stay coupled even under test scaffolding where + // `ctx.session !== ctx.engine.session`. + applyLifetime(ctx, targetId, params.attr, params.lifetime); }, }; diff --git a/packages/chess/src/modifiers/primitives/types.ts b/packages/chess/src/modifiers/primitives/types.ts index b38f6df..10583c5 100644 --- a/packages/chess/src/modifiers/primitives/types.ts +++ b/packages/chess/src/modifiers/primitives/types.ts @@ -85,6 +85,8 @@ export type PrimitiveKind = | "swap-pieces" | "convert-piece-type" | "set-piece-attr" + | "set-moves-as" + | "set-moves-also-as" | "cancel-capture" | "spawn-marker" | "spawn-marker-pair" @@ -95,7 +97,12 @@ export type PrimitiveKind = | "for-each-square" | "for-column" | "for-row" - | "conditional"; + | "random-pick" + | "with-probability" + | "conditional" + | "must-class" + | "block-by-piece-type" + | "pawn-pushes-pieces"; /** * Forward-declared shape of the back-reference passed to primitive diff --git a/packages/chess/src/modifiers/primitives/with-probability.test.ts b/packages/chess/src/modifiers/primitives/with-probability.test.ts new file mode 100644 index 0000000..c18f489 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/with-probability.test.ts @@ -0,0 +1,310 @@ +/** + * `with-probability` RNG primitive (T36) — unit tests. + * + * Covers the locked V1 contract: + * 1. Registry registration under exact 'with-probability' kind. + * 2. paramsSchema accepts/rejects per the `p ∈ [0, 1]` clamp + + * `then` array + optional `else` array shape. + * 3. `p === 1` → `then` always runs (and `else` never). + * 4. `p === 0` → `else` always runs (and `then` never). + * 5. p === 0.5 over a seeded engine → exact, locked hit count + * (we pin the bit pattern, not just the statistical mean, so + * any future refactor of the draw / stream advance breaks + * loudly). + * 6. Determinism across N=10 iterations: a fresh engine seeded + * with the same seed via `setRngSeed` reproduces the same + * branch sequence byte-identically. + * + * The seeded engine's RNG is the canonical oracle. We use + * `setRngSeed(42)` (the integer-seed overload) so the seed is + * literal — bypassing `deriveSeedFromGameId`'s FNV-1a hash and + * keeping the assertions readable. Each `engine.rng().next()` + * call advances the persistent `RngStream` fact by 1, then + * constructs a fresh `SeededRng(42 + stream)` to draw from. The + * first 100 draws of that sequence land 55 of them in `[0, 0.5)` + * — that's the locked number we assert below. + * + * The recorder is a test-only synthetic primitive that pushes the + * branch-tag string ("then" / "else") into a module-level array on + * every apply(). Registered ONCE at module load (try/catch guards + * watch-mode re-eval); each test resets the array via direct + * assignment so per-test isolation is preserved. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { z } from "zod"; +import { ChessEngine } from "../../engine.js"; +import { GAME_ENTITY } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { WITH_PROBABILITY_PRIMITIVE } from "./with-probability.js"; +import type { + EffectPrimitive, + EffectPrimitiveNode, + PrimitiveApplyContext, +} from "./types.js"; +import "./with-probability.js"; + +const RECORDER_KIND = "__t36_record_branch__"; +const RECORDED: string[] = []; + +try { + PRIMITIVE_REGISTRY.register({ + kind: RECORDER_KIND as unknown as EffectPrimitive["kind"], + label: "T36 branch recorder", + description: "Test-only stub that records params.tag on every apply.", + paramsSchema: z.object({ tag: z.string() }).passthrough(), + apply: (_ctx: PrimitiveApplyContext, params: unknown) => { + const p = params as { tag: string }; + RECORDED.push(p.tag); + }, + } as unknown as EffectPrimitive); +} catch { + // already registered (test file re-evaluated under watch mode) +} + +beforeAll(() => { + RECORDED.length = 0; +}); + +afterAll(() => { + RECORDED.length = 0; +}); + +function recorderNode(tag: string): EffectPrimitiveNode { + return { + kind: RECORDER_KIND as unknown as EffectPrimitiveNode["kind"], + params: { tag }, + }; +} + +function makeContext(engine: ChessEngine): PrimitiveApplyContext { + const pieceId = engine.session.nextId(); + return { + engine, + session: engine.session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-with-probability", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + bindings: new Map(), + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; +} + +describe("with-probability primitive — registry", () => { + it("registers in PRIMITIVE_REGISTRY under key 'with-probability'", () => { + expect(PRIMITIVE_REGISTRY.has("with-probability")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("with-probability")).toBe( + WITH_PROBABILITY_PRIMITIVE, + ); + }); + + it("declares label 'With Probability' and empty seedsAttrs", () => { + expect(WITH_PROBABILITY_PRIMITIVE.label).toBe("With Probability"); + expect(WITH_PROBABILITY_PRIMITIVE.seedsAttrs).toEqual([]); + }); +}); + +describe("with-probability primitive — paramsSchema", () => { + it("accepts p in [0, 1] with then-only", () => { + const parsed = WITH_PROBABILITY_PRIMITIVE.paramsSchema.parse({ + p: 0.25, + then: [], + }); + expect(parsed.p).toBe(0.25); + expect(parsed.then).toEqual([]); + expect(parsed.else).toBeUndefined(); + }); + + it("accepts both then and else arms", () => { + const parsed = WITH_PROBABILITY_PRIMITIVE.paramsSchema.parse({ + p: 0.5, + then: [{ kind: "set-capture-flag", params: { flag: 1 } }], + else: [{ kind: "set-capture-flag", params: { flag: 2 } }], + }); + expect(parsed.else).toHaveLength(1); + }); + + it("rejects p > 1 and p < 0", () => { + expect(() => + WITH_PROBABILITY_PRIMITIVE.paramsSchema.parse({ + p: 1.1, + then: [], + }), + ).toThrow(); + expect(() => + WITH_PROBABILITY_PRIMITIVE.paramsSchema.parse({ + p: -0.0001, + then: [], + }), + ).toThrow(); + }); + + it("accepts p === 0 and p === 1 (boundary)", () => { + expect(() => + WITH_PROBABILITY_PRIMITIVE.paramsSchema.parse({ p: 0, then: [] }), + ).not.toThrow(); + expect(() => + WITH_PROBABILITY_PRIMITIVE.paramsSchema.parse({ p: 1, then: [] }), + ).not.toThrow(); + }); +}); + +describe("with-probability primitive — apply()", () => { + it("p === 1 always runs `then` and never `else` (over 50 trials)", () => { + RECORDED.length = 0; + const engine = new ChessEngine(); + engine.setRngSeed(42); + const ctx = makeContext(engine); + + for (let i = 0; i < 50; i += 1) { + WITH_PROBABILITY_PRIMITIVE.apply(ctx, { + p: 1, + then: [recorderNode("then")], + else: [recorderNode("else")], + }); + } + + expect(RECORDED).toHaveLength(50); + expect(RECORDED.every((t) => t === "then")).toBe(true); + // Stream advanced by one per apply — even on the always-then arm. + expect(engine.session.get(GAME_ENTITY, "RngStream")).toBe(50); + }); + + it("p === 0 always runs `else` (or no-ops if absent) and never `then`", () => { + RECORDED.length = 0; + const engine = new ChessEngine(); + engine.setRngSeed(42); + const ctx = makeContext(engine); + + for (let i = 0; i < 50; i += 1) { + WITH_PROBABILITY_PRIMITIVE.apply(ctx, { + p: 0, + then: [recorderNode("then")], + else: [recorderNode("else")], + }); + } + + expect(RECORDED).toHaveLength(50); + expect(RECORDED.every((t) => t === "else")).toBe(true); + expect(engine.session.get(GAME_ENTITY, "RngStream")).toBe(50); + }); + + it("p === 0 with no `else` is a no-op on the false branch (stream still advances)", () => { + RECORDED.length = 0; + const engine = new ChessEngine(); + engine.setRngSeed(42); + const ctx = makeContext(engine); + + for (let i = 0; i < 10; i += 1) { + WITH_PROBABILITY_PRIMITIVE.apply(ctx, { + p: 0, + then: [recorderNode("then")], + // no else + }); + } + + expect(RECORDED).toHaveLength(0); + // The draw STILL fires — stream advances — so a save+resume past + // the no-op false branch resumes at the correct offset. + expect(engine.session.get(GAME_ENTITY, "RngStream")).toBe(10); + }); + + it("p === 0.5 over 100 trials with seed=42 gives EXACTLY 55 'then' hits (locked bit pattern)", () => { + // The Mulberry32 sequence seeded by `setRngSeed(42)` and drawn + // 100 times via engine.rng() (each call constructs SeededRng(42+stream) + // then advances stream) lands 55 draws strictly < 0.5. We assert + // the EXACT number — pinning the bit pattern, not "approximately + // 50". Any future refactor of the draw arithmetic, the stream + // advance, or the comparator will fail this test loudly. That's + // the determinism contract. + RECORDED.length = 0; + const engine = new ChessEngine(); + engine.setRngSeed(42); + const ctx = makeContext(engine); + + for (let i = 0; i < 100; i += 1) { + WITH_PROBABILITY_PRIMITIVE.apply(ctx, { + p: 0.5, + then: [recorderNode("then")], + else: [recorderNode("else")], + }); + } + + expect(RECORDED).toHaveLength(100); + const thenHits = RECORDED.filter((t) => t === "then").length; + const elseHits = RECORDED.filter((t) => t === "else").length; + expect(thenHits).toBe(55); + expect(elseHits).toBe(45); + expect(engine.session.get(GAME_ENTITY, "RngStream")).toBe(100); + }); + + it("deterministic across N=10 iterations: a freshly-seeded engine reproduces the same branch sequence byte-identically", () => { + // Construct 10 engines, each seeded with `setRngSeed(42)`. Each + // runs the same 20-trial p=0.3 dispatch. ALL 10 sequences must + // be byte-identical — Mulberry32 is stateless given a seed, and + // engine.rng() reads a NEW SeededRng(seed + stream) per call, + // so two engines with the same (seed, call sequence) produce + // identical outputs. + const sequences: string[][] = []; + for (let trial = 0; trial < 10; trial += 1) { + const engine = new ChessEngine(); + engine.setRngSeed(42); + const ctx = makeContext(engine); + + const seq: string[] = []; + for (let i = 0; i < 20; i += 1) { + const before = RECORDED.length; + WITH_PROBABILITY_PRIMITIVE.apply(ctx, { + p: 0.3, + then: [recorderNode("then")], + else: [recorderNode("else")], + }); + // The recorder pushed exactly one entry — capture it. + const tag = RECORDED[RECORDED.length - 1] as string; + seq.push(tag); + // Sanity: exactly one apply() per iteration produced exactly + // one recorded tag. Double-firing would corrupt the + // determinism signal. + expect(RECORDED.length).toBe(before + 1); + } + sequences.push(seq); + } + + // Every sequence equals sequences[0] — full byte-identical + // determinism across re-seeded engines. + const ref = sequences[0]!; + expect(ref).toHaveLength(20); + for (let i = 1; i < sequences.length; i += 1) { + expect(sequences[i]).toEqual(ref); + } + }); +}); + +describe("with-probability primitive — childPrimitives()", () => { + it("returns then ++ else for tree-walker consumers", () => { + const then: EffectPrimitiveNode[] = [recorderNode("then")]; + const elseArm: EffectPrimitiveNode[] = [recorderNode("else")]; + const children = WITH_PROBABILITY_PRIMITIVE.childPrimitives?.({ + p: 0.5, + then, + else: elseArm, + }); + expect(children).toEqual([...then, ...elseArm]); + }); + + it("returns just `then` when else is omitted", () => { + const then: EffectPrimitiveNode[] = [recorderNode("then")]; + const children = WITH_PROBABILITY_PRIMITIVE.childPrimitives?.({ + p: 1, + then, + }); + expect(children).toEqual(then); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/with-probability.ts b/packages/chess/src/modifiers/primitives/with-probability.ts new file mode 100644 index 0000000..6c005a4 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/with-probability.ts @@ -0,0 +1,175 @@ +/** + * `with-probability` RNG primitive (T36). + * + * Draws one float in `[0, 1)` from the engine's persistent seeded RNG + * stream (T9 — `engine.rng().next()`). If the draw is strictly less + * than `params.p`, runs every primitive in `then`. Otherwise, if + * `params.else` is set, runs `else`; an absent `else` is a no-op on + * the false branch. + * + * ## Determinism + * + * The draw uses `engine.rng()`, which advances the persistent + * `RngStream` fact on `GAME_ENTITY` by exactly 1 on EVERY call — + * regardless of which branch is taken. This is the load-bearing + * invariant for replay: a save+resume preserving `(RngSeed, + * RngStream)` continues drawing exactly where the previous session + * left off, and the next `with-probability` call sees the next + * stream offset whether the prior call hit `then` or `else`. + * + * Mulberry32 is stateless given a seed, so two engines constructed + * with the same `gameId` and run through the same primitive sequence + * produce byte-identical branch outcomes. Tests pin a known + * `(seed, p)` pair and assert the exact hit count over N trials — + * we lock the bit pattern, not just the statistical mean, so any + * future "optimization" of the draw or stream advance breaks loudly. + * + * ## Stream-advance ordering + * + * The draw happens BEFORE either nested arm runs, so a nested + * `with-probability` in `then` sees `RngStream + 1`, not the + * outer's pre-draw value. This is the semantics described in + * `decisions.md § Seeded RNG` ("draws-before-suspension"): the + * stream advances at the point of decision, not at the point of + * effect. + * + * ## V1 invariants (T34 validator) + * + * `request-choice` is forbidden inside `then` / `else` arms — the + * V1 simplification keeps probabilistic effects fully automatic + * (no UI prompt mid-roll). Enforcement is the validator's job + * (T34); this primitive does not police nesting at apply() time. + * + * ## Imperative gating (T20 / T14) + * + * `with-probability` is NOT in `IMPERATIVE_KINDS` — it's a + * control-flow primitive (mirrors `conditional`). Dry-mode + * (`suppressTriggers`) does NOT skip it; the dispatcher's gate + * only catches imperative primitives. The probability draw fires + * in BOTH wet and dry passes so that legality analysis sees the + * same RNG advance the wet path would. (Whether dry-mode SHOULD + * advance the stream is a separate design call locked by + * `decisions.md`; this primitive defers to that contract by + * always calling `engine.rng().next()`.) + * + * ## Cascade depth (T15) + * + * Each nested-arm `runPrimitives` call increments `depth` by 1 + * (mirrors `conditional`'s recursion). `cascadeDepth` and + * `suppressTriggers` flow through unchanged. + */ +import { z } from "zod"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { runPrimitives } from "../triggers.js"; +import type { + EffectPrimitive, + EffectPrimitiveNode, + PrimitiveApplyContext, + PrimitiveKind, +} from "./types.js"; + +/** + * Inline NodeSchema (mirrors `conditional.ts` / `for-each-square.ts`). + * The tree validator handles deep kind-validation; here we only + * assert the structural shape `{ kind, params }`. + */ +const NodeSchema: z.ZodType = z.object({ + kind: z.string() as z.ZodType, + params: z.unknown(), +}); + +const schema = z.object({ + /** + * Probability of taking the `then` branch — float in `[0, 1]`. + * `0` always runs `else` (or no-ops if absent); `1` always runs + * `then`. The strict-less-than comparison (`draw < p`) means + * `p === 0` will NEVER hit `then` (even if `next()` returns + * exactly `0`, which Mulberry32 can produce on degenerate + * states), and `p === 1` will ALWAYS hit `then` (since `next()` + * is bounded above by `1`, never reaching it). + */ + p: z.number().min(0).max(1), + then: z.array(NodeSchema), + else: z.array(NodeSchema).optional(), +}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "with-probability", + label: "With Probability", + description: + "Draws from the seeded RNG; runs `then` if draw < p, else runs optional `else`.", + longDescription: + "Probabilistic branching primitive backed by the engine's persistent seeded RNG (T9 / `engine.rng()`). On every apply() the primitive advances the RNG stream by exactly 1, drawing a float in [0, 1). If the draw is strictly less than `p`, every primitive in `then` runs in order; otherwise `else` runs if set (omit `else` to make the false branch a no-op). The draw fires BEFORE either arm, so nested `with-probability` calls inside `then` see the next stream offset — replay-safe by construction. V1 simplification: `request-choice` is forbidden inside the arms (validator T34 enforces).", + examples: [ + { + title: "25%-chance crit", + params: { + p: 0.25, + then: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } }, + ], + }, + effect: + "On every trigger fire, 25% of the time the target loses 1 HP; the other 75% of the time nothing happens (no `else` arm).", + }, + { + title: "Coin-flip blessing or curse", + params: { + p: 0.5, + then: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } }, + ], + else: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } }, + ], + }, + effect: + "Half the time the target gains 1 HP; the other half it loses 1 HP. Both branches advance the same RNG stream offset by exactly 1 — replays land on the same outcomes.", + }, + ], + paramsSchema: schema, + // No attr seeded — with-probability is a control-flow orchestrator, + // not a writer. Nested arms that DO write are visible to manifest / + // cleanup walks via `childPrimitives` below. + seedsAttrs: [], + apply(ctx: PrimitiveApplyContext, params: Params): void { + // Phase 1 — draw. ALWAYS call engine.rng().next() so the + // persistent stream advances regardless of branch outcome. Two + // calls to with-probability with the same `p` from the same + // post-state engine MUST produce the same draw / branch. + const draw = ctx.engine.rng().next(); + + // Phase 2 — branch. Strict-less-than (`<`) — see schema comment + // for edge-case semantics at p = 0 / p = 1. + if (draw < params.p) { + runPrimitives( + ctx.engine, + ctx.pieceId, + params.then, + ctx.depth + 1, + ctx.event, + ctx.bindings, + ctx.cascadeDepth, + ctx.suppressTriggers, + ); + } else if (params.else !== undefined) { + runPrimitives( + ctx.engine, + ctx.pieceId, + params.else, + ctx.depth + 1, + ctx.event, + ctx.bindings, + ctx.cascadeDepth, + ctx.suppressTriggers, + ); + } + }, + childPrimitives(params: Params): EffectPrimitiveNode[] { + return [...params.then, ...(params.else ?? [])]; + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as WITH_PROBABILITY_PRIMITIVE }; diff --git a/packages/chess/src/schema.ts b/packages/chess/src/schema.ts index 7f3d0b5..c7586c5 100644 --- a/packages/chess/src/schema.ts +++ b/packages/chess/src/schema.ts @@ -223,6 +223,24 @@ export interface ChessAttrMap { * over allies. Used by ThressGame "block-all-except-king" rule. */ BlockAllExceptKing: boolean; + /** + * Game-level only (set on `GAME_ENTITY`). Deduped list of + * PieceTypes that are forbidden from generating any move while + * the fact is present. Seeded by T39 `block-by-piece-type`; read + * by Wave-10 movegen filter (deferred). Distinct from + * `BlockAllExceptKing` (which gates piece-blocking-during-slide, + * not move-generation). Used by ThressGame `all_on_red` rule. + */ + BlockedPieceTypes: readonly PieceType[]; + /** + * Game-level only (set on `GAME_ENTITY`). When true, pawns push the + * piece directly in front of them one rank further forward instead + * of capturing it diagonally. Seeded by T41 `pawn-pushes-pieces`; + * read by the (deferred) Wave-10 move-gen reader that branches + * between FIDE pawn-capture and push semantics. Orthogonal to + * `BlockedPieceTypes` (which gates whether a pawn can move at all). + */ + PawnPushesPiecesEnabled: boolean; /** * Per-piece additional reach for king-style movement (queen-king, * super-king variants). Added on top of the natural king's 1-square @@ -381,6 +399,61 @@ export interface ChessAttrMap { * to keep churn proportional to expiry count. */ LifetimeRegistry: readonly LifetimeEntry[]; + /** + * T38 — `must-class` move-class restriction. Stored on + * `GAME_ENTITY` (one active restriction at a time). Set by the + * `must-class` primitive at trigger-fire time to constrain the + * active player's NEXT generated move to a specific move class + * (`"capture"`, `"advance"`, or `"move-to"` a specific square). + * Used by parity rules like "must capture if possible" and + * "must move to the threatened square". + * + * Shape: + * - `class` — the move class to require: capture (any move that + * captures), advance (any non-capture relocation), or move-to + * (move that ENDS on `square`). + * - `square` — required when `class === "move-to"`, otherwise + * omitted. The exact destination square the next move must + * land on. + * - `descriptorId` — provenance for debugging/audit; the source + * descriptor that seeded this restriction. + * - `expiresAtTurn` — optional absolute `FullmoveNumber` target + * after which the restriction self-clears. Omitted = lives + * until the move-gen consumer explicitly clears it (a future + * sweep, paired with the move it constrained). + * + * V1 wire-in scope: the apply() side stores the restriction on + * GAME_ENTITY. The MOVE-GEN CONSUMER side (filtering generated + * moves to only those satisfying the restriction) is DEFERRED to + * a future task. Storing the restriction now anchors the + * load-time consumer-integrity check (`registerAttrConsumer`) and + * lets parity descriptors author the rule today; move-gen will + * begin honouring it when the filter lands. + * + * Nullable so the move-gen consumer (and tests) can express + * "explicitly cleared" distinctly from "never set" — the same + * pattern `EnPassantTarget` and `Winner` use elsewhere in this + * map. + */ + MoveClassRestriction: MoveClassRestrictionValue | null; +} + +/** + * T38 — value shape of {@link ChessAttrMap.MoveClassRestriction}. + * + * `class` is the discriminator; `square` is required when + * `class === "move-to"` and otherwise omitted (the primitive's Zod + * schema enforces this refinement at apply-time). `descriptorId` + * records the source descriptor for debugging. `expiresAtTurn` is + * an ABSOLUTE `FullmoveNumber` target (mirrors `LifetimeEntry` and + * `MarkerLifetime`) — omitted = no auto-expiry; the move-gen + * consumer or a sibling descriptor must explicitly clear. + */ +export interface MoveClassRestrictionValue { + readonly class: "capture" | "advance" | "move-to"; + readonly square?: Square; + readonly descriptorId: string; + readonly expiresAtTurn?: number; } /** diff --git a/packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap b/packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap index 1cfa364..228737c 100644 --- a/packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap +++ b/packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap @@ -200,7 +200,7 @@ exports[`ParamField rendering (T14 regression baseline) > seed-attribute renders }

Piece always starts with 5 HP regardless of baseline.

Declare shield charges
{
   "attr": "ShieldCharges",
   "value": 3
-}

Creates a 3-charge counter. Combine with absorb-damage-with-attribute to make each charge soak one damage point.

" +}

Creates a 3-charge counter. Combine with absorb-damage-with-attribute to make each charge soak one damage point.

" `; exports[`ParamField rendering (T14 regression baseline) > set-capture-flag renders flag enum 1`] = ` diff --git a/packages/chess/src/util/lifetime-registry.ts b/packages/chess/src/util/lifetime-registry.ts index 4fd541a..4bb8055 100644 --- a/packages/chess/src/util/lifetime-registry.ts +++ b/packages/chess/src/util/lifetime-registry.ts @@ -64,6 +64,7 @@ */ import type { EntityId, Session } from "@paratype/rete"; import type { ChessEngine } from "../engine.js"; +import type { PrimitiveApplyContext } from "../modifiers/primitives/types.js"; import { GAME_ENTITY, type ChessAttrMap, @@ -172,3 +173,62 @@ export function decrementLifetimes(engine: ChessEngine): void { engine.session.insert(GAME_ENTITY, "LifetimeRegistry", survivors); } } + +/** + * T42 — shared helper for imperative primitives that accept the + * uniform `lifetime` field. Centralises the lifetime-shape decoder + * + registry append so callers (set-piece-attr, seed-attribute, + * future primitives) stay one-liners — keeps the policy + * (FullmoveNumber-based absolute target, default-1 fallback, + * session-direct registry write) in one place rather than copy- + * pasted across every primitive that adds a `lifetime` field. + * + * Accepted shapes (mirrors the locked `lifetimeSchema` union): + * - `undefined` / `"permanent"` → no-op (fact lives until something + * else retracts it). + * - `{ kind: "turns", count: N }` (positive integer) → registers a + * `LifetimeEntry` with `expiresAtTurn = currentFullmove + N`. + * + * `currentFullmove` reads `GAME_ENTITY.FullmoveNumber` with a default + * of `1` — matches `engine.ts#advanceTurnAfterMutation`'s init value + * so pre-first-move applies still resolve to a sane absolute target. + * + * The registry write goes to `ctx.session` (NOT `ctx.engine.session`) + * so primitives whose test scaffolding constructs a standalone + * session keep registry + bound fact on the same session. Production + * dispatchers always pass `ctx.session === ctx.engine.session` so + * the two converge. + * + * Defensive type-narrowing (`typeof !== "object"`, null check, kind + * sentinel, count positivity) makes this safe to call on raw + * `unknown` from already-resolved params — Zod has typically + * validated upstream, but the helper degrades to a no-op rather than + * throwing if a caller skips Zod (e.g. internal direct-construction + * paths). + */ +export function applyLifetime( + ctx: PrimitiveApplyContext, + entityId: EntityId, + attr: string, + lifetime: unknown, +): void { + if (lifetime === undefined || lifetime === "permanent") return; + if (typeof lifetime !== "object" || lifetime === null) return; + const lt = lifetime as { kind?: string; count?: number }; + if ( + lt.kind !== "turns" || + typeof lt.count !== "number" || + lt.count <= 0 + ) { + return; + } + const currentTurn = + (ctx.session.get(GAME_ENTITY, "FullmoveNumber") as number | undefined) ?? + 1; + registerLifetime(ctx.session, { + entityId, + attr, + expiresAtTurn: currentTurn + lt.count, + descriptorId: ctx.descriptor.id, + }); +}