From 999ae79279e6ff70655c85f733c541475b84edb6 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 01:27:28 +0300 Subject: [PATCH] feat(backend): run a game clock for each player Each player starts with GAME_CLOCK_MS (5 minutes). A move deducts the turn's time from the mover; a move that arrives after the time has run out is not played and the opponent wins on time. Every ending stops the clock, and clients get the clock and the server time with each game message. --- apps/backend/src/consts/config.ts | 10 ++- apps/backend/src/game/GameClock.ts | 33 ++++++++ apps/backend/src/game/GameManager.ts | 60 +++++++++++-- apps/backend/src/game/GameSerialization.ts | 5 +- apps/backend/src/models/Game.ts | 5 +- apps/backend/tests/game/GameManager.test.ts | 94 ++++++++++++++++++++- docs/specs/game-clock/tasks.md | 2 +- 7 files changed, 197 insertions(+), 12 deletions(-) create mode 100644 apps/backend/src/game/GameClock.ts 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