- Games record start and move times, spectator peak and distinct count - Games end as completed, left (the leaver loses), timeout (5 minutes offline on your turn while the opponent is online) or inactive (both offline for 30 minutes); everyone gets on_game_end - Connection info carries offlineSince for the countdown - A new game is rejected while the user has one in progress - Live games are written to Valkey after every change and restored on startup; finished games are saved to Postgres with public ids, and saves that fail are kept and retried every 30 seconds - Clients get a trimmed game without spectator ids - Startup waits for Valkey, restores games, then accepts connections; migrations are retried while Postgres is unreachable
350 lines
15 KiB
TypeScript
350 lines
15 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,
|
|
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);
|
|
});
|
|
});
|
|
});
|