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:
parent
fb6170127e
commit
747d0fb728
3 changed files with 597 additions and 7 deletions
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
264
packages/chess/src/modifiers/triggers.test.ts
Normal file
264
packages/chess/src/modifiers/triggers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
222
packages/chess/src/modifiers/triggers.ts
Normal file
222
packages/chess/src/modifiers/triggers.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue