import { Chess } from "chess.js"; import type { GamePlugin, GameResult, GameOverResult } from "../types"; import type { ChessState, ChessAction, ChessPlayerView, ChessSpectatorView, ChessClock, } from "./chess.types"; import { TIME_CONTROLS } from "./chess.types"; function colorOfPlayer(state: ChessState, playerId: string): "white" | "black" | null { const isWhite = state.players.white === playerId; const isBlack = state.players.black === playerId; if (isWhite && isBlack) { // Solo test mode — same player controls both sides, return current turn return currentTurn(state.fen); } if (isWhite) return "white"; if (isBlack) return "black"; return null; } function currentTurn(fen: string): "white" | "black" { // FEN active color field is the second space-separated token return fen.split(" ")[1] === "w" ? "white" : "black"; } function applyClockTick(clock: ChessClock, turn: "white" | "black"): ChessClock { const now = Date.now(); const elapsed = now - clock.lastMoveAt; const remaining = Math.max(0, clock[turn] - elapsed); return { ...clock, [turn]: remaining, lastMoveAt: now }; } function addIncrement(clock: ChessClock, color: "white" | "black"): ChessClock { return { ...clock, [color]: clock[color] + clock.increment }; } export const chessPlugin: GamePlugin = { slug: "chess", name: "Chess", minPlayers: 2, maxPlayers: 2, createInitialState(players: string[], options?: Record): ChessState { const game = new Chess(); const timeControlKey = (options?.timeControl as string) ?? "blitz_5_3"; const tc = TIME_CONTROLS[timeControlKey] ?? TIME_CONTROLS["blitz_5_3"]!; // Randomly assign colors const shuffled: [string, string] = Math.random() < 0.5 ? [players[0]!, players[1]!] : [players[1]!, players[0]!]; const clock: ChessClock | null = tc.time > 0 ? { white: tc.time, black: tc.time, increment: tc.increment, lastMoveAt: Date.now() } : null; return { fen: game.fen(), pgn: game.pgn(), players: { white: shuffled[0], black: shuffled[1] }, clock, drawOffer: null, result: null, resultReason: null, moveHistory: [], }; }, handleAction(state: ChessState, action: ChessAction, playerId: string): GameResult { if (state.result) return { ok: false, error: "Game is already over" }; const playerColor = colorOfPlayer(state, playerId); if (!playerColor) return { ok: false, error: "You are not a player in this game" }; switch (action.type) { case "move": { const turn = currentTurn(state.fen); if (playerColor !== turn) return { ok: false, error: "It's not your turn" }; // Check clock timeout before allowing move if (state.clock) { const ticked = applyClockTick(state.clock, turn); if (ticked[turn] <= 0) { return { ok: true, state: { ...state, clock: ticked, result: turn === "white" ? "black" : "white", resultReason: "Timeout", }, }; } } const game = new Chess(state.fen); try { const move = game.move({ from: action.from, to: action.to, promotion: action.promotion }); if (!move) return { ok: false, error: "Illegal move" }; } catch { return { ok: false, error: "Illegal move" }; } let newClock = state.clock; if (newClock) { newClock = applyClockTick(newClock, turn); newClock = addIncrement(newClock, turn); } const moveEntry = { from: action.from, to: action.to, san: game.history().slice(-1)[0]!, color: turn === "white" ? "w" as const : "b" as const, }; let result: ChessState["result"] = null; let resultReason: string | null = null; if (game.isCheckmate()) { result = turn; resultReason = "Checkmate"; } else if (game.isStalemate()) { result = "draw"; resultReason = "Stalemate"; } else if (game.isThreefoldRepetition()) { result = "draw"; resultReason = "Threefold repetition"; } else if (game.isInsufficientMaterial()) { result = "draw"; resultReason = "Insufficient material"; } else if (game.isDraw()) { result = "draw"; resultReason = "Draw (50-move rule)"; } return { ok: true, state: { ...state, fen: game.fen(), pgn: game.pgn(), clock: newClock, drawOffer: null, // any pending draw offer is cleared on move result, resultReason, moveHistory: [...state.moveHistory, moveEntry], }, }; } case "resign": { return { ok: true, state: { ...state, result: playerColor === "white" ? "black" : "white", resultReason: "Resignation", }, }; } case "offer_draw": { if (state.drawOffer === playerColor) return { ok: false, error: "You already offered a draw" }; return { ok: true, state: { ...state, drawOffer: playerColor }, }; } case "accept_draw": { const opponentColor = playerColor === "white" ? "black" : "white"; if (state.drawOffer !== opponentColor) return { ok: false, error: "No draw offer to accept" }; return { ok: true, state: { ...state, result: "draw", resultReason: "Agreement", drawOffer: null, }, }; } case "decline_draw": { const opponentColor = playerColor === "white" ? "black" : "white"; if (state.drawOffer !== opponentColor) return { ok: false, error: "No draw offer to decline" }; return { ok: true, state: { ...state, drawOffer: null }, }; } case "claim_timeout": { if (!state.clock) return { ok: false, error: "No clock in this game" }; const opponentColor = playerColor === "white" ? "black" : "white"; const ticked = applyClockTick(state.clock, opponentColor); if (ticked[opponentColor] > 0) return { ok: false, error: "Opponent still has time remaining" }; return { ok: true, state: { ...state, clock: ticked, result: playerColor, resultReason: "Timeout", }, }; } default: return { ok: false, error: "Unknown action type" }; } }, getPlayerView(state: ChessState, playerId: string): ChessPlayerView { const myColor = colorOfPlayer(state, playerId) ?? "white"; const turn = currentTurn(state.fen); let clockView: ChessPlayerView["clock"] = null; if (state.clock) { // Compute live remaining time for display const elapsed = state.result ? 0 : Date.now() - state.clock.lastMoveAt; const activeRemaining = Math.max(0, state.clock[turn] - elapsed); const inactiveColor = turn === "white" ? "black" : "white"; clockView = { white: turn === "white" ? activeRemaining : state.clock.white, black: turn === "black" ? activeRemaining : state.clock.black, increment: state.clock.increment, activeColor: state.result ? null : turn, }; } // Compute legal moves for the current player const game = new Chess(state.fen); const legalMoves = myColor === turn && !state.result ? game.moves({ verbose: true }).map(m => ({ from: m.from, to: m.to, ...(m.promotion ? { promotion: m.promotion } : {}), })) : []; return { fen: state.fen, pgn: state.pgn, myColor, turn, clock: clockView, drawOffer: state.drawOffer, result: state.result, resultReason: state.resultReason, moveHistory: state.moveHistory, isCheck: game.isCheck(), legalMoves, }; }, getSpectatorView(state: ChessState): ChessSpectatorView { const turn = currentTurn(state.fen); const game = new Chess(state.fen); let clockView: ChessSpectatorView["clock"] = null; if (state.clock) { const elapsed = state.result ? 0 : Date.now() - state.clock.lastMoveAt; const activeRemaining = Math.max(0, state.clock[turn] - elapsed); clockView = { white: turn === "white" ? activeRemaining : state.clock.white, black: turn === "black" ? activeRemaining : state.clock.black, increment: state.clock.increment, activeColor: state.result ? null : turn, }; } return { fen: state.fen, pgn: state.pgn, players: state.players, turn, clock: clockView, drawOffer: state.drawOffer, result: state.result, resultReason: state.resultReason, moveHistory: state.moveHistory, isCheck: game.isCheck(), }; }, isGameOver(state: ChessState): GameOverResult | null { if (!state.result) return null; let winner: string | null = null; if (state.result === "white") winner = state.players.white; else if (state.result === "black") winner = state.players.black; return { winner, reason: state.resultReason ?? "Game over", }; }, onPlayerDisconnect(state: ChessState, playerId: string): ChessState { if (state.result) return state; const color = colorOfPlayer(state, playerId); if (!color) return state; return { ...state, result: color === "white" ? "black" : "white", resultReason: "Opponent disconnected", }; }, };