Some checks failed
Deploy to Production / test (push) Failing after 29s
- 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
312 lines
11 KiB
TypeScript
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",
|
|
};
|
|
},
|
|
};
|