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 = [