diff --git a/src/client/api.ts b/src/client/api.ts index 4ae4ecc1..525c2288 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -66,10 +66,7 @@ export function useSocket( onMessage(message); } }, - - onClose: () => { - console.log("IT'S CLOSED BRO!"); - }, + share: true, }); // handle how a message is sent diff --git a/src/client/game/game.tsx b/src/client/game/game.tsx index 00d2a236..5219bd5e 100644 --- a/src/client/game/game.tsx +++ b/src/client/game/game.tsx @@ -25,6 +25,7 @@ import { ChessEngine } from "../../common/chess-engine"; import type { Move } from "../../common/game-types"; import { NonIdealState, Spinner } from "@blueprintjs/core"; import { AcceptDrawDialog, OfferDrawDialog } from "./draw-dialog"; +import { Sidebar } from "../setup/sidebar"; import { bgColor } from "../check-dark-mode"; import "../colors.css"; import { NotificationDialog, PauseDialog } from "./admin-dialogs"; @@ -195,19 +196,22 @@ export function Game(): JSX.Element { -
- - {gameEndDialog} - {gameOfferDialog} - {gameAcceptDialog} - {gamePauseDialog} - {gameUnpauseDialog} - + +
+
+ + {gameEndDialog} + {gameOfferDialog} + {gameAcceptDialog} + {gamePauseDialog} + {gameUnpauseDialog} + +
); diff --git a/src/client/index.scss b/src/client/index.scss index a2af4610..c37f6909 100644 --- a/src/client/index.scss +++ b/src/client/index.scss @@ -27,3 +27,65 @@ position: absolute; // border: 3px solid blue; } + +.sidebar { + //w3 schools for the win + width: 20%; + height: 100%; + position: fixed; /* Fixed Sidebar (stay in place on scroll) */ + z-index: 1; /* Stay on top */ + top: 0; /* Stay at the top */ + left: 0; + overflow-x: hidden; /* Disable horizontal scroll */ + padding-top: 20px; +} + +.main-dialog { + margin-left: 20%; + position: fixed; + width: 80%; + height: 100%; + padding-top: 50px; +} + +.flex-container { + display: flex; + flex-direction: column; + padding: 5px; + background-color: #eee; +} + +.button-container { + display: flex; + flex-direction: column; + gap: 5px; + margin-bottom: 5px; + margin-top: auto; +} + +@media (max-width: 500px) { + .flex-container { + flex-direction: row; + } + + .button-container { + margin-right: 5px; + margin-left: auto; + margin-bottom: auto; + margin-top: 0px; + } + + .sidebar { + width: 100%; + height: 20%; + bottom: 0; + top: auto; + padding-top: 10px !important; + } + + .main-dialog { + width: 100%; + margin-left: auto; + padding-top: 0px; + } +} diff --git a/src/client/setup/lobby.tsx b/src/client/setup/lobby.tsx index 8dcfebee..acf16621 100644 --- a/src/client/setup/lobby.tsx +++ b/src/client/setup/lobby.tsx @@ -4,6 +4,7 @@ import { useNavigate, Navigate } from "react-router-dom"; import { GameStartedMessage } from "../../common/message/game-message"; import { useSocket, useEffectQuery, get } from "../api"; import { ClientType } from "../../common/client-types"; +import { ThemeButtons } from "./setup"; /** * check for an active game and waits for one or forwards to setup @@ -41,10 +42,27 @@ export function Lobby() { } else { return ( - } - /> + <> + } + /> +
+ +
+
); } diff --git a/src/client/setup/setup-base.tsx b/src/client/setup/setup-base.tsx index eaece055..3a5b396f 100644 --- a/src/client/setup/setup-base.tsx +++ b/src/client/setup/setup-base.tsx @@ -4,6 +4,7 @@ import { ChessboardWrapper } from "../chessboard/chessboard-wrapper"; import type { PropsWithChildren, ReactNode } from "react"; import { ChessEngine } from "../../common/chess-engine"; import { Side } from "../../common/game-types"; +import { Sidebar } from "./sidebar"; import { bgColor } from "../check-dark-mode"; import "../colors.css"; @@ -20,27 +21,32 @@ interface SetupBaseProps extends PropsWithChildren { export function SetupBase(props: SetupBaseProps): JSX.Element { return ( <> - - {}} - rotation={0} - /> - -
- {props.children} - -
-
+
+ + {}} + rotation={0} + /> + +
+ {props.children} + +
+
+
+ ); } diff --git a/src/client/setup/setup.tsx b/src/client/setup/setup.tsx index 486e5a8f..a6ce96fd 100644 --- a/src/client/setup/setup.tsx +++ b/src/client/setup/setup.tsx @@ -132,20 +132,7 @@ function SetupMain(props: SetupMainProps) { onClick={() => props.onPageChange(SetupType.PUZZLE)} className={buttonColor()} /> -

Display Settings:

- - {allSettings.map((item, idx) => ( -
); } + +export function ThemeButtons(): JSX.Element { + return ( + <> +

Display Settings:

+ + {allSettings.map((item, idx) => ( + + + + ); +} diff --git a/src/common/message/game-message.ts b/src/common/message/game-message.ts index 455dcbcb..0f002166 100644 --- a/src/common/message/game-message.ts +++ b/src/common/message/game-message.ts @@ -139,3 +139,33 @@ export class GameEndMessage extends Message { }; } } + +export class JoinQueue extends Message { + constructor(public readonly playerName: string) { + super(); + } + + protected type = MessageType.JOIN_QUEUE; + + protected toObj(): object { + return { + ...super.toObj(), + playerName: this.playerName, + }; + } +} + +export class UpdateQueue extends Message { + constructor(public readonly updatedPlayerList: string[]) { + super(); + } + + protected type = MessageType.UPDATE_QUEUE; + + protected toObj(): object { + return { + ...super.toObj(), + updatedPlayerList: this.updatedPlayerList, + }; + } +} diff --git a/src/common/message/message.ts b/src/common/message/message.ts index 112c6d63..d10b51a9 100644 --- a/src/common/message/message.ts +++ b/src/common/message/message.ts @@ -56,6 +56,14 @@ export enum MessageType { * A message sent from server to all clients for updating the robot simulator. */ SIMULATOR_UPDATE = "simulator-update", + /** + * A message for a client to join the game queue + */ + JOIN_QUEUE = "join-queue", + /** + * A message for the server to update queues + */ + UPDATE_QUEUE = "update-queue", } /** diff --git a/src/common/message/parse-message.ts b/src/common/message/parse-message.ts index df576acd..9c2cb380 100644 --- a/src/common/message/parse-message.ts +++ b/src/common/message/parse-message.ts @@ -8,6 +8,8 @@ import { GameStartedMessage, GameHoldMessage, GameFinishedMessage, + JoinQueue, + UpdateQueue, GameEndMessage, SetChessMessage, } from "./game-message"; @@ -39,6 +41,10 @@ export function parseMessage(text: string): Message { return new PositionMessage(obj.pgn); case MessageType.MOVE: return new MoveMessage(obj.move); + case MessageType.JOIN_QUEUE: + return new JoinQueue(obj.playerName); + case MessageType.UPDATE_QUEUE: + return new UpdateQueue(obj.updatedPlayerList); case MessageType.SET_CHESS: return new SetChessMessage(obj.chess); case MessageType.DRIVE_ROBOT: diff --git a/src/server/api/api.ts b/src/server/api/api.ts index 28430f32..2ae01704 100644 --- a/src/server/api/api.ts +++ b/src/server/api/api.ts @@ -7,7 +7,10 @@ import { GameEndMessage, GameHoldMessage, GameInterruptedMessage, + GameStartedMessage, + JoinQueue, MoveMessage, + UpdateQueue, SetChessMessage, } from "../../common/message/game-message"; import { @@ -15,6 +18,7 @@ import { SetRobotVariableMessage, } from "../../common/message/robot-message"; +import { ClientType } from "../../common/client-types"; import type { Difficulty } from "../../common/client-types"; import { RegisterWebsocketMessage } from "../../common/message/message"; import { @@ -30,13 +34,19 @@ import { } from "./game-manager"; import { ChessEngine } from "../../common/chess-engine"; import { Side } from "../../common/game-types"; -import { USE_VIRTUAL_ROBOTS, START_ROBOTS_AT_DEFAULT } from "../utils/env"; +import { + USE_VIRTUAL_ROBOTS, + START_ROBOTS_AT_DEFAULT, + DO_SAVES, +} from "../utils/env"; import { SaveManager } from "./save-manager"; import { VirtualBotTunnel } from "../simulator"; import { Position } from "../robot/position"; import { DEGREE } from "../../common/units"; import { PacketType } from "../utils/tcp-packet"; +import { PriorityQueue } from "./queue"; +import { GameInterruptedReason } from "../../common/game-end-reasons"; import { ShowfileSchema, TimelineEventTypes } from "../../common/show"; import { SplinePointType } from "../../common/spline"; import type { Command } from "../command/command"; @@ -92,6 +102,13 @@ async function setupDefaultRobotPositions( } } +const queue = new PriorityQueue(); +//hashmap mapping cookie ids to user names +const names = new Map(); + +//let the queue be moved once per game +let canReloadQueue = true; + /** * An endpoint used to establish a websocket connection with the server. * @@ -102,6 +119,108 @@ export const websocketHandler: WebsocketRequestHandler = (ws, req) => { ws.on("close", () => { console.log("We closed the connection"); socketManager.handleSocketClosed(req.cookies.id); + + //if you reload and the game is over + if (gameManager?.isGameEnded() && canReloadQueue) { + //make the reassignment occur once per game instead of once per reload + canReloadQueue = false; + + //remove the old players and store them for future reference + const oldPlayers = clientManager.getIds(); + clientManager.removeHost(); + clientManager.removeClient(); + + if (oldPlayers !== undefined) { + //in most cases, the second player becomes the host + clientManager.assignPlayer(oldPlayers[1]); + + //if no one else wants to play, the host just swaps + if (queue.size() === 0) { + clientManager.assignPlayer(oldPlayers[0]); + } + + //if there is one person who wants to play, host moves to the second player + if (queue.size() === 1) { + const newPlayer = queue.pop(); + if (newPlayer) { + clientManager.removeSpectator(newPlayer); + clientManager.assignPlayer(newPlayer); + names.delete(newPlayer); + } + } + + //are enough people to start a game, forget the old people + if (queue.size() >= 2) { + const newPlayer = queue.pop(); + const newSecondPlayer = queue.pop(); + if (newPlayer && newSecondPlayer) { + //reset the clients + clientManager.removeHost(); + clientManager.removeClient(); + + //assign new players + clientManager.removeSpectator(newPlayer); + clientManager.assignPlayer(newPlayer); + names.delete(newPlayer); + clientManager.removeSpectator(newSecondPlayer); + clientManager.assignPlayer(newSecondPlayer); + names.delete(newSecondPlayer); + } + } + } + } + + //wait in case the client is just reloading or disconnected instead of leaving + setTimeout(() => { + if (socketManager.getSocket(req.cookies.id) === undefined) { + //remove the person from the queue to free up space + queue.popInd(queue.find(req.cookies.id)); + names.delete(req.cookies.id); + const clientType = clientManager.getClientType(req.cookies.id); + + //if the person was a host / client, a new one needs to be reassigned + if (clientManager.isPlayer(req.cookies.id)) { + //clear the existing game + const ids = clientManager.getIds(); + if (ids) { + if ( + SaveManager.loadGame(req.cookies.id)?.host === + ids[0] + ) + SaveManager.endGame(ids[0], ids[1]); + else SaveManager.endGame(ids[1], ids[0]); + } + + setGameManager(null); + + //remove the old host/client + clientType === ClientType.HOST ? + clientManager.removeHost() + : clientManager.removeClient(); + + //if there exists someone to take their place + const newPlayer = queue.pop(); + if (newPlayer) { + //transfer them from spectator to the newly-opened spot and remove them from queue + clientManager.removeSpectator(newPlayer); + clientManager.assignPlayer(newPlayer); + names.delete(newPlayer); + socketManager.sendToAll( + new GameInterruptedMessage( + GameInterruptedReason.ABORTED, + ), + ); + } + //else they were a spectator and don't need game notifications anymore + } else { + clientManager.removeSpectator(req.cookies.id); + } + + //update the queue and reload all the pages + socketManager.sendToAll(new UpdateQueue([...names.values()])); + socketManager.sendToAll(new GameStartedMessage()); + } + }, 5000); }); // if there is an actual message, forward it to appropriate handler @@ -133,12 +252,38 @@ export const websocketHandler: WebsocketRequestHandler = (ws, req) => { await doDriveRobot(message); } else if (message instanceof SetRobotVariableMessage) { await doSetRobotVariable(message); + } else if (message instanceof JoinQueue) { + console.log("So we got the join message"); + // this was initially !isPlayer, shouldn't it be isPlayer? + if (!clientManager.isPlayer(req.cookies.id)) { + if (queue.find(req.cookies.id) === undefined) { + queue.insert(req.cookies.id, 0); + } + names.set(req.cookies.id, message.playerName); + socketManager.sendToAll(new UpdateQueue([...names.values()])); + } } }); }; export const apiRouter = Router(); +/** + * gets the current stored queue + */ +apiRouter.get("/get-queue", (_, res) => { + if (names) return res.send([...names.values()]); + else return res.send([]); +}); + +/** + * gets the name associated with the request cookie + */ +apiRouter.get("/get-name", (req, res) => { + if (names) return res.send({ message: names.get(req.cookies.id) }); + else return res.send(""); +}); + /** * client information endpoint * @@ -150,7 +295,8 @@ apiRouter.get("/client-information", async (req, res) => { const clientType = clientManager.getClientType(req.cookies.id); // loading saves from file if found const oldSave = SaveManager.loadGame(req.cookies.id); - if (oldSave) { + if (oldSave && DO_SAVES) { + console.log("ADACHI!!"); // if the game was an ai game, create a computer game manager with the ai difficulty if (oldSave.aiDifficulty !== -1) { const cgm = new ComputerGameManager( @@ -227,6 +373,7 @@ apiRouter.get("/game-state", (req, res) => { * returns a success message */ apiRouter.post("/start-computer-game", async (req, res) => { + canReloadQueue = true; const side = req.query.side as Side; const difficulty = parseInt(req.query.difficulty as string) as Difficulty; @@ -261,6 +408,7 @@ apiRouter.post("/start-computer-game", async (req, res) => { * returns a success message */ apiRouter.post("/start-human-game", async (req, res) => { + canReloadQueue = true; const side = req.query.side as Side; // Position robots from home to default positions before starting the game diff --git a/src/server/api/client-manager.ts b/src/server/api/client-manager.ts index 7717b768..b0892446 100644 --- a/src/server/api/client-manager.ts +++ b/src/server/api/client-manager.ts @@ -107,12 +107,31 @@ export class ClientManager { } } + public removeHost(): void { + this.hostId = undefined; + } + + public removeClient(): void { + this.clientId = undefined; + } + + public removeSpectator(id: string): void { + this.spectatorIds.delete(id); + } + + public isPlayer(id: string): boolean { + console.log( + `Id passed in is ${id}, host id is ${this.hostId}, and client id is ${this.clientId}`, + ); + return id === this.hostId || id === this.clientId; + } + /** * gets the ids of all currently connected clients * @returns - list of ids, if available */ public getIds(): undefined | [string, string] { - if (this.hostId && this.clientId) { + if (this.hostId !== undefined && this.clientId !== undefined) { return [this.hostId, this.clientId]; } else { return; diff --git a/src/server/api/managers.ts b/src/server/api/managers.ts index 7aa70ee5..f612212f 100644 --- a/src/server/api/managers.ts +++ b/src/server/api/managers.ts @@ -11,6 +11,6 @@ export const clientManager = new ClientManager(socketManager); export let gameManager: GameManager | null = null; export const disconnectedBots: Set = new Set(); -export function setGameManager(manager: GameManager) { +export function setGameManager(manager: GameManager | null) { gameManager = manager; } diff --git a/src/server/api/queue.ts b/src/server/api/queue.ts new file mode 100644 index 00000000..71496cc2 --- /dev/null +++ b/src/server/api/queue.ts @@ -0,0 +1,60 @@ +// adapted from https://itnext.io/priority-queue-in-typescript-6ef23116901 +/** + * An priority queue class since ts doesn't have one + * + * Inverted for ease of use with pricing (0 is lowest priority) + */ +export class PriorityQueue { + private data: [number, T][] = []; + + public insert(item: T, priority: number): boolean { + if (this.data.length === 0) { + this.data.push([priority, item]); + return true; + } + + for (let index = 0; index < this.data.length; index++) { + if (index === this.data.length - 1) { + this.data.push([priority, item]); + return true; + } + + if (this.data[index][0] < priority) { + this.data.splice(index, 0, [priority, item]); + return true; + } + } + return false; + } + public peek(): T | undefined { + return this.data.length === 0 ? undefined : this.data[0][1]; + } + public pop(): T | undefined { + return this.data !== undefined ? this.data.shift()?.[1] : undefined; + } + public popInd(num: number | undefined): T | undefined { + if (num !== undefined && this.data !== undefined) { + const data = this.data[num][1]; + // this.data.slice(0, num).concat(this.data.slice(num + 1, -1)); + this.data = this.data + .slice(0, num) + .concat(this.data.slice(num + 1)); + return data; + } + return undefined; + } + public size(): number { + return this.data.length; + } + public isEmpty(): boolean { + return this.data.length === 0; + } + public find(entry: T) { + for (let x = 0; x < this.data.length; x++) { + if (this.data[x][1] === entry) { + return x; + } + } + return undefined; + } +} diff --git a/src/server/utils/env.ts b/src/server/utils/env.ts index 6435c08d..9015558c 100644 --- a/src/server/utils/env.ts +++ b/src/server/utils/env.ts @@ -4,7 +4,7 @@ config(); export const IS_DEVELOPMENT = process.env.NODE_ENV === "development"; export const IS_PRODUCTION = !IS_DEVELOPMENT; -export const DO_SAVES = process.env.ENABLE_SAVES === "false"; +export const DO_SAVES = process.env.ENABLE_SAVES === "true"; export const USE_VIRTUAL_ROBOTS = process.env.VIRTUAL_ROBOTS === "true"; export const START_ROBOTS_AT_DEFAULT = process.env.START_ROBOTS_AT_DEFAULT === "true";