Files
aurorabot/shared/games/chess/chess.plugin.ts
syntaxbullet 25a0bd3431
Some checks failed
Deploy to Production / test (push) Failing after 29s
Sign panel sessions and isolate test runs
- Replace in-memory auth sessions with signed cookies and signed OAuth state
- Add auth route coverage and update panel/web server wiring
- Switch test script to per-file Bun processes and clean up type checks
2026-04-09 21:44:05 +02:00

312 lines
11 KiB
TypeScript

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<ChessState, ChessAction> = {
slug: "chess",
name: "Chess",
minPlayers: 2,
maxPlayers: 2,
createInitialState(players: string[], options?: Record<string, unknown>): 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<ChessState> {
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",
};
},
};