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:
parent
ef93eb6101
commit
f566c3a488
2 changed files with 251 additions and 0 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
102
packages/chess/src/modifiers/descriptors/direction-additions.ts
Normal file
102
packages/chess/src/modifiers/descriptors/direction-additions.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue