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.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 22:08:11 -06:00
commit fab8a8115b
No known key found for this signature in database
3 changed files with 414 additions and 4 deletions

View file

@ -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<MoveGetter>(
(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 = [

View file

@ -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;

View file

@ -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([]);
});
});