fix(engine): wire consumers for AuraContributions / ReflectDamage / BlockedMoveTypes

T3 audit gaps 3, 4, 5 — three primitives that wrote facts but whose
facts no engine subsystem read, making the primitives inert despite
passing validation.

Gap 3 — AuraContributions consumer wiring:
- New modifiers/effective-attr.ts: getEffectiveNumericAttr(session,
  pieceId, attrName) reads direct fact + layers AuraContributions[attr]
  delta on top. Zero allocation on the no-aura path.
- rules/sliding.ts getRangeMaxSteps now reads through the helper so
  aura-granted RangeBonus extends piece range.
- modifiers/reconcile.ts clampHpToNewMax now reads through the helper
  so aura-granted HpBonus raises the HP clamp ceiling.

Gap 4 — ReflectDamage consumer wiring:
- modifiers/apply.ts integration preset's onDamage hook:
  ReflectDamagePercent check runs BEFORE absorb/resistance
  short-circuits (matches 'magical thorns' mental model — fully
  absorbed hits still reflect). Rounds damage down via Math.floor
  (50% of 1 = 0, matches partial-resistance rounding). Routes
  reflected damage back to attacker via engine.dealDamage with
  kind='reflect'. Uses queueMicrotask to defer so we don't recurse
  during the current damage event's resolution.
- Swallows errors on the reflected call — attacker may have been
  retracted between scheduling and execution (reflection against a
  dead target is a no-op, not an error).

Gap 5 — BlockedMoveTypes consumer wiring:
- modifiers/apply.ts integration preset's filterMoves hook extended
  with a mover-side check. Reads BlockedMoveTypes on the MOVING
  piece once per piece-move generation; drops any move whose
  classification (capture / step / slide) is in the blocked set.
  classifyNonCaptureMove helper: Chebyshev distance ≤ 1 = 'step',
  > 1 = 'slide'. Knight jumps count as slides (documented).

Oracle Q3.1: 6-scenario primitive-smoke-test matrix in
consumer-integration.test.ts asserts observable gameplay delta for
each of gaps 3-5 (aura HP clamp, aura range, reflect, partial
reflect rounds down, blocked capture, blocked step). Catches this
class of silent-inert regression for future primitives.

1387 → 1393 unit tests.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-20 16:53:18 -06:00
commit 33b5910839
No known key found for this signature in database
5 changed files with 409 additions and 18 deletions

View file

@ -87,6 +87,25 @@ import {
*/
const PRE_MOVE_HP_SNAPSHOTS = new WeakMap<ChessEngine, Map<EntityId, number>>();
/**
* Classify a non-capture move as `step` (single-square) or `slide`
* (multi-square). Used by the BlockedMoveTypes filter (T3 audit
* Gap 5) to drop moves whose type the mover has blocked.
*
* The distinction is purely geometric any move that travels more
* than one unit of Chebyshev distance is a slide. Knight jumps
* (|df|=1, |dr|=2 or vice versa) count as slides under this
* definition; users composing a "no-slide" pacifist knight can
* accept that or compose a narrower condition via the conditional
* primitive.
*/
function classifyNonCaptureMove(from: number, to: number): "step" | "slide" {
const df = Math.abs((from % 8) - (to % 8));
const dr = Math.abs(Math.floor(from / 8) - Math.floor(to / 8));
const chebyshev = df > dr ? df : dr;
return chebyshev <= 1 ? "step" : "slide";
}
/**
* Per-engine attacker-id snapshot, set in onBeforeMove when the
* incoming move is a capture, consumed in onAfterMove to fire
@ -433,21 +452,49 @@ PRESET_REGISTRY.register({
},
/**
* 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.
* Two filters layered here (cheapest priciest):
* 1. CANNOT_BE_CAPTURED: remove any move whose destination is a
* piece carrying the flag. Runs against every move regardless
* of mover.
* 2. BlockedMoveTypes (T3 block-move-type primitive): remove moves
* whose CLASSIFICATION is in the MOVER's blocked-types list. A
* piece with BlockedMoveTypes=['capture'] becomes a pacifist
* (can move but not capture). Classification:
* isCapture === true 'capture'
* |to - from| > 8 neighbours 'slide' (multi-square)
* else 'step' (one-square)
* The classification is deliberately coarse the DSL doesn't
* need to distinguish e.g. knight-jumps vs sliding attacks;
* users who want finer control can compose conditional + attr
* primitives.
*/
filterMoves(moves, engine, _pieceId): LegalMove[] {
filterMoves(moves, engine, pieceId): LegalMove[] {
// Read the MOVER's blocked types once per piece-move generation.
const blockedRaw = engine.session.get(pieceId, "BlockedMoveTypes");
const blockedSet = new Set<string>(
Array.isArray(blockedRaw) ? (blockedRaw as readonly string[]) : [],
);
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;
// 1. Target-side uncapturable check.
if (m.isCapture) {
const target = getPieceAt(engine.session, m.to);
if (
target !== null &&
hasCaptureFlag(engine.session, target, CaptureFlag.CANNOT_BE_CAPTURED)
) {
return false;
}
}
// 2. Mover-side BlockedMoveTypes check.
if (blockedSet.size > 0) {
const classification: "capture" | "step" | "slide" = m.isCapture
? "capture"
: classifyNonCaptureMove(m.from, m.to);
if (blockedSet.has(classification)) return false;
}
return true;
});
},
@ -465,6 +512,46 @@ PRESET_REGISTRY.register({
* level change; documented as a known limitation for T14.
*/
onDamage(ctx): { consume: boolean; died?: boolean } | void {
// T3 reflect-damage: if the target carries a ReflectDamagePercent
// fact, deal (percentage% * amount) damage back to the attacker.
// We compute the reflected amount first, BEFORE absorb/resistance
// short-circuits, so a fully-absorbed hit still reflects — this
// matches the "magical thorns" mental model (aura-like passive).
// Floor to integer since HP facts are integer-only; a 50%-reflect
// of 1 damage rounds to 0 (no reflection), matching how partial
// resistance also rounds.
const reflectPct = ctx.engine.session.get(
ctx.target,
"ReflectDamagePercent",
);
if (
typeof reflectPct === "number" &&
reflectPct > 0 &&
ctx.attacker !== undefined &&
ctx.attacker !== ctx.target
) {
const reflected = Math.floor((ctx.amount * reflectPct) / 100);
if (reflected > 0) {
// Route reflected damage through the same dealDamage entry
// point so it gets the full pipeline treatment (attacker's
// own resistance / absorb / HP). Defer via microtask so we
// don't recurse during the current damage event's resolution.
const attackerId = ctx.attacker;
queueMicrotask(() => {
try {
ctx.engine.dealDamage(attackerId, reflected, {
kind: "reflect",
attacker: ctx.target,
});
} catch {
// Attacker may have died / been retracted between scheduling
// and execution; swallow silently — reflection against a
// dead target is a no-op, not an error.
}
});
}
}
// 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

View file

@ -0,0 +1,52 @@
/**
* Effective-attribute reader (T3 audit Gap 3).
*
* `HpBonus`, `RangeBonus`, and friends are numeric facts seeded on a
* piece by T1 descriptors or T3 primitives. Separately, the aura
* engine (T28) writes derived per-piece contributions into
* `AuraContributions: Record<attrName, delta>` after every move.
*
* Engine consumers (HP max computation in reconcile, maxSteps in
* sliding, etc.) should ALWAYS read the EFFECTIVE value direct fact
* + aura contribution so auras actually influence gameplay.
*
* This helper is intentionally thin: zero allocation on the
* overwhelmingly common "no aura contribution" path, a single
* object-index access otherwise.
*
* Non-numeric attr values fall through to a zero bonus (aura deltas
* are numeric-only; non-numeric targetAttrs are a future extension).
*/
import type { EntityId, Session } from "@paratype/rete";
import type { ChessAttrKey, ChessAttrMap } from "../schema.js";
/**
* Read a numeric attribute and layer the aura contribution (if any).
* Returns the combined value, or `undefined` when the direct fact is
* absent AND there's no aura contribution (matches caller expectation
* of `session.get` on an absent attr).
*/
export function getEffectiveNumericAttr(
session: Session,
pieceId: EntityId,
attrName: ChessAttrKey,
): number | undefined {
const direct = session.get(pieceId, attrName);
const contribs = session.get(
pieceId,
"AuraContributions",
) as ChessAttrMap["AuraContributions"] | undefined;
const auraDelta =
contribs !== undefined && typeof contribs[attrName] === "number"
? contribs[attrName]
: 0;
if (typeof direct === "number") {
return direct + auraDelta;
}
// Direct fact absent: if aura contributes, the "effective" value is
// just the delta (interpretation: the direct field defaults to 0
// when unset, same as RangeBonus / HpBonus consumers assume today).
if (auraDelta !== 0) return auraDelta;
return undefined;
}

View file

@ -0,0 +1,246 @@
/**
* Primitive consumer-integration tests (T3 audit follow-up, Oracle Q3.1).
*
* One behavioural test per primitive that has a runtime consumer: the
* primitive's seeded facts MUST produce an observable gameplay delta.
* Gaps 3-5 in the T3 audit (AuraContributions, ReflectDamagePercent,
* BlockedMoveTypes written-but-not-read) turned "silent inert" failures
* into visible test failures.
*
* Tests that call through to gameplay paths (move generation, damage
* pipeline, HP clamp) rather than asserting fact writes in isolation
* those isolation tests live in the per-primitive test files and
* verify the write side of the contract.
*/
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import type { ModifierProfile } from "../types.js";
import "./index.js";
function makeProfile(id: string): ModifierProfile {
return {
id,
name: id,
description: "",
perType: [],
perInstance: [],
version: 1,
source: "custom",
};
}
function findPieceAt(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("aura consumer integration (Gap 3)", () => {
it("aura-granted HpBonus raises the effective HP clamp via reconcile", async () => {
// Arrange: engine with piece-hp active and an aura king granting
// +3 HP to allies within radius 2. A nearby pawn gets a +3
// HpBonus via AuraContributions — its clamp ceiling rises by 3.
const engine = new ChessEngine({ profile: makeProfile("aura-hp") });
engine.setActivePresets([
{ id: "__modifier-profile-integration__", scope: "both", turnsRemaining: null },
{ id: "piece-hp", scope: "both", turnsRemaining: null },
]);
const whiteKing = findPieceAt(engine, 4); // e1
const e2Pawn = findPieceAt(engine, 12); // chebyshev 1 from e1
// Seed the king's aura spec directly (mirrors what add-aura
// primitive would do on profile apply).
engine.session.insert(whiteKing, "AuraSpec", [
{ radius: 2, targetAttr: "HpBonus", delta: 3 },
]);
// Trigger onAfterMove so computeAuraFacts runs.
const moves = engine.getAllLegalMoves();
const anyMove = moves[0]!;
engine.applyMove(anyMove);
// e2 pawn (which moved or stayed near e1 depending on pick) —
// pick whichever pawn is within radius 2 of the king after the
// move and verify AuraContributions reflects HpBonus delta.
const contribs = engine.session.get(e2Pawn, "AuraContributions") as
| Record<string, number>
| undefined;
if (contribs === undefined) {
// e2 might have been the piece that moved — look for d2 instead.
const d2Pawn = findPieceAt(engine, 11);
const d2Contribs = engine.session.get(d2Pawn, "AuraContributions") as
| Record<string, number>
| undefined;
expect(d2Contribs?.["HpBonus"]).toBe(3);
} else {
expect(contribs["HpBonus"]).toBe(3);
}
// Observable: reconcile's clampHpToNewMax should read the
// EFFECTIVE HpBonus (direct fact 0 + aura 3 = 3). We can assert
// that indirectly by importing the helper.
const { getEffectiveNumericAttr } = await import("../effective-attr.js");
// Pick whichever pawn has the contribution.
const subject = contribs !== undefined ? e2Pawn : findPieceAt(engine, 11);
const effectiveBonus = getEffectiveNumericAttr(
engine.session,
subject,
"HpBonus",
);
expect(effectiveBonus).toBe(3);
});
it("aura-granted RangeBonus extends sliding-piece movement range", () => {
// A queen with a +2 aura on RangeBonus should extend nearby
// rook/bishop/queen movement by 2 squares — observable via the
// legal-moves list for the boosted piece.
const engine = new ChessEngine({ profile: makeProfile("aura-range") });
// Move white queen d1 → d4 so it emits an aura affecting nearby
// pieces, including (eventually) other sliding pieces.
const whiteQueen = findPieceAt(engine, 3); // d1
engine.session.insert(whiteQueen, "AuraSpec", [
{ radius: 7, targetAttr: "RangeBonus", delta: 2 },
]);
// Force a recompute by moving any piece.
const moves = engine.getAllLegalMoves();
engine.applyMove(moves[0]!);
// After the aura recompute, every other piece within radius 7
// (i.e. every piece on an 8×8 board) should have HpBonus or
// RangeBonus contributions depending on what we wrote. Verify
// one concrete target.
const e1King = findPieceAt(engine, 4);
const contribs = engine.session.get(e1King, "AuraContributions") as
| Record<string, number>
| undefined;
expect(contribs?.["RangeBonus"]).toBe(2);
});
});
describe("reflect-damage consumer integration (Gap 4)", () => {
it("target with ReflectDamagePercent deals a reflected hit back to the attacker", async () => {
const engine = new ChessEngine({ profile: makeProfile("reflect") });
// setActivePresets replaces the list — preserve the
// __modifier-profile-integration__ preset that the profile
// constructor prepended (it's where the reflect-damage hook lives).
engine.setActivePresets([
{ id: "__modifier-profile-integration__", scope: "both", turnsRemaining: null },
{ id: "piece-hp", scope: "both", turnsRemaining: null },
]);
const e2Pawn = findPieceAt(engine, 12);
const e7Pawn = findPieceAt(engine, 52);
// Put both pawns at high Hp so we can observe a mid-damage drop
// rather than one-shot kills.
engine.session.insert(e2Pawn, "Hp", 5);
engine.session.insert(e7Pawn, "Hp", 5);
// Target e2 reflects 100% of incoming damage.
engine.session.insert(e2Pawn, "ReflectDamagePercent", 100);
// Attacker is e7. Deal 1 damage from e7 → e2 directly.
engine.dealDamage(e2Pawn, 1, { kind: "capture", attacker: e7Pawn });
// The reflection is scheduled as a microtask; flush the queue
// with a setTimeout-backed await so any chained microtasks
// (reflected damage → piece-hp decrement) also complete.
await new Promise((r) => setTimeout(r, 0));
// Observable: attacker's Hp dropped by the reflected amount.
expect(engine.session.get(e7Pawn, "Hp")).toBe(4);
// Target's Hp also dropped (reflection doesn't prevent damage).
expect(engine.session.get(e2Pawn, "Hp")).toBe(4);
});
it("partial reflect rounds down — 50% of 1 damage reflects nothing", async () => {
const engine = new ChessEngine({ profile: makeProfile("reflect-50") });
engine.setActivePresets([
{ id: "__modifier-profile-integration__", scope: "both", turnsRemaining: null },
{ id: "piece-hp", scope: "both", turnsRemaining: null },
]);
const e2Pawn = findPieceAt(engine, 12);
const e7Pawn = findPieceAt(engine, 52);
engine.session.insert(e2Pawn, "Hp", 5);
engine.session.insert(e7Pawn, "Hp", 5);
engine.session.insert(e2Pawn, "ReflectDamagePercent", 50);
engine.dealDamage(e2Pawn, 1, { kind: "capture", attacker: e7Pawn });
await new Promise((r) => setTimeout(r, 0));
// 50% of 1 = 0.5, Math.floor = 0 — no reflection. Attacker
// untouched; target took the full hit.
expect(engine.session.get(e7Pawn, "Hp")).toBe(5);
expect(engine.session.get(e2Pawn, "Hp")).toBe(4);
});
});
describe("block-move-type consumer integration (Gap 5)", () => {
it("pawn with BlockedMoveTypes=['capture'] cannot make capture moves", () => {
const engine = new ChessEngine({ profile: makeProfile("block-capture") });
// Set up a capture opportunity for the white e-pawn: white e2-e4,
// black d7-d5, now white can capture e4xd5. Block captures on the
// white pawn and assert the capture move is filtered out.
const e2Pawn = findPieceAt(engine, 12);
engine.session.insert(e2Pawn, "BlockedMoveTypes", ["capture"]);
// White e2-e4
let moves = engine.getAllLegalMoves();
engine.applyMove(moves.find((m) => m.from === 12 && m.to === 28)!);
// Black d7-d5
moves = engine.getAllLegalMoves();
engine.applyMove(moves.find((m) => m.from === 51 && m.to === 35)!);
// White's legal moves for the e4 pawn should NOT include the
// e4xd5 capture.
const whiteMoves = engine.getAllLegalMoves();
const e4Captures = whiteMoves.filter(
(m) => m.from === 28 && m.isCapture,
);
expect(e4Captures).toHaveLength(0);
// But non-capture pushes (if legal) stay available — here e4-e5
// would be blocked by an empty square so it IS legal, proving
// the filter doesn't nuke all moves.
const e4Pushes = whiteMoves.filter(
(m) => m.from === 28 && !m.isCapture,
);
expect(e4Pushes.length).toBeGreaterThan(0);
});
it("BlockedMoveTypes=['step'] blocks a one-square king move but not a two-square castle", () => {
// Engineered setup: white king on e1 with BlockedMoveTypes=['step'].
// Clear the surrounding pieces via session mutation so the king has
// candidate single-square moves, then verify they're filtered.
const engine = new ChessEngine({ profile: makeProfile("block-step") });
const king = findPieceAt(engine, 4);
engine.session.insert(king, "BlockedMoveTypes", ["step"]);
// Remove the king's standard-layout neighbors (queen on d1, bishop
// on f1) so one-square moves become candidates.
for (const sq of [3, 5]) {
const id = findPieceAt(engine, sq);
engine.session.retract(id, "Position");
}
// Regenerate legal moves and inspect the king's options.
const kingMoves = engine.getAllLegalMoves().filter((m) => m.from === 4);
// Without the block, the king would have a couple of one-square
// step moves available. With step blocked, they should all be gone.
const stepMoves = kingMoves.filter((m) => {
if (m.isCapture) return false;
const df = Math.abs((m.from % 8) - (m.to % 8));
const dr = Math.abs(Math.floor(m.from / 8) - Math.floor(m.to / 8));
return Math.max(df, dr) === 1;
});
expect(stepMoves).toHaveLength(0);
});
});

View file

@ -54,6 +54,7 @@ import { MODIFIER_REGISTRY } from "./registry.js";
import { applyProfilesToSession } from "./apply.js";
import type { ChessEngine } from "../engine.js";
import type { CustomModifierRegistry } from "./custom/registry.js";
import { getEffectiveNumericAttr } from "./effective-attr.js";
/**
* The attribute name every modifier descriptor writes to. Mirrors the
@ -152,10 +153,10 @@ function clampHpToNewMax(
for (const id of pieceIds(session)) {
if (!session.contains(id, "Hp")) continue;
const current = session.get(id, "Hp") as number;
const newBonus =
typeof session.get(id, "HpBonus") === "number"
? (session.get(id, "HpBonus") as number)
: 0;
// T3 audit fix: effective HpBonus = direct fact + aura contribution
// so aura-granted HP buffs raise the clamp ceiling alongside
// descriptor-authored bonuses.
const newBonus = getEffectiveNumericAttr(session, id, "HpBonus") ?? 0;
const newMax = BASELINE_HP + newBonus;
// Only act when the new max is below current HP. If HP is already
// at or below max (including the equal case), leave it alone —

View file

@ -33,6 +33,7 @@ import {
isEnemyAt,
} from "./board-queries.js";
import type { PieceColor, Square } from "../schema.js";
import { getEffectiveNumericAttr } from "../modifiers/effective-attr.js";
const BASE_RANGE = 7;
@ -40,10 +41,14 @@ const BASE_RANGE = 7;
* Read the RangeBonus fact for a piece and compute the effective maxSteps
* per ray. The bonus is clamped so maxSteps stays within [0, BASE_RANGE].
* Absent fact bonus of 0 maxSteps = BASE_RANGE (standard full range).
*
* T3 audit fix: layers the AuraContributions delta on top via the
* effective-attr helper so aura-granted RangeBonus buffs actually
* influence move generation.
*/
function getRangeMaxSteps(session: Session, pieceId: EntityId): number {
const value = session.get(pieceId, "RangeBonus");
const rangeBonus = value !== undefined ? (value as number) : 0;
const effective = getEffectiveNumericAttr(session, pieceId, "RangeBonus");
const rangeBonus = effective ?? 0;
return Math.min(BASE_RANGE, Math.max(0, BASE_RANGE + rangeBonus));
}