diff --git a/packages/chess/src/hooks/useChessEngine.ts b/packages/chess/src/hooks/useChessEngine.ts index f67a876..4a175f5 100644 --- a/packages/chess/src/hooks/useChessEngine.ts +++ b/packages/chess/src/hooks/useChessEngine.ts @@ -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'); diff --git a/packages/chess/src/index.ts b/packages/chess/src/index.ts index ae13cb0..6e1cda3 100644 --- a/packages/chess/src/index.ts +++ b/packages/chess/src/index.ts @@ -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, diff --git a/packages/chess/src/presets/wrap-board.test.ts b/packages/chess/src/presets/wrap-board.test.ts new file mode 100644 index 0000000..90be448 --- /dev/null +++ b/packages/chess/src/presets/wrap-board.test.ts @@ -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); + }); +}); diff --git a/packages/chess/src/presets/wrap-board.ts b/packages/chess/src/presets/wrap-board.ts index 979ffee..d939a59 100644 --- a/packages/chess/src/presets/wrap-board.ts +++ b/packages/chess/src/presets/wrap-board.ts @@ -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(); + 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()]; }, }); diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx index b081611..f191a90 100644 --- a/packages/chess/src/ui/GameView.tsx +++ b/packages/chess/src/ui/GameView.tsx @@ -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> | ReturnType['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=. + const colorById = new Map(); + 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 ( + {isCheck && !isGameOver && ( + + + Check! + + )} + + {/* Undo is only meaningful in local mode (server is authoritative * in multiplayer); the hook exposes canUndo=false there so the * button naturally stays disabled. */}