feat(chess/modifiers): add 7 fire*Hooks evaluators for new trigger primitives

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.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 18:02:20 -06:00
commit d9df4b64ca
No known key found for this signature in database
5 changed files with 788 additions and 18 deletions

View file

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

View file

@ -62,9 +62,12 @@ const descriptor: EffectPrimitive<Params> = {
| 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[] {

View file

@ -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<EntityId, readonly EntityId[]>([[whiteKing, []]]),
black: new Map<EntityId, readonly EntityId[]>(),
};
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<EntityId, readonly EntityId[]>([
[whiteKing, [blackRook]],
]),
black: new Map<EntityId, readonly EntityId[]>(),
};
// 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<EntityId, readonly EntityId[]>(),
black: new Map<EntityId, readonly EntityId[]>([[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<EntityId, readonly EntityId[]>(),
black: new Map<EntityId, readonly EntityId[]>([
[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();
});
});

View file

@ -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 royalattackers 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<EntityId, readonly EntityId[]>;
readonly black: ReadonlyMap<EntityId, readonly EntityId[]>;
}
/**
* 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 royalattackers 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<EntityId, readonly EntityId[]> {
const out = new Map<EntityId, readonly EntityId[]>();
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;
}

View file

@ -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[][];