From b8afc9017fd3b5cf2b591a3345bff65b07c19843 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 08:58:19 -0600 Subject: [PATCH] feat(presets): bouncing-pieces-2 (all four edges, 2-bounce cap) --- .../src/presets/bouncing-pieces-2.test.ts | 314 ++++++++++++++++++ .../chess/src/presets/bouncing-pieces-2.ts | 233 +++++++++++++ packages/chess/src/presets/index.ts | 1 + packages/chess/src/presets/presets.test.ts | 1 + 4 files changed, 549 insertions(+) create mode 100644 packages/chess/src/presets/bouncing-pieces-2.test.ts create mode 100644 packages/chess/src/presets/bouncing-pieces-2.ts diff --git a/packages/chess/src/presets/bouncing-pieces-2.test.ts b/packages/chess/src/presets/bouncing-pieces-2.test.ts new file mode 100644 index 0000000..db5ad89 --- /dev/null +++ b/packages/chess/src/presets/bouncing-pieces-2.test.ts @@ -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(); + }); +}); diff --git a/packages/chess/src/presets/bouncing-pieces-2.ts b/packages/chess/src/presets/bouncing-pieces-2.ts new file mode 100644 index 0000000..89cf786 --- /dev/null +++ b/packages/chess/src/presets/bouncing-pieces-2.ts @@ -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 = [ + [-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 { + const dests = new Set(); + 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, +): 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; + }, +}); diff --git a/packages/chess/src/presets/index.ts b/packages/chess/src/presets/index.ts index d71b9df..5637e70 100644 --- a/packages/chess/src/presets/index.ts +++ b/packages/chess/src/presets/index.ts @@ -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"; diff --git a/packages/chess/src/presets/presets.test.ts b/packages/chess/src/presets/presets.test.ts index d39238c..1c1d8f9 100644 --- a/packages/chess/src/presets/presets.test.ts +++ b/packages/chess/src/presets/presets.test.ts @@ -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", () => {