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 });
|
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", () => {
|
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: "both", primitives: first });
|
||||||
ON_TURN_END_PRIMITIVE.apply(ctx, { color: "white", primitives: second });
|
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"]
|
| ChessAttrMap["OnTurnEndHooks"]
|
||||||
| undefined) ?? [];
|
| 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", [
|
ctx.session.insert(ctx.pieceId, "OnTurnEndHooks", [
|
||||||
...existing,
|
...existing,
|
||||||
[...params.primitives],
|
{ color: params.color, primitives: [...params.primitives] },
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
childPrimitives(params: Params): EffectPrimitiveNode[] {
|
childPrimitives(params: Params): EffectPrimitiveNode[] {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,24 @@
|
||||||
* actually ran.
|
* actually ran.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { EntityId } from "@paratype/rete";
|
||||||
import { ChessEngine } from "../engine.js";
|
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 type { ModifierProfile } from "./types.js";
|
||||||
|
import {
|
||||||
|
fireOnCapturedHooks,
|
||||||
|
fireOnCheckDeliveredHooks,
|
||||||
|
fireOnCheckReceivedHooks,
|
||||||
|
fireOnMoveHooks,
|
||||||
|
fireOnMovedOntoSquareHooks,
|
||||||
|
fireOnPromotionHooks,
|
||||||
|
fireOnTurnEndHooks,
|
||||||
|
type PreMoveCheckStateLike,
|
||||||
|
} from "./triggers.js";
|
||||||
import "./primitives/index.js";
|
import "./primitives/index.js";
|
||||||
|
import "../presets/index.js";
|
||||||
|
|
||||||
function makeProfileWithCustomKind(profileId: string): ModifierProfile {
|
function makeProfileWithCustomKind(profileId: string): ModifierProfile {
|
||||||
return {
|
return {
|
||||||
|
|
@ -262,3 +277,361 @@ describe("absorb-damage-with-attribute (damage pipeline integration)", () => {
|
||||||
expect(fourthHit?.consume).not.toBe(true);
|
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`,
|
* Trigger primitives — `on-turn-start`, `on-capture`, `on-damaged`,
|
||||||
* `on-damaged`, `conditional` — seed hook facts on a piece at apply
|
* `conditional`, plus the seven T1-extension triggers added in Wave 2
|
||||||
* time. The integration preset's `onAfterMove` hook calls these
|
* (`on-move`, `on-turn-end`, `on-promotion`, `on-check-received`,
|
||||||
* dispatchers to walk every piece with each kind of hook fact and
|
* `on-check-delivered`, `on-moved-onto-square`, `on-captured`) —
|
||||||
* run the inner primitive lists at the corresponding game phase.
|
* 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:
|
* Phase mapping:
|
||||||
* - on-turn-start hooks fire for pieces of the color whose turn is
|
* - on-turn-start hooks fire for pieces of the color whose turn is
|
||||||
* NOW beginning (i.e. the non-mover after a successful move).
|
* 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
|
* - on-capture hooks fire for the mover's piece when the move just
|
||||||
* captured something (capturedId !== null on the last move log entry).
|
* captured something (capturedId !== null on the last move log entry).
|
||||||
* - on-damaged hooks fire for any piece whose Hp decreased between
|
* - on-damaged hooks fire for any piece whose Hp decreased between
|
||||||
* the snapshot taken before the move and the post-move state.
|
* 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
|
* - conditional hooks evaluate at every onAfterMove against the
|
||||||
* piece's current facts; the matching branch's primitives run.
|
* piece's current facts; the matching branch's primitives run.
|
||||||
*
|
*
|
||||||
* Each inner primitive runs via the same `applyCustomDescriptor` path
|
* Each inner primitive runs via the same `applyCustomDescriptor` path
|
||||||
* used at profile-apply time, so nested triggers and conditionals
|
* used at profile-apply time, so nested triggers and conditionals
|
||||||
* compose recursively (with the runtime depth cap as a backstop).
|
* 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 { 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 { PRIMITIVE_REGISTRY } from "./primitives/registry.js";
|
||||||
|
import {
|
||||||
|
resolveTargets,
|
||||||
|
type PrimitiveEvent,
|
||||||
|
} from "./primitives/context.js";
|
||||||
import type {
|
import type {
|
||||||
EffectPrimitiveNode,
|
EffectPrimitiveNode,
|
||||||
PrimitiveApplyContext,
|
PrimitiveApplyContext,
|
||||||
} from "./primitives/types.js";
|
} from "./primitives/types.js";
|
||||||
import type { ChessEngine } from "../engine.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).
|
* Iterate every piece (id > 0) and yield (id, color, hp).
|
||||||
*/
|
*/
|
||||||
|
|
@ -52,12 +106,19 @@ function* eachPiece(
|
||||||
* `applyCustomDescriptor`'s walker but operates without a parent
|
* `applyCustomDescriptor`'s walker but operates without a parent
|
||||||
* descriptor (triggers fire mid-game; the descriptor that originally
|
* descriptor (triggers fire mid-game; the descriptor that originally
|
||||||
* seeded the hook isn't available at this phase).
|
* 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(
|
function runPrimitives(
|
||||||
engine: ChessEngine,
|
engine: ChessEngine,
|
||||||
pieceId: EntityId,
|
pieceId: EntityId,
|
||||||
nodes: readonly EffectPrimitiveNode[],
|
nodes: readonly EffectPrimitiveNode[],
|
||||||
depth: number,
|
depth: number,
|
||||||
|
event?: PrimitiveEvent,
|
||||||
): void {
|
): void {
|
||||||
if (depth > 8) return; // hard runtime cap, mirrors validator
|
if (depth > 8) return; // hard runtime cap, mirrors validator
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
|
|
@ -72,12 +133,14 @@ function runPrimitives(
|
||||||
// Trigger evaluation has no parent descriptor — synthesise a
|
// Trigger evaluation has no parent descriptor — synthesise a
|
||||||
// minimal ref so the type contract is satisfied.
|
// minimal ref so the type contract is satisfied.
|
||||||
descriptor: { id: "__trigger__", type: "data", version: 1 },
|
descriptor: { id: "__trigger__", type: "data", version: 1 },
|
||||||
// T1: defaults. Real target + event metadata will be threaded
|
// Default target stays 'self'. Hook entries that store their own
|
||||||
// through by the dispatcher rework in T12; today every trigger
|
// `target` (on-captured) resolve it BEFORE invoking
|
||||||
// fires primitives against the hook-owning piece (self) with no
|
// `runPrimitives`, calling once per resolved entity with that
|
||||||
// event payload.
|
// 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",
|
target: "self",
|
||||||
event: undefined,
|
event,
|
||||||
};
|
};
|
||||||
primitive.apply(ctx, node.params);
|
primitive.apply(ctx, node.params);
|
||||||
|
|
||||||
|
|
@ -89,7 +152,7 @@ function runPrimitives(
|
||||||
children = [];
|
children = [];
|
||||||
}
|
}
|
||||||
if (children.length > 0) {
|
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
|
* Fire `on-capture` hooks for the attacker piece. Called from the
|
||||||
* integration preset's onAfterMove, which receives the attacker id
|
* 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)
|
// T3-extension trigger hook attrs (read by triggers.ts evaluators added in T12)
|
||||||
OnMoveHooks: readonly EffectPrimitiveNode[][];
|
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[][];
|
OnPromotionHooks: readonly EffectPrimitiveNode[][];
|
||||||
OnCheckReceivedHooks: readonly EffectPrimitiveNode[][];
|
OnCheckReceivedHooks: readonly EffectPrimitiveNode[][];
|
||||||
OnCheckDeliveredHooks: readonly EffectPrimitiveNode[][];
|
OnCheckDeliveredHooks: readonly EffectPrimitiveNode[][];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue