2026-10-16 00:08:38 +03:00
|
|
|
import {
|
|
|
|
|
channel_game_move,
|
|
|
|
|
channel_leave_game,
|
|
|
|
|
channel_listen_game_events,
|
|
|
|
|
channel_new_game,
|
2026-10-16 00:18:33 +03:00
|
|
|
channel_on_game_end,
|
2026-10-16 00:08:38 +03:00
|
|
|
channel_on_game_start,
|
2026-10-16 00:18:33 +03:00
|
|
|
channel_on_game_update,
|
|
|
|
|
channel_on_game_user_leave,
|
|
|
|
|
channel_on_new_game_rejected,
|
2026-10-16 00:08:38 +03:00
|
|
|
channel_on_user_connection_change,
|
|
|
|
|
} from "@mancala/core";
|
|
|
|
|
import { GameManager } from "../../src/game/GameManager";
|
2026-10-16 00:18:33 +03:00
|
|
|
import { GameRecorder } from "../../src/game/GameRecorder";
|
2026-10-16 00:08:38 +03:00
|
|
|
import { GameStoreImpl } from "../../src/game/gamestore/GameStoreImpl";
|
2026-10-16 00:18:33 +03:00
|
|
|
import { publicUserId } from "../../src/game/publicIds";
|
2026-10-16 00:08:38 +03:00
|
|
|
import { MatchMakerImpl } from "../../src/matchmaker/MatchMakerImpl";
|
|
|
|
|
import { BackendGame } from "../../src/models/Game";
|
|
|
|
|
import { FakeRtmt } from "./FakeRtmt";
|
2026-10-16 00:18:33 +03:00
|
|
|
import { flushPromises, InMemoryGameHistoryRepository, InMemoryLiveGameStore } from "./fakes";
|
2026-10-16 00:08:38 +03:00
|
|
|
|
|
|
|
|
const ALICE = { id: "kc:alice", name: "alice", isGuest: false };
|
2026-10-16 00:18:33 +03:00
|
|
|
const BOB = { id: "kc:bob", name: "guest-1a2b3c4d", isGuest: true };
|
2026-10-16 00:08:38 +03:00
|
|
|
const CAROL = { id: "kc:carol", name: "carol", isGuest: false };
|
2026-10-16 00:18:33 +03:00
|
|
|
const DAVE = { id: "kc:dave", name: "dave", isGuest: false };
|
|
|
|
|
const LEGACY = { id: "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11", isGuest: true };
|
2026-10-16 01:27:28 +03:00
|
|
|
const CLOCK_MS = 5 * 60_000;
|
2026-10-16 00:08:38 +03:00
|
|
|
|
|
|
|
|
describe("GameManager", () => {
|
2026-10-16 00:18:33 +03:00
|
|
|
let now: number;
|
2026-10-16 00:08:38 +03:00
|
|
|
let rtmt: FakeRtmt;
|
|
|
|
|
let gameStore: GameStoreImpl;
|
2026-10-16 00:18:33 +03:00
|
|
|
let liveGameStore: InMemoryLiveGameStore;
|
|
|
|
|
let repository: InMemoryGameHistoryRepository;
|
|
|
|
|
let gameManager: GameManager;
|
2026-10-16 00:08:38 +03:00
|
|
|
|
2026-10-16 00:18:33 +03:00
|
|
|
const createManager = () => {
|
2026-10-16 00:08:38 +03:00
|
|
|
rtmt = new FakeRtmt();
|
|
|
|
|
gameStore = new GameStoreImpl();
|
2026-10-16 00:18:33 +03:00
|
|
|
const recorder = new GameRecorder(repository, liveGameStore);
|
|
|
|
|
gameManager = new GameManager({
|
|
|
|
|
rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore, recorder, clock: () => now,
|
2026-10-16 01:27:28 +03:00
|
|
|
gameClockMs: CLOCK_MS,
|
2026-10-16 00:18:33 +03:00
|
|
|
});
|
|
|
|
|
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));
|
2026-10-16 00:08:38 +03:00
|
|
|
});
|
|
|
|
|
|
2026-10-16 00:18:33 +03:00
|
|
|
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);
|
2026-10-16 00:08:38 +03:00
|
|
|
if (!game) throw new Error("game did not start");
|
|
|
|
|
return game;
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-16 00:18:33 +03:00
|
|
|
// 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);
|
|
|
|
|
});
|
2026-10-16 00:08:38 +03:00
|
|
|
});
|
|
|
|
|
|
2026-10-16 01:27:28 +03:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-16 01:29:22 +03:00
|
|
|
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 });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-16 00:18:33 +03:00
|
|
|
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([
|
2026-10-16 01:27:28 +03:00
|
|
|
{ gameId: game.id, result: aliceSeat, endReason: "completed", winnerId: ALICE.id, clock: expect.any(Object) },
|
2026-10-16 00:18:33 +03:00
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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([
|
2026-10-16 01:27:28 +03:00
|
|
|
{
|
|
|
|
|
gameId: game.id, result: bobSeat, endReason: "left", winnerId: BOB.id, leftUserId: ALICE.id,
|
|
|
|
|
clock: expect.any(Object),
|
|
|
|
|
},
|
2026-10-16 00:18:33 +03:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-16 00:08:38 +03:00
|
|
|
});
|
|
|
|
|
|
2026-10-16 00:18:33 +03:00
|
|
|
describe("saving", () => {
|
2026-10-16 01:30:39 +03:00
|
|
|
it("saves the initial time and each move's time left", async () => {
|
|
|
|
|
const game = startGame();
|
|
|
|
|
now += 4_000;
|
|
|
|
|
playAnyMove(game);
|
|
|
|
|
now += 6_000;
|
|
|
|
|
playAnyMove(game);
|
|
|
|
|
rtmt.receive(ALICE.id, channel_leave_game);
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
|
|
const record = repository.records.get(game.id)!;
|
|
|
|
|
expect(record.clockInitialMs).toBe(CLOCK_MS);
|
|
|
|
|
expect(record.moves.map((move) => move.remainingMs)).toEqual([CLOCK_MS - 4_000, CLOCK_MS - 6_000]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-16 00:18:33 +03:00
|
|
|
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();
|
|
|
|
|
});
|
2026-10-16 00:08:38 +03:00
|
|
|
});
|
|
|
|
|
|
2026-10-16 00:18:33 +03:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-16 01:30:39 +03:00
|
|
|
it("restores the clock without charging the time the backend was down", async () => {
|
|
|
|
|
const game = startGame();
|
|
|
|
|
const first = game.mancalaGame.turnPlayerId;
|
|
|
|
|
const firstSeat = game.mancalaGame.player1Id === first ? "player1Ms" : "player2Ms";
|
|
|
|
|
const secondSeat = firstSeat === "player1Ms" ? "player2Ms" : "player1Ms";
|
|
|
|
|
now += 10_000;
|
|
|
|
|
playAnyMove(game);
|
|
|
|
|
await flushPromises();
|
|
|
|
|
now += 5_000; // the second player thinks, then the backend goes down for a minute
|
|
|
|
|
now += 60_000;
|
|
|
|
|
|
|
|
|
|
createManager();
|
|
|
|
|
await gameManager.restoreLiveGames();
|
|
|
|
|
const restored = gameStore.getGameByUser(ALICE.id)!;
|
|
|
|
|
expect(restored.clock).toEqual({
|
|
|
|
|
initialMs: CLOCK_MS, [firstSeat]: CLOCK_MS - 10_000, [secondSeat]: CLOCK_MS, turnStartedAt: now,
|
|
|
|
|
});
|
|
|
|
|
expect(restored.moveRemainingMs).toEqual([CLOCK_MS - 10_000]);
|
|
|
|
|
|
|
|
|
|
now += 1_000;
|
|
|
|
|
playAnyMove(restored);
|
|
|
|
|
expect(restored.clock[secondSeat]).toBe(CLOCK_MS - 1_000);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("gives games from before the game clock a new clock", async () => {
|
|
|
|
|
const game = startGame();
|
|
|
|
|
playAnyMove(game);
|
|
|
|
|
playAnyMove(game);
|
|
|
|
|
await flushPromises();
|
|
|
|
|
const { clock, moveRemainingMs, ...rest } = liveGameStore.games.get(game.id) as any;
|
|
|
|
|
liveGameStore.games.set(game.id, { ...rest, version: 1 });
|
|
|
|
|
|
|
|
|
|
now += 60_000;
|
|
|
|
|
createManager();
|
|
|
|
|
expect(await gameManager.restoreLiveGames()).toBe(1);
|
|
|
|
|
const restored = gameStore.getGameByUser(ALICE.id)!;
|
|
|
|
|
expect(restored.clock).toEqual({ initialMs: CLOCK_MS, player1Ms: CLOCK_MS, player2Ms: CLOCK_MS, turnStartedAt: now });
|
|
|
|
|
expect(restored.moveRemainingMs).toEqual([null, null]);
|
|
|
|
|
|
|
|
|
|
now += 2_000;
|
|
|
|
|
playAnyMove(restored);
|
|
|
|
|
rtmt.receive(ALICE.id, channel_leave_game);
|
|
|
|
|
await flushPromises();
|
|
|
|
|
const record = repository.records.get(game.id)!;
|
|
|
|
|
expect(record.clockInitialMs).toBe(CLOCK_MS);
|
|
|
|
|
expect(record.moves.map((move) => move.remainingMs)).toEqual([undefined, undefined, CLOCK_MS - 2_000]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-16 00:18:33 +03:00
|
|
|
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
|
2026-10-16 00:08:38 +03:00
|
|
|
|
2026-10-16 00:18:33 +03:00
|
|
|
createManager();
|
|
|
|
|
expect(await gameManager.restoreLiveGames()).toBe(0);
|
|
|
|
|
await flushPromises();
|
|
|
|
|
expect(liveGameStore.games.has(game.id)).toBe(false);
|
|
|
|
|
});
|
2026-10-16 00:08:38 +03:00
|
|
|
});
|
|
|
|
|
});
|