"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Board from "@/components/Board"; import { BoardState, GameEntry, ParsedMessage, PlayerEntry, ScoreEntry, cmd, createEmptyBoard, placeToken, replayMoves, } from "@/lib/protocol"; import { useConnection } from "@/lib/connection"; 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; } export default function SpectatePage() { const router = useRouter(); const { role, status, send, subscribe, disconnect, shouldRedirectToConnect, clearRedirectFlag, } = useConnection(); 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 pollRef = useRef | null>(null); const liveGamesRef = useRef>(new Map()); const addLog = useCallback( (msg: string) => setLog((prev) => [ `[${new Date().toLocaleTimeString()}] ${msg}`, ...prev.slice(0, 79), ]), [], ); 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; }); }, []); useEffect(() => { if (status === "disconnected" && shouldRedirectToConnect) { clearRedirectFlag(); router.replace("/"); } else if (status === "idle") { router.replace("/"); } if (role !== "observer" && status !== "idle") { router.replace("/play"); return; } }, [role, status, shouldRedirectToConnect, clearRedirectFlag, router]); useEffect(() => { const unsubscribe = subscribe((msg: ParsedMessage) => { 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); break; case "TOURNAMENT_END": addLog("Round ended"); send(cmd.gameList()); send(cmd.playerList()); break; case "GAME_LIST": setGameList(msg.games); 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, }); break; } case "GAME_MOVE": { 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, }); 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 } }); break; } } setTimeout(() => { send(cmd.gameList()); send(cmd.playerList()); }, 750); break; } case "GAME_DRAW": if (selectedGame !== null) { updateGame(selectedGame, { result: { kind: "draw" } }); } break; case "GAME_TERMINATED": if (selectedGame !== null) { updateGame(selectedGame, { result: { kind: "terminated" } }); } send(cmd.gameList()); break; case "PLAYER_LIST": setPlayers(msg.players); break; case "GET_DATA": if ( msg.key === "TOURNAMENT_STATUS" && msg.value && msg.value !== "false" ) { setTournamentActive(true); setTournamentType(msg.value); } break; case "ERROR": addLog(`Error: ${msg.message}`); break; default: break; } }); return unsubscribe; }, [addLog, selectedGame, send, subscribe, updateGame]); useEffect(() => { if (status !== "connected" || role !== "observer") { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } return; } send(cmd.getData("TOURNAMENT_STATUS")); send(cmd.gameList()); send(cmd.playerList()); pollRef.current = setInterval(() => { send(cmd.gameList()); send(cmd.playerList()); }, 5000); return () => { if (pollRef.current) clearInterval(pollRef.current); }; }, [role, send, status]); const selectedGameData = selectedGame !== null ? liveGames.get(selectedGame) : null; return (

๐Ÿ‘ Observer Dashboard

Unified spectate and tournament view

{status !== "connected" && (
Waiting for observer connection...
)} {status === "connected" && (
{tournamentActive ? "๐Ÿ†" : "โณ"}
{tournamentActive ? `Tournament Active - ${tournamentType ?? "Unknown Type"}` : "No Active Tournament"}
{gameList.length} match{gameList.length !== 1 ? "es" : ""} running ยท {players.filter((p) => p.inMatch).length}/{players.length}{" "} players in game
)}

Leaderboard

{scores.length === 0 ? (

No scores yet

) : (
{scores.map((s, i) => (
{i + 1}. {s.player} {s.score}
))}
)}

Players {players.length} connected

{players.length === 0 ? (

No players connected

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

Event Log

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

{entry}

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

No events yet

)}

Active Matches

{gameList.length === 0 ? (

No active matches

) : (
{gameList.map((g) => { const live = liveGames.get(g.id); return ( ); })}
)}
{!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"}
)} )}
); }