From 60217adf97507eb2ff7443f537b622c4ffa32336 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 17 Apr 2026 15:17:57 -0600 Subject: [PATCH] fix(chess): cylindrical board wraps every piece, plus eliminate piece image flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- packages/chess/src/assets/pieces/index.ts | 45 +++ packages/chess/src/presets/wrap-board.test.ts | 117 +++++++- packages/chess/src/presets/wrap-board.ts | 273 ++++++++++++------ packages/chess/src/ui/Piece.tsx | 12 +- 4 files changed, 356 insertions(+), 91 deletions(-) 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({ > {`${color} 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)]'