feat(thressgame-coverage): Wave 7 (RNG + restriction + movement-replacement primitives)

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.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-26 11:17:43 -06:00
commit 778ebc4129
No known key found for this signature in database
25 changed files with 2811 additions and 41 deletions

View file

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

View file

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

View file

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

View file

@ -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<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
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<PieceType>(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 };

View file

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

View file

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

View file

@ -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<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
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 };

View file

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

View file

@ -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<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
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 };

View file

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

View file

@ -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<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
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<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
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: '<bind>' }`; 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<string, BindingValue>(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 };

View file

@ -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", () => {

View file

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

View file

@ -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<typeof schema>;
@ -66,6 +87,12 @@ const descriptor: EffectPrimitive<Params> = {
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);
},
};

View file

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

View file

@ -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<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
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 };

View file

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

View file

@ -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<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
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 };

View file

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

View file

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

View file

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

View file

@ -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<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
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<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
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 };

View file

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

View file

@ -200,7 +200,7 @@ exports[`ParamField rendering (T14 regression baseline) > seed-attribute renders
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Piece always starts with 5 HP regardless of baseline.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Declare shield charges</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;ShieldCharges&quot;,
&quot;value&quot;: 3
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Creates a 3-charge counter. Combine with absorb-damage-with-attribute to make each charge soak one damage point.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-seed-attribute-attr" data-recognized="true" data-mode="declare"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-seed-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="ShieldCharges"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">value</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="3"/></div></div>"
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Creates a 3-charge counter. Combine with absorb-damage-with-attribute to make each charge soak one damage point.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-seed-attribute-attr" data-recognized="true" data-mode="declare"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-seed-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="ShieldCharges"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">value</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="3"/></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">lifetime</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value=""/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > set-capture-flag renders flag enum 1`] = `

View file

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