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",