Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
4
apps/backend/src/db/migrations/002_clock.ts
Normal file
4
apps/backend/src/db/migrations/002_clock.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// Time each player started with; NULL for games saved before the game clock. Each move's clock is in `moves`.
|
||||||
|
export const sql = `
|
||||||
|
ALTER TABLE games ADD COLUMN clock_initial_ms integer;
|
||||||
|
`;
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { sql as games001 } from "./001_games";
|
import { sql as games001 } from "./001_games";
|
||||||
|
import { sql as clock002 } from "./002_clock";
|
||||||
|
|
||||||
export interface Migration {
|
export interface Migration {
|
||||||
version: string;
|
version: string;
|
||||||
@ -8,4 +9,5 @@ export interface Migration {
|
|||||||
// Applied in this order; never change a migration that has been released, add a new one instead.
|
// Applied in this order; never change a migration that has been released, add a new one instead.
|
||||||
export const migrations: Migration[] = [
|
export const migrations: Migration[] = [
|
||||||
{ version: "001_games", sql: games001 },
|
{ version: "001_games", sql: games001 },
|
||||||
|
{ version: "002_clock", sql: clock002 },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { EndReason, GameRecord, GameResult, MoveRecord } from "@mancala/core";
|
import { EndReason, GameClock, GameRecord, GameResult, MoveRecord } from "@mancala/core";
|
||||||
import { CommonMancalaGame, GameState, GameStep } from "mancala.js";
|
import { CommonMancalaGame, GameState, GameStep } from "mancala.js";
|
||||||
import { BackendGame, PlayerInfo } from "../models/Game";
|
import { BackendGame, PlayerInfo } from "../models/Game";
|
||||||
import { publicUserId } from "./publicIds";
|
import { publicUserId } from "./publicIds";
|
||||||
import { startClock } from "./GameClock";
|
import { resumeClock, startClock } from "./GameClock";
|
||||||
|
|
||||||
type HistoryEntry = { playerId: string, moveIndex: number, boardSnapshot: number[], gameSteps: GameStep[] };
|
type HistoryEntry = { playerId: string, moveIndex: number, boardSnapshot: number[], gameSteps: GameStep[] };
|
||||||
|
|
||||||
// What is kept in Valkey for a live game; enough to rebuild it after a restart.
|
// What is kept in Valkey for a live game; enough to rebuild it after a restart. Version 1 has no clock.
|
||||||
export interface LiveGameSnapshot {
|
export interface LiveGameSnapshot {
|
||||||
version: 1;
|
version: 1 | 2;
|
||||||
id: string;
|
id: string;
|
||||||
player1Id: string;
|
player1Id: string;
|
||||||
player2Id: string;
|
player2Id: string;
|
||||||
@ -21,6 +21,8 @@ export interface LiveGameSnapshot {
|
|||||||
moveTimes: number[];
|
moveTimes: number[];
|
||||||
peakSpectators: number;
|
peakSpectators: number;
|
||||||
allSpectatorIds: string[];
|
allSpectatorIds: string[];
|
||||||
|
clock?: GameClock;
|
||||||
|
moveRemainingMs?: (number | null)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameEnding {
|
export interface GameEnding {
|
||||||
@ -33,7 +35,7 @@ export interface GameEnding {
|
|||||||
export function toSnapshot(game: BackendGame): LiveGameSnapshot {
|
export function toSnapshot(game: BackendGame): LiveGameSnapshot {
|
||||||
const { mancalaGame } = game;
|
const { mancalaGame } = game;
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 2,
|
||||||
id: game.id,
|
id: game.id,
|
||||||
player1Id: mancalaGame.player1Id,
|
player1Id: mancalaGame.player1Id,
|
||||||
player2Id: mancalaGame.player2Id,
|
player2Id: mancalaGame.player2Id,
|
||||||
@ -46,17 +48,24 @@ export function toSnapshot(game: BackendGame): LiveGameSnapshot {
|
|||||||
moveTimes: game.moveTimes,
|
moveTimes: game.moveTimes,
|
||||||
peakSpectators: game.peakSpectators,
|
peakSpectators: game.peakSpectators,
|
||||||
allSpectatorIds: game.allSpectatorIds,
|
allSpectatorIds: game.allSpectatorIds,
|
||||||
|
clock: game.clock,
|
||||||
|
moveRemainingMs: game.moveRemainingMs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLiveGameSnapshot(value: unknown): value is LiveGameSnapshot {
|
export function isLiveGameSnapshot(value: unknown): value is LiveGameSnapshot {
|
||||||
const snapshot = value as LiveGameSnapshot;
|
const snapshot = value as LiveGameSnapshot;
|
||||||
return typeof snapshot === "object" && snapshot !== null && snapshot.version === 1
|
if (typeof snapshot !== "object" || snapshot === null) return false;
|
||||||
&& typeof snapshot.id === "string" && Array.isArray(snapshot.board) && snapshot.board.length === 14
|
const valid = typeof snapshot.id === "string" && Array.isArray(snapshot.board) && snapshot.board.length === 14
|
||||||
&& Array.isArray(snapshot.history) && typeof snapshot.players === "object";
|
&& Array.isArray(snapshot.history) && typeof snapshot.players === "object";
|
||||||
|
if (snapshot.version === 1) return valid;
|
||||||
|
return snapshot.version === 2 && valid && typeof snapshot.clock?.initialMs === "number"
|
||||||
|
&& Array.isArray(snapshot.moveRemainingMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rebuilds a live game with real engine objects (rules, pits), so it can be played on.
|
// Rebuilds a live game with real engine objects (rules, pits), so it can be played on. The running clock goes on
|
||||||
|
// from the start of the turn, so the time the backend was down is not charged; games from before the game clock
|
||||||
|
// get a new clock.
|
||||||
export function fromSnapshot(snapshot: LiveGameSnapshot, params: { now: number, gameClockMs: number }): BackendGame {
|
export function fromSnapshot(snapshot: LiveGameSnapshot, params: { now: number, gameClockMs: number }): BackendGame {
|
||||||
const mancalaGame = new CommonMancalaGame(snapshot.id, snapshot.player1Id, snapshot.player2Id);
|
const mancalaGame = new CommonMancalaGame(snapshot.id, snapshot.player1Id, snapshot.player2Id);
|
||||||
snapshot.board.forEach((stoneCount, index) => (mancalaGame.board.pits[index].stoneCount = stoneCount));
|
snapshot.board.forEach((stoneCount, index) => (mancalaGame.board.pits[index].stoneCount = stoneCount));
|
||||||
@ -77,8 +86,8 @@ export function fromSnapshot(snapshot: LiveGameSnapshot, params: { now: number,
|
|||||||
players: snapshot.players,
|
players: snapshot.players,
|
||||||
startedAt: snapshot.startedAt,
|
startedAt: snapshot.startedAt,
|
||||||
moveTimes: snapshot.moveTimes,
|
moveTimes: snapshot.moveTimes,
|
||||||
clock: startClock(params.gameClockMs, params.now),
|
clock: snapshot.clock ? resumeClock(snapshot.clock, params.now) : startClock(params.gameClockMs, params.now),
|
||||||
moveRemainingMs: [],
|
moveRemainingMs: snapshot.moveRemainingMs ?? snapshot.history.map(() => null),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,13 +107,18 @@ export function toGameRecord(game: BackendGame, ending: GameEnding, endedAt: num
|
|||||||
const summary = { userId: publicUserId(id), isGuest: player?.isGuest ?? true };
|
const summary = { userId: publicUserId(id), isGuest: player?.isGuest ?? true };
|
||||||
return player?.name === undefined ? summary : { ...summary, name: player.name };
|
return player?.name === undefined ? summary : { ...summary, name: player.name };
|
||||||
};
|
};
|
||||||
const moves: MoveRecord[] = history.map((entry, i) => ({
|
const moves: MoveRecord[] = history.map((entry, i) => {
|
||||||
playerId: publicUserId(entry.playerId),
|
const move: MoveRecord = {
|
||||||
moveIndex: entry.moveIndex,
|
playerId: publicUserId(entry.playerId),
|
||||||
boardSnapshot: entry.boardSnapshot,
|
moveIndex: entry.moveIndex,
|
||||||
gameSteps: entry.gameSteps,
|
boardSnapshot: entry.boardSnapshot,
|
||||||
at: game.moveTimes[i] ?? endedAt,
|
gameSteps: entry.gameSteps,
|
||||||
}));
|
at: game.moveTimes[i] ?? endedAt,
|
||||||
|
};
|
||||||
|
const remainingMs = game.moveRemainingMs[i];
|
||||||
|
if (typeof remainingMs === "number") move.remainingMs = remainingMs;
|
||||||
|
return move;
|
||||||
|
});
|
||||||
const record: GameRecord = {
|
const record: GameRecord = {
|
||||||
id: game.id,
|
id: game.id,
|
||||||
player1: playerSummary(mancalaGame.player1Id),
|
player1: playerSummary(mancalaGame.player1Id),
|
||||||
@ -118,6 +132,7 @@ export function toGameRecord(game: BackendGame, ending: GameEnding, endedAt: num
|
|||||||
peakSpectators: game.peakSpectators,
|
peakSpectators: game.peakSpectators,
|
||||||
totalSpectators: game.allSpectatorIds.length,
|
totalSpectators: game.allSpectatorIds.length,
|
||||||
moves,
|
moves,
|
||||||
|
clockInitialMs: game.clock.initialMs,
|
||||||
};
|
};
|
||||||
if (ending.leftUserId !== undefined) record.leftUserId = publicUserId(ending.leftUserId);
|
if (ending.leftUserId !== undefined) record.leftUserId = publicUserId(ending.leftUserId);
|
||||||
return record;
|
return record;
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export interface GameHistoryRepository {
|
|||||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
const SUMMARY_COLUMNS = `g.id, g.result, g.end_reason, g.left_user_id, g.started_at, g.ended_at, g.move_count,
|
const SUMMARY_COLUMNS = `g.id, g.result, g.end_reason, g.left_user_id, g.started_at, g.ended_at, g.move_count,
|
||||||
g.final_board, g.peak_spectators,
|
g.final_board, g.peak_spectators, g.clock_initial_ms,
|
||||||
p1.user_id AS p1_user_id, p1.name AS p1_name, p1.is_guest AS p1_is_guest,
|
p1.user_id AS p1_user_id, p1.name AS p1_name, p1.is_guest AS p1_is_guest,
|
||||||
p2.user_id AS p2_user_id, p2.name AS p2_name, p2.is_guest AS p2_is_guest`;
|
p2.user_id AS p2_user_id, p2.name AS p2_name, p2.is_guest AS p2_is_guest`;
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ const FROM_GAMES = `FROM games g
|
|||||||
type SummaryRow = {
|
type SummaryRow = {
|
||||||
id: string; result: FinishedGameSummary["result"]; end_reason: FinishedGameSummary["endReason"];
|
id: string; result: FinishedGameSummary["result"]; end_reason: FinishedGameSummary["endReason"];
|
||||||
left_user_id: string | null; started_at: Date; ended_at: Date; move_count: number; final_board: number[];
|
left_user_id: string | null; started_at: Date; ended_at: Date; move_count: number; final_board: number[];
|
||||||
peak_spectators: number;
|
peak_spectators: number; clock_initial_ms: number | null;
|
||||||
p1_user_id: string; p1_name: string | null; p1_is_guest: boolean;
|
p1_user_id: string; p1_name: string | null; p1_is_guest: boolean;
|
||||||
p2_user_id: string; p2_name: string | null; p2_is_guest: boolean;
|
p2_user_id: string; p2_name: string | null; p2_is_guest: boolean;
|
||||||
};
|
};
|
||||||
@ -48,6 +48,7 @@ function toSummary(row: SummaryRow): FinishedGameSummary {
|
|||||||
peakSpectators: row.peak_spectators,
|
peakSpectators: row.peak_spectators,
|
||||||
};
|
};
|
||||||
if (row.left_user_id !== null) summary.leftUserId = row.left_user_id;
|
if (row.left_user_id !== null) summary.leftUserId = row.left_user_id;
|
||||||
|
if (row.clock_initial_ms !== null) summary.clockInitialMs = row.clock_initial_ms;
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,14 +59,14 @@ export class PostgresGameHistoryRepository implements GameHistoryRepository {
|
|||||||
await this.inTransaction(async (client) => {
|
await this.inTransaction(async (client) => {
|
||||||
const inserted = await client.query(
|
const inserted = await client.query(
|
||||||
`INSERT INTO games (id, player1_id, player2_id, result, end_reason, left_user_id, started_at, ended_at,
|
`INSERT INTO games (id, player1_id, player2_id, result, end_reason, left_user_id, started_at, ended_at,
|
||||||
move_count, final_board, moves, peak_spectators, total_spectators)
|
move_count, final_board, moves, peak_spectators, total_spectators, clock_initial_ms)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, to_timestamp($7 / 1000.0), to_timestamp($8 / 1000.0),
|
VALUES ($1, $2, $3, $4, $5, $6, to_timestamp($7 / 1000.0), to_timestamp($8 / 1000.0),
|
||||||
$9, $10, $11, $12, $13)
|
$9, $10, $11, $12, $13, $14)
|
||||||
ON CONFLICT (id) DO NOTHING`,
|
ON CONFLICT (id) DO NOTHING`,
|
||||||
[record.id, record.player1.userId, record.player2.userId, record.result, record.endReason,
|
[record.id, record.player1.userId, record.player2.userId, record.result, record.endReason,
|
||||||
record.leftUserId ?? null, record.startedAt, record.endedAt, record.moveCount,
|
record.leftUserId ?? null, record.startedAt, record.endedAt, record.moveCount,
|
||||||
JSON.stringify(record.board), JSON.stringify(record.moves), record.peakSpectators,
|
JSON.stringify(record.board), JSON.stringify(record.moves), record.peakSpectators,
|
||||||
record.totalSpectators]);
|
record.totalSpectators, record.clockInitialMs ?? null]);
|
||||||
if (inserted.rowCount === 0) return;
|
if (inserted.rowCount === 0) return;
|
||||||
for (const [seat, player] of [[1, record.player1], [2, record.player2]] as const) {
|
for (const [seat, player] of [[1, record.player1], [2, record.player2]] as const) {
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|||||||
@ -19,6 +19,6 @@ export interface BackendGame extends Game {
|
|||||||
// Time of each move, aligned with mancalaGame.history.
|
// Time of each move, aligned with mancalaGame.history.
|
||||||
moveTimes: number[];
|
moveTimes: number[];
|
||||||
clock: GameClock;
|
clock: GameClock;
|
||||||
// The mover's clock after each move, aligned with mancalaGame.history.
|
// The mover's clock after each move, aligned with mancalaGame.history; null for moves made before the game clock.
|
||||||
moveRemainingMs: number[];
|
moveRemainingMs: (number | null)[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -317,6 +317,20 @@ describe("GameManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("saving", () => {
|
describe("saving", () => {
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
|
||||||
it("replaces legacy ids with public ids in saved games", async () => {
|
it("replaces legacy ids with public ids in saved games", async () => {
|
||||||
rtmt.connect({ id: LEGACY.id, isGuest: true });
|
rtmt.connect({ id: LEGACY.id, isGuest: true });
|
||||||
const game = startGame(ALICE, { ...LEGACY, name: undefined } as any);
|
const game = startGame(ALICE, { ...LEGACY, name: undefined } as any);
|
||||||
@ -383,6 +397,54 @@ describe("GameManager", () => {
|
|||||||
expect(restored.mancalaGame.history).toHaveLength(3);
|
expect(restored.mancalaGame.history).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
|
||||||
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();
|
||||||
|
|||||||
@ -60,7 +60,7 @@ describe("PostgresGameHistoryRepository", () => {
|
|||||||
it("runs migrations only once", async () => {
|
it("runs migrations only once", async () => {
|
||||||
await runMigrations(pool);
|
await runMigrations(pool);
|
||||||
const { rows } = await pool.query("SELECT version FROM schema_migrations");
|
const { rows } = await pool.query("SELECT version FROM schema_migrations");
|
||||||
expect(rows).toEqual([{ version: "001_games" }]);
|
expect(rows).toEqual([{ version: "001_games" }, { version: "002_clock" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves a game and reads it back unchanged", async () => {
|
it("saves a game and reads it back unchanged", async () => {
|
||||||
@ -69,6 +69,22 @@ describe("PostgresGameHistoryRepository", () => {
|
|||||||
expect(await repository.get(game.id)).toEqual(game);
|
expect(await repository.get(game.id)).toEqual(game);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the clock: the initial time and each move's time left", async () => {
|
||||||
|
const game = record({ clockInitialMs: 300_000 });
|
||||||
|
game.moves[0].remainingMs = 291_500;
|
||||||
|
await repository.save(game);
|
||||||
|
expect(await repository.get(game.id)).toEqual(game);
|
||||||
|
expect((await repository.getRecentForUser(ALICE.userId, 1))[0].clockInitialMs).toBe(300_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads games saved before the game clock without clock data", async () => {
|
||||||
|
const game = record();
|
||||||
|
await repository.save(game);
|
||||||
|
const saved = await repository.get(game.id);
|
||||||
|
expect(saved).not.toHaveProperty("clockInitialMs");
|
||||||
|
expect(saved?.moves[0]).not.toHaveProperty("remainingMs");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps leftUserId for games a player left", async () => {
|
it("keeps leftUserId for games a player left", async () => {
|
||||||
const game = record({ result: "player2", endReason: "left", leftUserId: ALICE.userId });
|
const game = record({ result: "player2", endReason: "left", leftUserId: ALICE.userId });
|
||||||
await repository.save(game);
|
await repository.save(game);
|
||||||
|
|||||||
@ -10,7 +10,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
|
|||||||
`serverTime` in client messages; unit tests.
|
`serverTime` in client messages; unit tests.
|
||||||
- [x] 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 and the backend's `offlineSince`; 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
|
- [x] 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, and with it `TURN_TIMEOUT_MS`, `INACTIVE_TIMEOUT_MS`,
|
layouts; remove the offline countdown, and with it `TURN_TIMEOUT_MS`, `INACTIVE_TIMEOUT_MS`,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user