fix(chess): cylindrical board wraps every piece, plus eliminate piece image flash

wrap-board previously only wrapped rooks and queens horizontally,
which meant knights, kings, bishops, and pawn captures couldn`t
cross the file seam at all — a knight on h4 with cylindrical
enabled had no wrap targets, contrary to the "horizontal cylinder"
framing. Rewrote the hook to handle every piece type:
knights/kings via mod-8 file offsets; bishops/queens/rooks via
cylindrical ray walkers; pawn captures via mod-8 diagonal targets.
Rank bounds still terminate walks (cylinder, not torus). Added 4
piece-type tests to cover the new cases.

Piece image flash on remount: every move triggers a FLIP unmount
at the source square and remount at the destination, producing a
brand-new <img> element. Browsers don`t block paint on image
load, so the alt text briefly rendered before the SVG decoded.
Preload and eagerly-decode every piece SVG at module init to keep
the decoded bitmap warm in the browser image cache, and set
`decoding="sync"` plus empty alt on the Piece img so the alt
never has a paint window. aria-label preserves the accessible
name.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-17 15:17:57 -06:00
commit 60217adf97
No known key found for this signature in database
4 changed files with 356 additions and 91 deletions

View file

@ -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
* `<img>` 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 `<img>` 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 `<img src=…>`
* 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);
}
}
}

View file

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

View file

@ -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<readonly [number, number]> = [
[1, 2], [2, 1], [2, -1], [1, -2],
[-1, -2], [-2, -1], [-2, 1], [-1, 2],
];
/** King 8-neighbour offsets. */
const KING_OFFSETS: ReadonlyArray<readonly [number, number]> = [
[1, 0], [1, 1], [0, 1], [-1, 1],
[-1, 0], [-1, -1], [0, -1], [1, -1],
];
/** Rook ray directions. */
const ROOK_RAYS: ReadonlyArray<readonly [number, number]> = [
[1, 0], [-1, 0], [0, 1], [0, -1],
];
/** Bishop ray directions. */
const BISHOP_RAYS: ReadonlyArray<readonly [number, number]> = [
[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<Square, LegalMove>();
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);
},
});

View file

@ -324,7 +324,17 @@ export function Piece({
>
<img
src={imgSrc}
alt={`${color} ${type}`}
// Empty alt + `decoding=sync` eliminate the text-flash
// between unmount at the old square and mount at the new
// one. Previously the freshly-attached <img> 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)]'