diff --git a/packages/chess/src/modifiers/apply.ts b/packages/chess/src/modifiers/apply.ts index db95172..87b2faa 100644 --- a/packages/chess/src/modifiers/apply.ts +++ b/packages/chess/src/modifiers/apply.ts @@ -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>(); + +/** + * 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(); /** * 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); }, }); diff --git a/packages/chess/src/modifiers/triggers.test.ts b/packages/chess/src/modifiers/triggers.test.ts new file mode 100644 index 0000000..81b5380 --- /dev/null +++ b/packages/chess/src/modifiers/triggers.test.ts @@ -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); + }); +}); diff --git a/packages/chess/src/modifiers/triggers.ts b/packages/chess/src/modifiers/triggers.ts new file mode 100644 index 0000000..1c329ab --- /dev/null +++ b/packages/chess/src/modifiers/triggers.ts @@ -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(); + 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 { + const out = new Map(); + 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, +): 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); + } + } +}