From f796cac6be45f2a101757575b64358588a9d2d4a Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 10 Apr 2026 10:19:33 +0200 Subject: [PATCH] Add chess premoves and time control metadata - pass chess room time control to the client - add premove handling and richer chess board UI - update join result typing for room options --- api/src/games/GameServer.ts | 7 +- api/src/games/types.ts | 2 +- panel/src/games/chess/ChessGame.tsx | 1078 ++++++++++++++++++--------- panel/src/games/registry.ts | 2 +- panel/src/lib/useGameRoom.ts | 2 +- 5 files changed, 743 insertions(+), 348 deletions(-) diff --git a/api/src/games/GameServer.ts b/api/src/games/GameServer.ts index 86f0310..bca6199 100644 --- a/api/src/games/GameServer.ts +++ b/api/src/games/GameServer.ts @@ -244,7 +244,12 @@ export class GameServer { this.replacedConnections.delete(discordId); // Build room options for the client - const roomOptions = room?.betAmount ? { betAmount: room.betAmount } : undefined; + const roomOptions = room + ? { + ...(room.betAmount > 0 ? { betAmount: room.betAmount } : {}), + ...(typeof room.options?.timeControl === "string" ? { timeControl: room.options.timeControl } : {}), + } + : undefined; // Respond with JOIN_RESULT ws.send(JSON.stringify({ diff --git a/api/src/games/types.ts b/api/src/games/types.ts index 194533b..74c5ea1 100644 --- a/api/src/games/types.ts +++ b/api/src/games/types.ts @@ -58,7 +58,7 @@ export type GameWsServerMessage = | { type: "GAME_STARTED"; roomId: string; state: unknown } | { type: "GAME_ENDED"; roomId: string; winner: string | null; reason: string; payout?: { amount: number; refunded?: boolean } } | { type: "ROOM_CREATED"; roomId: string; gameSlug: string } - | { type: "JOIN_RESULT"; roomId: string; joinedAs: "player" | "spectator"; roomStatus: "waiting" | "playing" | "finished"; players: PlayerInfo[]; spectators: PlayerInfo[]; state?: unknown; roomOptions?: { betAmount?: number } } + | { type: "JOIN_RESULT"; roomId: string; joinedAs: "player" | "spectator"; roomStatus: "waiting" | "playing" | "finished"; players: PlayerInfo[]; spectators: PlayerInfo[]; state?: unknown; roomOptions?: { betAmount?: number; timeControl?: string } } | { type: "ROUND_SETTLED"; roomId: string; payouts: Record } | { type: "SESSION_REPLACED"; roomId: string } | { type: "ERROR"; message: string }; diff --git a/panel/src/games/chess/ChessGame.tsx b/panel/src/games/chess/ChessGame.tsx index 99243a5..c003f97 100644 --- a/panel/src/games/chess/ChessGame.tsx +++ b/panel/src/games/chess/ChessGame.tsx @@ -2,12 +2,10 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Chessboard } from "react-chessboard"; import { Chess } from "chess.js"; import type { Square } from "chess.js"; +import { Check, Clock3, Eye, Flag, Handshake, RotateCw, TimerReset, X } from "lucide-react"; import type { GameUIProps } from "../registry"; -import { Flag, Handshake, X, Check, Clock } from "lucide-react"; import { chessPieces } from "./pieces"; -// ── Types matching server ChessPlayerView / ChessSpectatorView ── - interface ChessClockView { white: number; black: number; @@ -36,12 +34,36 @@ interface SpectatorView extends ChessViewBase { players: { white: string; black: string }; } +interface MoveIntent { + from: Square; + to: Square; + promotion?: string; + mode: "move" | "premove"; +} + +interface LocalNotice { + tone: "info" | "success" | "warning"; + text: string; +} + +const FILES = ["a", "b", "c", "d", "e", "f", "g", "h"] as const; +const CHESS_TIME_CONTROL_LABELS: Record = { + bullet_1_0: "Bullet 1+0", + bullet_2_1: "Bullet 2+1", + blitz_3_0: "Blitz 3+0", + blitz_3_2: "Blitz 3+2", + blitz_5_0: "Blitz 5+0", + blitz_5_3: "Blitz 5+3", + rapid_10_0: "Rapid 10+0", + rapid_15_10: "Rapid 15+10", + classical_30_0: "Classical 30+0", + none: "No Clock", +}; + function isPlayerView(state: unknown): state is PlayerView { return typeof state === "object" && state !== null && "myColor" in state; } -// ── Clock Display ── - function formatTime(ms: number): string { if (ms <= 0) return "0:00"; const totalSeconds = Math.ceil(ms / 1000); @@ -55,41 +77,199 @@ function formatTime(ms: number): string { return `${minutes}:${seconds.toString().padStart(2, "0")}`; } -function ChessClock({ time, isActive, isLow, label }: { - time: number; - isActive: boolean; - isLow: boolean; - label: string; +function colorLabel(color: "white" | "black"): string { + return color === "white" ? "White" : "Black"; +} + +function squareToCoords(square: Square): [number, number] { + return [square.charCodeAt(0) - 97, Number(square[1]) - 1]; +} + +function coordsToSquare(file: number, rank: number): Square | null { + if (file < 0 || file > 7 || rank < 0 || rank > 7) return null; + return `${FILES[file]}${rank + 1}` as Square; +} + +function appendBoxShadow(style: React.CSSProperties | undefined, shadow: string): React.CSSProperties { + return { + ...style, + boxShadow: style?.boxShadow ? `${style.boxShadow}, ${shadow}` : shadow, + }; +} + +function formatMoveIntent(intent: Pick): string { + const promotion = intent.promotion ? `=${intent.promotion.toUpperCase()}` : ""; + return `${intent.from.toUpperCase()}-${intent.to.toUpperCase()}${promotion}`; +} + +function isPromotionMove(pieceType: string | undefined, pieceColor: "w" | "b" | undefined, to: Square): boolean { + if (pieceType !== "p" || !pieceColor) return false; + return (pieceColor === "w" && to[1] === "8") || (pieceColor === "b" && to[1] === "1"); +} + +function getPremoveTargets(game: Chess, from: Square, myColor: "white" | "black"): Square[] { + const piece = game.get(from); + const colorCode = myColor === "white" ? "w" : "b"; + if (!piece || piece.color !== colorCode) return []; + + const targets = new Set(); + const [file, rank] = squareToCoords(from); + const add = (target: Square | null) => { + if (!target) return false; + const occupant = game.get(target); + if (occupant?.color === colorCode) return false; + targets.add(target); + return occupant == null; + }; + + switch (piece.type) { + case "n": { + for (const [df, dr] of [[1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, -1], [-2, 1], [-1, 2]] as const) { + add(coordsToSquare(file + df, rank + dr)); + } + break; + } + + case "k": { + for (const [df, dr] of [[1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1]] as const) { + add(coordsToSquare(file + df, rank + dr)); + } + + const castling = game.fen().split(" ")[2] ?? "-"; + if (myColor === "white") { + if (castling.includes("K") && game.get("f1")?.color !== colorCode && game.get("g1")?.color !== colorCode) { + targets.add("g1"); + } + if (castling.includes("Q") && game.get("b1")?.color !== colorCode && game.get("c1")?.color !== colorCode && game.get("d1")?.color !== colorCode) { + targets.add("c1"); + } + } else { + if (castling.includes("k") && game.get("f8")?.color !== colorCode && game.get("g8")?.color !== colorCode) { + targets.add("g8"); + } + if (castling.includes("q") && game.get("b8")?.color !== colorCode && game.get("c8")?.color !== colorCode && game.get("d8")?.color !== colorCode) { + targets.add("c8"); + } + } + break; + } + + case "p": { + const direction = myColor === "white" ? 1 : -1; + const startRank = myColor === "white" ? 1 : 6; + const oneForward = coordsToSquare(file, rank + direction); + if (add(oneForward) && rank === startRank) { + add(coordsToSquare(file, rank + direction * 2)); + } + + add(coordsToSquare(file - 1, rank + direction)); + add(coordsToSquare(file + 1, rank + direction)); + break; + } + + case "b": + case "r": + case "q": { + const directions: Array<[number, number]> = []; + if (piece.type === "b" || piece.type === "q") directions.push([1, 1], [1, -1], [-1, 1], [-1, -1]); + if (piece.type === "r" || piece.type === "q") directions.push([1, 0], [-1, 0], [0, 1], [0, -1]); + + for (const [df, dr] of directions) { + let nextFile = file + df; + let nextRank = rank + dr; + while (true) { + const target = coordsToSquare(nextFile, nextRank); + if (!target) break; + const shouldContinue = add(target); + if (!shouldContinue) break; + nextFile += df; + nextRank += dr; + } + } + break; + } + } + + return Array.from(targets); +} + +function PlayerCard({ + name, + color, + isTurn, + isSelf, + time, + clockActive, + clockLow, +}: { + name: string; + color: "white" | "black"; + isTurn: boolean; + isSelf: boolean; + time: number | null; + clockActive: boolean; + clockLow: boolean; }) { return ( -
- - {formatTime(time)} - {label} +
+
+
+
+ {colorLabel(color)} + {isSelf && You} + {isTurn && Turn} +
+
{name}
+
+ + {time != null && ( +
+
+ + Clock +
+
{formatTime(time)}
+
+ )} +
); } -// ── Move History ── - -function MoveHistory({ moves, containerRef }: { +function MoveHistory({ + moves, + containerRef, +}: { moves: { san: string; color: "w" | "b" }[]; containerRef: React.RefObject; }) { - const pairs: { number: number; white?: string; black?: string }[] = []; + const pairs: { number: number; white?: string; black?: string; isLatest: boolean }[] = []; + for (let i = 0; i < moves.length; i++) { - const moveNum = Math.floor(i / 2) + 1; + const moveNumber = Math.floor(i / 2) + 1; if (i % 2 === 0) { - pairs.push({ number: moveNum, white: moves[i]!.san }); + pairs.push({ + number: moveNumber, + white: moves[i]!.san, + isLatest: i === moves.length - 1, + }); } else { const pair = pairs[pairs.length - 1]; - if (pair) pair.black = moves[i]!.san; + if (pair) { + pair.black = moves[i]!.san; + pair.isLatest = i === moves.length - 1; + } } } @@ -97,133 +277,173 @@ function MoveHistory({ moves, containerRef }: { if (containerRef.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight; } - }, [moves.length]); + }, [moves.length, containerRef]); if (pairs.length === 0) { return ( -
+
No moves yet
); } return ( -
- {pairs.map(p => ( -
- {p.number}. - {p.white ?? ""} - {p.black ?? ""} +
+ {pairs.map(pair => ( +
+ {pair.number}. + + {pair.white ?? ""} + + + {pair.black ?? ""} +
))}
); } -// ── Main Component ── - -export function ChessGame({ state, myPlayerId, isSpectator, onAction, players }: GameUIProps) { +export function ChessGame({ state, myPlayerId, isSpectator, onAction, players, roomOptions }: GameUIProps) { const view = state as PlayerView | SpectatorView; - const playerView = isPlayerView(state) ? state as PlayerView : null; + const playerView = isPlayerView(state) ? state : null; const myColor = playerView?.myColor ?? "white"; - // Solo mode: both players are the same user — myColor flips each turn, so lock orientation to white const isSoloMode = !isSpectator && players.length === 2 && players[0]?.discordId === players[1]?.discordId; - const boardOrientation = isSpectator || isSoloMode ? "white" : myColor; + const defaultOrientation: "white" | "black" = isSpectator || isSoloMode ? "white" : myColor; + const [boardOrientation, setBoardOrientation] = useState<"white" | "black">(defaultOrientation); const isMyTurn = playerView ? view.turn === playerView.myColor : false; const isGameOver = view.result !== null; const moveHistoryRef = useRef(null); + const board = useMemo(() => new Chess(view.fen), [view.fen]); - // Selected square for click-to-move const [selectedSquare, setSelectedSquare] = useState(null); - const [promotionSquare, setPromotionSquare] = useState<{ from: Square; to: Square } | null>(null); - - // Live clock state (client-side countdown for smooth display) + const [pendingPromotion, setPendingPromotion] = useState(null); + const [queuedMove, setQueuedMove] = useState(null); const [liveClock, setLiveClock] = useState(view.clock); - const lastServerClock = useRef(view.clock); + const [localNotice, setLocalNotice] = useState(null); + + useEffect(() => { + setBoardOrientation(defaultOrientation); + }, [defaultOrientation]); - // Sync from server useEffect(() => { - lastServerClock.current = view.clock; setLiveClock(view.clock); }, [view.clock?.white, view.clock?.black, view.clock?.activeColor]); - // Client-side clock tick (100ms intervals for smooth countdown) useEffect(() => { if (!liveClock || !liveClock.activeColor || isGameOver) return; const interval = setInterval(() => { setLiveClock(prev => { if (!prev || !prev.activeColor) return prev; - const active = prev.activeColor; - const remaining = Math.max(0, prev[active] - 100); - if (remaining <= 0 && !isSpectator && active !== myColor) { - // Opponent ran out — claim timeout + const activeColor = prev.activeColor; + const remaining = Math.max(0, prev[activeColor] - 100); + + if (remaining <= 0 && !isSpectator && activeColor !== myColor) { onAction({ type: "claim_timeout" }); } - return { ...prev, [active]: remaining }; + + return { ...prev, [activeColor]: remaining }; }); }, 100); return () => clearInterval(interval); }, [liveClock?.activeColor, isGameOver, isSpectator, myColor, onAction]); - // Compute legal move squares for highlighting - const legalMovesForSquare = useMemo(() => { - if (!playerView || !selectedSquare || isGameOver || !isMyTurn) return []; - return playerView.legalMoves - .filter(m => m.from === selectedSquare) - .map(m => m.to); - }, [playerView, selectedSquare, isGameOver, isMyTurn]); + useEffect(() => { + if (!localNotice) return; + const timeout = setTimeout(() => setLocalNotice(null), 2500); + return () => clearTimeout(timeout); + }, [localNotice]); + + useEffect(() => { + if (!playerView || !queuedMove || !isMyTurn || isGameOver) return; + + const isLegal = playerView.legalMoves.some(move => + move.from === queuedMove.from && + move.to === queuedMove.to && + (queuedMove.promotion ? move.promotion === queuedMove.promotion : !move.promotion), + ); + + if (isLegal) { + const moveToSend = queuedMove; + setQueuedMove(null); + setSelectedSquare(null); + setLocalNotice({ tone: "success", text: `Premove played: ${formatMoveIntent(moveToSend)}` }); + onAction({ type: "move", from: moveToSend.from, to: moveToSend.to, promotion: moveToSend.promotion }); + return; + } + + setQueuedMove(null); + setLocalNotice({ tone: "warning", text: "Premove cancelled because the position changed." }); + }, [playerView, queuedMove, isMyTurn, isGameOver, onAction]); + + useEffect(() => { + setSelectedSquare(null); + setPendingPromotion(null); + }, [view.turn]); + + const selectedTargets = useMemo(() => { + if (!playerView || !selectedSquare || isGameOver) return []; + + if (isMyTurn) { + return playerView.legalMoves + .filter(move => move.from === selectedSquare) + .map(move => move.to as Square); + } + + return getPremoveTargets(board, selectedSquare, myColor); + }, [playerView, selectedSquare, isGameOver, isMyTurn, board, myColor]); - // Build custom square styles const customSquareStyles = useMemo(() => { const styles: Record = {}; + const latestMove = view.moveHistory.length > 0 ? view.moveHistory[view.moveHistory.length - 1] : null; + + if (latestMove) { + styles[latestMove.from] = { ...styles[latestMove.from], backgroundColor: "rgba(233, 195, 73, 0.16)" }; + styles[latestMove.to] = { ...styles[latestMove.to], backgroundColor: "rgba(233, 195, 73, 0.24)" }; + } + + if (queuedMove) { + styles[queuedMove.from] = appendBoxShadow(styles[queuedMove.from], "inset 0 0 0 3px rgba(59, 130, 246, 0.65)"); + styles[queuedMove.to] = appendBoxShadow(styles[queuedMove.to], "inset 0 0 0 3px rgba(59, 130, 246, 0.9)"); + } - // Highlight selected square if (selectedSquare) { - styles[selectedSquare] = { - backgroundColor: "rgba(233, 195, 73, 0.35)", - }; + styles[selectedSquare] = appendBoxShadow(styles[selectedSquare], `inset 0 0 0 3px ${ + isMyTurn ? "rgba(233, 195, 73, 0.95)" : "rgba(59, 130, 246, 0.9)" + }`); } - // Highlight legal moves - for (const sq of legalMovesForSquare) { - // Check if a piece occupies the target (capture indicator) - const game = new Chess(view.fen); - const targetPiece = game.get(sq as Square); - styles[sq] = targetPiece - ? { - background: "radial-gradient(circle, transparent 55%, rgba(233, 195, 73, 0.45) 55%)", - } - : { - background: "radial-gradient(circle, rgba(233, 195, 73, 0.35) 25%, transparent 25%)", + for (const target of selectedTargets) { + const occupant = board.get(target); + if (occupant) { + styles[target] = appendBoxShadow(styles[target], `inset 0 0 0 3px ${ + isMyTurn ? "rgba(233, 195, 73, 0.55)" : "rgba(59, 130, 246, 0.55)" + }`); + } else { + styles[target] = { + ...styles[target], + backgroundImage: `radial-gradient(circle, ${ + isMyTurn ? "rgba(233, 195, 73, 0.38)" : "rgba(59, 130, 246, 0.36)" + } 21%, transparent 23%)`, }; + } } - // Highlight last move - const lastMove = view.moveHistory.length > 0 ? view.moveHistory[view.moveHistory.length - 1] : undefined; - if (lastMove) { - styles[lastMove.from] = { - ...styles[lastMove.from], - backgroundColor: "rgba(233, 195, 73, 0.15)", - }; - styles[lastMove.to] = { - ...styles[lastMove.to], - backgroundColor: "rgba(233, 195, 73, 0.2)", - }; - } - - // Highlight king in check if (view.isCheck && !isGameOver) { - const game = new Chess(view.fen); - // Find the king of the side in check - const board = game.board(); - for (const row of board) { + const checkedColor = view.turn === "white" ? "w" : "b"; + for (const row of board.board()) { for (const piece of row) { - if (piece && piece.type === "k" && piece.color === (view.turn === "white" ? "w" : "b")) { + if (piece && piece.type === "k" && piece.color === checkedColor) { styles[piece.square] = { ...styles[piece.square], - background: "radial-gradient(circle, rgba(220, 38, 38, 0.7) 0%, rgba(220, 38, 38, 0.2) 60%, transparent 70%)", + background: "radial-gradient(circle, rgba(220, 38, 38, 0.72) 0%, rgba(220, 38, 38, 0.2) 58%, transparent 70%)", }; } } @@ -231,292 +451,462 @@ export function ChessGame({ state, myPlayerId, isSpectator, onAction, players }: } return styles; - }, [selectedSquare, legalMovesForSquare, view.fen, view.moveHistory, view.isCheck, view.turn, isGameOver]); + }, [board, isGameOver, isMyTurn, queuedMove, selectedSquare, selectedTargets, view.isCheck, view.moveHistory, view.turn]); - // Handle square click (for click-to-move) - const onSquareClick = useCallback((square: Square) => { - if (isSpectator || isGameOver || !isMyTurn || !playerView) return; + const queueMove = useCallback((move: MoveIntent) => { + setQueuedMove(move); + setSelectedSquare(null); + setLocalNotice({ tone: "info", text: `Premove armed: ${formatMoveIntent(move)}` }); + }, []); - // If clicking a legal move target, make the move - if (selectedSquare && legalMovesForSquare.includes(square)) { - // Check for promotion - const game = new Chess(view.fen); - const piece = game.get(selectedSquare); - const isPromotion = piece?.type === "p" && - ((piece.color === "w" && square[1] === "8") || - (piece.color === "b" && square[1] === "1")); + const chooseOrPlayMove = useCallback((from: Square, to: Square, mode: "move" | "premove") => { + const piece = board.get(from); + const promotionRequired = isPromotionMove(piece?.type, piece?.color, to); + const moveIntent: MoveIntent = { from, to, mode }; - if (isPromotion) { - setPromotionSquare({ from: selectedSquare, to: square }); - } else { - onAction({ type: "move", from: selectedSquare, to: square }); - } + if (promotionRequired) { + setPendingPromotion(moveIntent); + return; + } + + if (mode === "move") { + onAction({ type: "move", from, to }); setSelectedSquare(null); return; } - // Select a piece (must be our color) - const game = new Chess(view.fen); - const piece = game.get(square); - if (piece && piece.color === (myColor === "white" ? "w" : "b")) { - setSelectedSquare(square); - } else { + queueMove(moveIntent); + }, [board, onAction, queueMove]); + + const onSquareClick = useCallback((square: Square) => { + if (!playerView || isSpectator || isGameOver) return; + + const clickedPiece = board.get(square); + const ownColorCode = myColor === "white" ? "w" : "b"; + const isOwnPiece = clickedPiece?.color === ownColorCode; + const mode: "move" | "premove" = isMyTurn ? "move" : "premove"; + + if (selectedSquare && selectedTargets.includes(square)) { + chooseOrPlayMove(selectedSquare, square, mode); + return; + } + + if (selectedSquare === square) { setSelectedSquare(null); - } - }, [isSpectator, isGameOver, isMyTurn, playerView, selectedSquare, legalMovesForSquare, view.fen, myColor, onAction]); - - // Handle drag-and-drop (react-chessboard v5 API) - const onPieceDrop = useCallback(({ sourceSquare, targetSquare }: { piece: unknown; sourceSquare: string; targetSquare: string | null }): boolean => { - if (isSpectator || isGameOver || !isMyTurn || !playerView || !targetSquare) return false; - - // Check if this is a legal move - const isLegal = playerView.legalMoves.some(m => - m.from === sourceSquare && m.to === targetSquare - ); - if (!isLegal) return false; - - // Check for promotion - const game = new Chess(view.fen); - const sourcePiece = game.get(sourceSquare as Square); - const isPromotion = sourcePiece?.type === "p" && - ((sourcePiece.color === "w" && targetSquare[1] === "8") || - (sourcePiece.color === "b" && targetSquare[1] === "1")); - - if (isPromotion) { - setPromotionSquare({ from: sourceSquare as Square, to: targetSquare as Square }); - return false; // Don't visually drop yet; wait for promotion choice + return; + } + + if (isOwnPiece) { + setSelectedSquare(square); + return; } - onAction({ type: "move", from: sourceSquare, to: targetSquare }); setSelectedSquare(null); - return true; - }, [isSpectator, isGameOver, isMyTurn, playerView, view.fen, onAction]); + }, [board, chooseOrPlayMove, isGameOver, isMyTurn, isSpectator, myColor, playerView, selectedSquare, selectedTargets]); - // Handle promotion selection - const handlePromotion = useCallback((promoType: string) => { - if (!promotionSquare) return; - onAction({ type: "move", from: promotionSquare.from, to: promotionSquare.to, promotion: promoType }); - setPromotionSquare(null); - }, [promotionSquare, onAction]); + const onPieceDrop = useCallback(({ sourceSquare, targetSquare }: { piece: unknown; sourceSquare: string; targetSquare: string | null }): boolean => { + if (!playerView || isSpectator || isGameOver || !targetSquare) return false; + + const from = sourceSquare as Square; + const to = targetSquare as Square; + + const isAllowed = isMyTurn + ? playerView.legalMoves.some(move => move.from === from && move.to === to) + : getPremoveTargets(board, from, myColor).includes(to); + + if (!isAllowed) return false; + + chooseOrPlayMove(from, to, isMyTurn ? "move" : "premove"); + return isMyTurn ? true : false; + }, [board, chooseOrPlayMove, isGameOver, isMyTurn, isSpectator, myColor, playerView]); + + const handlePromotion = useCallback((promotion: string) => { + if (!pendingPromotion) return; + + if (pendingPromotion.mode === "move") { + onAction({ + type: "move", + from: pendingPromotion.from, + to: pendingPromotion.to, + promotion, + }); + setSelectedSquare(null); + } else { + queueMove({ ...pendingPromotion, promotion }); + } + + setPendingPromotion(null); + }, [onAction, pendingPromotion, queueMove]); - // Only allow dragging own pieces when it's your turn (v5 API) const canDragPiece = useCallback(({ piece }: { isSparePiece: boolean; piece: { pieceType: string }; square: string | null }): boolean => { - if (isSpectator || isGameOver || !isMyTurn) return false; + if (isSpectator || isGameOver || !playerView) return false; const pieceColor = piece.pieceType[0] === "w" ? "white" : "black"; return pieceColor === myColor; - }, [isSpectator, isGameOver, isMyTurn, myColor]); + }, [isSpectator, isGameOver, playerView, myColor]); - // Clear selection when turn changes - useEffect(() => { - setSelectedSquare(null); - setPromotionSquare(null); - }, [view.turn]); - - // Resolve player names - const getPlayerName = (color: "white" | "black") => { + const getPlayerName = useCallback((color: "white" | "black") => { if (isPlayerView(state)) { - if (isSoloMode) return players[0]?.username ?? color; - const id = color === (state as PlayerView).myColor ? myPlayerId : players.find(p => p.discordId !== myPlayerId)?.discordId; - return players.find(p => p.discordId === id)?.username ?? (color === myColor ? "You" : "Opponent"); - } - const spectatorState = state as SpectatorView; - return players.find(p => p.discordId === spectatorState.players[color])?.username ?? color; - }; + if (isSoloMode) return players[0]?.username ?? colorLabel(color); + + const selfId = color === state.myColor ? myPlayerId : players.find(player => player.discordId !== myPlayerId)?.discordId; + return players.find(player => player.discordId === selfId)?.username ?? (color === myColor ? "You" : "Opponent"); + } + + const spectatorState = state as SpectatorView; + return players.find(player => player.discordId === spectatorState.players[color])?.username ?? colorLabel(color); + }, [isSoloMode, myColor, myPlayerId, players, state]); - // Determine opponent/bottom player for layout const topColor: "white" | "black" = boardOrientation === "white" ? "black" : "white"; const bottomColor: "white" | "black" = boardOrientation; - // Draw offer UI for the receiving player const showDrawOffer = playerView && view.drawOffer && view.drawOffer !== playerView.myColor && !isGameOver; const showDrawButton = playerView && !view.drawOffer && !isGameOver; const pendingDrawOffer = playerView && view.drawOffer === playerView.myColor; + const latestMove = view.moveHistory.length > 0 ? view.moveHistory[view.moveHistory.length - 1] : null; + const moveNumber = Math.floor(view.moveHistory.length / 2) + 1; + + const timeControlLabel = typeof roomOptions?.timeControl === "string" + ? CHESS_TIME_CONTROL_LABELS[roomOptions.timeControl] ?? roomOptions.timeControl + : liveClock + ? "Clocked game" + : "No clock"; + + const statusTone = isGameOver + ? view.result === "draw" ? "warning" : "primary" + : showDrawOffer ? "info" + : isMyTurn ? "success" : "default"; + + const statusCardClass = statusTone === "warning" + ? "border-warning/30 bg-warning/10" + : statusTone === "primary" + ? "border-primary/30 bg-primary/10" + : statusTone === "info" + ? "border-info/30 bg-info/10" + : statusTone === "success" + ? "border-success/30 bg-success/10" + : "border-border/60 bg-card/80"; + + const statusTitle = isGameOver + ? view.result === "draw" + ? "Drawn game" + : `${getPlayerName(view.result!)} wins` + : showDrawOffer + ? "Draw offered" + : isSpectator + ? `${colorLabel(view.turn)} to move` + : isMyTurn + ? "Your move" + : queuedMove + ? "Premove ready" + : "Waiting for opponent"; + + const statusText = isGameOver + ? view.resultReason ?? "Game over" + : showDrawOffer + ? `${getPlayerName(view.drawOffer!)} offered a draw.` + : isSpectator + ? view.isCheck + ? `${getPlayerName(view.turn)} is in check.` + : `Watching ${getPlayerName("white")} vs ${getPlayerName("black")}.` + : isMyTurn + ? view.isCheck + ? "Your king is in check. Respond now." + : `Play as ${colorLabel(myColor)}.` + : queuedMove + ? `${formatMoveIntent(queuedMove)} will fire automatically if legal.` + : `${getPlayerName(view.turn)} is thinking.`; + + const metaFacts = [ + { label: isSpectator ? "View" : "Side", value: isSpectator ? "Spectator" : colorLabel(myColor) }, + { label: "Time", value: timeControlLabel }, + { label: "Move", value: `${moveNumber}` }, + { label: "Latest", value: latestMove?.san ?? "Opening" }, + ]; + + if ((roomOptions?.betAmount ?? 0) > 0) { + metaFacts.push({ label: "Wager", value: `${roomOptions!.betAmount} AU` }); + } return ( -
- {/* Board + Clocks Column */} -
- {/* Top player info + clock */} -
-
-
- - {getPlayerName(topColor)} - - {view.turn === topColor && !isGameOver && ( - - )} -
- {liveClock && ( - - )} -
+
+
+
+
+
+ - {/* Chess Board */} -
- onSquareClick(square as Square), - canDragPiece, - squareStyles: customSquareStyles, - darkSquareStyle: { backgroundColor: "#2a3a5c" }, - lightSquareStyle: { backgroundColor: "#c8cad6" }, - boardStyle: { borderRadius: "0" }, - dropSquareStyle: { boxShadow: "inset 0 0 1px 4px rgba(233, 195, 73, 0.6)" }, - animationDurationInMs: 200, - allowDragging: !isSpectator && !isGameOver, - }} /> +
+
+
+ onSquareClick(square as Square), + canDragPiece, + squareStyles: customSquareStyles, + showNotation: true, + darkSquareStyle: { backgroundColor: "#7c6554" }, + lightSquareStyle: { backgroundColor: "#eadfcb" }, + boardStyle: { borderRadius: "24px" }, + dropSquareStyle: { boxShadow: "inset 0 0 0 4px rgba(233, 195, 73, 0.65)" }, + animationDurationInMs: 180, + allowDragging: !isSpectator && !isGameOver, + }} /> +
- {/* Custom Promotion Dialog */} - {promotionSquare && ( -
-
-
Promote to
-
- {(["q", "r", "b", "n"] as const).map(piece => { - const key = `${myColor === "white" ? "w" : "b"}${piece.toUpperCase()}`; - const PieceSvg = chessPieces[key]; - return ( - - ); - })} + {pendingPromotion && ( +
+
+
+ Promotion +
+
+ Choose the piece for {formatMoveIntent(pendingPromotion)} +
+
+ {(["q", "r", "b", "n"] as const).map(piece => { + const key = `${myColor === "white" ? "w" : "b"}${piece.toUpperCase()}`; + const PieceSvg = chessPieces[key]; + return ( + + ); + })} +
+ +
+
+ )}
-
- )} -
- {/* Bottom player info + clock */} -
-
-
- - {getPlayerName(bottomColor)} - {!isSpectator && " (You)"} - - {view.turn === bottomColor && !isGameOver && ( - + + +
+ {!isSpectator && ( + + )} + + + + {liveClock && liveClock.increment > 0 && ( +
+ +{liveClock.increment / 1000}s increment +
+ )} + + {!isSpectator && !isMyTurn && !queuedMove && !isGameOver && ( +
+ You can queue a premove while waiting. +
+ )} +
+
+
+
+ +
-
- {/* Side Panel */} -
- {/* Game Status */} - {isGameOver && ( -
-
- {view.result === "draw" - ? "Draw" - : `${getPlayerName(view.result!)} wins`} -
-
{view.resultReason}
-
- )} - - {/* Turn Indicator */} - {!isGameOver && !isSpectator && ( -
- {isMyTurn ? "Your turn" : "Waiting for opponent..."} -
- )} - - {/* Draw Offer Banner */} - {showDrawOffer && ( -
-
Draw offered
-
- - -
-
- )} - - {pendingDrawOffer && ( -
- Draw offer sent — waiting for response... -
- )} - - {/* Action Buttons */} - {!isSpectator && !isGameOver && ( -
- - {showDrawButton && ( - + {showDrawOffer && ( +
+
+ Decision Needed +
+
+ {getPlayerName(view.drawOffer!)} offered a draw. +
+
+ + +
+
)} -
- )} - {/* Move History */} -
-
- Moves -
-
- -
-
+ {pendingDrawOffer && ( +
+ Draw offer sent. Waiting for a response. +
+ )} - {/* Clock Info (if applicable) */} - {liveClock && liveClock.increment > 0 && ( -
- +{liveClock.increment / 1000}s increment per move +
+
+ Controls +
+
+ {!isSpectator && !isGameOver && ( + + )} + + {!isSpectator && !isGameOver && showDrawButton && ( + + )} + + + + {!isSpectator && ( + + )} +
+ + {!isSpectator && !isGameOver && ( +
+ Drag or click pieces. Off-turn interactions queue a premove and play automatically if the move is still legal. +
+ )} +
+ +
+
+
+ Move History +
+
+ {latestMove ? `Latest: ${latestMove.san}` : "Opening phase"} +
+
+
+ +
+
- )} +
); diff --git a/panel/src/games/registry.ts b/panel/src/games/registry.ts index 4f8c7fa..e1f945d 100644 --- a/panel/src/games/registry.ts +++ b/panel/src/games/registry.ts @@ -7,7 +7,7 @@ export interface GameUIProps { onAction: (action: unknown) => void; players: { discordId: string; username: string }[]; roundResult?: { payouts: Record } | null; - roomOptions?: { betAmount?: number }; + roomOptions?: { betAmount?: number; timeControl?: string }; } export interface GameUIPlugin { diff --git a/panel/src/lib/useGameRoom.ts b/panel/src/lib/useGameRoom.ts index 78383e8..d7b91ee 100644 --- a/panel/src/lib/useGameRoom.ts +++ b/panel/src/lib/useGameRoom.ts @@ -21,7 +21,7 @@ interface GameRoomState { roundResult: RoundResult | null; error: string | null; sessionReplaced: boolean; - roomOptions: { betAmount?: number }; + roomOptions: { betAmount?: number; timeControl?: string }; } export function useGameRoom(roomId: string, userId: string, role?: string, preferAs: "player" | "spectator" = "player") {