feat(chess/modifiers): wire 7 new triggers into onAfterMove with Metis-locked dispatch order

Completes the DSL-side wiring of the Wave 2 trigger primitives. Each
fire*Hooks evaluator from T12 is now called from the integration
presets onAfterMove hook in a precisely-ordered 12-stage pipeline,
with pre-move state snapshots populated in onBeforeMove and cleared in
a finally block.

Dispatch order (Metis-locked):
1.  computeAuraFacts
2.  fireOnDamagedHooks (existing)
3.  fireOnCaptureHooks (existing)
4.  fireOnCapturedHooks — per captured defender id, before fact cleanup could see it
5.  fireOnPromotionHooks — per pawn whose post-move PieceType is not pawn
6.  fireOnMoveHooks — per piece whose Position WME changed
7.  fireOnMovedOntoSquareHooks — per moved piece, using its new Position
8.  fireOnCheckReceivedHooks — edge diff vs pre-move check state
9.  fireOnCheckDeliveredHooks — newly attacking pieces vs pre-move state
10. fireConditionalHooks (existing)
11. fireOnTurnEndHooks — for mover
12. fireOnTurnStartHooks — for next color

Pre-move snapshots added alongside existing PRE_MOVE_HP_SNAPSHOTS /
PRE_MOVE_CAPTURE_ATTACKERS / PRE_MOVE_CHECK_STATE_SNAPSHOTS /
PRE_MOVE_PROMOTION_PAWNS:
- PRE_MOVE_POSITION_SNAPSHOTS: Map<EntityId, Square> — every pieces
  Position at onBeforeMove. Post-move diff yields movedPieceIds for
  fireOnMoveHooks + per-piece fireOnMovedOntoSquareHooks.
- PRE_MOVE_CAPTURED_DEFENDERS: EntityId | null — the piece at ctx.to
  before the move mutates, so fireOnCapturedHooks has the victims id.

Helpers added:
- snapshotPositions(session)
- diffMovedPieceIds(session, preMap)
- diffPromotedPieces(session, preCandidates) → [{id, promotedTo}]

All new snapshots clear in the finally block so an exception in any
fire*Hooks path cannot leak stale state into the next move.

Ordering test (3 new tests in apply.test.ts): uses vi.spyOn on each
of the 11 fire*Hooks to log call order, then triggers quiet move /
capture / promotion scenarios and asserts first-occurrence ordering
matches the Metis-locked sequence. Conditional dispatchers (on-captured
only fires on capture; on-promotion only on actual promotion) are
correctly excluded from quiet-move expectations.

Known limitation — en-passant: the EP victim sits on a different
square than ctx.to, so PRE_MOVE_CAPTURED_DEFENDERS misses them. EP
pawns wont fire on-captured hooks until a BeforeMoveContext.epVictimSquare
field lands or we switch to a post-move moveLog peek. Documented in
the WeakMaps docstring; not a regression (on-captured is new).

Known limitation — "before retraction" is aspirational: engine fact
cleanup happens inside applyMove before onAfterMove fires. The
dispatcher CALL ORDER guarantees no primitive re-seeds the dying
pieces hook list first, but the defenders facts are already gone by
call time. Inner primitives should read event.defenderId from ctx (the
triggers.ts runPrimitives context supplies it) rather than doing
session reads.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 18:13:43 -06:00
commit 9a7916917c
No known key found for this signature in database
2 changed files with 407 additions and 16 deletions

View file

@ -7,7 +7,7 @@
* session — we don't exercise the engine-level integration preset
* here; that's covered by engine-surface tests elsewhere.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Session } from "@paratype/rete";
import type { EntityId } from "@paratype/rete";
import { applyLayout, CLASSIC_LAYOUT } from "../starting-position.js";
@ -20,6 +20,7 @@ import type { ModifierProfile } from "./types.js";
import { CaptureFlag, GAME_ENTITY } from "../schema.js";
import { ChessEngine } from "../engine.js";
import { clearBoard, placePiece, pieceAt } from "../presets/test-utils.js";
import * as triggers from "./triggers.js";
// Side-effect import — ensures every descriptor is registered so
// `MODIFIER_REGISTRY.get(kind)` resolves in applyProfileToSession.
import "./index.js";
@ -476,3 +477,185 @@ describe("T4 pre-move snapshot WeakMaps", () => {
expect(getPreMovePromotionPawns(engine)).toBeUndefined();
});
});
/**
* T21 — onAfterMove dispatch order is Metis-locked.
*
* The integration preset's onAfterMove fires the 11 trigger
* dispatchers in a specific order (computeAuraFacts is stage 1; the
* 11 fire*Hooks dispatchers occupy stages 2–12). Reordering breaks
* subtle invariants — e.g. on-captured MUST run before on-move (so
* the dying piece's hook list isn't mutated by a sibling on-move
* that re-seeds it), and on-turn-end MUST run before on-turn-start
* (so end-of-turn ticks resolve before opponent's start-of-turn
* ticks read them).
*
* Strategy: spy on every fire*Hooks function in the `triggers`
* module via `vi.spyOn`, run a single `applyMove`, then assert the
* captured call order matches the 12-stage Metis sequence. We use
* `mockImplementation` that wraps the original so all downstream
* behaviour still runs — only the call-ordering is observed.
*
* The quiet-move case proves every dispatcher fires unconditionally
* (so the relative order is observable even without seeded hooks).
* The capture case additionally exercises the on-captured slot which
* only invokes its dispatcher when the move was a capture.
*/
describe("T21 onAfterMove dispatch order (Metis-locked)", () => {
/** The exact 11-name sequence we expect, in dispatch order. */
const EXPECTED_ORDER: readonly string[] = [
"fireOnDamagedHooks",
"fireOnCaptureHooks",
"fireOnCapturedHooks",
"fireOnPromotionHooks",
"fireOnMoveHooks",
"fireOnMovedOntoSquareHooks",
"fireOnCheckReceivedHooks",
"fireOnCheckDeliveredHooks",
"fireConditionalHooks",
"fireOnTurnEndHooks",
"fireOnTurnStartHooks",
];
const NOOP_PROFILE: ModifierProfile = {
id: "t21-noop",
name: "t21-noop",
description: "",
perType: [],
perInstance: [],
version: 1,
source: "custom",
};
let callLog: string[];
let spies: Array<ReturnType<typeof vi.spyOn>>;
beforeEach(() => {
callLog = [];
spies = [];
const targets = [
"fireOnDamagedHooks",
"fireOnCaptureHooks",
"fireOnCapturedHooks",
"fireOnPromotionHooks",
"fireOnMoveHooks",
"fireOnMovedOntoSquareHooks",
"fireOnCheckReceivedHooks",
"fireOnCheckDeliveredHooks",
"fireConditionalHooks",
"fireOnTurnEndHooks",
"fireOnTurnStartHooks",
] as const;
for (const name of targets) {
// Capture the original implementation BEFORE the spy installs
// its mock — `triggers[name]` after `vi.spyOn` returns the spy
// itself (calling it would recurse).
const original = triggers[name] as (...args: unknown[]) => unknown;
const spy = vi
.spyOn(triggers, name)
.mockImplementation((...args: unknown[]) => {
callLog.push(name);
return original(...args);
});
spies.push(spy);
}
});
afterEach(() => {
for (const spy of spies) spy.mockRestore();
});
/**
* Reduce a raw call log to the FIRST occurrence of each dispatcher
* name. Some dispatchers may be invoked once per moved piece (e.g.
* fireOnMovedOntoSquareHooks runs per id in `movedIds`); this
* helper preserves the inter-dispatcher ordering we want to lock
* while ignoring within-dispatcher repetition.
*/
function firstOccurrences(log: readonly string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const name of log) {
if (seen.has(name)) continue;
seen.add(name);
out.push(name);
}
return out;
}
it("dispatch order matches the Metis-locked 12-stage sequence (quiet move)", () => {
const engine = new ChessEngine({ profile: NOOP_PROFILE });
// e2-e4 — quiet move with no promotion candidates and no
// capture, so the on-captured slot AND the on-promotion slot are
// both guarded out by the dispatcher (no defender id, no
// promoted pawns). Strip both from the expected sequence.
const moves = engine.getAllLegalMoves();
const e2e4 = moves.find((m) => m.from === 12 && m.to === 28);
expect(e2e4).toBeDefined();
engine.applyMove(e2e4!);
const expectedQuiet = EXPECTED_ORDER.filter(
(n) =>
n !== "fireOnCapturedHooks" && n !== "fireOnPromotionHooks",
);
expect(firstOccurrences(callLog)).toEqual(expectedQuiet);
});
it("dispatch order holds on a CAPTURE (on-captured slot fires)", () => {
const engine = new ChessEngine({ profile: NOOP_PROFILE });
// Build a position with a guaranteed capture available: white
// pawn d4, black pawn e5; white pawn captures e5. No promotion
// possible, so on-promotion stays guarded out.
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "black", "h8");
placePiece(engine, "pawn", "white", "d4");
placePiece(engine, "pawn", "black", "e5");
engine.session.insert(GAME_ENTITY, "Turn", "white");
callLog = []; // discard any noise from board-setup hooks
const moves = engine.getAllLegalMoves();
const dxe5 = moves.find(
(m) => m.from === 27 /* d4 */ && m.to === 36 /* e5 */ && m.isCapture,
);
expect(dxe5).toBeDefined();
engine.applyMove(dxe5!);
const expectedCapture = EXPECTED_ORDER.filter(
(n) => n !== "fireOnPromotionHooks",
);
expect(firstOccurrences(callLog)).toEqual(expectedCapture);
});
it("dispatch order holds on a PROMOTION (on-promotion slot fires)", () => {
const engine = new ChessEngine({ profile: NOOP_PROFILE });
// Build a position where white can push a7-a8=Q (promotion, no
// capture). All 11 stages should fire EXCEPT on-captured.
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "e1");
placePiece(engine, "king", "black", "h8");
placePiece(engine, "pawn", "white", "a7");
engine.session.insert(GAME_ENTITY, "Turn", "white");
callLog = [];
const moves = engine.getAllLegalMoves();
// a7 = 48, a8 = 56. Pick the promotion-to-queen variant.
const promoMove = moves.find(
(m) => m.from === 48 && m.to === 56 && m.promoteTo === "queen",
);
expect(promoMove).toBeDefined();
engine.applyMove(promoMove!);
const expectedPromo = EXPECTED_ORDER.filter(
(n) => n !== "fireOnCapturedHooks",
);
expect(firstOccurrences(callLog)).toEqual(expectedPromo);
});
});

View file

@ -74,7 +74,14 @@ import { computeAuraFacts } from "./auras.js";
import {
fireConditionalHooks,
fireOnCaptureHooks,
fireOnCapturedHooks,
fireOnCheckDeliveredHooks,
fireOnCheckReceivedHooks,
fireOnDamagedHooks,
fireOnMoveHooks,
fireOnMovedOntoSquareHooks,
fireOnPromotionHooks,
fireOnTurnEndHooks,
fireOnTurnStartHooks,
snapshotHp,
} from "./triggers.js";
@ -178,6 +185,107 @@ const PRE_MOVE_CHECK_STATE_SNAPSHOTS = new WeakMap<
*/
const PRE_MOVE_PROMOTION_PAWNS = new WeakMap<ChessEngine, Set<EntityId>>();
/**
* T21 — pre-move Position snapshot. Maps every piece's EntityId to
* its Position fact value BEFORE the move mutates the board. The
* `onAfterMove` dispatcher diffs this against the post-move state to
* compute the set of pieces whose Position changed (mover, plus the
* castling rook on a castling move). En-passant victims do NOT show
* up in the diff because their Position fact is RETRACTED — they
* trigger on-captured hooks instead.
*
* Captured here (not in onAfterMove) because the post-move board no
* longer reflects pre-move squares.
*/
const PRE_MOVE_POSITION_SNAPSHOTS = new WeakMap<
ChessEngine,
Map<EntityId, Square>
>();
/**
* T21 — pre-move captured-defender snapshot. Holds the EntityId of
* the piece that is ABOUT to be captured by the incoming move (read
* via `getPieceAt(session, ctx.to)` in onBeforeMove), or `null` when
* the move is not a capture. The `fireOnCapturedHooks` dispatcher
* needs this id to fire the dying piece's hooks BEFORE the engine
* retracts its facts — once retraction has happened we'd have no way
* to read the defender's stored hook list.
*
* Note: en-passant captures the pawn on a DIFFERENT square than the
* destination. For now we only resolve the defender at `ctx.to`,
* which covers normal captures. EP-victim on-captured firing is a
* known limitation; documenting it here so future work knows where to
* extend (the EP victim square = `ctx.to ± 8` based on the mover's
* direction; the engine doesn't expose it via BeforeMoveContext).
*/
const PRE_MOVE_CAPTURED_DEFENDERS = new WeakMap<
ChessEngine,
EntityId | null
>();
/**
* Snapshot every piece's current Position fact value. Mirrors
* `snapshotHp` (in triggers.ts) but for Position facts; lives here
* because the integration preset is the sole consumer.
*/
function snapshotPositions(session: Session): Map<EntityId, Square> {
const out = new Map<EntityId, Square>();
for (const f of session.allFacts()) {
if (f.attr !== "Position") continue;
if ((f.id as number) <= 0) continue;
if (typeof f.value === "number") out.set(f.id, f.value as Square);
}
return out;
}
/**
* Diff a pre-move Position snapshot against the current (post-move)
* session state to compute the list of EntityIds whose Position fact
* value CHANGED between snapshots. Pieces whose Position fact was
* retracted (captured / removed) do NOT appear — only pieces whose
* Position both existed before and now and differs.
*/
export function diffMovedPieceIds(
session: Session,
preSnapshot: ReadonlyMap<EntityId, Square>,
): EntityId[] {
const moved: EntityId[] = [];
for (const [id, prev] of preSnapshot) {
const current = session.get(id, "Position") as Square | undefined;
if (current === undefined) continue; // retracted (captured)
if (current !== prev) moved.push(id);
}
return moved;
}
/**
* Diff the pre-move promotion-candidate set against the current
* PieceType facts to find pawns that were flagged AND whose post-move
* PieceType is no longer "pawn" — these are the pieces that just
* promoted. Returns an array of `{ id, promotedTo }` so the
* `fireOnPromotionHooks` dispatcher can pass `promotedTo` to the hook
* (and `promotedFrom` is always `'pawn'`).
*
* Returns an empty array when no flagged pawn promoted (the common
* case — most moves don't promote).
*/
export function diffPromotedPieces(
session: Session,
preCandidates: ReadonlySet<EntityId>,
): Array<{ id: EntityId; promotedTo: PieceType }> {
const out: Array<{ id: EntityId; promotedTo: PieceType }> = [];
for (const id of preCandidates) {
const current = session.get(id, "PieceType") as PieceType | undefined;
// A pawn that was captured on the same move it was about to
// promote (rare but possible with adjacent enemy attackers) loses
// its PieceType fact; it didn't promote, it died — skip.
if (current === undefined) continue;
if (current === "pawn") continue;
out.push({ id, promotedTo: current });
}
return out;
}
/**
* Read-only accessor for the pre-move check-state snapshot. Returns
* `undefined` when no snapshot is currently held for the engine — the
@ -817,8 +925,19 @@ PRESET_REGISTRY.register({
PRE_MOVE_HP_SNAPSHOTS.set(ctx.engine, snapshotHp(ctx.engine.session));
if (ctx.isCapture) {
PRE_MOVE_CAPTURE_ATTACKERS.set(ctx.engine, ctx.pieceId);
// T21: resolve the defender at the destination square BEFORE
// the engine retracts its facts so on-captured hooks can fire
// on the dying piece. `getPieceAt` reads Position facts, which
// are still pre-move at this point. NB: en-passant captures the
// pawn on a different square; this defender lookup misses EP
// victims. Documented limitation on PRE_MOVE_CAPTURED_DEFENDERS.
PRE_MOVE_CAPTURED_DEFENDERS.set(
ctx.engine,
getPieceAt(ctx.engine.session, ctx.to as Square),
);
} else {
PRE_MOVE_CAPTURE_ATTACKERS.delete(ctx.engine);
PRE_MOVE_CAPTURED_DEFENDERS.set(ctx.engine, null);
}
// T4: snapshot pre-move check lines for BOTH colors and the set of
@ -833,51 +952,140 @@ PRESET_REGISTRY.register({
ctx.engine,
capturePromotionCandidates(ctx.engine.session),
);
// T21: snapshot every piece's Position so onAfterMove can diff to
// find moved pieces (mover, castling rook). Captured pieces have
// their Position retracted post-move so they correctly drop out
// of the diff (and trigger on-captured instead).
PRE_MOVE_POSITION_SNAPSHOTS.set(
ctx.engine,
snapshotPositions(ctx.engine.session),
);
},
/**
* After every successful move:
* 1. Recompute aura contributions (T28).
* 2. Fire on-damaged hooks for every piece whose HP dropped during
* the move (compared against the pre-move snapshot).
* 3. Fire on-capture hooks for the mover's piece if the move was a
* capture (last move log entry's capturedId !== null).
* 4. Evaluate every conditional hook against current piece state
* and run the matching branch.
* 5. Fire on-turn-start hooks for the color whose turn is now
* beginning (the opposite of the mover).
* After every successful move, run the **Metis-locked 12-stage
* dispatch sequence** (T21). Stages must remain in this order —
* triggers depend on observable state from earlier stages and on
* the fact that later stages haven't fired yet.
*
* The order matters: damage / capture triggers see post-move state
* (the kill has happened, Hp facts are current), conditional hooks
* see whatever state the trigger primitives just produced, and
* turn-start runs last so it sees a fully-resolved board.
* 1. computeAuraFacts — recompute aura WMEs (T28)
* 2. fireOnDamagedHooks — uses PRE_MOVE_HP snapshot
* 3. fireOnCaptureHooks — attacker fires
* 4. fireOnCapturedHooks — defender fires BEFORE retraction
* 5. fireOnPromotionHooks — promoted-pawn diff
* 6. fireOnMoveHooks — Position-diff set
* 7. fireOnMovedOntoSquareHooks — per moved piece, dest-filter
* 8. fireOnCheckReceivedHooks — pre/post check-state diff
* 9. fireOnCheckDeliveredHooks — pre/post attacker-set diff
* 10. fireConditionalHooks — branch on current facts
* 11. fireOnTurnEndHooks (mover color) — end-of-turn ticks
* 12. fireOnTurnStartHooks (next color) — start-of-next-turn ticks
*
* NOTE on stage 4 ordering: the engine's actual fact retraction for
* a captured piece happens INSIDE `applyMove` BEFORE this hook
* fires, so by the time we call `fireOnCapturedHooks` here the
* defender's facts are already gone. The naming "before retraction"
* is aspirational — it's the contract the dispatcher CALL ORDER
* enforces among the trigger pipeline (we fire the dying piece's
* stored hook list before any later dispatcher could re-seed or
* mutate it). Inner primitives that need to read defender attrs
* should rely on the `event.defenderId` carried in the trigger ctx
* rather than session reads. Documented in `triggers.ts`.
*/
onAfterMove(ctx): void {
try {
// 1. Aura recomputation.
computeAuraFacts(ctx.engine.session);
// 2. on-damaged.
const preHp = PRE_MOVE_HP_SNAPSHOTS.get(ctx.engine);
if (preHp !== undefined) {
fireOnDamagedHooks(ctx.engine, preHp);
PRE_MOVE_HP_SNAPSHOTS.delete(ctx.engine);
}
// 3. on-capture (attacker).
const attacker = PRE_MOVE_CAPTURE_ATTACKERS.get(ctx.engine) ?? null;
fireOnCaptureHooks(ctx.engine, attacker);
PRE_MOVE_CAPTURE_ATTACKERS.delete(ctx.engine);
// 4. on-captured (defender) — fires BEFORE any later dispatcher
// could touch the dying piece's hook list. Requires both the
// attacker id (from PRE_MOVE_CAPTURE_ATTACKERS, captured above)
// and the defender id (from PRE_MOVE_CAPTURED_DEFENDERS). When
// the move was not a capture, both are null and we skip.
const defender = PRE_MOVE_CAPTURED_DEFENDERS.get(ctx.engine) ?? null;
if (defender !== null && attacker !== null) {
fireOnCapturedHooks(ctx.engine, defender, attacker);
}
// 5. on-promotion. Diff the pre-move pawn-candidate set against
// the post-move PieceType facts to detect actual promotions.
const preCandidates = PRE_MOVE_PROMOTION_PAWNS.get(ctx.engine);
if (preCandidates !== undefined) {
const promoted = diffPromotedPieces(ctx.engine.session, preCandidates);
for (const { id, promotedTo } of promoted) {
fireOnPromotionHooks(ctx.engine, id, "pawn", promotedTo);
}
}
// 6. on-move. Diff the pre-move Position snapshot against the
// post-move state to find every piece whose Position changed.
const prePositions = PRE_MOVE_POSITION_SNAPSHOTS.get(ctx.engine);
const movedIds: readonly EntityId[] =
prePositions !== undefined
? diffMovedPieceIds(ctx.engine.session, prePositions)
: [];
fireOnMoveHooks(ctx.engine, movedIds);
// 7. on-moved-onto-square. For each moved piece, pass its NEW
// Position so the per-hook square-filter can decide whether to
// fire. Pieces whose Position fact is unexpectedly missing post
// diff (shouldn't happen — diffMovedPieceIds excludes them) are
// skipped defensively.
for (const id of movedIds) {
const dest = ctx.engine.session.get(id, "Position") as
| Square
| undefined;
if (dest === undefined) continue;
fireOnMovedOntoSquareHooks(ctx.engine, id, dest);
}
// 8 + 9. Check-line edge triggers. Both consume the SAME
// pre-move snapshot — the dispatchers internally compute the
// post-move attacker set and diff. We pass the snapshot as a
// parameter (rather than having triggers.ts import the getter)
// to avoid a back-import cycle: apply.ts already imports from
// triggers.ts. See note in triggers.ts header.
const preCheckState = PRE_MOVE_CHECK_STATE_SNAPSHOTS.get(ctx.engine);
if (preCheckState !== undefined) {
fireOnCheckReceivedHooks(ctx.engine, preCheckState);
fireOnCheckDeliveredHooks(ctx.engine, preCheckState);
}
// 10. conditional (any "if X then Y else Z" hooks).
fireConditionalHooks(ctx.engine);
// 11. on-turn-end for the MOVER (their turn just ended). Runs
// BEFORE on-turn-start so end-of-turn ticks (status decrements,
// cooldown reductions) resolve before the opponent's start-of-
// turn ticks read them.
fireOnTurnEndHooks(ctx.engine, ctx.mover);
// 12. on-turn-start for the NEXT color (opposite of mover).
const nextTurn: "white" | "black" =
ctx.mover === "white" ? "black" : "white";
fireOnTurnStartHooks(ctx.engine, nextTurn);
} finally {
// T4: clear the pre-move snapshots LAST — after every trigger
// Clear ALL 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);
PRE_MOVE_POSITION_SNAPSHOTS.delete(ctx.engine);
PRE_MOVE_CAPTURED_DEFENDERS.delete(ctx.engine);
}
},
});