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.
This commit is contained in:
parent
03e1543d5d
commit
999ae79279
@ -1 +1,9 @@
|
|||||||
export const RTMT_WS_PING_INTERVAL = 2000;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
33
apps/backend/src/game/GameClock.ts
Normal file
33
apps/backend/src/game/GameClock.ts
Normal file
@ -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 };
|
||||||
|
}
|
||||||
@ -20,8 +20,10 @@ import {
|
|||||||
GameEndMessage,
|
GameEndMessage,
|
||||||
GameMove,
|
GameMove,
|
||||||
GameUsersConnectionInfo,
|
GameUsersConnectionInfo,
|
||||||
|
getSeat,
|
||||||
INACTIVE_TIMEOUT_MS,
|
INACTIVE_TIMEOUT_MS,
|
||||||
TURN_TIMEOUT_MS,
|
TURN_TIMEOUT_MS,
|
||||||
|
Seat,
|
||||||
UserConnectionInfo,
|
UserConnectionInfo,
|
||||||
} from "@mancala/core";
|
} from "@mancala/core";
|
||||||
import { GameCrashManager } from "./GameCrashManager";
|
import { GameCrashManager } from "./GameCrashManager";
|
||||||
@ -30,11 +32,15 @@ import { BackendGame, PlayerInfo } from "../models/Game";
|
|||||||
import { LiveGameStore } from "../live/LiveGameStore";
|
import { LiveGameStore } from "../live/LiveGameStore";
|
||||||
import { GameRecorder } from "./GameRecorder";
|
import { GameRecorder } from "./GameRecorder";
|
||||||
import { fromSnapshot, GameEnding, getResult, isLiveGameSnapshot, toGameRecord, toSnapshot } from "./GameSerialization";
|
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;
|
const TIMEOUT_CHECK_INTERVAL_MS = 10_000;
|
||||||
|
|
||||||
// What players and spectators receive; leaves out spectator ids and other server-side state.
|
// 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<Game["clock"]>, serverTime: number,
|
||||||
|
};
|
||||||
|
|
||||||
export class GameManager {
|
export class GameManager {
|
||||||
gameStore: GameStore;
|
gameStore: GameStore;
|
||||||
@ -43,6 +49,8 @@ export class GameManager {
|
|||||||
private readonly liveGameStore: LiveGameStore;
|
private readonly liveGameStore: LiveGameStore;
|
||||||
private readonly recorder: GameRecorder;
|
private readonly recorder: GameRecorder;
|
||||||
private readonly clock: () => number;
|
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.
|
// When each player in a live game went offline; cleared when they reconnect.
|
||||||
private readonly offlineSince = new Map<string, number>();
|
private readonly offlineSince = new Map<string, number>();
|
||||||
private readonly liveGamesListeners: (() => void)[] = [];
|
private readonly liveGamesListeners: (() => void)[] = [];
|
||||||
@ -55,6 +63,7 @@ export class GameManager {
|
|||||||
liveGameStore: LiveGameStore,
|
liveGameStore: LiveGameStore,
|
||||||
recorder: GameRecorder,
|
recorder: GameRecorder,
|
||||||
clock?: () => number,
|
clock?: () => number,
|
||||||
|
gameClockMs?: number,
|
||||||
}) {
|
}) {
|
||||||
this.gameStore = params.gameStore;
|
this.gameStore = params.gameStore;
|
||||||
this.rtmt = params.rtmt;
|
this.rtmt = params.rtmt;
|
||||||
@ -62,6 +71,7 @@ export class GameManager {
|
|||||||
this.liveGameStore = params.liveGameStore;
|
this.liveGameStore = params.liveGameStore;
|
||||||
this.recorder = params.recorder;
|
this.recorder = params.recorder;
|
||||||
this.clock = params.clock ?? Date.now;
|
this.clock = params.clock ?? Date.now;
|
||||||
|
this.gameClockMs = params.gameClockMs ?? GAME_CLOCK_MS;
|
||||||
this.initialize();
|
this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,9 +129,21 @@ export class GameManager {
|
|||||||
const game = this.gameStore.getGameByUser(userKey);
|
const game = this.gameStore.getGameByUser(userKey);
|
||||||
if (game) {
|
if (game) {
|
||||||
try {
|
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 movesBefore = game.mancalaGame.history.length;
|
||||||
|
const moverSeat = getSeat(game.mancalaGame, userKey);
|
||||||
game.mancalaGame.moveByPlayerPit(userKey, gameMove.index);
|
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);
|
game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game);
|
||||||
this.sendMessageToPlayersAndSpectators(game, channel_on_game_update, this.toClientGame(game));
|
this.sendMessageToPlayersAndSpectators(game, channel_on_game_update, this.toClientGame(game));
|
||||||
if (game.mancalaGame.state == "ended") {
|
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) {
|
private endGame(game: BackendGame, ending: GameEnding) {
|
||||||
if (this.gameStore.get(game.id) !== game) return;
|
if (this.gameStore.get(game.id) !== game) return;
|
||||||
this.deleteGame(game);
|
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.winnerId !== undefined) endMessage.winnerId = ending.winnerId;
|
||||||
if (ending.leftUserId !== undefined) {
|
if (ending.leftUserId !== undefined) {
|
||||||
endMessage.leftUserId = ending.leftUserId;
|
endMessage.leftUserId = ending.leftUserId;
|
||||||
@ -173,6 +198,24 @@ export class GameManager {
|
|||||||
this.notifyLiveGamesChanged();
|
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
|
// 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.
|
// while the opponent is online; a game where both are offline for INACTIVE_TIMEOUT_MS ends without a winner.
|
||||||
public checkTimeouts() {
|
public checkTimeouts() {
|
||||||
@ -211,7 +254,7 @@ export class GameManager {
|
|||||||
this.liveGameStore.removeGame(snapshot.id).catch((err) => this.logStoreError(err));
|
this.liveGameStore.removeGame(snapshot.id).catch((err) => this.logStoreError(err));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const game = fromSnapshot(snapshot);
|
const game = fromSnapshot(snapshot, { now: this.clock(), gameClockMs: this.gameClockMs });
|
||||||
this.gameStore.set(game.id, game);
|
this.gameStore.set(game.id, game);
|
||||||
const now = this.clock();
|
const now = this.clock();
|
||||||
for (const playerId of [game.mancalaGame.player1Id, game.mancalaGame.player2Id]) {
|
for (const playerId of [game.mancalaGame.player1Id, game.mancalaGame.player2Id]) {
|
||||||
@ -278,6 +321,8 @@ export class GameManager {
|
|||||||
startedAt: game.startedAt,
|
startedAt: game.startedAt,
|
||||||
moveTimes: game.moveTimes,
|
moveTimes: game.moveTimes,
|
||||||
spectatorCount: game.spectatorIds.length,
|
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 mancalaGame = new CommonMancalaGame(generateKey(), userKey1, userKey2);
|
||||||
const random = Math.random();
|
const random = Math.random();
|
||||||
mancalaGame.turnPlayerId = random > 0.5 ? userKey1 : userKey2;
|
mancalaGame.turnPlayerId = random > 0.5 ? userKey1 : userKey2;
|
||||||
|
const now = this.clock();
|
||||||
|
|
||||||
const players: Record<string, PlayerInfo> = {
|
const players: Record<string, PlayerInfo> = {
|
||||||
[userKey1]: this.getPlayerInfo(userKey1),
|
[userKey1]: this.getPlayerInfo(userKey1),
|
||||||
@ -305,8 +351,10 @@ export class GameManager {
|
|||||||
allSpectatorIds: [],
|
allSpectatorIds: [],
|
||||||
peakSpectators: 0,
|
peakSpectators: 0,
|
||||||
players,
|
players,
|
||||||
startedAt: this.clock(),
|
startedAt: now,
|
||||||
moveTimes: [],
|
moveTimes: [],
|
||||||
|
clock: startClock(this.gameClockMs, now),
|
||||||
|
moveRemainingMs: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
this.gameStore.set(game.id, game);
|
this.gameStore.set(game.id, game);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { EndReason, GameRecord, GameResult, MoveRecord } from "@mancala/core";
|
|||||||
import { CommonMancalaGame, GameState, GameStep } from "mancala.js";
|
import { CommonMancalaGame, GameState, GameStep } from "mancala.js";
|
||||||
import { BackendGame, PlayerInfo } from "../models/Game";
|
import { BackendGame, PlayerInfo } from "../models/Game";
|
||||||
import { publicUserId } from "./publicIds";
|
import { publicUserId } from "./publicIds";
|
||||||
|
import { startClock } from "./GameClock";
|
||||||
|
|
||||||
type HistoryEntry = { playerId: string, moveIndex: number, boardSnapshot: number[], gameSteps: GameStep[] };
|
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.
|
// 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);
|
const mancalaGame = new CommonMancalaGame(snapshot.id, snapshot.player1Id, snapshot.player2Id);
|
||||||
snapshot.board.forEach((stoneCount, index) => (mancalaGame.board.pits[index].stoneCount = stoneCount));
|
snapshot.board.forEach((stoneCount, index) => (mancalaGame.board.pits[index].stoneCount = stoneCount));
|
||||||
mancalaGame.turnPlayerId = snapshot.turnPlayerId;
|
mancalaGame.turnPlayerId = snapshot.turnPlayerId;
|
||||||
@ -76,6 +77,8 @@ export function fromSnapshot(snapshot: LiveGameSnapshot): BackendGame {
|
|||||||
players: snapshot.players,
|
players: snapshot.players,
|
||||||
startedAt: snapshot.startedAt,
|
startedAt: snapshot.startedAt,
|
||||||
moveTimes: snapshot.moveTimes,
|
moveTimes: snapshot.moveTimes,
|
||||||
|
clock: startClock(params.gameClockMs, params.now),
|
||||||
|
moveRemainingMs: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Game } from "@mancala/core";
|
import { Game, GameClock } from "@mancala/core";
|
||||||
|
|
||||||
export interface PlayerInfo {
|
export interface PlayerInfo {
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -18,4 +18,7 @@ export interface BackendGame extends Game {
|
|||||||
startedAt: number;
|
startedAt: number;
|
||||||
// Time of each move, aligned with mancalaGame.history.
|
// Time of each move, aligned with mancalaGame.history.
|
||||||
moveTimes: number[];
|
moveTimes: number[];
|
||||||
|
clock: GameClock;
|
||||||
|
// The mover's clock after each move, aligned with mancalaGame.history.
|
||||||
|
moveRemainingMs: number[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ const BOB = { id: "kc:bob", name: "guest-1a2b3c4d", isGuest: true };
|
|||||||
const CAROL = { id: "kc:carol", name: "carol", isGuest: false };
|
const CAROL = { id: "kc:carol", name: "carol", isGuest: false };
|
||||||
const DAVE = { id: "kc:dave", name: "dave", isGuest: false };
|
const DAVE = { id: "kc:dave", name: "dave", isGuest: false };
|
||||||
const LEGACY = { id: "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11", isGuest: true };
|
const LEGACY = { id: "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11", isGuest: true };
|
||||||
|
const CLOCK_MS = 5 * 60_000;
|
||||||
|
|
||||||
describe("GameManager", () => {
|
describe("GameManager", () => {
|
||||||
let now: number;
|
let now: number;
|
||||||
@ -41,6 +42,7 @@ describe("GameManager", () => {
|
|||||||
const recorder = new GameRecorder(repository, liveGameStore);
|
const recorder = new GameRecorder(repository, liveGameStore);
|
||||||
gameManager = new GameManager({
|
gameManager = new GameManager({
|
||||||
rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore, recorder, clock: () => now,
|
rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore, recorder, clock: () => now,
|
||||||
|
gameClockMs: CLOCK_MS,
|
||||||
});
|
});
|
||||||
return recorder;
|
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", () => {
|
describe("how games end", () => {
|
||||||
it("completed: the winner by the rules, with every move saved", async () => {
|
it("completed: the winner by the rules, with every move saved", async () => {
|
||||||
const game = startGame();
|
const game = startGame();
|
||||||
@ -160,7 +247,7 @@ describe("GameManager", () => {
|
|||||||
expect(record.moves.map((m) => m.at)).toEqual(game.moveTimes);
|
expect(record.moves.map((m) => m.at)).toEqual(game.moveTimes);
|
||||||
expect(gameStore.get(game.id)).toBeUndefined();
|
expect(gameStore.get(game.id)).toBeUndefined();
|
||||||
expect(rtmt.messagesTo(BOB.id, channel_on_game_end)).toEqual([
|
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]) {
|
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_user_leave)).toEqual([ALICE.id]);
|
||||||
expect(rtmt.messagesTo(user.id, channel_on_game_end)).toEqual([
|
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),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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`,
|
- [x] 1. **Core types and helpers**: `GameClock`, `CLOCK_LOW_TIME_MS`, the new optional fields on `Game`,
|
||||||
`GameEndMessage`, `MoveRecord` and `FinishedGameSummary`; `getClockTimes`, `ClockSync`, `getClockAtMove`,
|
`GameEndMessage`, `MoveRecord` and `FinishedGameSummary`; `getClockTimes`, `ClockSync`, `getClockAtMove`,
|
||||||
`formatClock` with unit tests; English timeout texts.
|
`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
|
`BackendGame`, start, charging moves, refusing late moves, stopping the clock on every ending, `clock` and
|
||||||
`serverTime` in client messages; unit tests.
|
`serverTime` in client messages; unit tests.
|
||||||
- [ ] 3. **Scheduled service**: `endGamesOutOfTime`, `GameClockScheduler` wired in `index.ts`; remove
|
- [ ] 3. **Scheduled service**: `endGamesOutOfTime`, `GameClockScheduler` wired in `index.ts`; remove
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user