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:
parent
882e176b34
commit
60217adf97
4 changed files with 356 additions and 91 deletions
|
|
@ -29,3 +29,48 @@ export const pieceAssets = {
|
||||||
pawn: blackPawn,
|
pawn: blackPawn,
|
||||||
},
|
},
|
||||||
} as const;
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -181,17 +181,118 @@ describe("wrap-board preset — horizontal slide wrap", () => {
|
||||||
expect(targetFiles.has(7)).toBe(true); // h4
|
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();
|
const engine = new ChessEngine();
|
||||||
activateWrap(engine);
|
activateWrap(engine);
|
||||||
|
|
||||||
const knight = findPiece(engine, "white", "knight")!;
|
const knight = findPiece(engine, "white", "knight")!;
|
||||||
// Base knight moves from b1 or g1 never include wrap targets; the
|
// Park the knight on h3 (file 7, rank 2). With the cylinder, the
|
||||||
// preset should contribute nothing for them.
|
// 8 knight offsets wrap file mod 8. From (file=7, rank=2) the
|
||||||
const knightMoves = engine
|
// wrap-crossing targets are:
|
||||||
.getAllLegalMoves()
|
// (+1,+2) → file 0, rank 4 → a5
|
||||||
.filter(m => m.pieceId === knight);
|
// (+2,+1) → file 1, rank 3 → b4
|
||||||
// Standard knight on b1 has 2 legal moves (a3, c3). g1 has 2 (f3, h3).
|
// (+2,-1) → file 1, rank 1 → b2
|
||||||
expect(knightMoves.length).toBeLessThanOrEqual(2);
|
// (+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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,98 +2,210 @@
|
||||||
* Preset: `wrap-board` (Cylindrical Board, RULES.md rule #7)
|
* Preset: `wrap-board` (Cylindrical Board, RULES.md rule #7)
|
||||||
*
|
*
|
||||||
* The board is a horizontal cylinder: a-file and h-file are adjacent.
|
* The board is a horizontal cylinder: a-file and h-file are adjacent.
|
||||||
* Rooks and queens sliding horizontally may continue past the edge and
|
* Every piece whose movement could "fall off" the left or right edge
|
||||||
* emerge on the opposite file, continuing their slide toward the origin
|
* instead emerges on the opposite side, continuing its geometry there.
|
||||||
* square. The slide stops at the first friendly piece (no destination)
|
* Vertical edges (ranks 1 and 8) do NOT wrap — the board is a cylinder,
|
||||||
* or first enemy piece (capturing it), or just before the rook's own
|
* not a torus.
|
||||||
* square (to avoid a zero-move phantom).
|
|
||||||
*
|
*
|
||||||
* Vertical edges (ranks 1, 8) do NOT wrap — only files. That's what
|
* Concretely:
|
||||||
* makes it a cylinder rather than a torus.
|
* - 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
|
* We implement this with a single `getExtraMoves` hook that, per piece,
|
||||||
* (or file-0→7) hop when the rook sat on an edge file. That made the
|
* computes the wrap-aware destinations and returns only the ones the
|
||||||
* "cylindrical" framing misleading: a rook on d4 couldn't reach h4 via
|
* base FIDE rule could not already produce (because its file math is
|
||||||
* a-file-wrap at all. This version implements the full slide wrap so
|
* clamped to [0,7]). The base rule's standard moves still run, so the
|
||||||
* a rook on d4 with clear c4/b4/a4 can reach h4/g4/f4/e4 as wrap
|
* sum is the full cylindrical move set.
|
||||||
* destinations (stopping at the first blocker per standard slide rules).
|
|
||||||
*
|
*
|
||||||
* Incompatible with `rook-warp`: rook-warp uses a stricter condition
|
* Incompatible with `rook-warp`: rook-warp uses a stricter precondition
|
||||||
* (must have clear path to the edge first, then consumes the whole
|
* (the full path to the edge must be clear before any wrap is legal)
|
||||||
* wrap path), while wrap-board treats files as simply adjacent. The two
|
* and applies only to rooks. Combining them would produce ambiguous
|
||||||
* semantics cannot both be applied to the same rook's moves without
|
* destination sets — see RULES.md for the full matrix.
|
||||||
* producing ambiguous destination sets — see RULES.md.
|
|
||||||
*/
|
*/
|
||||||
import { PRESET_REGISTRY } from "./registry.js";
|
import { PRESET_REGISTRY } from "./registry.js";
|
||||||
import { fileOf, rankOf, squareOf } from "../coord.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 { 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 { 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
|
* Walk a cylindrical ray with the given (fileDelta, rankDelta) step.
|
||||||
* across the wrap seam, moving toward `from`, collecting each empty
|
* Files wrap mod 8; ranks terminate the walk when out of [0,7].
|
||||||
* square and stopping at the first ally or enemy.
|
* 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
|
||||||
* @param direction `+1` to wrap rightward off h-file onto a-file and
|
* `from` (possible on a pure-horizontal ray since files wrap), it
|
||||||
* continue right toward `from`'s file;
|
* stops without re-adding.
|
||||||
* `-1` to wrap leftward off a-file onto h-file and
|
|
||||||
* continue left toward `from`'s file.
|
|
||||||
*/
|
*/
|
||||||
function collectWrapMoves(
|
function walkRay(
|
||||||
session: Session,
|
session: Session,
|
||||||
pieceId: EntityId,
|
pieceId: EntityId,
|
||||||
from: Square,
|
from: Square,
|
||||||
color: PieceColor,
|
color: PieceColor,
|
||||||
direction: 1 | -1,
|
fileDelta: number,
|
||||||
|
rankDelta: number,
|
||||||
): LegalMove[] {
|
): LegalMove[] {
|
||||||
const rank = rankOf(from);
|
const out: LegalMove[] = [];
|
||||||
const fromFile = fileOf(from);
|
let file = fileOf(from) + fileDelta;
|
||||||
const moves: LegalMove[] = [];
|
let rank = rankOf(from) + rankDelta;
|
||||||
|
// Safety cap: at most 8 file-wraps * 8 ranks = 64, pick 80 for margin.
|
||||||
// `direction = +1` means the original slide heads right. After wrapping
|
for (let step = 0; step < 80; step++) {
|
||||||
// off file 7 we enter on file 0 and continue rightward toward `fromFile`.
|
if (rank < 0 || rank > 7) break;
|
||||||
// `direction = -1` mirrors: slide left off file 0, enter on file 7,
|
const sq = squareOf(wrapFile(file), rank);
|
||||||
// continue left toward `fromFile`.
|
if (sq === from) break;
|
||||||
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);
|
|
||||||
if (isAllyAt(session, sq, color)) break;
|
if (isAllyAt(session, sq, color)) break;
|
||||||
if (isEnemyAt(session, sq, color)) {
|
if (isEnemyAt(session, sq, color)) {
|
||||||
moves.push({ pieceId, from, to: sq, isCapture: true });
|
out.push({ pieceId, from, to: sq, isCapture: true });
|
||||||
break;
|
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({
|
PRESET_REGISTRY.register({
|
||||||
id: "wrap-board",
|
id: "wrap-board",
|
||||||
name: "Cylindrical Board",
|
name: "Cylindrical Board",
|
||||||
description:
|
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"],
|
incompatibleWith: ["rook-warp"],
|
||||||
requires: [],
|
requires: [],
|
||||||
getExtraMoves: (engine, pieceId) => {
|
getExtraMoves: (engine, pieceId) => {
|
||||||
|
|
@ -101,28 +213,25 @@ PRESET_REGISTRY.register({
|
||||||
const type = facts.find(
|
const type = facts.find(
|
||||||
(f) => f.id === pieceId && f.attr === "PieceType",
|
(f) => f.id === pieceId && f.attr === "PieceType",
|
||||||
)?.value as PieceType | undefined;
|
)?.value as PieceType | undefined;
|
||||||
if (type !== "rook" && type !== "queen") return [];
|
|
||||||
|
|
||||||
const color = facts.find(
|
const color = facts.find(
|
||||||
(f) => f.id === pieceId && f.attr === "Color",
|
(f) => f.id === pieceId && f.attr === "Color",
|
||||||
)?.value as PieceColor | undefined;
|
)?.value as PieceColor | undefined;
|
||||||
const from = facts.find(
|
const fromVal = facts.find(
|
||||||
(f) => f.id === pieceId && f.attr === "Position",
|
(f) => f.id === pieceId && f.attr === "Position",
|
||||||
)?.value as number | undefined;
|
)?.value as number | undefined;
|
||||||
if (color === undefined || from === undefined) return [];
|
if (type === undefined || color === undefined || fromVal === 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);
|
|
||||||
}
|
}
|
||||||
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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -324,7 +324,17 @@ export function Piece({
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={imgSrc}
|
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 ${
|
className={`w-[85%] h-[85%] pointer-events-none transition-[filter] duration-200 ${
|
||||||
isDragging
|
isDragging
|
||||||
? 'drop-shadow-[0_12px_16px_rgba(0,0,0,0.45)]'
|
? 'drop-shadow-[0_12px_16px_rgba(0,0,0,0.45)]'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue