feat(presets): bouncing-pieces (bishop/queen diagonals bounce off file edges once)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 08:37:52 -06:00
commit b873095c17
No known key found for this signature in database
4 changed files with 520 additions and 0 deletions

View file

@ -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();
});
});

View file

@ -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<readonly [number, number]> = [
[-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<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 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<Square>,
): 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;
},
});

View file

@ -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";

View file

@ -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", () => {