diff --git a/docs/specs/game-clock/tasks.md b/docs/specs/game-clock/tasks.md index 60abd5b..e5d5882 100644 --- a/docs/specs/game-clock/tasks.md +++ b/docs/specs/game-clock/tasks.md @@ -2,7 +2,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed. -- [ ] 1. **Core types and helpers**: `GameClock`, `CLOCK_LOW_TIME_MS`, the new optional fields on `Game`, +- [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 diff --git a/packages/core/src/clock/Clock.ts b/packages/core/src/clock/Clock.ts new file mode 100644 index 0000000..aef42f2 --- /dev/null +++ b/packages/core/src/clock/Clock.ts @@ -0,0 +1,83 @@ +import { Game } from "../models/Game"; +import { ClockTimes, GameClock, Seat } from "../models/GameClock"; +import { GameRecord } from "../models/GameHistory"; + +export interface ClockState extends ClockTimes { + // Undefined when no clock runs. + runningSeat?: Seat; +} + +export function getSeat(players: { player1Id: string, player2Id: string }, playerId: string): Seat | undefined { + if (playerId === players.player1Id) return "player1"; + if (playerId === players.player2Id) return "player2"; + return undefined; +} + +// Both players' time at `now` (server time); never below zero. +export function getClockTimes(clock: GameClock, runningSeat: Seat | undefined, now: number): ClockTimes { + const elapsed = runningSeat && clock.turnStartedAt !== undefined ? Math.max(0, now - clock.turnStartedAt) : 0; + return { + player1Ms: Math.max(0, clock.player1Ms - (runningSeat === "player1" ? elapsed : 0)), + player2Ms: Math.max(0, clock.player2Ms - (runningSeat === "player2" ? elapsed : 0)), + }; +} + +// "4:59"; rounded up, so "0:00" only shows when the time is gone. +export function formatClock(milliseconds: number): string { + const seconds = Math.ceil(Math.max(0, milliseconds) / 1000); + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; +} + +type ClockRecord = Pick; + +// Both players' time after the given move of a saved game; move 0 is the start. Undefined for games saved before +// the game clock. +export function getClockAtMove(record: ClockRecord, moveNumber: number): ClockTimes | undefined { + if (record.clockInitialMs === undefined) return undefined; + const n = Math.min(Math.max(moveNumber, 0), record.moves.length); + const times: ClockTimes = { player1Ms: record.clockInitialMs, player2Ms: record.clockInitialMs }; + for (const move of record.moves.slice(0, n)) { + if (move.remainingMs === undefined) continue; + if (move.playerId === record.player1.userId) times.player1Ms = move.remainingMs; + else times.player2Ms = move.remainingMs; + } + // The player who ran out of time did not get to move again; their clock ends at zero. + if (n === record.moves.length && record.endReason === "timeout" && record.result !== null) { + if (record.result === "player1") times.player2Ms = 0; + if (record.result === "player2") times.player1Ms = 0; + } + return times; +} + +// Follows the clock of a live game from the server's messages and counts down between them, in server time. +export class ClockSync { + private clock?: GameClock; + private runningSeat?: Seat; + // Server time minus local time. + private offsetMs = 0; + + // From a live game message (game start, update, reconnect). + update(game: Game, receivedAt = Date.now()) { + const { mancalaGame } = game; + this.clock = game.clock; + this.runningSeat = mancalaGame.state === "ended" ? undefined : getSeat(mancalaGame, mancalaGame.turnPlayerId); + if (game.serverTime !== undefined) this.offsetMs = game.serverTime - receivedAt; + } + + // When the game has ended; the server sends the stopped clocks. + stop(clock?: GameClock) { + if (clock) this.clock = clock; + this.runningSeat = undefined; + } + + get isRunning(): boolean { + return this.clock?.turnStartedAt !== undefined && this.runningSeat !== undefined; + } + + // Undefined when the server sends no clock. + getTimes(now = Date.now()): ClockState | undefined { + if (!this.clock) return undefined; + const runningSeat = this.isRunning ? this.runningSeat : undefined; + return { ...getClockTimes(this.clock, runningSeat, now + this.offsetMs), runningSeat }; + } +} diff --git a/packages/core/src/clock/index.ts b/packages/core/src/clock/index.ts new file mode 100644 index 0000000..9b8b971 --- /dev/null +++ b/packages/core/src/clock/index.ts @@ -0,0 +1 @@ +export * from './Clock' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5e3baca..72c074e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,3 +9,4 @@ export * from './factory/index' export * from './viewmodel/index' export * from './service/index' export * from './history/index' +export * from './clock/index' diff --git a/packages/core/src/localization/en.ts b/packages/core/src/localization/en.ts index 8b2e101..12c08a9 100644 --- a/packages/core/src/localization/en.ts +++ b/packages/core/src/localization/en.ts @@ -54,8 +54,8 @@ export const en = { Draw: "Draw", OpponentLeft: "Opponent left", YouLeft: "You left", - OpponentTimedOut: "Opponent timed out", - YouTimedOut: "You timed out", + OpponentTimedOut: "Opponent ran out of time", + YouTimedOut: "You ran out of time", Abandoned: "Abandoned", PlayerWon: "{{name}} won", Moves: "Moves", diff --git a/packages/core/src/models/Game.ts b/packages/core/src/models/Game.ts index 7a0a2bb..bc496a6 100644 --- a/packages/core/src/models/Game.ts +++ b/packages/core/src/models/Game.ts @@ -1,4 +1,5 @@ import { MancalaGame } from "mancala.js"; +import { GameClock } from "./GameClock"; import { UserConnectionInfo } from "./UserConnectionInfo"; export interface Game { @@ -9,6 +10,10 @@ export interface Game { startedAt?: number; // Time of each move, aligned with mancalaGame.history. moveTimes?: number[]; + // Sent by servers with a game clock. + clock?: GameClock; + // Server time (epoch milliseconds) when the message was built, so clients can correct for their own clock. + serverTime?: number; } export interface GameUsersConnectionInfo { diff --git a/packages/core/src/models/GameClock.ts b/packages/core/src/models/GameClock.ts new file mode 100644 index 0000000..059c211 --- /dev/null +++ b/packages/core/src/models/GameClock.ts @@ -0,0 +1,20 @@ +// Below this, the running clock is shown as low on time. +export const CLOCK_LOW_TIME_MS = 30_000; + +export type Seat = "player1" | "player2"; + +// The players' clocks in a live game. The server keeps the time; clients only display it. +export interface GameClock { + // Time each player started the game with. + initialMs: number; + // Time left for each player. For the player whose turn it is, at turnStartedAt. + player1Ms: number; + player2Ms: number; + // Server time (epoch milliseconds) when the running clock started; undefined when no clock runs. + turnStartedAt?: number; +} + +export interface ClockTimes { + player1Ms: number; + player2Ms: number; +} diff --git a/packages/core/src/models/GameHistory.ts b/packages/core/src/models/GameHistory.ts index 9460093..516ffb0 100644 --- a/packages/core/src/models/GameHistory.ts +++ b/packages/core/src/models/GameHistory.ts @@ -1,4 +1,5 @@ import { GameStep } from "mancala.js"; +import { GameClock } from "./GameClock"; // How long the player whose turn it is may stay offline before the opponent wins. export const TURN_TIMEOUT_MS = 5 * 60 * 1000; @@ -20,6 +21,8 @@ export interface MoveRecord { gameSteps: GameStep[]; // Epoch milliseconds. at: number; + // The mover's clock after the move; undefined for games saved before the game clock. + remainingMs?: number; } export type GameResult = "player1" | "player2" | "draw"; @@ -51,6 +54,8 @@ export interface FinishedGameSummary { endedAt: number; moveCount: number; peakSpectators: number; + // Time each player started with; undefined for games saved before the game clock. + clockInitialMs?: number; } export interface GameRecord extends FinishedGameSummary { @@ -72,4 +77,6 @@ export interface GameEndMessage { endReason: EndReason; winnerId?: string; leftUserId?: string; + // The stopped clocks. + clock?: GameClock; } diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index 8e274b5..408e5bb 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -5,3 +5,4 @@ export * from './User' export * from './UserConnectionInfo' export * from './AuthUser' export * from './GameHistory' +export * from './GameClock' diff --git a/packages/core/tests/clock/Clock.test.ts b/packages/core/tests/clock/Clock.test.ts new file mode 100644 index 0000000..4c01529 --- /dev/null +++ b/packages/core/tests/clock/Clock.test.ts @@ -0,0 +1,136 @@ +import { CommonMancalaGame } from "mancala.js"; +import { ClockSync, formatClock, getClockAtMove, getClockTimes } from "../../src/clock"; +import { Game, GameClock, GameRecord, MoveRecord } from "../../src/models"; + +const P1 = "player-1"; +const P2 = "player-2"; +const MINUTE = 60_000; + +const clock = (overrides: Partial = {}): GameClock => + ({ initialMs: 5 * MINUTE, player1Ms: 5 * MINUTE, player2Ms: 5 * MINUTE, turnStartedAt: 1_000_000, ...overrides }); + +const liveGame = (turnPlayerId: string, gameClock: GameClock | undefined, serverTime?: number): Game => { + const mancalaGame = new CommonMancalaGame("game-1", P1, P2); + mancalaGame.turnPlayerId = turnPlayerId; + return { + id: "game-1", + mancalaGame, + gameUsersConnectionInfo: { + user1ConnectionInfo: { userId: P1, isOnline: true }, + user2ConnectionInfo: { userId: P2, isOnline: true }, + }, + clock: gameClock, + serverTime, + }; +}; + +const move = (playerId: string, remainingMs?: number): MoveRecord => + ({ playerId, moveIndex: 0, boardSnapshot: [], gameSteps: [], at: 0, remainingMs }); + +const record = (overrides: Partial): GameRecord => ({ + id: "game-1", + player1: { userId: P1, isGuest: true }, + player2: { userId: P2, isGuest: true }, + board: [], + result: "player1", + endReason: "completed", + startedAt: 0, + endedAt: 0, + moveCount: 0, + peakSpectators: 0, + totalSpectators: 0, + moves: [], + clockInitialMs: 5 * MINUTE, + ...overrides, +}); + +describe("getClockTimes", () => { + it("deducts the time since the turn started from the running seat only", () => { + expect(getClockTimes(clock(), "player1", 1_000_000 + 20_000)).toEqual({ player1Ms: 280_000, player2Ms: 300_000 }); + expect(getClockTimes(clock(), "player2", 1_000_000 + 20_000)).toEqual({ player1Ms: 300_000, player2Ms: 280_000 }); + }); + + it("does not count down when no clock runs", () => { + expect(getClockTimes(clock({ turnStartedAt: undefined }), "player1", 5_000_000)).toEqual({ player1Ms: 300_000, player2Ms: 300_000 }); + expect(getClockTimes(clock(), undefined, 5_000_000)).toEqual({ player1Ms: 300_000, player2Ms: 300_000 }); + }); + + it("never goes below zero", () => { + expect(getClockTimes(clock({ player1Ms: 1_000 }), "player1", 1_000_000 + 60_000).player1Ms).toBe(0); + }); +}); + +describe("formatClock", () => { + it("shows minutes and seconds, rounding up", () => { + expect(formatClock(300_000)).toBe("5:00"); + expect(formatClock(299_001)).toBe("5:00"); + expect(formatClock(299_000)).toBe("4:59"); + expect(formatClock(9_500)).toBe("0:10"); + expect(formatClock(1)).toBe("0:01"); + expect(formatClock(0)).toBe("0:00"); + expect(formatClock(-5)).toBe("0:00"); + }); +}); + +describe("ClockSync", () => { + it("counts down the running clock in server time, whatever the local clock says", () => { + const sync = new ClockSync(); + const localTimeAtReceive = 1_000_000 + 10 * MINUTE; // the device clock is 10 minutes fast + sync.update(liveGame(P2, clock({ turnStartedAt: 1_000_000 }), 1_000_000 + 5_000), localTimeAtReceive); + + expect(sync.isRunning).toBe(true); + expect(sync.getTimes(localTimeAtReceive)).toEqual({ player1Ms: 300_000, player2Ms: 295_000, runningSeat: "player2" }); + expect(sync.getTimes(localTimeAtReceive + 3_000)).toEqual({ player1Ms: 300_000, player2Ms: 292_000, runningSeat: "player2" }); + }); + + it("stops with the clocks the server sends when the game ends", () => { + const sync = new ClockSync(); + sync.update(liveGame(P1, clock(), 1_000_000), 1_000_000); + sync.stop(clock({ player1Ms: 0, turnStartedAt: undefined })); + + expect(sync.isRunning).toBe(false); + expect(sync.getTimes(9_000_000)).toEqual({ player1Ms: 0, player2Ms: 300_000, runningSeat: undefined }); + }); + + it("does not run once the game has ended", () => { + const game = liveGame(P1, clock(), 1_000_000); + game.mancalaGame.state = "ended"; + const sync = new ClockSync(); + sync.update(game, 1_000_000); + + expect(sync.isRunning).toBe(false); + expect(sync.getTimes(2_000_000)?.player1Ms).toBe(300_000); + }); + + it("has no times when the server sends no clock", () => { + const sync = new ClockSync(); + sync.update(liveGame(P1, undefined), 0); + expect(sync.getTimes()).toBeUndefined(); + expect(sync.isRunning).toBe(false); + }); +}); + +describe("getClockAtMove", () => { + const moves = [move(P1, 290_000), move(P2, 280_000), move(P2, 270_000), move(P1, 250_000)]; + + it("starts with the initial time", () => { + expect(getClockAtMove(record({ moves }), 0)).toEqual({ player1Ms: 300_000, player2Ms: 300_000 }); + }); + + it("shows each player's time after their last move so far", () => { + expect(getClockAtMove(record({ moves }), 1)).toEqual({ player1Ms: 290_000, player2Ms: 300_000 }); + expect(getClockAtMove(record({ moves }), 3)).toEqual({ player1Ms: 290_000, player2Ms: 270_000 }); + expect(getClockAtMove(record({ moves }), 4)).toEqual({ player1Ms: 250_000, player2Ms: 270_000 }); + expect(getClockAtMove(record({ moves }), 99)).toEqual({ player1Ms: 250_000, player2Ms: 270_000 }); + }); + + it("shows the player who ran out of time at zero in the final position", () => { + const timedOut = record({ moves, endReason: "timeout", result: "player1" }); + expect(getClockAtMove(timedOut, 4)).toEqual({ player1Ms: 250_000, player2Ms: 0 }); + expect(getClockAtMove(timedOut, 3)).toEqual({ player1Ms: 290_000, player2Ms: 270_000 }); + }); + + it("is undefined for games saved before the game clock", () => { + expect(getClockAtMove(record({ moves: [move(P1)], clockInitialMs: undefined }), 1)).toBeUndefined(); + }); +});