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:
parent
109be25be6
commit
9441570349
4 changed files with 349 additions and 0 deletions
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
211
packages/chess/src/modifiers/auras.test.ts
Normal file
211
packages/chess/src/modifiers/auras.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
117
packages/chess/src/modifiers/auras.ts
Normal file
117
packages/chess/src/modifiers/auras.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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[][];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue