diff --git a/packages/chess/src/modifiers/apply.test.ts b/packages/chess/src/modifiers/apply.test.ts index f207571..945b693 100644 --- a/packages/chess/src/modifiers/apply.test.ts +++ b/packages/chess/src/modifiers/apply.test.ts @@ -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>; + + 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(); + 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); + }); +}); diff --git a/packages/chess/src/modifiers/apply.ts b/packages/chess/src/modifiers/apply.ts index f754443..be1ccf2 100644 --- a/packages/chess/src/modifiers/apply.ts +++ b/packages/chess/src/modifiers/apply.ts @@ -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>(); +/** + * 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 +>(); + +/** + * 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 { + const out = new Map(); + 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[] { + 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, +): 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); } }, });