Skip to content
82 changes: 82 additions & 0 deletions src/client/game/admin-dialogs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {
Button,
Dialog,
DialogBody,
DialogFooter,
NonIdealState,
} from "@blueprintjs/core";
import { useState } from "react";
import { bgColor, buttonColor, textColor } from "../check-dark-mode";

interface DrawDialogProps {
dialogText?: string;
}

/**
* Shows a paused dialog that cannot be closed
* @param props - dialog text
* @returns - pause dialog
*/
export function PauseDialog(props: DrawDialogProps) {
const [isOpen, setIsOpen] = useState(true);
return (
<Dialog
className={bgColor()}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
canOutsideClickClose={false}
canEscapeKeyClose={false}
>
<DialogBody>
<NonIdealState>
<h4 className={textColor()}>
{props.dialogText || "Game Paused"}
</h4>
</NonIdealState>
</DialogBody>
</Dialog>
);
}

interface NotificationDialogProps {
dialogText: string;
}

/**
* Shows a closable notification dialog
* @param props - dialog text
* @returns - notification dialog
*/
export function NotificationDialog(props: NotificationDialogProps) {
const [isOpen, setIsOpen] = useState(true);

/** okay button */
const actions = (
<Button
text="Continue"
rightIcon="arrow-right"
className={buttonColor()}
intent="primary"
onClick={() => {
setIsOpen(false);
}}
/>
);

return (
<Dialog
className={bgColor()}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
canOutsideClickClose={true}
canEscapeKeyClose={true}
>
<DialogBody>
<NonIdealState>
<h4 className={textColor()}>{props.dialogText}</h4>
</NonIdealState>
</DialogBody>
<DialogFooter minimal actions={actions} />
</Dialog>
);
}
33 changes: 29 additions & 4 deletions src/client/game/game.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { NonIdealState, Spinner } from "@blueprintjs/core";
import { AcceptDrawDialog, OfferDrawDialog } from "./draw-dialog";
import { bgColor } from "../check-dark-mode";
import "../colors.css";
import { NotificationDialog, PauseDialog } from "./admin-dialogs";
import { PuzzleTipBox } from "../PuzzleTipBox";

/**
Expand All @@ -41,6 +42,7 @@ function getMessageHandler(
setGameInterruptedReason: Dispatch<GameInterruptedReason>,
setGameEndedReason: Dispatch<GameEndReason>,
setGameHoldReason: Dispatch<GameHoldReason>,
setPaused: Dispatch<boolean>,
): MessageHandler {
return (message) => {
if (message instanceof MoveMessage) {
Expand All @@ -63,6 +65,11 @@ function getMessageHandler(
setGameEndedReason(message.reason);
} else if (message instanceof GameHoldMessage) {
setGameHoldReason(message.reason);
if (message.reason === GameHoldReason.GAME_PAUSED) {
setPaused(true);
} else if (message.reason === GameHoldReason.GAME_UNPAUSED) {
setPaused(false);
}
}
};
}
Expand All @@ -78,6 +85,7 @@ export function Game(): JSX.Element {
const [gameEndedReason, setGameEndedReason] = useState<GameEndReason>();
const [gameHoldReason, setGameHoldReason] = useState<GameHoldReason>();
const [rotation, setRotation] = useState<number>(0);
const [paused, setPause] = useState<boolean>(false);

/** send any messages using our defined message handler inside a message socket for handling */
const sendMessage = useSocket(
Expand All @@ -87,6 +95,7 @@ export function Game(): JSX.Element {
setGameInterruptedReason,
setGameEndedReason,
setGameHoldReason,
setPause,
),
);

Expand All @@ -96,6 +105,7 @@ export function Game(): JSX.Element {
async () => {
return get("/game-state").then((gameState) => {
setChess(new ChessEngine(gameState.position));
setPause(gameState.pause);
if (gameState.gameEndReason !== undefined) {
setGameInterruptedReason(gameState.gameEndReason);
}
Expand Down Expand Up @@ -137,6 +147,7 @@ export function Game(): JSX.Element {
gameEndReason !== undefined ?
<GameEndDialog reason={gameEndReason} side={side} />
: null;

const gameOfferDialog =
gameHoldReason !== undefined ?
gameHoldReason === GameHoldReason.DRAW_CONFIRMATION ?
Expand All @@ -151,11 +162,23 @@ export function Game(): JSX.Element {
: null
: null;

const gamePauseDialog = paused ? <PauseDialog /> : null;

const gameUnpauseDialog =
gameHoldReason !== undefined ?
gameHoldReason === GameHoldReason.GAME_UNPAUSED ?
<NotificationDialog dialogText="Game Unpaused" />
: null
: null;

/** make moves by making a copy of the chessboard and sending the move message */
const handleMove = (move: Move): void => {
setChess(chess.copy(move));
sendMessage(new MoveMessage(move));
};
const handleMove =
!paused ?
(move: Move): void => {
setChess(chess.copy(move));
sendMessage(new MoveMessage(move));
}
: () => {}; //send a do-nothing function if game is paused

// return the chessboard wrapper, navbar, and potential end dialog
return (
Expand All @@ -182,6 +205,8 @@ export function Game(): JSX.Element {
{gameEndDialog}
{gameOfferDialog}
{gameAcceptDialog}
{gamePauseDialog}
{gameUnpauseDialog}
<Outlet />
</div>
</>
Expand Down
2 changes: 2 additions & 0 deletions src/common/game-end-reasons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,6 @@ export enum GameHoldReason {
DRAW_OFFERED = "draw-offered",
DRAW_CONFIRMATION = "draw-confirmation",
DRAW_DENIED = "draw-denied",
GAME_PAUSED = "game-paused",
GAME_UNPAUSED = "game-unpaused",
}
57 changes: 57 additions & 0 deletions src/server/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { RegisterWebsocketMessage } from "../../common/message/message";
import {
clientManager,
gameManager,
gamePaused,
setGameManager,
socketManager,
} from "./managers";
Expand Down Expand Up @@ -59,6 +60,7 @@ import {
import { tcpServer } from "./tcp-interface";
import { robotManager } from "../robot/robot-manager";
import { executor } from "../command/executor";
import { GameHoldReason } from "../../common/game-end-reasons";

/**
* Helper function to move all robots from their home positions to their default positions
Expand Down Expand Up @@ -184,6 +186,17 @@ apiRouter.get("/client-information", async (req, res) => {
),
);
}
const robotPos = new Map(
oldSave!.robotPos?.map<[string, GridIndices]>((obj) => [
obj[1],
new GridIndices(
parseInt(obj[0].split(", ")[0]),
parseInt(obj[0].split(", ")[1]),
),
]),
);
console.log(robotPos);
setAllRobotsToDefaultPositions(robotPos);
}
/**
* Note the client currently redirects to home from the game over screen
Expand Down Expand Up @@ -556,6 +569,50 @@ apiRouter.get("/get-puzzles", (_, res) => {
return res.send(out);
});

/**
* Pause the game
* Todo: add authentication instead of an exposed pause call
*/
apiRouter.get("/pause-game", (_, res) => {
Comment on lines +573 to +576
Copy link

Copilot AI Sep 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pause endpoint is publicly accessible without authentication as noted in the TODO. This allows anyone to pause games, which could be abused to disrupt gameplay.

Copilot uses AI. Check for mistakes.

gamePaused.flag = true;
robotManager.stopAllRobots();
socketManager.sendToAll(new GameHoldMessage(GameHoldReason.GAME_PAUSED));
return res.send({ message: "success" });
});

/**
* Unpause the game
* Todo: add authentication instead of an exposed unpause call
*/
apiRouter.get("/unpause-game", async (_, res) => {
Comment on lines +586 to +587
Copy link

Copilot AI Sep 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unpause endpoint is publicly accessible without authentication as noted in the TODO. This allows anyone to unpause games, which could be abused to disrupt gameplay.

Suggested change
*/
apiRouter.get("/unpause-game", async (_, res) => {
* Now requires a valid x-api-key header.
*/
apiRouter.get("/unpause-game", async (req, res) => {
// Simple authentication check using x-api-key header
const API_KEY = "CHANGE_ME_TO_A_SECRET_KEY"; // Replace with env variable if available
const clientKey = req.headers["x-api-key"];
if (clientKey !== API_KEY) {
return res.status(401).send({ message: "Unauthorized" });
}

Copilot uses AI. Check for mistakes.

if (gamePaused.flag) {
gamePaused.flag = false;
const ids = clientManager.getIds();
if (ids) {
const oldSave = SaveManager.loadGame(ids[0]);
gameManager?.chess.loadFen(oldSave!.oldPos);
setAllRobotsToDefaultPositions(
new Map(
oldSave!.oldRobotPos?.map<[string, GridIndices]>((obj) => [
obj[1],
new GridIndices(
parseInt(obj[0].split(", ")[0]),
parseInt(obj[0].split(", ")[1]),
),
]),
),
);
socketManager.sendToAll(new SetChessMessage(oldSave!.oldPos));
}
socketManager.sendToAll(
new GameHoldMessage(GameHoldReason.GAME_UNPAUSED),
);
return res.send({ message: "success" });
} else {
return res.send({ message: "game not paused" });
}
});

/**
* sends a drive message through the tcp connection
*
Expand Down
19 changes: 15 additions & 4 deletions src/server/api/game-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,15 @@ import { SaveManager } from "./save-manager";
import { materializePath } from "../robot/path-materializer";
import { DO_SAVES } from "../utils/env";
import { executor } from "../command/executor";
import { robotManager } from "../robot/robot-manager";
import { gamePaused } from "./managers";

type GameState = {
type?: "puzzle" | "human" | "computer";
side: Side;
position: string;
gameEndReason: GameEndReason | undefined;
pause: boolean;
tooltip?: string;
aiDifficulty?: number;
difficulty?: number;
Expand Down Expand Up @@ -89,6 +92,7 @@ export abstract class GameManager {
position: this.chess.pgn,
gameEndReason: this.getGameEndReason(),
tooltip: this.tooltip,
pause: gamePaused.flag,
};
}

Expand Down Expand Up @@ -143,8 +147,8 @@ export class HumanGameManager extends GameManager {
);
const ids = this.clientManager.getIds();
const currentSave = SaveManager.loadGame(id);
// update the internal chess object if it is a move massage
if (message instanceof MoveMessage) {
// update the internal chess object if it is a move massage and game not paused
if (message instanceof MoveMessage && !gamePaused.flag) {
// Call path materializer and send to bots
const command = materializePath(message.move);

Expand All @@ -163,6 +167,8 @@ export class HumanGameManager extends GameManager {
this.hostSide,
-1,
this.chess.pgn,
this.chess.fen,
robotManager.getIndicesToIds(),
);
} else {
SaveManager.saveGame(
Expand All @@ -171,6 +177,8 @@ export class HumanGameManager extends GameManager {
oppositeSide(this.hostSide),
-1,
this.chess.pgn,
this.chess.fen,
robotManager.getIndicesToIds(),
);
}
}
Expand Down Expand Up @@ -265,7 +273,7 @@ export class ComputerGameManager extends GameManager {
* @returns when the game ends
*/
public async handleMessage(message: Message, id: string): Promise<void> {
if (message instanceof MoveMessage) {
if (message instanceof MoveMessage && !gamePaused.flag) {
// Call path materializer and send to bots for human move
const command = materializePath(message.move);

Expand All @@ -281,6 +289,8 @@ export class ComputerGameManager extends GameManager {
this.hostSide,
this.difficulty,
this.chess.pgn,
this.chess.fen,
robotManager.getIndicesToIds(),
);
}

Expand Down Expand Up @@ -349,7 +359,8 @@ export class PuzzleGameManager extends GameManager {
//if the move is correct
if (
this.moves[this.moveNumber].from === message.move.from &&
this.moves[this.moveNumber].to === message.move.to
this.moves[this.moveNumber].to === message.move.to &&
!gamePaused.flag
) {
const command = materializePath(message.move);

Expand Down
1 change: 1 addition & 0 deletions src/server/api/managers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { SocketManager } from "./socket-manager";
export const socketManager = new SocketManager({});
export const clientManager = new ClientManager(socketManager);
export let gameManager: GameManager | null = null;
export const gamePaused = { flag: false };

export function setGameManager(manager: GameManager) {
gameManager = manager;
Expand Down
Loading