From 8f6c666ade3bc58bb17a09d87ee84c0cdaeb5dcb Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 16:59:32 -0600 Subject: [PATCH] feat(chess/modifiers): add 7 new attr consumers + pre-move check/promotion snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/chess/src/modifiers/apply.test.ts | 206 +++++++++++++++++- packages/chess/src/modifiers/apply.ts | 229 +++++++++++++++++++-- 2 files changed, 418 insertions(+), 17 deletions(-) diff --git a/packages/chess/src/modifiers/apply.test.ts b/packages/chess/src/modifiers/apply.test.ts index 69d959b..f207571 100644 --- a/packages/chess/src/modifiers/apply.test.ts +++ b/packages/chess/src/modifiers/apply.test.ts @@ -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; + promotionPawns?: ReadonlySet; + }, + ): Promise { + 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; + } = {}; + 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 } = {}; + 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(); + }); +}); diff --git a/packages/chess/src/modifiers/apply.ts b/packages/chess/src/modifiers/apply.ts index 72128fb..f754443 100644 --- a/packages/chess/src/modifiers/apply.ts +++ b/packages/chess/src/modifiers/apply.ts @@ -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(); +/** + * 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; + readonly black: ReadonlyMap; +} + +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>(); + +/** + * 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 | undefined { + return PRE_MOVE_PROMOTION_PAWNS.get(engine); +} + +/** + * Build a per-color royal→attackers 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 { + const out = new Map(); + 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 { + const candidates = new Set(); + 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); }, });