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:
parent
26e708be14
commit
d9df4b64ca
5 changed files with 788 additions and 18 deletions
|
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<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 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<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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[][];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue