From 29a587c1153b8316014a0a602109bc94568627f0 Mon Sep 17 00:00:00 2001
From: Thibaud <thibaud.gasser@gmx.com>
Date: Wed, 29 Jan 2025 23:43:33 +0100
Subject: [PATCH] Add spectator mode

---
 client.ts | 22 ++++++++++++++-
 common.ts | 13 +++++++--
 server.ts | 83 +++++++++++++++++++++++++++++++++++++------------------
 3 files changed, 87 insertions(+), 31 deletions(-)

diff --git a/client.ts b/client.ts
index 984ae0c..84e8ad4 100644
--- a/client.ts
+++ b/client.ts
@@ -1,4 +1,4 @@
-import { Click, Update, Message, Hello, EndGame } from "common.js";
+import { Click, Update, Message, Hello, EndGame, Spectate } from "common.js";
 
 const CELL_SIZE = 150;
 const GRID_SIZE = CELL_SIZE * 3;
@@ -29,6 +29,7 @@ interface Shape {
 
 let grid: Cell[] = new Array(9);
 let pendingEvts: Point[] = [];
+let spectate = false;  // is this client in spectator mode?
 let myId: number | null = null;
 let mySymbol: "x" | "o" | null = null;
 let canvasMsg: string = "Offline...";
@@ -181,6 +182,10 @@ function init() {
     resizeCanvas(ctx); // Init canvas
 
     canvas.addEventListener("click", (evt) => {
+        if (spectate) {
+            console.debug("ignoring click in spectator mode");
+            return;
+        }
         const {clientX, clientY} = evt;
         pendingEvts.push({x: clientX, y: clientY});
     });
@@ -201,6 +206,21 @@ function init() {
                 console.log(canvasMsg);
                 break;
             }
+            case "spectate": {
+                spectate = true;
+                canvasMsg = "connected as spectator";
+                // Initialize grid state
+                for (const [index, sym] of (msg.data as Spectate).grid.entries()) {
+                    if (sym === undefined) continue;
+                    grid[index] = {
+                        kind: sym,
+                        pos: { x: index % 3, y: Math.floor(index / 3) } as Point,
+                        hue: Math.floor(Math.random() * 255),
+                        time: null,
+                    } as Shape;
+                }
+                break;
+            }
             case "update": {
                 const res = msg.data as Update;
                 const { x, y } = res.last;
diff --git a/common.ts b/common.ts
index 057591f..d4055ba 100644
--- a/common.ts
+++ b/common.ts
@@ -1,8 +1,8 @@
-export type MessageKind = "click" | "hello" | "update" | "endgame" | "reset";
+export type MessageKind = "click" | "hello" | "update" | "endgame" | "reset" | "spectate";
 
 export interface Message {
     kind: MessageKind,
-    data: Click | Update | Hello | EndGame | Reset,
+    data: Click | Update | Hello | EndGame | Reset | Spectate,
 }
 
 export interface Click {
@@ -10,8 +10,10 @@ export interface Click {
     y: number
 }
 
+export type Symbol = "x" | "o";
+
 export interface Update {
-    last: { x: number, y: number, symbol: "x" | "o" }
+    last: { x: number, y: number, symbol: Symbol }
 }
 
 export interface Hello {
@@ -25,3 +27,8 @@ export interface EndGame {
 
 type Reset = undefined;
 
+
+export interface Spectate {
+    grid: (Symbol | undefined)[]
+}
+
diff --git a/server.ts b/server.ts
index d415b2e..d25a758 100644
--- a/server.ts
+++ b/server.ts
@@ -1,4 +1,4 @@
-import { Message, Update, Hello, EndGame } from "common.js"
+import { Message, Update, Hello, EndGame, Symbol } from "common.js"
 import { WebSocket, WebSocketServer, MessageEvent } from "ws";
 
 const port = 1234
@@ -16,10 +16,11 @@ interface Client {
 }
 
 let id = 1;
+let spectators: WebSocket[] = [];
 let clients: Client[] = [];
 let currentPlayer: Client | undefined = undefined;
 
-function getPlayerSymbol(): "o" | "x" {
+function getPlayerSymbol(): Symbol {
     console.assert(clients.length < 2, "there should never be more than 2 clients");
     if (clients.length === 0) return "o";
     return clients[0].symbol === "o" ? "x" : "o";
@@ -28,8 +29,23 @@ function getPlayerSymbol(): "o" | "x" {
 wss.on("connection", (ws, req) => {
     id += 1;
     if (clients.length === 2) {
-        console.log("too many players");
-        ws.close();
+        spectators.push(ws);
+        const spectateData: (Symbol | undefined)[] = [];
+        for (const playerId of grid) {
+            if (playerId === 0) {
+                spectateData.push(undefined);
+            } else {
+                const sym = clients.find(c => c.id === playerId)!.symbol;
+                spectateData.push(sym);
+            }
+        }
+        const spectateMsg: Message = {
+            kind: "spectate",
+            data: { grid: spectateData }
+        };
+        ws.send(JSON.stringify(spectateMsg));
+        const addr = req.headers["x-forwarded-for"] || req.socket.remoteAddress;
+        console.log(`new spectator connected with address ${addr}. total spectators ${spectators.length}`);
         return;
     }
 
@@ -73,14 +89,19 @@ wss.on("connection", (ws, req) => {
 
         if (grid[y*3+x] === 0) {
             grid[y*3+x] = player.id;
+            const msg = JSON.stringify({
+                kind: "update",
+                data: {
+                    last: { x, y, symbol: player.symbol }
+                } as Update,
+            } as Message);
+
             for (const c of clients) {
-                const msg: Message = {
-                    kind: "update",
-                    data: {
-                        last: { x, y, symbol: player.symbol }
-                    } as Update,
-                }
-                c.ws.send(JSON.stringify(msg));
+                c.ws.send(msg);
+            }
+
+            for (const s of spectators) {
+                s.send(msg);
             }
 
             const winnerId = checkWin(grid);
@@ -89,13 +110,16 @@ wss.on("connection", (ws, req) => {
                 console.assert(currentPlayer);
                 console.log(`current player is #${currentPlayer?.id}`);
             } else if (winnerId == 0) {
+                endGame = true;
+                const msg = JSON.stringify({
+                    kind: "endgame",
+                    data: { issue: "draw" } as EndGame
+                } as Message);
                 for (const c of clients) {
-                    const msg: Message = {
-                        kind: "endgame",
-                        data: { issue: "draw" } as EndGame
-                    };
-                    c.ws.send(JSON.stringify(msg));
-                    endGame = true;
+                    c.ws.send(msg);
+                }
+                for (const s of spectators) {
+                    s.send(msg);
                 }
             } else {
                 console.log(`player ${winnerId} won !`);
@@ -117,16 +141,21 @@ wss.on("connection", (ws, req) => {
     });
 
     ws.on("close", (code: number) => {
-        clients = clients.filter(x => x.ws.readyState !== 3); // 3 == CLOSED
-        console.log(`player disconnected. Resetting game. Total clients ${clients.length}`);
-        // reset game state
-        grid = [0, 0, 0, 0, 0, 0, 0, 0, 0];
-        currentPlayer = undefined;
-        endGame = false;
-        for (const c of clients) {
-            c.ws.send(JSON.stringify({
-                kind: "reset"
-            } as Message));
+        // readyState 3 == CLOSED
+        spectators = spectators.filter(s => s.readyState !== 3);
+        const isClientDisconnect = clients.findIndex(c => c.ws.readyState !== 3)
+        if (isClientDisconnect) {
+            clients = clients.filter(x => x.ws.readyState !== 3);
+            console.log(`player disconnected. Resetting game. Total clients ${clients.length}`);
+            // reset game state
+            grid = [0, 0, 0, 0, 0, 0, 0, 0, 0];
+            currentPlayer = undefined;
+            endGame = false;
+            for (const c of clients) {
+                c.ws.send(JSON.stringify({
+                    kind: "reset"
+                } as Message));
+            }
         }
     });
 });