feat(engine): aura effect computation + onAfterMove hook

T3 Wave 4 (T28). Wires the add-aura primitive's seeded AuraSpec facts
into a runtime recomputation that produces AuraContributions on
affected pieces every move.

- computeAuraFacts(session):
    1. Retracts every AuraContributions fact (clean slate).
    2. Walks every piece with an AuraSpec list and, for each aura,
       finds all in-range pieces via Chebyshev (king-move) distance
       and accumulates deltas per (targetId, targetAttr).
    3. Commits the staging map as AuraContributions = { attr → delta }
       on each affected piece. Empty maps are NOT written, so
       unaffected pieces return undefined for session.get(id, 'AuraContributions').
- New ChessAttrMap entry: AuraContributions = Readonly<Record<string, number>>.
- __modifier-profile-integration__ preset gains an onAfterMove hook
  that calls computeAuraFacts(session) after every successful move.
  Self-application is skipped; source moving out of range retracts
  contribution on next recompute.

9 vitest scenarios: neighbour coverage, out-of-range exclusion,
multi-source accumulation, self-application skip, stale retraction
on source move, idempotency, multi-attr per source, empty-state
no-op, and the engine end-to-end wiring via onAfterMove.

Consumer wiring (HpBonus + AuraContributions[HpBonus] compose at
effective-attr read points) is deferred — this task delivers the
infrastructure and the recompute cadence; downstream readers
integrate on an as-needed basis.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 20:05:38 -06:00
commit 9441570349
No known key found for this signature in database
4 changed files with 349 additions and 0 deletions

View file

@ -69,6 +69,7 @@ import { getPieceAt } from "../rules/board-queries.js";
import type { ChessEngine } from "../engine.js";
import type { CustomModifierRegistry } from "./custom/registry.js";
import { applyCustomDescriptor } from "./custom/apply.js";
import { computeAuraFacts } from "./auras.js";
/**
* Stable id for the pseudo-preset that wires modifier facts into
@ -457,4 +458,17 @@ PRESET_REGISTRY.register({
// limitation to revisit when HP + partial resistance both ship.
return;
},
/**
* 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.
*/
onAfterMove(ctx): void {
computeAuraFacts(ctx.engine.session);
},
});

View file

@ -0,0 +1,211 @@
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../engine.js";
import type { ChessAttrMap } from "../schema.js";
import { computeAuraFacts } from "./auras.js";
import "./primitives/index.js";
function findPieceAtSquare(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}`);
}
function getContribs(
engine: ChessEngine,
pieceId: ReturnType<typeof findPieceAtSquare>,
): Record<string, number> | undefined {
return engine.session.get(pieceId, "AuraContributions") as
| Record<string, number>
| undefined;
}
function seedAura(
engine: ChessEngine,
sourceId: ReturnType<typeof findPieceAtSquare>,
aura: ChessAttrMap["AuraSpec"][number],
): void {
const existing =
(engine.session.get(sourceId, "AuraSpec") as
| ChessAttrMap["AuraSpec"]
| undefined) ?? [];
engine.session.insert(sourceId, "AuraSpec", [...existing, aura]);
}
describe("computeAuraFacts", () => {
it("radius-1 aura contributes to 8 neighbours (king on e4 hits d3..f5)", () => {
const engine = new ChessEngine();
// Place a lone "source" conceptually on e4. Re-use the white king
// (square 4 = e1) — auras don't care about piece type. We'll move
// it to e4 (square 28) by overwriting its Position.
const kingId = findPieceAtSquare(engine, 4);
engine.session.insert(kingId, "Position", 28);
seedAura(engine, kingId, { radius: 1, targetAttr: "HpBonus", delta: 1 });
computeAuraFacts(engine.session);
// d3 (19), e3 (20), f3 (21), d4 (27), f4 (29), d5 (35), e5 (36), f5 (37)
// Most of those squares are empty in the classic layout (ranks 3-6
// are empty). Only d5/e5/f5 etc. are empty. But the king moved
// from e1, so e1 is now empty. We need pieces IN range to observe
// contributions. Pawns on e2 (12) = rank 2, and the kings are
// typically at the back ranks — so with the king at e4, let's
// check a PAWN that's within range. e2 pawn (sq 12) is at
// chebyshev(28, 12) = max(0, 2) = 2, OUT of radius 1. Move a pawn
// to e3 (sq 20) to test.
const e2Pawn = findPieceAtSquare(engine, 12);
engine.session.insert(e2Pawn, "Position", 20);
computeAuraFacts(engine.session);
const contribs = getContribs(engine, e2Pawn);
expect(contribs).toBeDefined();
expect(contribs!["HpBonus"]).toBe(1);
});
it("a piece outside the radius gets no contribution", () => {
const engine = new ChessEngine();
const kingId = findPieceAtSquare(engine, 4); // e1
seedAura(engine, kingId, { radius: 1, targetAttr: "HpBonus", delta: 1 });
computeAuraFacts(engine.session);
// e2 pawn (square 12) is chebyshev 1 — IN range.
const e2Pawn = findPieceAtSquare(engine, 12);
const e2Contribs = getContribs(engine, e2Pawn);
expect(e2Contribs?.["HpBonus"]).toBe(1);
// e7 pawn (square 52) is chebyshev 6 — OUT of range.
const e7Pawn = findPieceAtSquare(engine, 52);
expect(getContribs(engine, e7Pawn)).toBeUndefined();
});
it("multiple auras from different sources accumulate additively", () => {
const engine = new ChessEngine();
const whiteKing = findPieceAtSquare(engine, 4); // e1
const whiteQueen = findPieceAtSquare(engine, 3); // d1
seedAura(engine, whiteKing, {
radius: 2,
targetAttr: "HpBonus",
delta: 1,
});
seedAura(engine, whiteQueen, {
radius: 2,
targetAttr: "HpBonus",
delta: 2,
});
computeAuraFacts(engine.session);
// d2 pawn (11) is radius 1 from e1 AND radius 1 from d1 → both hit.
const d2Pawn = findPieceAtSquare(engine, 11);
expect(getContribs(engine, d2Pawn)?.["HpBonus"]).toBe(3);
});
it("a piece's own aura does not apply to itself", () => {
const engine = new ChessEngine();
const kingId = findPieceAtSquare(engine, 4);
seedAura(engine, kingId, { radius: 7, targetAttr: "HpBonus", delta: 5 });
computeAuraFacts(engine.session);
expect(getContribs(engine, kingId)).toBeUndefined();
});
it("recompute retracts stale contributions from sources that moved out of range", () => {
const engine = new ChessEngine();
const kingId = findPieceAtSquare(engine, 4); // e1
const e2Pawn = findPieceAtSquare(engine, 12);
seedAura(engine, kingId, { radius: 1, targetAttr: "HpBonus", delta: 1 });
computeAuraFacts(engine.session);
expect(getContribs(engine, e2Pawn)?.["HpBonus"]).toBe(1);
// Move the king to a1 (square 0) — now e2 is chebyshev 4, out of radius 1.
engine.session.insert(kingId, "Position", 0);
computeAuraFacts(engine.session);
expect(getContribs(engine, e2Pawn)).toBeUndefined();
});
it("is idempotent: calling twice without state change produces the same contributions", () => {
const engine = new ChessEngine();
const kingId = findPieceAtSquare(engine, 4);
seedAura(engine, kingId, { radius: 1, targetAttr: "HpBonus", delta: 3 });
computeAuraFacts(engine.session);
const first = getContribs(engine, findPieceAtSquare(engine, 12));
computeAuraFacts(engine.session);
const second = getContribs(engine, findPieceAtSquare(engine, 12));
expect(second).toEqual(first);
});
it("multiple aura specs on ONE source (different attrs) all contribute", () => {
const engine = new ChessEngine();
const kingId = findPieceAtSquare(engine, 4);
engine.session.insert(kingId, "AuraSpec", [
{ radius: 1, targetAttr: "HpBonus", delta: 1 },
{ radius: 1, targetAttr: "RangeBonus", delta: 2 },
]);
computeAuraFacts(engine.session);
const e2Pawn = findPieceAtSquare(engine, 12);
const contribs = getContribs(engine, e2Pawn);
expect(contribs?.["HpBonus"]).toBe(1);
expect(contribs?.["RangeBonus"]).toBe(2);
});
it("no auras anywhere → no AuraContributions facts written", () => {
const engine = new ChessEngine();
computeAuraFacts(engine.session);
for (const f of engine.session.allFacts()) {
expect(f.attr).not.toBe("AuraContributions");
}
});
it("engine's onAfterMove hook recomputes auras automatically when a profile is active", async () => {
// Use a no-op modifier profile so the __modifier-profile-integration__
// preset auto-activates — that's the only way onAfterMove fires
// computeAuraFacts without any explicit caller.
const { applyProfileToSession: _apply } = await import("./apply.js");
void _apply;
const minimalProfile = {
id: "test-minimal-profile",
name: "Minimal",
description: "",
perType: [],
perInstance: [],
version: 1 as const,
source: "custom" as const,
};
const engine = new ChessEngine({ profile: minimalProfile });
const kingId = findPieceAtSquare(engine, 4); // e1
seedAura(engine, kingId, { radius: 1, targetAttr: "HpBonus", delta: 1 });
// Before any move → contribs not yet computed.
const e2Before = getContribs(engine, findPieceAtSquare(engine, 12));
expect(e2Before).toBeUndefined();
// Apply any legal move (e2 → e4). After the move, onAfterMove fires
// and auras get recomputed. e2 pawn moved to e4 (square 28); it's
// now at chebyshev 3 from the king on e1 — out of radius 1.
// Meanwhile, the king still emits — d2/e2 (now empty)/f2 are in
// range and d2/f2 pawns get +1 HpBonus from the king.
const moves = engine.getAllLegalMoves();
const e2e4 = moves.find((m) => m.from === 12 && m.to === 28);
expect(e2e4).toBeDefined();
engine.applyMove(e2e4!);
const d2Pawn = findPieceAtSquare(engine, 11);
expect(getContribs(engine, d2Pawn)?.["HpBonus"]).toBe(1);
});
});

View file

@ -0,0 +1,117 @@
/**
* Aura engine integration (T28).
*
* The `add-aura` primitive (T14) seeds `AuraSpec` facts on a SOURCE
* piece declaring an emitted aura: `{ radius, targetAttr, delta }`.
* At runtime each aura contributes `delta` to every in-range TARGET
* piece's `targetAttr` attribute. "In-range" uses Chebyshev distance
* (king-move metric): a radius of 1 covers the 8 neighbours of the
* source square; radius 2 covers a 5×5 box minus the source.
*
* Design:
* - Auras are recomputed by `computeAuraFacts` after every successful
* move. Source or target moving OUT of / INTO range takes effect
* on the NEXT turn (no mid-move phases).
* - Contributions are written to a dedicated `AuraContributions`
* attr a map keyed by target-attr name so they never stomp
* attribute writes from the modifier profile itself (HpBonus from
* the profile + HpBonus from an aura compose as two separate
* records consumers can blend).
* - Sources and targets are independent. A piece can emit auras AND
* be affected by other pieces' auras. Self-application is skipped.
* - Empty contribution maps are retracted rather than written as `{}`
* so `session.get(id, "AuraContributions")` returns `undefined` on
* unaffected pieces (matches the convention for other optional
* modifier attrs).
*/
import type { EntityId, Session } from "@paratype/rete";
import type { ChessAttrMap, Square } from "../schema.js";
import { fileOf, rankOf } from "../coord.js";
/**
* Recompute `AuraContributions` on every piece in the session.
* Idempotent: calling twice produces the same state.
*
* Strategy:
* 1. Retract the existing `AuraContributions` fact on every piece
* (clean slate avoids stale contributions from sources that
* moved out of range since the last compute).
* 2. Walk every piece that has an `AuraSpec` list and, for each
* aura, iterate every in-range piece. Accumulate per-target
* per-attr deltas into a staging map.
* 3. Commit the staging map: for each affected piece, write the
* accumulated contributions as a single `AuraContributions` fact.
*/
export function computeAuraFacts(session: Session): void {
// Step 1: gather every piece on the board with its square.
const pieces = collectPiecesWithPositions(session);
// Step 2: retract stale contributions everywhere.
for (const { id } of pieces) {
if (session.contains(id, "AuraContributions")) {
session.retract(id, "AuraContributions");
}
}
// Step 3: walk aura sources and accumulate contributions.
//
// `staging` maps target pieceId → (attr → delta).
const staging = new Map<EntityId, Map<string, number>>();
for (const source of pieces) {
const auras = session.get(source.id, "AuraSpec") as
| ChessAttrMap["AuraSpec"]
| undefined;
if (auras === undefined || auras.length === 0) continue;
for (const aura of auras) {
for (const target of pieces) {
if (target.id === source.id) continue; // no self-application
if (chebyshev(source.square, target.square) > aura.radius) continue;
let byAttr = staging.get(target.id);
if (byAttr === undefined) {
byAttr = new Map();
staging.set(target.id, byAttr);
}
const prev = byAttr.get(aura.targetAttr) ?? 0;
byAttr.set(aura.targetAttr, prev + aura.delta);
}
}
}
// Step 4: commit.
for (const [targetId, byAttr] of staging) {
if (byAttr.size === 0) continue;
const record: Record<string, number> = {};
for (const [attr, delta] of byAttr) record[attr] = delta;
session.insert(targetId, "AuraContributions", record);
}
}
// ── Helpers ───────────────────────────────────────────────────────────
interface PieceWithSquare {
readonly id: EntityId;
readonly square: Square;
}
function collectPiecesWithPositions(session: Session): PieceWithSquare[] {
const out: PieceWithSquare[] = [];
for (const f of session.allFacts()) {
if (f.attr !== "Position") continue;
if ((f.id as number) <= 0) continue;
out.push({ id: f.id, square: f.value as Square });
}
return out;
}
/**
* Chebyshev (chess king) distance between two squares. Range 0..7.
* Two squares are "within radius R" when chebyshev R.
*/
function chebyshev(a: Square, b: Square): number {
const df = Math.abs(fileOf(a) - fileOf(b));
const dr = Math.abs(rankOf(a) - rankOf(b));
return df > dr ? df : dr;
}

View file

@ -77,6 +77,13 @@ export interface ChessAttrMap {
readonly targetAttr: string;
readonly delta: number;
}[];
/**
* Aura contributions DERIVED per-move from in-range AuraSpec sources.
* Keyed by target-attr name, the cumulative delta every active aura
* contributes to this piece. Recomputed on every successful move by
* `computeAuraFacts` never written directly by primitives.
*/
AuraContributions: Readonly<Record<string, number>>;
OnTurnStartHooks: readonly EffectPrimitiveNode[][];
OnCaptureHooks: readonly EffectPrimitiveNode[][];
OnDamagedHooks: readonly EffectPrimitiveNode[][];