feat(presets): bouncing-pieces-2 (all four edges, 2-bounce cap)
This commit is contained in:
parent
ee08e20871
commit
b8afc9017f
4 changed files with 549 additions and 0 deletions
314
packages/chess/src/presets/bouncing-pieces-2.test.ts
Normal file
314
packages/chess/src/presets/bouncing-pieces-2.test.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
/**
|
||||
* Tests for the `bouncing-pieces-2` preset (rule-variants epic, phase E.4).
|
||||
*
|
||||
* Covers:
|
||||
* - registry activation + metadata
|
||||
* - single file-edge bounce (back-compat with E.3 shape)
|
||||
* - top-edge bounce (NEW vs E.3)
|
||||
* - bottom-edge bounce (NEW vs E.3)
|
||||
* - two bounces in sequence (NEW — E.3 caps at one)
|
||||
* - third-bounce cap (SAFETY — no cycles on empty board)
|
||||
* - enemy capture on the bounce segment
|
||||
* - friendly block on the SECOND bounce segment stops before
|
||||
* - queen diagonals bounce; queen orthogonals don't
|
||||
* - incompatibility with bouncing-pieces
|
||||
* - incompatibility with wrap-board
|
||||
* - non-bishop/queen pieces get 0 extras
|
||||
*
|
||||
* Geometry trace used throughout — bishop on e6, NW ray:
|
||||
* pre-bounce: d7, c8 (both default NW dests, skipped)
|
||||
* → step off top at c8, bounce #1 (dy flip): b7, a6
|
||||
* → step off left at a6, bounce #2 (dx flip): b5, (c4 skip — default
|
||||
* SW), d3, e2, f1
|
||||
* → step off bottom at f1 would require bounce #3 → STOP (cap).
|
||||
*
|
||||
* Absent the cap, bounce #3 would flip dy again and emit g2. Test (f)
|
||||
* asserts g2 is NOT in extras.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import "./index.js";
|
||||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
import { ActivePresetSet } from "./active-set.js";
|
||||
import { Session } from "@paratype/rete";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
import { algebraicToSquare } from "../coord.js";
|
||||
|
||||
const BOUNCING_2_ID = "bouncing-pieces-2";
|
||||
|
||||
const mkId = (n: number) => n as EntityId;
|
||||
|
||||
function makeEngine(
|
||||
pieces: Array<{ id: number; type: string; color: string; sq: number }>,
|
||||
): ChessEngine {
|
||||
const session = new Session({ autoFire: false });
|
||||
for (const p of pieces) {
|
||||
const eid = mkId(p.id);
|
||||
session.insert(eid, "PieceType", p.type);
|
||||
session.insert(eid, "Color", p.color);
|
||||
session.insert(eid, "Position", p.sq);
|
||||
}
|
||||
return { session } as unknown as ChessEngine;
|
||||
}
|
||||
|
||||
function preset() {
|
||||
const def = PRESET_REGISTRY.get(BOUNCING_2_ID);
|
||||
if (def === undefined) {
|
||||
throw new Error(`preset ${BOUNCING_2_ID} not registered`);
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
const sq = (s: string) => algebraicToSquare(s) as number;
|
||||
|
||||
describe("bouncing-pieces-2 — registration", () => {
|
||||
it("is registered with the expected metadata", () => {
|
||||
const def = preset();
|
||||
expect(def.id).toBe("bouncing-pieces-2");
|
||||
expect(def.name).toBe("Bouncing Pieces (Extended)");
|
||||
expect(def.description).toMatch(/four/i);
|
||||
expect(def.incompatibleWith).toEqual(
|
||||
expect.arrayContaining(["bouncing-pieces", "wrap-board"]),
|
||||
);
|
||||
expect(def.requires).toEqual([]);
|
||||
expect(typeof def.getExtraMoves).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bouncing-pieces-2 — single file-edge bounce", () => {
|
||||
it("d4 bishop NW ray bounces off a-file and emits b8", () => {
|
||||
// Same as the E.3 baseline: NW walks c5, b6, a7 (pre-bounce, all
|
||||
// in default diagonals → skipped), then the step off the a-file
|
||||
// reflects to b8. b8 is unreachable via any default diagonal
|
||||
// from d4, so it's a genuinely new bounce destination.
|
||||
const engine = makeEngine([
|
||||
{ id: 1, type: "bishop", color: "white", sq: sq("d4") },
|
||||
]);
|
||||
const extras = preset().getExtraMoves!(engine, mkId(1));
|
||||
const tos = new Set(extras.map((m) => m.to));
|
||||
expect(tos.has(sq("b8"))).toBe(true);
|
||||
// Pre-bounce squares must not appear — they're default destinations.
|
||||
for (const s of ["c5", "b6", "a7"]) {
|
||||
expect(tos.has(sq(s))).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("bouncing-pieces-2 — rank-edge bounces (NEW vs E.3)", () => {
|
||||
it("e6 bishop NW ray bounces off the top edge and emits b7 + a6", () => {
|
||||
// e6 NW pre: d7, c8 (both default → skipped).
|
||||
// Step off top at c8 → bounce #1 (dy flip). Reflected squares:
|
||||
// b7, a6 (both below rank 7 → confirms top-edge reflection).
|
||||
const engine = makeEngine([
|
||||
{ id: 1, type: "bishop", color: "white", sq: sq("e6") },
|
||||
]);
|
||||
const extras = preset().getExtraMoves!(engine, mkId(1));
|
||||
const tos = new Set(extras.map((m) => m.to));
|
||||
expect(tos.has(sq("b7"))).toBe(true);
|
||||
expect(tos.has(sq("a6"))).toBe(true);
|
||||
// Sanity: pre-bounce squares skipped.
|
||||
expect(tos.has(sq("d7"))).toBe(false);
|
||||
expect(tos.has(sq("c8"))).toBe(false);
|
||||
});
|
||||
|
||||
it("e3 bishop SW ray bounces off the bottom edge and emits b2 + a3", () => {
|
||||
// Mirror of the above. e3 SW pre: d2, c1 (default → skipped).
|
||||
// Step off bottom at c1 → bounce #1 (dy flip). Reflected squares:
|
||||
// b2, a3 (both above rank 0 → confirms bottom-edge reflection).
|
||||
const engine = makeEngine([
|
||||
{ id: 1, type: "bishop", color: "white", sq: sq("e3") },
|
||||
]);
|
||||
const extras = preset().getExtraMoves!(engine, mkId(1));
|
||||
const tos = new Set(extras.map((m) => m.to));
|
||||
expect(tos.has(sq("b2"))).toBe(true);
|
||||
expect(tos.has(sq("a3"))).toBe(true);
|
||||
expect(tos.has(sq("d2"))).toBe(false);
|
||||
expect(tos.has(sq("c1"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bouncing-pieces-2 — two-bounce ray", () => {
|
||||
it("e6 bishop NW walks through TWO bounces and emits b5 beyond them", () => {
|
||||
// Bounce #1 off top at c8. Bounce #2 off left at a6. After
|
||||
// bounce #2, direction is (+1,-1); first emitted post-2 square
|
||||
// is b5. d3, e2, f1 follow (c4 is default SW, skipped).
|
||||
const engine = makeEngine([
|
||||
{ id: 1, type: "bishop", color: "white", sq: sq("e6") },
|
||||
]);
|
||||
const extras = preset().getExtraMoves!(engine, mkId(1));
|
||||
const tos = new Set(extras.map((m) => m.to));
|
||||
// Post-second-bounce squares.
|
||||
for (const s of ["b5", "d3", "e2", "f1"]) {
|
||||
expect(tos.has(sq(s))).toBe(true);
|
||||
}
|
||||
// c4 is reachable via the default SW ray (d5→c4→b3→a2), so it
|
||||
// must be deduped out of bounce extras even though the
|
||||
// second-bounce path steps through it.
|
||||
expect(tos.has(sq("c4"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bouncing-pieces-2 — third-bounce safety cap", () => {
|
||||
it("e7 bishop NW stops at e1 — 3rd-bounce squares f2/g3/h4 absent", () => {
|
||||
// To isolate the cap's effect we BLOCK the other three diagonal
|
||||
// rays at their first square with friendlies, so NW is the only
|
||||
// ray that can contribute bounces. Then any square emitted must
|
||||
// come from NW's bounce sequence, making the 2-bounce vs
|
||||
// 3-bounce distinction directly observable.
|
||||
//
|
||||
// Setup: bishop e7; allies at f8 (blocks NE), f6 (blocks SE),
|
||||
// d6 (blocks SW). NW is unobstructed.
|
||||
//
|
||||
// NW ray trace:
|
||||
// pre: d8 (default — skipped)
|
||||
// bounce#1 off top at d8: c7, b6, a5 (emit)
|
||||
// bounce#2 off left at a5: b4, c3, d2, e1 (emit — note
|
||||
// b4 is NOT in the default set here because ally on d6
|
||||
// truncates the default SW ray, so b4 slips through the
|
||||
// dedupe filter)
|
||||
// would-be bounce#3 off bottom at e1 → CAP → stop.
|
||||
//
|
||||
// If the cap were absent, bounce#3 (dy flip) would emit
|
||||
// f2, g3, h4 along the new direction (+1,+1). With the other
|
||||
// three rays blocked AND none of those squares being default
|
||||
// destinations, their absence in extras uniquely proves the
|
||||
// 2-bounce cap fired.
|
||||
const engine = makeEngine([
|
||||
{ id: 1, type: "bishop", color: "white", sq: sq("e7") },
|
||||
{ id: 2, type: "pawn", color: "white", sq: sq("f8") },
|
||||
{ id: 3, type: "pawn", color: "white", sq: sq("f6") },
|
||||
{ id: 4, type: "pawn", color: "white", sq: sq("d6") },
|
||||
]);
|
||||
const extras = preset().getExtraMoves!(engine, mkId(1));
|
||||
const tos = new Set(extras.map((m) => m.to));
|
||||
// Two-bounce emits along NW are present.
|
||||
for (const s of ["c7", "b6", "a5", "b4", "c3", "d2", "e1"]) {
|
||||
expect(tos.has(sq(s))).toBe(true);
|
||||
}
|
||||
// 3rd-bounce squares (uniquely reachable via NW's 3rd bounce
|
||||
// here, since NE/SE/SW are each blocked at their first square)
|
||||
// must NOT appear — the cap suppresses them.
|
||||
for (const s of ["f2", "g3", "h4"]) {
|
||||
expect(tos.has(sq(s))).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("bouncing-pieces-2 — blockers on the bounce segment", () => {
|
||||
// These tests use a b2 bishop geometry where the NW ray's
|
||||
// post-bounce first square (b4) and the square just beyond it (c5)
|
||||
// are unreachable via any of the other three rays within the
|
||||
// 2-bounce budget. That makes "stopping at b4" cleanly observable
|
||||
// via c5's presence/absence, without interference from SE/NE/SW.
|
||||
it("enemy on b4 is captured on the bounce segment (capture flagged, ray stops)", () => {
|
||||
// b2 NW ray: a3 (pre, not emit) → file-edge bounce → b4. Enemy
|
||||
// there → emit capture, stop. c5 (next NW post-bounce square)
|
||||
// would have been emitted by this ray alone; no other ray from
|
||||
// b2 reaches c5 within 2 bounces, so its absence confirms the
|
||||
// stop.
|
||||
const engine = makeEngine([
|
||||
{ id: 1, type: "bishop", color: "white", sq: sq("b2") },
|
||||
{ id: 2, type: "pawn", color: "black", sq: sq("b4") },
|
||||
]);
|
||||
const extras = preset().getExtraMoves!(engine, mkId(1));
|
||||
const cap = extras.find((m) => m.to === sq("b4"));
|
||||
expect(cap).toBeDefined();
|
||||
expect(cap!.isCapture).toBe(true);
|
||||
const tos = new Set(extras.map((m) => m.to));
|
||||
expect(tos.has(sq("c5"))).toBe(false);
|
||||
});
|
||||
|
||||
it("friendly on b4 stops the NW bounce ray before b4 (no emit for b4 or c5)", () => {
|
||||
// Same geometry, ally blocker: stop BEFORE, so b4 is not
|
||||
// emitted at all and c5 is also absent.
|
||||
const engine = makeEngine([
|
||||
{ id: 1, type: "bishop", color: "white", sq: sq("b2") },
|
||||
{ id: 2, type: "pawn", color: "white", sq: sq("b4") },
|
||||
]);
|
||||
const extras = preset().getExtraMoves!(engine, mkId(1));
|
||||
const tos = new Set(extras.map((m) => m.to));
|
||||
expect(tos.has(sq("b4"))).toBe(false);
|
||||
expect(tos.has(sq("c5"))).toBe(false);
|
||||
});
|
||||
|
||||
it("friendly on second-bounce segment stops the NW ray mid-way", () => {
|
||||
// b2 NW with ally on g8 — NW walks b4, c5, d6, e7, f8 (all
|
||||
// post-bounce-1 emits), then top-edge bounce (bounces=2),
|
||||
// reflected to g8 → ally, stop BEFORE. g8 and h7 (the square
|
||||
// past g8 on this ray) must not appear; the pre-block emits
|
||||
// should still be present.
|
||||
const engine = makeEngine([
|
||||
{ id: 1, type: "bishop", color: "white", sq: sq("b2") },
|
||||
{ id: 2, type: "pawn", color: "white", sq: sq("g8") },
|
||||
]);
|
||||
const extras = preset().getExtraMoves!(engine, mkId(1));
|
||||
const tos = new Set(extras.map((m) => m.to));
|
||||
for (const s of ["b4", "c5", "d6", "e7", "f8"]) {
|
||||
expect(tos.has(sq(s))).toBe(true);
|
||||
}
|
||||
expect(tos.has(sq("g8"))).toBe(false);
|
||||
expect(tos.has(sq("h7"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bouncing-pieces-2 — queen geometry", () => {
|
||||
it("queen diagonals bounce; orthogonal rays contribute no extras", () => {
|
||||
// Direct comparison: at the same square, a queen's bounce-extras
|
||||
// must equal a bishop's. The preset only generates on the four
|
||||
// diagonals, so rook-like rays contribute nothing — any
|
||||
// difference would indicate the preset incorrectly walked
|
||||
// orthogonals. (Note: diagonal bounce paths CAN land on the
|
||||
// piece's own file/rank as a side effect of reflection, so an
|
||||
// "exclude same file/rank" assertion would be incorrect.)
|
||||
const bishopEngine = makeEngine([
|
||||
{ id: 1, type: "bishop", color: "white", sq: sq("d4") },
|
||||
]);
|
||||
const queenEngine = makeEngine([
|
||||
{ id: 1, type: "queen", color: "white", sq: sq("d4") },
|
||||
]);
|
||||
const bishopExtras = preset().getExtraMoves!(bishopEngine, mkId(1));
|
||||
const queenExtras = preset().getExtraMoves!(queenEngine, mkId(1));
|
||||
const bishopTos = new Set(bishopExtras.map((m) => m.to));
|
||||
const queenTos = new Set(queenExtras.map((m) => m.to));
|
||||
expect(queenTos).toEqual(bishopTos);
|
||||
// Non-empty — bounce logic is actually running on the queen.
|
||||
expect(queenTos.size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bouncing-pieces-2 — non-diagonal piece types", () => {
|
||||
it("rook, knight, pawn, and king get 0 extras", () => {
|
||||
const engine = makeEngine([
|
||||
{ id: 1, type: "rook", color: "white", sq: sq("d4") },
|
||||
{ id: 2, type: "knight", color: "white", sq: sq("d4") },
|
||||
{ id: 3, type: "pawn", color: "white", sq: sq("d4") },
|
||||
{ id: 4, type: "king", color: "white", sq: sq("d4") },
|
||||
]);
|
||||
for (const id of [1, 2, 3, 4]) {
|
||||
const extras = preset().getExtraMoves!(engine, mkId(id));
|
||||
expect(extras).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("bouncing-pieces-2 — composition / incompatibility", () => {
|
||||
it("activating bouncing-pieces-2 with bouncing-pieces throws", () => {
|
||||
const set = new ActivePresetSet();
|
||||
expect(() =>
|
||||
set.replaceAll([
|
||||
{ id: BOUNCING_2_ID, scope: "both", turnsRemaining: null },
|
||||
{ id: "bouncing-pieces", scope: "both", turnsRemaining: null },
|
||||
]),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("activating bouncing-pieces-2 with wrap-board throws", () => {
|
||||
const set = new ActivePresetSet();
|
||||
expect(() =>
|
||||
set.replaceAll([
|
||||
{ id: BOUNCING_2_ID, scope: "both", turnsRemaining: null },
|
||||
{ id: "wrap-board", scope: "both", turnsRemaining: null },
|
||||
]),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
233
packages/chess/src/presets/bouncing-pieces-2.ts
Normal file
233
packages/chess/src/presets/bouncing-pieces-2.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
/**
|
||||
* Preset: `bouncing-pieces-2` (rule-variants epic, phase E.4).
|
||||
*
|
||||
* Extends `bouncing-pieces` (phase E.3) to reflect bishop and queen
|
||||
* diagonal rays off ALL FOUR board edges — left (file 0), right
|
||||
* (file 7), bottom (rank 0), and top (rank 7) — with a hard cap of
|
||||
* TWO reflections per ray. Ray walking otherwise matches E.3:
|
||||
*
|
||||
* - empty square → legal non-capture, keep walking
|
||||
* - ally-occupied → stop BEFORE, nothing emitted for this square
|
||||
* - enemy-occupied → emit capture, stop AFTER
|
||||
*
|
||||
* When the NEXT step would leave the board, the ray "reflects":
|
||||
*
|
||||
* - off a file edge → `dx` flips sign, `dy` preserved
|
||||
* - off a rank edge → `dy` flips sign, `dx` preserved
|
||||
* - off BOTH at once (diagonal corner exit, e.g., h8 going NE) →
|
||||
* BOTH flip, counted as TWO reflections simultaneously
|
||||
*
|
||||
* After flipping, the reflected destination is recomputed as
|
||||
* `(file + newDx, rank + newDy)` — the reflection point is the
|
||||
* current square, and the ray continues from the reflected square
|
||||
* under the new direction.
|
||||
*
|
||||
* ── Two-bounce cap (safety + design) ──────────────────────────────────
|
||||
*
|
||||
* A third reflection terminates the ray IMMEDIATELY — i.e., as soon
|
||||
* as a third reflection would be required to continue, the walk
|
||||
* stops without emitting any further squares. This is both a design
|
||||
* choice and a SAFETY measure:
|
||||
*
|
||||
* - Unbounded bouncing on an empty board produces cycles: a
|
||||
* diagonal ray that has bounced off two perpendicular edges will
|
||||
* eventually return to its origin and the cycle repeats. Bounded
|
||||
* iteration plus a visited-set would paper over this, but the
|
||||
* resulting "everywhere on the same parity" reachability is
|
||||
* strategically uninteresting — every diagonal square is
|
||||
* eventually in range regardless of board structure.
|
||||
* - Two bounces is the minimum that exhibits the DISTINGUISHING
|
||||
* feature of the preset vs. E.3 (rank-edge bouncing AND
|
||||
* multi-bounce paths) while still producing bounded, legible
|
||||
* move shapes.
|
||||
*
|
||||
* Corner-exit cases count as two bounces simultaneously: from h8 in
|
||||
* direction (+1,+1), the next step (8,8) is off both edges; both
|
||||
* axes flip in one iteration and the bounce counter jumps by two.
|
||||
* If the piece was already at one bounce, a corner exit terminates
|
||||
* the ray (would exceed the cap).
|
||||
*
|
||||
* ── Deduplication ─────────────────────────────────────────────────────
|
||||
*
|
||||
* Same concern as E.3: for certain geometries, the post-bounce path
|
||||
* overlaps the default bishop/queen sliding diagonals (e.g., an
|
||||
* a-file bishop bouncing NW→NE walks squares already reachable via
|
||||
* the default NE ray). We compute the set of default-diagonal
|
||||
* destinations with the same blocker semantics and emit ONLY
|
||||
* bounce-segment squares not in that set. Pre-bounce squares are
|
||||
* never emitted (identical to E.3 — they're already in the default
|
||||
* set by construction).
|
||||
*
|
||||
* ── Composition notes ─────────────────────────────────────────────────
|
||||
*
|
||||
* - `bouncing-pieces` is incompatible — applying two different
|
||||
* bounce policies to the same piece types is ill-defined.
|
||||
* - `wrap-board` is incompatible — wrapping abolishes edges, and
|
||||
* reflection off a non-edge has no meaning.
|
||||
* - Composes freely with `knights-leap-twice`, `capture-to-win`,
|
||||
* `piece-hp`, `knight-immunity`, etc. — no shared hooks beyond
|
||||
* `getExtraMoves`.
|
||||
* - As with E.3, `RangeBonus` / range-capping effects are NOT
|
||||
* currently applied to the bounce segment. Deferred until a
|
||||
* concrete request exists.
|
||||
*/
|
||||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
import { fileOf, rankOf, squareOf } from "../coord.js";
|
||||
import { isAllyAt, isEnemyAt } from "../rules/board-queries.js";
|
||||
import type { PieceColor, PieceType, Square } from "../schema.js";
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
import type { LegalMove } from "../rules/types.js";
|
||||
|
||||
/** Diagonal step vectors: NW, NE, SW, SE. */
|
||||
const DIAGONALS: ReadonlyArray<readonly [number, number]> = [
|
||||
[-1, 1],
|
||||
[1, 1],
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
];
|
||||
|
||||
/** Max reflections per ray (design + safety cap). */
|
||||
const BOUNCE_CAP = 2;
|
||||
|
||||
/**
|
||||
* Compute the set of destination squares the default bishop/queen
|
||||
* diagonal generator reaches from `from`. Walks all four diagonals
|
||||
* with standard blocker semantics (ally stops before, enemy stops
|
||||
* after — included in set). Used to dedupe bounce-segment moves that
|
||||
* would otherwise duplicate the default generator's output.
|
||||
*/
|
||||
function defaultDiagonalDests(
|
||||
session: Session,
|
||||
from: Square,
|
||||
color: PieceColor,
|
||||
): Set<Square> {
|
||||
const dests = new Set<Square>();
|
||||
for (const [dx, dy] of DIAGONALS) {
|
||||
let f = fileOf(from) + dx;
|
||||
let r = rankOf(from) + dy;
|
||||
while (f >= 0 && f <= 7 && r >= 0 && r <= 7) {
|
||||
const sq = squareOf(f, r);
|
||||
if (isAllyAt(session, sq, color)) break;
|
||||
dests.add(sq);
|
||||
if (isEnemyAt(session, sq, color)) break;
|
||||
f += dx;
|
||||
r += dy;
|
||||
}
|
||||
}
|
||||
return dests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a single diagonal ray from `from` in direction `(dx, dy)`,
|
||||
* reflecting up to TWO times off any combination of the four board
|
||||
* edges. Returns ONLY the moves collected AFTER the first reflection
|
||||
* (i.e., `bounces ≥ 1`), filtered against `skipDests`.
|
||||
*
|
||||
* Stop conditions:
|
||||
* - ally on the next (reflected, if applicable) square → stop BEFORE
|
||||
* - enemy on the next square → emit capture, stop AFTER
|
||||
* - a step would require a 3rd reflection (total bounces + new
|
||||
* reflections > BOUNCE_CAP) → stop
|
||||
* - defensive: reflected square still out of bounds → stop
|
||||
*/
|
||||
function walkBouncingRay(
|
||||
session: Session,
|
||||
pieceId: EntityId,
|
||||
from: Square,
|
||||
color: PieceColor,
|
||||
dx: number,
|
||||
dy: number,
|
||||
skipDests: Set<Square>,
|
||||
): LegalMove[] {
|
||||
const out: LegalMove[] = [];
|
||||
let file = fileOf(from);
|
||||
let rank = rankOf(from);
|
||||
let curDx = dx;
|
||||
let curDy = dy;
|
||||
let bounces = 0;
|
||||
|
||||
// Safety cap on iterations. Upper bound for a 2-bounce ray on an
|
||||
// 8×8 board is ~24 distinct squares; 64 is comfortable headroom
|
||||
// and guarantees termination even on an unexpected geometry bug.
|
||||
for (let step = 0; step < 64; step++) {
|
||||
let nextFile = file + curDx;
|
||||
let nextRank = rank + curDy;
|
||||
|
||||
const fileOff = nextFile < 0 || nextFile > 7;
|
||||
const rankOff = nextRank < 0 || nextRank > 7;
|
||||
|
||||
if (fileOff || rankOff) {
|
||||
const add = (fileOff ? 1 : 0) + (rankOff ? 1 : 0);
|
||||
if (bounces + add > BOUNCE_CAP) break;
|
||||
bounces += add;
|
||||
if (fileOff) curDx = -curDx;
|
||||
if (rankOff) curDy = -curDy;
|
||||
nextFile = file + curDx;
|
||||
nextRank = rank + curDy;
|
||||
// Defensive: if reflection somehow still produces an
|
||||
// out-of-bounds square (shouldn't occur on an 8×8 with
|
||||
// single-axis flips), stop.
|
||||
if (nextFile < 0 || nextFile > 7 || nextRank < 0 || nextRank > 7) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
file = nextFile;
|
||||
rank = nextRank;
|
||||
const sq = squareOf(file, rank) as Square;
|
||||
|
||||
if (isAllyAt(session, sq, color)) break;
|
||||
if (isEnemyAt(session, sq, color)) {
|
||||
if (bounces >= 1 && !skipDests.has(sq)) {
|
||||
out.push({ pieceId, from, to: sq, isCapture: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (bounces >= 1 && !skipDests.has(sq)) {
|
||||
out.push({ pieceId, from, to: sq, isCapture: false });
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
id: "bouncing-pieces-2",
|
||||
name: "Bouncing Pieces (Extended)",
|
||||
description:
|
||||
"Bishop and queen diagonal rays reflect off all four board edges (up to 2 bounces).",
|
||||
incompatibleWith: ["bouncing-pieces", "wrap-board"],
|
||||
requires: [],
|
||||
getExtraMoves: (engine, pieceId) => {
|
||||
const session = engine.session;
|
||||
const facts = session.allFacts();
|
||||
|
||||
const typeFact = facts.find(
|
||||
(f) => f.id === pieceId && f.attr === "PieceType",
|
||||
);
|
||||
if (typeFact === undefined) return [];
|
||||
const type = typeFact.value as PieceType;
|
||||
if (type !== "bishop" && type !== "queen") return [];
|
||||
|
||||
const colorFact = facts.find(
|
||||
(f) => f.id === pieceId && f.attr === "Color",
|
||||
);
|
||||
const posFact = facts.find(
|
||||
(f) => f.id === pieceId && f.attr === "Position",
|
||||
);
|
||||
if (colorFact === undefined || posFact === undefined) return [];
|
||||
|
||||
const color = colorFact.value as PieceColor;
|
||||
const from = posFact.value as Square;
|
||||
|
||||
const skipDests = defaultDiagonalDests(session, from, color);
|
||||
|
||||
const out: LegalMove[] = [];
|
||||
for (const [dx, dy] of DIAGONALS) {
|
||||
out.push(
|
||||
...walkBouncingRay(session, pieceId, from, color, dx, dy, skipDests),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
});
|
||||
|
|
@ -41,5 +41,6 @@ import "./extinction-chess.js";
|
|||
import "./berolina-pawns.js";
|
||||
import "./berolina-pawns-2.js";
|
||||
import "./bouncing-pieces.js";
|
||||
import "./bouncing-pieces-2.js";
|
||||
|
||||
export { PRESET_REGISTRY, type PresetDef } from "./registry.js";
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ describe("Preset registry — all registered", () => {
|
|||
"berolina-pawns",
|
||||
"berolina-pawns-2",
|
||||
"bouncing-pieces",
|
||||
"bouncing-pieces-2",
|
||||
];
|
||||
|
||||
it("registry size matches the expected ID list", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue