feat(core): add game clock types and helpers

GameClock and optional clock fields on live games, end messages, move records and finished games; getClockTimes, ClockSync, getClockAtMove and formatClock with tests.
This commit is contained in:
Halit Aksoy 2026-10-16 01:25:15 +03:00
parent cb34c03246
commit 03e1543d5d
10 changed files with 257 additions and 3 deletions

View File

@ -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

View File

@ -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<GameRecord, "clockInitialMs" | "moves" | "player1" | "result" | "endReason">;
// 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 };
}
}

View File

@ -0,0 +1 @@
export * from './Clock'

View File

@ -9,3 +9,4 @@ export * from './factory/index'
export * from './viewmodel/index'
export * from './service/index'
export * from './history/index'
export * from './clock/index'

View File

@ -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",

View File

@ -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 {

View File

@ -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;
}

View File

@ -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;
}

View File

@ -5,3 +5,4 @@ export * from './User'
export * from './UserConnectionInfo'
export * from './AuthUser'
export * from './GameHistory'
export * from './GameClock'

View File

@ -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> = {}): 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>): 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();
});
});