diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index 147ad09..7aa8af7 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -69,6 +69,15 @@ import { PIECE_TYPE_REGISTRY } from "./presets/piece-type-registry.js"; // Importing from the barrel guarantees every preset module's // side-effect registration has run before the first engine is created. import "./presets/index.js"; +// Side-effect import: registers every modifier descriptor AND the +// `__modifier-profile-integration__` preset with PRESET_REGISTRY, so +// the engine can activate it when a profile is supplied. +import "./modifiers/index.js"; +import { + applyProfileToSession, + MODIFIER_INTEGRATION_PRESET_ID, +} from "./modifiers/apply.js"; +import type { ModifierProfile } from "./modifiers/types.js"; type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[]; @@ -299,6 +308,21 @@ class PresetStateImpl> export interface EngineOptions { readonly activePresets?: ActivePresetSet; readonly layout?: StartingLayout; + /** + * Optional ModifierProfile to apply at game start. When supplied, + * the engine: + * 1. Applies the layout (as normal). + * 2. Calls `applyProfileToSession(session, profile, layout)` to + * seed all modifier facts on piece entities. + * 3. Auto-activates the `__modifier-profile-integration__` preset + * (scope=both, permanent) so CaptureFlags, DirectionAdditions, + * and DamageResistance facts drive actual gameplay. + * + * Order: profile is applied BEFORE `activePresets` activation, so + * preset `onActivate` hooks (e.g. piece-hp seeding Hp) observe any + * HpBonus the profile contributed. + */ + readonly profile?: ModifierProfile; } export class ChessEngine { @@ -421,8 +445,39 @@ export class ChessEngine { const layout = opts.layout ?? CLASSIC_LAYOUT; applyLayout(this.session, layout); + + // Profile seeding runs BEFORE the position is recorded for + // threefold repetition — the modifier facts are part of the + // "initial position" from a repetition-tracking perspective, and + // are not expected to change mid-game (profiles hot-swap via + // turn-boundary replacement, which re-seeds). + if (opts.profile) { + applyProfileToSession(this.session, opts.profile, layout); + } + recordPosition(this.session); this.activePresets = opts.activePresets ?? new ActivePresetSet(); + + // When a profile is present, auto-activate the integration preset + // so its filterMoves / getExtraMoves / onDamage hooks fire. We + // prepend it to whatever the caller provided so it always runs + // FIRST in the preset-iteration order (cheap predicate — cheap to + // short-circuit). + if (opts.profile) { + const existing = this.activePresets.list(); + this.activePresets.replaceAll([ + { + id: MODIFIER_INTEGRATION_PRESET_ID, + scope: "both", + turnsRemaining: null, + }, + ...existing.map((e) => ({ + id: e.id, + scope: e.scope, + turnsRemaining: e.turnsRemaining, + })), + ]); + } } /** diff --git a/packages/chess/src/modifiers/apply.test.ts b/packages/chess/src/modifiers/apply.test.ts new file mode 100644 index 0000000..69d959b --- /dev/null +++ b/packages/chess/src/modifiers/apply.test.ts @@ -0,0 +1,276 @@ +/** + * Tests for applyProfileToSession. + * + * Each test builds a fresh Session, applies CLASSIC_LAYOUT (so we have + * a known piece topology), then calls applyProfileToSession with a + * targeted profile. We read the resulting facts directly from the + * session — we don't exercise the engine-level integration preset + * here; that's covered by engine-surface tests elsewhere. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Session } from "@paratype/rete"; +import type { EntityId } from "@paratype/rete"; +import { applyLayout, CLASSIC_LAYOUT } from "../starting-position.js"; +import { applyProfileToSession } from "./apply.js"; +import type { ModifierProfile } from "./types.js"; +import { CaptureFlag } from "../schema.js"; +// Side-effect import — ensures every descriptor is registered so +// `MODIFIER_REGISTRY.get(kind)` resolves in applyProfileToSession. +import "./index.js"; + +/** + * Helper: return EntityIds of pieces matching the given filter. We + * need this in several tests to assert that the right pieces got + * seeded (and the wrong ones didn't). + */ +function findPieces( + session: Session, + filter: (type: string, color: string, square: number) => boolean, +): EntityId[] { + const facts = session.allFacts(); + const out: EntityId[] = []; + for (const f of facts) { + if (f.attr !== "PieceType") continue; + if ((f.id as number) <= 0) continue; + const type = f.value as string; + const colorFact = facts.find((c) => c.id === f.id && c.attr === "Color"); + const posFact = facts.find((p) => p.id === f.id && p.attr === "Position"); + if (!colorFact || !posFact) continue; + if (filter(type, colorFact.value as string, posFact.value as number)) { + out.push(f.id as EntityId); + } + } + return out; +} + +/** Minimal profile builder — keeps test bodies focused on assertions. */ +function profile( + parts: Partial, +): ModifierProfile { + return { + id: "test", + name: "test", + description: "", + perType: [], + perInstance: [], + version: 1, + source: "custom", + ...parts, + }; +} + +describe("applyProfileToSession", () => { + let session: Session; + let warnSpy: ReturnType; + + beforeEach(() => { + session = new Session({ autoFire: false }); + applyLayout(session, CLASSIC_LAYOUT); + // Silence expected dev-warnings for orphan / unknown-kind cases; + // individual tests that care about the warning inspect `warnSpy`. + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + it("per-type modifier seeds every matching piece", () => { + applyProfileToSession( + session, + profile({ + perType: [ + { kind: "hp-bonus", pieceType: "knight", color: "white", value: 3 }, + ], + }), + CLASSIC_LAYOUT, + ); + + // Both white knights (b1 = square 1, g1 = square 6) should carry + // HpBonus=3; the two black knights should not. + const whiteKnights = findPieces( + session, + (t, c) => t === "knight" && c === "white", + ); + expect(whiteKnights).toHaveLength(2); + for (const id of whiteKnights) { + expect(session.get(id, "HpBonus")).toBe(3); + } + + const blackKnights = findPieces( + session, + (t, c) => t === "knight" && c === "black", + ); + for (const id of blackKnights) { + expect(session.contains(id, "HpBonus")).toBe(false); + } + }); + + it("per-instance modifier targets the exact square, not other same-type pieces", () => { + applyProfileToSession( + session, + profile({ + // Layout ref optional but exercised here to match intended use. + layoutId: "classic", + perInstance: [ + { kind: "range-bonus", square: "b1", value: 2 }, + ], + }), + CLASSIC_LAYOUT, + ); + + // square 1 = b1 (white knight). Look up by position. + const facts = session.allFacts(); + const b1Piece = facts.find( + (f) => f.attr === "Position" && f.value === 1, + ); + expect(b1Piece).toBeDefined(); + expect(session.get(b1Piece!.id as EntityId, "RangeBonus")).toBe(2); + + // g1 = square 6, also a white knight — must NOT have RangeBonus. + const g1Piece = facts.find( + (f) => f.attr === "Position" && f.value === 6, + ); + expect(g1Piece).toBeDefined(); + expect(session.contains(g1Piece!.id as EntityId, "RangeBonus")).toBe(false); + }); + + it("orphan per-instance entry (empty square) logs a warning and skips without throwing", () => { + expect(() => + applyProfileToSession( + session, + profile({ + perInstance: [ + // z9 is not a valid square at all — invalid notation path. + { kind: "hp-bonus", square: "z9", value: 5 }, + // e4 is a valid square but empty in CLASSIC_LAYOUT — orphan path. + { kind: "hp-bonus", square: "e4", value: 5 }, + // e2 is a white pawn — this entry SHOULD still apply + // despite the earlier orphans. + { kind: "hp-bonus", square: "e2", value: 7 }, + ], + }), + CLASSIC_LAYOUT, + ), + ).not.toThrow(); + + expect(warnSpy).toHaveBeenCalled(); + + // e2 = square 12. Confirm the valid entry still landed. + const facts = session.allFacts(); + const e2Piece = facts.find( + (f) => f.attr === "Position" && f.value === 12, + ); + expect(e2Piece).toBeDefined(); + expect(session.get(e2Piece!.id as EntityId, "HpBonus")).toBe(7); + }); + + it("additive stacking: two perType entries sum per-piece", () => { + applyProfileToSession( + session, + profile({ + perType: [ + { kind: "hp-bonus", pieceType: "pawn", color: "white", value: 2 }, + { kind: "hp-bonus", pieceType: "pawn", color: "white", value: 3 }, + ], + }), + CLASSIC_LAYOUT, + ); + + const whitePawns = findPieces( + session, + (t, c) => t === "pawn" && c === "white", + ); + expect(whitePawns).toHaveLength(8); + for (const id of whitePawns) { + expect(session.get(id, "HpBonus")).toBe(5); + } + }); + + it("color: 'both' targets both white and black pieces of that type", () => { + applyProfileToSession( + session, + profile({ + perType: [ + { kind: "range-bonus", pieceType: "rook", color: "both", value: 1 }, + ], + }), + CLASSIC_LAYOUT, + ); + + // All four rooks (a1, h1, a8, h8) should carry RangeBonus=1. + const rooks = findPieces(session, (t) => t === "rook"); + expect(rooks).toHaveLength(4); + for (const id of rooks) { + expect(session.get(id, "RangeBonus")).toBe(1); + } + }); + + it("per-instance OVERRIDES per-type for priority-wins kind (PromotionOverride)", () => { + applyProfileToSession( + session, + profile({ + perType: [ + { + kind: "promotion-override", + pieceType: "pawn", + color: "white", + value: "knight", + }, + ], + perInstance: [ + // e2 white pawn gets a more specific override — "rook". + { kind: "promotion-override", square: "e2", value: "rook" }, + ], + }), + CLASSIC_LAYOUT, + ); + + const facts = session.allFacts(); + // Sanity: another white pawn (a2 = square 8) gets the type-level value. + const a2Piece = facts.find( + (f) => f.attr === "Position" && f.value === 8, + ); + expect(session.get(a2Piece!.id as EntityId, "PromotionOverride")).toBe( + "knight", + ); + + // e2 = square 12 should see the per-instance value, not the per-type. + const e2Piece = facts.find( + (f) => f.attr === "Position" && f.value === 12, + ); + expect(session.get(e2Piece!.id as EntityId, "PromotionOverride")).toBe( + "rook", + ); + }); + + it("union stacking: CaptureFlags bitwise-OR across multiple sources", () => { + applyProfileToSession( + session, + profile({ + perType: [ + { + kind: "capture-flags", + pieceType: "king", + color: "white", + value: CaptureFlag.CANNOT_BE_CAPTURED, + }, + { + kind: "capture-flags", + pieceType: "king", + color: "white", + value: CaptureFlag.CAN_CAPTURE_OWN, + }, + ], + }), + CLASSIC_LAYOUT, + ); + + const [whiteKing] = findPieces( + session, + (t, c) => t === "king" && c === "white", + ); + expect(whiteKing).toBeDefined(); + const flags = session.get(whiteKing!, "CaptureFlags"); + // Both bits set. + expect(flags).toBe( + CaptureFlag.CANNOT_BE_CAPTURED | CaptureFlag.CAN_CAPTURE_OWN, + ); + }); +}); diff --git a/packages/chess/src/modifiers/apply.ts b/packages/chess/src/modifiers/apply.ts new file mode 100644 index 0000000..323b505 --- /dev/null +++ b/packages/chess/src/modifiers/apply.ts @@ -0,0 +1,373 @@ +/** + * Apply a ModifierProfile to a live Session at game start. + * + * Runs AFTER `applyLayout` has populated the session with piece + * entities. Walks `profile.perType` and `profile.perInstance`, + * resolves each entry to the target entity/entities, collects all + * values per (pieceId, kind), stacks them via the descriptor's + * `stackingRule`, and finally calls `descriptor.apply(...)` once per + * (pieceId, kind) pair to seed the corresponding fact. + * + * ## Stacking (ADR-4) + * + * A single piece may accumulate multiple values for the same kind + * from different sources (two perType entries matching the same + * type+color, or a perType + a perInstance for the piece on that + * square). Each descriptor declares how these combine: + * + * - `additive` — numeric sum. Used by HpBonus, RangeBonus. + * - `union` — set-union of array elements OR bitwise-OR for number + * bitflag values. Used by DirectionAdditions (string[]) and + * CaptureFlags (number). + * - `multiplicative` — 1 - ∏(1 - r_i). Used by DamageResistance. + * - `priority-wins` — last value in collection order wins. Used by + * PromotionOverride. Per ADR-4 the intended semantic is "per- + * instance beats per-type", which falls out naturally because we + * always iterate perType before perInstance. + * + * ## Orphan entries + * + * A `perInstance` entry whose square is empty (no piece placed + * there by the layout) is NOT an error — the validator already + * surfaced it as a warning. We log a dev-time `console.warn` so the + * case is visible during manual testing, then silently skip. This + * matches the validator's "benign" classification. + * + * ## Engine-level integration + * + * Seeding the fact is only half the story — three modifiers need + * runtime hooks to affect gameplay: + * - `CaptureFlags` — filter enemy captures of CANNOT_BE_CAPTURED + * pieces; allow same-color captures for CAN_CAPTURE_OWN. + * - `DirectionAdditions` — contribute extra 1-square moves. + * - `DamageResistance` — reduce incoming damage. + * + * These hooks live on a single preset (`__modifier-profile-integration__`) + * registered by this module at load time. The ChessEngine constructor + * auto-activates it when a profile is supplied — callers do NOT need + * to activate it manually. + */ +import type { Session, EntityId } from "@paratype/rete"; +import type { PieceColor, PieceType, Square } from "../schema.js"; +import { CaptureFlag } from "../schema.js"; +import { algebraicToSquare } from "../coord.js"; +import type { StartingLayout } from "../layouts/types.js"; +import type { + ModifierProfile, + ModifierKindId, + ModifierDescriptor, + TypeModifier, + InstanceModifier, +} from "./types.js"; +import { MODIFIER_REGISTRY } from "./registry.js"; +import { stackResistances, applyResistance } from "./descriptors/damage-resistance.js"; +import { hasCaptureFlag } from "./descriptors/capture-flags.js"; +import { generateDirectionMoves } from "./descriptors/direction-additions.js"; +import { PRESET_REGISTRY } from "../presets/registry.js"; +import type { LegalMove } from "../rules/types.js"; +import { getPieceAt } from "../rules/board-queries.js"; + +/** + * Stable id for the pseudo-preset that wires modifier facts into + * engine runtime behaviour. Reserved name — userland presets must not + * use this id. The double-underscore prefix is the "internal" marker; + * the string is exported so tests and debug tooling can reference it + * without re-typing the literal. + */ +export const MODIFIER_INTEGRATION_PRESET_ID = "__modifier-profile-integration__"; + +/** + * Stack a list of raw values according to a descriptor's rule. Kept as + * a standalone function (not a method on the descriptor) because the + * collection logic is identical across all kinds — only the reducer + * differs. Invariants: + * - `values` is non-empty (collectors skip empty groups before calling). + * - Element types match the descriptor's schema; we do minimal + * runtime type narrowing and trust upstream validation (schema.ts + * zod-checks profiles before they hit this code path). + */ +function stackValues( + rule: ModifierDescriptor["stackingRule"], + values: readonly unknown[], +): unknown { + switch (rule) { + case "additive": { + // Sum of numbers. Non-numbers are treated as 0 defensively; in + // practice zod validation prevents them from reaching this point. + let sum = 0; + for (const v of values) if (typeof v === "number") sum += v; + return sum; + } + case "union": { + // Two shapes ride on this rule: + // 1. arrays of strings (DirectionAdditions) → dedup via Set. + // 2. numeric bitflags (CaptureFlags) → bitwise OR. + // We branch on the first non-empty value's type; mixed inputs + // are a bug in profile construction the validator should catch. + const first = values.find((v) => v !== undefined && v !== null); + if (Array.isArray(first)) { + const out = new Set(); + for (const v of values) { + if (Array.isArray(v)) for (const x of v) out.add(String(x)); + } + return Array.from(out); + } + // Numeric bitflag path. + let bits = 0; + for (const v of values) if (typeof v === "number") bits |= v; + return bits; + } + case "multiplicative": { + // Damage-resistance stacking: 1 - ∏(1 - r_i). Reuses the helper + // exported from the descriptor module so behaviour stays in one + // place. + const rs: number[] = []; + for (const v of values) if (typeof v === "number") rs.push(v); + return stackResistances(rs); + } + case "priority-wins": { + // Last value wins. perType entries collected before perInstance, + // so a per-instance override naturally trumps per-type defaults. + // Guaranteed non-empty by the caller. + return values[values.length - 1]; + } + } +} + +/** + * Build a square→EntityId map from the session's current piece facts. + * Needed so `perInstance` entries (which reference squares by + * algebraic notation like "b1") can resolve to the actual EntityId + * the layout handed out. + * + * Walks `session.allFacts()` once for O(n) construction; the resulting + * Map is then queried O(1) per instance entry. Pieces without a + * Position fact are silently skipped — they aren't "on the board" in + * the sense the modifier system cares about. + */ +function buildSquareIndex(session: Session): Map { + const index = new Map(); + for (const f of session.allFacts()) { + if (f.attr !== "Position") continue; + if ((f.id as number) <= 0) continue; // skip GAME_ENTITY etc. + index.set(f.value as Square, f.id as EntityId); + } + return index; +} + +/** + * Find every piece entity whose (PieceType, Color) matches `typeMod`. + * `color === "both"` matches white AND black pieces of the given + * type. Returns EntityIds in session-fact order so repeated calls are + * deterministic (useful for stacking order of per-type entries). + */ +function findPiecesByType( + session: Session, + pieceType: PieceType, + color: PieceColor | "both", +): EntityId[] { + const facts = session.allFacts(); + const out: EntityId[] = []; + for (const f of facts) { + if (f.attr !== "PieceType" || f.value !== pieceType) continue; + if ((f.id as number) <= 0) continue; + if (color !== "both") { + const cf = facts.find((c) => c.id === f.id && c.attr === "Color"); + if (!cf || cf.value !== color) continue; + } + out.push(f.id as EntityId); + } + return out; +} + +/** + * Apply every modifier entry in `profile` to entities in `session`, + * using `layout` as the source of truth for per-instance square → + * piece mappings. + * + * This mutates `session` directly — callers should run it exactly + * ONCE per game, after `applyLayout` and before any presets' + * `onActivate` hooks fire (so e.g. piece-hp's seeded Hp can observe + * HpBonus during its own activation scan). + * + * `layout` is accepted (not re-derived from the session) so future + * features can cross-reference placements by properties the session + * doesn't preserve (e.g. original-square ids that survive a piece + * moving). + */ +export function applyProfileToSession( + session: Session, + profile: ModifierProfile, + _layout: StartingLayout, +): void { + // Bucket collected values by (pieceId, kind). Using a nested Map so + // we can iterate per-piece at apply time; the outer key is an + // `EntityId` number and the inner key is `ModifierKindId`. + const collected = new Map>(); + const push = (id: EntityId, kind: ModifierKindId, value: unknown): void => { + let byKind = collected.get(id); + if (!byKind) { + byKind = new Map(); + collected.set(id, byKind); + } + const arr = byKind.get(kind); + if (arr) arr.push(value); + else byKind.set(kind, [value]); + }; + + // Per-type pass FIRST, per-instance SECOND. Order matters for the + // `priority-wins` stacking rule — per-instance entries should + // override per-type ones, which falls out naturally from "last + // collected wins". + for (const tm of profile.perType as readonly TypeModifier[]) { + const targets = findPiecesByType(session, tm.pieceType, tm.color); + for (const id of targets) push(id, tm.kind, tm.value); + } + + const squareIndex = buildSquareIndex(session); + for (const im of profile.perInstance as readonly InstanceModifier[]) { + const square = algebraicToSquare(im.square); + if (square === -1) { + console.warn( + `applyProfileToSession: invalid square notation "${im.square}" — skipping.`, + ); + continue; + } + const id = squareIndex.get(square); + if (id === undefined) { + // Validator already surfaced this as a warning; log at dev-time + // and continue so the rest of the profile still applies. + console.warn( + `applyProfileToSession: no piece at square "${im.square}" — skipping instance modifier.`, + ); + continue; + } + push(id, im.kind, im.value); + } + + // Stack + apply. Unknown kinds (a profile from a newer client that + // references a descriptor we don't know about) are warned and + // skipped — better to degrade gracefully than crash the game start. + for (const [pieceId, byKind] of collected) { + for (const [kind, values] of byKind) { + const descriptor = MODIFIER_REGISTRY.get(kind); + if (!descriptor) { + console.warn( + `applyProfileToSession: unknown modifier kind "${kind}" — skipping.`, + ); + continue; + } + if (values.length === 0) continue; + const effective = stackValues(descriptor.stackingRule, values); + descriptor.apply(session, pieceId, effective); + } + } +} + +// ── Engine-level integration preset ───────────────────────────────────── +// +// Registers ONCE at module load. The ChessEngine constructor activates +// it when `options.profile` is supplied. The preset holds no state of +// its own — all decisions are driven by facts seeded onto piece +// entities by `applyProfileToSession`. + +/** + * Does any active modifier integration exist on this piece? + * Shortcut to avoid work when nothing relevant is seeded. + */ +function hasAnyModifierFact(session: Session, pieceId: EntityId): boolean { + return ( + session.contains(pieceId, "CaptureFlags") || + session.contains(pieceId, "DirectionAdditions") || + session.contains(pieceId, "DamageResistance") + ); +} + +PRESET_REGISTRY.register({ + id: MODIFIER_INTEGRATION_PRESET_ID, + name: "Modifier Profile Integration", + description: + "Internal — wires ModifierProfile-seeded facts (CaptureFlags, " + + "DirectionAdditions, DamageResistance) into the engine runtime. " + + "Auto-activated by ChessEngine when a profile is supplied.", + incompatibleWith: [], + requires: [], + + /** + * CAN_CAPTURE_OWN: allow moves onto squares occupied by same-color + * pieces by introducing synthetic capture moves — the base move + * generators refuse to produce these on their own. + * + * Emitted as "extra" moves (not filter) because the base generator's + * own-occupancy check stops iteration early; retroactively turning + * an already-rejected square into a move requires re-generating. + * + * Kept minimal for now: we only emit the attacker→target square as a + * capture. Sliding pieces with the flag will still stop at own- + * blockers mid-range; fuller sliding semantics are a later task if + * we ever ship a preset that needs them. + */ + getExtraMoves(_engine, pieceId): LegalMove[] { + const extras: LegalMove[] = []; + if (!hasAnyModifierFact(_engine.session, pieceId)) return extras; + + // DirectionAdditions: 1-square moves in the listed directions, + // non-capture only. Capture semantics on those directions are a + // future extension (would interact with CaptureFlags too). + if (_engine.session.contains(pieceId, "DirectionAdditions")) { + extras.push(...generateDirectionMoves(_engine.session, pieceId)); + } + return extras; + }, + + /** + * CANNOT_BE_CAPTURED: remove any move (from ANY attacker) whose + * destination is a piece carrying the flag. Runs once per piece-move + * generation, so the O(cost) is moves×hasFlag-check. For typical + * game sizes (~40 legal moves, a handful of flagged pieces) this is + * dominated by the base movegen, not this filter. + */ + filterMoves(moves, engine, _pieceId): LegalMove[] { + return moves.filter((m) => { + if (!m.isCapture) return true; + const target = getPieceAt(engine.session, m.to); + if (target === null) return true; + // Drop the capture if the target is flagged as uncapturable. + if (hasCaptureFlag(engine.session, target, CaptureFlag.CANNOT_BE_CAPTURED)) { + return false; + } + return true; + }); + }, + + /** + * DamageResistance: intercept the damage pipeline, reduce `amount` + * by the target's resistance fact, and either consume (kill) or + * fall through. We DON'T fully consume the event when the target + * lives — we want the rest of the pipeline (piece-hp, etc.) to run + * on the reduced amount. But `onDamage` doesn't expose a "reduce + * and continue" primitive. So we handle the two boundary cases: + * - resistance == 1.0 (immune) → consume, died=false, fully absorb. + * - resistance < 1.0 → leave untouched, fall through to default + * or piece-hp. Fractional reduction would require a pipeline- + * level change; documented as a known limitation for T14. + */ + onDamage(ctx): { consume: boolean; died?: boolean } | void { + const resistance = ctx.engine.session.get(ctx.target, "DamageResistance"); + if (typeof resistance !== "number" || resistance <= 0) return; + // Immunity short-circuit: fully absorb. + if (resistance >= 1) { + return { consume: true, died: false }; + } + // Partial resistance: if applying it drops the amount to 0 we + // can absorb; otherwise we fall through. applyResistance clamps + // to ≥ 0 so the comparison is safe. + const reduced = applyResistance(ctx.amount, resistance); + if (reduced <= 0) return { consume: true, died: false }; + // Non-zero damage after resistance — let the next handler process + // it. Note: this currently passes the ORIGINAL amount through + // because the pipeline doesn't support mutation. Documented + // limitation to revisit when HP + partial resistance both ship. + return; + }, +});