diff --git a/connect4-ui/app/play/page.tsx b/connect4-ui/app/play/page.tsx index c48af30..7472dde 100644 --- a/connect4-ui/app/play/page.tsx +++ b/connect4-ui/app/play/page.tsx @@ -67,10 +67,14 @@ export default function PlayPage() { }, []); useEffect(() => { - if (status === "disconnected" && shouldRedirectToConnect) { + if (status === "disconnected" && shouldRedirectToConnect) { clearRedirectFlag(); router.replace("/"); - } + } + + if (status === "idle") { + router.replace("/"); + } if (role !== "player" && status !== "idle") { router.replace("/spectate"); @@ -80,8 +84,6 @@ export default function PlayPage() { if (status === "connected" && gamePhase === "idle") { setGamePhase("connected"); } - - }, [ role, status, @@ -236,6 +238,8 @@ export default function PlayPage() { myColor === 1 ? "🔴 Red" : myColor === 2 ? "🟡 Yellow" : null; const opponentColor: 1 | 2 | null = myColor === 1 ? 2 : myColor === 2 ? 1 : null; + const redPlayerName = myColor === 1 ? username : "Opponent"; + const yellowPlayerName = myColor === 2 ? username : "Opponent"; return (
@@ -418,8 +422,8 @@ export default function PlayPage() { { - if (status === "disconnected" && shouldRedirectToConnect) { + if (status === "disconnected" && shouldRedirectToConnect) { clearRedirectFlag(); router.replace("/"); - } else if (status === "idle") { + } + + if (status === "idle") { router.replace("/"); - } + } if (role !== "observer" && status !== "idle") { router.replace("/play"); @@ -147,37 +149,36 @@ export default function SpectatePage() { } 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; - } + if (typeof msg.matchId !== "number") { + addLog("Protocol error: GAME_MOVE missing matchId"); + break; } + + const game = liveGamesRef.current.get(msg.matchId); + if (!game) break; + const color: 1 | 2 = msg.username === game.player1 ? 1 : 2; + const { board: next, row } = placeToken( + game.board, + color, + msg.column, + ); + updateGame(msg.matchId, { + board: next, + lastMove: { column: msg.column, row }, + currentTurnColor: (color === 1 ? 2 : 1) as 1 | 2, + }); 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; - } + if (typeof msg.matchId !== "number") { + addLog("Protocol error: GAME_WIN missing matchId"); + break; } + + updateGame(msg.matchId, { + result: { kind: "win", winner: msg.winner }, + }); setTimeout(() => { send(cmd.gameList()); send(cmd.playerList()); @@ -186,15 +187,19 @@ export default function SpectatePage() { } case "GAME_DRAW": - if (selectedGame !== null) { - updateGame(selectedGame, { result: { kind: "draw" } }); + if (typeof msg.matchId !== "number") { + addLog("Protocol error: GAME_DRAW missing matchId"); + break; } + updateGame(msg.matchId, { result: { kind: "draw" } }); break; case "GAME_TERMINATED": - if (selectedGame !== null) { - updateGame(selectedGame, { result: { kind: "terminated" } }); + if (typeof msg.matchId !== "number") { + addLog("Protocol error: GAME_TERMINATED missing matchId"); + break; } + updateGame(msg.matchId, { result: { kind: "terminated" } }); send(cmd.gameList()); break; diff --git a/connect4-ui/lib/connection.tsx b/connect4-ui/lib/connection.tsx index dbc2c39..d323d52 100644 --- a/connect4-ui/lib/connection.tsx +++ b/connect4-ui/lib/connection.tsx @@ -147,7 +147,7 @@ export function ConnectionProvider({ if (!session) return; if (session.role === "observer") { - setStatus("connected"); + socket.send(cmd.observe()); return; } @@ -160,24 +160,31 @@ export function ConnectionProvider({ socket.onmessage = (event) => { const raw = event.data as string; + console.log(raw); const parsed = parseMessage(raw); - if (parsed.type === "CONNECT_ACK") { - setRole("player"); + if (parsed.type === "OBSERVE_ACK") { + setRole("observer"); + setShouldRedirectToConnect(false); + setStatus("connected"); } - if (parsed.type === "RECONNECT_ACK") { + if (parsed.type === "CONNECT_ACK") { + setRole("player"); + } + + if (parsed.type === "RECONNECT_ACK") { clearReconnectState(); setShouldRedirectToConnect(false); setStatus("connected"); } - if (parsed.type === "DISCONNECT_ACK") { - setRole("observer"); - setUsername(""); - isInMatchRef.current = false; - setIsInMatch(false); - } + if (parsed.type === "DISCONNECT_ACK") { + setRole("observer"); + setUsername(""); + isInMatchRef.current = false; + setIsInMatch(false); + } if (parsed.type === "GAME_START") { isInMatchRef.current = true; @@ -296,7 +303,6 @@ export function ConnectionProvider({ [clearReconnectState, openSocket], ); - const disconnect = useCallback(() => { clearReconnectState(); manualCloseRef.current = true; diff --git a/connect4-ui/lib/protocol.ts b/connect4-ui/lib/protocol.ts index 6801c9f..aa8da69 100644 --- a/connect4-ui/lib/protocol.ts +++ b/connect4-ui/lib/protocol.ts @@ -22,9 +22,10 @@ export interface MoveEntry { column: number; } -export const DEFAULT_WS_URL = process.env.NODE_ENV === "development" - ? "ws://localhost:8080" - : "wss://connect4.abunchofknowitalls.com"; +export const DEFAULT_WS_URL = + process.env.NODE_ENV === "development" + ? "ws://localhost:8080" + : "wss://connect4.abunchofknowitalls.com"; export const RECONNECT_INTERVAL_MS = 5000; export const RECONNECT_TIMEOUT_MS = 60000; @@ -34,12 +35,13 @@ export type ParsedMessage = | { type: "CONNECT_ACK" } | { type: "RECONNECT_ACK" } | { type: "DISCONNECT_ACK" } + | { type: "OBSERVE_ACK"; enabled: boolean } | { type: "READY_ACK" } | { type: "GAME_START"; goesFirst: boolean } | { type: "GAME_WINS" } | { type: "GAME_LOSS" } - | { type: "GAME_DRAW" } - | { type: "GAME_TERMINATED" } + | { type: "GAME_DRAW"; matchId?: number } + | { type: "GAME_TERMINATED"; matchId?: number } | { type: "OPPONENT_MOVE"; column: number } | { type: "GAME_LIST"; games: GameEntry[] } | { @@ -49,8 +51,8 @@ export type ParsedMessage = player2: string; moves: MoveEntry[]; } - | { type: "GAME_MOVE"; username: string; column: number } - | { type: "GAME_WIN"; winner: string } + | { type: "GAME_MOVE"; matchId?: number; username: string; column: number } + | { type: "GAME_WIN"; matchId?: number; winner: string } | { type: "PLAYER_LIST"; players: PlayerEntry[] } | { type: "TOURNAMENT_START"; tournamentType: string } | { type: "TOURNAMENT_CANCEL" } @@ -77,11 +79,39 @@ export function parseMessage(raw: string): ParsedMessage { case "DISCONNECT": if (parts[1] === "ACK") return { type: "DISCONNECT_ACK" }; break; + case "OBSERVE": + if (parts[1] === "ACK") { + return { type: "OBSERVE_ACK", enabled: parts[2] === "1" }; + } + break; case "READY": if (parts[1] === "ACK") return { type: "READY_ACK" }; break; case "GAME": { + const scopedMatchId = parseInt(parts[1], 10); + if (!Number.isNaN(scopedMatchId)) { + switch (parts[2]) { + case "MOVE": + return { + type: "GAME_MOVE", + matchId: scopedMatchId, + username: parts[3], + column: parseInt(parts[4], 10), + }; + case "WIN": + return { + type: "GAME_WIN", + matchId: scopedMatchId, + winner: parts[3], + }; + case "DRAW": + return { type: "GAME_DRAW", matchId: scopedMatchId }; + case "TERMINATED": + return { type: "GAME_TERMINATED", matchId: scopedMatchId }; + } + } + switch (parts[1]) { case "START": return { type: "GAME_START", goesFirst: parts[2] === "1" }; @@ -130,23 +160,15 @@ export function parseMessage(raw: string): ParsedMessage { } break; } - - case "MOVE": - // GAME:MOVE:: - return { - type: "GAME_MOVE", - username: parts[2], - column: parseInt(parts[3]), - }; - - case "WIN": - return { type: "GAME_WIN", winner: parts[2] }; } break; } case "OPPONENT": - return { type: "OPPONENT_MOVE", column: parseInt(parts[1]) }; + return { + type: "OPPONENT_MOVE", + column: parseInt(parts[parts.length - 1], 10), + }; case "PLAYER": { if (parts[1] === "LIST") { @@ -214,6 +236,7 @@ export const cmd = { connect: (username: string) => `CONNECT:${username}`, reconnect: (username: string) => `RECONNECT:${username}`, disconnect: () => "DISCONNECT", + observe: () => "OBSERVE", ready: () => "READY", play: (column: number) => `PLAY:${column}`, playerList: () => "PLAYER:LIST",