From 94415703495faa608f79bee2eaa7c1fcb4b41b5c Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 19 Apr 2026 20:05:38 -0600 Subject: [PATCH] feat(engine): aura effect computation + onAfterMove hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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>. - __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. --- packages/chess/src/modifiers/apply.ts | 14 ++ packages/chess/src/modifiers/auras.test.ts | 211 +++++++++++++++++++++ packages/chess/src/modifiers/auras.ts | 117 ++++++++++++ packages/chess/src/schema.ts | 7 + 4 files changed, 349 insertions(+) create mode 100644 packages/chess/src/modifiers/auras.test.ts create mode 100644 packages/chess/src/modifiers/auras.ts diff --git a/packages/chess/src/modifiers/apply.ts b/packages/chess/src/modifiers/apply.ts index a5704cb..db95172 100644 --- a/packages/chess/src/modifiers/apply.ts +++ b/packages/chess/src/modifiers/apply.ts @@ -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); + }, }); diff --git a/packages/chess/src/modifiers/auras.test.ts b/packages/chess/src/modifiers/auras.test.ts new file mode 100644 index 0000000..151228f --- /dev/null +++ b/packages/chess/src/modifiers/auras.test.ts @@ -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, +): Record | undefined { + return engine.session.get(pieceId, "AuraContributions") as + | Record + | undefined; +} + +function seedAura( + engine: ChessEngine, + sourceId: ReturnType, + 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); + }); +}); diff --git a/packages/chess/src/modifiers/auras.ts b/packages/chess/src/modifiers/auras.ts new file mode 100644 index 0000000..afa1c2c --- /dev/null +++ b/packages/chess/src/modifiers/auras.ts @@ -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>(); + + 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 = {}; + 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; +} diff --git a/packages/chess/src/schema.ts b/packages/chess/src/schema.ts index 36c96f7..0eb92db 100644 --- a/packages/chess/src/schema.ts +++ b/packages/chess/src/schema.ts @@ -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>; OnTurnStartHooks: readonly EffectPrimitiveNode[][]; OnCaptureHooks: readonly EffectPrimitiveNode[][]; OnDamagedHooks: readonly EffectPrimitiveNode[][];