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(); }); });