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:
parent
3b6f79ac74
commit
8f6c666ade
2 changed files with 418 additions and 17 deletions
|
|
@ -11,12 +11,19 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { Session } from "@paratype/rete";
|
import { Session } from "@paratype/rete";
|
||||||
import type { EntityId } from "@paratype/rete";
|
import type { EntityId } from "@paratype/rete";
|
||||||
import { applyLayout, CLASSIC_LAYOUT } from "../starting-position.js";
|
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 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
|
// Side-effect import — ensures every descriptor is registered so
|
||||||
// `MODIFIER_REGISTRY.get(kind)` resolves in applyProfileToSession.
|
// `MODIFIER_REGISTRY.get(kind)` resolves in applyProfileToSession.
|
||||||
import "./index.js";
|
import "./index.js";
|
||||||
|
import "../presets/index.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: return EntityIds of pieces matching the given filter. We
|
* 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ import { stackResistances, applyResistance } from "./descriptors/damage-resistan
|
||||||
import { hasCaptureFlag } from "./descriptors/capture-flags.js";
|
import { hasCaptureFlag } from "./descriptors/capture-flags.js";
|
||||||
import { generateDirectionMoves } from "./descriptors/direction-additions.js";
|
import { generateDirectionMoves } from "./descriptors/direction-additions.js";
|
||||||
import { PRESET_REGISTRY } from "../presets/registry.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 type { LegalMove } from "../rules/types.js";
|
||||||
import { getPieceAt } from "../rules/board-queries.js";
|
import { getPieceAt } from "../rules/board-queries.js";
|
||||||
import type { ChessEngine } from "../engine.js";
|
import type { ChessEngine } from "../engine.js";
|
||||||
|
|
@ -96,6 +97,14 @@ registerAttrConsumer("OnCaptureHooks");
|
||||||
registerAttrConsumer("OnDamagedHooks");
|
registerAttrConsumer("OnDamagedHooks");
|
||||||
registerAttrConsumer("ConditionalHooks");
|
registerAttrConsumer("ConditionalHooks");
|
||||||
registerAttrConsumer("AuraSpec");
|
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
|
* 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>();
|
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 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<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
|
* Stable id for the pseudo-preset that wires modifier facts into
|
||||||
* engine runtime behaviour. Reserved name — userland presets must not
|
* engine runtime behaviour. Reserved name — userland presets must not
|
||||||
|
|
@ -643,6 +820,19 @@ PRESET_REGISTRY.register({
|
||||||
} else {
|
} else {
|
||||||
PRE_MOVE_CAPTURE_ATTACKERS.delete(ctx.engine);
|
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.
|
* turn-start runs last so it sees a fully-resolved board.
|
||||||
*/
|
*/
|
||||||
onAfterMove(ctx): void {
|
onAfterMove(ctx): void {
|
||||||
computeAuraFacts(ctx.engine.session);
|
try {
|
||||||
|
computeAuraFacts(ctx.engine.session);
|
||||||
|
|
||||||
const preHp = PRE_MOVE_HP_SNAPSHOTS.get(ctx.engine);
|
const preHp = PRE_MOVE_HP_SNAPSHOTS.get(ctx.engine);
|
||||||
if (preHp !== undefined) {
|
if (preHp !== undefined) {
|
||||||
fireOnDamagedHooks(ctx.engine, preHp);
|
fireOnDamagedHooks(ctx.engine, preHp);
|
||||||
PRE_MOVE_HP_SNAPSHOTS.delete(ctx.engine);
|
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);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue