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 clock002 } from "./002_clock";
|
||||
|
||||
export interface Migration {
|
||||
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.
|
||||
export const migrations: Migration[] = [
|
||||
{ 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 { BackendGame, PlayerInfo } from "../models/Game";
|
||||
import { publicUserId } from "./publicIds";
|
||||
import { startClock } from "./GameClock";
|
||||
import { resumeClock, startClock } from "./GameClock";
|
||||
|
||||
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 {
|
||||
version: 1;
|
||||
version: 1 | 2;
|
||||
id: string;
|
||||
player1Id: string;
|
||||
player2Id: string;
|
||||
@ -21,6 +21,8 @@ export interface LiveGameSnapshot {
|
||||
moveTimes: number[];
|
||||
peakSpectators: number;
|
||||
allSpectatorIds: string[];
|
||||
clock?: GameClock;
|
||||
moveRemainingMs?: (number | null)[];
|
||||
}
|
||||
|
||||
export interface GameEnding {
|
||||
@ -33,7 +35,7 @@ export interface GameEnding {
|
||||
export function toSnapshot(game: BackendGame): LiveGameSnapshot {
|
||||
const { mancalaGame } = game;
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
id: game.id,
|
||||
player1Id: mancalaGame.player1Id,
|
||||
player2Id: mancalaGame.player2Id,
|
||||
@ -46,17 +48,24 @@ export function toSnapshot(game: BackendGame): LiveGameSnapshot {
|
||||
moveTimes: game.moveTimes,
|
||||
peakSpectators: game.peakSpectators,
|
||||
allSpectatorIds: game.allSpectatorIds,
|
||||
clock: game.clock,
|
||||
moveRemainingMs: game.moveRemainingMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function isLiveGameSnapshot(value: unknown): value is LiveGameSnapshot {
|
||||
const snapshot = value as LiveGameSnapshot;
|
||||
return typeof snapshot === "object" && snapshot !== null && snapshot.version === 1
|
||||
&& typeof snapshot.id === "string" && Array.isArray(snapshot.board) && snapshot.board.length === 14
|
||||
if (typeof snapshot !== "object" || snapshot === null) return false;
|
||||
const valid = typeof snapshot.id === "string" && Array.isArray(snapshot.board) && snapshot.board.length === 14
|
||||
&& 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 {
|
||||
const mancalaGame = new CommonMancalaGame(snapshot.id, snapshot.player1Id, snapshot.player2Id);
|
||||
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,
|
||||
startedAt: snapshot.startedAt,
|
||||
moveTimes: snapshot.moveTimes,
|
||||
clock: startClock(params.gameClockMs, params.now),
|
||||
moveRemainingMs: [],
|
||||
clock: snapshot.clock ? resumeClock(snapshot.clock, params.now) : startClock(params.gameClockMs, params.now),
|
||||
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 };
|
||||
return player?.name === undefined ? summary : { ...summary, name: player.name };
|
||||
};
|
||||
const moves: MoveRecord[] = history.map((entry, i) => ({
|
||||
playerId: publicUserId(entry.playerId),
|
||||
moveIndex: entry.moveIndex,
|
||||
boardSnapshot: entry.boardSnapshot,
|
||||
gameSteps: entry.gameSteps,
|
||||
at: game.moveTimes[i] ?? endedAt,
|
||||
}));
|
||||
const moves: MoveRecord[] = history.map((entry, i) => {
|
||||
const move: MoveRecord = {
|
||||
playerId: publicUserId(entry.playerId),
|
||||
moveIndex: entry.moveIndex,
|
||||
boardSnapshot: entry.boardSnapshot,
|
||||
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 = {
|
||||
id: game.id,
|
||||
player1: playerSummary(mancalaGame.player1Id),
|
||||
@ -118,6 +132,7 @@ export function toGameRecord(game: BackendGame, ending: GameEnding, endedAt: num
|
||||
peakSpectators: game.peakSpectators,
|
||||
totalSpectators: game.allSpectatorIds.length,
|
||||
moves,
|
||||
clockInitialMs: game.clock.initialMs,
|
||||
};
|
||||
if (ending.leftUserId !== undefined) record.leftUserId = publicUserId(ending.leftUserId);
|
||||
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 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,
|
||||
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 = {
|
||||
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[];
|
||||
peak_spectators: number;
|
||||
peak_spectators: number; clock_initial_ms: number | null;
|
||||
p1_user_id: string; p1_name: string | null; p1_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,
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
@ -58,14 +59,14 @@ export class PostgresGameHistoryRepository implements GameHistoryRepository {
|
||||
await this.inTransaction(async (client) => {
|
||||
const inserted = await client.query(
|
||||
`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),
|
||||
$9, $10, $11, $12, $13)
|
||||
$9, $10, $11, $12, $13, $14)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[record.id, record.player1.userId, record.player2.userId, record.result, record.endReason,
|
||||
record.leftUserId ?? null, record.startedAt, record.endedAt, record.moveCount,
|
||||
JSON.stringify(record.board), JSON.stringify(record.moves), record.peakSpectators,
|
||||
record.totalSpectators]);
|
||||
record.totalSpectators, record.clockInitialMs ?? null]);
|
||||
if (inserted.rowCount === 0) return;
|
||||
for (const [seat, player] of [[1, record.player1], [2, record.player2]] as const) {
|
||||
await client.query(
|
||||
|
||||
@ -19,6 +19,6 @@ export interface BackendGame extends Game {
|
||||
// Time of each move, aligned with mancalaGame.history.
|
||||
moveTimes: number[];
|
||||
clock: GameClock;
|
||||
// The mover's clock after each move, aligned with mancalaGame.history.
|
||||
moveRemainingMs: number[];
|
||||
// The mover's clock after each move, aligned with mancalaGame.history; null for moves made before the game clock.
|
||||
moveRemainingMs: (number | null)[];
|
||||
}
|
||||
|
||||
@ -317,6 +317,20 @@ describe("GameManager", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
rtmt.connect({ id: LEGACY.id, isGuest: true });
|
||||
const game = startGame(ALICE, { ...LEGACY, name: undefined } as any);
|
||||
@ -383,6 +397,54 @@ describe("GameManager", () => {
|
||||
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 () => {
|
||||
const game = startGame();
|
||||
await flushPromises();
|
||||
|
||||
@ -60,7 +60,7 @@ describe("PostgresGameHistoryRepository", () => {
|
||||
it("runs migrations only once", async () => {
|
||||
await runMigrations(pool);
|
||||
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 () => {
|
||||
@ -69,6 +69,22 @@ describe("PostgresGameHistoryRepository", () => {
|
||||
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 () => {
|
||||
const game = record({ result: "player2", endReason: "left", leftUserId: ALICE.userId });
|
||||
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.
|
||||
- [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
|
||||
- [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.
|
||||
- [ ] 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`,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user