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:
parent
cb34c03246
commit
03e1543d5d
@ -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
|
||||
|
||||
83
packages/core/src/clock/Clock.ts
Normal file
83
packages/core/src/clock/Clock.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
1
packages/core/src/clock/index.ts
Normal file
1
packages/core/src/clock/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Clock'
|
||||
@ -9,3 +9,4 @@ export * from './factory/index'
|
||||
export * from './viewmodel/index'
|
||||
export * from './service/index'
|
||||
export * from './history/index'
|
||||
export * from './clock/index'
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
20
packages/core/src/models/GameClock.ts
Normal file
20
packages/core/src/models/GameClock.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -5,3 +5,4 @@ export * from './User'
|
||||
export * from './UserConnectionInfo'
|
||||
export * from './AuthUser'
|
||||
export * from './GameHistory'
|
||||
export * from './GameClock'
|
||||
|
||||
136
packages/core/tests/clock/Clock.test.ts
Normal file
136
packages/core/tests/clock/Clock.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user