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:
parent
fea511790b
commit
fab8a8115b
3 changed files with 414 additions and 4 deletions
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
359
packages/chess/src/presets/transform-hook.test.ts
Normal file
359
packages/chess/src/presets/transform-hook.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue