From d9df4b64ca4dbd4be70e60e8b1c195335334726b Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 18:02:20 -0600 Subject: [PATCH] feat(chess/modifiers): add 7 fire*Hooks evaluators for new trigger primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threads the 7 new trigger primitives added in Wave 2 into the trigger dispatcher. T21 wires these into onAfterMove next; for now they're callable standalone and covered by 13 new targeted tests. Evaluators added: - fireOnMoveHooks(engine, movedPieceIds) — iterates moved-piece subset so the caller in T21 can pass the Position-diff set - fireOnTurnEndHooks(engine, endedColor) — matches 'white'|'black'|'both' against the hook's stored color - fireOnPromotionHooks(engine, pieceId, from, to) — populates ctx.event={kind:'promotion', promotedFrom, promotedTo} - fireOnCheckReceivedHooks(engine, preMoveCheckState) — edge-triggered: fires only when a royal transitions from not-in-check → in-check relative to the passed pre-move snapshot - fireOnCheckDeliveredHooks(engine, preMoveCheckState) — for each royal, finds pieces newly in the attacker set (handles discovered check correctly — attributes to the revealing piece, not the mover) - fireOnMovedOntoSquareHooks(engine, pieceId, destSquare) — matches either {kind:'squares',squares[]} or {kind:'predicate',file?,rank?} - fireOnCapturedHooks(engine, pieceId, attackerId) — resolves per-hook target via resolveTargets() with ctx.event={kind:'capture',attackerId, defenderId=pieceId}, then runs primitives on each resolved entity Schema change — OnTurnEndHooks shape: Extended from to so the evaluator can enforce the per-hook color filter declared in the primitive's params. Updated: - schema.ts ChessAttrMap.OnTurnEndHooks - on-turn-end.ts apply() stores the rich object - on-turn-end.test.ts two assertions updated runPrimitives refactor: Added optional parameter threading through the recursive walk so nested primitives at any depth see the trigger metadata that fired the root. Backward compatible — existing 4 callers omit the param, getting ctx.event=undefined. Circular-import avoidance: triggers.ts does NOT import from apply.ts (apply.ts already imports from triggers.ts). Pre-move check state is passed as a parameter (mirrors the existing fireOnDamagedHooks(engine, preHp) pattern) rather than imported via getPreMoveCheckState. The post-move check probe (computeCheckStateForColor) mirrors apply.ts's private captureCheckStateForColor — documented as an intentional copy to keep in sync if the royal-detection logic ever moves. --- .../modifiers/primitives/on-turn-end.test.ts | 9 +- .../src/modifiers/primitives/on-turn-end.ts | 5 +- packages/chess/src/modifiers/triggers.test.ts | 373 ++++++++++++++++ packages/chess/src/modifiers/triggers.ts | 408 +++++++++++++++++- packages/chess/src/schema.ts | 11 +- 5 files changed, 788 insertions(+), 18 deletions(-) diff --git a/packages/chess/src/modifiers/primitives/on-turn-end.test.ts b/packages/chess/src/modifiers/primitives/on-turn-end.test.ts index 692a3c6..3b6791d 100644 --- a/packages/chess/src/modifiers/primitives/on-turn-end.test.ts +++ b/packages/chess/src/modifiers/primitives/on-turn-end.test.ts @@ -41,7 +41,9 @@ describe("on-turn-end primitive — apply()", () => { ON_TURN_END_PRIMITIVE.apply(ctx, { color: "both", primitives }); - expect(session.get(ctx.pieceId, "OnTurnEndHooks")).toEqual([primitives]); + expect(session.get(ctx.pieceId, "OnTurnEndHooks")).toEqual([ + { color: "both", primitives }, + ]); }); it("stacks across multiple apply calls", () => { @@ -56,7 +58,10 @@ describe("on-turn-end primitive — apply()", () => { ON_TURN_END_PRIMITIVE.apply(ctx, { color: "both", primitives: first }); ON_TURN_END_PRIMITIVE.apply(ctx, { color: "white", primitives: second }); - expect(session.get(ctx.pieceId, "OnTurnEndHooks")).toEqual([first, second]); + expect(session.get(ctx.pieceId, "OnTurnEndHooks")).toEqual([ + { color: "both", primitives: first }, + { color: "white", primitives: second }, + ]); }); }); diff --git a/packages/chess/src/modifiers/primitives/on-turn-end.ts b/packages/chess/src/modifiers/primitives/on-turn-end.ts index 17fa583..79d71f8 100644 --- a/packages/chess/src/modifiers/primitives/on-turn-end.ts +++ b/packages/chess/src/modifiers/primitives/on-turn-end.ts @@ -62,9 +62,12 @@ const descriptor: EffectPrimitive = { | ChessAttrMap["OnTurnEndHooks"] | undefined) ?? []; + // Preserve `color` alongside the inner primitive list so the + // trigger dispatcher in triggers.ts can match against the ending + // turn's color at fire-time. Schema attr shape extended in T12. ctx.session.insert(ctx.pieceId, "OnTurnEndHooks", [ ...existing, - [...params.primitives], + { color: params.color, primitives: [...params.primitives] }, ]); }, childPrimitives(params: Params): EffectPrimitiveNode[] { diff --git a/packages/chess/src/modifiers/triggers.test.ts b/packages/chess/src/modifiers/triggers.test.ts index 81b5380..b9a39e7 100644 --- a/packages/chess/src/modifiers/triggers.test.ts +++ b/packages/chess/src/modifiers/triggers.test.ts @@ -8,9 +8,24 @@ * actually ran. */ import { describe, expect, it } from "vitest"; +import type { EntityId } from "@paratype/rete"; import { ChessEngine } from "../engine.js"; +import { GAME_ENTITY } from "../schema.js"; +import { algebraicToSquare } from "../coord.js"; +import { clearBoard, pieceAt, placePiece } from "../presets/test-utils.js"; import type { ModifierProfile } from "./types.js"; +import { + fireOnCapturedHooks, + fireOnCheckDeliveredHooks, + fireOnCheckReceivedHooks, + fireOnMoveHooks, + fireOnMovedOntoSquareHooks, + fireOnPromotionHooks, + fireOnTurnEndHooks, + type PreMoveCheckStateLike, +} from "./triggers.js"; import "./primitives/index.js"; +import "../presets/index.js"; function makeProfileWithCustomKind(profileId: string): ModifierProfile { return { @@ -262,3 +277,361 @@ describe("absorb-damage-with-attribute (damage pipeline integration)", () => { expect(fourthHit?.consume).not.toBe(true); }); }); + +// =========================================================================== +// T12 — new evaluator coverage. The integration preset's onAfterMove +// doesn't yet wire these (T21's responsibility). Tests below invoke the +// evaluators DIRECTLY and assert on resulting attr mutations / no-ops. +// =========================================================================== + +describe("fireOnMoveHooks", () => { + it("runs nested primitives for each moved piece carrying the attr", () => { + const engine = new ChessEngine({ + profile: makeProfileWithCustomKind("on-move-fires"), + }); + + const whitePawn = findPiece(engine, 12); // e2 + const whiteKnight = findPiece(engine, 6); // g1 + engine.session.insert(whitePawn, "OnMoveHooks", [ + [{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 2 } }], + ]); + // Knight has no hook seeded — verifies "no-attr ⇒ no-op". + + fireOnMoveHooks(engine, [whitePawn, whiteKnight]); + + expect(engine.session.get(whitePawn, "RangeBonus")).toBe(2); + expect(engine.session.get(whiteKnight, "RangeBonus")).toBeUndefined(); + }); + + it("does not fire for pieces NOT in the moved-piece set", () => { + const engine = new ChessEngine({ + profile: makeProfileWithCustomKind("on-move-static"), + }); + const whitePawn = findPiece(engine, 12); + const blackPawn = findPiece(engine, 52); // e7 — not moved + + engine.session.insert(blackPawn, "OnMoveHooks", [ + [{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 9 } }], + ]); + + fireOnMoveHooks(engine, [whitePawn]); // only the white pawn moved + + expect(engine.session.get(blackPawn, "RangeBonus")).toBeUndefined(); + }); +}); + +describe("fireOnTurnEndHooks", () => { + it("respects color filter — fires for matching color and 'both'", () => { + const engine = new ChessEngine({ + profile: makeProfileWithCustomKind("on-turn-end-color"), + }); + + const whiteQueen = findPiece(engine, 3); + const blackQueen = findPiece(engine, 59); + + // White queen has TWO hooks: one filtered to white, one to 'both'. + engine.session.insert(whiteQueen, "OnTurnEndHooks", [ + { + color: "white", + primitives: [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } }, + ], + }, + { + color: "both", + primitives: [ + { + kind: "add-to-attribute", + params: { attr: "RangeBonus", delta: 1 }, + }, + ], + }, + ]); + // Black queen has a 'black'-only hook. + engine.session.insert(blackQueen, "OnTurnEndHooks", [ + { + color: "black", + primitives: [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: 5 } }, + ], + }, + ]); + + // White's turn just ended. + fireOnTurnEndHooks(engine, "white"); + + expect(engine.session.get(whiteQueen, "HpBonus")).toBe(1); // white-filter fired + expect(engine.session.get(whiteQueen, "RangeBonus")).toBe(1); // both fired + expect(engine.session.get(blackQueen, "HpBonus")).toBeUndefined(); // black filter skipped + + // Now black's turn ends. + fireOnTurnEndHooks(engine, "black"); + expect(engine.session.get(whiteQueen, "HpBonus")).toBe(1); // unchanged (white-only) + expect(engine.session.get(whiteQueen, "RangeBonus")).toBe(2); // both fires again + expect(engine.session.get(blackQueen, "HpBonus")).toBe(5); // black-only fires + }); +}); + +describe("fireOnPromotionHooks", () => { + it("fires hooks on the promoted piece", () => { + const engine = new ChessEngine({ + profile: makeProfileWithCustomKind("on-promotion-event"), + }); + const whitePawn = findPiece(engine, 12); + + engine.session.insert(whitePawn, "OnPromotionHooks", [ + [{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 4 } }], + ]); + + fireOnPromotionHooks(engine, whitePawn, "pawn", "queen"); + + expect(engine.session.get(whitePawn, "RangeBonus")).toBe(4); + }); + + it("is a no-op when the piece has no OnPromotionHooks", () => { + const engine = new ChessEngine({ + profile: makeProfileWithCustomKind("on-promotion-noop"), + }); + const whitePawn = findPiece(engine, 12); + expect(() => + fireOnPromotionHooks(engine, whitePawn, "pawn", "queen"), + ).not.toThrow(); + expect(engine.session.get(whitePawn, "RangeBonus")).toBeUndefined(); + }); +}); + +describe("fireOnCheckReceivedHooks", () => { + it("fires only on the not-in-check → in-check edge transition", () => { + const engine = new ChessEngine({ + profile: makeProfileWithCustomKind("on-check-received-edge"), + }); + + // Build a position where the white king is currently in check from + // a black rook. (The board state here represents "post-move".) + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "king", "white", "e1"); + placePiece(engine, "king", "black", "h8"); + const blackRook = placePiece(engine, "rook", "black", "e4"); + const whiteKing = pieceAt(engine, "e1") as EntityId; + engine.session.insert(GAME_ENTITY, "Turn", "white"); + + engine.session.insert(whiteKing, "OnCheckReceivedHooks", [ + [{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 7 } }], + ]); + + // Pre-move snapshot: white king NOT in check (empty attacker list). + const preEdge: PreMoveCheckStateLike = { + white: new Map([[whiteKing, []]]), + black: new Map(), + }; + + fireOnCheckReceivedHooks(engine, preEdge); + + // Hook fired → HpBonus seeded. + expect(engine.session.get(whiteKing, "HpBonus")).toBe(7); + + // Now simulate "still in check" (already in check pre-move): no + // re-trigger. + const preStill: PreMoveCheckStateLike = { + white: new Map([ + [whiteKing, [blackRook]], + ]), + black: new Map(), + }; + // Reset the bumped attribute and re-fire with the "already-in-check" + // pre-state — should NOT fire again (edge consumed). + engine.session.insert(whiteKing, "HpBonus", 0); + fireOnCheckReceivedHooks(engine, preStill); + expect(engine.session.get(whiteKing, "HpBonus")).toBe(0); + }); +}); + +describe("fireOnCheckDeliveredHooks", () => { + it("fires on each piece newly attacking an enemy royal (discovered check attribution)", () => { + const engine = new ChessEngine({ + profile: makeProfileWithCustomKind("on-check-delivered-discover"), + }); + + // Position: white rook on e4 ALREADY attacks black king (post-move). + // Pre-move state: no attackers (the move that just happened + // unblocked the rook's line — the rook is the "newly attacking" + // piece, even if some other white piece moved). + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "king", "white", "a1"); + placePiece(engine, "king", "black", "e8"); + const whiteRook = placePiece(engine, "rook", "white", "e4"); + const blackKing = pieceAt(engine, "e8") as EntityId; + engine.session.insert(GAME_ENTITY, "Turn", "black"); + + engine.session.insert(whiteRook, "OnCheckDeliveredHooks", [ + [{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 3 } }], + ]); + + const preDiscovered: PreMoveCheckStateLike = { + white: new Map(), + black: new Map([[blackKing, []]]), + }; + + fireOnCheckDeliveredHooks(engine, preDiscovered); + + // Rook is the newly-attacking piece → its hook fired. + expect(engine.session.get(whiteRook, "RangeBonus")).toBe(3); + }); + + it("does NOT fire for attackers already in the pre-move attacker set", () => { + const engine = new ChessEngine({ + profile: makeProfileWithCustomKind("on-check-delivered-no-redundant"), + }); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "king", "white", "a1"); + placePiece(engine, "king", "black", "e8"); + const whiteRook = placePiece(engine, "rook", "white", "e4"); + const blackKing = pieceAt(engine, "e8") as EntityId; + engine.session.insert(GAME_ENTITY, "Turn", "black"); + + engine.session.insert(whiteRook, "OnCheckDeliveredHooks", [ + [{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 9 } }], + ]); + + // Already attacking pre-move — so this is NOT an edge transition + // for THIS attacker. + const preStill: PreMoveCheckStateLike = { + white: new Map(), + black: new Map([ + [blackKing, [whiteRook]], + ]), + }; + + fireOnCheckDeliveredHooks(engine, preStill); + expect(engine.session.get(whiteRook, "RangeBonus")).toBeUndefined(); + }); +}); + +describe("fireOnMovedOntoSquareHooks", () => { + it("matches a static squares list", () => { + const engine = new ChessEngine({ + profile: makeProfileWithCustomKind("on-moved-onto-square-list"), + }); + const whitePawn = findPiece(engine, 12); // e2 → moves to e4 (sq 28) + + engine.session.insert(whitePawn, "OnMovedOntoSquareHooks", [ + { + filter: { kind: "squares", squares: [28, 35] }, + primitives: [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: 4 } }, + ], + }, + ]); + + fireOnMovedOntoSquareHooks(engine, whitePawn, 28); + expect(engine.session.get(whitePawn, "HpBonus")).toBe(4); + + // Different square (not in list): no fire. + engine.session.insert(whitePawn, "HpBonus", 0); + fireOnMovedOntoSquareHooks(engine, whitePawn, 0); + expect(engine.session.get(whitePawn, "HpBonus")).toBe(0); + }); + + it("matches a file/rank predicate (rank-only filter)", () => { + const engine = new ChessEngine({ + profile: makeProfileWithCustomKind("on-moved-onto-square-rank"), + }); + const whitePawn = findPiece(engine, 12); + + engine.session.insert(whitePawn, "OnMovedOntoSquareHooks", [ + { + filter: { kind: "predicate", rank: 3 }, // rank 4 (1-indexed) = rank 3 (0-indexed) + primitives: [ + { kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 1 } }, + ], + }, + ]); + + // square 28 = e4 = rank 3 (0-indexed). Match. + fireOnMovedOntoSquareHooks(engine, whitePawn, 28); + expect(engine.session.get(whitePawn, "RangeBonus")).toBe(1); + + // square 12 = e2 = rank 1. No match. + engine.session.insert(whitePawn, "RangeBonus", 0); + fireOnMovedOntoSquareHooks(engine, whitePawn, 12); + expect(engine.session.get(whitePawn, "RangeBonus")).toBe(0); + }); + + it("matches file+rank predicate intersection", () => { + const engine = new ChessEngine({ + profile: makeProfileWithCustomKind("on-moved-onto-square-file-rank"), + }); + const whitePawn = findPiece(engine, 12); + + engine.session.insert(whitePawn, "OnMovedOntoSquareHooks", [ + { + filter: { kind: "predicate", file: 4, rank: 3 }, // e4 only + primitives: [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: 5 } }, + ], + }, + ]); + + // d4 = file 3, rank 3 → file mismatch, no fire. + fireOnMovedOntoSquareHooks(engine, whitePawn, algebraicToSquare("d4")); + expect(engine.session.get(whitePawn, "HpBonus")).toBeUndefined(); + + // e4 = file 4, rank 3 → match. + fireOnMovedOntoSquareHooks(engine, whitePawn, algebraicToSquare("e4")); + expect(engine.session.get(whitePawn, "HpBonus")).toBe(5); + }); +}); + +describe("fireOnCapturedHooks", () => { + it("respects target redirection — 'attacker' aims primitives at the capturing piece", () => { + const engine = new ChessEngine({ + profile: makeProfileWithCustomKind("on-captured-attacker"), + }); + + const whitePawn = findPiece(engine, 12); // mock attacker + const blackPawn = findPiece(engine, 52); // mock victim + + // Death-rattle on the BLACK pawn that adds RangeBonus to the + // ATTACKER (white pawn). + engine.session.insert(blackPawn, "OnCapturedHooks", [ + { + target: "attacker", + primitives: [ + { + kind: "add-to-attribute", + params: { attr: "RangeBonus", delta: 6 }, + }, + ], + }, + ]); + + fireOnCapturedHooks(engine, blackPawn, whitePawn); + + // Hook fired against the attacker, not the dying piece. + expect(engine.session.get(whitePawn, "RangeBonus")).toBe(6); + expect(engine.session.get(blackPawn, "RangeBonus")).toBeUndefined(); + }); + + it("'self' target hits the dying piece (default semantic)", () => { + const engine = new ChessEngine({ + profile: makeProfileWithCustomKind("on-captured-self"), + }); + const whitePawn = findPiece(engine, 12); + const blackPawn = findPiece(engine, 52); + + engine.session.insert(blackPawn, "OnCapturedHooks", [ + { + target: "self", + primitives: [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: 9 } }, + ], + }, + ]); + + fireOnCapturedHooks(engine, blackPawn, whitePawn); + + // 'self' = the dying piece (capturedPieceId). + expect(engine.session.get(blackPawn, "HpBonus")).toBe(9); + expect(engine.session.get(whitePawn, "HpBonus")).toBeUndefined(); + }); +}); diff --git a/packages/chess/src/modifiers/triggers.ts b/packages/chess/src/modifiers/triggers.ts index 71561b0..f1767b5 100644 --- a/packages/chess/src/modifiers/triggers.ts +++ b/packages/chess/src/modifiers/triggers.ts @@ -1,36 +1,90 @@ /** - * Trigger-primitive evaluator (T29 follow-up). + * Trigger-primitive evaluator (T12 + T29 follow-up). * - * The four trigger primitives — `on-turn-start`, `on-capture`, - * `on-damaged`, `conditional` — seed hook facts on a piece at apply - * time. The integration preset's `onAfterMove` hook calls these - * dispatchers to walk every piece with each kind of hook fact and - * run the inner primitive lists at the corresponding game phase. + * Trigger primitives — `on-turn-start`, `on-capture`, `on-damaged`, + * `conditional`, plus the seven T1-extension triggers added in Wave 2 + * (`on-move`, `on-turn-end`, `on-promotion`, `on-check-received`, + * `on-check-delivered`, `on-moved-onto-square`, `on-captured`) — + * seed hook facts on a piece at apply time. The integration preset's + * `onAfterMove` hook calls these dispatchers (sequenced by T21) to + * walk every piece with each kind of hook fact and run the inner + * primitive lists at the corresponding game phase. * * Phase mapping: * - on-turn-start hooks fire for pieces of the color whose turn is * NOW beginning (i.e. the non-mover after a successful move). + * - on-turn-end hooks fire for pieces whose stored `color` matches + * the color whose turn JUST ended (mover) — `'both'` always fires. * - on-capture hooks fire for the mover's piece when the move just * captured something (capturedId !== null on the last move log entry). * - on-damaged hooks fire for any piece whose Hp decreased between * the snapshot taken before the move and the post-move state. - * Detected by comparing a pre-move HP snapshot. + * - on-move hooks fire on each piece whose Position changed during + * the move (mover, plus castling-rook and en-passant pawn). + * - on-promotion hooks fire on the just-promoted piece with + * `event = {kind:'promotion', promotedFrom, promotedTo}` so inner + * primitives (e.g. seed-attribute) can branch on the new type. + * - on-check-received hooks are EDGE-triggered: a royal that + * transitioned from "not in check" → "in check" this move fires. + * - on-check-delivered hooks fire on each enemy piece NEWLY + * attacking a royal this move (handles discovered + double check + * by attributing to the revealing/added attacker, not the mover). + * - on-moved-onto-square hooks fire on the moved piece when the + * destination square matches the stored filter (squares list or + * file/rank predicate). + * - on-captured hooks fire on the dying piece BEFORE its facts are + * retracted, with `event = {kind:'capture', attackerId, defenderId}`. + * The hook's stored `target` is resolved via `resolveTargets()` so + * classic ally-buff / enemy-debuff death-rattle patterns compose. * - conditional hooks evaluate at every onAfterMove against the * piece's current facts; the matching branch's primitives run. * * Each inner primitive runs via the same `applyCustomDescriptor` path * used at profile-apply time, so nested triggers and conditionals * compose recursively (with the runtime depth cap as a backstop). + * + * ## Snapshot inputs + * + * `fireOnCheckReceivedHooks` and `fireOnCheckDeliveredHooks` accept + * the pre-move check-state snapshot (built in `apply.ts#onBeforeMove`) + * as a function parameter rather than importing the WeakMap getter. + * Reason: `apply.ts` already imports from this module, so importing + * back from `apply.ts` would form a cycle. Passing as a parameter + * mirrors the established pattern used by `fireOnDamagedHooks(engine, + * preMoveHp)` and `fireOnCaptureHooks(engine, attackerId)`. */ import type { EntityId, Session } from "@paratype/rete"; -import type { ChessAttrMap, ConditionSpec, PieceColor } from "../schema.js"; +import type { + ChessAttrMap, + ConditionSpec, + PieceColor, + PieceType, + Square, +} from "../schema.js"; +import { fileOf, rankOf } from "../coord.js"; +import { PIECE_TYPE_REGISTRY } from "../presets/piece-type-registry.js"; import { PRIMITIVE_REGISTRY } from "./primitives/registry.js"; +import { + resolveTargets, + type PrimitiveEvent, +} from "./primitives/context.js"; import type { EffectPrimitiveNode, PrimitiveApplyContext, } from "./primitives/types.js"; import type { ChessEngine } from "../engine.js"; +/** + * Per-color royal→attackers map (mirrors the shape exported from + * `apply.ts` as `PreMoveCheckState`). Re-declared structurally here so + * `triggers.ts` doesn't import from `apply.ts` (apply.ts imports from + * triggers.ts — a back-import would form a cycle). + */ +export interface PreMoveCheckStateLike { + readonly white: ReadonlyMap; + readonly black: ReadonlyMap; +} + /** * Iterate every piece (id > 0) and yield (id, color, hp). */ @@ -52,12 +106,19 @@ function* eachPiece( * `applyCustomDescriptor`'s walker but operates without a parent * descriptor (triggers fire mid-game; the descriptor that originally * seeded the hook isn't available at this phase). + * + * `event` is optional and is threaded into the constructed + * `PrimitiveApplyContext` so primitives that consult `ctx.event` + * (e.g. `target: 'attacker'` resolution, on-promotion narrators) see + * the trigger metadata that fired them. Existing callers that don't + * supply an event get `event: undefined` — backward compatible. */ function runPrimitives( engine: ChessEngine, pieceId: EntityId, nodes: readonly EffectPrimitiveNode[], depth: number, + event?: PrimitiveEvent, ): void { if (depth > 8) return; // hard runtime cap, mirrors validator for (const node of nodes) { @@ -72,12 +133,14 @@ function runPrimitives( // Trigger evaluation has no parent descriptor — synthesise a // minimal ref so the type contract is satisfied. descriptor: { id: "__trigger__", type: "data", version: 1 }, - // T1: defaults. Real target + event metadata will be threaded - // through by the dispatcher rework in T12; today every trigger - // fires primitives against the hook-owning piece (self) with no - // event payload. + // Default target stays 'self'. Hook entries that store their own + // `target` (on-captured) resolve it BEFORE invoking + // `runPrimitives`, calling once per resolved entity with that + // entity as `pieceId`. So from runPrimitives' POV every call is + // self-targeted; per-hook target redirection is a dispatcher + // concern, not a runner concern. target: "self", - event: undefined, + event, }; primitive.apply(ctx, node.params); @@ -89,7 +152,7 @@ function runPrimitives( children = []; } if (children.length > 0) { - runPrimitives(engine, pieceId, children, depth + 1); + runPrimitives(engine, pieceId, children, depth + 1, event); } } } @@ -143,6 +206,31 @@ export function fireOnTurnStartHooks( } } +/** + * Fire `on-turn-end` hooks for every piece whose stored `color` + * filter matches `endedColor` (the color whose turn JUST ended, + * i.e. the mover). `color: 'both'` fires on either side. + * + * Called from the integration preset's onAfterMove BEFORE + * `fireOnTurnStartHooks` so end-of-turn effects resolve before the + * opponent's turn-start tick. + */ +export function fireOnTurnEndHooks( + engine: ChessEngine, + endedColor: PieceColor, +): void { + for (const { id } of eachPiece(engine.session)) { + const hooks = engine.session.get(id, "OnTurnEndHooks") as + | ChessAttrMap["OnTurnEndHooks"] + | undefined; + if (hooks === undefined) continue; + for (const hook of hooks) { + if (hook.color !== "both" && hook.color !== endedColor) continue; + runPrimitives(engine, id, hook.primitives, 1); + } + } +} + /** * Fire `on-capture` hooks for the attacker piece. Called from the * integration preset's onAfterMove, which receives the attacker id @@ -226,3 +314,295 @@ export function fireConditionalHooks(engine: ChessEngine): void { } } } + +/** + * Fire `on-move` hooks for every piece whose Position changed during + * the move. The caller (T21 onAfterMove) computes the moved-piece set + * by diffing pre-move vs post-move Position facts and passes the list + * here. Includes the mover, the castling rook (when castling), and + * the en-passant captured pawn? — no, the EP victim is captured (its + * Position retracts), so the diff is "ids whose Position both existed + * before AND now and changed value", which is just movers + rooks in + * castling. EP-victim retraction triggers on-captured, not on-move. + */ +export function fireOnMoveHooks( + engine: ChessEngine, + movedPieceIds: readonly EntityId[], +): void { + for (const id of movedPieceIds) { + const hooks = engine.session.get(id, "OnMoveHooks") as + | ChessAttrMap["OnMoveHooks"] + | undefined; + if (hooks === undefined) continue; + for (const primitives of hooks) { + runPrimitives(engine, id, primitives, 1); + } + } +} + +/** + * Fire `on-promotion` hooks for the just-promoted piece. Inner + * primitives see `ctx.event = {kind: 'promotion', promotedFrom, + * promotedTo}` so they can branch on the new type (e.g. only seed HP + * if the promotion went to queen). + * + * Called from the integration preset's onAfterMove after diffing the + * pre-move PieceType snapshot against the post-move state. + */ +export function fireOnPromotionHooks( + engine: ChessEngine, + promotedPieceId: EntityId, + promotedFrom: PieceType, + promotedTo: PieceType, +): void { + const hooks = engine.session.get(promotedPieceId, "OnPromotionHooks") as + | ChessAttrMap["OnPromotionHooks"] + | undefined; + if (hooks === undefined) return; + const event: PrimitiveEvent = { + kind: "promotion", + promotedFrom, + promotedTo, + }; + for (const primitives of hooks) { + runPrimitives(engine, promotedPieceId, primitives, 1, event); + } +} + +/** + * Fire `on-check-received` hooks edge-triggered by the not-in-check → + * in-check transition. For every royal of every color, compare the + * pre-move attacker set against the post-move attacker set: if the + * pre-move set was empty AND the post-move set is non-empty, fire + * the royal's hooks. + * + * Royals that were already in check pre-move (and stayed in check) + * do NOT re-trigger — this is a pure edge detector. A royal that + * was in check, escaped, and got re-checked on a later move WILL + * trigger again on that later move (the pre-move snapshot will show + * empty attackers because the prior move escaped). + */ +export function fireOnCheckReceivedHooks( + engine: ChessEngine, + preMoveCheckState: PreMoveCheckStateLike, +): void { + for (const color of ["white", "black"] as const) { + const preColor = preMoveCheckState[color]; + const postColor = computeCheckStateForColor(engine, color); + for (const [royalId, postAttackers] of postColor) { + if (postAttackers.length === 0) continue; + const preAttackers = preColor.get(royalId) ?? []; + if (preAttackers.length > 0) continue; // already in check pre-move + // EDGE: not-in-check → in-check. Fire royal's hooks. + const hooks = engine.session.get(royalId, "OnCheckReceivedHooks") as + | ChessAttrMap["OnCheckReceivedHooks"] + | undefined; + if (hooks === undefined) continue; + for (const primitives of hooks) { + runPrimitives(engine, royalId, primitives, 1); + } + } + } +} + +/** + * Fire `on-check-delivered` hooks for every piece that NEWLY attacks + * an enemy royal this move. "Newly" = present in post-move attacker + * set for that royal but absent from pre-move attacker set. Handles + * discovered check (attribution to the revealing piece, not the + * mover) and double check (BOTH new attackers fire) without special + * casing. + */ +export function fireOnCheckDeliveredHooks( + engine: ChessEngine, + preMoveCheckState: PreMoveCheckStateLike, +): void { + for (const color of ["white", "black"] as const) { + const preColor = preMoveCheckState[color]; + const postColor = computeCheckStateForColor(engine, color); + for (const [royalId, postAttackers] of postColor) { + const preAttackers = new Set(preColor.get(royalId) ?? []); + for (const attackerId of postAttackers) { + if (preAttackers.has(attackerId)) continue; // not new + const hooks = engine.session.get( + attackerId, + "OnCheckDeliveredHooks", + ) as ChessAttrMap["OnCheckDeliveredHooks"] | undefined; + if (hooks === undefined) continue; + for (const primitives of hooks) { + runPrimitives(engine, attackerId, primitives, 1); + } + } + } + } +} + +/** + * Fire `on-moved-onto-square` hooks for the moved piece when the + * destination square matches the stored filter. Each entry's filter + * is either an explicit squares list or a file/rank predicate (any + * combination of the two — both undefined matches every square). + */ +export function fireOnMovedOntoSquareHooks( + engine: ChessEngine, + movedPieceId: EntityId, + destSquare: Square, +): void { + const hooks = engine.session.get( + movedPieceId, + "OnMovedOntoSquareHooks", + ) as ChessAttrMap["OnMovedOntoSquareHooks"] | undefined; + if (hooks === undefined) return; + for (const hook of hooks) { + if (!squareMatchesFilter(destSquare, hook.filter)) continue; + runPrimitives(engine, movedPieceId, hook.primitives, 1); + } +} + +/** + * Fire `on-captured` hooks on the dying piece BEFORE its facts are + * retracted (T21 sequences this call to run before the capture + * removes the defender's WMEs, so the inner primitives can still + * read the defender's attrs). + * + * The hook's stored `target` redirects the inner primitive list to + * other entities (attacker, allies, enemies, specific squares). For + * each resolved target id, runs the hook's inner primitives with + * that id as `ctx.pieceId` and `ctx.event = {kind:'capture', + * attackerId, defenderId}` so primitives can consult capture + * metadata (and `target: 'attacker'/'defender'` resolution works + * INSIDE nested primitives too). + */ +export function fireOnCapturedHooks( + engine: ChessEngine, + capturedPieceId: EntityId, + attackerId: EntityId, +): void { + const hooks = engine.session.get( + capturedPieceId, + "OnCapturedHooks", + ) as ChessAttrMap["OnCapturedHooks"] | undefined; + if (hooks === undefined) return; + const event: PrimitiveEvent = { + kind: "capture", + attackerId, + defenderId: capturedPieceId, + }; + for (const hook of hooks) { + // Build a transient context pinned to the dying piece so + // resolveTargets() can interpret 'self', 'ally', 'enemy' relative + // to the defender. The context is consumed only by + // resolveTargets — runPrimitives below builds its own per-target. + const resolverCtx: PrimitiveApplyContext = { + engine, + session: engine.session, + pieceId: capturedPieceId, + depth: 0, + descriptor: { id: "__trigger__", type: "data", version: 1 }, + target: hook.target, + event, + }; + const targets = resolveTargets(resolverCtx, hook.target); + for (const targetId of targets) { + runPrimitives(engine, targetId, hook.primitives, 1, event); + } + } +} + +/** + * Predicate matcher for OnMovedOntoSquare's filter union. + * - `kind: "squares"` matches if the destination is in the list. + * - `kind: "predicate"` matches when both file and rank constraints + * pass (undefined constraint = wildcard). + */ +function squareMatchesFilter( + square: Square, + filter: ChessAttrMap["OnMovedOntoSquareHooks"][number]["filter"], +): boolean { + if (filter.kind === "squares") { + return filter.squares.includes(square); + } + // predicate + const f = fileOf(square); + const r = rankOf(square); + if (filter.file !== undefined && f !== filter.file) return false; + if (filter.rank !== undefined && r !== filter.rank) return false; + return true; +} + +/** + * Compute a per-color royal→attackers map at the CURRENT (post-move) + * session state, mirroring the helper used in `apply.ts` to capture + * the pre-move snapshot. Re-implemented here (instead of importing + * from apply.ts) to keep `triggers.ts` free of back-imports — the + * apply.ts module already imports from here, so a back-import would + * form a cycle. + * + * Behaviour parity check-list with `apply.ts#captureCheckStateForColor`: + * - Royal resolution: `engine.getActiveRoyalEntityIds(color)` first; + * fall back to "every PieceType=king of `color`" when the preset + * doesn't override royalty. + * - Returns empty map when neither the preset nor the king-fallback + * yields any royal of `color`. + * - Per royal, maps to the list of enemy ids whose `attackProbe` + * threatens the royal's current Position. + */ +function computeCheckStateForColor( + engine: ChessEngine, + color: PieceColor, +): ReadonlyMap { + const out = new Map(); + const session = engine.session; + const facts = session.allFacts(); + + const presetRoyals = engine.getActiveRoyalEntityIds(color); + let royals: readonly EntityId[]; + if (presetRoyals !== undefined) { + if (presetRoyals.length === 0) return out; + royals = presetRoyals; + } else { + const defaultIds: EntityId[] = []; + for (const f of facts) { + if (f.attr !== "PieceType" || f.value !== "king") continue; + if ((f.id as number) <= 0) continue; + const cf = facts.find((c) => c.id === f.id && c.attr === "Color"); + if (cf !== undefined && cf.value === color) defaultIds.push(f.id); + } + if (defaultIds.length === 0) return out; + royals = defaultIds; + } + + const attackerColor: PieceColor = color === "white" ? "black" : "white"; + + const enemies: Array<{ id: EntityId; type: PieceType }> = []; + for (const f of facts) { + if (f.attr !== "Color" || f.value !== attackerColor) continue; + if ((f.id as number) <= 0) continue; + const typeFact = facts.find( + (t) => t.id === f.id && t.attr === "PieceType", + ); + if (typeFact === undefined) continue; + enemies.push({ id: f.id, type: typeFact.value as PieceType }); + } + + for (const royalId of royals) { + const posFact = facts.find( + (f) => f.id === royalId && f.attr === "Position", + ); + if (posFact === undefined) { + out.set(royalId, []); + continue; + } + const royalSquare = posFact.value as Square; + const attackers: EntityId[] = []; + for (const enemy of enemies) { + const def = PIECE_TYPE_REGISTRY.get(enemy.type); + if (def === undefined) continue; + if (def.attackProbe(session, enemy.id, royalSquare)) { + attackers.push(enemy.id); + } + } + out.set(royalId, attackers); + } + return out; +} diff --git a/packages/chess/src/schema.ts b/packages/chess/src/schema.ts index 96e2dbe..b2e194d 100644 --- a/packages/chess/src/schema.ts +++ b/packages/chess/src/schema.ts @@ -119,7 +119,16 @@ export interface ChessAttrMap { }[]; // T3-extension trigger hook attrs (read by triggers.ts evaluators added in T12) OnMoveHooks: readonly EffectPrimitiveNode[][]; - OnTurnEndHooks: readonly EffectPrimitiveNode[][]; + /** + * On-turn-end hooks carry their `color` filter alongside the inner + * primitives, because the filter must be evaluated at fire-time + * (the dispatcher in triggers.ts compares the ENDING turn's color + * against this entry to decide whether to run the inner list). + */ + OnTurnEndHooks: readonly { + readonly color: "white" | "black" | "both"; + readonly primitives: readonly EffectPrimitiveNode[]; + }[]; OnPromotionHooks: readonly EffectPrimitiveNode[][]; OnCheckReceivedHooks: readonly EffectPrimitiveNode[][]; OnCheckDeliveredHooks: readonly EffectPrimitiveNode[][];