feat(presets): bouncing-pieces (bishop/queen diagonals bounce off file edges once)
This commit is contained in:
parent
5393b96b3f
commit
b873095c17
4 changed files with 520 additions and 0 deletions
274
packages/chess/src/presets/bouncing-pieces.test.ts
Normal file
274
packages/chess/src/presets/bouncing-pieces.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
244
packages/chess/src/presets/bouncing-pieces.ts
Normal file
244
packages/chess/src/presets/bouncing-pieces.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue