feat(games): implement chess plugin with tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
143
shared/games/chess/plugin.test.ts
Normal file
143
shared/games/chess/plugin.test.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import { chessPlugin } from "./plugin";
|
||||||
|
import { gameRegistry } from "../registry";
|
||||||
|
|
||||||
|
const PLAYER_WHITE = "player1";
|
||||||
|
const PLAYER_BLACK = "player2";
|
||||||
|
|
||||||
|
describe("chessPlugin", () => {
|
||||||
|
describe("metadata", () => {
|
||||||
|
it("should have correct slug and player counts", () => {
|
||||||
|
expect(chessPlugin.slug).toBe("chess");
|
||||||
|
expect(chessPlugin.minPlayers).toBe(2);
|
||||||
|
expect(chessPlugin.maxPlayers).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createInitialState", () => {
|
||||||
|
it("should create a board with pieces in starting positions", () => {
|
||||||
|
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.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", () => {
|
||||||
|
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||||
|
const result = chessPlugin.handleAction(state, { type: "move", from: [6, 4], to: [4, 4] }, 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject moving opponent's piece", () => {
|
||||||
|
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||||
|
const result = chessPlugin.handleAction(state, { type: "move", from: [1, 4], to: [3, 4] }, PLAYER_WHITE);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject move from empty square", () => {
|
||||||
|
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||||
|
const result = chessPlugin.handleAction(state, { type: "move", from: [4, 4], to: [3, 4] }, PLAYER_WHITE);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleAction — forfeit", () => {
|
||||||
|
it("should end the game with the other player as winner", () => {
|
||||||
|
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||||
|
const result = chessPlugin.handleAction(state, { type: "forfeit" }, PLAYER_WHITE);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.state.status).toBe("forfeit");
|
||||||
|
expect(result.state.winner).toBe(PLAYER_BLACK);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
expect(view).toEqual(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onPlayerDisconnect", () => {
|
||||||
|
it("should forfeit the disconnected player", () => {
|
||||||
|
const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
|
||||||
|
const result = chessPlugin.onPlayerDisconnect!(state, PLAYER_WHITE);
|
||||||
|
expect(result.status).toBe("forfeit");
|
||||||
|
expect(result.winner).toBe(PLAYER_BLACK);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chess registration", () => {
|
||||||
|
it("should register and retrieve from gameRegistry", () => {
|
||||||
|
gameRegistry.register(chessPlugin);
|
||||||
|
expect(gameRegistry.get("chess")).toBe(chessPlugin);
|
||||||
|
expect(gameRegistry.list()).toContain(chessPlugin);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
156
shared/games/chess/plugin.ts
Normal file
156
shared/games/chess/plugin.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import type { GamePlugin, GameResult, GameOverResult } from "../types";
|
||||||
|
import type { ChessState, ChessAction, Piece, PieceColor, PieceType } 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 {
|
||||||
|
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]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
|
||||||
|
slug: "chess",
|
||||||
|
name: "Chess",
|
||||||
|
minPlayers: 2,
|
||||||
|
maxPlayers: 2,
|
||||||
|
|
||||||
|
createInitialState(players: string[]): ChessState {
|
||||||
|
return {
|
||||||
|
board: createStartingBoard(),
|
||||||
|
currentTurn: "white",
|
||||||
|
players: { white: players[0], black: players[1] },
|
||||||
|
moveHistory: [],
|
||||||
|
status: "playing",
|
||||||
|
winner: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
handleAction(state: ChessState, action: ChessAction, playerId: string): GameResult<ChessState> {
|
||||||
|
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";
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
state: { ...state, board: newBoard, currentTurn: nextTurn, moveHistory: [...state.moveHistory, notation] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: false, error: "Unknown action type" };
|
||||||
|
},
|
||||||
|
|
||||||
|
getPlayerView(state: ChessState, _playerId: string): ChessState { return state; },
|
||||||
|
getSpectatorView(state: ChessState): ChessState { return state; },
|
||||||
|
|
||||||
|
isGameOver(state: ChessState): GameOverResult | null {
|
||||||
|
if (state.status === "playing") return null;
|
||||||
|
return { winner: state.winner, reason: state.status };
|
||||||
|
},
|
||||||
|
|
||||||
|
onPlayerDisconnect(state: ChessState, playerId: string): ChessState {
|
||||||
|
const color = getPlayerColor(state, playerId);
|
||||||
|
if (!color) return state;
|
||||||
|
const winner = color === "white" ? state.players.black : state.players.white;
|
||||||
|
return { ...state, status: "forfeit", winner };
|
||||||
|
},
|
||||||
|
};
|
||||||
20
shared/games/chess/types.ts
Normal file
20
shared/games/chess/types.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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;
|
||||||
|
players: { white: string; black: string };
|
||||||
|
moveHistory: string[];
|
||||||
|
status: "playing" | "checkmate" | "stalemate" | "forfeit";
|
||||||
|
winner: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChessAction =
|
||||||
|
| { type: "move"; from: [number, number]; to: [number, number] }
|
||||||
|
| { type: "forfeit" };
|
||||||
Reference in New Issue
Block a user