diff --git a/packages/chess/src/presets/bouncing-pieces.test.ts b/packages/chess/src/presets/bouncing-pieces.test.ts new file mode 100644 index 0000000..cb9f3d8 --- /dev/null +++ b/packages/chess/src/presets/bouncing-pieces.test.ts @@ -0,0 +1,274 @@ +/** + * Tests for the `bouncing-pieces` preset (rule-variants epic, phase E.3). + * + * Covers: + * - registry activation + * - bishop bouncing off the h-file (NE ray bounce) + * - bishop bouncing off the a-file (NW/SW ray bounces from an inner square) + * - capture / friendly-block semantics on the post-bounce segment + * - queen participation (diagonals bounce, orthogonals do not) + * - rank-edge termination (no bounce when the ray exits via the rank) + * - corner-ambiguity dedupe (a1 bishop contributes 0 bounce extras) + * - composition with `knights-leap-twice` + * - incompatibility with `wrap-board` + * - non-diagonal piece types get 0 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, rankOf } from "../coord.js"; +import type { Square } from "../schema.js"; + +const BOUNCING_ID = "bouncing-pieces"; + +const mkId = (n: number) => n as EntityId; + +/** + * Build a minimal engine-like stub that carries just the working memory + * the preset reads. The preset uses `engine.session` only, so this is a + * safe projection for unit-style tests that don't need the full + * ChessEngine lifecycle. + */ +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_ID); + if (def === undefined) throw new Error(`preset ${BOUNCING_ID} not registered`); + return def; +} + +describe("bouncing-pieces — registration", () => { + it("is registered with the expected metadata", () => { + const def = preset(); + expect(def.id).toBe("bouncing-pieces"); + expect(def.name).toBe("Bouncing Pieces"); + expect(def.description).toMatch(/bounce/i); + expect(def.incompatibleWith).toEqual( + expect.arrayContaining(["bouncing-pieces-2", "wrap-board"]), + ); + expect(def.requires).toEqual([]); + expect(typeof def.getExtraMoves).toBe("function"); + }); +}); + +describe("bouncing-pieces — bishop geometry", () => { + it("d4 bishop on an empty board bounces NW off the a-file to reach b8", () => { + // d4 → NW ray walks c5, b6, a7; next step goes off the a-file and + // reflects to (+1,+1), landing on b8. b8 is unreachable via any of + // the four default diagonals from d4, so it's a genuinely new + // destination contributed by the bounce. + const engine = makeEngine([ + { id: 1, type: "bishop", color: "white", sq: algebraicToSquare("d4") }, + ]); + const extras = preset().getExtraMoves!(engine, mkId(1)); + const tos = new Set(extras.map((m) => m.to)); + expect(tos.has(algebraicToSquare("b8") as number)).toBe(true); + // All extras should come from post-bounce segments — d4's default + // diagonals already cover c5/b6/a7/e5/f6/g7/h8/etc., so none of + // those should appear. + for (const sq of ["c5", "b6", "a7", "e5", "f6", "g7", "h8"]) { + expect(tos.has(algebraicToSquare(sq) as number)).toBe(false); + } + }); + + it("b4 bishop bounces NW off the a-file yielding b6, c7, d8", () => { + // From b4 NW: step to a5 (pre-bounce, not emitted — already a + // default dest). Next step off a-file → bounce to (+1,+1), walking + // b6, c7, d8 before exiting rank 8. None of these three squares + // are reachable via any default diagonal from b4, so all should be + // present as bounce extras. + const engine = makeEngine([ + { id: 1, type: "bishop", color: "white", sq: algebraicToSquare("b4") }, + ]); + const extras = preset().getExtraMoves!(engine, mkId(1)); + const tos = new Set(extras.map((m) => m.to)); + for (const sq of ["b6", "c7", "d8"]) { + expect(tos.has(algebraicToSquare(sq) as number)).toBe(true); + } + }); + + it("g4 bishop bounces NE off the h-file yielding g6, f7, e8", () => { + // Symmetric to the b4 case: NE walks to h5 (pre-bounce), reflects, + // and continues g6 → f7 → e8 before exiting rank 8. + const engine = makeEngine([ + { id: 1, type: "bishop", color: "white", sq: algebraicToSquare("g4") }, + ]); + const extras = preset().getExtraMoves!(engine, mkId(1)); + const tos = new Set(extras.map((m) => m.to)); + for (const sq of ["g6", "f7", "e8"]) { + expect(tos.has(algebraicToSquare(sq) as number)).toBe(true); + } + }); + + it("bounce ray captures an enemy and stops", () => { + // b4 bishop, black enemy on c7. Bounce NW walks b6 (empty) → c7 + // (enemy capture, stop). d8 should NOT be emitted (stop after + // capture). + const engine = makeEngine([ + { id: 1, type: "bishop", color: "white", sq: algebraicToSquare("b4") }, + { id: 2, type: "pawn", color: "black", sq: algebraicToSquare("c7") }, + ]); + const extras = preset().getExtraMoves!(engine, mkId(1)); + const capture = extras.find( + (m) => m.to === (algebraicToSquare("c7") as number), + ); + expect(capture).toBeDefined(); + expect(capture!.isCapture).toBe(true); + // Past the capture: d8 must NOT appear via the NW bounce. + expect(extras.some((m) => m.to === (algebraicToSquare("d8") as number))).toBe( + false, + ); + // Pre-capture empty square IS emitted. + expect(extras.some((m) => m.to === (algebraicToSquare("b6") as number))).toBe( + true, + ); + }); + + it("bounce ray stops before a friendly piece without emitting a move for it", () => { + // b4 bishop, white pawn on c7. NW bounce walks b6 (emit) → c7 + // (ally → stop, no emit). The ally square must NOT appear in + // extras. + const engine = makeEngine([ + { id: 1, type: "bishop", color: "white", sq: algebraicToSquare("b4") }, + { id: 2, type: "pawn", color: "white", sq: algebraicToSquare("c7") }, + ]); + const extras = preset().getExtraMoves!(engine, mkId(1)); + const tos = new Set(extras.map((m) => m.to)); + expect(tos.has(algebraicToSquare("b6") as number)).toBe(true); + expect(tos.has(algebraicToSquare("c7") as number)).toBe(false); + expect(tos.has(algebraicToSquare("d8") as number)).toBe(false); + }); +}); + +describe("bouncing-pieces — queen geometry", () => { + it("queen diagonals bounce; orthogonals produce no extras", () => { + // Queen on d4. Diagonal NW bounce contributes b8 exactly like a + // bishop. Orthogonals (rank 4 and file d) are NOT affected by this + // preset — extras must be subset of diagonal destinations. + const engine = makeEngine([ + { id: 1, type: "queen", color: "white", sq: algebraicToSquare("d4") }, + ]); + const extras = preset().getExtraMoves!(engine, mkId(1)); + const tos = new Set(extras.map((m) => m.to)); + // Diagonal contribution present. + expect(tos.has(algebraicToSquare("b8") as number)).toBe(true); + // Nothing on rank 4 (the queen's rank) — those are orthogonal. + const onRank4 = [...tos].some( + (t) => rankOf(t as Square) === rankOf(algebraicToSquare("d4") as Square), + ); + expect(onRank4).toBe(false); + // Nothing along file d either (excluding the piece's own square, + // which isn't emitted anyway). + const onFileD = [...tos].some( + (t) => + (t as number) % 8 === (algebraicToSquare("d4") as number) % 8 && + t !== algebraicToSquare("d4"), + ); + expect(onFileD).toBe(false); + }); +}); + +describe("bouncing-pieces — termination rules", () => { + it("no bounce occurs when the ray exits through a rank edge first", () => { + // d7 bishop: NE and NW rays step to e8/c8 and then exit through + // rank 8 WITHOUT reaching either file edge. No bounce should be + // triggered on those rays. All extras must therefore lie on ranks + // BELOW rank 6 (the bishop's own rank). + const engine = makeEngine([ + { id: 1, type: "bishop", color: "white", sq: algebraicToSquare("d7") }, + ]); + const extras = preset().getExtraMoves!(engine, mkId(1)); + expect(extras.length).toBeGreaterThan(0); // SE / SW bounces still fire + const fromRank = rankOf(algebraicToSquare("d7") as Square); + for (const m of extras) { + expect(rankOf(m.to as Square)).toBeLessThan(fromRank); + } + }); + + it("corner bishop on a1 contributes 0 bounce extras (duplicate-diagonal skip)", () => { + // From a1 the NW bounce reflects to (+1,+1), which walks the exact + // NE diagonal already covered by the default generator. With the + // preset's dedupe against default-diagonal destinations, every + // post-bounce square is filtered → 0 extras. The other three rays + // terminate before reaching a file edge (SE / SW step through rank + // -1 immediately; NE never bounces). + const engine = makeEngine([ + { id: 1, type: "bishop", color: "white", sq: algebraicToSquare("a1") }, + ]); + const extras = preset().getExtraMoves!(engine, mkId(1)); + expect(extras).toHaveLength(0); + }); +}); + +describe("bouncing-pieces — non-diagonal piece types", () => { + it("rooks, knights, pawns, and kings get 0 extras", () => { + const engine = makeEngine([ + { id: 1, type: "rook", color: "white", sq: algebraicToSquare("d4") }, + { id: 2, type: "knight", color: "white", sq: algebraicToSquare("d4") }, + { id: 3, type: "pawn", color: "white", sq: algebraicToSquare("d4") }, + { id: 4, type: "king", color: "white", sq: algebraicToSquare("d4") }, + ]); + for (const id of [1, 2, 3, 4]) { + const extras = preset().getExtraMoves!(engine, mkId(id)); + expect(extras).toHaveLength(0); + } + }); +}); + +describe("bouncing-pieces — composition", () => { + it("composes with knights-leap-twice: both hooks contribute independently", () => { + // Pure-stub composition test — verifies that `bouncing-pieces` and + // `knights-leap-twice` expose disjoint, non-interfering hook + // behaviour. Each preset's hook is called on the same engine stub + // carrying a bishop + a knight; neither should touch the other + // piece type. + const engine = makeEngine([ + { id: 1, type: "bishop", color: "white", sq: algebraicToSquare("b4") }, + { id: 2, type: "knight", color: "white", sq: algebraicToSquare("d4") }, + ]); + const bouncingDef = preset(); + const leapDef = PRESET_REGISTRY.get("knights-leap-twice")!; + // bouncing-pieces: non-zero on bishop, zero on knight. + expect( + bouncingDef.getExtraMoves!(engine, mkId(1)).length, + ).toBeGreaterThan(0); + expect(bouncingDef.getExtraMoves!(engine, mkId(2))).toHaveLength(0); + // knights-leap-twice: zero on bishop, non-zero on knight. + expect(leapDef.getExtraMoves!(engine, mkId(1))).toHaveLength(0); + expect(leapDef.getExtraMoves!(engine, mkId(2)).length).toBeGreaterThan(0); + // And they can be activated together without the set throwing — + // they declare no mutual incompatibility. + const set = new ActivePresetSet(); + expect(() => + set.replaceAll([ + { id: BOUNCING_ID, scope: "both", turnsRemaining: null }, + { id: "knights-leap-twice", scope: "both", turnsRemaining: null }, + ]), + ).not.toThrow(); + }); + + it("activating bouncing-pieces with wrap-board throws", () => { + const set = new ActivePresetSet(); + expect(() => + set.replaceAll([ + { id: BOUNCING_ID, scope: "both", turnsRemaining: null }, + { id: "wrap-board", scope: "both", turnsRemaining: null }, + ]), + ).toThrow(); + }); +}); diff --git a/packages/chess/src/presets/bouncing-pieces.ts b/packages/chess/src/presets/bouncing-pieces.ts new file mode 100644 index 0000000..232932c --- /dev/null +++ b/packages/chess/src/presets/bouncing-pieces.ts @@ -0,0 +1,244 @@ +/** + * Preset: `bouncing-pieces` (rule-variants epic, phase E.3). + * + * Bishops and queens gain a single bounce off the LEFT (a-file) or RIGHT + * (h-file) edge on each of their four diagonal rays. A ray is walked + * square-by-square exactly like the default sliding generator: + * + * - empty square → legal non-capture, keep walking + * - ally-occupied → stop BEFORE, nothing emitted for this square + * - enemy-occupied → emit capture, stop AFTER + * - rank out of bounds→ stop, NO bounce + * + * When the NEXT step would leave the board through file 0 or 7, the ray + * "reflects": the file delta flips sign while the rank delta stays. The + * reflection point is the current square; the next destination is + * `(file - dx, rank + dy)` in the new direction. From there the ray + * continues under the same stop conditions, with ONE exception — a second + * file-edge reflection terminates the ray immediately. (The + * `bouncing-pieces-2` preset, declared in phase E.4, lifts the limit to + * two and additionally handles rank-edge reflections; the two presets + * are mutually exclusive.) + * + * ── Ray reflection semantics ─────────────────────────────────────────── + * + * The reflection is the mirror image of continuing the ray through a + * wall placed just past file 0 or file 7. Concretely, from file `f` and + * rank `r` with step `(dx, dy)`: + * + * pre-bounce step → next = (f+dx, r+dy) + * if next is off the file edge (f+dx === -1 or 8): + * bounced-ray step → next = (f-dx, r+dy) + * direction becomes (-dx, dy) for subsequent steps + * + * Rank edges (rank 0 / rank 7) terminate a ray before bouncing — we + * never bounce off the top or bottom of the board. That's + * `bouncing-pieces-2`'s territory (phase E.4). + * + * ── Corner-ambiguity decision ────────────────────────────────────────── + * + * For a bishop on the a- or h-file, the reflected bounce ray walks along + * the opposite diagonal relative to the piece's own file. For example, + * from a3 going NW = (-1,+1), the bounce reflects to direction (+1,+1) + * starting at b4 — which is exactly the squares already produced by the + * piece's default NE diagonal ray (b4, c5, d6, e7, f8). Emitting these + * as "extra" moves would duplicate destinations already returned by the + * default generator, so we dedupe: the preset computes the set of + * default-diagonal destinations (walking all four rays with the same + * blocker semantics) and only emits bounce-segment moves whose target + * square isn't in that set. This makes the contribution ADDITIVE in the + * strict sense — every move added here is reachable ONLY because of the + * bounce. + * + * Pre-bounce segment moves are ALSO omitted for the same reason — those + * are exactly the squares the default bishop/queen diagonal generator + * already produces, so they'd always duplicate. + * + * ── Bouncing off rank edges ──────────────────────────────────────────── + * + * Deliberately NOT implemented here. A ray that reaches rank 0 or 7 + * (before reaching a file edge) simply stops. Rank reflections + two + * bounces are the `bouncing-pieces-2` extension. Composition guard: + * `incompatibleWith: ["bouncing-pieces-2"]`. + * + * ── Composition notes ────────────────────────────────────────────────── + * + * - `wrap-board` is declared incompatible. Wrap-board makes file + * edges non-edges; reflection off a non-edge is ill-defined. + * - `bouncing-pieces-2` is incompatible (would apply a DIFFERENT + * bounce policy to the same piece types). + * - Composes freely with `knights-leap-twice`, `capture-to-win`, + * `piece-hp`, etc. — no shared state, no shared hooks beyond + * `getExtraMoves`. + * - `modifyMoveAttrs` range-bonus effects on bishop/queen do NOT + * currently influence the bounce segment. If that's ever desired, + * this preset would need to read `RangeBonus` and cap the walk — + * 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], +]; + +/** + * 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 (see + * corner-ambiguity note in the file docblock). + */ +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 ONCE off file 0 / file 7 if the ray would otherwise leave + * the board through a vertical edge. Returns ONLY the moves collected + * on the post-bounce segment, after filtering against `skipDests` + * (squares already reachable via the default generator — see + * `defaultDiagonalDests`). + * + * Stop conditions (apply to both pre- and post-bounce segments): + * - rank leaves [0,7] → stop immediately (rank edges never bounce here) + * - ally on the next square → stop BEFORE (no capture, no emission) + * - enemy on the next square → emit capture (if post-bounce and not in + * skipDests), stop AFTER + * Extra stop for the post-bounce segment: + * - a second file-edge step → stop (only ONE bounce per ray) + */ +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 bounced = false; + + // Safety cap: a single-bounce diagonal on an 8×8 board cannot visit + // more than 16 distinct squares (8 pre + 8 post). 32 leaves room. + for (let step = 0; step < 32; step++) { + const nextFile = file + curDx; + const nextRank = rank + dy; + + // Rank edge terminates the ray unconditionally (no rank-bouncing + // here — that's bouncing-pieces-2). + if (nextRank < 0 || nextRank > 7) break; + + if (nextFile < 0 || nextFile > 7) { + // File edge → bounce if we haven't yet, otherwise terminate. + if (bounced) break; + bounced = true; + curDx = -curDx; + const reflectedFile = file + curDx; + const reflectedRank = rank + dy; + // Defensive: if the reflected step itself somehow lands + // off-board (rank out — shouldn't happen given the rank check + // above, but cheap to guard), stop. + if ( + reflectedFile < 0 || + reflectedFile > 7 || + reflectedRank < 0 || + reflectedRank > 7 + ) { + break; + } + file = reflectedFile; + rank = reflectedRank; + } else { + file = nextFile; + rank = nextRank; + } + + const sq = squareOf(file, rank) as Square; + + if (isAllyAt(session, sq, color)) break; + if (isEnemyAt(session, sq, color)) { + if (bounced && !skipDests.has(sq)) { + out.push({ pieceId, from, to: sq, isCapture: true }); + } + break; + } + if (bounced && !skipDests.has(sq)) { + out.push({ pieceId, from, to: sq, isCapture: false }); + } + } + + return out; +} + +PRESET_REGISTRY.register({ + id: "bouncing-pieces", + name: "Bouncing Pieces", + description: + "Bishop and queen diagonal rays bounce once off the a-file or h-file.", + incompatibleWith: ["bouncing-pieces-2", "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 5a63c81..1fba158 100644 --- a/packages/chess/src/presets/index.ts +++ b/packages/chess/src/presets/index.ts @@ -39,5 +39,6 @@ import "./capture-all.js"; import "./suicide-chess.js"; import "./extinction-chess.js"; import "./berolina-pawns.js"; +import "./bouncing-pieces.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 902d0c3..e417080 100644 --- a/packages/chess/src/presets/presets.test.ts +++ b/packages/chess/src/presets/presets.test.ts @@ -52,6 +52,7 @@ describe("Preset registry — all registered", () => { "suicide-chess", "extinction-chess", "berolina-pawns", + "bouncing-pieces", ]; it("registry size matches the expected ID list", () => {