mancala/apps/backend/tests/game/GameManager.test.ts

350 lines
15 KiB
TypeScript
Raw Normal View History

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,
INACTIVE_TIMEOUT_MS,
TURN_TIMEOUT_MS,
} 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 };
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,
});
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("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 },
]);
});
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 },
]);
}
});
it("timeout: the player whose turn it is loses after 5 minutes offline", async () => {
const game = startGame();
const turnPlayer = game.mancalaGame.turnPlayerId;
const waitingPlayer = turnPlayer === ALICE.id ? BOB.id : ALICE.id;
rtmt.disconnect(turnPlayer);
const offlineAt = now;
const info = rtmt.messagesTo(waitingPlayer, channel_on_user_connection_change).pop();
const turnPlayerInfo = [info.user1ConnectionInfo, info.user2ConnectionInfo].find((i: any) => i.userId === turnPlayer);
expect(turnPlayerInfo).toMatchObject({ isOnline: false, offlineSince: offlineAt });
now = offlineAt + TURN_TIMEOUT_MS - 1;
gameManager.checkTimeouts();
expect(gameStore.get(game.id)).toBe(game);
now = offlineAt + TURN_TIMEOUT_MS;
gameManager.checkTimeouts();
await flushPromises();
expect(gameStore.get(game.id)).toBeUndefined();
const record = repository.records.get(game.id)!;
expect(record.endReason).toBe("timeout");
expect(record.result).toBe(game.mancalaGame.player1Id === waitingPlayer ? "player1" : "player2");
});
it("timeout does not apply when the waiting player is offline too, or when it is not the offline player's turn", () => {
const game = startGame();
const turnPlayer = game.mancalaGame.turnPlayerId;
const waitingPlayer = turnPlayer === ALICE.id ? BOB.id : ALICE.id;
rtmt.disconnect(waitingPlayer);
now += TURN_TIMEOUT_MS * 2;
gameManager.checkTimeouts();
expect(gameStore.get(game.id)).toBe(game);
rtmt.disconnect(turnPlayer);
now += TURN_TIMEOUT_MS * 2;
gameManager.checkTimeouts();
expect(gameStore.get(game.id)).toBe(game);
});
it("a reconnect resets the offline time", () => {
const game = startGame();
const turnPlayer = game.mancalaGame.turnPlayerId;
const user = turnPlayer === ALICE.id ? ALICE : BOB;
rtmt.disconnect(turnPlayer);
now += TURN_TIMEOUT_MS - 1000;
rtmt.connect(user);
rtmt.disconnect(turnPlayer);
now += 2000;
gameManager.checkTimeouts();
expect(gameStore.get(game.id)).toBe(game);
});
it("inactive: both players offline for 30 minutes ends the game without a winner", async () => {
const game = startGame();
rtmt.disconnect(ALICE.id);
now += 60_000;
rtmt.disconnect(BOB.id);
now += INACTIVE_TIMEOUT_MS - 1;
gameManager.checkTimeouts();
expect(gameStore.get(game.id)).toBe(game);
now += 1;
gameManager.checkTimeouts();
await flushPromises();
expect(repository.records.get(game.id)).toMatchObject({ endReason: "inactive", result: null });
});
});
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("counts players as offline from the restart, so the timeout rules start over", () => {
startGame();
createManager();
return gameManager.restoreLiveGames().then(() => {
const game = gameStore.getGameByUser(ALICE.id)!;
const waiting = game.mancalaGame.turnPlayerId === ALICE.id ? BOB : ALICE;
rtmt.connect(waiting);
now += TURN_TIMEOUT_MS - 1;
gameManager.checkTimeouts();
expect(gameStore.get(game.id)).toBe(game);
now += 1;
gameManager.checkTimeouts();
expect(gameStore.get(game.id)).toBeUndefined();
});
});
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);
});
});
});