diff --git a/src/client/api.ts b/src/client/api.ts index 5dd48508..aa258d02 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -64,6 +64,7 @@ export function useSocket( onMessage(message); } }, + share: true, }); // handle how a message is sent diff --git a/src/client/game/game.tsx b/src/client/game/game.tsx index 74c0dcb0..bf26d5ec 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"; @@ -166,17 +167,20 @@ export function Game(): JSX.Element { aiDifficulty={data.aiDifficulty} setRotation={setRotation} /> -
- - {gameEndDialog} - {gameOfferDialog} - {gameAcceptDialog} - + +
+
+ + {gameEndDialog} + {gameOfferDialog} + {gameAcceptDialog} + +
); 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..583a16a9 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(props): JSX.Element { + props; + 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..9d140b01 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 queue: string) { + super(); + } + + protected type = MessageType.JOIN_QUEUE; + + protected toObj(): object { + return { + ...super.toObj(), + queue: this.queue, + }; + } +} + +export class UpdateQueue extends Message { + constructor(public readonly queue: string[]) { + super(); + } + + protected type = MessageType.UPDATE_QUEUE; + + protected toObj(): object { + return { + ...super.toObj(), + queue: this.queue, + }; + } +} 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..29533661 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.queue); + case MessageType.UPDATE_QUEUE: + return new UpdateQueue(obj.queue); 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 ef382121..89b9246e 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 { @@ -37,6 +41,8 @@ import { VirtualBotTunnel, VirtualRobot } 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"; @@ -106,6 +112,12 @@ function setAllRobotsToDefaultPositions( } } +const queue = new PriorityQueue(); +const names = new Map(); + +//let the queue be moved once per game +let onlyOnce = true; + /** * An endpoint used to establish a websocket connection with the server. * @@ -115,6 +127,108 @@ export const websocketHandler: WebsocketRequestHandler = (ws, req) => { // on close, delete the cookie id ws.on("close", () => { socketManager.handleSocketClosed(req.cookies.id); + + //if you reload and the game is over + if (gameManager?.isGameEnded() && onlyOnce) { + //make the reassignment occur once per game instead of once per reload + onlyOnce = 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 @@ -138,12 +252,44 @@ export const websocketHandler: WebsocketRequestHandler = (ws, req) => { await doDriveRobot(message); } else if (message instanceof SetRobotVariableMessage) { await doSetRobotVariable(message); + } else if (message instanceof JoinQueue) { + 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.queue); + 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(""); +}); + +/** + * 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 * @@ -218,6 +364,7 @@ apiRouter.get("/game-state", (req, res) => { * returns a success message */ apiRouter.post("/start-computer-game", async (req, res) => { + onlyOnce = true; const side = req.query.side as Side; const difficulty = parseInt(req.query.difficulty as string) as Difficulty; @@ -252,6 +399,7 @@ apiRouter.post("/start-computer-game", async (req, res) => { * returns a success message */ apiRouter.post("/start-human-game", async (req, res) => { + onlyOnce = 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..c0124d0d 100644 --- a/src/server/api/client-manager.ts +++ b/src/server/api/client-manager.ts @@ -107,12 +107,28 @@ 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 { + 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 37804692..8e5f3dd2 100644 --- a/src/server/api/managers.ts +++ b/src/server/api/managers.ts @@ -10,6 +10,6 @@ export const socketManager = new SocketManager({}); export const clientManager = new ClientManager(socketManager); export let gameManager: GameManager | null = null; -export function setGameManager(manager: GameManager) { +export function setGameManager(manager: GameManager | null) { gameManager = manager; } diff --git a/src/server/api/queue.tsx b/src/server/api/queue.tsx new file mode 100644 index 00000000..1c44f37b --- /dev/null +++ b/src/server/api/queue.tsx @@ -0,0 +1,57 @@ +// 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)); + 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/main.ts b/src/server/main.ts index 3e17f561..9e6adfc9 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -56,3 +56,5 @@ app.use("/api", apiRouter); ViteExpress.listen(app as unknown as Express, 3000, () => { console.log("Server is listening on port 3000."); }); + +app.addListener("beforeunload", () => {});