diff --git a/apps/backend/src/consts/config.ts b/apps/backend/src/consts/config.ts index 7bb3c13..909455f 100644 --- a/apps/backend/src/consts/config.ts +++ b/apps/backend/src/consts/config.ts @@ -1 +1,9 @@ -export const RTMT_WS_PING_INTERVAL = 2000; \ No newline at end of file +export const RTMT_WS_PING_INTERVAL = 2000; + +// Time each player has for a whole game (docs/specs/game-clock). +export const GAME_CLOCK_MS = positiveInteger(process.env.GAME_CLOCK_MS) ?? 5 * 60 * 1000; + +function positiveInteger(value: string | undefined): number | undefined { + const number = Number(value); + return value && Number.isInteger(number) && number > 0 ? number : undefined; +} diff --git a/apps/backend/src/game/GameClock.ts b/apps/backend/src/game/GameClock.ts new file mode 100644 index 0000000..aa1f8cd --- /dev/null +++ b/apps/backend/src/game/GameClock.ts @@ -0,0 +1,33 @@ +import { GameClock, getClockTimes, Seat } from "@mancala/core"; + +// The game clock (docs/specs/game-clock). A clock is stored as each player's time at the start of the current turn +// plus when that turn started, so it only changes on moves; the time left at any moment is computed. + +export function startClock(initialMs: number, now: number): GameClock { + return { initialMs, player1Ms: initialMs, player2Ms: initialMs, turnStartedAt: now }; +} + +export function getRemainingMs(clock: GameClock, seat: Seat, runningSeat: Seat | undefined, now: number): number { + const times = getClockTimes(clock, runningSeat, now); + return seat === "player1" ? times.player1Ms : times.player2Ms; +} + +// Deducts the turn's time from the mover and starts the next turn, whether it is the opponent's or an extra turn. +export function chargeMove(clock: GameClock, moverSeat: Seat, now: number): { clock: GameClock, remainingMs: number } { + const remainingMs = getRemainingMs(clock, moverSeat, moverSeat, now); + const charged = { ...clock, turnStartedAt: now }; + if (moverSeat === "player1") charged.player1Ms = remainingMs; + else charged.player2Ms = remainingMs; + return { clock: charged, remainingMs }; +} + +// Deducts the running seat's time, if any, and stops the clock. +export function stopClock(clock: GameClock, runningSeat: Seat | undefined, now: number): GameClock { + const times = getClockTimes(clock, runningSeat, now); + return { initialMs: clock.initialMs, ...times }; +} + +// Starts the running clock again from the time it had at the start of the turn, e.g. after a restart. +export function resumeClock(clock: GameClock, now: number): GameClock { + return { ...clock, turnStartedAt: now }; +} diff --git a/apps/backend/src/game/GameManager.ts b/apps/backend/src/game/GameManager.ts index 9e0357a..a5cfbd7 100644 --- a/apps/backend/src/game/GameManager.ts +++ b/apps/backend/src/game/GameManager.ts @@ -20,8 +20,10 @@ import { GameEndMessage, GameMove, GameUsersConnectionInfo, + getSeat, INACTIVE_TIMEOUT_MS, TURN_TIMEOUT_MS, + Seat, UserConnectionInfo, } from "@mancala/core"; import { GameCrashManager } from "./GameCrashManager"; @@ -30,11 +32,15 @@ import { BackendGame, PlayerInfo } from "../models/Game"; import { LiveGameStore } from "../live/LiveGameStore"; import { GameRecorder } from "./GameRecorder"; import { fromSnapshot, GameEnding, getResult, isLiveGameSnapshot, toGameRecord, toSnapshot } from "./GameSerialization"; +import { chargeMove, getRemainingMs, startClock, stopClock } from "./GameClock"; +import { GAME_CLOCK_MS } from "../consts/config"; const TIMEOUT_CHECK_INTERVAL_MS = 10_000; // What players and spectators receive; leaves out spectator ids and other server-side state. -export type ClientGame = Game & { startedAt: number, moveTimes: number[], spectatorCount: number }; +export type ClientGame = Game & { + startedAt: number, moveTimes: number[], spectatorCount: number, clock: NonNullable, serverTime: number, +}; export class GameManager { gameStore: GameStore; @@ -43,6 +49,8 @@ export class GameManager { private readonly liveGameStore: LiveGameStore; private readonly recorder: GameRecorder; private readonly clock: () => number; + // Time each player has for a new game. + private readonly gameClockMs: number; // When each player in a live game went offline; cleared when they reconnect. private readonly offlineSince = new Map(); private readonly liveGamesListeners: (() => void)[] = []; @@ -55,6 +63,7 @@ export class GameManager { liveGameStore: LiveGameStore, recorder: GameRecorder, clock?: () => number, + gameClockMs?: number, }) { this.gameStore = params.gameStore; this.rtmt = params.rtmt; @@ -62,6 +71,7 @@ export class GameManager { this.liveGameStore = params.liveGameStore; this.recorder = params.recorder; this.clock = params.clock ?? Date.now; + this.gameClockMs = params.gameClockMs ?? GAME_CLOCK_MS; this.initialize(); } @@ -119,9 +129,21 @@ export class GameManager { const game = this.gameStore.getGameByUser(userKey); if (game) { try { + const now = this.clock(); + // A move that arrives after the mover's time has run out is not played. + if (userKey === game.mancalaGame.turnPlayerId && this.isOutOfTime(game, now)) { + this.endGameOnTime(game); + return; + } const movesBefore = game.mancalaGame.history.length; + const moverSeat = getSeat(game.mancalaGame, userKey); game.mancalaGame.moveByPlayerPit(userKey, gameMove.index); - if (game.mancalaGame.history.length > movesBefore) game.moveTimes.push(this.clock()); + if (game.mancalaGame.history.length > movesBefore && moverSeat) { + game.moveTimes.push(now); + const charged = chargeMove(game.clock, moverSeat, now); + game.clock = charged.clock; + game.moveRemainingMs.push(charged.remainingMs); + } game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game); this.sendMessageToPlayersAndSpectators(game, channel_on_game_update, this.toClientGame(game)); if (game.mancalaGame.state == "ended") { @@ -152,11 +174,14 @@ export class GameManager { } } - // Ends a live game for any reason: tells everyone, forgets it and saves it. + // Ends a live game for any reason: stops the clock, tells everyone, forgets the game and saves it. private endGame(game: BackendGame, ending: GameEnding) { if (this.gameStore.get(game.id) !== game) return; this.deleteGame(game); - const endMessage: GameEndMessage = { gameId: game.id, result: getResult(game, ending), endReason: ending.endReason }; + game.clock = stopClock(game.clock, this.getRunningSeat(game), this.clock()); + const endMessage: GameEndMessage = { + gameId: game.id, result: getResult(game, ending), endReason: ending.endReason, clock: game.clock, + }; if (ending.winnerId !== undefined) endMessage.winnerId = ending.winnerId; if (ending.leftUserId !== undefined) { endMessage.leftUserId = ending.leftUserId; @@ -173,6 +198,24 @@ export class GameManager { this.notifyLiveGamesChanged(); } + // The player whose turn it is ran out of time: the opponent wins. + private endGameOnTime(game: BackendGame) { + const { turnPlayerId } = game.mancalaGame; + this.endGame(game, { endReason: "timeout", winnerId: this.getOpponentId(game, turnPlayerId) }); + } + + private isOutOfTime(game: BackendGame, now: number): boolean { + const runningSeat = this.getRunningSeat(game); + return runningSeat !== undefined && getRemainingMs(game.clock, runningSeat, runningSeat, now) <= 0; + } + + // The seat whose clock runs; undefined once the game has ended. + private getRunningSeat(game: BackendGame): Seat | undefined { + const { mancalaGame } = game; + if (mancalaGame.state === "ended" || game.clock.turnStartedAt === undefined) return undefined; + return getSeat(mancalaGame, mancalaGame.turnPlayerId); + } + // Applies the rules for players who stay away: the player whose turn it is loses after TURN_TIMEOUT_MS offline // while the opponent is online; a game where both are offline for INACTIVE_TIMEOUT_MS ends without a winner. public checkTimeouts() { @@ -211,7 +254,7 @@ export class GameManager { this.liveGameStore.removeGame(snapshot.id).catch((err) => this.logStoreError(err)); continue; } - const game = fromSnapshot(snapshot); + const game = fromSnapshot(snapshot, { now: this.clock(), gameClockMs: this.gameClockMs }); this.gameStore.set(game.id, game); const now = this.clock(); for (const playerId of [game.mancalaGame.player1Id, game.mancalaGame.player2Id]) { @@ -278,6 +321,8 @@ export class GameManager { startedAt: game.startedAt, moveTimes: game.moveTimes, spectatorCount: game.spectatorIds.length, + clock: game.clock, + serverTime: this.clock(), }; } @@ -292,6 +337,7 @@ export class GameManager { const mancalaGame = new CommonMancalaGame(generateKey(), userKey1, userKey2); const random = Math.random(); mancalaGame.turnPlayerId = random > 0.5 ? userKey1 : userKey2; + const now = this.clock(); const players: Record = { [userKey1]: this.getPlayerInfo(userKey1), @@ -305,8 +351,10 @@ export class GameManager { allSpectatorIds: [], peakSpectators: 0, players, - startedAt: this.clock(), + startedAt: now, moveTimes: [], + clock: startClock(this.gameClockMs, now), + moveRemainingMs: [], }; this.gameStore.set(game.id, game); diff --git a/apps/backend/src/game/GameSerialization.ts b/apps/backend/src/game/GameSerialization.ts index 985de24..0be5e13 100644 --- a/apps/backend/src/game/GameSerialization.ts +++ b/apps/backend/src/game/GameSerialization.ts @@ -2,6 +2,7 @@ import { EndReason, GameRecord, GameResult, MoveRecord } from "@mancala/core"; import { CommonMancalaGame, GameState, GameStep } from "mancala.js"; import { BackendGame, PlayerInfo } from "../models/Game"; import { publicUserId } from "./publicIds"; +import { startClock } from "./GameClock"; type HistoryEntry = { playerId: string, moveIndex: number, boardSnapshot: number[], gameSteps: GameStep[] }; @@ -56,7 +57,7 @@ export function isLiveGameSnapshot(value: unknown): value is LiveGameSnapshot { } // Rebuilds a live game with real engine objects (rules, pits), so it can be played on. -export function fromSnapshot(snapshot: LiveGameSnapshot): BackendGame { +export function fromSnapshot(snapshot: LiveGameSnapshot, params: { now: number, gameClockMs: number }): BackendGame { const mancalaGame = new CommonMancalaGame(snapshot.id, snapshot.player1Id, snapshot.player2Id); snapshot.board.forEach((stoneCount, index) => (mancalaGame.board.pits[index].stoneCount = stoneCount)); mancalaGame.turnPlayerId = snapshot.turnPlayerId; @@ -76,6 +77,8 @@ export function fromSnapshot(snapshot: LiveGameSnapshot): BackendGame { players: snapshot.players, startedAt: snapshot.startedAt, moveTimes: snapshot.moveTimes, + clock: startClock(params.gameClockMs, params.now), + moveRemainingMs: [], }; } diff --git a/apps/backend/src/models/Game.ts b/apps/backend/src/models/Game.ts index 3fa1756..ee9c3c0 100644 --- a/apps/backend/src/models/Game.ts +++ b/apps/backend/src/models/Game.ts @@ -1,4 +1,4 @@ -import { Game } from "@mancala/core"; +import { Game, GameClock } from "@mancala/core"; export interface PlayerInfo { userId: string; @@ -18,4 +18,7 @@ export interface BackendGame extends Game { startedAt: number; // Time of each move, aligned with mancalaGame.history. moveTimes: number[]; + clock: GameClock; + // The mover's clock after each move, aligned with mancalaGame.history. + moveRemainingMs: number[]; } diff --git a/apps/backend/tests/game/GameManager.test.ts b/apps/backend/tests/game/GameManager.test.ts index a454c67..7bb116d 100644 --- a/apps/backend/tests/game/GameManager.test.ts +++ b/apps/backend/tests/game/GameManager.test.ts @@ -26,6 +26,7 @@ const BOB = { id: "kc:bob", name: "guest-1a2b3c4d", isGuest: true }; const CAROL = { id: "kc:carol", name: "carol", isGuest: false }; const DAVE = { id: "kc:dave", name: "dave", isGuest: false }; const LEGACY = { id: "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11", isGuest: true }; +const CLOCK_MS = 5 * 60_000; describe("GameManager", () => { let now: number; @@ -41,6 +42,7 @@ describe("GameManager", () => { const recorder = new GameRecorder(repository, liveGameStore); gameManager = new GameManager({ rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore, recorder, clock: () => now, + gameClockMs: CLOCK_MS, }); return recorder; }; @@ -143,6 +145,91 @@ describe("GameManager", () => { }); }); + describe("clock", () => { + const seatOf = (game: BackendGame, playerId: string) => game.mancalaGame.player1Id === playerId ? "player1Ms" : "player2Ms"; + const opponentOf = (playerId: string) => playerId === ALICE.id ? BOB.id : ALICE.id; + + it("starts with the full time for both players, running for the player who moves first", () => { + const game = startGame(); + expect(game.clock).toEqual({ initialMs: CLOCK_MS, player1Ms: CLOCK_MS, player2Ms: CLOCK_MS, turnStartedAt: now }); + expect(rtmt.messagesTo(ALICE.id, channel_on_game_start)[0]).toMatchObject({ clock: game.clock, serverTime: now }); + }); + + it("a move deducts the turn's time from the mover only and starts the opponent's clock", () => { + const game = startGame(); + const first = game.mancalaGame.turnPlayerId; + now += 7_000; + playAnyMove(game); + expect(game.mancalaGame.turnPlayerId).toBe(opponentOf(first)); + expect(game.clock[seatOf(game, first)]).toBe(CLOCK_MS - 7_000); + expect(game.clock[seatOf(game, opponentOf(first))]).toBe(CLOCK_MS); + expect(game.clock.turnStartedAt).toBe(now); + expect(rtmt.messagesTo(BOB.id, channel_on_game_update).pop()).toMatchObject({ clock: game.clock, serverTime: now }); + + now += 3_000; + playAnyMove(game); + expect(game.clock[seatOf(game, opponentOf(first))]).toBe(CLOCK_MS - 3_000); + expect(game.moveRemainingMs).toEqual([CLOCK_MS - 7_000, CLOCK_MS - 3_000]); + }); + + it("an extra turn keeps the mover's clock running", () => { + const game = startGame(); + const mover = game.mancalaGame.turnPlayerId; + now += 2_000; + // Four stones from the fourth pit: the last one lands in the mover's store. + rtmt.receive(mover, channel_game_move, { index: 3 }); + expect(game.mancalaGame.turnPlayerId).toBe(mover); + now += 1_000; + playAnyMove(game); + expect(game.moveRemainingMs).toEqual([CLOCK_MS - 2_000, CLOCK_MS - 3_000]); + }); + + it("a move with time left is played", () => { + const game = startGame(); + now += CLOCK_MS - 1; + playAnyMove(game); + expect(game.mancalaGame.history).toHaveLength(1); + expect(game.moveRemainingMs).toEqual([1]); + }); + + it("a move that arrives after the time has run out is not played, and the opponent wins", async () => { + const game = startGame(); + const mover = game.mancalaGame.turnPlayerId; + now += CLOCK_MS; + playAnyMove(game); + await flushPromises(); + + expect(game.mancalaGame.history).toHaveLength(0); + expect(gameStore.get(game.id)).toBeUndefined(); + const winnerSeat = game.mancalaGame.player1Id === mover ? "player2" : "player1"; + expect(repository.records.get(game.id)).toMatchObject({ endReason: "timeout", result: winnerSeat }); + const end = rtmt.messagesTo(mover, channel_on_game_end)[0]; + expect(end).toMatchObject({ endReason: "timeout", winnerId: opponentOf(mover) }); + expect(end.clock).toEqual({ initialMs: CLOCK_MS, [seatOf(game, mover)]: 0, [seatOf(game, opponentOf(mover))]: CLOCK_MS }); + }); + + it("stops when the game ends, after deducting the running player's time", () => { + const game = startGame(); + const mover = game.mancalaGame.turnPlayerId; + now += 4_000; + rtmt.receive(ALICE.id, channel_leave_game); + expect(game.clock).toEqual({ + initialMs: CLOCK_MS, [seatOf(game, mover)]: CLOCK_MS - 4_000, [seatOf(game, opponentOf(mover))]: CLOCK_MS, + }); + expect(rtmt.messagesTo(BOB.id, channel_on_game_end)[0].clock).toEqual(game.clock); + }); + + it("stops when the game is completed by the rules", () => { + const game = startGame(); + playAnyMove(game); + setUpLastMove(game, ALICE.id); + now += 1_000; + rtmt.receive(ALICE.id, channel_game_move, { index: 5 }); + expect(game.clock.turnStartedAt).toBeUndefined(); + expect(rtmt.messagesTo(BOB.id, channel_on_game_end)[0].clock).toEqual(game.clock); + }); + }); + describe("how games end", () => { it("completed: the winner by the rules, with every move saved", async () => { const game = startGame(); @@ -160,7 +247,7 @@ describe("GameManager", () => { expect(record.moves.map((m) => m.at)).toEqual(game.moveTimes); expect(gameStore.get(game.id)).toBeUndefined(); expect(rtmt.messagesTo(BOB.id, channel_on_game_end)).toEqual([ - { gameId: game.id, result: aliceSeat, endReason: "completed", winnerId: ALICE.id }, + { gameId: game.id, result: aliceSeat, endReason: "completed", winnerId: ALICE.id, clock: expect.any(Object) }, ]); }); @@ -176,7 +263,10 @@ describe("GameManager", () => { for (const user of [ALICE, BOB, CAROL]) { expect(rtmt.messagesTo(user.id, channel_on_game_user_leave)).toEqual([ALICE.id]); expect(rtmt.messagesTo(user.id, channel_on_game_end)).toEqual([ - { gameId: game.id, result: bobSeat, endReason: "left", winnerId: BOB.id, leftUserId: ALICE.id }, + { + gameId: game.id, result: bobSeat, endReason: "left", winnerId: BOB.id, leftUserId: ALICE.id, + clock: expect.any(Object), + }, ]); } }); diff --git a/docs/specs/game-clock/tasks.md b/docs/specs/game-clock/tasks.md index e5d5882..48aed39 100644 --- a/docs/specs/game-clock/tasks.md +++ b/docs/specs/game-clock/tasks.md @@ -5,7 +5,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [x] 1. **Core types and helpers**: `GameClock`, `CLOCK_LOW_TIME_MS`, the new optional fields on `Game`, `GameEndMessage`, `MoveRecord` and `FinishedGameSummary`; `getClockTimes`, `ClockSync`, `getClockAtMove`, `formatClock` with unit tests; English timeout texts. -- [ ] 2. **Clock in the game lifecycle**: `GameClock.ts`, `GAME_CLOCK_MS`, `clock` and `moveRemainingMs` on +- [x] 2. **Clock in the game lifecycle**: `GameClock.ts`, `GAME_CLOCK_MS`, `clock` and `moveRemainingMs` on `BackendGame`, start, charging moves, refusing late moves, stopping the clock on every ending, `clock` and `serverTime` in client messages; unit tests. - [ ] 3. **Scheduled service**: `endGamesOutOfTime`, `GameClockScheduler` wired in `index.ts`; remove