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:
parent
9a7436e2ad
commit
778ebc4129
25 changed files with 2811 additions and 41 deletions
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
147
packages/chess/src/modifiers/primitives/block-by-piece-type.ts
Normal file
147
packages/chess/src/modifiers/primitives/block-by-piece-type.ts
Normal 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 };
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
183
packages/chess/src/modifiers/primitives/must-class.test.ts
Normal file
183
packages/chess/src/modifiers/primitives/must-class.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
127
packages/chess/src/modifiers/primitives/must-class.ts
Normal file
127
packages/chess/src/modifiers/primitives/must-class.ts
Normal 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 };
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
327
packages/chess/src/modifiers/primitives/random-pick.test.ts
Normal file
327
packages/chess/src/modifiers/primitives/random-pick.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
180
packages/chess/src/modifiers/primitives/random-pick.ts
Normal file
180
packages/chess/src/modifiers/primitives/random-pick.ts
Normal 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 };
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
120
packages/chess/src/modifiers/primitives/set-moves-also-as.ts
Normal file
120
packages/chess/src/modifiers/primitives/set-moves-also-as.ts
Normal 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 };
|
||||
207
packages/chess/src/modifiers/primitives/set-moves-as.test.ts
Normal file
207
packages/chess/src/modifiers/primitives/set-moves-as.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
111
packages/chess/src/modifiers/primitives/set-moves-as.ts
Normal file
111
packages/chess/src/modifiers/primitives/set-moves-as.ts
Normal 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 };
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
310
packages/chess/src/modifiers/primitives/with-probability.test.ts
Normal file
310
packages/chess/src/modifiers/primitives/with-probability.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
175
packages/chess/src/modifiers/primitives/with-probability.ts
Normal file
175
packages/chess/src/modifiers/primitives/with-probability.ts
Normal 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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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">{
|
||||
"attr": "ShieldCharges",
|
||||
"value": 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`] = `
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue