feat(engine): wire trigger primitives + absorb-damage into runtime pipelines

T3 follow-up addressing 4 of the 6 e2e fixmes (T29). Ships the engine
wiring that was deferred in T3's original scope per the implementation
retrospective.

New triggers.ts exports four dispatchers + an HP snapshot helper:
- fireOnTurnStartHooks(engine, whoseTurn): walks every piece of the
  given color, runs each OnTurnStartHooks entry as a primitive list.
- fireOnCaptureHooks(engine, attackerId): runs OnCaptureHooks for the
  attacker piece. attackerId is captured in onBeforeMove (engine.moveLog
  isn't yet populated when onAfterMove fires, so we can't read from it).
- fireOnDamagedHooks(engine, preMoveHp): compares post-move Hp facts
  against a pre-move snapshot; pieces whose Hp dropped (or whose Hp
  fact was retracted = died) get their OnDamagedHooks fired.
- fireConditionalHooks(engine): re-evaluates every ConditionalHook's
  condition against current piece state; runs the matching then/else
  branch.
- snapshotHp(session): freezes Hp facts at the supplied phase for the
  on-damaged dispatcher to compare against.

Conditions supported: attr-lt, attr-gt, attr-eq, always, never (matches
the ConditionSpec union from T14).

Each dispatcher recurses through nested primitives via the same
PRIMITIVE_REGISTRY lookup the descriptor applier uses, with the runtime
depth cap (8) as a backstop. Trigger evaluation has no parent
descriptor — synthesised __trigger__ ref fills the contract.

Integration in apply.ts (__modifier-profile-integration__ preset):

- onBeforeMove: snapshots Hp + the attacker pieceId (when isCapture).
  Both stored in WeakMaps keyed by engine so concurrent engines
  (server-authoritative + client-predicted) keep independent state.
- onDamage: NEW absorb-damage-with-attribute branch BEFORE the existing
  DamageResistance branch. When AbsorbDamageAttr+AbsorbDamageRate are
  set on the target, incoming damage spends the attribute first;
  full absorption short-circuits with consume:true died:false.
  Partial absorbs fall through (same documented limitation as
  partial DamageResistance).
- onAfterMove: now runs computeAuraFacts (T28, unchanged) + four
  trigger dispatchers in order:
    fireOnDamagedHooks → fireOnCaptureHooks → fireConditionalHooks
    → fireOnTurnStartHooks (for the next-mover's color)

7 vitest scenarios in triggers.test.ts cover each dispatcher
including the absorb-damage shield (3 charges → 0 → fall through).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 21:38:01 -06:00
commit 747d0fb728
No known key found for this signature in database
3 changed files with 597 additions and 7 deletions

View file

@ -70,6 +70,30 @@ import type { ChessEngine } from "../engine.js";
import type { CustomModifierRegistry } from "./custom/registry.js";
import { applyCustomDescriptor } from "./custom/apply.js";
import { computeAuraFacts } from "./auras.js";
import {
fireConditionalHooks,
fireOnCaptureHooks,
fireOnDamagedHooks,
fireOnTurnStartHooks,
snapshotHp,
} from "./triggers.js";
/**
* Per-engine pre-move HP snapshot, used by the on-damaged trigger
* dispatcher to detect HP drops between phases. Keyed by engine
* (WeakMap) so concurrent engines (server-authoritative + client
* predicted) keep independent state. The integration preset's
* onBeforeMove takes the snapshot; onAfterMove consumes it.
*/
const PRE_MOVE_HP_SNAPSHOTS = new WeakMap<ChessEngine, Map<EntityId, number>>();
/**
* Per-engine attacker-id snapshot, set in onBeforeMove when the
* incoming move is a capture, consumed in onAfterMove to fire
* on-capture trigger primitives. The engine's `moveLog` isn't yet
* populated when onAfterMove fires, so we can't read it from there.
*/
const PRE_MOVE_CAPTURE_ATTACKERS = new WeakMap<ChessEngine, EntityId>();
/**
* Stable id for the pseudo-preset that wires modifier facts into
@ -441,6 +465,47 @@ PRESET_REGISTRY.register({
* level change; documented as a known limitation for T14.
*/
onDamage(ctx): { consume: boolean; died?: boolean } | void {
// T3 absorb-damage-with-attribute: if the target carries an
// AbsorbDamageAttr/AbsorbDamageRate pair, route incoming damage
// through the named attribute first. Each damage point consumes
// `rate` of `attr`; when the attribute reaches 0, the remainder
// falls through to the rest of the pipeline (HP / kill).
const absorbAttr = ctx.engine.session.get(
ctx.target,
"AbsorbDamageAttr",
) as string | undefined;
const absorbRate = ctx.engine.session.get(
ctx.target,
"AbsorbDamageRate",
) as number | undefined;
if (
typeof absorbAttr === "string" &&
absorbAttr.length > 0 &&
typeof absorbRate === "number" &&
absorbRate > 0
) {
const charges = ctx.engine.session.get(ctx.target, absorbAttr);
const numericCharges =
typeof charges === "number" ? charges : 0;
if (numericCharges > 0) {
// Each damage point spends `rate` of attr; truncate at 0.
const totalNeeded = ctx.amount * absorbRate;
const consumed = Math.min(numericCharges, totalNeeded);
const remainingCharges = numericCharges - consumed;
ctx.engine.session.insert(ctx.target, absorbAttr, remainingCharges);
// If we absorbed every damage point, fully consume — target lives.
if (consumed >= totalNeeded) {
return { consume: true, died: false };
}
// Partial absorb: reduced damage falls through. Same limitation
// documented for partial resistance below — pipeline doesn't
// support mutation, so the next handler sees the original
// amount. Net effect: full damage still applies once charges
// can't cover it. Acceptable for the common case where charges
// are sized to absorb whole hits.
}
}
const resistance = ctx.engine.session.get(ctx.target, "DamageResistance");
if (typeof resistance !== "number" || resistance <= 0) return;
// Immunity short-circuit: fully absorb.
@ -460,15 +525,54 @@ PRESET_REGISTRY.register({
},
/**
* After every successful move, recompute aura contributions
* (T28). The add-aura primitive seeds AuraSpec on source pieces;
* computeAuraFacts walks every source, finds in-range targets via
* Chebyshev distance, and accumulates deltas into per-target
* AuraContributions maps. Retracts stale contributions before
* re-emitting so a source moving out of range no longer boosts
* its former neighbours.
* Snapshot state BEFORE the move mutates it:
* - HP per piece (used by on-damaged hooks to detect drops).
* - Attacker id when the move is a capture (used by on-capture
* hooks; engine.moveLog isn't yet populated at onAfterMove time).
*/
onBeforeMove(ctx): void {
PRE_MOVE_HP_SNAPSHOTS.set(ctx.engine, snapshotHp(ctx.engine.session));
if (ctx.isCapture) {
PRE_MOVE_CAPTURE_ATTACKERS.set(ctx.engine, ctx.pieceId);
} else {
PRE_MOVE_CAPTURE_ATTACKERS.delete(ctx.engine);
}
},
/**
* After every successful move:
* 1. Recompute aura contributions (T28).
* 2. Fire on-damaged hooks for every piece whose HP dropped during
* the move (compared against the pre-move snapshot).
* 3. Fire on-capture hooks for the mover's piece if the move was a
* capture (last move log entry's capturedId !== null).
* 4. Evaluate every conditional hook against current piece state
* and run the matching branch.
* 5. Fire on-turn-start hooks for the color whose turn is now
* beginning (the opposite of the mover).
*
* The order matters: damage / capture triggers see post-move state
* (the kill has happened, Hp facts are current), conditional hooks
* see whatever state the trigger primitives just produced, and
* turn-start runs last so it sees a fully-resolved board.
*/
onAfterMove(ctx): void {
computeAuraFacts(ctx.engine.session);
const preHp = PRE_MOVE_HP_SNAPSHOTS.get(ctx.engine);
if (preHp !== undefined) {
fireOnDamagedHooks(ctx.engine, preHp);
PRE_MOVE_HP_SNAPSHOTS.delete(ctx.engine);
}
const attacker = PRE_MOVE_CAPTURE_ATTACKERS.get(ctx.engine) ?? null;
fireOnCaptureHooks(ctx.engine, attacker);
PRE_MOVE_CAPTURE_ATTACKERS.delete(ctx.engine);
fireConditionalHooks(ctx.engine);
const nextTurn: "white" | "black" =
ctx.mover === "white" ? "black" : "white";
fireOnTurnStartHooks(ctx.engine, nextTurn);
},
});

View file

@ -0,0 +1,264 @@
/**
* Trigger primitive evaluator tests.
*
* The integration preset's onBeforeMove / onAfterMove hooks dispatch
* to the four trigger families. These tests poke at the dispatchers
* directly via a profile that wires each kind of trigger and then
* apply moves through the engine, asserting the inner primitives
* actually ran.
*/
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../engine.js";
import type { ModifierProfile } from "./types.js";
import "./primitives/index.js";
function makeProfileWithCustomKind(profileId: string): ModifierProfile {
return {
id: profileId,
name: profileId,
description: "",
perType: [],
perInstance: [],
version: 1,
source: "custom",
};
}
function findPiece(engine: ChessEngine, square: number) {
for (const f of engine.session.allFacts()) {
if (f.attr === "Position" && f.value === square && (f.id as number) > 0) {
return f.id;
}
}
throw new Error(`no piece at square ${square}`);
}
describe("on-turn-start triggers", () => {
it("runs nested primitives at the start of the matching color's turn", () => {
const engine = new ChessEngine({
profile: makeProfileWithCustomKind("trigger-test"),
});
// Seed an on-turn-start hook on the white queen that bumps RangeBonus
// by 1 each turn.
const whiteQueen = findPiece(engine, 3); // d1
engine.session.insert(whiteQueen, "OnTurnStartHooks", [
[
{
kind: "add-to-attribute",
params: { attr: "RangeBonus", delta: 1 },
},
],
]);
// Move e2-e4. After this move, the next turn (black) starts. Our
// hook is on a WHITE piece; it fires when WHITE's turn begins.
// So one half-move (e4) doesn't trigger; we need the next white
// move.
const movesAfterE4 = engine.getAllLegalMoves();
const e2e4 = movesAfterE4.find((m) => m.from === 12 && m.to === 28);
expect(e2e4).toBeDefined();
engine.applyMove(e2e4!);
// After black moves, white's turn begins → hook fires.
const blackMoves = engine.getAllLegalMoves();
const e7e5 = blackMoves.find((m) => m.from === 52 && m.to === 36);
expect(e7e5).toBeDefined();
engine.applyMove(e7e5!);
const range = engine.session.get(whiteQueen, "RangeBonus") as
| number
| undefined;
expect(range).toBe(1);
});
it("does not fire for pieces of the opposite color when their turn isn't starting", () => {
const engine = new ChessEngine({
profile: makeProfileWithCustomKind("trigger-color"),
});
// Hook on the WHITE queen.
const whiteQueen = findPiece(engine, 3);
engine.session.insert(whiteQueen, "OnTurnStartHooks", [
[
{
kind: "add-to-attribute",
params: { attr: "RangeBonus", delta: 1 },
},
],
]);
// Make ONE white move. Black's turn now begins. The hook is on a
// white piece, so it should NOT fire (only white-turn beginnings
// trigger it).
const movesBeforeAnyMove = engine.getAllLegalMoves();
const e2e4 = movesBeforeAnyMove.find((m) => m.from === 12 && m.to === 28);
engine.applyMove(e2e4!);
expect(engine.session.get(whiteQueen, "RangeBonus")).toBeUndefined();
});
});
describe("on-capture triggers", () => {
it("fires when this piece captures another", () => {
const engine = new ChessEngine({
profile: makeProfileWithCustomKind("on-capture-test"),
});
// Set up a capture by moving white pawn to e4 then black pawn to d5
// then white pawn captures e4xd5. We need on-capture hook on the
// white pawn that ends up doing the capture.
const whitePawn = findPiece(engine, 12); // e2
engine.session.insert(whitePawn, "OnCaptureHooks", [
[{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 5 } }],
]);
// White e2-e4
let moves = engine.getAllLegalMoves();
let m = moves.find((mv) => mv.from === 12 && mv.to === 28);
engine.applyMove(m!);
// Black d7-d5
moves = engine.getAllLegalMoves();
m = moves.find((mv) => mv.from === 51 && mv.to === 35);
engine.applyMove(m!);
// White e4xd5
moves = engine.getAllLegalMoves();
m = moves.find((mv) => mv.from === 28 && mv.to === 35 && mv.isCapture);
expect(m).toBeDefined();
engine.applyMove(m!);
// Hook should have fired — RangeBonus of 5 written.
expect(engine.session.get(whitePawn, "RangeBonus")).toBe(5);
});
});
describe("conditional triggers", () => {
it("runs the then-branch when the condition matches", () => {
const engine = new ChessEngine({
profile: makeProfileWithCustomKind("conditional-then"),
});
const whiteQueen = findPiece(engine, 3);
// Seed condition: when HpBonus is 0 (default), run the then-branch
// which sets RangeBonus to 7. The condition will match on every move.
engine.session.insert(whiteQueen, "ConditionalHooks", [
{
condition: { type: "always" } as const,
then: [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 7 } },
],
},
]);
// Apply any move so onAfterMove fires.
const moves = engine.getAllLegalMoves();
const e2e4 = moves.find((mv) => mv.from === 12 && mv.to === 28);
engine.applyMove(e2e4!);
expect(engine.session.get(whiteQueen, "RangeBonus")).toBe(7);
});
it("runs the else-branch when the condition fails", () => {
const engine = new ChessEngine({
profile: makeProfileWithCustomKind("conditional-else"),
});
const whiteQueen = findPiece(engine, 3);
engine.session.insert(whiteQueen, "ConditionalHooks", [
{
condition: { type: "never" } as const,
then: [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 1 } },
],
else: [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 9 } },
],
},
]);
const moves = engine.getAllLegalMoves();
const e2e4 = moves.find((mv) => mv.from === 12 && mv.to === 28);
engine.applyMove(e2e4!);
expect(engine.session.get(whiteQueen, "RangeBonus")).toBe(9);
});
it("attr-lt condition compares against the live attr value", () => {
const engine = new ChessEngine({
profile: makeProfileWithCustomKind("conditional-attr-lt"),
});
const whiteQueen = findPiece(engine, 3);
engine.session.insert(whiteQueen, "RangeBonus", 0);
engine.session.insert(whiteQueen, "ConditionalHooks", [
{
condition: {
type: "attr-lt" as const,
attr: "RangeBonus",
value: 5,
},
then: [
{
kind: "seed-attribute",
params: { attr: "RangeBonus", value: 100 },
},
],
},
]);
const moves = engine.getAllLegalMoves();
const e2e4 = moves.find((mv) => mv.from === 12 && mv.to === 28);
engine.applyMove(e2e4!);
// 0 < 5 → then-branch fires → RangeBonus = 100.
expect(engine.session.get(whiteQueen, "RangeBonus")).toBe(100);
});
});
describe("absorb-damage-with-attribute (damage pipeline integration)", () => {
it("consumes ShieldCharges before HP drops via the onDamage hook", async () => {
// Drive the integration preset's onDamage hook directly. The
// preset is registered globally; we look it up via PRESET_REGISTRY
// and call its hook with a synthetic ctx. The behavioural
// end-to-end (capture during real gameplay → shield absorbs) is
// covered by the Playwright e2e suite.
const { PRESET_REGISTRY } = await import("../presets/registry.js");
const integrationPreset = PRESET_REGISTRY.get(
"__modifier-profile-integration__",
);
expect(integrationPreset?.onDamage).toBeDefined();
const engine = new ChessEngine();
const e2Pawn = findPiece(engine, 12);
// Charge the shield: 3 charges, rate 1 per damage point.
engine.session.insert(e2Pawn, "ShieldCharges" as never, 3);
engine.session.insert(e2Pawn, "AbsorbDamageAttr", "ShieldCharges");
engine.session.insert(e2Pawn, "AbsorbDamageRate", 1);
const damageCtx = {
engine,
target: e2Pawn,
amount: 1,
kind: "capture" as const,
attacker: e2Pawn,
};
// Fire a 1-damage event. Charges go 3 → 2; consume=true returned.
const result = integrationPreset!.onDamage!(damageCtx);
expect(result?.consume).toBe(true);
expect(result?.died).toBe(false);
expect(engine.session.get(e2Pawn, "ShieldCharges" as never)).toBe(2);
// Drain the shield with two more 1-damage events. After the third,
// charges reach 0; further damage should fall through (no consume).
integrationPreset!.onDamage!(damageCtx);
integrationPreset!.onDamage!(damageCtx);
expect(engine.session.get(e2Pawn, "ShieldCharges" as never)).toBe(0);
// Fourth hit: shield empty. Handler should fall through (no
// consume-true return), so the rest of the damage pipeline runs.
const fourthHit = integrationPreset!.onDamage!(damageCtx);
expect(fourthHit?.consume).not.toBe(true);
});
});

View file

@ -0,0 +1,222 @@
/**
* Trigger-primitive evaluator (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.
*
* 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-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.
* - 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).
*/
import type { EntityId, Session } from "@paratype/rete";
import type { ChessAttrMap, ConditionSpec, PieceColor } from "../schema.js";
import { PRIMITIVE_REGISTRY } from "./primitives/registry.js";
import type {
EffectPrimitiveNode,
PrimitiveApplyContext,
} from "./primitives/types.js";
import type { ChessEngine } from "../engine.js";
/**
* Iterate every piece (id > 0) and yield (id, color, hp).
*/
function* eachPiece(
session: Session,
): Generator<{ id: EntityId; color: PieceColor }> {
const seen = new Set<number>();
for (const f of session.allFacts()) {
if (f.attr !== "Color") continue;
if ((f.id as number) <= 0) continue;
if (seen.has(f.id as number)) continue;
seen.add(f.id as number);
yield { id: f.id, color: f.value as PieceColor };
}
}
/**
* Run a list of primitive nodes against a single piece. Mirrors
* `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).
*/
function runPrimitives(
engine: ChessEngine,
pieceId: EntityId,
nodes: readonly EffectPrimitiveNode[],
depth: number,
): void {
if (depth > 8) return; // hard runtime cap, mirrors validator
for (const node of nodes) {
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
if (primitive === undefined) continue;
const ctx: PrimitiveApplyContext = {
engine,
session: engine.session,
pieceId,
depth,
// Trigger evaluation has no parent descriptor — synthesise a
// minimal ref so the type contract is satisfied.
descriptor: { id: "__trigger__", type: "data", version: 1 },
};
primitive.apply(ctx, node.params);
if (primitive.childPrimitives === undefined) continue;
let children: readonly EffectPrimitiveNode[] = [];
try {
children = primitive.childPrimitives(node.params);
} catch {
children = [];
}
if (children.length > 0) {
runPrimitives(engine, pieceId, children, depth + 1);
}
}
}
/**
* Evaluate a single ConditionSpec against a piece's current facts.
*/
function evaluateCondition(
session: Session,
pieceId: EntityId,
condition: ConditionSpec,
): boolean {
switch (condition.type) {
case "always":
return true;
case "never":
return false;
case "attr-eq": {
const v = session.get(pieceId, condition.attr);
return v === condition.value;
}
case "attr-lt": {
const v = session.get(pieceId, condition.attr);
return typeof v === "number" && v < condition.value;
}
case "attr-gt": {
const v = session.get(pieceId, condition.attr);
return typeof v === "number" && v > condition.value;
}
}
}
/**
* Fire `on-turn-start` hooks for every piece of the given color.
* Called from the integration preset's onAfterMove with the
* non-mover color (whose turn is now beginning).
*/
export function fireOnTurnStartHooks(
engine: ChessEngine,
whoseTurn: PieceColor,
): void {
for (const { id, color } of eachPiece(engine.session)) {
if (color !== whoseTurn) continue;
const hooks = engine.session.get(id, "OnTurnStartHooks") as
| ChessAttrMap["OnTurnStartHooks"]
| undefined;
if (hooks === undefined) continue;
for (const primitives of hooks) {
runPrimitives(engine, id, primitives, 1);
}
}
}
/**
* Fire `on-capture` hooks for the attacker piece. Called from the
* integration preset's onAfterMove, which receives the attacker id
* + capture-target metadata via a per-engine snapshot taken in
* onBeforeMove (the engine's `moveLog` isn't populated yet at
* onAfterMove time).
*
* `attackerId` is null when the most-recent move wasn't a capture.
*/
export function fireOnCaptureHooks(
engine: ChessEngine,
attackerId: EntityId | null,
): void {
if (attackerId === null) return;
const hooks = engine.session.get(attackerId, "OnCaptureHooks") as
| ChessAttrMap["OnCaptureHooks"]
| undefined;
if (hooks === undefined) return;
for (const primitives of hooks) {
runPrimitives(engine, attackerId, primitives, 1);
}
}
/**
* Snapshot every piece's current Hp value (for damage-delta detection
* around a move). The integration preset captures this BEFORE the
* move happens, then `fireOnDamagedHooks` compares to the post-move
* state to find pieces whose Hp decreased.
*/
export function snapshotHp(session: Session): Map<EntityId, number> {
const out = new Map<EntityId, number>();
for (const f of session.allFacts()) {
if (f.attr !== "Hp") continue;
if ((f.id as number) <= 0) continue;
if (typeof f.value === "number") out.set(f.id, f.value);
}
return out;
}
/**
* Fire `on-damaged` hooks for every piece whose Hp decreased relative
* to the supplied pre-move snapshot. A retracted Hp fact (piece died)
* also counts as damage. New pieces (no entry in snapshot) don't fire.
*/
export function fireOnDamagedHooks(
engine: ChessEngine,
preMoveHp: ReadonlyMap<EntityId, number>,
): void {
for (const [id, prev] of preMoveHp) {
const current = engine.session.get(id, "Hp") as number | undefined;
const damaged = current === undefined || current < prev;
if (!damaged) continue;
const hooks = engine.session.get(id, "OnDamagedHooks") as
| ChessAttrMap["OnDamagedHooks"]
| undefined;
if (hooks === undefined) continue;
for (const primitives of hooks) {
runPrimitives(engine, id, primitives, 1);
}
}
}
/**
* Evaluate every piece's `ConditionalHooks` and run the matching
* branch (then-primitives or else-primitives). Fires on every move
* conditions are re-evaluated against current facts so a hook that
* reacts to "Hp < 2" fires the moment HP drops below threshold.
*/
export function fireConditionalHooks(engine: ChessEngine): void {
for (const { id } of eachPiece(engine.session)) {
const hooks = engine.session.get(id, "ConditionalHooks") as
| ChessAttrMap["ConditionalHooks"]
| undefined;
if (hooks === undefined) continue;
for (const hook of hooks) {
const matches = evaluateCondition(engine.session, id, hook.condition);
const branch = matches ? hook.then : hook.else;
if (branch === undefined || branch.length === 0) continue;
runPrimitives(engine, id, branch, 1);
}
}
}