diff --git a/packages/server/src/games.ts b/packages/server/src/games.ts index 44080579..0efa7ff8 100644 --- a/packages/server/src/games.ts +++ b/packages/server/src/games.ts @@ -8,7 +8,9 @@ import { import { ObjectId, WithId, Document } from "mongodb"; import { getDb } from "./db"; import { io } from "./socket_io"; +import { getTimeoutService } from "./index"; import { + GetInitialTimeControl, HasTimeControlConfig, timeControlHandlerMap, ValidateTimeControlConfig, @@ -35,6 +37,13 @@ export async function getGames( return (await games).map(outwardFacingGame); } +export async function getGamesWithTimeControl(): Promise { + const games = gamesCollection() + .find({ time_control: { $ne: null } }) + .toArray(); + return (await games).map(outwardFacingGame); +} + export async function getGame(id: string): Promise { const db_game = await gamesCollection().findOne({ _id: new ObjectId(id), @@ -42,14 +51,12 @@ export async function getGame(id: string): Promise { // TODO: db_game might be undefined if unknown ID is provided - console.log(db_game); const game = outwardFacingGame(db_game); // Legacy games don't have a players field // TODO: remove this code after doing proper db migration if (!game.players) { game.players = await BACKFILL_addEmptyPlayersArray(game); } - console.log(game); return game; } @@ -62,6 +69,7 @@ export async function createGame( variant: variant, moves: [] as MovesType[], config: config, + time_control: GetInitialTimeControl(variant, config), }; const result = await gamesCollection().insertOne(game); @@ -94,13 +102,13 @@ export async function playMove( throw Error("Game is already finished."); } - const move = getOnlyMove(moves); + const { player: playerNr, move: new_move } = getOnlyMove(moves); - if (!game.players || game.players[move.player] == null) { - throw Error(`Seat ${move.player} not occupied!`); + if (!game.players || game.players[playerNr] == null) { + throw Error(`Seat ${playerNr} not occupied!`); } - const expected_player = game.players[move.player]; + const expected_player = game.players[playerNr]; if (expected_player.id !== user_id) { throw Error( @@ -108,19 +116,26 @@ export async function playMove( ); } - const { player, move: new_move } = getOnlyMove(moves); - game_obj.playMove(player, new_move); + game_obj.playMove(playerNr, new_move); + let timeControl = game.time_control; if ( HasTimeControlConfig(game.config) && ValidateTimeControlConfig(game.config.time_control) ) { - const timeHandler = new timeControlHandlerMap[game.variant](); - await timeHandler.handleMove(game, game_obj, move.player, move.move); + if (game_obj.result !== "") { + getTimeoutService().clearGameTimeouts(game.id); + } else { + const timeHandler = new timeControlHandlerMap[game.variant](); + timeControl = timeHandler.handleMove(game, game_obj, playerNr, new_move); + } } gamesCollection() - .updateOne({ _id: new ObjectId(game_id) }, { $push: { moves: moves } }) + .updateOne( + { _id: new ObjectId(game_id) }, + { $push: { moves: moves }, $set: { time_control: timeControl } }, + ) .catch(console.log); game.moves.push(moves); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f838990b..e2e86868 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -18,8 +18,9 @@ import { Strategy as LocalStrategy } from "passport-local"; import { UserResponse } from "@ogfcommunity/variants-shared"; import { router as apiRouter } from "./api"; import * as socket_io from "./socket_io"; +import { TimeoutService } from "./timeout"; -const LOCAL_ORIGIN = "http://localhost:3000"; +const LOCAL_ORIGIN = "http://localhost:5173"; passport.use( new LocalStrategy(async function (username, password, callback) { @@ -36,11 +37,18 @@ passport.use( }), ); +const timeoutService = new TimeoutService(); +export function getTimeoutService(): TimeoutService { + return timeoutService; +} + // Initialize MongoDB -connectToDb().catch((e) => { - console.log("Unable to connect to the database."); - console.log(e); -}); +connectToDb() + .then(() => timeoutService.initialize()) + .catch((e) => { + console.log("Unable to connect to the database."); + console.log(e); + }); passport.use( "guest", diff --git a/packages/server/src/time-control.ts b/packages/server/src/time-control.ts index 5e104a9a..1a8202e5 100644 --- a/packages/server/src/time-control.ts +++ b/packages/server/src/time-control.ts @@ -1,13 +1,19 @@ -import { ObjectId } from "mongodb"; import { TimeControlType, ITimeControlBase, ITimeControlConfig, IConfigWithTimeControl, + makeGameObject, + TimeControlParallel, } from "@ogfcommunity/variants-shared"; -import { gamesCollection } from "./games"; import { AbstractGame, GameResponse } from "@ogfcommunity/variants-shared"; +import { TimeoutService } from "./timeout"; +import { getTimeoutService } from "./index"; +/** + * Validates whether game_config has type and + * properties for being a config with time control. + */ export function HasTimeControlConfig( game_config: unknown, ): game_config is IConfigWithTimeControl { @@ -18,6 +24,10 @@ export function HasTimeControlConfig( ); } +/** + * Validates whether time_control_config has type and + * properties for being a time control config. + */ export function ValidateTimeControlConfig( time_control_config: unknown, ): time_control_config is ITimeControlConfig { @@ -29,6 +39,10 @@ export function ValidateTimeControlConfig( ); } +/** + * Validates whether time_control has type and + * properties for being a basic time control data object. + */ export function ValidateTimeControlBase( time_control: unknown, ): time_control is ITimeControlBase { @@ -40,100 +54,343 @@ export function ValidateTimeControlBase( ); } +/** + * Returns the initial state of a time control data object + * for a new game, if it has time control at all. + */ +export function GetInitialTimeControl( + variant: string, + config: object, +): ITimeControlBase | null { + if (!HasTimeControlConfig(config)) return null; + + const handler = new timeControlHandlerMap[variant](); + return handler.initialState(variant, config); +} + // validation of the config should happen before this is called export interface ITimeHandler { + /** + * Returns the initial state of a time control data object + * that this handler works with. + */ + initialState( + variant: string, + config: IConfigWithTimeControl, + ): ITimeControlBase; + + /** + * Transitions the time control data when a move is played. + * Schedules timeouts for next players, and cancels old + * scheduled timeouts if necessary. + */ handleMove( game: GameResponse, game_obj: AbstractGame, playerNr: number, move: string, - ): Promise; + ): ITimeControlBase; + + /** + * Returns the time in milliseconds until this player + * times out, provided no moves are played. + */ + getMsUntilTimeout(game: GameResponse, playerNr: number): number; } class TimeHandlerSequentialMoves implements ITimeHandler { - async handleMove( - game: GameResponse, - game_obj: AbstractGame, - playerNr: number, - ): Promise { - if (!HasTimeControlConfig(game.config)) { - console.log("has no time control config"); + private _timeoutService: TimeoutService; + constructor() { + this._timeoutService = getTimeoutService(); + } + + initialState( + variant: string, + config: IConfigWithTimeControl, + ): ITimeControlBase { + const numPlayers = makeGameObject(variant, config).numPlayers(); - return; + const timeControl: ITimeControlBase = { + moveTimestamps: [], + forPlayer: {}, + }; + + for (let i = 0; i < numPlayers; i++) { + timeControl.forPlayer[i] = { + remainingTimeMS: config.time_control.mainTimeMS, + onThePlaySince: null, + }; } - switch (game.config.time_control.type) { + switch (config.time_control.type) { case TimeControlType.Absolute: { - let timeData: ITimeControlBase = game.time_control; - if (timeData === undefined) { - timeData = { - moveTimestamps: [], - forPlayer: {}, - }; - } + // nothing to do + break; + } - if (timeData.forPlayer[playerNr] === undefined) { - timeData.forPlayer[playerNr] = { - remainingTimeMS: game.config.time_control.mainTimeMS, - onThePlaySince: undefined, - }; - } + case TimeControlType.Fischer: { + // nothing to do + break; + } - const nextPlayers = game_obj.nextToPlay(); + default: { + console.error("received config with invalid time control type"); + } + } - if (timeData.forPlayer[playerNr] === null) { - console.error(`game with id ${game.id} has defect time control data`); - return; - } + return timeControl; + } - const playerData = timeData.forPlayer[playerNr]; + handleMove( + game: GameResponse, + game_obj: AbstractGame, + playerNr: number, + ): ITimeControlBase { + const config = game.config as IConfigWithTimeControl; + switch (config.time_control.type) { + case TimeControlType.Absolute: { + let timeControl = game.time_control; + // could happen for old games + if (timeControl === undefined) { + timeControl = this.initialState(game.variant, config); + } + const playerData = timeControl.forPlayer[playerNr]; const timestamp = new Date(); - timeData.moveTimestamps.push(timestamp); - if (playerData.onThePlaySince !== undefined) { - // with this construction, time will not be substracted before the first move of a player - // which is good imho + timeControl.moveTimestamps.push(timestamp); + + if (playerData.onThePlaySince !== null) { playerData.remainingTimeMS -= timestamp.getTime() - playerData.onThePlaySince.getTime(); } playerData.onThePlaySince = null; + this._timeoutService.clearPlayerTimeout(game.id, playerNr); + + // time control starts only after the first move of player + const nextPlayers = game_obj + .nextToPlay() + .filter((playerNr) => game.moves.some((move) => playerNr in move)); nextPlayers.forEach((player) => { - if ( - timeData.forPlayer[player] && - timeData.forPlayer[player].remainingTimeMS - ) { - timeData.forPlayer[player].onThePlaySince = timestamp; - } + timeControl.forPlayer[player].onThePlaySince = timestamp; + this._timeoutService.scheduleTimeout( + game.id, + player, + timeControl.forPlayer[player].remainingTimeMS, + ); }); - await gamesCollection() - .updateOne( - { _id: new ObjectId(game.id) }, - { $set: { time_control: timeData } }, - ) - .catch(console.log); - break; + return timeControl; } case TimeControlType.Invalid: + throw Error(`game with id ${game.id} has invalid time control type`); + } + } + + getMsUntilTimeout(game: GameResponse, playerNr: number): number | null { + if (!HasTimeControlConfig(game.config)) { + console.log("has no time control config"); + + return null; + } + + switch (game.config.time_control.type) { + case TimeControlType.Absolute: + case TimeControlType.Fischer: { + const times: ITimeControlBase = game.time_control; + + if (times === undefined || times.forPlayer === undefined) { + // old game with no moves + return null; + } + + const playerTime = times.forPlayer[playerNr]; + + if (playerTime === undefined || playerTime.onThePlaySince === null) { + // player has not played a move yet + // or player is not on the play + return null; + } + + const timeoutTime = + playerTime.onThePlaySince.getTime() + playerTime.remainingTimeMS; + + return timeoutTime - new Date().getTime(); + } + default: { console.error(`game with id ${game.id} has invalid time control type`); return; + } } } } class TimeHandlerParallelMoves implements ITimeHandler { + private _timeoutService: TimeoutService; + constructor() { + this._timeoutService = getTimeoutService(); + } + + initialState( + variant: string, + config: IConfigWithTimeControl, + ): TimeControlParallel { + const numPlayers = makeGameObject(variant, config).numPlayers(); + + const timeControl: TimeControlParallel = { + moveTimestamps: [], + forPlayer: {}, + }; + + for (let i = 0; i < numPlayers; i++) { + timeControl.forPlayer[i] = { + remainingTimeMS: config.time_control.mainTimeMS, + onThePlaySince: null, + stagedMoveAt: null, + }; + } + + switch (config.time_control.type) { + case TimeControlType.Absolute: { + // nothing to do + break; + } + + case TimeControlType.Fischer: { + // nothing to do + break; + } + + default: { + console.error("received config with invalid time control type"); + } + } + + return timeControl; + } + handleMove( - _game: GameResponse, - _game_obj: AbstractGame, - _playerNr: number, - _move: string, - ): Promise { - console.log( - "time control handler for parallel moves is not implemented yet", - ); - return; + game: GameResponse, + game_obj: AbstractGame, + playerNr: number, + move: string, + ): ITimeControlBase { + const config = game.config as IConfigWithTimeControl; + + switch (config.time_control.type) { + case TimeControlType.Absolute: { + let timeControl = game.time_control as TimeControlParallel; + // could happen for old games + if (timeControl === undefined) { + timeControl = this.initialState(game.variant, config); + } + + const playerTimeControl = timeControl.forPlayer[playerNr]; + const timestamp = new Date(); + + if (!(move === "resign") && !(move === "timeout")) { + timeControl.forPlayer[playerNr].stagedMoveAt = timestamp; + + if ( + playerTimeControl.onThePlaySince !== null && + timeControl.forPlayer[playerNr].remainingTimeMS <= + timestamp.getTime() - playerTimeControl.onThePlaySince.getTime() + ) { + throw new Error( + "you can't change your move because it would reduce your remaining time to zero.", + ); + } + } + + timeControl.moveTimestamps.push(timestamp); + this._timeoutService.clearPlayerTimeout(game.id, playerNr); + + // check if the round finishes with this move + // + // I'm not sure if this will always work as intended + // because this is the game object after the move has been played + // whereas we actually need the players that are on the play in the round that the move belongs to + if ( + game_obj + .nextToPlay() + .every( + (player_nr) => + timeControl.forPlayer[player_nr].stagedMoveAt !== null, + ) + ) { + for (const player_nr of game_obj.nextToPlay()) { + const playerData = timeControl.forPlayer[player_nr]; + + // time control starts only after first move of player + if ( + game.moves.filter((move) => player_nr in move).length > + (player_nr === playerNr ? 0 : 1) + ) { + playerData.remainingTimeMS -= + playerData.stagedMoveAt.getTime() - + playerData.onThePlaySince.getTime(); + } + + this._timeoutService.scheduleTimeout( + game.id, + player_nr, + timeControl.forPlayer[player_nr].remainingTimeMS, + ); + playerData.onThePlaySince = timestamp; + playerData.stagedMoveAt = null; + } + } + + return timeControl; + } + + case TimeControlType.Invalid: + throw Error(`game with id ${game.id} has invalid time control type`); + } + } + + getMsUntilTimeout(game: GameResponse, playerNr: number): number { + if (!HasTimeControlConfig(game.config)) { + console.log("has no time control config"); + + return null; + } + + switch (game.config.time_control.type) { + case TimeControlType.Absolute: + case TimeControlType.Fischer: { + const times: TimeControlParallel = + game.time_control as TimeControlParallel; + + if (times === undefined || times.forPlayer === undefined) { + // old game with no moves + return null; + } + + const playerTime = times.forPlayer[playerNr]; + + if (playerTime === undefined || playerTime.onThePlaySince === null) { + // player has not played a move yet + // or player is not on the play + return null; + } + + if (playerTime.stagedMoveAt !== null) { + return null; + } + + const timeoutTime = + playerTime.onThePlaySince.getTime() + playerTime.remainingTimeMS; + + return timeoutTime - new Date().getTime(); + } + + default: { + console.error(`game with id ${game.id} has invalid time control type`); + return; + } + } } } diff --git a/packages/server/src/timeout.ts b/packages/server/src/timeout.ts new file mode 100644 index 00000000..5274f6e4 --- /dev/null +++ b/packages/server/src/timeout.ts @@ -0,0 +1,154 @@ +import { gamesCollection, getGame, getGamesWithTimeControl } from "./games"; +import { + MovesType, + getOnlyMove, + makeGameObject, +} from "@ogfcommunity/variants-shared"; +import { timeControlHandlerMap } from "./time-control"; +import { ObjectId } from "mongodb"; +import { io } from "./socket_io"; + +type GameTimeouts = { + // timeout for this player, or null + // used to clear the timeout timer + [player: number]: ReturnType | null; +}; + +export class TimeoutService { + private timeoutsByGame = new Map(); + + /** + * initializes timeout schedules + */ + public async initialize(): Promise { + // I would like to improve this by only querying unfinished games from db + // but currently I think its not possible, because of the game result + // is not being stored in the db directly + for (const game of await getGamesWithTimeControl()) { + const game_object = makeGameObject(game.variant, game.config); + + try { + // check if game is already finished + game.moves.forEach((moves) => { + const { player, move } = getOnlyMove(moves); + game_object.playMove(player, move); + }); + + if (game_object.result !== "" || game_object.phase == "gameover") { + continue; + } + + const timeHandler = new timeControlHandlerMap[game.variant](); + for (const playerNr of game_object.nextToPlay()) { + const t = timeHandler.getMsUntilTimeout(game, playerNr); + this.scheduleTimeout(game.id, playerNr, t); + } + } catch (error) { + console.error(error); + continue; + } + } + } + + /** + * Stores a timeout-id or null for this player + game. + * If an id was previously stored, the corresponding timeout is cleared. + */ + private setPlayerTimeout( + gameId: string, + playerNr: number, + timeout: ReturnType | null, + ): void { + let gameTimeouts = this.timeoutsByGame.get(gameId); + + if (gameTimeouts === undefined) { + gameTimeouts = {}; + this.timeoutsByGame.set(gameId, gameTimeouts); + } else { + const playerTimeout = gameTimeouts[playerNr]; + if (playerTimeout) { + clearTimeout(playerTimeout); + } + } + + gameTimeouts[playerNr] = timeout; + } + + /** + * Clears the timeout-ids of this game. + */ + public clearGameTimeouts(gameId: string): void { + const gameTimeouts = this.timeoutsByGame.get(gameId); + + if (gameTimeouts !== undefined) { + Object.values(gameTimeouts) + .filter((t) => t !== null) + .forEach(clearTimeout); + this.timeoutsByGame.delete(gameId); + } + } + + public clearPlayerTimeout(gameId: string, playerNr: number): void { + this.setPlayerTimeout(gameId, playerNr, null); + } + + /** + * Schedules a timeout for this player + game. + * Timeout is resolved by adding a timeout move + * and applying the time control handler again. + */ + public scheduleTimeout( + gameId: string, + playerNr: number, + inTimeMs: number, + ): void { + const timeoutResolver = async () => { + const game = await getGame(gameId); + + const timeoutMove: MovesType = { [playerNr]: "timeout" }; + game.moves.push(timeoutMove); + let timeControl = game.time_control; + + // this next part is somewhat duplicated from the playMove function + // which I don't like. But for parallel variants and consistency (move timestamps), + // the timeout move needs to be handled as well. + try { + const game_object = makeGameObject(game.variant, game.config); + game.moves.forEach((moves) => { + const { player, move } = getOnlyMove(moves); + game_object.playMove(player, move); + }); + + if (game_object.result !== "" || game_object.phase === "gameover") { + this.clearGameTimeouts(game.id); + } else { + const timeHandler = new timeControlHandlerMap[game.variant](); + timeControl = timeHandler.handleMove( + game, + game_object, + playerNr, + "timeout", + ); + } + } catch (error) { + console.error(error); + } + + // TODO: improving the error handling would be great in future + await gamesCollection() + .updateOne( + { _id: new ObjectId(gameId) }, + { + $push: { moves: timeoutMove }, + $set: { time_control: timeControl }, + }, + ) + .catch(console.log); + + io().emit(`game/${gameId}`, game); + }; + + const timeout = setTimeout(timeoutResolver, inTimeMs); + this.setPlayerTimeout(gameId, playerNr, timeout); + } +} diff --git a/packages/shared/src/abstract_game.ts b/packages/shared/src/abstract_game.ts index b6486ec0..b0243da2 100644 --- a/packages/shared/src/abstract_game.ts +++ b/packages/shared/src/abstract_game.ts @@ -27,6 +27,7 @@ export abstract class AbstractGame { /** * Returns the list of players that need to play a move the next round. + * Returns empty array when the game is finished. */ abstract nextToPlay(): number[]; diff --git a/packages/shared/src/lib/utils.ts b/packages/shared/src/lib/utils.ts index 8a057c04..9f5f7992 100644 --- a/packages/shared/src/lib/utils.ts +++ b/packages/shared/src/lib/utils.ts @@ -16,3 +16,8 @@ export function getOnlyMove(moves: MovesType): { const player = Number(players[0]); return { player, move: moves[player] }; } + +export type Participation = { + playerNr: number; + dropOutAtRound: number | null; +}; diff --git a/packages/shared/src/time_control/time_control.types.ts b/packages/shared/src/time_control/time_control.types.ts index 9fd24986..3d4a5b8c 100644 --- a/packages/shared/src/time_control/time_control.types.ts +++ b/packages/shared/src/time_control/time_control.types.ts @@ -29,3 +29,14 @@ export interface ITimeControlBase { [player: number]: IPerPlayerTimeControlBase; }; } + +export type PerPlayerTimeControlParallel = IPerPlayerTimeControlBase & { + stagedMoveAt: Date | null; +}; + +export type TimeControlParallel = { + moveTimestamps: Date[]; + forPlayer: { + [player: number]: PerPlayerTimeControlParallel; + }; +}; diff --git a/packages/shared/src/variants/baduk.ts b/packages/shared/src/variants/baduk.ts index fac1d013..3c5118b1 100644 --- a/packages/shared/src/variants/baduk.ts +++ b/packages/shared/src/variants/baduk.ts @@ -52,7 +52,7 @@ export class Baduk extends AbstractGame { } override nextToPlay(): number[] { - return [this.next_to_play]; + return this.phase === "gameover" ? [] : [this.next_to_play]; } override playMove(player: number, move: string): void { @@ -66,6 +66,12 @@ export class Baduk extends AbstractGame { return; } + if (move === "timeout") { + this.phase = "gameover"; + this.result = player === 0 ? "W+T" : "B+T"; + return; + } + if (move != "pass") { const decoded_move = Coordinate.fromSgfRepr(move); const { x, y } = decoded_move; @@ -94,7 +100,7 @@ export class Baduk extends AbstractGame { } override specialMoves() { - return { pass: "Pass", resign: "Resign" }; + return { pass: "Pass", resign: "Resign", timeout: "Timeout" }; } private playMoveInternal(move: Coordinate): void { diff --git a/packages/shared/src/variants/badukWithAbstractBoard/badukWithAbstractBoard.ts b/packages/shared/src/variants/badukWithAbstractBoard/badukWithAbstractBoard.ts index e21d9e19..09889ee4 100644 --- a/packages/shared/src/variants/badukWithAbstractBoard/badukWithAbstractBoard.ts +++ b/packages/shared/src/variants/badukWithAbstractBoard/badukWithAbstractBoard.ts @@ -52,7 +52,7 @@ export class BadukWithAbstractBoard extends AbstractGame< } nextToPlay(): number[] { - return [this.next_to_play]; + return this.phase === "gameover" ? [] : [this.next_to_play]; } playMove(player: number, move: string): void { @@ -76,6 +76,12 @@ export class BadukWithAbstractBoard extends AbstractGame< return; } + if (move === "timeout") { + this.phase = "gameover"; + this.result = player === 0 ? "W+T" : "B+T"; + return; + } + const decoded_move = decodeMove(move); if (isOutOfBounds(decoded_move, this.board)) { throw Error( @@ -120,7 +126,7 @@ export class BadukWithAbstractBoard extends AbstractGame< } specialMoves() { - return { pass: "Pass", resign: "Resign" }; + return { pass: "Pass", resign: "Resign", timeout: "Timeout" }; } defaultConfig(): BadukWithAbstractBoardConfig { diff --git a/packages/shared/src/variants/chess.ts b/packages/shared/src/variants/chess.ts index 02bb0417..e3414f90 100644 --- a/packages/shared/src/variants/chess.ts +++ b/packages/shared/src/variants/chess.ts @@ -10,6 +10,18 @@ export class ChessGame extends AbstractGame { private chess = new Chess(); playMove(player: number, move: string): void { + if (move === "resign") { + this.phase = "gameover"; + this.result = player === 0 ? "B+R" : "W+R"; + return; + } + + if (move === "timeout") { + this.phase = "gameover"; + this.result = player === 0 ? "B+T" : "W+T"; + return; + } + if (player === 1 && "b" != this.chess.turn()) { throw Error("Not Black's turn"); } @@ -23,7 +35,7 @@ export class ChessGame extends AbstractGame { return { fen: this.chess.fen() }; } nextToPlay(): number[] { - return [this.chess.turn() == "b" ? 1 : 0]; + return this.phase === "gameover" ? [] : [this.chess.turn() == "b" ? 1 : 0]; } numPlayers(): number { return 2; diff --git a/packages/shared/src/variants/fractional/fractional.ts b/packages/shared/src/variants/fractional/fractional.ts index e4d38216..d99039d2 100644 --- a/packages/shared/src/variants/fractional/fractional.ts +++ b/packages/shared/src/variants/fractional/fractional.ts @@ -117,7 +117,9 @@ export class Fractional extends AbstractBaduk< } nextToPlay(): number[] { - return [...Array(this.config.players.length).keys()]; + return this.phase === "gameover" + ? [] + : [...Array(this.config.players.length).keys()]; } numPlayers(): number { diff --git a/packages/shared/src/variants/parallel.ts b/packages/shared/src/variants/parallel.ts index 5ab58089..773a7aae 100644 --- a/packages/shared/src/variants/parallel.ts +++ b/packages/shared/src/variants/parallel.ts @@ -1,7 +1,7 @@ import { AbstractGame } from "../abstract_game"; import { Coordinate } from "../lib/coordinate"; import { Grid } from "../lib/grid"; -import { MovesType } from "../lib/utils"; +import { MovesType, Participation } from "../lib/utils"; export interface ParallelGoConfig { width: number; @@ -30,6 +30,10 @@ export class ParallelGo extends AbstractGame< private board: Grid; private staged: MovesType = {}; private last_round: MovesType = {}; + private playerParticipation = this.initializeParticipation( + this.config.num_players, + ); + private numberOfRounds: number = 0; constructor(config?: ParallelGoConfig) { super(config); @@ -51,7 +55,15 @@ export class ParallelGo extends AbstractGame< } nextToPlay(): number[] { - return [...Array(this.config.num_players).keys()]; + return this.phase === "gameover" + ? [] + : this.playerParticipation + .filter( + (x) => + x.dropOutAtRound === null || + x.dropOutAtRound > this.numberOfRounds, + ) + .map((p) => p.playerNr); } playMove(player: number, move: string): void { @@ -68,9 +80,28 @@ export class ParallelGo extends AbstractGame< } } - this.staged[player] = move; + if (move === "resign" || move === "timeout") { + this.playerParticipation[player].dropOutAtRound = this.numberOfRounds; - if (Object.entries(this.staged).length !== this.numPlayers()) { + if (this.nextToPlay().length < 2) { + if (this.nextToPlay().length === 1) { + this.result = `Player ${this.nextToPlay()[0]} wins!`; + } + + this.phase = "gameover"; + return; + } + } else { + // stage move + + if (!this.nextToPlay().includes(player)) { + throw new Error("Not your turn"); + } + + this.staged[player] = move; + } + + if (this.nextToPlay().some((playerNr) => !(playerNr in this.staged))) { // Don't play moves until everybody has staged a move return; } @@ -88,7 +119,7 @@ export class ParallelGo extends AbstractGame< const player = Number(player_str); this.board.at(decoded_move)?.push(player); }); - if (num_passes === this.numPlayers()) { + if (num_passes === this.nextToPlay().length) { this.phase = "gameover"; } @@ -107,6 +138,7 @@ export class ParallelGo extends AbstractGame< this.last_round = this.staged; this.staged = {}; + this.numberOfRounds++; } numPlayers(): number { @@ -115,7 +147,7 @@ export class ParallelGo extends AbstractGame< specialMoves() { // TODO: support resign - return { pass: "Pass" }; + return { pass: "Pass", timeout: "Timeout" }; } replaceMultiColoredStonesWith(arr: number[]) { @@ -212,6 +244,14 @@ export class ParallelGo extends AbstractGame< group.stones.forEach((pos) => this.board.set(pos, [])); }); } + + initializeParticipation(numPlayers: number): Participation[] { + const participation = new Array(numPlayers); + for (let i = 0; i < numPlayers; i++) { + participation[i] = { playerNr: i, dropOutAtRound: null }; + } + return participation; + } } interface Group { diff --git a/packages/vue-client/src/components/GameTimer.vue b/packages/vue-client/src/components/GameTimer.vue index 74cafbf0..b45348a0 100644 --- a/packages/vue-client/src/components/GameTimer.vue +++ b/packages/vue-client/src/components/GameTimer.vue @@ -48,18 +48,32 @@ function resetTimer(): void { isDefined(props.time_control.onThePlaySince) && props.time_control.remainingTimeMS !== null ) { - isCountingDown.value = true; - const onThePlaySince: Date = new Date(props.time_control.onThePlaySince); - const now = new Date(); - time.value -= now.getTime() - onThePlaySince.getTime(); + // this is not ideal + if ( + "stagedMoveAt" in props.time_control && + props.time_control.stagedMoveAt !== null + ) { + isCountingDown.value = false; + const onThePlaySince = new Date(props.time_control.onThePlaySince); + const stagedMove = new Date(props.time_control.stagedMoveAt as Date); + time.value = Math.max( + 0, + time.value - (stagedMove.getTime() - onThePlaySince.getTime()) + ); + } else { + isCountingDown.value = true; + const onThePlaySince: Date = new Date(props.time_control.onThePlaySince); + const now = new Date(); + time.value = Math.max( + 0, + time.value - (now.getTime() - onThePlaySince.getTime()) + ); + } } else { isCountingDown.value = false; } - if ( - isDefined(props.time_control?.onThePlaySince) && - typeof window !== "undefined" - ) { + if (isCountingDown.value && typeof window !== "undefined") { timerIndex = window.setInterval(() => { if (time.value <= 0 && timerIndex !== null) { clearInterval(timerIndex);