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,
|
GameMove,
|
||||||
GameUsersConnectionInfo,
|
GameUsersConnectionInfo,
|
||||||
getSeat,
|
getSeat,
|
||||||
INACTIVE_TIMEOUT_MS,
|
|
||||||
TURN_TIMEOUT_MS,
|
|
||||||
Seat,
|
Seat,
|
||||||
UserConnectionInfo,
|
UserConnectionInfo,
|
||||||
} from "@mancala/core";
|
} from "@mancala/core";
|
||||||
@ -35,8 +33,6 @@ import { fromSnapshot, GameEnding, getResult, isLiveGameSnapshot, toGameRecord,
|
|||||||
import { chargeMove, getRemainingMs, startClock, stopClock } from "./GameClock";
|
import { chargeMove, getRemainingMs, startClock, stopClock } from "./GameClock";
|
||||||
import { GAME_CLOCK_MS } from "../consts/config";
|
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.
|
// What players and spectators receive; leaves out spectator ids and other server-side state.
|
||||||
export type ClientGame = Game & {
|
export type ClientGame = Game & {
|
||||||
startedAt: number, moveTimes: number[], spectatorCount: number, clock: NonNullable<Game["clock"]>, serverTime: number,
|
startedAt: number, moveTimes: number[], spectatorCount: number, clock: NonNullable<Game["clock"]>, serverTime: number,
|
||||||
@ -51,10 +47,7 @@ export class GameManager {
|
|||||||
private readonly clock: () => number;
|
private readonly clock: () => number;
|
||||||
// Time each player has for a new game.
|
// Time each player has for a new game.
|
||||||
private readonly gameClockMs: number;
|
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 readonly liveGamesListeners: (() => void)[] = [];
|
||||||
private timeoutTimer?: NodeJS.Timeout;
|
|
||||||
|
|
||||||
constructor(params: {
|
constructor(params: {
|
||||||
gameStore: GameStore,
|
gameStore: GameStore,
|
||||||
@ -86,14 +79,6 @@ export class GameManager {
|
|||||||
this.liveGamesListeners.push(listener);
|
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() {
|
private listenRtmtMessages() {
|
||||||
this.rtmt.listenMessage(channel_new_game, (userKey: string, message: Object) => {
|
this.rtmt.listenMessage(channel_new_game, (userKey: string, message: Object) => {
|
||||||
const activeGame = this.gameStore.getGameByUser(userKey);
|
const activeGame = this.gameStore.getGameByUser(userKey);
|
||||||
@ -189,8 +174,6 @@ export class GameManager {
|
|||||||
}
|
}
|
||||||
this.sendMessageToPlayersAndSpectators(game, channel_on_game_end, endMessage);
|
this.sendMessageToPlayersAndSpectators(game, channel_on_game_end, endMessage);
|
||||||
game.spectatorIds = [];
|
game.spectatorIds = [];
|
||||||
this.offlineSince.delete(game.mancalaGame.player1Id);
|
|
||||||
this.offlineSince.delete(game.mancalaGame.player2Id);
|
|
||||||
|
|
||||||
const record = toGameRecord(game, ending, this.clock());
|
const record = toGameRecord(game, ending, this.clock());
|
||||||
this.liveGameStore.removeGame(game.id).catch((err) => this.logStoreError(err));
|
this.liveGameStore.removeGame(game.id).catch((err) => this.logStoreError(err));
|
||||||
@ -216,23 +199,14 @@ export class GameManager {
|
|||||||
return getSeat(mancalaGame, mancalaGame.turnPlayerId);
|
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
|
// Ends every live game whose running clock has reached zero; run by GameClockScheduler. The clock runs whether or
|
||||||
// while the opponent is online; a game where both are offline for INACTIVE_TIMEOUT_MS ends without a winner.
|
// not the players are online, so this also ends games that both players have left.
|
||||||
public checkTimeouts() {
|
public endGamesOutOfTime(now = this.clock()) {
|
||||||
const now = this.clock();
|
|
||||||
for (const game of this.gameStore.getAll()) {
|
for (const game of this.gameStore.getAll()) {
|
||||||
const { player1Id, player2Id, turnPlayerId } = game.mancalaGame;
|
try {
|
||||||
const player1Offline = this.offlineSince.get(player1Id);
|
if (this.isOutOfTime(game, now)) this.endGameOnTime(game);
|
||||||
const player2Offline = this.offlineSince.get(player2Id);
|
} catch (err) {
|
||||||
if (player1Offline !== undefined && player2Offline !== undefined) {
|
console.error(`(GameManager) Could not end game ${game.id} on time:`, err);
|
||||||
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) });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -256,10 +230,8 @@ export class GameManager {
|
|||||||
}
|
}
|
||||||
const game = fromSnapshot(snapshot, { now: this.clock(), gameClockMs: this.gameClockMs });
|
const game = fromSnapshot(snapshot, { now: this.clock(), gameClockMs: this.gameClockMs });
|
||||||
this.gameStore.set(game.id, game);
|
this.gameStore.set(game.id, game);
|
||||||
const now = this.clock();
|
|
||||||
for (const playerId of [game.mancalaGame.player1Id, game.mancalaGame.player2Id]) {
|
for (const playerId of [game.mancalaGame.player1Id, game.mancalaGame.player2Id]) {
|
||||||
this.gameStore.setGameByUser(playerId, game);
|
this.gameStore.setGameByUser(playerId, game);
|
||||||
if (!this.rtmt.isClientOnline(playerId)) this.offlineSince.set(playerId, now);
|
|
||||||
}
|
}
|
||||||
game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game);
|
game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game);
|
||||||
restored++;
|
restored++;
|
||||||
@ -375,12 +347,8 @@ export class GameManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getGameUsersConnectionInfoFromPlayers(player1: PlayerInfo, player2: PlayerInfo): GameUsersConnectionInfo {
|
private getGameUsersConnectionInfoFromPlayers(player1: PlayerInfo, player2: PlayerInfo): GameUsersConnectionInfo {
|
||||||
const toConnectionInfo = (player: PlayerInfo): UserConnectionInfo => {
|
const toConnectionInfo = (player: PlayerInfo): UserConnectionInfo =>
|
||||||
const info: UserConnectionInfo = { ...player, isOnline: this.rtmt.isClientOnline(player.userId) };
|
({ ...player, isOnline: this.rtmt.isClientOnline(player.userId) });
|
||||||
const offlineSince = this.offlineSince.get(player.userId);
|
|
||||||
if (!info.isOnline && offlineSince !== undefined) info.offlineSince = offlineSince;
|
|
||||||
return info;
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
user1ConnectionInfo: toConnectionInfo(player1),
|
user1ConnectionInfo: toConnectionInfo(player1),
|
||||||
user2ConnectionInfo: toConnectionInfo(player2)
|
user2ConnectionInfo: toConnectionInfo(player2)
|
||||||
@ -422,11 +390,7 @@ export class GameManager {
|
|||||||
private listenUserConnectionChange() {
|
private listenUserConnectionChange() {
|
||||||
this.rtmt.listenOnClientConnectionChange((clientId: string, isOnline: boolean) => {
|
this.rtmt.listenOnClientConnectionChange((clientId: string, isOnline: boolean) => {
|
||||||
const game = this.gameStore.getGameByUser(clientId);
|
const game = this.gameStore.getGameByUser(clientId);
|
||||||
if (game) {
|
if (game) this.sendUserConnectionInfo(game);
|
||||||
if (isOnline) this.offlineSince.delete(clientId);
|
|
||||||
else if (!this.offlineSince.has(clientId)) this.offlineSince.set(clientId, this.clock());
|
|
||||||
this.sendUserConnectionInfo(game);
|
|
||||||
}
|
|
||||||
if (!isOnline) {
|
if (!isOnline) {
|
||||||
const speactatingGameIds = this.gameStore.getSpeactatingGameIdsByUser(clientId);
|
const speactatingGameIds = this.gameStore.getSpeactatingGameIdsByUser(clientId);
|
||||||
speactatingGameIds.forEach((gameId) => this.removeSpectator(clientId, gameId));
|
speactatingGameIds.forEach((gameId) => this.removeSpectator(clientId, gameId));
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { GameRecorder } from "./game/GameRecorder";
|
|||||||
import { LiveGames } from "./game/LiveGames";
|
import { LiveGames } from "./game/LiveGames";
|
||||||
import { GameHistoryService } from "./history/GameHistoryService";
|
import { GameHistoryService } from "./history/GameHistoryService";
|
||||||
import { ValkeyCache } from "./cache/Cache";
|
import { ValkeyCache } from "./cache/Cache";
|
||||||
|
import { GameClockScheduler } from "./game/GameClockScheduler";
|
||||||
|
|
||||||
const DATABASE_URL = process.env.DATABASE_URL || "postgres://mancala:mancala@localhost:5433/mancala";
|
const DATABASE_URL = process.env.DATABASE_URL || "postgres://mancala:mancala@localhost:5433/mancala";
|
||||||
const VALKEY_URL = process.env.VALKEY_URL || "redis://localhost:6379";
|
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.
|
// Before accepting connections, so reconnecting players find their games.
|
||||||
await gameManager.restoreLiveGames();
|
await gameManager.restoreLiveGames();
|
||||||
recorder.start();
|
recorder.start();
|
||||||
gameManager.startTimers();
|
new GameClockScheduler(gameManager).start();
|
||||||
|
|
||||||
const expressApp = new ExpressApp({
|
const expressApp = new ExpressApp({
|
||||||
gameStore, gameManager, liveGames, history, tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth,
|
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_game_user_leave,
|
||||||
channel_on_new_game_rejected,
|
channel_on_new_game_rejected,
|
||||||
channel_on_user_connection_change,
|
channel_on_user_connection_change,
|
||||||
INACTIVE_TIMEOUT_MS,
|
|
||||||
TURN_TIMEOUT_MS,
|
|
||||||
} from "@mancala/core";
|
} from "@mancala/core";
|
||||||
import { GameManager } from "../../src/game/GameManager";
|
import { GameManager } from "../../src/game/GameManager";
|
||||||
import { GameRecorder } from "../../src/game/GameRecorder";
|
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", () => {
|
describe("how games end", () => {
|
||||||
it("completed: the winner by the rules, with every move saved", async () => {
|
it("completed: the winner by the rules, with every move saved", async () => {
|
||||||
const game = startGame();
|
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", () => {
|
describe("saving", () => {
|
||||||
@ -406,22 +383,6 @@ describe("GameManager", () => {
|
|||||||
expect(restored.mancalaGame.history).toHaveLength(3);
|
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 () => {
|
it("does not bring back a game that has already been saved", async () => {
|
||||||
const game = startGame();
|
const game = startGame();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|||||||
@ -84,12 +84,12 @@ src/db/migrations/002_clock.ts clock_initial_ms column
|
|||||||
### Scheduled service (`GameClockScheduler`)
|
### Scheduled service (`GameClockScheduler`)
|
||||||
```ts
|
```ts
|
||||||
class GameClockScheduler {
|
class GameClockScheduler {
|
||||||
constructor(gameManager: GameManager, intervalMs = CLOCK_CHECK_INTERVAL_MS /* 1000 */, clock = Date.now)
|
constructor(gameManager: GameManager, intervalMs = CLOCK_CHECK_INTERVAL_MS /* 1000 */)
|
||||||
start(): void // setInterval → gameManager.endGamesOutOfTime(clock())
|
start(): void // setInterval → gameManager.endGamesOutOfTime(), which reads GameManager's own clock
|
||||||
stop(): void
|
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
|
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.
|
`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
|
- [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
|
`BackendGame`, start, charging moves, refusing late moves, stopping the clock on every ending, `clock` and
|
||||||
`serverTime` in client messages; unit tests.
|
`serverTime` in client messages; unit tests.
|
||||||
- [ ] 3. **Scheduled service**: `endGamesOutOfTime`, `GameClockScheduler` wired in `index.ts`; remove
|
- [x] 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.
|
`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
|
- [ ] 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.
|
`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
|
- [ ] 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`.
|
- [ ] 6. **Web app, replay**: clocks in `ReplayView` from `getClockAtMove`.
|
||||||
- [ ] 7. **Mobile app**: clocks in `GameScreen`; type check and bundle.
|
- [ ] 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
|
- [ ] 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