feat(engine): filterLegalMoves hook
Phase A.2 of the rule-variants epic — adds the post-aggregation legal-move filter pipeline. - Adds FilterLegalMovesContext + filterLegalMoves hook on PresetDef. Hook runs AFTER the self-check filter; iterated in activePresets list order, each preset sees the prior's output. Subset-only contract (drop, don't synthesize); documented + pinned via test. - Wires the iteration into engine.getAllLegalMoves — immediately after the royal-aware self-check filter lands. - Adds filter-legal-moves.test.ts (6 tests): baseline unchanged, no-a-file drop filter, compulsory-capture prototype (proves the suicide-chess rule surface before D.1 lands), two-preset composition, and subset-contract doc test. Tests: 1434 passing (was 1428, +6 new). Blocks: unblocks D.1 (suicide-chess).
This commit is contained in:
parent
4d05473919
commit
db8145fb97
4 changed files with 311 additions and 1 deletions
|
|
@ -124,3 +124,32 @@ section).
|
||||||
- `packages/chess/src/presets/royal-pieces.test.ts` (new, 300+ lines, 11 tests)
|
- `packages/chess/src/presets/royal-pieces.test.ts` (new, 300+ lines, 11 tests)
|
||||||
|
|
||||||
**Blocks**: A.1 now clears B.1, C.1, C.2, C.3, D.1, D.2. Phase A remaining: A.2 (filterLegalMoves), A.3 (shouldAdvanceTurn + HalfMovesThisTurn), A.4 (overridePieceMoves), A.5 (gate + PRESET-API.md docs).
|
**Blocks**: A.1 now clears B.1, C.1, C.2, C.3, D.1, D.2. Phase A remaining: A.2 (filterLegalMoves), A.3 (shouldAdvanceTurn + HalfMovesThisTurn), A.4 (overridePieceMoves), A.5 (gate + PRESET-API.md docs).
|
||||||
|
|
||||||
|
## [2026-04-20 20:08] Task: A.2 — filterLegalMoves hook
|
||||||
|
|
||||||
|
**Shipped**:
|
||||||
|
- `FilterLegalMovesContext` type + `filterLegalMoves` hook on
|
||||||
|
`PresetDef` (`packages/chess/src/presets/registry.ts`). Context
|
||||||
|
carries `engine`, `color`, `moves`. Hook signature returns
|
||||||
|
`readonly LegalMove[]` (subset of input).
|
||||||
|
- `engine.getAllLegalMoves`: after the self-check filter + royal
|
||||||
|
resolution, iterate every active preset's `filterLegalMoves` in
|
||||||
|
`activePresets.list()` order. Each preset sees the prior's output.
|
||||||
|
- New test file: `packages/chess/src/presets/filter-legal-moves.test.ts`
|
||||||
|
(6 tests). Covers: baseline unchanged (FIDE opening = 20 moves),
|
||||||
|
prototype "no-a-file" drop filter, compulsory-capture prototype
|
||||||
|
(suicide-chess surface — proven before D.1 lands), two-preset
|
||||||
|
composition (a-file + h-file = 14 surviving), and the subset-
|
||||||
|
contract documentation test.
|
||||||
|
|
||||||
|
**Verification**: `bun run check` green. 1434 tests (was 1428, +6).
|
||||||
|
|
||||||
|
**Gotchas**:
|
||||||
|
- `EntityId` is branded — tests creating synthetic `LegalMove`
|
||||||
|
literals need `999 as EntityId`. Vitest was happy; `tsc -b` caught
|
||||||
|
it on the full check.
|
||||||
|
- Hook contract is subset-only; the engine does NOT enforce it at
|
||||||
|
runtime (documented + pinned via a test). Future enforcement
|
||||||
|
would be a visible diff. Keeping enforcement out for now
|
||||||
|
because the 1 call site per legal-move request is hot-path-
|
||||||
|
adjacent and a `Set`-based prune would show up.
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import type {
|
||||||
DamageContext,
|
DamageContext,
|
||||||
DamageHookContext,
|
DamageHookContext,
|
||||||
DescribeMoveEffectContext,
|
DescribeMoveEffectContext,
|
||||||
|
FilterLegalMovesContext,
|
||||||
GameResultHookContext,
|
GameResultHookContext,
|
||||||
LifecycleContext,
|
LifecycleContext,
|
||||||
MoveHookContext,
|
MoveHookContext,
|
||||||
|
|
@ -1005,9 +1006,27 @@ export class ChessEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const royalIds = this.getActiveRoyalEntityIds(color);
|
const royalIds = this.getActiveRoyalEntityIds(color);
|
||||||
return applyFilter
|
let finalMoves = applyFilter
|
||||||
? filterSelfCheckMoves(this.session, moves, color, royalIds)
|
? filterSelfCheckMoves(this.session, moves, color, royalIds)
|
||||||
: moves;
|
: moves;
|
||||||
|
|
||||||
|
// Phase A.2: post-aggregation filter pass. Each active preset's
|
||||||
|
// `filterLegalMoves` runs in registration order, receiving the
|
||||||
|
// accumulated list from the prior. Used by `suicide-chess` to
|
||||||
|
// enforce compulsory capture. See `filterLegalMoves` docs on
|
||||||
|
// `PresetDef` for composition semantics.
|
||||||
|
for (const entry of this.activePresets.list()) {
|
||||||
|
const def = PRESET_REGISTRY.get(entry.id);
|
||||||
|
if (def?.filterLegalMoves === undefined) continue;
|
||||||
|
const ctx: FilterLegalMovesContext = {
|
||||||
|
engine: this,
|
||||||
|
color,
|
||||||
|
moves: finalMoves,
|
||||||
|
};
|
||||||
|
finalMoves = [...def.filterLegalMoves(ctx)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalMoves;
|
||||||
}
|
}
|
||||||
|
|
||||||
applyMove(move: LegalMove, promoteTo: PieceType = "queen"): GameResult {
|
applyMove(move: LegalMove, promoteTo: PieceType = "queen"): GameResult {
|
||||||
|
|
|
||||||
213
packages/chess/src/presets/filter-legal-moves.test.ts
Normal file
213
packages/chess/src/presets/filter-legal-moves.test.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
/**
|
||||||
|
* Tests for the `filterLegalMoves` hook (Phase A.2 of the rule-variants
|
||||||
|
* epic).
|
||||||
|
*
|
||||||
|
* The hook runs AFTER:
|
||||||
|
* - per-piece move generation (type registry + transformMoveGenerator)
|
||||||
|
* - per-piece contributions (getExtraMoves) and filters (filterMoves)
|
||||||
|
* - the self-check filter (unless opted out)
|
||||||
|
* and BEFORE:
|
||||||
|
* - `getAllLegalMoves` returns its result to callers
|
||||||
|
*
|
||||||
|
* Each active preset's `filterLegalMoves` is iterated in registration
|
||||||
|
* order; each sees the OUTPUT of the prior. A preset returning a
|
||||||
|
* subset DROPS the unlisted moves. Synthesizing new moves is NOT
|
||||||
|
* supported — that's what `getExtraMoves` is for.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - Prototype "no-a-file" preset drops every move whose source or
|
||||||
|
* destination is on the a-file. Affects multiple pieces.
|
||||||
|
* - No active preset with the hook → getAllLegalMoves unchanged
|
||||||
|
* (regression coverage for the 1428-test baseline).
|
||||||
|
* - Compulsory-capture prototype (stand-in for suicide-chess): if
|
||||||
|
* any move is a capture, drop everything else. Used to prove the
|
||||||
|
* hook is enough to implement the motivating use case before
|
||||||
|
* suicide-chess lands in Phase D.
|
||||||
|
* - Two presets stacking: first drops one class, second drops
|
||||||
|
* another — each sees the prior's output. Documents the
|
||||||
|
* composition model.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import "./index.js";
|
||||||
|
import type { EntityId } from "@paratype/rete";
|
||||||
|
import { ChessEngine } from "../engine.js";
|
||||||
|
import { PRESET_REGISTRY } from "./registry.js";
|
||||||
|
import type { LegalMove } from "../rules/types.js";
|
||||||
|
import { clearBoard, placePiece } from "./test-utils.js";
|
||||||
|
import { fileOf } from "../coord.js";
|
||||||
|
|
||||||
|
const NO_A_FILE_ID = "test-no-a-file";
|
||||||
|
const COMPULSORY_CAPTURE_ID = "test-compulsory-capture";
|
||||||
|
const NO_H_FILE_ID = "test-no-h-file";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Drops any move touching the a-file (either `from` or `to`).
|
||||||
|
PRESET_REGISTRY.register({
|
||||||
|
id: NO_A_FILE_ID,
|
||||||
|
name: "No a-file (test)",
|
||||||
|
description: "Drops every move starting on or landing on the a-file.",
|
||||||
|
incompatibleWith: [],
|
||||||
|
requires: [],
|
||||||
|
filterLegalMoves({ moves }) {
|
||||||
|
return moves.filter(m => fileOf(m.from) !== 0 && fileOf(m.to) !== 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// If any capture is legal, keep only captures — the suicide-chess
|
||||||
|
// rule surface, proven here against the hook directly.
|
||||||
|
PRESET_REGISTRY.register({
|
||||||
|
id: COMPULSORY_CAPTURE_ID,
|
||||||
|
name: "Compulsory capture (test)",
|
||||||
|
description:
|
||||||
|
"If any move in the aggregated list is a capture, restrict to captures.",
|
||||||
|
incompatibleWith: [],
|
||||||
|
requires: [],
|
||||||
|
filterLegalMoves({ moves }) {
|
||||||
|
const anyCapture = moves.some(m => m.isCapture === true);
|
||||||
|
if (!anyCapture) return moves;
|
||||||
|
return moves.filter(m => m.isCapture === true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drops any move touching the h-file. Used alongside NO_A_FILE_ID
|
||||||
|
// to verify two presets stack deterministically.
|
||||||
|
PRESET_REGISTRY.register({
|
||||||
|
id: NO_H_FILE_ID,
|
||||||
|
name: "No h-file (test)",
|
||||||
|
description: "Drops every move starting on or landing on the h-file.",
|
||||||
|
incompatibleWith: [],
|
||||||
|
requires: [],
|
||||||
|
filterLegalMoves({ moves }) {
|
||||||
|
return moves.filter(m => fileOf(m.from) !== 7 && fileOf(m.to) !== 7);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filterLegalMoves — baseline back-compat", () => {
|
||||||
|
it("no active preset with the hook → getAllLegalMoves unchanged", () => {
|
||||||
|
// This test exists purely as a regression check: if someone adds
|
||||||
|
// an always-on filter downstream, this fails. We compare the
|
||||||
|
// initial white move count to the well-known FIDE opening number.
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
const whiteMoves = engine.getAllLegalMoves();
|
||||||
|
// Standard chess opening: 8 pawn pushes × 2 (1- or 2-square) = 16
|
||||||
|
// pawn moves + 4 knight moves = 20 legal moves for white.
|
||||||
|
expect(whiteMoves).toHaveLength(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filterLegalMoves — drop-filter prototype", () => {
|
||||||
|
it("no-a-file preset drops every move touching file 0", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: NO_A_FILE_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
// None of the surviving moves touch the a-file.
|
||||||
|
for (const m of moves) {
|
||||||
|
expect(fileOf(m.from)).not.toBe(0);
|
||||||
|
expect(fileOf(m.to)).not.toBe(0);
|
||||||
|
}
|
||||||
|
// Sanity: some moves DID survive (we didn't nuke the whole list).
|
||||||
|
expect(moves.length).toBeGreaterThan(0);
|
||||||
|
// And we DID lose moves compared to the baseline — opening has
|
||||||
|
// a-pawn + knight-to-a3 = 3 moves touching the a-file.
|
||||||
|
expect(moves.length).toBeLessThan(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filterLegalMoves — compulsory-capture prototype (suicide-chess surface)", () => {
|
||||||
|
it("no captures available → list unchanged", () => {
|
||||||
|
// Minimal position with no captures possible.
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: COMPULSORY_CAPTURE_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
const before = engine.getAllLegalMoves();
|
||||||
|
// FIDE opening has no captures → compulsory-capture hook is a no-op.
|
||||||
|
expect(before.length).toBe(20);
|
||||||
|
expect(before.every(m => m.isCapture !== true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("at least one capture available → only captures survive", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
clearBoard(engine, { preserveKings: false });
|
||||||
|
|
||||||
|
placePiece(engine, "king", "white", "e1");
|
||||||
|
placePiece(engine, "king", "black", "e8");
|
||||||
|
// White rook on a1 can capture black pawn on a5. It also has
|
||||||
|
// many non-capture sliding moves. The hook should discard those.
|
||||||
|
placePiece(engine, "rook", "white", "a1");
|
||||||
|
placePiece(engine, "pawn", "black", "a5");
|
||||||
|
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: COMPULSORY_CAPTURE_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
expect(moves.length).toBeGreaterThan(0);
|
||||||
|
// Every surviving move MUST be a capture.
|
||||||
|
expect(moves.every(m => m.isCapture === true)).toBe(true);
|
||||||
|
// And specifically the rook-captures-pawn move must be present.
|
||||||
|
const rxa5 = moves.find(m => m.to === 32); // a5 = rank 4 * 8 + file 0 = 32
|
||||||
|
expect(rxa5).toBeDefined();
|
||||||
|
expect(rxa5?.isCapture).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filterLegalMoves — composition (registration order)", () => {
|
||||||
|
it("stacked no-a-file + no-h-file drops moves touching either edge file", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: NO_A_FILE_ID, scope: "both", turnsRemaining: null },
|
||||||
|
{ id: NO_H_FILE_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
for (const m of moves) {
|
||||||
|
expect(fileOf(m.from)).not.toBe(0);
|
||||||
|
expect(fileOf(m.from)).not.toBe(7);
|
||||||
|
expect(fileOf(m.to)).not.toBe(0);
|
||||||
|
expect(fileOf(m.to)).not.toBe(7);
|
||||||
|
}
|
||||||
|
// FIDE opening: 20 moves; a-file costs 3 (a2-a3, a2-a4, Na3);
|
||||||
|
// h-file costs 3 (h2-h3, h2-h4, Nh3); no overlap → 14.
|
||||||
|
expect(moves.length).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subset property preserved — hook cannot synthesize moves", () => {
|
||||||
|
// Register a malicious preset that tries to return a move NOT in
|
||||||
|
// the input. The engine is defensive-by-contract: we document the
|
||||||
|
// hook's contract in `PresetDef.filterLegalMoves` but don't
|
||||||
|
// enforce at runtime. This test captures current behaviour so a
|
||||||
|
// future change to ADD enforcement is a visible diff.
|
||||||
|
const BAD_ID = "test-synthesize-move";
|
||||||
|
PRESET_REGISTRY.register({
|
||||||
|
id: BAD_ID,
|
||||||
|
name: "Synthesize (test)",
|
||||||
|
description: "Tries to add a fake move. Documented as violating the contract.",
|
||||||
|
incompatibleWith: [],
|
||||||
|
requires: [],
|
||||||
|
filterLegalMoves({ moves }) {
|
||||||
|
const fakeMove: LegalMove = {
|
||||||
|
pieceId: 999 as EntityId,
|
||||||
|
from: 0,
|
||||||
|
to: 1,
|
||||||
|
isCapture: false,
|
||||||
|
};
|
||||||
|
return [...moves, fakeMove];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: BAD_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
// Current behaviour: the engine does NOT prune the fake move.
|
||||||
|
// The contract docs this as a caller responsibility. Test
|
||||||
|
// asserts 20 (baseline) + 1 (fake) = 21 to lock the semantics.
|
||||||
|
expect(moves.length).toBe(21);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -229,6 +229,28 @@ export interface RoyalContext extends HookContext {
|
||||||
readonly color: "white" | "black";
|
readonly color: "white" | "black";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context passed to `filterLegalMoves`. Carries the aggregated legal-
|
||||||
|
* move list (post-self-check-filter) + the color whose moves are being
|
||||||
|
* generated, so the preset can filter / reorder / drop entries.
|
||||||
|
*
|
||||||
|
* Phase A.2 (rule-variants epic): this is the post-aggregation
|
||||||
|
* pipeline stage. The `moves` array is already fully filtered for
|
||||||
|
* self-check (unless a preset opted out via `shouldFilterSelfCheck`);
|
||||||
|
* presets iterate in registration order, each seeing the OUTPUT of
|
||||||
|
* the prior. Composition is safe iff the transforms are commutative
|
||||||
|
* — for "drop" filters this is trivially true; for reorderings, use
|
||||||
|
* `incompatibleWith` to keep conflicting presets from stacking.
|
||||||
|
*
|
||||||
|
* Used by `suicide-chess` to enforce compulsory capture (if any
|
||||||
|
* capture is legal, return only captures) — the canonical motivating
|
||||||
|
* example.
|
||||||
|
*/
|
||||||
|
export interface FilterLegalMovesContext extends HookContext {
|
||||||
|
readonly color: "white" | "black";
|
||||||
|
readonly moves: readonly LegalMove[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Why a piece is being spawned. Used by `onPieceSpawn` hooks to
|
* Why a piece is being spawned. Used by `onPieceSpawn` hooks to
|
||||||
* decide whether to participate (e.g., a preset that resurrects
|
* decide whether to participate (e.g., a preset that resurrects
|
||||||
|
|
@ -497,6 +519,33 @@ export interface PresetDef {
|
||||||
ctx: RoyalContext,
|
ctx: RoyalContext,
|
||||||
) => readonly EntityId[] | undefined;
|
) => readonly EntityId[] | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-aggregation filter on the per-color legal-move list.
|
||||||
|
*
|
||||||
|
* Runs AFTER the engine's self-check filter (and after every
|
||||||
|
* preset's `getExtraMoves` + `filterMoves` have contributed to the
|
||||||
|
* per-piece list) but BEFORE the engine returns the final list to
|
||||||
|
* callers. Iterated in preset-registration order; each preset sees
|
||||||
|
* the output of the previous.
|
||||||
|
*
|
||||||
|
* The returned array MUST be a subset of `ctx.moves` (or an equal
|
||||||
|
* reordering) — the hook can DROP entries but cannot SYNTHESIZE
|
||||||
|
* new ones. Use `getExtraMoves` for contribution; use this hook for
|
||||||
|
* filtration.
|
||||||
|
*
|
||||||
|
* Canonical use: `suicide-chess`. If any move in `ctx.moves` has
|
||||||
|
* `isCapture === true`, return only the captures → enforces
|
||||||
|
* compulsory capture without per-piece reasoning.
|
||||||
|
*
|
||||||
|
* Composition: SAFE for pure-subset filters (commutative across
|
||||||
|
* presets). For stricter filters (e.g. "only moves into the top
|
||||||
|
* half of the board") that might interact non-commutatively with
|
||||||
|
* another drop filter, declare `incompatibleWith` between them.
|
||||||
|
*/
|
||||||
|
readonly filterLegalMoves?: (
|
||||||
|
ctx: FilterLegalMovesContext,
|
||||||
|
) => readonly LegalMove[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase hook: fires AFTER the move has been confirmed legal but
|
* Phase hook: fires AFTER the move has been confirmed legal but
|
||||||
* BEFORE any state mutation. Return `{ cancel: true, reason }` to
|
* BEFORE any state mutation. Return `{ cancel: true, reason }` to
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue