GameClockScheduler checks every live game once a second and ends those whose running clock has reached zero, whether or not anyone is connected. The clock replaces the offline turn timeout and the inactivity rule, so both are removed with the offline times they used.
401 lines
18 KiB
TypeScript
401 lines
18 KiB
TypeScript
import {
|
|
channel_game_move,
|
|
channel_leave_game,
|
|
channel_listen_game_events,
|
|
channel_new_game,
|
|
channel_on_game_end,
|
|
channel_on_game_start,
|
|
channel_on_game_update,
|
|
channel_on_game_user_leave,
|
|
channel_on_new_game_rejected,
|
|
channel_on_user_connection_change,
|
|
} from "@mancala/core";
|
|
import { GameManager } from "../../src/game/GameManager";
|
|
import { GameRecorder } from "../../src/game/GameRecorder";
|
|
import { GameStoreImpl } from "../../src/game/gamestore/GameStoreImpl";
|
|
import { publicUserId } from "../../src/game/publicIds";
|
|
import { MatchMakerImpl } from "../../src/matchmaker/MatchMakerImpl";
|
|
import { BackendGame } from "../../src/models/Game";
|
|
import { FakeRtmt } from "./FakeRtmt";
|
|
import { flushPromises, InMemoryGameHistoryRepository, InMemoryLiveGameStore } from "./fakes";
|
|
|
|
const ALICE = { id: "kc:alice", name: "alice", isGuest: false };
|
|
const BOB = { id: "kc:bob", name: "guest-1a2b3c4d", isGuest: true };
|
|
const CAROL = { id: "kc:carol", name: "carol", isGuest: false };
|
|
const DAVE = { id: "kc:dave", name: "dave", isGuest: false };
|
|
const LEGACY = { id: "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11", isGuest: true };
|
|
const CLOCK_MS = 5 * 60_000;
|
|
|
|
describe("GameManager", () => {
|
|
let now: number;
|
|
let rtmt: FakeRtmt;
|
|
let gameStore: GameStoreImpl;
|
|
let liveGameStore: InMemoryLiveGameStore;
|
|
let repository: InMemoryGameHistoryRepository;
|
|
let gameManager: GameManager;
|
|
|
|
const createManager = () => {
|
|
rtmt = new FakeRtmt();
|
|
gameStore = new GameStoreImpl();
|
|
const recorder = new GameRecorder(repository, liveGameStore);
|
|
gameManager = new GameManager({
|
|
rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore, recorder, clock: () => now,
|
|
gameClockMs: CLOCK_MS,
|
|
});
|
|
return recorder;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
now = Date.UTC(2026, 9, 16, 12);
|
|
liveGameStore = new InMemoryLiveGameStore();
|
|
repository = new InMemoryGameHistoryRepository();
|
|
createManager();
|
|
[ALICE, BOB, CAROL, DAVE].forEach((user) => rtmt.connect(user));
|
|
});
|
|
|
|
const startGame = (first = ALICE, second = BOB): BackendGame => {
|
|
rtmt.receive(first.id, channel_new_game);
|
|
rtmt.receive(second.id, channel_new_game);
|
|
const game = gameStore.getGameByUser(first.id);
|
|
if (!game) throw new Error("game did not start");
|
|
return game;
|
|
};
|
|
|
|
// Plays the current player's first non-empty pit.
|
|
const playAnyMove = (game: BackendGame) => {
|
|
const { turnPlayerId } = game.mancalaGame;
|
|
const pit = [0, 1, 2, 3, 4, 5].find((p) =>
|
|
game.mancalaGame.board.pits[game.mancalaGame.getBoardIndexByPlayerId(turnPlayerId, p)].stoneCount > 0)!;
|
|
rtmt.receive(turnPlayerId, channel_game_move, { index: pit });
|
|
};
|
|
|
|
// Leaves the given player a single stone next to their store; playing it empties their side and ends the game.
|
|
const setUpLastMove = (game: BackendGame, playerId: string) => {
|
|
const isPlayer1 = game.mancalaGame.player1Id === playerId;
|
|
const pits = game.mancalaGame.board.pits;
|
|
pits.forEach((pit) => (pit.stoneCount = 0));
|
|
pits[isPlayer1 ? 5 : 12].stoneCount = 1;
|
|
pits[isPlayer1 ? 7 : 0].stoneCount = 1;
|
|
game.mancalaGame.turnPlayerId = playerId;
|
|
};
|
|
|
|
describe("while a game is played", () => {
|
|
it("sends the connection info once when a game starts", () => {
|
|
startGame();
|
|
expect(rtmt.messagesTo(ALICE.id, channel_on_game_start)).toHaveLength(1);
|
|
expect(rtmt.messagesTo(ALICE.id, channel_on_user_connection_change)).toHaveLength(1);
|
|
});
|
|
|
|
it("does not send spectator ids to clients", () => {
|
|
const game = startGame();
|
|
rtmt.receive(CAROL.id, channel_listen_game_events, game.id);
|
|
playAnyMove(game);
|
|
const update = rtmt.messagesTo(ALICE.id, channel_on_game_update).pop();
|
|
expect(update).not.toHaveProperty("spectatorIds");
|
|
expect(update).not.toHaveProperty("allSpectatorIds");
|
|
expect(update.spectatorCount).toBe(1);
|
|
});
|
|
|
|
it("records the time of each move", () => {
|
|
const game = startGame();
|
|
now += 1000;
|
|
playAnyMove(game);
|
|
now += 2000;
|
|
playAnyMove(game);
|
|
expect(game.moveTimes).toEqual([game.startedAt + 1000, game.startedAt + 3000]);
|
|
expect(rtmt.messagesTo(ALICE.id, channel_on_game_update).pop().moveTimes).toHaveLength(2);
|
|
});
|
|
|
|
it("rejects a new game for a player who has one in progress", () => {
|
|
const game = startGame();
|
|
rtmt.receive(ALICE.id, channel_new_game);
|
|
rtmt.receive(CAROL.id, channel_new_game);
|
|
expect(rtmt.messagesTo(ALICE.id, channel_on_new_game_rejected)).toEqual([{ gameId: game.id }]);
|
|
expect(gameStore.getGameByUser(ALICE.id)).toBe(game);
|
|
expect(gameStore.getGameByUser(CAROL.id)).toBeUndefined();
|
|
});
|
|
|
|
it("counts spectators: removed on disconnect, peak and distinct total kept", async () => {
|
|
const game = startGame();
|
|
rtmt.receive(CAROL.id, channel_listen_game_events, game.id);
|
|
rtmt.receive(DAVE.id, channel_listen_game_events, game.id);
|
|
rtmt.disconnect(CAROL.id);
|
|
rtmt.connect(CAROL);
|
|
rtmt.receive(CAROL.id, channel_listen_game_events, game.id);
|
|
rtmt.disconnect(DAVE.id);
|
|
expect(game.spectatorIds).toEqual([CAROL.id]);
|
|
expect(game.peakSpectators).toBe(2);
|
|
|
|
rtmt.receive(ALICE.id, channel_leave_game);
|
|
await flushPromises();
|
|
expect(repository.records.get(game.id)).toMatchObject({ peakSpectators: 2, totalSpectators: 2 });
|
|
});
|
|
|
|
it("keeps a snapshot of the live game until it ends", async () => {
|
|
const game = startGame();
|
|
playAnyMove(game);
|
|
await flushPromises();
|
|
expect(liveGameStore.games.get(game.id)).toMatchObject({ id: game.id, moveTimes: [now] });
|
|
|
|
rtmt.receive(ALICE.id, channel_leave_game);
|
|
await flushPromises();
|
|
expect(liveGameStore.games.has(game.id)).toBe(false);
|
|
});
|
|
});
|
|
|
|
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("ending games on time", () => {
|
|
it("ends only the games whose running clock has reached zero, telling players and spectators", async () => {
|
|
const late = startGame(ALICE, BOB);
|
|
rtmt.receive(DAVE.id, channel_listen_game_events, late.id);
|
|
now += 60_000;
|
|
const early = startGame(CAROL, { ...DAVE, id: "kc:erin", name: "erin" });
|
|
const latePlayer = late.mancalaGame.turnPlayerId;
|
|
|
|
now += CLOCK_MS - 60_000 - 1;
|
|
gameManager.endGamesOutOfTime();
|
|
expect(gameStore.get(late.id)).toBe(late);
|
|
|
|
now += 1;
|
|
gameManager.endGamesOutOfTime();
|
|
await flushPromises();
|
|
expect(gameStore.get(late.id)).toBeUndefined();
|
|
expect(gameStore.get(early.id)).toBe(early);
|
|
const winnerSeat = late.mancalaGame.player1Id === latePlayer ? "player2" : "player1";
|
|
expect(repository.records.get(late.id)).toMatchObject({ endReason: "timeout", result: winnerSeat });
|
|
for (const user of [ALICE, BOB, DAVE]) {
|
|
expect(rtmt.messagesTo(user.id, channel_on_game_end)).toEqual([
|
|
expect.objectContaining({ gameId: late.id, endReason: "timeout", result: winnerSeat }),
|
|
]);
|
|
}
|
|
});
|
|
|
|
it("keeps the clock running while players are offline, so games nobody comes back to end too", async () => {
|
|
const game = startGame();
|
|
rtmt.disconnect(ALICE.id);
|
|
rtmt.disconnect(BOB.id);
|
|
now += CLOCK_MS;
|
|
gameManager.endGamesOutOfTime();
|
|
await flushPromises();
|
|
expect(repository.records.get(game.id)).toMatchObject({ endReason: "timeout" });
|
|
});
|
|
|
|
it("no longer sends when players went offline", () => {
|
|
startGame();
|
|
rtmt.disconnect(BOB.id);
|
|
const info = rtmt.messagesTo(ALICE.id, channel_on_user_connection_change).pop();
|
|
expect([info.user1ConnectionInfo, info.user2ConnectionInfo].find((i: any) => i.userId === BOB.id))
|
|
.toEqual({ userId: BOB.id, name: BOB.name, isGuest: true, isOnline: false });
|
|
});
|
|
});
|
|
|
|
describe("how games end", () => {
|
|
it("completed: the winner by the rules, with every move saved", async () => {
|
|
const game = startGame();
|
|
playAnyMove(game);
|
|
setUpLastMove(game, ALICE.id);
|
|
rtmt.receive(ALICE.id, channel_game_move, { index: 5 });
|
|
await flushPromises();
|
|
|
|
// Alice's stone goes into her store and empties her side, so she also collects Bob's last stone: 2-0.
|
|
const aliceSeat = game.mancalaGame.player1Id === ALICE.id ? "player1" : "player2";
|
|
const record = repository.records.get(game.id)!;
|
|
expect(record.endReason).toBe("completed");
|
|
expect(record.result).toBe(aliceSeat);
|
|
expect(record.moves).toHaveLength(2);
|
|
expect(record.moves.map((m) => m.at)).toEqual(game.moveTimes);
|
|
expect(gameStore.get(game.id)).toBeUndefined();
|
|
expect(rtmt.messagesTo(BOB.id, channel_on_game_end)).toEqual([
|
|
{ gameId: game.id, result: aliceSeat, endReason: "completed", winnerId: ALICE.id, clock: expect.any(Object) },
|
|
]);
|
|
});
|
|
|
|
it("left: the player who leaves loses", async () => {
|
|
const game = startGame();
|
|
rtmt.receive(CAROL.id, channel_listen_game_events, game.id);
|
|
rtmt.receive(ALICE.id, channel_leave_game);
|
|
await flushPromises();
|
|
|
|
const record = repository.records.get(game.id)!;
|
|
const bobSeat = game.mancalaGame.player1Id === BOB.id ? "player1" : "player2";
|
|
expect(record).toMatchObject({ endReason: "left", result: bobSeat, leftUserId: ALICE.id });
|
|
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_end)).toEqual([
|
|
{
|
|
gameId: game.id, result: bobSeat, endReason: "left", winnerId: BOB.id, leftUserId: ALICE.id,
|
|
clock: expect.any(Object),
|
|
},
|
|
]);
|
|
}
|
|
});
|
|
|
|
});
|
|
|
|
describe("saving", () => {
|
|
it("replaces legacy ids with public ids in saved games", async () => {
|
|
rtmt.connect({ id: LEGACY.id, isGuest: true });
|
|
const game = startGame(ALICE, { ...LEGACY, name: undefined } as any);
|
|
playAnyMove(game);
|
|
rtmt.receive(LEGACY.id, channel_leave_game);
|
|
await flushPromises();
|
|
|
|
const saved = JSON.stringify(repository.records.get(game.id));
|
|
expect(saved).not.toContain(LEGACY.id);
|
|
expect(saved).toContain(publicUserId(LEGACY.id));
|
|
expect(saved).toContain(ALICE.id);
|
|
});
|
|
|
|
it("keeps a game that could not be saved and saves it once the database is back", async () => {
|
|
repository.failing = true;
|
|
const game = startGame();
|
|
rtmt.receive(ALICE.id, channel_leave_game);
|
|
await flushPromises();
|
|
expect(liveGameStore.pending.has(game.id)).toBe(true);
|
|
|
|
// A restarted backend picks the pending record up from Valkey.
|
|
repository.failing = false;
|
|
const recorder = createManager();
|
|
await recorder.retryPending();
|
|
expect(repository.records.has(game.id)).toBe(true);
|
|
expect(liveGameStore.pending.has(game.id)).toBe(false);
|
|
});
|
|
|
|
it("keeps games playable while Valkey and the database are down", async () => {
|
|
liveGameStore.failing = true;
|
|
repository.failing = true;
|
|
const game = startGame();
|
|
playAnyMove(game);
|
|
playAnyMove(game);
|
|
rtmt.receive(ALICE.id, channel_leave_game);
|
|
await flushPromises();
|
|
expect(game.mancalaGame.history).toHaveLength(2);
|
|
expect(gameStore.get(game.id)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("restart", () => {
|
|
it("restores live games, which can then be played on", async () => {
|
|
const game = startGame();
|
|
playAnyMove(game);
|
|
playAnyMove(game);
|
|
await flushPromises();
|
|
const board = game.mancalaGame.board.getStoneArray();
|
|
const turnPlayer = game.mancalaGame.turnPlayerId;
|
|
|
|
createManager();
|
|
expect(await gameManager.restoreLiveGames()).toBe(1);
|
|
const restored = gameStore.getGameByUser(ALICE.id)!;
|
|
expect(restored.id).toBe(game.id);
|
|
expect(restored.mancalaGame.board.getStoneArray()).toEqual(board);
|
|
expect(restored.mancalaGame.turnPlayerId).toBe(turnPlayer);
|
|
expect(restored.mancalaGame.history).toHaveLength(2);
|
|
expect(gameStore.getGameByUser(BOB.id)).toBe(restored);
|
|
|
|
rtmt.connect(ALICE);
|
|
rtmt.connect(BOB);
|
|
expect(rtmt.messagesTo(ALICE.id, channel_on_game_update)).toHaveLength(1);
|
|
playAnyMove(restored);
|
|
expect(restored.mancalaGame.history).toHaveLength(3);
|
|
});
|
|
|
|
it("does not bring back a game that has already been saved", async () => {
|
|
const game = startGame();
|
|
await flushPromises();
|
|
const snapshot = liveGameStore.games.get(game.id);
|
|
rtmt.receive(ALICE.id, channel_leave_game);
|
|
await flushPromises();
|
|
liveGameStore.games.set(game.id, snapshot); // as if removing it had failed
|
|
|
|
createManager();
|
|
expect(await gameManager.restoreLiveGames()).toBe(0);
|
|
await flushPromises();
|
|
expect(liveGameStore.games.has(game.id)).toBe(false);
|
|
});
|
|
});
|
|
});
|