feat(engine): direction-additions modifier descriptor

Adds DIRECTION_ADDITIONS_DESCRIPTOR (ModifierDescriptor<Direction[]>) and
generateDirectionMoves() helper. The descriptor seeds DirectionAdditions fact
on pieces; the helper generates 1-square non-capture moves for each listed
direction. Includes 11 tests covering registration, apply, describe, and
generateDirectionMoves with white/black color-relative semantics, blocking,
union stacking, and edge-board clamping.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 22:23:29 -06:00
commit f566c3a488
No known key found for this signature in database
2 changed files with 251 additions and 0 deletions

View file

@ -0,0 +1,149 @@
import { describe, it, expect } from "vitest";
import { Session } from "@paratype/rete";
import { MODIFIER_REGISTRY } from "../registry.js";
import "./direction-additions.js";
import {
DIRECTION_ADDITIONS_DESCRIPTOR,
generateDirectionMoves,
} from "./direction-additions.js";
// Square encoding: square = rank * 8 + file
// e4 = rank 3, file 4 → 3*8+4 = 28
// e3 = rank 2, file 4 → 2*8+4 = 20
// e5 = rank 4, file 4 → 4*8+4 = 36
// d4 = rank 3, file 3 → 3*8+3 = 27
// f4 = rank 3, file 5 → 3*8+5 = 29
describe("direction-additions descriptor — registry", () => {
it("registers in MODIFIER_REGISTRY under key 'direction-additions'", () => {
expect(MODIFIER_REGISTRY.has("direction-additions")).toBe(true);
expect(MODIFIER_REGISTRY.get("direction-additions")).toBe(DIRECTION_ADDITIONS_DESCRIPTOR);
});
});
describe("direction-additions descriptor — apply()", () => {
it("seeds DirectionAdditions fact on piece entity in session", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["backward"]);
expect(session.contains(pieceId, "DirectionAdditions")).toBe(true);
expect(session.get(pieceId, "DirectionAdditions")).toEqual(["backward"]);
});
});
describe("direction-additions descriptor — describe()", () => {
it("formats a single direction", () => {
expect(DIRECTION_ADDITIONS_DESCRIPTOR.describe(["forward"])).toBe("+Directions: forward");
});
it("contains all direction names when multiple directions given (union stacking)", () => {
const result = DIRECTION_ADDITIONS_DESCRIPTOR.describe(["forward", "backward"]);
expect(result).toContain("forward");
expect(result).toContain("backward");
});
it("formats all eight directions", () => {
const all = DIRECTION_ADDITIONS_DESCRIPTOR.describe([
"forward", "backward", "left", "right",
"diagonal-fl", "diagonal-fr", "diagonal-bl", "diagonal-br",
]);
expect(all).toContain("diagonal-fl");
expect(all).toContain("diagonal-br");
});
});
describe("direction-additions — generateDirectionMoves()", () => {
it("returns backward move for white pawn at e4 when e3 is empty", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
session.insert(pieceId, "Color", "white");
session.insert(pieceId, "Position", 28); // e4
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["backward"]);
const moves = generateDirectionMoves(session, pieceId);
expect(moves).toHaveLength(1);
expect(moves[0]).toMatchObject({ from: 28, to: 20, isCapture: false });
});
it("returns no move when target square is occupied (blocked backward)", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
session.insert(pieceId, "Color", "white");
session.insert(pieceId, "Position", 28); // e4
// Blocker at e3 (square 20)
const blockerId = session.nextId();
session.insert(blockerId, "PieceType", "pawn");
session.insert(blockerId, "Color", "black");
session.insert(blockerId, "Position", 20); // e3
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["backward"]);
const moves = generateDirectionMoves(session, pieceId);
expect(moves).toHaveLength(0);
});
it("returns union of moves for two directions, no duplication", () => {
// white pawn at e4: forward → e5 (36), backward → e3 (20)
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
session.insert(pieceId, "Color", "white");
session.insert(pieceId, "Position", 28); // e4
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["forward", "backward"]);
const moves = generateDirectionMoves(session, pieceId);
expect(moves).toHaveLength(2);
const targets = moves.map(m => m.to);
expect(targets).toContain(36); // e5 (forward)
expect(targets).toContain(20); // e3 (backward)
});
it("returns no moves when no DirectionAdditions fact is set", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
session.insert(pieceId, "Color", "white");
session.insert(pieceId, "Position", 28); // e4
// DirectionAdditions NOT seeded
const moves = generateDirectionMoves(session, pieceId);
expect(moves).toHaveLength(0);
});
it("uses color-relative forward direction: black pawn at e5 forward goes to e4", () => {
// black forward = dr=-1; e5 = square 36, e4 = square 28
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
session.insert(pieceId, "Color", "black");
session.insert(pieceId, "Position", 36); // e5
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["forward"]);
const moves = generateDirectionMoves(session, pieceId);
expect(moves).toHaveLength(1);
expect(moves[0]).toMatchObject({ from: 36, to: 28, isCapture: false });
});
it("skips out-of-board targets (e.g. backward from rank 1)", () => {
// white pawn at e1 (square 4, rank 0), backward would go to rank -1
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
session.insert(pieceId, "Color", "white");
session.insert(pieceId, "Position", 4); // e1
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["backward"]);
const moves = generateDirectionMoves(session, pieceId);
expect(moves).toHaveLength(0);
});
});

View file

@ -0,0 +1,102 @@
import { z } from "zod";
import type { Session, EntityId } from "@paratype/rete";
import { MODIFIER_REGISTRY } from "../registry.js";
import type { ModifierDescriptor, Direction } from "../types.js";
import type { PieceColor, Square } from "../../schema.js";
import { fileOf, rankOf, isOnBoard, squareOf } from "../../coord.js";
import {
getPiecePosition,
getPieceColor,
isPieceAt,
} from "../../rules/board-queries.js";
import type { LegalMove } from "../../rules/types.js";
const schema = z.array(
z.enum([
"forward",
"backward",
"left",
"right",
"diagonal-fl",
"diagonal-fr",
"diagonal-bl",
"diagonal-br",
]),
);
type Value = z.infer<typeof schema>;
/**
* Compute file+rank delta for a named direction from a piece's perspective.
* forward/backward are color-relative (toward/away from opponent's back rank).
* left/right are board-absolute (toward a-file / h-file respectively).
* Diagonals combine the two axes accordingly.
*/
function directionDelta(dir: Direction, color: PieceColor): { df: number; dr: number } {
// +1 = white advances up ranks; -1 = black advances down ranks
const forward = color === "white" ? 1 : -1;
switch (dir) {
case "forward": return { df: 0, dr: forward };
case "backward": return { df: 0, dr: -forward };
case "left": return { df: -1, dr: 0 }; // always toward a-file
case "right": return { df: 1, dr: 0 }; // always toward h-file
case "diagonal-fl": return { df: -1, dr: forward }; // forward + toward a-file
case "diagonal-fr": return { df: 1, dr: forward }; // forward + toward h-file
case "diagonal-bl": return { df: -1, dr: -forward }; // backward + toward a-file
case "diagonal-br": return { df: 1, dr: -forward }; // backward + toward h-file
}
}
export const DIRECTION_ADDITIONS_DESCRIPTOR: ModifierDescriptor<Value> = {
id: "direction-additions",
attrName: "DirectionAdditions",
label: "Direction Additions",
valueSchema: schema,
stackingRule: "union",
uiForm: "direction-set",
apply(session: Session, pieceId: EntityId, effectiveValue: Value): void {
session.insert(pieceId, "DirectionAdditions", effectiveValue);
},
describe(value: Value): string {
return `+Directions: ${value.join(", ")}`;
},
};
MODIFIER_REGISTRY.register(DIRECTION_ADDITIONS_DESCRIPTOR);
/**
* Generate 1-square non-capture moves in all directions listed in the
* piece's `DirectionAdditions` fact.
*
* Semantics:
* - Step-piece only: exactly 1 square per direction (no sliding).
* - Non-capture only: skips squares occupied by any piece.
* Capture semantics are handled separately by CaptureFlags.
* - Out-of-board targets are silently skipped.
*
* Called by the engine integration layer (T14) after the modifier profile
* has seeded `DirectionAdditions` on the piece.
*/
export function generateDirectionMoves(
session: Session,
pieceId: EntityId,
): LegalMove[] {
const directions = session.get(pieceId, "DirectionAdditions") as readonly string[] | undefined;
if (!directions || directions.length === 0) return [];
const from = getPiecePosition(session, pieceId);
const color = getPieceColor(session, pieceId);
if (from === null || color === null) return [];
const moves: LegalMove[] = [];
for (const dir of directions as Direction[]) {
const { df, dr } = directionDelta(dir, color);
const toFile = fileOf(from as Square) + df;
const toRank = rankOf(from as Square) + dr;
if (!isOnBoard(toFile, toRank)) continue;
const to = squareOf(toFile, toRank);
if (!isPieceAt(session, to)) {
moves.push({ pieceId, from: from as Square, to, isCapture: false });
}
}
return moves;
}