diff --git a/.env.example b/.env.example index 632ae79f..46abde08 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,9 @@ VIRTUAL_ROBOTS=false # Determines if robots should start on the board rather than home START_ROBOTS_AT_DEFAULT=false + +# How long before a command times out and pauses the game +MOVE_TIMEOUT=60000 + +# number of retries before an error +MAX_RETRIES=2; \ No newline at end of file diff --git a/src/client/game/game.tsx b/src/client/game/game.tsx index 737e88da..00d2a236 100644 --- a/src/client/game/game.tsx +++ b/src/client/game/game.tsx @@ -104,10 +104,10 @@ export function Game(): JSX.Element { "game-state", async () => { return get("/game-state").then((gameState) => { - setChess(new ChessEngine(gameState.state.position)); + setChess(new ChessEngine(gameState.position)); setPause(gameState.pause); - if (gameState.state.gameEndReason !== undefined) { - setGameInterruptedReason(gameState.state.gameEndReason); + if (gameState.gameEndReason !== undefined) { + setGameInterruptedReason(gameState.gameEndReason); } return gameState.state; }); diff --git a/src/server/api/game-manager.ts b/src/server/api/game-manager.ts index 7b6a9e61..084744a3 100644 --- a/src/server/api/game-manager.ts +++ b/src/server/api/game-manager.ts @@ -27,7 +27,7 @@ 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 "./pauseHandler"; +import { gamePaused, setPaused } from "./pauseHandler"; type GameState = { type?: "puzzle" | "human" | "computer"; @@ -156,7 +156,15 @@ export class HumanGameManager extends GameManager { console.log("running executor"); console.dir(command, { depth: null }); - await executor.execute(command); + await executor.execute(command).catch((reason) => { + setPaused(true); + console.log(reason); + this.chess.undo(); + this.socketManager.sendToAll( + new GameHoldMessage(GameHoldReason.GAME_PAUSED), + ); + return; + }); console.log("executor done"); if (ids && DO_SAVES) { @@ -280,7 +288,15 @@ export class ComputerGameManager extends GameManager { this.socketManager.sendToAll(new MoveMessage(message.move)); this.chess.makeMove(message.move); - await executor.execute(command); + await executor.execute(command).catch((reason) => { + setPaused(true); + console.log(reason); + this.chess.undo(); + this.socketManager.sendToAll( + new GameHoldMessage(GameHoldReason.GAME_PAUSED), + ); + return; + }); if (DO_SAVES) { SaveManager.saveGame( @@ -370,7 +386,15 @@ export class PuzzleGameManager extends GameManager { console.log("running executor"); console.dir(command, { depth: null }); - await executor.execute(command); + await executor.execute(command).catch((reason) => { + setPaused(true); + console.log(reason); + this.chess.undo(); + this.socketManager.sendToAll( + new GameHoldMessage(GameHoldReason.GAME_PAUSED), + ); + return; + }); console.log("executor done"); //if there is another move, make it diff --git a/src/server/api/save-manager.ts b/src/server/api/save-manager.ts index c88e93c0..5243a90f 100644 --- a/src/server/api/save-manager.ts +++ b/src/server/api/save-manager.ts @@ -11,6 +11,7 @@ */ import { Side } from "../../common/game-types"; +2; // Save files contain a date in ms and pgn string export interface iSave { @@ -56,7 +57,7 @@ export class SaveManager { game: pgn, pos: fen, robotPos: Array.from(robots), - oldPos: "", + oldPos: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", oldRobotPos: Array<[string, string]>(), }; const oldGame = SaveManager.loadGame(hostId + "+" + clientID); diff --git a/src/server/command/command.ts b/src/server/command/command.ts index 73c1cff3..df7ab708 100644 --- a/src/server/command/command.ts +++ b/src/server/command/command.ts @@ -1,5 +1,6 @@ import { gamePaused } from "../api/pauseHandler"; import { robotManager } from "../robot/robot-manager"; +import { MAX_RETRIES, MOVE_TIMEOUT } from "../utils/env"; /** * An command which operates on one or more robots. @@ -11,6 +12,11 @@ export interface Command { */ requirements: Set; + /** + * used for time calculations + */ + height: number; + /** * Executes the command. */ @@ -45,6 +51,8 @@ export interface Reversible> { export abstract class CommandBase implements Command { protected _requirements: Set = new Set(); + protected _height: number = 1; + public abstract execute(): Promise; public then(next: Command): SequentialCommandGroup { @@ -55,6 +63,14 @@ export abstract class CommandBase implements Command { return new ParallelCommandGroup([this, new WaitCommand(seconds)]); } + public get height(): number { + return this._height; + } + + public set height(height: number) { + this._height = height; + } + public get requirements(): Set { return this._requirements; } @@ -72,6 +88,7 @@ export abstract class CommandBase implements Command { * Note this class redirects the execute implementation to executeRobot. */ export abstract class RobotCommand extends CommandBase { + commandIsCompleted = false; constructor(public readonly robotId: string) { super(); // TO DISCUSS: idk if its possible for a robot object to change between adding it as a requrement and executing the command but if it is, adding the robot object as a requirement semi defeats the purpose of using robot ids everywhere @@ -86,6 +103,8 @@ export abstract class RobotCommand extends CommandBase { export class WaitCommand extends CommandBase { constructor(public readonly durationSec: number) { super(); + //in case there is a long wait that isn't accounted for in the regular timeout + this.height = durationSec / MOVE_TIMEOUT; } public async execute(): Promise { return new Promise((resolve) => @@ -112,21 +131,41 @@ function isReversable(obj): obj is Reversible { * Executes one or more commands in parallel. */ export class ParallelCommandGroup extends CommandGroup { + constructor(public readonly commands: Command[]) { + super(commands); + let max = 1; + for (let x = 0; x < commands.length; x++) { + if (commands[x].height > max) { + max = commands[x].height; + } + } + this.height = max; + } + public async execute(): Promise { const promises = this.commands .map((move) => { - if (!gamePaused) return move.execute(); + if (!gamePaused) return move.execute().catch(); + else return new Promise(() => {}).catch(); }) .filter(Boolean); - return Promise.all(promises).then(null); + if (promises) { + return timeoutRetry( + Promise.all(promises), + MAX_RETRIES, + this.height, + 0, + "Parallel Group Error", + ) as Promise; + } } public async reverse(): Promise { const promises = this.commands.map((move) => { if (isReversable(move)) { - move.reverse(); + move.reverse().catch(); } }); - return Promise.all(promises).then(null); + return Promise.all(promises).catch().then(null); } } @@ -134,23 +173,75 @@ export class ParallelCommandGroup extends CommandGroup { * Executes one or more commands in sequence, one after another. */ export class SequentialCommandGroup extends CommandGroup { + constructor(public readonly commands: Command[]) { + super(commands); + let sum = 0; + for (let x = 0; x < commands.length; x++) { + sum += commands[x].height; + } + this.height = sum; + } + public async execute(): Promise { let promise = Promise.resolve(); + for (const command of this.commands) { - promise = promise.then(() => { - if (!gamePaused) return command.execute(); - else return Promise.resolve(); - }); + promise = promise + .then(() => { + if (!gamePaused) return command.execute().catch(); + }) + .catch(); } - return promise; + + return timeoutRetry( + promise, + MAX_RETRIES, + this.height, + 0, + "Sequential Group Error", + ) as Promise; } + public async reverse(): Promise { let promise = Promise.resolve(); for (const command of this.commands) { if (isReversable(command)) { - promise = promise.then(() => command.reverse()); + promise = promise.then(() => command.reverse().catch()); } } - return promise; + return promise.catch(); } } + +export function timeoutRetry( + promise: Promise, + maxRetries: number, + height: number, + count: number, + debugInfo?: string, +): typeof promise { + const timeout = new Promise((_, rej) => { + //time for each move to execute plus time to handle errors + setTimeout( + () => { + rej("Move Timeout"); + }, + height * MOVE_TIMEOUT * maxRetries * 1.1, + ); + }).catch(); + return Promise.race([promise, timeout]).catch((reason) => { + if (reason.indexOf("Move Timeout") >= 0) { + if (count < MAX_RETRIES) { + return timeoutRetry( + promise, + maxRetries, + height, + count + 1, + debugInfo, + ).catch(); + } else { + throw `${reason} failed at height: ${height.toString()} with error: ${debugInfo} \\`; + } + } + }); +} diff --git a/src/server/command/executor.ts b/src/server/command/executor.ts index 415b7ff7..800ddca5 100644 --- a/src/server/command/executor.ts +++ b/src/server/command/executor.ts @@ -60,17 +60,15 @@ export class CommandExecutor { * @returns - The command to execute. */ public async finishExecution(): Promise { - return Promise.all( - this.runningCommands.map((command) => { - command.execute().finally(() => { - this.oldCommands.unshift(command); - const index = this.runningCommands.indexOf(command); - if (index >= 0) { - this.runningCommands.splice(index, 1); - } - }); - }), - ).then(); + return this.runningCommands.forEach((command) => { + command.execute().finally(() => { + this.oldCommands.unshift(command); + const index = this.runningCommands.indexOf(command); + if (index >= 0) { + this.runningCommands.splice(index, 1); + } + }); + }); } public clearExecution() { diff --git a/src/server/command/move-command.ts b/src/server/command/move-command.ts index 2aded6f1..c5acfdc2 100644 --- a/src/server/command/move-command.ts +++ b/src/server/command/move-command.ts @@ -1,8 +1,9 @@ import type { Reversible } from "./command"; -import { RobotCommand } from "./command"; +import { RobotCommand, timeoutRetry } from "./command"; import { Position } from "../robot/position"; import { GridIndices } from "../robot/grid-indices"; import { robotManager } from "../robot/robot-manager"; +import { MAX_RETRIES } from "../utils/env"; /** * Represents a rotation. @@ -25,7 +26,17 @@ export class RelativeRotateCommand { public async execute(): Promise { const robot = robotManager.getRobot(this.robotId); - return robot.relativeRotate(this.headingRadians); + return this.commandIsCompleted ? + Promise.resolve() + : (timeoutRetry( + robot.relativeRotate(this.headingRadians).then(() => { + this.commandIsCompleted = true; + }), + MAX_RETRIES, + this.height, + 0, + `Relative Rotate Command Error at Robot:${this.robotId}`, + ) as Promise); } public reverse(): RelativeRotateCommand { @@ -39,7 +50,17 @@ export class RelativeRotateCommand export class AbsoluteRotateCommand extends RotateCommand { public async execute(): Promise { const robot = robotManager.getRobot(this.robotId); - return robot.absoluteRotate(this.headingRadians); + return this.commandIsCompleted ? + Promise.resolve() + : (timeoutRetry( + robot.absoluteRotate(this.headingRadians).then(() => { + this.commandIsCompleted = true; + }), + MAX_RETRIES, + this.height, + 0, + `Absolute Rotate Command Error at Robot:${this.robotId}`, + ) as Promise); } } @@ -63,7 +84,17 @@ export class ReversibleAbsoluteRotateCommand public async execute(): Promise { const robot = robotManager.getRobot(this.robotId); this.previousHeadingRadians = robot.headingRadians; - return robot.absoluteRotate(this.headingSupplier()); + return this.commandIsCompleted ? + Promise.resolve() + : (timeoutRetry( + robot.absoluteRotate(this.headingSupplier()).then(() => { + this.commandIsCompleted = true; + }), + MAX_RETRIES, + this.height, + 0, + `Reversible Absolute Rotate Command Error at Robot:${this.robotId}`, + ) as Promise); } public reverse(): ReversibleAbsoluteRotateCommand { @@ -80,7 +111,17 @@ export class ReversibleAbsoluteRotateCommand export class RotateToStartCommand extends RobotCommand { public async execute(): Promise { const robot = robotManager.getRobot(this.robotId); - return robot.absoluteRotate(robot.startHeadingRadians); + return this.commandIsCompleted ? + Promise.resolve() + : (timeoutRetry( + robot.absoluteRotate(robot.startHeadingRadians).then(() => { + this.commandIsCompleted = true; + }), + MAX_RETRIES, + this.height, + 0, + `Rotate to Start Command Error at Robot:${this.robotId}`, + ) as Promise); } } @@ -98,13 +139,25 @@ export class DriveCubicSplineCommand extends RobotCommand { public async execute(): Promise { const robot = robotManager.getRobot(this.robotId); - return robot.sendDriveCubicPacket( + const promise = robot.sendDriveCubicPacket( this.startPosition, this.endPosition, this.controlPositionA, this.controlPositionB, this.timeDeltaMs, ); + + return this.commandIsCompleted ? + Promise.resolve() + : (timeoutRetry( + promise.then(() => { + this.commandIsCompleted = true; + }), + MAX_RETRIES, + this.height, + 0, + `Drive Cubic Command Error at Robot:${this.robotId}`, + ) as Promise); } } @@ -118,7 +171,19 @@ export class SpinRadiansCommand extends RobotCommand { } public async execute(): Promise { const robot = robotManager.getRobot(this.robotId); - return robot.sendSpinPacket(this.radians, this.timeDeltaMs); + return this.commandIsCompleted ? + Promise.resolve() + : (timeoutRetry( + robot + .sendSpinPacket(this.radians, this.timeDeltaMs) + .then(() => { + this.commandIsCompleted = true; + }), + MAX_RETRIES, + this.height, + 0, + `Spin Radians Command Error at Robot:${this.robotId}`, + ) as Promise); } } @@ -134,19 +199,41 @@ export class DriveQuadraticSplineCommand extends RobotCommand { } public async execute(): Promise { const robot = robotManager.getRobot(this.robotId); - return robot.sendDriveQuadraticPacket( + const promise = robot.sendDriveQuadraticPacket( this.startPosition, this.endPosition, this.controlPosition, this.timeDeltaMs, ); + + return this.commandIsCompleted ? + Promise.resolve() + : (timeoutRetry( + promise.then(() => { + this.commandIsCompleted = true; + }), + MAX_RETRIES, + this.height, + 0, + `Drive Quadratic Command Error at Robot:${this.robotId}`, + ) as Promise); } } export class StopCommand extends RobotCommand { public async execute(): Promise { const robot = robotManager.getRobot(this.robotId); - return robot.sendDrivePacket(0); + return this.commandIsCompleted ? + Promise.resolve() + : (timeoutRetry( + robot.sendDrivePacket(0).then(() => { + this.commandIsCompleted = true; + }), + MAX_RETRIES, + this.height, + 0, + `Stop Command Error at Robot:${this.robotId}`, + ) as Promise); } } @@ -181,7 +268,17 @@ export class DriveCommand this.robotId, GridIndices.fromPosition(robot.position), ); - return robot.sendDrivePacket(this.tileDistance); + return this.commandIsCompleted ? + Promise.resolve() + : (timeoutRetry( + robot.sendDrivePacket(this.tileDistance).then(() => { + this.commandIsCompleted = true; + }), + MAX_RETRIES, + this.height, + 0, + `Drive Command Error at Robot:${this.robotId}`, + ) as Promise); } public reverse(): DriveCommand { @@ -218,7 +315,17 @@ export class RelativeMoveCommand this.robotId, GridIndices.fromPosition(robot.position.add(this.position)), ); - return robot.relativeMove(this.position); + return this.commandIsCompleted ? + Promise.resolve() + : (timeoutRetry( + robot.relativeMove(this.position).then(() => { + this.commandIsCompleted = true; + }), + MAX_RETRIES, + this.height, + 0, + `Relative Move Command Error at Robot:${this.robotId}`, + ) as Promise); } public reverse(): RelativeMoveCommand { @@ -236,7 +343,19 @@ export class AbsoluteMoveCommand extends MoveCommand { this.robotId, GridIndices.fromPosition(this.position), ); - return robot.relativeMove(this.position.sub(robot.position)); + return this.commandIsCompleted ? + Promise.resolve() + : (timeoutRetry( + robot + .relativeMove(this.position.sub(robot.position)) + .then(() => { + this.commandIsCompleted = true; + }), + MAX_RETRIES, + this.height, + 0, + `Absolute Move Command Error at Robot:${this.robotId}`, + ) as Promise); } } @@ -253,7 +372,17 @@ export class DriveTicksCommand public async execute(): Promise { const robot = robotManager.getRobot(this.robotId); - return robot.sendDriveTicksPacket(this.ticksDistance); + return this.commandIsCompleted ? + Promise.resolve() + : (timeoutRetry( + robot.sendDriveTicksPacket(this.ticksDistance).then(() => { + this.commandIsCompleted = true; + }), + MAX_RETRIES, + this.height, + 0, + `Drive Ticks Command Error at Robot:${this.robotId}`, + ) as Promise); } public reverse(): DriveTicksCommand { @@ -281,7 +410,21 @@ export class ReversibleAbsoluteMoveCommand public async execute(): Promise { const robot = robotManager.getRobot(this.robotId); this.previousPosition = robot.position; - return robot.relativeMove(this.positionSupplier().sub(robot.position)); + return this.commandIsCompleted ? + Promise.resolve() + : (timeoutRetry( + robot + .relativeMove( + this.positionSupplier().sub(robot.position), + ) + .then(() => { + this.commandIsCompleted = true; + }), + MAX_RETRIES, + this.height, + 0, + `Reversible Absolute Rotate Command Error at Robot:${this.robotId}`, + ) as Promise); } public reverse(): ReversibleAbsoluteMoveCommand { diff --git a/src/server/utils/env.ts b/src/server/utils/env.ts index 781ef4ad..9015558c 100644 --- a/src/server/utils/env.ts +++ b/src/server/utils/env.ts @@ -8,6 +8,10 @@ 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"; +export const MOVE_TIMEOUT: number = + process.env.MOVE_TIMEOUT ? parseInt(process.env.MOVE_TIMEOUT) : 1000; +export const MAX_RETRIES: number = + process.env.MAX_RETRIES ? parseInt(process.env.MAX_RETRIES) : 2; export const PING_INTERVAL = 1000; export const PING_TIMEOUT = 100;