feat(chess): replace custom engine with chess.js and react-chessboard
Some checks failed
Deploy to Production / test (push) Failing after 39s
Some checks failed
Deploy to Production / test (push) Failing after 39s
Swap the custom move validation and Unicode piece grid for chess.js (full rules engine with check/checkmate/castling/en passant/promotion) and react-chessboard (drag-and-drop SVG board). Board styled to match the purple dark theme and auto-orients to the player's color. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
15
bun.lock
15
bun.lock
@@ -6,6 +6,7 @@
|
||||
"name": "app",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^0.1.89",
|
||||
"chess.js": "^1.4.0",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
@@ -25,10 +26,12 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@imgly/background-removal": "^1.7.0",
|
||||
"chess.js": "^1.4.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.564.0",
|
||||
"react": "^19.1.0",
|
||||
"react-chessboard": "^5.10.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
@@ -98,6 +101,14 @@
|
||||
|
||||
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
|
||||
|
||||
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||
|
||||
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
||||
|
||||
"@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="],
|
||||
|
||||
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||
|
||||
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
||||
@@ -336,6 +347,8 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
|
||||
|
||||
"chess.js": ["chess.js@1.4.0", "", {}, "sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
@@ -470,6 +483,8 @@
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react-chessboard": ["react-chessboard@5.10.0", "", { "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-Y3PgaCVhnDG3IaQfu86OzTSEIEAUtuU5XwmHWnx3tcFOX7lSoAq81ZFX3MBj6y5a6FzDMTczMVmkkrV2CzTrIw=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^0.1.89",
|
||||
"chess.js": "^1.4.0",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@imgly/background-removal": "^1.7.0",
|
||||
"chess.js": "^1.4.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.564.0",
|
||||
"react": "^19.1.0",
|
||||
"react-chessboard": "^5.10.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
||||
@@ -1,61 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { Chessboard } from "react-chessboard";
|
||||
import { Chess } from "chess.js";
|
||||
import type { GameUIProps } from "../registry";
|
||||
|
||||
interface Piece {
|
||||
type: string;
|
||||
color: "white" | "black";
|
||||
}
|
||||
|
||||
interface ChessState {
|
||||
board: (Piece | null)[][];
|
||||
currentTurn: "white" | "black";
|
||||
fen: string;
|
||||
players: { white: string; black: string };
|
||||
moveHistory: string[];
|
||||
status: string;
|
||||
winner: string | null;
|
||||
}
|
||||
|
||||
const PIECE_SYMBOLS: Record<string, Record<string, string>> = {
|
||||
white: { king: "♔", queen: "♕", rook: "♖", bishop: "♗", knight: "♘", pawn: "♙" },
|
||||
black: { king: "♚", queen: "♛", rook: "♜", bishop: "♝", knight: "♞", pawn: "♟" },
|
||||
};
|
||||
|
||||
export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }: GameUIProps) {
|
||||
const chess = state as ChessState;
|
||||
const [selected, setSelected] = useState<[number, number] | null>(null);
|
||||
const [promotionFrom, setPromotionFrom] = useState<string | null>(null);
|
||||
const [promotionTo, setPromotionTo] = useState<string | null>(null);
|
||||
|
||||
if (!chess?.board) {
|
||||
const game = useMemo(() => {
|
||||
if (!chess?.fen) return null;
|
||||
return new Chess(chess.fen);
|
||||
}, [chess?.fen]);
|
||||
|
||||
if (!game) {
|
||||
return <div className="text-text-tertiary text-sm">Waiting for game to start...</div>;
|
||||
}
|
||||
|
||||
const myColor = chess.players.white === myPlayerId ? "white" : chess.players.black === myPlayerId ? "black" : null;
|
||||
const isMyTurn = myColor === chess.currentTurn && !isSpectator;
|
||||
|
||||
function handleSquareClick(row: number, col: number) {
|
||||
if (isSpectator || !isMyTurn) return;
|
||||
|
||||
if (selected) {
|
||||
onAction({ type: "move", from: selected, to: [row, col] });
|
||||
setSelected(null);
|
||||
} else {
|
||||
const piece = chess.board[row][col];
|
||||
if (piece && piece.color === myColor) {
|
||||
setSelected([row, col]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleForfeit() {
|
||||
onAction({ type: "forfeit" });
|
||||
}
|
||||
const turn = game.turn() === "w" ? "white" : "black";
|
||||
const isMyTurn = myColor === turn && !isSpectator;
|
||||
const boardOrientation = myColor ?? "white";
|
||||
|
||||
const opponentId = myColor === "white" ? chess.players.black : chess.players.white;
|
||||
const opponent = players.find(p => p.discordId === opponentId);
|
||||
const me = players.find(p => p.discordId === myPlayerId);
|
||||
|
||||
function onDrop(sourceSquare: string, targetSquare: string): boolean {
|
||||
if (isSpectator || !isMyTurn) return false;
|
||||
|
||||
const testGame = new Chess(chess.fen);
|
||||
|
||||
// Check if this is a promotion move
|
||||
const piece = testGame.get(sourceSquare as any);
|
||||
if (piece?.type === "p") {
|
||||
const targetRank = targetSquare[1];
|
||||
if ((piece.color === "w" && targetRank === "8") || (piece.color === "b" && targetRank === "1")) {
|
||||
setPromotionFrom(sourceSquare);
|
||||
setPromotionTo(targetSquare);
|
||||
return true; // allow the visual drop, handle promotion via dialog
|
||||
}
|
||||
}
|
||||
|
||||
const move = testGame.move({ from: sourceSquare, to: targetSquare });
|
||||
if (!move) return false;
|
||||
|
||||
onAction({ type: "move", from: sourceSquare, to: targetSquare });
|
||||
return true;
|
||||
}
|
||||
|
||||
function handlePromotion(piece: string) {
|
||||
if (promotionFrom && promotionTo) {
|
||||
// react-chessboard gives us e.g. "wQ" — extract just the piece letter lowercase
|
||||
const promotionPiece = piece[1]?.toLowerCase() ?? "q";
|
||||
onAction({ type: "move", from: promotionFrom, to: promotionTo, promotion: promotionPiece });
|
||||
}
|
||||
setPromotionFrom(null);
|
||||
setPromotionTo(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function isDraggablePiece({ piece }: { piece: string }): boolean {
|
||||
if (isSpectator || !isMyTurn) return false;
|
||||
const pieceColor = piece[0] === "w" ? "white" : "black";
|
||||
return pieceColor === myColor;
|
||||
}
|
||||
|
||||
// Highlight king in check
|
||||
const customSquareStyles: Record<string, React.CSSProperties> = {};
|
||||
if (game.inCheck()) {
|
||||
// Find the king square of the player in check
|
||||
const board = game.board();
|
||||
const kingColor = game.turn();
|
||||
for (let r = 0; r < 8; r++) {
|
||||
for (let c = 0; c < 8; c++) {
|
||||
const sq = board[r][c];
|
||||
if (sq?.type === "k" && sq.color === kingColor) {
|
||||
const file = String.fromCharCode(97 + c);
|
||||
const rank = 8 - r;
|
||||
customSquareStyles[`${file}${rank}`] = {
|
||||
backgroundColor: "rgba(220, 38, 38, 0.45)",
|
||||
borderRadius: "50%",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<div>
|
||||
{/* Opponent info */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-6 h-6 rounded-full bg-surface flex items-center justify-center text-xs font-medium">
|
||||
{opponent?.username?.[0]?.toUpperCase() ?? "?"}
|
||||
@@ -64,30 +107,28 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
<span className="text-xs text-text-tertiary">· {myColor === "white" ? "Black" : "White"}</span>
|
||||
</div>
|
||||
|
||||
<div className="inline-grid grid-cols-8 border-2 border-border rounded overflow-hidden">
|
||||
{chess.board.map((row, r) =>
|
||||
row.map((piece, c) => {
|
||||
const isLight = (r + c) % 2 === 0;
|
||||
const isSelected = selected?.[0] === r && selected?.[1] === c;
|
||||
return (
|
||||
<button
|
||||
key={`${r}-${c}`}
|
||||
onClick={() => handleSquareClick(r, c)}
|
||||
className={`w-12 h-12 flex items-center justify-center text-2xl transition-colors ${
|
||||
isSelected
|
||||
? "bg-primary/40"
|
||||
: isLight
|
||||
? "bg-raised"
|
||||
: "bg-surface"
|
||||
} ${isMyTurn ? "cursor-pointer hover:bg-primary/20" : "cursor-default"}`}
|
||||
>
|
||||
{piece ? PIECE_SYMBOLS[piece.color][piece.type] : ""}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div className="rounded overflow-hidden border-2 border-border">
|
||||
<Chessboard
|
||||
position={chess.fen}
|
||||
onPieceDrop={onDrop}
|
||||
onPromotionPieceSelect={handlePromotion}
|
||||
boardOrientation={boardOrientation}
|
||||
isDraggablePiece={isDraggablePiece}
|
||||
boardWidth={400}
|
||||
showPromotionDialog={promotionFrom !== null}
|
||||
promotionToSquare={promotionTo as any}
|
||||
animationDuration={200}
|
||||
customSquareStyles={customSquareStyles}
|
||||
customDarkSquareStyle={{ backgroundColor: "#2D2A5F" }}
|
||||
customLightSquareStyle={{ backgroundColor: "#1E1B4B" }}
|
||||
customBoardStyle={{ borderRadius: "0" }}
|
||||
customDropSquareStyle={{ boxShadow: "inset 0 0 1px 4px rgba(139, 92, 246, 0.5)" }}
|
||||
customPremoveDarkSquareStyle={{ backgroundColor: "#4c1d95" }}
|
||||
customPremoveLightSquareStyle={{ backgroundColor: "#5b21b6" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Player info */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="w-6 h-6 rounded-full bg-surface flex items-center justify-center text-xs font-medium border-2 border-primary">
|
||||
{me?.username?.[0]?.toUpperCase() ?? "?"}
|
||||
@@ -100,6 +141,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="flex flex-col gap-3 min-w-[180px]">
|
||||
<div className="bg-card rounded-lg border border-border">
|
||||
<div className="px-4 py-2.5 border-b border-border text-xs font-semibold">Move History</div>
|
||||
@@ -121,7 +163,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
|
||||
{!isSpectator && chess.status === "playing" && (
|
||||
<button
|
||||
onClick={handleForfeit}
|
||||
onClick={() => onAction({ type: "forfeit" })}
|
||||
className="rounded-md px-3 py-2 text-sm font-medium bg-destructive/15 text-destructive hover:bg-destructive/25 transition-colors"
|
||||
>
|
||||
Forfeit
|
||||
|
||||
@@ -15,68 +15,64 @@ describe("chessPlugin", () => {
|
||||
});
|
||||
|
||||
describe("createInitialState", () => {
|
||||
it("should create a board with pieces in starting positions", () => {
|
||||
it("should create initial FEN position", () => {
|
||||
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||
expect(state.board.length).toBe(8);
|
||||
expect(state.board[0].length).toBe(8);
|
||||
expect(state.currentTurn).toBe("white");
|
||||
expect(state.fen).toBe("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1");
|
||||
expect(state.players.white).toBe(PLAYER_WHITE);
|
||||
expect(state.players.black).toBe(PLAYER_BLACK);
|
||||
expect(state.moveHistory).toEqual([]);
|
||||
expect(state.status).toBe("playing");
|
||||
});
|
||||
|
||||
it("should place white pawns on row 6 and black pawns on row 1", () => {
|
||||
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||
for (let col = 0; col < 8; col++) {
|
||||
expect(state.board[6][col]).toEqual({ type: "pawn", color: "white" });
|
||||
expect(state.board[1][col]).toEqual({ type: "pawn", color: "black" });
|
||||
}
|
||||
});
|
||||
|
||||
it("should place rooks in corners", () => {
|
||||
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||
expect(state.board[0][0]).toEqual({ type: "rook", color: "black" });
|
||||
expect(state.board[0][7]).toEqual({ type: "rook", color: "black" });
|
||||
expect(state.board[7][0]).toEqual({ type: "rook", color: "white" });
|
||||
expect(state.board[7][7]).toEqual({ type: "rook", color: "white" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAction — move", () => {
|
||||
it("should allow white pawn to move forward on white's turn", () => {
|
||||
it("should allow a legal pawn move", () => {
|
||||
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||
const result = chessPlugin.handleAction(state, { type: "move", from: [6, 4], to: [4, 4] }, PLAYER_WHITE);
|
||||
const result = chessPlugin.handleAction(state, { type: "move", from: "e2", to: "e4" }, PLAYER_WHITE);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.state.board[4][4]).toEqual({ type: "pawn", color: "white" });
|
||||
expect(result.state.board[6][4]).toBeNull();
|
||||
expect(result.state.currentTurn).toBe("black");
|
||||
expect(result.state.fen).not.toBe(state.fen);
|
||||
expect(result.state.moveHistory).toEqual(["e4"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject move when it is not the player's turn", () => {
|
||||
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||
const result = chessPlugin.handleAction(state, { type: "move", from: [1, 4], to: [3, 4] }, PLAYER_BLACK);
|
||||
const result = chessPlugin.handleAction(state, { type: "move", from: "e7", to: "e5" }, PLAYER_BLACK);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject moving opponent's piece", () => {
|
||||
it("should reject an illegal move", () => {
|
||||
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||
const result = chessPlugin.handleAction(state, { type: "move", from: [1, 4], to: [3, 4] }, PLAYER_WHITE);
|
||||
const result = chessPlugin.handleAction(state, { type: "move", from: "e2", to: "e5" }, PLAYER_WHITE);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject move from empty square", () => {
|
||||
it("should reject a non-player's move", () => {
|
||||
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||
const result = chessPlugin.handleAction(state, { type: "move", from: [4, 4], to: [3, 4] }, PLAYER_WHITE);
|
||||
const result = chessPlugin.handleAction(state, { type: "move", from: "e2", to: "e4" }, "random_player");
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject out-of-bounds coordinates", () => {
|
||||
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||
const result = chessPlugin.handleAction(state, { type: "move", from: [8, 0], to: [7, 0] }, PLAYER_WHITE);
|
||||
expect(result.ok).toBe(false);
|
||||
it("should detect checkmate", () => {
|
||||
// Scholar's mate: 1.e4 e5 2.Bc4 Nc6 3.Qh5 Nf6 4.Qxf7#
|
||||
let state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||
const moves = [
|
||||
{ player: PLAYER_WHITE, from: "e2", to: "e4" },
|
||||
{ player: PLAYER_BLACK, from: "e7", to: "e5" },
|
||||
{ player: PLAYER_WHITE, from: "f1", to: "c4" },
|
||||
{ player: PLAYER_BLACK, from: "b8", to: "c6" },
|
||||
{ player: PLAYER_WHITE, from: "d1", to: "h5" },
|
||||
{ player: PLAYER_BLACK, from: "g8", to: "f6" },
|
||||
{ player: PLAYER_WHITE, from: "h5", to: "f7" },
|
||||
];
|
||||
for (const m of moves) {
|
||||
const result = chessPlugin.handleAction(state, { type: "move", from: m.from, to: m.to }, m.player);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) state = result.state;
|
||||
}
|
||||
expect(state.status).toBe("checkmate");
|
||||
expect(state.winner).toBe(PLAYER_WHITE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,17 +89,9 @@ describe("chessPlugin", () => {
|
||||
});
|
||||
|
||||
describe("getPlayerView", () => {
|
||||
it("should return full state (chess has no hidden info)", () => {
|
||||
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||
const view = chessPlugin.getPlayerView(state, PLAYER_WHITE);
|
||||
expect(view).toEqual(state);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSpectatorView", () => {
|
||||
it("should return full state", () => {
|
||||
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||
const view = chessPlugin.getSpectatorView(state);
|
||||
const view = chessPlugin.getPlayerView(state, PLAYER_WHITE);
|
||||
expect(view).toEqual(state);
|
||||
});
|
||||
});
|
||||
@@ -111,16 +99,14 @@ describe("chessPlugin", () => {
|
||||
describe("isGameOver", () => {
|
||||
it("should return null for ongoing game", () => {
|
||||
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||
const result = chessPlugin.isGameOver!(state);
|
||||
expect(result).toBeNull();
|
||||
expect(chessPlugin.isGameOver!(state)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return winner for forfeit", () => {
|
||||
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||
state.status = "forfeit";
|
||||
state.winner = PLAYER_BLACK;
|
||||
const result = chessPlugin.isGameOver!(state);
|
||||
expect(result).toEqual({ winner: PLAYER_BLACK, reason: "forfeit" });
|
||||
expect(chessPlugin.isGameOver!(state)).toEqual({ winner: PLAYER_BLACK, reason: "forfeit" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,87 +1,18 @@
|
||||
import { Chess } from "chess.js";
|
||||
import type { GamePlugin, GameResult, GameOverResult } from "../types";
|
||||
import type { ChessState, ChessAction, Piece, PieceColor, PieceType } from "./types";
|
||||
import type { ChessState, ChessAction } from "./types";
|
||||
|
||||
const BACK_ROW: PieceType[] = ["rook", "knight", "bishop", "queen", "king", "bishop", "knight", "rook"];
|
||||
|
||||
function createStartingBoard(): (Piece | null)[][] {
|
||||
const board: (Piece | null)[][] = Array.from({ length: 8 }, () => Array(8).fill(null));
|
||||
for (let col = 0; col < 8; col++) {
|
||||
board[0][col] = { type: BACK_ROW[col], color: "black" };
|
||||
board[1][col] = { type: "pawn", color: "black" };
|
||||
board[6][col] = { type: "pawn", color: "white" };
|
||||
board[7][col] = { type: BACK_ROW[col], color: "white" };
|
||||
}
|
||||
return board;
|
||||
}
|
||||
|
||||
function inBounds(row: number, col: number): boolean {
|
||||
return row >= 0 && row < 8 && col >= 0 && col < 8;
|
||||
}
|
||||
|
||||
function getPlayerColor(state: ChessState, playerId: string): PieceColor | null {
|
||||
function getPlayerColor(state: ChessState, playerId: string): "white" | "black" | null {
|
||||
if (state.players.white === playerId) return "white";
|
||||
if (state.players.black === playerId) return "black";
|
||||
return null;
|
||||
}
|
||||
|
||||
function isValidMove(board: (Piece | null)[][], from: [number, number], to: [number, number], piece: Piece): boolean {
|
||||
const [fromRow, fromCol] = from;
|
||||
const [toRow, toCol] = to;
|
||||
const target = board[toRow][toCol];
|
||||
if (target && target.color === piece.color) return false;
|
||||
const rowDiff = toRow - fromRow;
|
||||
const colDiff = toCol - fromCol;
|
||||
const absRow = Math.abs(rowDiff);
|
||||
const absCol = Math.abs(colDiff);
|
||||
|
||||
switch (piece.type) {
|
||||
case "pawn": {
|
||||
const direction = piece.color === "white" ? -1 : 1;
|
||||
const startRow = piece.color === "white" ? 6 : 1;
|
||||
if (colDiff === 0 && rowDiff === direction && !target) return true;
|
||||
if (colDiff === 0 && rowDiff === 2 * direction && fromRow === startRow && !target && !board[fromRow + direction][fromCol]) return true;
|
||||
if (absCol === 1 && rowDiff === direction && target) return true;
|
||||
return false;
|
||||
}
|
||||
case "rook":
|
||||
if (fromRow !== toRow && fromCol !== toCol) return false;
|
||||
return isPathClear(board, from, to);
|
||||
case "knight":
|
||||
return (absRow === 2 && absCol === 1) || (absRow === 1 && absCol === 2);
|
||||
case "bishop":
|
||||
if (absRow !== absCol) return false;
|
||||
return isPathClear(board, from, to);
|
||||
case "queen":
|
||||
if (fromRow !== toRow && fromCol !== toCol && absRow !== absCol) return false;
|
||||
return isPathClear(board, from, to);
|
||||
case "king":
|
||||
return absRow <= 1 && absCol <= 1;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPathClear(board: (Piece | null)[][], from: [number, number], to: [number, number]): boolean {
|
||||
const [fromRow, fromCol] = from;
|
||||
const [toRow, toCol] = to;
|
||||
const rowStep = Math.sign(toRow - fromRow);
|
||||
const colStep = Math.sign(toCol - fromCol);
|
||||
let row = fromRow + rowStep;
|
||||
let col = fromCol + colStep;
|
||||
while (row !== toRow || col !== toCol) {
|
||||
if (board[row][col]) return false;
|
||||
row += rowStep;
|
||||
col += colStep;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function toAlgebraic(from: [number, number], to: [number, number], piece: Piece, captured: boolean): string {
|
||||
const files = "abcdefgh";
|
||||
const prefix = piece.type === "pawn" ? "" : piece.type[0].toUpperCase();
|
||||
const cap = captured ? "x" : "";
|
||||
const fromStr = piece.type === "pawn" && captured ? files[from[1]] : "";
|
||||
return `${prefix}${fromStr}${cap}${files[to[1]]}${8 - to[0]}`;
|
||||
function deriveStatus(game: Chess): ChessState["status"] {
|
||||
if (game.isCheckmate()) return "checkmate";
|
||||
if (game.isStalemate()) return "stalemate";
|
||||
if (game.isDraw()) return "draw";
|
||||
return "playing";
|
||||
}
|
||||
|
||||
export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
|
||||
@@ -91,9 +22,9 @@ export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
|
||||
maxPlayers: 2,
|
||||
|
||||
createInitialState(players: string[]): ChessState {
|
||||
const game = new Chess();
|
||||
return {
|
||||
board: createStartingBoard(),
|
||||
currentTurn: "white",
|
||||
fen: game.fen(),
|
||||
players: { white: players[0], black: players[1] },
|
||||
moveHistory: [],
|
||||
status: "playing",
|
||||
@@ -105,41 +36,56 @@ export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
|
||||
if (state.status !== "playing") {
|
||||
return { ok: false, error: "Game is already over" };
|
||||
}
|
||||
|
||||
if (action.type === "forfeit") {
|
||||
const color = getPlayerColor(state, playerId);
|
||||
if (!color) return { ok: false, error: "You are not a player in this game" };
|
||||
const winner = color === "white" ? state.players.black : state.players.white;
|
||||
return { ok: true, state: { ...state, status: "forfeit", winner } };
|
||||
}
|
||||
|
||||
if (action.type === "move") {
|
||||
const { from, to } = action;
|
||||
if (!inBounds(from[0], from[1]) || !inBounds(to[0], to[1])) {
|
||||
return { ok: false, error: "Coordinates out of bounds" };
|
||||
}
|
||||
const piece = state.board[from[0]][from[1]];
|
||||
if (!piece) return { ok: false, error: "No piece at source square" };
|
||||
const playerColor = getPlayerColor(state, playerId);
|
||||
if (!playerColor) return { ok: false, error: "You are not a player in this game" };
|
||||
if (playerColor !== state.currentTurn) return { ok: false, error: "It is not your turn" };
|
||||
if (piece.color !== playerColor) return { ok: false, error: "That is not your piece" };
|
||||
if (!isValidMove(state.board, from, to, piece)) return { ok: false, error: "Invalid move" };
|
||||
|
||||
const newBoard = state.board.map(row => [...row]);
|
||||
const captured = newBoard[to[0]][to[1]];
|
||||
newBoard[to[0]][to[1]] = piece;
|
||||
newBoard[from[0]][from[1]] = null;
|
||||
const notation = toAlgebraic(from, to, piece, captured !== null);
|
||||
const nextTurn: PieceColor = state.currentTurn === "white" ? "black" : "white";
|
||||
const game = new Chess(state.fen);
|
||||
const turn = game.turn() === "w" ? "white" : "black";
|
||||
if (playerColor !== turn) return { ok: false, error: "It is not your turn" };
|
||||
|
||||
let move;
|
||||
try {
|
||||
move = game.move({
|
||||
from: action.from,
|
||||
to: action.to,
|
||||
promotion: action.promotion ?? "q",
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, error: "Invalid move" };
|
||||
}
|
||||
|
||||
if (!move) return { ok: false, error: "Invalid move" };
|
||||
|
||||
const status = deriveStatus(game);
|
||||
const winner = status === "checkmate"
|
||||
? (turn === "white" ? state.players.white : state.players.black)
|
||||
: null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
state: { ...state, board: newBoard, currentTurn: nextTurn, moveHistory: [...state.moveHistory, notation] },
|
||||
state: {
|
||||
...state,
|
||||
fen: game.fen(),
|
||||
moveHistory: [...state.moveHistory, move.san],
|
||||
status,
|
||||
winner,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: false, error: "Unknown action type" };
|
||||
},
|
||||
|
||||
getPlayerView(state: ChessState, _playerId: string): ChessState { return state; },
|
||||
getPlayerView(state: ChessState): ChessState { return state; },
|
||||
getSpectatorView(state: ChessState): ChessState { return state; },
|
||||
|
||||
isGameOver(state: ChessState): GameOverResult | null {
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
export type PieceColor = "white" | "black";
|
||||
export type PieceType = "pawn" | "rook" | "knight" | "bishop" | "queen" | "king";
|
||||
|
||||
export interface Piece {
|
||||
type: PieceType;
|
||||
color: PieceColor;
|
||||
}
|
||||
|
||||
export interface ChessState {
|
||||
board: (Piece | null)[][];
|
||||
currentTurn: PieceColor;
|
||||
fen: string;
|
||||
players: { white: string; black: string };
|
||||
moveHistory: string[];
|
||||
status: "playing" | "checkmate" | "stalemate" | "forfeit";
|
||||
status: "playing" | "checkmate" | "stalemate" | "draw" | "forfeit";
|
||||
winner: string | null;
|
||||
}
|
||||
|
||||
export type ChessAction =
|
||||
| { type: "move"; from: [number, number]; to: [number, number] }
|
||||
| { type: "move"; from: string; to: string; promotion?: string }
|
||||
| { type: "forfeit" };
|
||||
|
||||
Reference in New Issue
Block a user