diff --git a/packages/chess/src/assets/pieces/index.ts b/packages/chess/src/assets/pieces/index.ts
index cc13878..c0f3782 100644
--- a/packages/chess/src/assets/pieces/index.ts
+++ b/packages/chess/src/assets/pieces/index.ts
@@ -29,3 +29,48 @@ export const pieceAssets = {
pawn: blackPawn,
},
} as const;
+
+/**
+ * Module-level array anchoring preloaded Image objects so the browser
+ * GC doesn't collect them after the init block below completes. The
+ * presence of a live reference is what keeps the decoded bitmap in
+ * the browser's image cache.
+ */
+// Using HTMLImageElement[] rather than Image[] so this module can be
+// imported from non-DOM contexts (tests, server) without TS complaining.
+const PRELOADED_IMAGES: HTMLImageElement[] = [];
+
+/**
+ * Preload + eagerly decode every piece SVG at module init.
+ *
+ * Why: every move triggers a FLIP remount in Piece.tsx — the old
+ * `
` tag unmounts at the source square and a brand-new one
+ * mounts at the destination. Browsers don't block paint on image
+ * load; even though Vite's bundled SVG is already in the HTTP cache,
+ * the new `
` element shows its `alt` attribute (the "text
+ * representation") for the one or two frames it takes to attach,
+ * parse, and decode.
+ *
+ * Holding a warm `Image()` for every asset keeps the decoded bitmap
+ * alive in the browser's image cache, so subsequent `
`
+ * attachments render the first painted frame from cache instead of
+ * briefly falling back to alt text.
+ *
+ * Guarded by `typeof Image` so server-side imports (tests, the
+ * headless server package) don't crash.
+ */
+if (typeof Image !== "undefined") {
+ for (const byType of Object.values(pieceAssets)) {
+ for (const url of Object.values(byType)) {
+ const img = new Image();
+ img.src = url;
+ // decode() returns a promise that resolves once pixels are
+ // ready. Swallow rejection — rare browsers without support still
+ // render the image fine when it's actually used.
+ if (typeof img.decode === "function") {
+ img.decode().catch(() => {});
+ }
+ PRELOADED_IMAGES.push(img);
+ }
+ }
+}
diff --git a/packages/chess/src/presets/wrap-board.test.ts b/packages/chess/src/presets/wrap-board.test.ts
index 90be448..a5a7fa9 100644
--- a/packages/chess/src/presets/wrap-board.test.ts
+++ b/packages/chess/src/presets/wrap-board.test.ts
@@ -181,17 +181,118 @@ describe("wrap-board preset — horizontal slide wrap", () => {
expect(targetFiles.has(7)).toBe(true); // h4
});
- it("does not apply to non-sliding pieces", () => {
+ it("knight on h-file gets wrap-around L-leaps to a-file", () => {
const engine = new ChessEngine();
activateWrap(engine);
const knight = findPiece(engine, "white", "knight")!;
- // Base knight moves from b1 or g1 never include wrap targets; the
- // preset should contribute nothing for them.
- const knightMoves = engine
- .getAllLegalMoves()
- .filter(m => m.pieceId === knight);
- // Standard knight on b1 has 2 legal moves (a3, c3). g1 has 2 (f3, h3).
- expect(knightMoves.length).toBeLessThanOrEqual(2);
+ // Park the knight on h3 (file 7, rank 2). With the cylinder, the
+ // 8 knight offsets wrap file mod 8. From (file=7, rank=2) the
+ // wrap-crossing targets are:
+ // (+1,+2) → file 0, rank 4 → a5
+ // (+2,+1) → file 1, rank 3 → b4
+ // (+2,-1) → file 1, rank 1 → b2
+ // (+1,-2) → file 0, rank 0 → a1
+ // (Same-side targets f2, f4, g1, g5 come from the base rule.)
+ clearRank(engine, 0);
+ clearRank(engine, 1);
+ clearRank(engine, 2);
+ clearRank(engine, 3);
+ clearRank(engine, 4);
+ teleport(engine, knight, squareOf(7, 2) as Square); // h3
+
+ const moves = engine.getAllLegalMoves().filter(m => m.pieceId === knight);
+ const targets = new Set(moves.map(m => m.to as number));
+ expect(targets.has(algebraicToSquare("a5"))).toBe(true);
+ expect(targets.has(algebraicToSquare("b4"))).toBe(true);
+ expect(targets.has(algebraicToSquare("b2"))).toBe(true);
+ expect(targets.has(algebraicToSquare("a1"))).toBe(true);
+ });
+
+ it("knight on a-file gets wrap-around L-leaps to h-file", () => {
+ // Regression: a knight near the left edge leaping across the seam.
+ // b1 (file 1, rank 0) has one wrap-only target at h2:
+ // (-2,+1) → file -1 = 7, rank 1 → h2.
+ // Plus the standard a3, c3, d2 from the base rule.
+ const engine = new ChessEngine();
+ activateWrap(engine);
+ clearRank(engine, 0);
+ clearRank(engine, 1);
+ clearRank(engine, 2);
+ const knight = findPiece(engine, "white", "knight")!;
+ teleport(engine, knight, squareOf(1, 0) as Square); // b1
+
+ const moves = engine.getAllLegalMoves().filter(m => m.pieceId === knight);
+ const targets = new Set(moves.map(m => m.to as number));
+ expect(targets.has(algebraicToSquare("h2"))).toBe(true); // -2,+1 wrap
+ expect(targets.has(algebraicToSquare("a3"))).toBe(true); // -1,+2 base
+ expect(targets.has(algebraicToSquare("c3"))).toBe(true); // +1,+2 base
+ });
+
+ it("knight on h4 reaches a5 and b4 via the seam (user screenshot regression)", () => {
+ // User reported: with cylindrical enabled, a knight on h4 had no
+ // wrap moves into a-file territory.
+ // From (file 7, rank 3):
+ // (+1,+2) → file 0, rank 5 → a6
+ // (+2,+1) → file 1, rank 4 → b5 ← b5 per user, close to b4 expectation
+ // (+2,-1) → file 1, rank 2 → b3
+ // (+1,-2) → file 0, rank 1 → a2
+ // Note the user said "a5 and b4" but the strict knight geometry
+ // from h4 actually produces a6/b5/b3/a2. The IMPORTANT thing is
+ // that wrap-crossing targets exist at all — the old implementation
+ // returned NONE. We assert on the real math.
+ const engine = new ChessEngine();
+ activateWrap(engine);
+ for (let r = 0; r <= 6; r++) clearRank(engine, r);
+ const knight = findPiece(engine, "white", "knight")!;
+ teleport(engine, knight, squareOf(7, 3) as Square); // h4
+
+ const moves = engine.getAllLegalMoves().filter(m => m.pieceId === knight);
+ const targets = new Set(moves.map(m => m.to as number));
+ expect(targets.has(algebraicToSquare("a6"))).toBe(true);
+ expect(targets.has(algebraicToSquare("b5"))).toBe(true);
+ expect(targets.has(algebraicToSquare("b3"))).toBe(true);
+ expect(targets.has(algebraicToSquare("a2"))).toBe(true);
+ });
+
+ it("king on a-file can step onto h-file via the seam", () => {
+ const engine = new ChessEngine();
+ activateWrap(engine);
+ // Clear rank 2 so the a2 square is empty and the king has empty
+ // squares to walk onto. Also clear the king's home rank neighbours.
+ clearRank(engine, 1);
+ clearRank(engine, 0);
+
+ const king = findPiece(engine, "white", "king")!;
+ teleport(engine, king, squareOf(0, 3) as Square); // a4
+ clearRank(engine, 3);
+ teleport(engine, king, squareOf(0, 3) as Square); // a4 again after rank clear
+
+ const moves = engine.getAllLegalMoves().filter(m => m.pieceId === king);
+ const targets = new Set(moves.map(m => m.to as number));
+ expect(targets.has(algebraicToSquare("h4"))).toBe(true); // west wrap
+ expect(targets.has(algebraicToSquare("h5"))).toBe(true); // NW wrap
+ expect(targets.has(algebraicToSquare("h3"))).toBe(true); // SW wrap
+ });
+
+ it("bishop on a-file gets wrap-around diagonal moves to h-file", () => {
+ const engine = new ChessEngine();
+ activateWrap(engine);
+ // Clear a diagonal path so the wrap is unobstructed.
+ clearRank(engine, 1);
+ clearRank(engine, 2);
+ clearRank(engine, 3);
+
+ const bishop = findPiece(engine, "white", "bishop")!;
+ teleport(engine, bishop, squareOf(0, 3) as Square); // a4
+
+ const moves = engine.getAllLegalMoves().filter(m => m.pieceId === bishop);
+ const targets = new Set(moves.map(m => m.to as number));
+ // Bishop on a4 walking up-left wraps: a4 -> h5 (file -1 = 7, rank 4)
+ // Then g6 (file -2 = 6, rank 5) — but we didn't clear rank 5, so
+ // stops at h5 or whatever blocker. Just assert h5 is reachable.
+ expect(targets.has(algebraicToSquare("h5"))).toBe(true);
+ // And down-left from a4: file -1 = 7, rank 2 → h3.
+ expect(targets.has(algebraicToSquare("h3"))).toBe(true);
});
});
diff --git a/packages/chess/src/presets/wrap-board.ts b/packages/chess/src/presets/wrap-board.ts
index d939a59..4b811c4 100644
--- a/packages/chess/src/presets/wrap-board.ts
+++ b/packages/chess/src/presets/wrap-board.ts
@@ -2,98 +2,210 @@
* Preset: `wrap-board` (Cylindrical Board, RULES.md rule #7)
*
* The board is a horizontal cylinder: a-file and h-file are adjacent.
- * Rooks and queens sliding horizontally may continue past the edge and
- * emerge on the opposite file, continuing their slide toward the origin
- * square. The slide stops at the first friendly piece (no destination)
- * or first enemy piece (capturing it), or just before the rook's own
- * square (to avoid a zero-move phantom).
+ * Every piece whose movement could "fall off" the left or right edge
+ * instead emerges on the opposite side, continuing its geometry there.
+ * Vertical edges (ranks 1 and 8) do NOT wrap — the board is a cylinder,
+ * not a torus.
*
- * Vertical edges (ranks 1, 8) do NOT wrap — only files. That's what
- * makes it a cylinder rather than a torus.
+ * Concretely:
+ * - Rooks / queen horizontals: slides continue past the seam along the
+ * rank, stopping at the first blocker or capturing the first enemy.
+ * - Bishops / queen diagonals: diagonal slides wrap in file but still
+ * terminate when the rank leaves the board.
+ * - Knights: the 8 L-offsets are computed with file `mod 8`, so a knight
+ * on h4 can leap to a6/b5/b3/a2 as if file 8 ≡ file 0.
+ * - Kings: all 8 adjacent offsets with file `mod 8`.
+ * - Pawn captures: the two diagonal-forward capture squares wrap too.
+ * - Pawn advances do NOT wrap — they move along files, not across the
+ * file seam, so wrapping is irrelevant there.
*
- * Earlier version of this preset only contributed a single file-7→0
- * (or file-0→7) hop when the rook sat on an edge file. That made the
- * "cylindrical" framing misleading: a rook on d4 couldn't reach h4 via
- * a-file-wrap at all. This version implements the full slide wrap so
- * a rook on d4 with clear c4/b4/a4 can reach h4/g4/f4/e4 as wrap
- * destinations (stopping at the first blocker per standard slide rules).
+ * We implement this with a single `getExtraMoves` hook that, per piece,
+ * computes the wrap-aware destinations and returns only the ones the
+ * base FIDE rule could not already produce (because its file math is
+ * clamped to [0,7]). The base rule's standard moves still run, so the
+ * sum is the full cylindrical move set.
*
- * Incompatible with `rook-warp`: rook-warp uses a stricter condition
- * (must have clear path to the edge first, then consumes the whole
- * wrap path), while wrap-board treats files as simply adjacent. The two
- * semantics cannot both be applied to the same rook's moves without
- * producing ambiguous destination sets — see RULES.md.
+ * Incompatible with `rook-warp`: rook-warp uses a stricter precondition
+ * (the full path to the edge must be clear before any wrap is legal)
+ * and applies only to rooks. Combining them would produce ambiguous
+ * destination sets — see RULES.md for the full matrix.
*/
import { PRESET_REGISTRY } from "./registry.js";
import { fileOf, rankOf, squareOf } from "../coord.js";
-import { isAllyAt, isEnemyAt, isPieceAt } from "../rules/board-queries.js";
+import {
+ isAllyAt,
+ isEnemyAt,
+ isPieceAt,
+} from "../rules/board-queries.js";
import type { PieceColor, PieceType, Square } from "../schema.js";
-import type { Session } from "@paratype/rete";
+import type { Session, EntityId } from "@paratype/rete";
import type { LegalMove } from "../rules/types.js";
-import type { EntityId } from "@paratype/rete";
+
+/** Modulo that handles negative file deltas correctly (JS `%` returns
+ * negative values for e.g. `-1 % 8`). */
+function wrapFile(file: number): number {
+ return ((file % 8) + 8) % 8;
+}
/**
- * Walk the horizontal wrap path starting from the square immediately
- * across the wrap seam, moving toward `from`, collecting each empty
- * square and stopping at the first ally or enemy.
- *
- * @param direction `+1` to wrap rightward off h-file onto a-file and
- * continue right toward `from`'s file;
- * `-1` to wrap leftward off a-file onto h-file and
- * continue left toward `from`'s file.
+ * Walk a cylindrical ray with the given (fileDelta, rankDelta) step.
+ * Files wrap mod 8; ranks terminate the walk when out of [0,7].
+ * The starting square is never included. Stops at the first ally, or
+ * captures and stops at the first enemy. If the walk loops back to
+ * `from` (possible on a pure-horizontal ray since files wrap), it
+ * stops without re-adding.
*/
-function collectWrapMoves(
+function walkRay(
session: Session,
pieceId: EntityId,
from: Square,
color: PieceColor,
- direction: 1 | -1,
+ fileDelta: number,
+ rankDelta: number,
): LegalMove[] {
- const rank = rankOf(from);
- const fromFile = fileOf(from);
- const moves: LegalMove[] = [];
-
- // `direction = +1` means the original slide heads right. After wrapping
- // off file 7 we enter on file 0 and continue rightward toward `fromFile`.
- // `direction = -1` mirrors: slide left off file 0, enter on file 7,
- // continue left toward `fromFile`.
- const entryFile = direction === 1 ? 0 : 7;
- const step = direction;
-
- // First, make sure the slide can actually REACH the edge in the direction
- // it's heading. If there's a piece between `from` and the seam on the
- // outgoing side, this wrap branch doesn't apply — the slide is already
- // blocked before any wrap is possible.
- //
- // Going right (direction=1): check files fromFile+1 .. 7 on the rank.
- // Going left (direction=-1): check files fromFile-1 .. 0 on the rank.
- {
- const edge = direction === 1 ? 7 : 0;
- for (let f = fromFile + step; f !== edge + step; f += step) {
- if (isPieceAt(session, squareOf(f, rank))) return [];
- }
- }
-
- // Walk the wrap path from the entry file toward fromFile, stopping just
- // before fromFile. Every empty square is a legal destination; the first
- // enemy is a final capture destination; the first ally cuts the walk.
- for (let f = entryFile; f !== fromFile; f += step) {
- const sq = squareOf(f, rank);
+ const out: LegalMove[] = [];
+ let file = fileOf(from) + fileDelta;
+ let rank = rankOf(from) + rankDelta;
+ // Safety cap: at most 8 file-wraps * 8 ranks = 64, pick 80 for margin.
+ for (let step = 0; step < 80; step++) {
+ if (rank < 0 || rank > 7) break;
+ const sq = squareOf(wrapFile(file), rank);
+ if (sq === from) break;
if (isAllyAt(session, sq, color)) break;
if (isEnemyAt(session, sq, color)) {
- moves.push({ pieceId, from, to: sq, isCapture: true });
+ out.push({ pieceId, from, to: sq, isCapture: true });
break;
}
- moves.push({ pieceId, from, to: sq, isCapture: false });
+ out.push({ pieceId, from, to: sq, isCapture: false });
+ file += fileDelta;
+ rank += rankDelta;
+ }
+ return out;
+}
+
+/**
+ * Single-offset (non-sliding) cylinder move. Returns 0 or 1 legal
+ * moves. Ranks out of bounds → nothing (cylinder, not torus).
+ */
+function offsetMove(
+ session: Session,
+ pieceId: EntityId,
+ from: Square,
+ color: PieceColor,
+ fileDelta: number,
+ rankDelta: number,
+): LegalMove | null {
+ const rank = rankOf(from) + rankDelta;
+ if (rank < 0 || rank > 7) return null;
+ const file = wrapFile(fileOf(from) + fileDelta);
+ const sq = squareOf(file, rank);
+ if (sq === from) return null;
+ if (isAllyAt(session, sq, color)) return null;
+ return {
+ pieceId,
+ from,
+ to: sq,
+ isCapture: isPieceAt(session, sq),
+ };
+}
+
+/** Knight L-shape offsets. */
+const KNIGHT_OFFSETS: ReadonlyArray = [
+ [1, 2], [2, 1], [2, -1], [1, -2],
+ [-1, -2], [-2, -1], [-2, 1], [-1, 2],
+];
+
+/** King 8-neighbour offsets. */
+const KING_OFFSETS: ReadonlyArray = [
+ [1, 0], [1, 1], [0, 1], [-1, 1],
+ [-1, 0], [-1, -1], [0, -1], [1, -1],
+];
+
+/** Rook ray directions. */
+const ROOK_RAYS: ReadonlyArray = [
+ [1, 0], [-1, 0], [0, 1], [0, -1],
+];
+
+/** Bishop ray directions. */
+const BISHOP_RAYS: ReadonlyArray = [
+ [1, 1], [1, -1], [-1, 1], [-1, -1],
+];
+
+/** Per-type cylinder-aware move generation. */
+function computeCylinderMoves(
+ session: Session,
+ pieceId: EntityId,
+ from: Square,
+ color: PieceColor,
+ type: PieceType,
+): LegalMove[] {
+ switch (type) {
+ case "knight": {
+ const out: LegalMove[] = [];
+ for (const [df, dr] of KNIGHT_OFFSETS) {
+ const m = offsetMove(session, pieceId, from, color, df, dr);
+ if (m !== null) out.push(m);
+ }
+ return out;
+ }
+ case "king": {
+ const out: LegalMove[] = [];
+ for (const [df, dr] of KING_OFFSETS) {
+ const m = offsetMove(session, pieceId, from, color, df, dr);
+ if (m !== null) out.push(m);
+ }
+ return out;
+ }
+ case "rook": {
+ const out: LegalMove[] = [];
+ for (const [df, dr] of ROOK_RAYS) {
+ out.push(...walkRay(session, pieceId, from, color, df, dr));
+ }
+ return out;
+ }
+ case "bishop": {
+ const out: LegalMove[] = [];
+ for (const [df, dr] of BISHOP_RAYS) {
+ out.push(...walkRay(session, pieceId, from, color, df, dr));
+ }
+ return out;
+ }
+ case "queen": {
+ const out: LegalMove[] = [];
+ for (const [df, dr] of [...ROOK_RAYS, ...BISHOP_RAYS]) {
+ out.push(...walkRay(session, pieceId, from, color, df, dr));
+ }
+ return out;
+ }
+ case "pawn": {
+ // Only diagonal CAPTURES wrap — straight advance never crosses
+ // the file seam. Emit both diagonals (forward one rank for this
+ // color), wrapping file, and only if an enemy actually occupies
+ // the target.
+ const dr = color === "white" ? 1 : -1;
+ const targetRank = rankOf(from) + dr;
+ if (targetRank < 0 || targetRank > 7) return [];
+ const out: LegalMove[] = [];
+ for (const df of [-1, 1]) {
+ const file = wrapFile(fileOf(from) + df);
+ const sq = squareOf(file, targetRank);
+ if (sq === from) continue;
+ if (isEnemyAt(session, sq, color)) {
+ out.push({ pieceId, from, to: sq, isCapture: true });
+ }
+ }
+ return out;
+ }
+ default:
+ return [];
}
- return moves;
}
PRESET_REGISTRY.register({
id: "wrap-board",
name: "Cylindrical Board",
description:
- "The board wraps horizontally: rooks and queens sliding off the a-file appear on the h-file and vice versa, continuing their slide until blocked.",
+ "The board wraps horizontally: every piece (knights, kings, bishops, queens, rooks, and pawn captures) can move off one side and appear on the other.",
incompatibleWith: ["rook-warp"],
requires: [],
getExtraMoves: (engine, pieceId) => {
@@ -101,28 +213,25 @@ PRESET_REGISTRY.register({
const type = facts.find(
(f) => f.id === pieceId && f.attr === "PieceType",
)?.value as PieceType | undefined;
- if (type !== "rook" && type !== "queen") return [];
-
const color = facts.find(
(f) => f.id === pieceId && f.attr === "Color",
)?.value as PieceColor | undefined;
- const from = facts.find(
+ const fromVal = facts.find(
(f) => f.id === pieceId && f.attr === "Position",
)?.value as number | undefined;
- if (color === undefined || from === undefined) return [];
-
- // Wrap in both horizontal directions. A rook in the middle of a rank
- // may be able to reach wrap destinations via EITHER direction if
- // both paths to the respective edges are clear. Dedup by `to` just
- // in case a very short rank produced the same target from both
- // sides (theoretically impossible on an 8-file board but cheap).
- const byTarget = new Map();
- for (const move of [
- ...collectWrapMoves(engine.session, pieceId, from as Square, color, 1),
- ...collectWrapMoves(engine.session, pieceId, from as Square, color, -1),
- ]) {
- byTarget.set(move.to, move);
+ if (type === undefined || color === undefined || fromVal === undefined) {
+ return [];
}
- return [...byTarget.values()];
+ const from = fromVal as Square;
+ const session = engine.session;
+
+ // Return the full cylinder-aware move set. We deliberately do NOT
+ // subtract the base-rule targets: duplicates here are harmless
+ // (downstream consumers dedup by (from,to) square) and returning
+ // a complete move list makes the preset testable in isolation —
+ // a test fixture with only the rook present should still see
+ // `wrap-board` contribute the slide targets rather than returning
+ // an empty list because the base rule would have reached them.
+ return computeCylinderMoves(session, pieceId, from, color, type);
},
});
diff --git a/packages/chess/src/ui/Piece.tsx b/packages/chess/src/ui/Piece.tsx
index 203872b..272bd70 100644
--- a/packages/chess/src/ui/Piece.tsx
+++ b/packages/chess/src/ui/Piece.tsx
@@ -324,7 +324,17 @@ export function Piece({
>
briefly
+ // displayed its `alt` string (e.g. "white knight") for the
+ // frame it took the browser to decode the SVG. With the
+ // images preloaded at module init (see ./assets/pieces)
+ // and sync decoding requested here, the first painted
+ // frame always has the piece bitmap.
+ alt=""
+ aria-label={`${color} ${type}`}
+ decoding="sync"
className={`w-[85%] h-[85%] pointer-events-none transition-[filter] duration-200 ${
isDragging
? 'drop-shadow-[0_12px_16px_rgba(0,0,0,0.45)]'