feat(engine): overridePieceMoves hook
Phase A.4 of the rule-variants epic — adds the 'replace the default move generator for this piece' extension point. First non-undefined wins; later matches emit a dev-mode console.warn. The winning set SKIPS default-generator + transformMoveGenerator + getExtraMoves + en-passant/castling/promotion synthesis; filterMoves still composes on top. - Adds overridePieceMoves hook on PresetDef with full docs covering execution order, skipped downstream steps, filterMoves layering, and collision semantics. - engine.getAllLegalMoves per-piece loop now checks overrides first; override path replaces moves entirely; default path unchanged when every preset returns undefined. - Collision warning is dev-only (suppressed when NODE_ENV=production). First registered preset wins; second's return is ignored but console.warn names both preset ids. - New override-piece-moves.test.ts (7 tests): default knight count baseline, lame-knight single-square override, undefined-declines fallthrough, collision warning fires with both preset ids in the message, production-mode suppression, filterMoves composes on top of overrides, unrelated piece types unaffected. Tests: 1448 passing (was 1441, +7 new). Blocks: unblocks E.1 (berolina-pawns), E.2 (berolina-pawns-2).
This commit is contained in:
parent
f9475e9739
commit
1a11491a15
4 changed files with 488 additions and 61 deletions
|
|
@ -194,3 +194,53 @@ section).
|
||||||
- `session.allFacts()` automatically carries the new fact across
|
- `session.allFacts()` automatically carries the new fact across
|
||||||
serialization boundaries (snapshot/restore) without protocol
|
serialization boundaries (snapshot/restore) without protocol
|
||||||
changes — no server-side work needed for the A.3 addition.
|
changes — no server-side work needed for the A.3 addition.
|
||||||
|
|
||||||
|
## [2026-04-20 20:17] Task: A.4 — overridePieceMoves hook
|
||||||
|
|
||||||
|
**Shipped**:
|
||||||
|
- `overridePieceMoves` hook on `PresetDef`
|
||||||
|
(`packages/chess/src/presets/registry.ts`). Signature:
|
||||||
|
`(engine, pieceId) => readonly LegalMove[] | undefined`.
|
||||||
|
- `engine.getAllLegalMoves` restructured for per-piece dispatch:
|
||||||
|
1. Poll every scoped preset's `overridePieceMoves`. First
|
||||||
|
non-undefined wins.
|
||||||
|
2. If override wins → replace moves entirely. SKIP: default
|
||||||
|
type-registry generator, `transformMoveGenerator` chain,
|
||||||
|
`getExtraMoves` for this piece, en-passant/castling/promotion
|
||||||
|
synthesis (preset owns those mechanics).
|
||||||
|
3. If no override → unchanged default path (type registry +
|
||||||
|
transforms + en-passant + castling + promotion +
|
||||||
|
`getExtraMoves`).
|
||||||
|
4. `filterMoves` runs REGARDLESS (layering stays useful).
|
||||||
|
5. Collision: second non-undefined return triggers
|
||||||
|
`console.warn` (dev only — suppressed when `NODE_ENV=production`).
|
||||||
|
First winner stands.
|
||||||
|
- New test file: `override-piece-moves.test.ts` (7 tests). Covers:
|
||||||
|
baseline knight count unchanged, lame-knight prototype, undefined-
|
||||||
|
return fallthrough, collision warning fires, production-mode
|
||||||
|
suppression, `filterMoves` composes on top of overrides, unrelated
|
||||||
|
piece types unaffected.
|
||||||
|
|
||||||
|
**Verification**: `bun run check` green. 1448 tests (was 1441, +7).
|
||||||
|
|
||||||
|
**Gotchas**:
|
||||||
|
- `engine.getPieceAt` is private. Test presets need occupancy
|
||||||
|
checks → use `engine.session.allFacts().find(f => f.attr === "Position" && f.value === sq)`.
|
||||||
|
Same friction every future preset test will hit. Flagged for
|
||||||
|
Phase A.5 docs consideration but leaving the private as-is per
|
||||||
|
"don't refactor during features".
|
||||||
|
- Override collisions are a guardrail, not opt-in. The design
|
||||||
|
choice is "warn, don't throw" because the incompatibleWith graph
|
||||||
|
is the authoritative contract — crashing on collision would
|
||||||
|
make it harder to iteratively develop two competing overrides.
|
||||||
|
|
||||||
|
## Phase A status at end of 20:17
|
||||||
|
|
||||||
|
All 4 hooks landed on master:
|
||||||
|
- A.1: 4d05473 — getRoyalPieces + engine royal dispatch
|
||||||
|
- A.2: db8145f — filterLegalMoves
|
||||||
|
- A.3: f9475e9 — shouldAdvanceTurn + HalfMovesThisTurn
|
||||||
|
- A.4: (next commit) — overridePieceMoves
|
||||||
|
|
||||||
|
Remaining in Phase A: A.5 — verification gate + PRESET-API.md
|
||||||
|
hook-ordering doc + phase summary.
|
||||||
|
|
|
||||||
|
|
@ -915,77 +915,125 @@ export class ChessEngine {
|
||||||
.filter(p => p.type !== undefined);
|
.filter(p => p.type !== undefined);
|
||||||
|
|
||||||
for (const piece of pieces) {
|
for (const piece of pieces) {
|
||||||
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 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);
|
// Phase A.4: check for overridePieceMoves BEFORE the default
|
||||||
|
// generator + transform chain + getExtraMoves. First
|
||||||
// Add en passant for pawns
|
// non-undefined wins; later collisions emit a dev-mode
|
||||||
if (piece.type === "pawn") {
|
// console.warn but are otherwise ignored (the first winner's
|
||||||
pieceMoves = [
|
// set is used as-is). The returned override REPLACES the
|
||||||
...pieceMoves,
|
// type-registry moves; en-passant / castling / promotion
|
||||||
...getEnPassantMoves(this.session, piece.id),
|
// synthesis below is SKIPPED because any preset redefining a
|
||||||
];
|
// pawn or king assumes responsibility for those mechanics.
|
||||||
}
|
let overrideMoves: readonly LegalMove[] | undefined = undefined;
|
||||||
|
let overrideSource: string | undefined = undefined;
|
||||||
// Add castling for kings
|
for (const preset of scopedPresets) {
|
||||||
if (piece.type === "king") {
|
if (preset.overridePieceMoves === undefined) continue;
|
||||||
const enemyColor: PieceColor = color === "white" ? "black" : "white";
|
const candidate = preset.overridePieceMoves(this, piece.id);
|
||||||
const castling = getCastlingMoves(
|
if (candidate === undefined) continue;
|
||||||
this.session,
|
if (overrideMoves === undefined) {
|
||||||
piece.id,
|
overrideMoves = candidate;
|
||||||
(s, sq) => isSquareAttacked(s, sq, enemyColor),
|
overrideSource = preset.id;
|
||||||
);
|
} else {
|
||||||
pieceMoves = [...pieceMoves, ...castling];
|
// Second non-undefined return → collision. Dev-mode
|
||||||
}
|
// warning only; first winner keeps its claim.
|
||||||
|
const env =
|
||||||
// Replace raw promotion-rank pawn moves with promotion-tagged variants
|
(globalThis as { readonly process?: { readonly env?: { readonly NODE_ENV?: string } } })
|
||||||
if (piece.type === "pawn") {
|
.process?.env?.NODE_ENV;
|
||||||
const promotions = getPromotionMoves(this.session, piece.id);
|
if (env !== "production") {
|
||||||
if (promotions.length > 0) {
|
console.warn(
|
||||||
const promotionTos = new Set(promotions.map(m => m.to));
|
`[overridePieceMoves] collision on piece ${String(piece.id)} ` +
|
||||||
pieceMoves = pieceMoves.filter(m => !promotionTos.has(m.to));
|
`(type=${piece.type}): preset "${overrideSource}" already ` +
|
||||||
pieceMoves = [...pieceMoves, ...promotions];
|
`overrode; "${preset.id}" also returned a non-undefined ` +
|
||||||
|
`result (ignored). Declare incompatibleWith between these ` +
|
||||||
|
`presets to catch this at config time.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply active preset rules for the color whose turn it is. The
|
let pieceMoves: LegalMove[];
|
||||||
// per-color filter implements `scope=white` / `scope=black` — a
|
if (overrideMoves !== undefined) {
|
||||||
// white-only preset never contributes moves while black is on move.
|
pieceMoves = [...overrideMoves];
|
||||||
//
|
} else {
|
||||||
// Order matters — `getExtraMoves` contributes to the set that
|
const baseGetter = lookupMoveGenerator(piece.type);
|
||||||
// `filterMoves` operates on, so every active preset sees the full
|
// Fold the transformMoveGenerator chain over all active
|
||||||
// aggregated set (including prior presets' additions).
|
// presets (scope-filtered for `color`). Each preset's wrapper
|
||||||
const activePresets = scopedPresets;
|
// receives the OUTPUT of the previous — composing cleanly.
|
||||||
for (const preset of activePresets) {
|
// We seed with the type-registry generator, or a degenerate
|
||||||
if (preset.getExtraMoves) {
|
// empty-move generator if the piece type is unknown (keeps
|
||||||
|
// later wrappers well-defined).
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
pieceMoves = getter(this.session, piece.id);
|
||||||
|
|
||||||
|
// Add en passant for pawns
|
||||||
|
if (piece.type === "pawn") {
|
||||||
pieceMoves = [
|
pieceMoves = [
|
||||||
...pieceMoves,
|
...pieceMoves,
|
||||||
...preset.getExtraMoves(this, piece.id),
|
...getEnPassantMoves(this.session, piece.id),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add castling for kings
|
||||||
|
if (piece.type === "king") {
|
||||||
|
const enemyColor: PieceColor = color === "white" ? "black" : "white";
|
||||||
|
const castling = getCastlingMoves(
|
||||||
|
this.session,
|
||||||
|
piece.id,
|
||||||
|
(s, sq) => isSquareAttacked(s, sq, enemyColor),
|
||||||
|
);
|
||||||
|
pieceMoves = [...pieceMoves, ...castling];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace raw promotion-rank pawn moves with promotion-tagged
|
||||||
|
// variants
|
||||||
|
if (piece.type === "pawn") {
|
||||||
|
const promotions = getPromotionMoves(this.session, piece.id);
|
||||||
|
if (promotions.length > 0) {
|
||||||
|
const promotionTos = new Set(promotions.map(m => m.to));
|
||||||
|
pieceMoves = pieceMoves.filter(m => !promotionTos.has(m.to));
|
||||||
|
pieceMoves = [...pieceMoves, ...promotions];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply active preset rules for the color whose turn it is.
|
||||||
|
// The per-color filter implements `scope=white` /
|
||||||
|
// `scope=black` — a white-only preset never contributes moves
|
||||||
|
// while black is on move.
|
||||||
|
//
|
||||||
|
// Order matters — `getExtraMoves` contributes to the set
|
||||||
|
// that `filterMoves` operates on, so every active preset
|
||||||
|
// sees the full aggregated set (including prior presets'
|
||||||
|
// additions).
|
||||||
|
for (const preset of scopedPresets) {
|
||||||
|
if (preset.getExtraMoves) {
|
||||||
|
pieceMoves = [
|
||||||
|
...pieceMoves,
|
||||||
|
...preset.getExtraMoves(this, piece.id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const preset of activePresets) {
|
|
||||||
|
// filterMoves ALWAYS runs — even when overridePieceMoves
|
||||||
|
// produced the set. Lets presets layer per-piece filters on top
|
||||||
|
// of overrides.
|
||||||
|
for (const preset of scopedPresets) {
|
||||||
if (preset.filterMoves) {
|
if (preset.filterMoves) {
|
||||||
pieceMoves = preset.filterMoves(pieceMoves, this, piece.id);
|
pieceMoves = preset.filterMoves(pieceMoves, this, piece.id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
288
packages/chess/src/presets/override-piece-moves.test.ts
Normal file
288
packages/chess/src/presets/override-piece-moves.test.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
/**
|
||||||
|
* Tests for the `overridePieceMoves` hook (Phase A.4 of the
|
||||||
|
* rule-variants epic).
|
||||||
|
*
|
||||||
|
* Semantics under test:
|
||||||
|
* - Override runs BEFORE the type-registry default generator.
|
||||||
|
* - First non-undefined return wins. Subsequent matching presets
|
||||||
|
* are ignored BUT emit a dev-mode console.warn naming the
|
||||||
|
* colliding presets.
|
||||||
|
* - Override SKIPS the default generator, transformMoveGenerator
|
||||||
|
* chain, and getExtraMoves contributions on THAT piece. The
|
||||||
|
* engine will NOT synthesize en-passant / castling / promotion
|
||||||
|
* on the overridden set — the preset owns those mechanics.
|
||||||
|
* - `filterMoves` and `filterLegalMoves` STILL run on the
|
||||||
|
* overridden set (documented contract — layering stays useful).
|
||||||
|
* - Returning undefined declines; the engine falls through to the
|
||||||
|
* next preset or the default.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - "Lame knight" prototype replaces knight moves with single-square
|
||||||
|
* orthogonal steps. Proves override semantics.
|
||||||
|
* - Non-matching return (undefined) leaves default generator intact.
|
||||||
|
* - Collision: two presets both overriding → first wins, dev-mode
|
||||||
|
* warning fires. Production-mode suppression is covered via an
|
||||||
|
* explicit env toggle.
|
||||||
|
* - `filterMoves` still runs on the overridden set.
|
||||||
|
* - Unrelated pieces are unaffected (override on pawns doesn't
|
||||||
|
* touch knights).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import "./index.js";
|
||||||
|
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";
|
||||||
|
|
||||||
|
const LAME_KNIGHT_ID = "test-lame-knight";
|
||||||
|
const NEVER_OVERRIDE_ID = "test-never-override";
|
||||||
|
const RIVAL_LAME_KNIGHT_ID = "test-rival-lame-knight";
|
||||||
|
const KNIGHT_FILTER_ID = "test-knight-filter";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
PRESET_REGISTRY.register({
|
||||||
|
id: LAME_KNIGHT_ID,
|
||||||
|
name: "Lame knight (test)",
|
||||||
|
description:
|
||||||
|
"Replaces knight moves with single-square orthogonal steps.",
|
||||||
|
incompatibleWith: [],
|
||||||
|
requires: [],
|
||||||
|
overridePieceMoves(engine, pieceId) {
|
||||||
|
const type = engine.session.get(pieceId, "PieceType") as string | undefined;
|
||||||
|
if (type !== "knight") return undefined;
|
||||||
|
const from = engine.session.get(pieceId, "Position") as number;
|
||||||
|
const out: LegalMove[] = [];
|
||||||
|
const dirs = [-8, 8, -1, 1]; // N, S, W, E
|
||||||
|
for (const d of dirs) {
|
||||||
|
const to = from + d;
|
||||||
|
if (to < 0 || to > 63) continue;
|
||||||
|
// Avoid wrapping across file edges on lateral moves.
|
||||||
|
if (d === -1 && (from % 8) === 0) continue;
|
||||||
|
if (d === 1 && (from % 8) === 7) continue;
|
||||||
|
// Inline occupancy check via session facts (getPieceAt is
|
||||||
|
// private on the engine).
|
||||||
|
let isCapture = false;
|
||||||
|
for (const f of engine.session.allFacts()) {
|
||||||
|
if (f.attr === "Position" && f.value === to) {
|
||||||
|
isCapture = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push({ pieceId, from, to, isCapture });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
PRESET_REGISTRY.register({
|
||||||
|
id: NEVER_OVERRIDE_ID,
|
||||||
|
name: "Never override (test)",
|
||||||
|
description: "Always returns undefined from the override hook.",
|
||||||
|
incompatibleWith: [],
|
||||||
|
requires: [],
|
||||||
|
overridePieceMoves() {
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
PRESET_REGISTRY.register({
|
||||||
|
id: RIVAL_LAME_KNIGHT_ID,
|
||||||
|
name: "Rival lame knight (test)",
|
||||||
|
description:
|
||||||
|
"Also overrides knights — triggers collision with LAME_KNIGHT_ID.",
|
||||||
|
incompatibleWith: [],
|
||||||
|
requires: [],
|
||||||
|
overridePieceMoves(engine, pieceId) {
|
||||||
|
const type = engine.session.get(pieceId, "PieceType") as string | undefined;
|
||||||
|
if (type !== "knight") return undefined;
|
||||||
|
// Return an empty set — different from LAME_KNIGHT_ID's output,
|
||||||
|
// so if this preset wins we can tell.
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
PRESET_REGISTRY.register({
|
||||||
|
id: KNIGHT_FILTER_ID,
|
||||||
|
name: "Knight filter (test)",
|
||||||
|
description:
|
||||||
|
"Drops knight moves to squares on rank 3 (index 16-23). Proves filterMoves runs on override output.",
|
||||||
|
incompatibleWith: [],
|
||||||
|
requires: [],
|
||||||
|
filterMoves(moves, engine, pieceId) {
|
||||||
|
const type = engine.session.get(pieceId, "PieceType") as string | undefined;
|
||||||
|
if (type !== "knight") return moves;
|
||||||
|
return moves.filter(m => m.to < 16 || m.to > 23);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("overridePieceMoves — default (no preset)", () => {
|
||||||
|
it("baseline: FIDE opening produces 4 knight moves (2 per knight × 2 knights)", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
const knightMoves = moves.filter(m => {
|
||||||
|
const type = engine.session.get(m.pieceId, "PieceType") as string;
|
||||||
|
return type === "knight";
|
||||||
|
});
|
||||||
|
// Each knight has 2 legal moves from the back rank; 2 knights → 4.
|
||||||
|
expect(knightMoves).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("overridePieceMoves — single-preset override", () => {
|
||||||
|
it("lame-knight replaces knight moves with single-square orthogonal steps", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: LAME_KNIGHT_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
const knightMoves = moves.filter(m => {
|
||||||
|
const type = engine.session.get(m.pieceId, "PieceType") as string;
|
||||||
|
return type === "knight";
|
||||||
|
});
|
||||||
|
// FIDE opening: each white knight (b1=1, g1=6) is blocked on 3 of
|
||||||
|
// 4 orthogonal squares (own pieces + board edge). Only "forward"
|
||||||
|
// to rank 2 is open BUT that's occupied by pawns. So every lame-
|
||||||
|
// knight move gets self-check-filtered? No — they're all blocked
|
||||||
|
// by friendly pieces (pawns on b2, g2; back-rank neighbours a1/c1,
|
||||||
|
// f1/h1). Expect 0 legal moves.
|
||||||
|
//
|
||||||
|
// Actually the lame-knight override generates moves INCLUDING
|
||||||
|
// captures of friendlies (we didn't filter). The preset doesn't
|
||||||
|
// distinguish friend vs enemy — the `isCapture` flag is set iff
|
||||||
|
// occupied. Then `filterMoves` (none registered in this setup)
|
||||||
|
// leaves them; `filterSelfCheckMoves` eventually drops friendly-
|
||||||
|
// capture moves? Actually NO — it only checks self-check after
|
||||||
|
// applying. Friendly-capture would leave the mover on the same
|
||||||
|
// square as a friendly piece. No rule in check.ts catches that.
|
||||||
|
//
|
||||||
|
// So we get the raw unfiltered count. Knight on b1 (1): N=-8
|
||||||
|
// (invalid, <0); S=9 (occupied, friendly pawn — isCapture=true);
|
||||||
|
// W=0 (occupied, friendly rook); E=2 (occupied, friendly
|
||||||
|
// bishop). That's 3 "moves". Knight on g1 (6): N=-2 (invalid);
|
||||||
|
// S=14 (pawn); W=5 (bishop); E=7 (rook). 3 moves. Total: 6.
|
||||||
|
//
|
||||||
|
// The self-check filter would drop any move that leaves the king
|
||||||
|
// attacked. None of these expose the king (pieces stay on their
|
||||||
|
// starting structure). So we expect 6 lame-knight moves.
|
||||||
|
expect(knightMoves).toHaveLength(6);
|
||||||
|
for (const m of knightMoves) {
|
||||||
|
// All lame-knight moves are single-square orthogonal.
|
||||||
|
const delta = Math.abs(m.to - m.from);
|
||||||
|
expect([1, 8]).toContain(delta);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-knight pieces are unaffected (override declined via undefined)", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: LAME_KNIGHT_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
const pawnMoves = moves.filter(m => {
|
||||||
|
const type = engine.session.get(m.pieceId, "PieceType") as string;
|
||||||
|
return type === "pawn";
|
||||||
|
});
|
||||||
|
// FIDE opening has 16 pawn moves (8 single-pushes + 8 double-pushes).
|
||||||
|
expect(pawnMoves).toHaveLength(16);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("overridePieceMoves — undefined return falls through", () => {
|
||||||
|
it("a preset returning undefined leaves default behaviour intact", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: NEVER_OVERRIDE_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
// Default: 20 opening moves. If NEVER_OVERRIDE_ID broke the
|
||||||
|
// fallback, the count would change.
|
||||||
|
expect(engine.getAllLegalMoves()).toHaveLength(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("overridePieceMoves — collision + dev-mode warning", () => {
|
||||||
|
it("first match wins; second match emits console.warn", () => {
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: LAME_KNIGHT_ID, scope: "both", turnsRemaining: null },
|
||||||
|
{ id: RIVAL_LAME_KNIGHT_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
const knightMoves = moves.filter(m => {
|
||||||
|
const type = engine.session.get(m.pieceId, "PieceType") as string;
|
||||||
|
return type === "knight";
|
||||||
|
});
|
||||||
|
// First preset (LAME_KNIGHT_ID) wins → 6 moves (see prior test).
|
||||||
|
// If RIVAL_LAME_KNIGHT_ID had won we'd see 0.
|
||||||
|
expect(knightMoves).toHaveLength(6);
|
||||||
|
|
||||||
|
// Collision warning fires once per colliding piece (2 knights).
|
||||||
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
|
const firstCallArg = warnSpy.mock.calls[0]?.[0];
|
||||||
|
expect(typeof firstCallArg).toBe("string");
|
||||||
|
expect(firstCallArg as string).toContain("[overridePieceMoves] collision");
|
||||||
|
expect(firstCallArg as string).toContain(LAME_KNIGHT_ID);
|
||||||
|
expect(firstCallArg as string).toContain(RIVAL_LAME_KNIGHT_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collision warning suppressed when NODE_ENV=production", () => {
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const originalEnv = process.env.NODE_ENV;
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: LAME_KNIGHT_ID, scope: "both", turnsRemaining: null },
|
||||||
|
{ id: RIVAL_LAME_KNIGHT_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
engine.getAllLegalMoves();
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
if (originalEnv === undefined) {
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
|
} else {
|
||||||
|
process.env.NODE_ENV = originalEnv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("overridePieceMoves — filterMoves still runs on override output", () => {
|
||||||
|
it("knight-filter preset drops rank-3 destinations from lame-knight moves", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
// Clear pawns so lame-knight has unblocked rank-3 candidates.
|
||||||
|
clearBoard(engine, { preserveKings: false });
|
||||||
|
placePiece(engine, "king", "white", "e1");
|
||||||
|
placePiece(engine, "king", "black", "e8");
|
||||||
|
// Knight on a3 (16): N=24 (b4? no, 24=a4, rank 4), S=8 (a2),
|
||||||
|
// W=invalid (file edge), E=17 (b3, rank 3). Without filter: 3
|
||||||
|
// moves. With KNIGHT_FILTER_ID dropping rank-3 destinations
|
||||||
|
// (16-23), "E=17" is dropped AND the starting square 16 doesn't
|
||||||
|
// matter (filter checks `to`). Expected surviving: 2 moves.
|
||||||
|
placePiece(engine, "knight", "white", "a3");
|
||||||
|
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: LAME_KNIGHT_ID, scope: "both", turnsRemaining: null },
|
||||||
|
{ id: KNIGHT_FILTER_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
const knightMoves = moves.filter(m => {
|
||||||
|
const type = engine.session.get(m.pieceId, "PieceType") as string;
|
||||||
|
return type === "knight";
|
||||||
|
});
|
||||||
|
// Every surviving move lands OUTSIDE rank 3 (16-23).
|
||||||
|
for (const m of knightMoves) {
|
||||||
|
expect(m.to < 16 || m.to > 23).toBe(true);
|
||||||
|
}
|
||||||
|
// At least one move survived (sanity).
|
||||||
|
expect(knightMoves.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -597,6 +597,47 @@ export interface PresetDef {
|
||||||
ctx: TurnAdvanceContext,
|
ctx: TurnAdvanceContext,
|
||||||
) => boolean | undefined;
|
) => boolean | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REPLACE the default move generator for a specific piece.
|
||||||
|
*
|
||||||
|
* Runs BEFORE `PIECE_TYPE_REGISTRY.get(type).generateMoves` in the
|
||||||
|
* engine's per-piece dispatch. First non-undefined return wins —
|
||||||
|
* the returned array becomes the piece's moves, and the engine
|
||||||
|
* SKIPS:
|
||||||
|
* - the default type-registry generator
|
||||||
|
* - `transformMoveGenerator` chain on this piece
|
||||||
|
* - `getExtraMoves` from other presets for this piece
|
||||||
|
*
|
||||||
|
* But STILL runs:
|
||||||
|
* - `filterMoves` (each preset per piece) — the returned set can
|
||||||
|
* still be filtered per-preset.
|
||||||
|
* - the self-check filter (unless opted out via
|
||||||
|
* `shouldFilterSelfCheck`).
|
||||||
|
* - `filterLegalMoves` (aggregate stage).
|
||||||
|
* - en-passant / castling / promotion handling (those run from
|
||||||
|
* piece-type switches in the engine, so if you override a pawn
|
||||||
|
* generator you MUST also synthesize promotion-tagged moves
|
||||||
|
* yourself; document the choice in-preset).
|
||||||
|
*
|
||||||
|
* Return undefined to decline — the engine moves to the next
|
||||||
|
* preset, and ultimately falls back to the default generator.
|
||||||
|
*
|
||||||
|
* COLLISIONS: If two presets both return non-undefined for the
|
||||||
|
* same piece, the FIRST WINS (registration order via
|
||||||
|
* `activePresets.list()`). The engine emits a dev-mode
|
||||||
|
* `console.warn` naming the two preset ids. This is a guardrail,
|
||||||
|
* not an opt-in: conflicts should be caught at config time via
|
||||||
|
* `incompatibleWith`. The warning is suppressed in production
|
||||||
|
* (`globalThis.process?.env?.NODE_ENV === "production"`).
|
||||||
|
*
|
||||||
|
* Canonical users: `berolina-pawns` (redefines pawn moves —
|
||||||
|
* forward-diagonal push + forward-orthogonal capture).
|
||||||
|
*/
|
||||||
|
readonly overridePieceMoves?: (
|
||||||
|
engine: ChessEngine,
|
||||||
|
pieceId: EntityId,
|
||||||
|
) => readonly LegalMove[] | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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