feat(chess/modifiers): add 7 new attr consumers + pre-move check/promotion snapshots

Combines T3 (consumer registrations for the 7 new hook attrs) and T4
(pre-move state snapshots) since both modify apply.ts and gate the
incoming wave of trigger primitives.

T3 — Consumer registrations:
Adds registerAttrConsumer() calls for the 7 trigger hook attrs added
to ChessAttrMap (OnMoveHooks, OnTurnEndHooks, OnPromotionHooks,
OnCheckReceivedHooks, OnCheckDeliveredHooks, OnMovedOntoSquareHooks,
OnCapturedHooks). Without these, the load-time
assertSeedConsumerIntegrity() check would fail loudly when the
upcoming Wave 2 primitives start writing the attrs.

T4 — Pre-move snapshots:
Two new per-engine WeakMaps mirror the existing PRE_MOVE_HP_SNAPSHOTS
lifecycle:
- PRE_MOVE_CHECK_STATE_SNAPSHOTS: per-color royal IDs + their
  attacker IDs at onBeforeMove time, used by the upcoming
  on-check-received (edge-triggered transition into check) and
  on-check-delivered (revealed-attacker discovered check) evaluators.
- PRE_MOVE_PROMOTION_PAWNS: pawn IDs eligible to promote this move
  (white pawns on rank 6, black pawns on rank 1) — feeds the upcoming
  on-promotion evaluator without re-walking the board post-move.

Snapshots populate in onBeforeMove, expose read-only getters
(getPreMoveCheckState, getPreMovePromotionPawns), and clear in
onAfterMove inside try/finally to prevent leaks even if a trigger
evaluator throws. Reuses the engine's existing attackProbe helper
(via PIECE_TYPE_REGISTRY) and getActiveRoyalEntityIds for preset-aware
royal resolution rather than computing check lines from scratch.

Adds 4 new tests in apply.test.ts: snapshot capture, promotion-pawn
flagging, post-move cleanup, and no-leak across 3 sequential moves.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 16:59:32 -06:00
commit 8f6c666ade
No known key found for this signature in database
2 changed files with 418 additions and 17 deletions

View file

@ -11,12 +11,19 @@ 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 {
applyProfileToSession,
getPreMoveCheckState,
getPreMovePromotionPawns,
} from "./apply.js";
import type { ModifierProfile } from "./types.js";
import { CaptureFlag } from "../schema.js";
import { CaptureFlag, GAME_ENTITY } from "../schema.js";
import { ChessEngine } from "../engine.js";
import { clearBoard, placePiece, pieceAt } from "../presets/test-utils.js";
// Side-effect import — ensures every descriptor is registered so
// `MODIFIER_REGISTRY.get(kind)` resolves in applyProfileToSession.
import "./index.js";
import "../presets/index.js";
/**
* Helper: return EntityIds of pieces matching the given filter. We
@ -274,3 +281,198 @@ describe("applyProfileToSession", () => {
);
});
});
/**
* T4 pre-move snapshot WeakMaps.
*
* These tests drive the integration preset end-to-end via a real
* ChessEngine (the WeakMaps are module-local and only populated by
* the preset's onBeforeMove hook). A minimal no-op profile is enough
* to auto-activate the integration preset; the pre-move snapshots
* are captured for every engine that has the preset live, regardless
* of what the profile actually configures.
*
* Intercepting the snapshot while it's still live is done by
* registering a second preset whose own onBeforeMove runs AFTER the
* integration preset's (preset iteration order is registration order;
* the integration preset is prepended so any user preset runs after
* it). That second hook reads the snapshot via the public getter and
* copies the data out for the test to assert on.
*/
describe("T4 pre-move snapshot WeakMaps", () => {
/** Minimal profile that just triggers integration-preset auto-activation. */
const NOOP_PROFILE: ModifierProfile = {
id: "t4-noop",
name: "t4-noop",
description: "",
perType: [],
perInstance: [],
version: 1,
source: "custom",
};
/**
* Register (or re-register) a test-only preset with an onBeforeMove
* hook that records the live snapshot into `captured`. Returns the
* preset id so the test can activate it. We rely on PRESET_REGISTRY
* accepting idempotent re-registration via the standard register()
* call tests using unique ids don't collide across runs.
*/
async function registerSnapshotCaptor(
presetId: string,
captured: {
checkState?: ReturnType<typeof getPreMoveCheckState>;
promotionPawns?: ReadonlySet<EntityId>;
},
): Promise<void> {
const { PRESET_REGISTRY } = await import("../presets/registry.js");
PRESET_REGISTRY.register({
id: presetId,
name: presetId,
description: "T4 test captor",
incompatibleWith: [],
requires: [],
onBeforeMove(ctx): void {
// Clone the live snapshot into the outer slot. The integration
// preset's onBeforeMove runs BEFORE this one (it's registered
// first in the active set), so the snapshot is live here.
const live = getPreMoveCheckState(ctx.engine);
if (live !== undefined) {
captured.checkState = {
white: new Map(live.white),
black: new Map(live.black),
};
}
const promo = getPreMovePromotionPawns(ctx.engine);
if (promo !== undefined) {
captured.promotionPawns = new Set(promo);
}
},
});
}
/** Build an engine with the integration preset + the test captor live. */
function makeEngineWithCaptor(presetId: string): ChessEngine {
const engine = new ChessEngine({ profile: NOOP_PROFILE });
const existing = engine.activePresets.list();
engine.activePresets.replaceAll([
...existing.map((e) => ({
id: e.id,
scope: e.scope,
turnsRemaining: e.turnsRemaining,
})),
{ id: presetId, scope: "both" as const, turnsRemaining: null },
]);
return engine;
}
it("T4 pre-move check snapshot captured (white king attacked by black rook)", async () => {
const captured: {
checkState?: ReturnType<typeof getPreMoveCheckState>;
} = {};
await registerSnapshotCaptor("__t4-check-captor__", captured);
const engine = makeEngineWithCaptor("__t4-check-captor__");
// Minimal position: white king e1, black rook e4 (checks king
// down the e-file; white queen on d1 blocks nothing). We need a
// black king somewhere harmless; h8.
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "e1");
placePiece(engine, "king", "black", "h8");
placePiece(engine, "rook", "black", "e4");
engine.session.insert(GAME_ENTITY, "Turn", "white");
const whiteKingId = pieceAt(engine, "e1")!;
const blackRookId = pieceAt(engine, "e4")!;
expect(whiteKingId).not.toBeNull();
expect(blackRookId).not.toBeNull();
// White's only sensible move: king out of check. Any legal move
// the engine produces will do — we just need onBeforeMove to fire.
const moves = engine.getAllLegalMoves();
expect(moves.length).toBeGreaterThan(0);
engine.applyMove(moves[0]!);
// Snapshot must have been captured and include the black rook as
// an attacker of the white king.
expect(captured.checkState).toBeDefined();
const whiteRoyalAttackers = captured.checkState!.white.get(whiteKingId);
expect(whiteRoyalAttackers).toBeDefined();
expect(whiteRoyalAttackers!).toContain(blackRookId);
// Black king was not attacked pre-move → empty list.
const blackKingId = pieceAt(engine, "h8")!;
expect(captured.checkState!.black.get(blackKingId)).toEqual([]);
});
it("T4 pre-move promotion pawns flag (white pawn on 7th rank)", async () => {
const captured: { promotionPawns?: ReadonlySet<EntityId> } = {};
await registerSnapshotCaptor("__t4-promo-captor__", captured);
const engine = makeEngineWithCaptor("__t4-promo-captor__");
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "black", "h8");
// White pawn on a7 — rank 6, eligible to promote on the next push.
const promoPawnId = placePiece(engine, "pawn", "white", "a7");
// A non-promoting pawn for contrast on rank 1 (white pawn on b2).
const normalPawnId = placePiece(engine, "pawn", "white", "b2");
engine.session.insert(GAME_ENTITY, "Turn", "white");
const moves = engine.getAllLegalMoves();
expect(moves.length).toBeGreaterThan(0);
engine.applyMove(moves[0]!);
expect(captured.promotionPawns).toBeDefined();
expect(captured.promotionPawns!.has(promoPawnId)).toBe(true);
expect(captured.promotionPawns!.has(normalPawnId)).toBe(false);
});
it("T4 snapshot cleared after onAfterMove", () => {
const engine = new ChessEngine({ profile: NOOP_PROFILE });
// Pre-move: no snapshot yet.
expect(getPreMoveCheckState(engine)).toBeUndefined();
expect(getPreMovePromotionPawns(engine)).toBeUndefined();
const moves = engine.getAllLegalMoves();
const e2e4 = moves.find((m) => m.from === 12 && m.to === 28);
expect(e2e4).toBeDefined();
engine.applyMove(e2e4!);
// Post-move: the onAfterMove hook deletes both maps as its last
// action. Neither should be accessible any more.
expect(getPreMoveCheckState(engine)).toBeUndefined();
expect(getPreMovePromotionPawns(engine)).toBeUndefined();
});
it("T4 no leak across moves (3 sequential moves)", () => {
const engine = new ChessEngine({ profile: NOOP_PROFILE });
// Move 1: e2-e4 (white).
let moves = engine.getAllLegalMoves();
const e2e4 = moves.find((m) => m.from === 12 && m.to === 28);
expect(e2e4).toBeDefined();
engine.applyMove(e2e4!);
expect(getPreMoveCheckState(engine)).toBeUndefined();
expect(getPreMovePromotionPawns(engine)).toBeUndefined();
// Move 2: e7-e5 (black).
moves = engine.getAllLegalMoves();
const e7e5 = moves.find((m) => m.from === 52 && m.to === 36);
expect(e7e5).toBeDefined();
engine.applyMove(e7e5!);
expect(getPreMoveCheckState(engine)).toBeUndefined();
expect(getPreMovePromotionPawns(engine)).toBeUndefined();
// Move 3: g1-f3 (white knight).
moves = engine.getAllLegalMoves();
const g1f3 = moves.find((m) => m.from === 6 && m.to === 21);
expect(g1f3).toBeDefined();
engine.applyMove(g1f3!);
expect(getPreMoveCheckState(engine)).toBeUndefined();
expect(getPreMovePromotionPawns(engine)).toBeUndefined();
});
});

View file

@ -64,6 +64,7 @@ import { stackResistances, applyResistance } from "./descriptors/damage-resistan
import { hasCaptureFlag } from "./descriptors/capture-flags.js";
import { generateDirectionMoves } from "./descriptors/direction-additions.js";
import { PRESET_REGISTRY } from "../presets/registry.js";
import { PIECE_TYPE_REGISTRY } from "../presets/piece-type-registry.js";
import type { LegalMove } from "../rules/types.js";
import { getPieceAt } from "../rules/board-queries.js";
import type { ChessEngine } from "../engine.js";
@ -96,6 +97,14 @@ registerAttrConsumer("OnCaptureHooks");
registerAttrConsumer("OnDamagedHooks");
registerAttrConsumer("ConditionalHooks");
registerAttrConsumer("AuraSpec");
// T3-extension trigger hook attrs (read by triggers.ts evaluators added in T12)
registerAttrConsumer("OnMoveHooks");
registerAttrConsumer("OnTurnEndHooks");
registerAttrConsumer("OnPromotionHooks");
registerAttrConsumer("OnCheckReceivedHooks");
registerAttrConsumer("OnCheckDeliveredHooks");
registerAttrConsumer("OnCapturedHooks");
registerAttrConsumer("OnMovedOntoSquareHooks");
/**
* Per-engine pre-move HP snapshot, used by the on-damaged trigger
@ -133,6 +142,174 @@ function classifyNonCaptureMove(from: number, to: number): "step" | "slide" {
*/
const PRE_MOVE_CAPTURE_ATTACKERS = new WeakMap<ChessEngine, EntityId>();
/**
* Pre-move check-state snapshot. For each color, maps every royal's
* EntityId to the list of enemy attacker EntityIds threatening it
* BEFORE the move. Edge-triggered on-check-received / on-check-delivered
* evaluators compare this to the post-move state to determine whether
* the move transitioned into or out of check for each royal.
*
* Empty attacker list the royal is NOT in check pre-move. A royal
* missing from the map entirely means "no royalty of this color"
* (preset-dependent; see ChessEngine.getActiveRoyalEntityIds).
*
* Maps are built fresh each onBeforeMove and deleted at the END of
* onAfterMove (after all fire*Hooks run), so evaluators can read them
* but state never leaks across moves.
*/
export interface PreMoveCheckState {
readonly white: ReadonlyMap<EntityId, readonly EntityId[]>;
readonly black: ReadonlyMap<EntityId, readonly EntityId[]>;
}
const PRE_MOVE_CHECK_STATE_SNAPSHOTS = new WeakMap<
ChessEngine,
PreMoveCheckState
>();
/**
* Pre-move promotion-candidate snapshot: set of pawn EntityIds that
* sit on their penultimate rank BEFORE the move. Coarse filter a
* pawn in the set MIGHT promote this move (iff the move is actually
* that pawn advancing to the last rank and it is legal). Evaluators
* for on-promotion triggers use this to avoid redundant rank checks
* and to distinguish "promotion happened this move" from "promotion
* happened on a prior move" when inspecting post-move state.
*/
const PRE_MOVE_PROMOTION_PAWNS = new WeakMap<ChessEngine, Set<EntityId>>();
/**
* Read-only accessor for the pre-move check-state snapshot. Returns
* `undefined` when no snapshot is currently held for the engine the
* normal state outside of an onBeforeMove/onAfterMove window, or when
* the integration preset is inactive.
*/
export function getPreMoveCheckState(
engine: ChessEngine,
): PreMoveCheckState | undefined {
return PRE_MOVE_CHECK_STATE_SNAPSHOTS.get(engine);
}
/**
* Read-only accessor for the pre-move promotion-candidate snapshot.
* Returns `undefined` when no snapshot is currently held (outside a
* move window or integration preset inactive).
*/
export function getPreMovePromotionPawns(
engine: ChessEngine,
): ReadonlySet<EntityId> | undefined {
return PRE_MOVE_PROMOTION_PAWNS.get(engine);
}
/**
* Build a per-color royalattackers map by walking the preset-resolved
* royal set for each color and asking each enemy piece's `attackProbe`
* whether it threatens the royal's current square. Mirrors the
* check-detection path in `rules/check.ts::isInCheck` / `isSquareAttacked`
* but records the attackers rather than returning a boolean needed
* so the on-check-received evaluator can report which piece delivered
* the check, and so on-check-delivered can attribute the threat.
*
* Runs O(royals × enemy pieces) per color, which is cheap in practice
* ( 2 royals × 16 enemies = 32 probe calls per color).
*/
function captureCheckStateForColor(
engine: ChessEngine,
color: PieceColor,
): ReadonlyMap<EntityId, readonly EntityId[]> {
const out = new Map<EntityId, readonly EntityId[]>();
const session = engine.session;
const facts = session.allFacts();
// Royal resolution mirrors `rules/check.ts::isInCheck`:
// - preset-contributed set → use as-is (empty = no royalty, skip).
// - undefined (no preset touched royalty) → fall back to "every
// PieceType=king of this color", so the snapshot is meaningful
// even in FIDE-default / no-preset configurations.
const presetRoyals = engine.getActiveRoyalEntityIds(color);
let royals: readonly EntityId[];
if (presetRoyals !== undefined) {
if (presetRoyals.length === 0) return out;
royals = presetRoyals;
} else {
const defaultIds: EntityId[] = [];
for (const f of facts) {
if (f.attr !== "PieceType" || f.value !== "king") continue;
if ((f.id as number) <= 0) continue;
const cf = facts.find((c) => c.id === f.id && c.attr === "Color");
if (cf !== undefined && cf.value === color) defaultIds.push(f.id);
}
if (defaultIds.length === 0) return out;
royals = defaultIds;
}
const attackerColor: PieceColor = color === "white" ? "black" : "white";
// Pre-filter enemy (id, type) once so each royal iteration reuses.
const enemies: Array<{ id: EntityId; type: PieceType }> = [];
for (const f of facts) {
if (f.attr !== "Color" || f.value !== attackerColor) continue;
if ((f.id as number) <= 0) continue;
const typeFact = facts.find(
(t) => t.id === f.id && t.attr === "PieceType",
);
if (typeFact === undefined) continue;
enemies.push({ id: f.id, type: typeFact.value as PieceType });
}
for (const royalId of royals) {
const posFact = facts.find(
(f) => f.id === royalId && f.attr === "Position",
);
if (posFact === undefined) {
out.set(royalId, []);
continue;
}
const royalSquare = posFact.value as Square;
const attackers: EntityId[] = [];
for (const enemy of enemies) {
const def = PIECE_TYPE_REGISTRY.get(enemy.type);
if (def === undefined) continue;
if (def.attackProbe(session, enemy.id, royalSquare)) {
attackers.push(enemy.id);
}
}
out.set(royalId, attackers);
}
return out;
}
/**
* Enumerate every pawn currently on its penultimate rank. White pawns
* on rank 6 (squares 48..55) could promote by advancing to rank 7;
* black pawns on rank 1 (squares 8..15) could promote by advancing to
* rank 0. This is the CANDIDATE set movegen / move legality is what
* actually determines whether a promotion occurs; downstream
* on-promotion evaluators intersect this with the post-move state to
* detect "a flagged pawn is no longer a pawn" == promoted.
*/
function capturePromotionCandidates(session: Session): Set<EntityId> {
const candidates = new Set<EntityId>();
const facts = session.allFacts();
for (const f of facts) {
if (f.attr !== "PieceType" || f.value !== "pawn") continue;
if ((f.id as number) <= 0) continue;
const posFact = facts.find((p) => p.id === f.id && p.attr === "Position");
if (posFact === undefined) continue;
const rank = Math.floor((posFact.value as number) / 8);
const colorFact = facts.find((c) => c.id === f.id && c.attr === "Color");
if (colorFact === undefined) continue;
const color = colorFact.value as PieceColor;
if (
(color === "white" && rank === 6) ||
(color === "black" && rank === 1)
) {
candidates.add(f.id as EntityId);
}
}
return candidates;
}
/**
* Stable id for the pseudo-preset that wires modifier facts into
* engine runtime behaviour. Reserved name userland presets must not
@ -643,6 +820,19 @@ PRESET_REGISTRY.register({
} else {
PRE_MOVE_CAPTURE_ATTACKERS.delete(ctx.engine);
}
// T4: snapshot pre-move check lines for BOTH colors and the set of
// pawns eligible to promote this move. Captured here (not in
// onAfterMove) because the post-move board no longer reflects who
// was in check / which pawns were on the 7th rank BEFORE the move.
PRE_MOVE_CHECK_STATE_SNAPSHOTS.set(ctx.engine, {
white: captureCheckStateForColor(ctx.engine, "white"),
black: captureCheckStateForColor(ctx.engine, "black"),
});
PRE_MOVE_PROMOTION_PAWNS.set(
ctx.engine,
capturePromotionCandidates(ctx.engine.session),
);
},
/**
@ -663,22 +853,31 @@ PRESET_REGISTRY.register({
* turn-start runs last so it sees a fully-resolved board.
*/
onAfterMove(ctx): void {
computeAuraFacts(ctx.engine.session);
try {
computeAuraFacts(ctx.engine.session);
const preHp = PRE_MOVE_HP_SNAPSHOTS.get(ctx.engine);
if (preHp !== undefined) {
fireOnDamagedHooks(ctx.engine, preHp);
PRE_MOVE_HP_SNAPSHOTS.delete(ctx.engine);
const preHp = PRE_MOVE_HP_SNAPSHOTS.get(ctx.engine);
if (preHp !== undefined) {
fireOnDamagedHooks(ctx.engine, preHp);
PRE_MOVE_HP_SNAPSHOTS.delete(ctx.engine);
}
const attacker = PRE_MOVE_CAPTURE_ATTACKERS.get(ctx.engine) ?? null;
fireOnCaptureHooks(ctx.engine, attacker);
PRE_MOVE_CAPTURE_ATTACKERS.delete(ctx.engine);
fireConditionalHooks(ctx.engine);
const nextTurn: "white" | "black" =
ctx.mover === "white" ? "black" : "white";
fireOnTurnStartHooks(ctx.engine, nextTurn);
} finally {
// T4: clear the pre-move snapshots LAST — after every trigger
// evaluator has had a chance to read them. `try/finally` so an
// exception in any fire*Hooks path doesn't leak stale state
// into the next move.
PRE_MOVE_CHECK_STATE_SNAPSHOTS.delete(ctx.engine);
PRE_MOVE_PROMOTION_PAWNS.delete(ctx.engine);
}
const attacker = PRE_MOVE_CAPTURE_ATTACKERS.get(ctx.engine) ?? null;
fireOnCaptureHooks(ctx.engine, attacker);
PRE_MOVE_CAPTURE_ATTACKERS.delete(ctx.engine);
fireConditionalHooks(ctx.engine);
const nextTurn: "white" | "black" =
ctx.mover === "white" ? "black" : "white";
fireOnTurnStartHooks(ctx.engine, nextTurn);
},
});