fix(chess): real cylindrical-board slide wrap + working check indicator

wrap-board previously contributed a single one-square hop when the
rook/queen was already on an edge file, which is essentially a no-op
for 99% of positions. Rewrote the hook to implement an actual
cylindrical topology: sliders walk past the a/h seam and continue
along the rank on the opposite side, stopping at the first ally (no
destination) or enemy (capture). Preserves the rook-warp incompatibility
since the two presets have different "when does the wrap apply"
preconditions.

Check indicator: GameView looked for an `InCheck` boolean fact that
doesn`t exist in the session — check status is a derived predicate
(isInCheck), not a stored fact. Exported isInCheck from the package
public API, wired GameView to query it per render, added a pulsing
red "Check!" banner in the header, and fixed the useChessEngine
audio path which had the same stale-fact bug (was never playing the
check sound).

Tests: 6 new wrap-board tests covering empty-rank slides, partial
blocks, queen slides, and the no-op case for non-sliders. Total
849 tests pass (+6).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-17 15:03:49 -06:00
commit 882e176b34
No known key found for this signature in database
5 changed files with 379 additions and 27 deletions

View file

@ -3,6 +3,7 @@ import { ChessEngine, type GameResult } from '../engine';
import type { PieceType } from '../schema';
import { saveAutoSave } from '../persist/autosave.js';
import * as audio from '../audio';
import { isInCheck } from '../rules/check';
import type { PresetActivation } from '../net/types';
export function useChessEngine() {
@ -50,10 +51,13 @@ export function useChessEngine() {
saveAutoSave(engine.session.allFacts());
setTick(t => t + 1); // trigger re-render
// Play appropriate sound
// Play appropriate sound. Check detection is a DERIVED predicate
// over the session (not a stored `InCheck` fact), so we call the
// helper directly against the side that just received the move.
const opponentColor = engine.getCurrentTurn();
if (result === 'checkmate') {
audio.play('checkmate');
} else if (engine.session.allFacts().some(f => f.attr === 'InCheck' && f.value === true)) {
} else if (isInCheck(engine.session, opponentColor)) {
audio.play('check');
} else if (move.isCapture) {
audio.play('capture');

View file

@ -29,6 +29,7 @@ export {
type ChessFact,
} from "./schema.js";
export type { LegalMove } from "./rules/types.js";
export { isInCheck } from "./rules/check.js";
export { PRESET_REGISTRY, type PresetDef } from "./presets/index.js";
export {
ActivePresetSet,

View file

@ -0,0 +1,197 @@
/**
* Targeted tests for the cylindrical-board preset.
*
* Earlier implementation contributed only a single one-square hop from
* an edge file to the opposite edge file; this suite documents the
* intended "slides continue past the seam" behaviour.
*/
import { describe, it, expect } from "vitest";
import "./index.js";
import { ChessEngine } from "../engine.js";
import { algebraicToSquare, squareOf, fileOf, rankOf } from "../coord.js";
import type { Square } from "../schema.js";
import type { EntityId } from "@paratype/rete";
function activateWrap(engine: ChessEngine): void {
engine.activePresets.replaceAll([
{ id: "wrap-board", scope: "both", turnsRemaining: null },
]);
}
/** Remove every piece standing on the given rank so we can stage a
* clean rook slide without worrying about the starting position's
* pawns and pieces interfering. */
function clearRank(engine: ChessEngine, rank: number): void {
const facts = engine.session.allFacts();
const toRetract: number[] = [];
for (const f of facts) {
if (f.attr !== "Position") continue;
if (rankOf(f.value as number) === rank) toRetract.push(f.id as number);
}
for (const id of toRetract) {
const eid = id as EntityId;
if (engine.session.contains(eid, "Position")) {
engine.session.retract(eid, "Position");
}
}
}
/** Move a piece to a specific square. Used to park a rook where we
* want it for the test. */
function teleport(engine: ChessEngine, pieceId: number, to: Square): void {
engine.session.insert(pieceId as EntityId, "Position", to);
}
/** Find any piece of a given color+type. */
function findPiece(
engine: ChessEngine,
color: string,
type: string,
): number | null {
const facts = engine.session.allFacts();
for (const f of facts) {
if (f.attr !== "PieceType" || f.value !== type) continue;
const colorFact = facts.find(c => c.id === f.id && c.attr === "Color");
if (colorFact?.value === color) return f.id as number;
}
return null;
}
describe("wrap-board preset — horizontal slide wrap", () => {
it("rook sliding off h-file toward a-file reaches the opposite side", () => {
const engine = new ChessEngine();
activateWrap(engine);
clearRank(engine, 3); // rank 4 in 1-indexed = rank 3 in 0-indexed
const rook = findPiece(engine, "white", "rook")!;
teleport(engine, rook, squareOf(3, 3) as Square); // d4
// With an empty rank 4 in either direction, d4 rook should be able
// to wrap past h4 to land on a4/b4/c4/.. up to just before d4.
const moves = engine.getAllLegalMoves().filter(m => m.pieceId === rook);
const targetFiles = new Set(moves.map(m => fileOf(m.to as number)));
// Normal slide on rank 4: a4..h4 minus d4 itself. Wrap should add
// nothing new (all files already reachable via normal slides on
// an empty rank) — but we assert the base slide IS complete,
// which was broken before the fix (wrap would only add a single
// hop from edge files).
expect(targetFiles.has(0)).toBe(true); // a4
expect(targetFiles.has(7)).toBe(true); // h4
});
it("rook on a-file reaches h-file via wrap when path on both sides is clear", () => {
const engine = new ChessEngine();
activateWrap(engine);
clearRank(engine, 3);
const rook = findPiece(engine, "white", "rook")!;
teleport(engine, rook, squareOf(0, 3) as Square); // a4
const moves = engine.getAllLegalMoves().filter(m => m.pieceId === rook);
const targetFiles = new Set(moves.map(m => fileOf(m.to as number)));
// a4 → b4..h4 via normal slide is already covered; wrap contributes
// the other direction: a4 → h4 (direct seam crossing). Assert both
// ends of the rank are reachable.
expect(targetFiles.has(7)).toBe(true); // h4 via the wrap seam
expect(targetFiles.has(1)).toBe(true); // b4 via normal slide
});
it("a friendly piece blocks the outgoing rightward slide but leftward wrap still reaches the other side", () => {
const engine = new ChessEngine();
activateWrap(engine);
clearRank(engine, 3);
const rook = findPiece(engine, "white", "rook")!;
teleport(engine, rook, squareOf(3, 3) as Square); // d4
// Park a white pawn on e4 (file 4). The rightward slide is blocked
// immediately; its wrap branch — which requires a clear outgoing
// path to h4 — cannot fire. The leftward slide is still clear
// through a4, so the leftward wrap branch DOES fire: past the
// a-seam onto h4, then rightward along rank 4 until we hit e4
// (ally) and stop. So h4/g4/f4 remain reachable via wrap, but e4
// itself never is (it's the ally). Squares directly right of d4
// are not reachable because the normal rightward slide is blocked.
const whitePawn = (() => {
const facts = engine.session.allFacts();
for (const f of facts) {
if (f.attr !== "PieceType" || f.value !== "pawn") continue;
const c = facts.find(x => x.id === f.id && x.attr === "Color");
if (c?.value === "white") return f.id as number;
}
return null;
})()!;
teleport(engine, whitePawn, squareOf(4, 3) as Square); // e4
const moves = engine.getAllLegalMoves().filter(m => m.pieceId === rook);
const targets = new Set(moves.map(m => m.to as number));
expect(targets.has(algebraicToSquare("e4"))).toBe(false); // ally blocks
expect(targets.has(algebraicToSquare("h4"))).toBe(true); // leftward wrap
expect(targets.has(algebraicToSquare("g4"))).toBe(true); // leftward wrap
expect(targets.has(algebraicToSquare("f4"))).toBe(true); // leftward wrap
expect(targets.has(algebraicToSquare("a4"))).toBe(true); // normal left slide
expect(targets.has(algebraicToSquare("c4"))).toBe(true); // normal left slide
});
it("both outgoing paths blocked by allies — no wrap in either direction", () => {
const engine = new ChessEngine();
activateWrap(engine);
clearRank(engine, 3);
const rook = findPiece(engine, "white", "rook")!;
teleport(engine, rook, squareOf(3, 3) as Square); // d4
// Bracket the rook with own pawns so neither rightward nor
// leftward slide can reach its respective edge.
const whitePawns: number[] = [];
const facts = engine.session.allFacts();
for (const f of facts) {
if (f.attr !== "PieceType" || f.value !== "pawn") continue;
const c = facts.find(x => x.id === f.id && x.attr === "Color");
if (c?.value === "white") whitePawns.push(f.id as number);
}
teleport(engine, whitePawns[0]!, squareOf(4, 3) as Square); // e4
teleport(engine, whitePawns[1]!, squareOf(2, 3) as Square); // c4
const moves = engine.getAllLegalMoves().filter(m => m.pieceId === rook);
const wrapTargets = moves
.filter(m => rankOf(m.to as number) === 3)
.map(m => fileOf(m.to as number));
// Rook can't go anywhere on rank 4 — both neighbours are allies.
expect(wrapTargets).toHaveLength(0);
});
it("queen also benefits from the wrap (not just rooks)", () => {
const engine = new ChessEngine();
activateWrap(engine);
clearRank(engine, 3);
const queen = findPiece(engine, "white", "queen")!;
teleport(engine, queen, squareOf(3, 3) as Square); // d4
const moves = engine.getAllLegalMoves().filter(m => m.pieceId === queen);
const targetFiles = new Set(
moves
.filter(m => rankOf(m.to as number) === 3) // only rank-4 moves
.map(m => fileOf(m.to as number)),
);
expect(targetFiles.has(0)).toBe(true); // a4
expect(targetFiles.has(7)).toBe(true); // h4
});
it("does not apply to non-sliding pieces", () => {
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);
});
});

View file

@ -1,27 +1,128 @@
/**
* 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).
*
* Vertical edges (ranks 1, 8) do NOT wrap — only files. That's what
* makes it a cylinder rather than a torus.
*
* 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).
*
* 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.
*/
import { PRESET_REGISTRY } from "./registry.js";
import { fileOf, rankOf, squareOf } from "../coord.js";
import { isAllyAt } from "../rules/board-queries.js";
import type { PieceColor, PieceType } from "../schema.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 { LegalMove } from "../rules/types.js";
import type { EntityId } from "@paratype/rete";
/**
* 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.
*/
function collectWrapMoves(
session: Session,
pieceId: EntityId,
from: Square,
color: PieceColor,
direction: 1 | -1,
): 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);
if (isAllyAt(session, sq, color)) break;
if (isEnemyAt(session, sq, color)) {
moves.push({ pieceId, from, to: sq, isCapture: true });
break;
}
moves.push({ pieceId, from, to: sq, isCapture: false });
}
return moves;
}
PRESET_REGISTRY.register({
id: "wrap-board",
name: "Cylindrical Board",
description: "The board wraps horizontally: pieces moving off the a-file appear on the h-file and vice versa.",
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.",
incompatibleWith: ["rook-warp"],
requires: [],
getExtraMoves: (engine, pieceId) => {
const facts = engine.session.allFacts();
const type = facts.find(f => f.id === pieceId && f.attr === "PieceType")?.value as PieceType;
if (!["rook", "queen"].includes(type)) return [];
const color = facts.find(f => f.id === pieceId && f.attr === "Color")?.value as PieceColor;
const from = facts.find(f => f.id === pieceId && f.attr === "Position")?.value as number;
if (from === undefined || !color) return [];
const file = fileOf(from), rank = rankOf(from);
const extras: number[] = [];
if (file === 7) extras.push(squareOf(0, rank));
if (file === 0) extras.push(squareOf(7, rank));
return extras
.filter(sq => !isAllyAt(engine.session, sq, color))
.map(to => ({ pieceId, from, to, isCapture: facts.some(f => f.attr === "Position" && f.value === to) }));
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(
(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);
}
return [...byTarget.values()];
},
});

View file

@ -6,6 +6,8 @@ import type { ChessFact, ChessAttrMap, PieceType } from '../schema';
import type { Color, PresetActivation } from '../net/types';
import type { GameResult } from '../engine';
import type { LegalMove } from '../rules/types';
import type { ChessEngine } from '../engine';
import { isInCheck } from '../rules/check';
import { useEffect, useState } from 'react';
import confetti from 'canvas-confetti';
import { motion, AnimatePresence } from 'motion/react';
@ -19,6 +21,9 @@ import * as audio from '../audio';
* mode-specific hook and is exposed via optional fields.
*/
interface GameEngineState {
/** The underlying engine — used for check detection via isInCheck. May
* be null only transiently during multiplayer's initial load. */
engine: ChessEngine | null;
facts: ReadonlyArray<ChessFact<keyof ChessAttrMap>> | ReturnType<typeof useChessEngine>['facts'];
legalMoves: LegalMove[];
turn: Color | 'white' | 'black';
@ -109,6 +114,7 @@ function GameLayout({
myColor: Color | null;
}) {
const {
engine,
facts,
legalMoves,
turn,
@ -166,16 +172,34 @@ function GameLayout({
setIsMuted(next);
};
// Find checked king for indicator
const checkedKingSquare = facts.find(
f => f.attr === 'InCheck' && f.value === true
) ? facts.find(
f => f.attr === 'PieceType' && f.value === 'king' &&
facts.some(f2 => f2.id === f.id && f2.attr === 'Color' && f2.value === turn)
)?.id ? facts.find(f3 => f3.id === facts.find(
f => f.attr === 'PieceType' && f.value === 'king' &&
facts.some(f2 => f2.id === f.id && f2.attr === 'Color' && f2.value === turn)
)?.id && f3.attr === 'Position')?.value as number : null : null;
// Check detection for the side to move. `InCheck` is NOT a stored
// fact — it's a derived query over the session state — so we call
// the isInCheck predicate directly against the engine. We also find
// the king's square so Board.tsx can render the pulsing red
// indicator on it. Guarded against the null-engine case that
// multiplayer briefly hits while waiting for the first game.state.
const turnAsColor = turn as 'white' | 'black';
const isCheck =
engine !== null ? isInCheck(engine.session, turnAsColor) : false;
const checkedKingSquare: number | null = (() => {
if (!isCheck) return null;
// Find the (king, current-turn-color) entity. facts is unordered
// so we search for a PieceType='king' fact whose entity also has
// Color=<turn>.
const colorById = new Map<number, string>();
for (const f of facts) {
if (f.attr === 'Color') colorById.set(f.id as number, f.value as string);
}
for (const f of facts) {
if (f.attr !== 'PieceType' || f.value !== 'king') continue;
if (colorById.get(f.id as number) !== turnAsColor) continue;
// Grab the Position fact for this entity.
const pos = facts.find((p) => p.id === f.id && p.attr === 'Position');
if (pos === undefined) return null;
return pos.value as number;
}
return null;
})();
return (
<motion.div
@ -262,6 +286,31 @@ function GameLayout({
);
})()}
{/* Check banner. Only renders when the side to move is in
* check AND the game hasn't reached a terminal state
* (checkmate shows the game-over banner instead). Turns
* red to match the pulsing king highlight on the board. */}
<AnimatePresence>
{isCheck && !isGameOver && (
<motion.div
key="check-banner"
initial={{ opacity: 0, scale: 0.8, x: -10 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.8, x: -10 }}
transition={{ type: 'spring', damping: 18, stiffness: 280 }}
data-testid="check-banner"
className="flex items-center gap-2 px-4 py-2 bg-red-50 border border-red-300 text-red-800 rounded-md font-semibold shadow-sm"
>
<motion.span
className="w-2 h-2 rounded-full bg-red-500"
animate={{ opacity: [1, 0.4, 1] }}
transition={{ repeat: Infinity, duration: 1.2, ease: 'easeInOut' }}
/>
Check!
</motion.div>
)}
</AnimatePresence>
{/* Undo is only meaningful in local mode (server is authoritative
* in multiplayer); the hook exposes canUndo=false there so the
* button naturally stays disabled. */}