diff --git a/shared/games/chess/plugin.test.ts b/shared/games/chess/plugin.test.ts new file mode 100644 index 0000000..9dc872b --- /dev/null +++ b/shared/games/chess/plugin.test.ts @@ -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); + }); + }); +}); diff --git a/shared/games/chess/plugin.ts b/shared/games/chess/plugin.ts new file mode 100644 index 0000000..bc2cae5 --- /dev/null +++ b/shared/games/chess/plugin.ts @@ -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 = { + 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 { + 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 }; + }, +}; diff --git a/shared/games/chess/types.ts b/shared/games/chess/types.ts new file mode 100644 index 0000000..e82c5a6 --- /dev/null +++ b/shared/games/chess/types.ts @@ -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" };