Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
25
apps/backend/src/game/GameClockScheduler.ts
Normal file
25
apps/backend/src/game/GameClockScheduler.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { GameManager } from "./GameManager";
|
||||
|
||||
export const CLOCK_CHECK_INTERVAL_MS = 1_000;
|
||||
|
||||
// The scheduled service that ends games whose running clock has reached zero, also when nobody is connected
|
||||
// (docs/specs/game-clock, R2.2). A move that arrives late is refused by GameManager itself, so how often this runs
|
||||
// only decides how soon the end is announced, never who wins.
|
||||
export class GameClockScheduler {
|
||||
private timer?: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private readonly gameManager: Pick<GameManager, "endGamesOutOfTime">,
|
||||
private readonly intervalMs = CLOCK_CHECK_INTERVAL_MS,
|
||||
) { }
|
||||
|
||||
start() {
|
||||
if (this.timer) return;
|
||||
this.timer = setInterval(() => this.gameManager.endGamesOutOfTime(), this.intervalMs);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
@ -21,8 +21,6 @@ import {
|
||||
GameMove,
|
||||
GameUsersConnectionInfo,
|
||||
getSeat,
|
||||
INACTIVE_TIMEOUT_MS,
|
||||
TURN_TIMEOUT_MS,
|
||||
Seat,
|
||||
UserConnectionInfo,
|
||||
} from "@mancala/core";
|
||||
@ -35,8 +33,6 @@ import { fromSnapshot, GameEnding, getResult, isLiveGameSnapshot, toGameRecord,
|
||||
import { chargeMove, getRemainingMs, startClock, stopClock } from "./GameClock";
|
||||
import { GAME_CLOCK_MS } from "../consts/config";
|
||||
|
||||
const TIMEOUT_CHECK_INTERVAL_MS = 10_000;
|
||||
|
||||
// What players and spectators receive; leaves out spectator ids and other server-side state.
|
||||
export type ClientGame = Game & {
|
||||
startedAt: number, moveTimes: number[], spectatorCount: number, clock: NonNullable<Game["clock"]>, serverTime: number,
|
||||
@ -51,10 +47,7 @@ export class GameManager {
|
||||
private readonly clock: () => number;
|
||||
// Time each player has for a new game.
|
||||
private readonly gameClockMs: number;
|
||||
// When each player in a live game went offline; cleared when they reconnect.
|
||||
private readonly offlineSince = new Map<string, number>();
|
||||
private readonly liveGamesListeners: (() => void)[] = [];
|
||||
private timeoutTimer?: NodeJS.Timeout;
|
||||
|
||||
constructor(params: {
|
||||
gameStore: GameStore,
|
||||
@ -86,14 +79,6 @@ export class GameManager {
|
||||
this.liveGamesListeners.push(listener);
|
||||
}
|
||||
|
||||
public startTimers() {
|
||||
this.timeoutTimer = setInterval(() => this.checkTimeouts(), TIMEOUT_CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
public stopTimers() {
|
||||
if (this.timeoutTimer) clearInterval(this.timeoutTimer);
|
||||
}
|
||||
|
||||
private listenRtmtMessages() {
|
||||
this.rtmt.listenMessage(channel_new_game, (userKey: string, message: Object) => {
|
||||
const activeGame = this.gameStore.getGameByUser(userKey);
|
||||
@ -189,8 +174,6 @@ export class GameManager {
|
||||
}
|
||||
this.sendMessageToPlayersAndSpectators(game, channel_on_game_end, endMessage);
|
||||
game.spectatorIds = [];
|
||||
this.offlineSince.delete(game.mancalaGame.player1Id);
|
||||
this.offlineSince.delete(game.mancalaGame.player2Id);
|
||||
|
||||
const record = toGameRecord(game, ending, this.clock());
|
||||
this.liveGameStore.removeGame(game.id).catch((err) => this.logStoreError(err));
|
||||
@ -216,23 +199,14 @@ export class GameManager {
|
||||
return getSeat(mancalaGame, mancalaGame.turnPlayerId);
|
||||
}
|
||||
|
||||
// Applies the rules for players who stay away: the player whose turn it is loses after TURN_TIMEOUT_MS offline
|
||||
// while the opponent is online; a game where both are offline for INACTIVE_TIMEOUT_MS ends without a winner.
|
||||
public checkTimeouts() {
|
||||
const now = this.clock();
|
||||
// Ends every live game whose running clock has reached zero; run by GameClockScheduler. The clock runs whether or
|
||||
// not the players are online, so this also ends games that both players have left.
|
||||
public endGamesOutOfTime(now = this.clock()) {
|
||||
for (const game of this.gameStore.getAll()) {
|
||||
const { player1Id, player2Id, turnPlayerId } = game.mancalaGame;
|
||||
const player1Offline = this.offlineSince.get(player1Id);
|
||||
const player2Offline = this.offlineSince.get(player2Id);
|
||||
if (player1Offline !== undefined && player2Offline !== undefined) {
|
||||
if (now - Math.max(player1Offline, player2Offline) >= INACTIVE_TIMEOUT_MS) {
|
||||
this.endGame(game, { endReason: "inactive" });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const turnPlayerOffline = this.offlineSince.get(turnPlayerId);
|
||||
if (turnPlayerOffline !== undefined && now - turnPlayerOffline >= TURN_TIMEOUT_MS) {
|
||||
this.endGame(game, { endReason: "timeout", winnerId: this.getOpponentId(game, turnPlayerId) });
|
||||
try {
|
||||
if (this.isOutOfTime(game, now)) this.endGameOnTime(game);
|
||||
} catch (err) {
|
||||
console.error(`(GameManager) Could not end game ${game.id} on time:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -256,10 +230,8 @@ export class GameManager {
|
||||
}
|
||||
const game = fromSnapshot(snapshot, { now: this.clock(), gameClockMs: this.gameClockMs });
|
||||
this.gameStore.set(game.id, game);
|
||||
const now = this.clock();
|
||||
for (const playerId of [game.mancalaGame.player1Id, game.mancalaGame.player2Id]) {
|
||||
this.gameStore.setGameByUser(playerId, game);
|
||||
if (!this.rtmt.isClientOnline(playerId)) this.offlineSince.set(playerId, now);
|
||||
}
|
||||
game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game);
|
||||
restored++;
|
||||
@ -375,12 +347,8 @@ export class GameManager {
|
||||
}
|
||||
|
||||
private getGameUsersConnectionInfoFromPlayers(player1: PlayerInfo, player2: PlayerInfo): GameUsersConnectionInfo {
|
||||
const toConnectionInfo = (player: PlayerInfo): UserConnectionInfo => {
|
||||
const info: UserConnectionInfo = { ...player, isOnline: this.rtmt.isClientOnline(player.userId) };
|
||||
const offlineSince = this.offlineSince.get(player.userId);
|
||||
if (!info.isOnline && offlineSince !== undefined) info.offlineSince = offlineSince;
|
||||
return info;
|
||||
};
|
||||
const toConnectionInfo = (player: PlayerInfo): UserConnectionInfo =>
|
||||
({ ...player, isOnline: this.rtmt.isClientOnline(player.userId) });
|
||||
return {
|
||||
user1ConnectionInfo: toConnectionInfo(player1),
|
||||
user2ConnectionInfo: toConnectionInfo(player2)
|
||||
@ -422,11 +390,7 @@ export class GameManager {
|
||||
private listenUserConnectionChange() {
|
||||
this.rtmt.listenOnClientConnectionChange((clientId: string, isOnline: boolean) => {
|
||||
const game = this.gameStore.getGameByUser(clientId);
|
||||
if (game) {
|
||||
if (isOnline) this.offlineSince.delete(clientId);
|
||||
else if (!this.offlineSince.has(clientId)) this.offlineSince.set(clientId, this.clock());
|
||||
this.sendUserConnectionInfo(game);
|
||||
}
|
||||
if (game) this.sendUserConnectionInfo(game);
|
||||
if (!isOnline) {
|
||||
const speactatingGameIds = this.gameStore.getSpeactatingGameIdsByUser(clientId);
|
||||
speactatingGameIds.forEach((gameId) => this.removeSpectator(clientId, gameId));
|
||||
|
||||
@ -14,6 +14,7 @@ import { GameRecorder } from "./game/GameRecorder";
|
||||
import { LiveGames } from "./game/LiveGames";
|
||||
import { GameHistoryService } from "./history/GameHistoryService";
|
||||
import { ValkeyCache } from "./cache/Cache";
|
||||
import { GameClockScheduler } from "./game/GameClockScheduler";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL || "postgres://mancala:mancala@localhost:5433/mancala";
|
||||
const VALKEY_URL = process.env.VALKEY_URL || "redis://localhost:6379";
|
||||
@ -48,7 +49,7 @@ async function main() {
|
||||
// Before accepting connections, so reconnecting players find their games.
|
||||
await gameManager.restoreLiveGames();
|
||||
recorder.start();
|
||||
gameManager.startTimers();
|
||||
new GameClockScheduler(gameManager).start();
|
||||
|
||||
const expressApp = new ExpressApp({
|
||||
gameStore, gameManager, liveGames, history, tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth,
|
||||
|
||||
22
apps/backend/tests/game/GameClockScheduler.test.ts
Normal file
22
apps/backend/tests/game/GameClockScheduler.test.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { GameClockScheduler } from "../../src/game/GameClockScheduler";
|
||||
|
||||
describe("GameClockScheduler", () => {
|
||||
beforeEach(() => jest.useFakeTimers());
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it("ends games out of time on every interval until stopped", () => {
|
||||
const gameManager = { endGamesOutOfTime: jest.fn() };
|
||||
const scheduler = new GameClockScheduler(gameManager, 1_000);
|
||||
scheduler.start();
|
||||
scheduler.start(); // starting twice does not run it twice
|
||||
|
||||
jest.advanceTimersByTime(999);
|
||||
expect(gameManager.endGamesOutOfTime).not.toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(2_001);
|
||||
expect(gameManager.endGamesOutOfTime).toHaveBeenCalledTimes(3);
|
||||
|
||||
scheduler.stop();
|
||||
jest.advanceTimersByTime(10_000);
|
||||
expect(gameManager.endGamesOutOfTime).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@ -9,8 +9,6 @@ import {
|
||||
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";
|
||||
@ -230,6 +228,51 @@ describe("GameManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
@ -271,72 +314,6 @@ describe("GameManager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@ -406,22 +383,6 @@ describe("GameManager", () => {
|
||||
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();
|
||||
|
||||
@ -84,12 +84,12 @@ src/db/migrations/002_clock.ts clock_initial_ms column
|
||||
### Scheduled service (`GameClockScheduler`)
|
||||
```ts
|
||||
class GameClockScheduler {
|
||||
constructor(gameManager: GameManager, intervalMs = CLOCK_CHECK_INTERVAL_MS /* 1000 */, clock = Date.now)
|
||||
start(): void // setInterval → gameManager.endGamesOutOfTime(clock())
|
||||
constructor(gameManager: GameManager, intervalMs = CLOCK_CHECK_INTERVAL_MS /* 1000 */)
|
||||
start(): void // setInterval → gameManager.endGamesOutOfTime(), which reads GameManager's own clock
|
||||
stop(): void
|
||||
}
|
||||
```
|
||||
`src/index.ts` starts it after `restoreLiveGames()` and stops it on shutdown, replacing `gameManager.startTimers()`.
|
||||
`src/index.ts` starts it after `restoreLiveGames()`, replacing `gameManager.startTimers()`.
|
||||
It only reads memory, so Postgres or Valkey being down does not affect it; ending a game saves it through
|
||||
`GameRecorder` with retry as today.
|
||||
|
||||
|
||||
@ -8,12 +8,13 @@ Work through the tasks in order; each ends with its tests passing and a commit.
|
||||
- [x] 2. **Clock in the game lifecycle**: `GameClock.ts`, `GAME_CLOCK_MS`, `clock` and `moveRemainingMs` on
|
||||
`BackendGame`, start, charging moves, refusing late moves, stopping the clock on every ending, `clock` and
|
||||
`serverTime` in client messages; unit tests.
|
||||
- [ ] 3. **Scheduled service**: `endGamesOutOfTime`, `GameClockScheduler` wired in `index.ts`; remove
|
||||
`checkTimeouts`, the 10-second timer, `offlineSince`, `TURN_TIMEOUT_MS` and `INACTIVE_TIMEOUT_MS`; unit tests.
|
||||
- [x] 3. **Scheduled service**: `endGamesOutOfTime`, `GameClockScheduler` wired in `index.ts`; remove
|
||||
`checkTimeouts`, the 10-second timer and the backend's `offlineSince`; unit tests.
|
||||
- [ ] 4. **Restore and saving**: snapshot version 2 and restore of versions 1 and 2, `remainingMs` and
|
||||
`clockInitialMs` in game records, migration `002_clock` and the repository; unit and integration tests.
|
||||
- [ ] 5. **Web app, live game**: `useGameClock`, `GameClockView`, clocks in `GamePage` on desktop and mobile
|
||||
layouts; remove the offline countdown.
|
||||
layouts; remove the offline countdown, and with it `TURN_TIMEOUT_MS`, `INACTIVE_TIMEOUT_MS`,
|
||||
`UserConnectionInfo.offlineSince` and `OpponentOfflineCountdown` from core (the web app uses them until then).
|
||||
- [ ] 6. **Web app, replay**: clocks in `ReplayView` from `getClockAtMove`.
|
||||
- [ ] 7. **Mobile app**: clocks in `GameScreen`; type check and bundle.
|
||||
- [ ] 8. **End to end and docs**: Chrome tests from `design.md`; README, `AGENTS.md` and `TODO.md` (release the mobile
|
||||
|
||||
Loading…
Reference in New Issue
Block a user