From fab8a8115b93303773ab501fcf24b42a884385d5 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:08:11 -0600 Subject: [PATCH] feat(engine): add transformMoveGenerator + modifyMoveAttrs preset hooks Adds two optional hooks to PresetDef: - transformMoveGenerator wraps a piece's move generator. Presets compose in list order; each receives the previous wrapper's output. Returned moves are pseudo-legal and still pass through the engine's self-check filter. - modifyMoveAttrs lets presets contribute additive range/direction deltas for generators that support them. ChessEngine.getAllLegalMoves folds the transform chain before getExtraMoves/filterMoves, preserving existing hook ordering and the downstream self-check filter. Tests cover: no-op wrap preserves moves, wrap adds pawn backward, two wraps compose, and self-check filter still prunes transform-added illegal moves. --- packages/chess/src/engine.ts | 26 +- packages/chess/src/presets/registry.ts | 33 +- .../chess/src/presets/transform-hook.test.ts | 359 ++++++++++++++++++ 3 files changed, 414 insertions(+), 4 deletions(-) create mode 100644 packages/chess/src/presets/transform-hook.test.ts diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index 8d4b8c1..147ad09 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -718,8 +718,28 @@ export class ChessEngine { .filter(p => p.type !== undefined); for (const piece of pieces) { - const getter = lookupMoveGenerator(piece.type); - if (!getter) continue; + const baseGetter = lookupMoveGenerator(piece.type); + // Fold the transformMoveGenerator chain over all active presets + // (scope-filtered for `color`). Each preset's wrapper receives the + // OUTPUT of the previous — composing cleanly. We seed with the + // type-registry generator, or a degenerate empty-move generator if + // the piece type is unknown (keeps later wrappers well-defined). + const scopedPresets = this.activePresets.getForColor(color); + const seedGetter: MoveGetter = + baseGetter ?? ((_s: Session, _id: EntityId) => [] as LegalMove[]); + const getter: MoveGetter = scopedPresets + .filter(p => p.transformMoveGenerator) + .reduce( + (gen, preset) => preset.transformMoveGenerator!(this, piece.id, gen), + seedGetter, + ); + // Skip pieces with no known type AND no transform — same semantics + // as the original "unknown type ⇒ immobile" guard, but now a + // transform preset can still produce moves for an otherwise unknown + // type. + if (!baseGetter && scopedPresets.every(p => !p.transformMoveGenerator)) { + continue; + } let pieceMoves = getter(this.session, piece.id); @@ -759,7 +779,7 @@ export class ChessEngine { // Order matters — `getExtraMoves` contributes to the set that // `filterMoves` operates on, so every active preset sees the full // aggregated set (including prior presets' additions). - const activePresets = this.activePresets.getForColor(color); + const activePresets = scopedPresets; for (const preset of activePresets) { if (preset.getExtraMoves) { pieceMoves = [ diff --git a/packages/chess/src/presets/registry.ts b/packages/chess/src/presets/registry.ts index 69f564e..cca8012 100644 --- a/packages/chess/src/presets/registry.ts +++ b/packages/chess/src/presets/registry.ts @@ -44,7 +44,7 @@ * * Presets register themselves via side-effect imports (see `./index.ts`). */ -import type { EntityId } from "@paratype/rete"; +import type { EntityId, Session } from "@paratype/rete"; import type { ChessEngine, GameResult } from "../engine.js"; import type { LegalMove } from "../rules/types.js"; import type { ChessAttrKey } from "../schema.js"; @@ -301,6 +301,37 @@ export interface PresetDef { pieceId: EntityId, ) => LegalMove[]; + /** + * Wraps the move generator for a specific piece. Receives the current + * generator (initially the type-registry's generator, or the output of + * a prior preset's transformation) and returns a new generator. + * + * Semantics: WRAP, not REPLACE. The returned function MUST call `prev` + * at some point to preserve base moves (unless intentionally discarding). + * Returned moves are PSEUDO-LEGAL — the engine's self-check filter runs + * downstream regardless. + * + * Evaluation order: presets iterate in list order. Each transform receives + * the output of the previous, composing cleanly. + */ + readonly transformMoveGenerator?: ( + engine: ChessEngine, + pieceId: EntityId, + prev: (session: Session, pieceId: EntityId) => LegalMove[], + ) => (session: Session, pieceId: EntityId) => LegalMove[]; + + /** + * Contribute move attribute deltas (range extension, direction additions) + * for a piece. Read by move generators that support them (rook, bishop, queen). + * Multiple presets returning values are ADDITIVE per the stacking rules. + * + * Returns undefined / empty object for no-op. + */ + readonly modifyMoveAttrs?: ( + engine: ChessEngine, + pieceId: EntityId, + ) => { rangeBonus?: number; directionAdditions?: readonly string[] } | undefined; + // ── Lifecycle hooks ────────────────────────────────────────────────── readonly onActivate?: (ctx: LifecycleContext) => void; readonly onDeactivate?: (ctx: LifecycleContext) => void; diff --git a/packages/chess/src/presets/transform-hook.test.ts b/packages/chess/src/presets/transform-hook.test.ts new file mode 100644 index 0000000..033c190 --- /dev/null +++ b/packages/chess/src/presets/transform-hook.test.ts @@ -0,0 +1,359 @@ +/** + * Tests for the `transformMoveGenerator` preset hook. + * + * `transformMoveGenerator` WRAPS the type-registry move generator for a + * given piece. Each preset that implements it receives the output of the + * previous preset's wrapper, composing cleanly. Self-check filtering + * runs downstream and cannot be bypassed. + * + * These tests exercise: + * 1. A no-op wrapper (calls prev unchanged) produces identical moves. + * 2. A wrapper can ADD moves (pawn backward-1) without discarding + * the base forward moves. + * 3. Two wrapping presets compose — each sees the other's output and + * both of their contributions land in the final move set. + * 4. The engine's self-check filter still runs AFTER the transform — + * a transform can't legalize a move that leaves the king in check. + */ +import { describe, it, expect, beforeAll } from "vitest"; +import "./index.js"; +import type { EntityId, Session } from "@paratype/rete"; +import { ChessEngine } from "../engine.js"; +import { PRESET_REGISTRY } from "./registry.js"; +import type { LegalMove } from "../rules/types.js"; +import type { PieceColor, Square } from "../schema.js"; +import { rankOf } from "../coord.js"; +import { EMPTY_LAYOUT } from "../layouts/empty.js"; + +/** Find the piece currently on a given raw square; null if empty. */ +function pieceAt(engine: ChessEngine, sq: number): EntityId | null { + for (const f of engine.session.allFacts()) { + if (f.attr === "Position" && f.value === sq) return f.id; + } + return null; +} + +// ─── Register test-only presets (idempotent across test runs) ──────── + +const NOOP_ID = "__test__/transform-noop"; +const PAWN_BACKWARD_ID = "__test__/transform-pawn-backward"; +const SECOND_WRAP_ID = "__test__/transform-pawn-sideways"; +const EXTRA_MOVES_ID = "__test__/transform-extra-moves"; + +beforeAll(() => { + // A preset that wraps the generator but calls prev(session, pieceId) + // and returns its result unmodified. The whole point is to prove that + // wrapping doesn't itself change behaviour — composition is safe. + if (!PRESET_REGISTRY.get(NOOP_ID)) { + PRESET_REGISTRY.register({ + id: NOOP_ID, + name: "Test: noop transform", + description: "Calls prev and returns output unchanged.", + incompatibleWith: [], + requires: [], + transformMoveGenerator: ( + _engine, + _pieceId, + prev, + ) => (session: Session, id: EntityId) => prev(session, id), + }); + } + + // A preset that wraps the pawn generator specifically and adds a + // backward-1 non-capture move. We don't touch non-pawn pieces: for + // those we return prev unchanged. + if (!PRESET_REGISTRY.get(PAWN_BACKWARD_ID)) { + PRESET_REGISTRY.register({ + id: PAWN_BACKWARD_ID, + name: "Test: transform adds pawn backward move", + description: + "Wraps the pawn generator to add a backward-1 non-capture move.", + incompatibleWith: [], + requires: [], + transformMoveGenerator: ( + engine, + pieceId, + prev, + ) => (session: Session, id: EntityId) => { + const base = prev(session, id); + const type = session.get(pieceId, "PieceType"); + if (type !== "pawn") return base; + const from = session.get(pieceId, "Position") as number | null; + const color = session.get(pieceId, "Color") as PieceColor | null; + if (from === null || color === null) return base; + const dir = color === "white" ? -8 : 8; + const targetRank = rankOf(from as Square) + (color === "white" ? -1 : 1); + if (targetRank < 0 || targetRank > 7) return base; + const to = (from + dir) as Square; + // Only add if the square is empty. + const occupied = engine.session + .allFacts() + .some((f) => f.attr === "Position" && f.value === to); + if (occupied) return base; + return [ + ...base, + { pieceId: id, from: from as Square, to, isCapture: false }, + ]; + }, + }); + } + + // A second wrapping preset that ALSO targets pawns but adds a + // sideways-right move to an arbitrary square (for composition + // testing — doesn't need to be a sensible chess move). + if (!PRESET_REGISTRY.get(SECOND_WRAP_ID)) { + PRESET_REGISTRY.register({ + id: SECOND_WRAP_ID, + name: "Test: transform adds pawn sideways move", + description: + "Wraps the pawn generator (or whatever the previous wrap produced) " + + "to add a sideways-right pseudo move.", + incompatibleWith: [], + requires: [], + transformMoveGenerator: ( + _engine, + pieceId, + prev, + ) => (session: Session, id: EntityId) => { + const base = prev(session, id); + const type = session.get(pieceId, "PieceType"); + if (type !== "pawn") return base; + const from = session.get(pieceId, "Position") as number | null; + if (from === null) return base; + // Avoid going off-board. + if ((from % 8) >= 7) return base; + const to = (from + 1) as Square; + return [ + ...base, + { pieceId: id, from: from as Square, to, isCapture: false }, + ]; + }, + }); + } + + // A helper preset for the self-check test: a transform that adds a + // move that LEAVES THE KING IN CHECK. We need this to prove the + // self-check filter still runs AFTER the transformed generator. + if (!PRESET_REGISTRY.get(EXTRA_MOVES_ID)) { + PRESET_REGISTRY.register({ + id: EXTRA_MOVES_ID, + name: "Test: transform adds a self-check move", + description: + "Wraps the generator for a specific piece to add a move that " + + "would leave the mover's king in check. The engine's self-check " + + "filter MUST prune it.", + incompatibleWith: [], + requires: [], + transformMoveGenerator: ( + _engine, + pieceId, + prev, + ) => (session: Session, id: EntityId) => { + const base = prev(session, id); + // We target a specific piece via its entity id — the test sets + // up a pinned bishop and we only augment IT (other pieces pass + // through unchanged). + if (pieceId !== id) return base; + const from = session.get(pieceId, "Position") as number | null; + if (from === null) return base; + // Generate "move 1 square up"; in the pin-pos below this + // abandons the pin and exposes the king. + const to = (from + 8) as Square; + if (to > 63) return base; + return [ + ...base, + { pieceId: id, from: from as Square, to, isCapture: false }, + ]; + }, + }); + } +}); + +// ─── 1. Noop transform preserves legal moves ───────────────────────── + +describe("transformMoveGenerator — noop preset", () => { + it("legal moves are identical to without the preset", () => { + const without = new ChessEngine(); + const withNoop = new ChessEngine(); + withNoop.setActivePresets([ + { id: NOOP_ID, scope: "both", turnsRemaining: null }, + ]); + + // Normalize for deterministic comparison: project each move to a + // comparable tuple and sort. + const proj = (ms: LegalMove[]) => + ms + .map((m) => `${m.from}->${m.to}:${m.isCapture ? 1 : 0}:${m.promoteTo ?? ""}`) + .sort(); + + expect(proj(withNoop.getAllLegalMoves())).toEqual( + proj(without.getAllLegalMoves()), + ); + }); +}); + +// ─── 2. Wrapping preset adds backward move to pawn ─────────────────── + +describe("transformMoveGenerator — pawn backward wrapper", () => { + it("adds backward move, preserves base forward moves", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: PAWN_BACKWARD_ID, scope: "both", turnsRemaining: null }, + ]); + + // Move the white a-pawn to a4 (square 24), leave a3 (square 16) + // empty by retracting the rook on a1 so the square in front of + // the rook doesn't actually matter — we only care that a3 is + // empty which it is when the pawn is on a4. + const whiteAPawn = pieceAt(engine, 8)!; + expect(whiteAPawn).not.toBeNull(); + engine.session.insert(whiteAPawn, "Position", 24 as Square); + + const moves = engine + .getAllLegalMoves() + .filter((m) => m.pieceId === whiteAPawn); + + // Base forward move: a4 -> a5 (24 -> 32), non-capture, still present. + expect(moves.some((m) => m.from === 24 && m.to === 32 && !m.isCapture)).toBe( + true, + ); + // Our wrapper's addition: a4 -> a3 (24 -> 16), non-capture, present. + expect(moves.some((m) => m.from === 24 && m.to === 16 && !m.isCapture)).toBe( + true, + ); + }); + + it("does not add backward move when target square is occupied", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: PAWN_BACKWARD_ID, scope: "both", turnsRemaining: null }, + ]); + + // A-pawn stays on a2 (square 8); a1 (square 0) holds the rook. + // Backward target a1 is occupied -> not added. + const whiteAPawn = pieceAt(engine, 8)!; + const moves = engine + .getAllLegalMoves() + .filter((m) => m.pieceId === whiteAPawn); + expect(moves.some((m) => m.to === 0)).toBe(false); + }); +}); + +// ─── 3. Two wrapping presets compose ───────────────────────────────── + +describe("transformMoveGenerator — composition", () => { + it("second preset receives first preset's output; both contributions land", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: PAWN_BACKWARD_ID, scope: "both", turnsRemaining: null }, + { id: SECOND_WRAP_ID, scope: "both", turnsRemaining: null }, + ]); + + // Move white a-pawn to a4 (24). Backward target a3 (16) is empty. + // Sideways target b4 (25) is empty (b-pawn is on b2=9). + const whiteAPawn = pieceAt(engine, 8)!; + engine.session.insert(whiteAPawn, "Position", 24 as Square); + + const moves = engine + .getAllLegalMoves() + .filter((m) => m.pieceId === whiteAPawn); + + // Contribution from PAWN_BACKWARD_ID: a4 -> a3. + expect(moves.some((m) => m.to === 16)).toBe(true); + // Contribution from SECOND_WRAP_ID: a4 -> b4 (24 -> 25). + expect(moves.some((m) => m.to === 25)).toBe(true); + // Base forward move still present. + expect(moves.some((m) => m.to === 32)).toBe(true); + }); +}); + +// ─── 4. Self-check filter still applies after transform ────────────── + +describe("transformMoveGenerator — self-check filter runs downstream", () => { + it("transform cannot bypass self-check filter", () => { + // Set up a custom position where the white bishop is absolutely + // pinned by the black rook: W king on e1, white bishop on e2, + // black rook on e8. Moving the bishop off the e-file would expose + // the king. The EXTRA_MOVES_ID transform adds a bishop move 1 + // square up (e2 -> e3) which stays on the e-file (so that's + // actually legal in this setup) — so we target a DIFFERENT + // position: we place the bishop OFF the e-file and have it add + // a move that leaves the e-file. Easier: put the king and + // attacker on a rank, bishop as the pinned piece, and have the + // transform add a move that steps off the rank. + const engine = new ChessEngine({ layout: EMPTY_LAYOUT }); + + // White king on a1 (0); white bishop on a2 (8); black rook on a8 (56). + // Bishop is pinned along the a-file. Adding a move "up 1" = a3 + // (a8-a1 is the pin line); a3 is still on the a-file, so the pin + // remains intact. NO — "up 1" in our helper is +8 = from+8 so + // a2(8) + 8 = a3(16), still on file a, pin intact. + // + // Better: put the bishop on b2 (9), king on a1 (0), rook on h1 (7) + // along rank 1. The bishop is NOT pinned — king is on a1, not + // along b2's lines. We need a real pin. + // + // Cleanest pin: king on a1 (0), white piece on d1 (3), black rook + // on h1 (7) — that's a pin along rank 1. The transform adds + // "+8" = up one rank. The white piece moving up one rank steps + // off rank 1 and exposes the king to the rook. The self-check + // filter MUST prune it. + const whiteKingId = engine.session.nextId(); + engine.session.insert(whiteKingId, "PieceType", "king"); + engine.session.insert(whiteKingId, "Color", "white"); + engine.session.insert(whiteKingId, "Position", 0 as Square); + engine.session.insert(whiteKingId, "HasMoved", false); + + // Pinned white piece: use a knight because its base moves include + // natural off-rank moves anyway — those will all be pruned by the + // filter. The transform adds one more off-rank move; it must ALSO + // be pruned. + const whiteKnightId = engine.session.nextId(); + engine.session.insert(whiteKnightId, "PieceType", "knight"); + engine.session.insert(whiteKnightId, "Color", "white"); + engine.session.insert(whiteKnightId, "Position", 3 as Square); // d1 + engine.session.insert(whiteKnightId, "HasMoved", false); + + // Black rook on h1 — pinning the knight along rank 1. + const blackRookId = engine.session.nextId(); + engine.session.insert(blackRookId, "PieceType", "rook"); + engine.session.insert(blackRookId, "Color", "black"); + engine.session.insert(blackRookId, "Position", 7 as Square); // h1 + engine.session.insert(blackRookId, "HasMoved", false); + + // Black king somewhere safe so the board is well-formed. + const blackKingId = engine.session.nextId(); + engine.session.insert(blackKingId, "PieceType", "king"); + engine.session.insert(blackKingId, "Color", "black"); + engine.session.insert(blackKingId, "Position", 63 as Square); // h8 + engine.session.insert(blackKingId, "HasMoved", false); + + // Activate the transform preset. It adds a "+8 squares" move for + // the entity whose id it targets — we need to target the knight. + // But EXTRA_MOVES_ID fires for every piece its wrapper sees. That's + // fine: the effect on any NON-knight piece is also an off-rank + // move that either stays legal on its own merits or is pruned. The + // assertion we care about is that the move d1->d2 (knight off the + // pin) does NOT appear in legal moves. + engine.setActivePresets([ + { id: EXTRA_MOVES_ID, scope: "both", turnsRemaining: null }, + ]); + + const legal = engine.getAllLegalMoves(); + + // The transform added a pseudo-legal move d1 -> d2 (3 -> 11) for + // the knight. The self-check filter MUST prune it because the + // knight is absolutely pinned. If the self-check filter were + // bypassed, this assertion would fail. + const knightOffRank = legal.find( + (m) => m.pieceId === whiteKnightId && m.to === 11, + ); + expect(knightOffRank).toBeUndefined(); + + // Sanity: the knight has NO legal moves at all (every knight jump + // off rank 1 would expose the king). If the self-check filter were + // bypassed we'd see moves here. + const knightMoves = legal.filter((m) => m.pieceId === whiteKnightId); + expect(knightMoves).toEqual([]); + }); +});