From f566c3a4888cc74ab208a4d7f8ca8c504a709223 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:23:29 -0600 Subject: [PATCH] feat(engine): direction-additions modifier descriptor Adds DIRECTION_ADDITIONS_DESCRIPTOR (ModifierDescriptor) 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. --- .../descriptors/direction-additions.test.ts | 149 ++++++++++++++++++ .../descriptors/direction-additions.ts | 102 ++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 packages/chess/src/modifiers/descriptors/direction-additions.test.ts create mode 100644 packages/chess/src/modifiers/descriptors/direction-additions.ts diff --git a/packages/chess/src/modifiers/descriptors/direction-additions.test.ts b/packages/chess/src/modifiers/descriptors/direction-additions.test.ts new file mode 100644 index 0000000..5cac9d6 --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/direction-additions.test.ts @@ -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); + }); +}); diff --git a/packages/chess/src/modifiers/descriptors/direction-additions.ts b/packages/chess/src/modifiers/descriptors/direction-additions.ts new file mode 100644 index 0000000..fc3e697 --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/direction-additions.ts @@ -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; + +/** + * 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 = { + 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; +}