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

401 lines
18 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,
} 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);
});
});
});