"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import Board from "@/components/Board"; import { BoardState, GameEntry, ParsedMessage, PlayerEntry, ScoreEntry, cmd, createEmptyBoard, parseMessage, placeToken, replayMoves, } from "@/lib/protocol"; type ConnStatus = "idle" | "connecting" | "connected" | "disconnected"; interface LiveGame { id: number; player1: string; player2: string; board: BoardState; lastMove: { column: number; row: number } | null; currentTurnColor: 1 | 2; result: { kind: "win"; winner: string } | { kind: "draw" } | { kind: "terminated" } | null; } const DEFAULT_URL = "wss://connect4.abunchofknowitalls.com"; export default function TournamentPage() { const [wsUrl, setWsUrl] = useState(DEFAULT_URL); const [status, setStatus] = useState("idle"); const [tournamentActive, setTournamentActive] = useState(false); const [tournamentType, setTournamentType] = useState(null); const [scores, setScores] = useState([]); const [players, setPlayers] = useState([]); const [gameList, setGameList] = useState([]); const [liveGames, setLiveGames] = useState>(new Map()); const [selectedGame, setSelectedGame] = useState(null); const [log, setLog] = useState([]); const wsRef = useRef(null); const pollRef = useRef | null>(null); // keep a ref to liveGames for use inside the message handler closure const liveGamesRef = useRef>(new Map()); const addLog = (msg: string) => setLog((prev) => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev.slice(0, 79)]); const send = useCallback((msg: string) => { if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(msg); }, []); /** Merge a partial update into a live game, creating it if new. */ const updateGame = useCallback((id: number, patch: Partial) => { setLiveGames((prev) => { const next = new Map(prev); const existing = next.get(id) ?? { id, player1: "", player2: "", board: createEmptyBoard(), lastMove: null, currentTurnColor: 1 as const, result: null, }; next.set(id, { ...existing, ...patch }); liveGamesRef.current = next; return next; }); }, []); const handleMessage = useCallback( (raw: string) => { const msg: ParsedMessage = parseMessage(raw); switch (msg.type) { case "TOURNAMENT_START": setTournamentActive(true); setTournamentType(msg.tournamentType); setScores([]); addLog(`πŸ† Tournament started: ${msg.tournamentType}`); send(cmd.gameList()); send(cmd.playerList()); break; case "TOURNAMENT_CANCEL": setTournamentActive(false); setTournamentType(null); addLog("❌ Tournament cancelled"); break; case "TOURNAMENT_SCORES": setScores(msg.scores); addLog( `πŸ“Š Scores updated: ${msg.scores .map((s) => `${s.player} ${s.score}`) .join(", ")}` ); break; case "TOURNAMENT_END": addLog("Round ended"); send(cmd.gameList()); send(cmd.playerList()); break; case "GAME_LIST": { setGameList(msg.games); // Watch any new games we don't have yet for (const g of msg.games) { if (!liveGamesRef.current.has(g.id)) { send(cmd.gameWatch(g.id)); } } break; } case "GAME_WATCH_ACK": { const { board, lastMove } = replayMoves(msg.moves, msg.player1); const moveCount = msg.moves.length; updateGame(msg.matchId, { player1: msg.player1, player2: msg.player2, board, lastMove, currentTurnColor: (moveCount % 2 === 0 ? 1 : 2) as 1 | 2, result: null, }); addLog(`Watching match ${msg.matchId}: ${msg.player1} vs ${msg.player2}`); break; } case "GAME_MOVE": { // find the game this player is in const gamesSnapshot = liveGamesRef.current; for (const [id, game] of gamesSnapshot) { if (game.player1 === msg.username || game.player2 === msg.username) { const color: 1 | 2 = msg.username === game.player1 ? 1 : 2; const { board: next, row } = placeToken(game.board, color, msg.column); updateGame(id, { board: next, lastMove: { column: msg.column, row }, currentTurnColor: (color === 1 ? 2 : 1) as 1 | 2, }); addLog(`[#${id}] ${msg.username} played column ${msg.column}`); break; } } break; } case "GAME_WIN": { const gamesSnapshot = liveGamesRef.current; for (const [id, game] of gamesSnapshot) { if (game.player1 === msg.winner || game.player2 === msg.winner) { updateGame(id, { result: { kind: "win", winner: msg.winner } }); addLog(`πŸ† [#${id}] ${msg.winner} wins!`); break; } } // refresh lists after match ends setTimeout(() => { send(cmd.gameList()); send(cmd.playerList()); }, 1000); break; } case "GAME_DRAW": { // mark the selected game as draw (we can't easily identify which) if (selectedGame !== null) { updateGame(selectedGame, { result: { kind: "draw" } }); } addLog("🀝 Draw"); break; } case "GAME_TERMINATED": { if (selectedGame !== null) { updateGame(selectedGame, { result: { kind: "terminated" } }); } addLog("β›” Match terminated"); send(cmd.gameList()); break; } case "PLAYER_LIST": setPlayers(msg.players); break; case "GET_DATA": if (msg.key === "TOURNAMENT_STATUS") { if (msg.value && msg.value !== "false") { setTournamentActive(true); setTournamentType(msg.value); } } break; case "ERROR": addLog(`Error: ${msg.message}`); break; default: break; } }, [send, updateGame, selectedGame] ); const connect = useCallback(() => { if (wsRef.current) wsRef.current.close(); setStatus("connecting"); setLog([]); setLiveGames(new Map()); liveGamesRef.current = new Map(); setSelectedGame(null); setScores([]); setPlayers([]); setGameList([]); setTournamentActive(false); setTournamentType(null); const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { setStatus("connected"); addLog("Connected as observer"); ws.send(cmd.getData("TOURNAMENT_STATUS")); ws.send(cmd.gameList()); ws.send(cmd.playerList()); }; ws.onmessage = (e) => handleMessage(e.data as string); ws.onclose = () => { setStatus("disconnected"); addLog("Disconnected"); if (pollRef.current) clearInterval(pollRef.current); }; ws.onerror = () => addLog("WebSocket error"); pollRef.current = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(cmd.gameList()); ws.send(cmd.playerList()); } }, 5000); }, [wsUrl, handleMessage]); const disconnect = useCallback(() => { if (pollRef.current) clearInterval(pollRef.current); wsRef.current?.close(); }, []); useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); wsRef.current?.close(); }, []); const selectedGameData = selectedGame !== null ? liveGames.get(selectedGame) : null; return (
{/* Header */}

πŸ† Tournament View

Live standings, active matches, and player status

{/* Connection bar */}
setWsUrl(e.target.value)} disabled={status === "connected" || status === "connecting"} />
{status !== "connected" ? ( ) : ( )}
{/* Tournament banner */} {status === "connected" && (
{tournamentActive ? "πŸ†" : "⏳"}
{tournamentActive ? `Tournament Active β€” ${tournamentType ?? "Unknown Type"}` : "No Active Tournament"}
{tournamentActive ? `${gameList.length} match${gameList.length !== 1 ? "es" : ""} running Β· ${players.filter((p) => p.inMatch).length}/${players.length} players in game` : "Waiting for admin to start a tournament"}
)}
{/* Left column */}
{/* Leaderboard */}

Leaderboard

{scores.length === 0 ? (

{status === "connected" ? "No scores yet" : "Connect to see scores"}

) : (
{scores.map((s, i) => (
{i === 0 ? "πŸ₯‡" : i === 1 ? "πŸ₯ˆ" : i === 2 ? "πŸ₯‰" : `${i + 1}.`} {s.player} {s.score}
))}
)}
{/* Player list */}

Players {players.length} connected

{players.length === 0 ? (

{status === "connected" ? "No players connected" : "Connect to see players"}

) : (
{players.map((p) => (
{p.username} {p.inMatch ? ( In game ) : p.ready ? ( Ready ) : ( Idle )}
))}
)}
{/* Event log */}

Event Log

{log.slice(0, 20).map((entry, i) => (

{entry}

))} {log.length === 0 && (

No events yet

)}
{/* Right: active matches + board */}
{/* Active matches */}

Active Matches

{gameList.length === 0 ? (

{status === "connected" ? "No active matches" : "Connect to see matches"}

) : (
{gameList.map((g) => { const live = liveGames.get(g.id); return ( ); })}
)}
{/* Selected game board */}
{!selectedGameData ? (
🎯

{gameList.length > 0 ? "Click a match above to see the board" : "Active matches will appear here"}

) : ( <> {selectedGameData.result && (
{selectedGameData.result.kind === "win" ? `πŸ† ${selectedGameData.result.winner} wins!` : selectedGameData.result.kind === "draw" ? "🀝 Draw!" : "β›” Match Terminated"}
)} )}
); } function StatusBadge({ status }: { status: ConnStatus }) { const colors: Record = { idle: "bg-gray-700 text-gray-400", connecting: "bg-yellow-900/60 text-yellow-300 animate-pulse", connected: "bg-green-900/60 text-green-300", disconnected: "bg-red-900/60 text-red-300", }; const labels: Record = { idle: "Not connected", connecting: "Connecting…", connected: "Connected", disconnected: "Disconnected", }; return ( {labels[status]} ); }