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.
440 lines
20 KiB
TypeScript
440 lines
20 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 };
|
|
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("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),
|
|
},
|
|
]);
|
|
}
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|