Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
7 changed files with 111 additions and 137 deletions
Showing only changes of commit 3d4f5d7d59 - Show all commits

View 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;
}
}

View File

@ -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));

View File

@ -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,

View 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);
});
});

View File

@ -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();

View File

@ -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.

View File

@ -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