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 197 additions and 12 deletions
Showing only changes of commit 999ae79279 - Show all commits

View File

@ -1 +1,9 @@
export const RTMT_WS_PING_INTERVAL = 2000;
export const RTMT_WS_PING_INTERVAL = 2000;
// Time each player has for a whole game (docs/specs/game-clock).
export const GAME_CLOCK_MS = positiveInteger(process.env.GAME_CLOCK_MS) ?? 5 * 60 * 1000;
function positiveInteger(value: string | undefined): number | undefined {
const number = Number(value);
return value && Number.isInteger(number) && number > 0 ? number : undefined;
}

View File

@ -0,0 +1,33 @@
import { GameClock, getClockTimes, Seat } from "@mancala/core";
// The game clock (docs/specs/game-clock). A clock is stored as each player's time at the start of the current turn
// plus when that turn started, so it only changes on moves; the time left at any moment is computed.
export function startClock(initialMs: number, now: number): GameClock {
return { initialMs, player1Ms: initialMs, player2Ms: initialMs, turnStartedAt: now };
}
export function getRemainingMs(clock: GameClock, seat: Seat, runningSeat: Seat | undefined, now: number): number {
const times = getClockTimes(clock, runningSeat, now);
return seat === "player1" ? times.player1Ms : times.player2Ms;
}
// Deducts the turn's time from the mover and starts the next turn, whether it is the opponent's or an extra turn.
export function chargeMove(clock: GameClock, moverSeat: Seat, now: number): { clock: GameClock, remainingMs: number } {
const remainingMs = getRemainingMs(clock, moverSeat, moverSeat, now);
const charged = { ...clock, turnStartedAt: now };
if (moverSeat === "player1") charged.player1Ms = remainingMs;
else charged.player2Ms = remainingMs;
return { clock: charged, remainingMs };
}
// Deducts the running seat's time, if any, and stops the clock.
export function stopClock(clock: GameClock, runningSeat: Seat | undefined, now: number): GameClock {
const times = getClockTimes(clock, runningSeat, now);
return { initialMs: clock.initialMs, ...times };
}
// Starts the running clock again from the time it had at the start of the turn, e.g. after a restart.
export function resumeClock(clock: GameClock, now: number): GameClock {
return { ...clock, turnStartedAt: now };
}

View File

@ -20,8 +20,10 @@ import {
GameEndMessage,
GameMove,
GameUsersConnectionInfo,
getSeat,
INACTIVE_TIMEOUT_MS,
TURN_TIMEOUT_MS,
Seat,
UserConnectionInfo,
} from "@mancala/core";
import { GameCrashManager } from "./GameCrashManager";
@ -30,11 +32,15 @@ import { BackendGame, PlayerInfo } from "../models/Game";
import { LiveGameStore } from "../live/LiveGameStore";
import { GameRecorder } from "./GameRecorder";
import { fromSnapshot, GameEnding, getResult, isLiveGameSnapshot, toGameRecord, toSnapshot } from "./GameSerialization";
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 };
export type ClientGame = Game & {
startedAt: number, moveTimes: number[], spectatorCount: number, clock: NonNullable<Game["clock"]>, serverTime: number,
};
export class GameManager {
gameStore: GameStore;
@ -43,6 +49,8 @@ export class GameManager {
private readonly liveGameStore: LiveGameStore;
private readonly recorder: GameRecorder;
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)[] = [];
@ -55,6 +63,7 @@ export class GameManager {
liveGameStore: LiveGameStore,
recorder: GameRecorder,
clock?: () => number,
gameClockMs?: number,
}) {
this.gameStore = params.gameStore;
this.rtmt = params.rtmt;
@ -62,6 +71,7 @@ export class GameManager {
this.liveGameStore = params.liveGameStore;
this.recorder = params.recorder;
this.clock = params.clock ?? Date.now;
this.gameClockMs = params.gameClockMs ?? GAME_CLOCK_MS;
this.initialize();
}
@ -119,9 +129,21 @@ export class GameManager {
const game = this.gameStore.getGameByUser(userKey);
if (game) {
try {
const now = this.clock();
// A move that arrives after the mover's time has run out is not played.
if (userKey === game.mancalaGame.turnPlayerId && this.isOutOfTime(game, now)) {
this.endGameOnTime(game);
return;
}
const movesBefore = game.mancalaGame.history.length;
const moverSeat = getSeat(game.mancalaGame, userKey);
game.mancalaGame.moveByPlayerPit(userKey, gameMove.index);
if (game.mancalaGame.history.length > movesBefore) game.moveTimes.push(this.clock());
if (game.mancalaGame.history.length > movesBefore && moverSeat) {
game.moveTimes.push(now);
const charged = chargeMove(game.clock, moverSeat, now);
game.clock = charged.clock;
game.moveRemainingMs.push(charged.remainingMs);
}
game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game);
this.sendMessageToPlayersAndSpectators(game, channel_on_game_update, this.toClientGame(game));
if (game.mancalaGame.state == "ended") {
@ -152,11 +174,14 @@ export class GameManager {
}
}
// Ends a live game for any reason: tells everyone, forgets it and saves it.
// Ends a live game for any reason: stops the clock, tells everyone, forgets the game and saves it.
private endGame(game: BackendGame, ending: GameEnding) {
if (this.gameStore.get(game.id) !== game) return;
this.deleteGame(game);
const endMessage: GameEndMessage = { gameId: game.id, result: getResult(game, ending), endReason: ending.endReason };
game.clock = stopClock(game.clock, this.getRunningSeat(game), this.clock());
const endMessage: GameEndMessage = {
gameId: game.id, result: getResult(game, ending), endReason: ending.endReason, clock: game.clock,
};
if (ending.winnerId !== undefined) endMessage.winnerId = ending.winnerId;
if (ending.leftUserId !== undefined) {
endMessage.leftUserId = ending.leftUserId;
@ -173,6 +198,24 @@ export class GameManager {
this.notifyLiveGamesChanged();
}
// The player whose turn it is ran out of time: the opponent wins.
private endGameOnTime(game: BackendGame) {
const { turnPlayerId } = game.mancalaGame;
this.endGame(game, { endReason: "timeout", winnerId: this.getOpponentId(game, turnPlayerId) });
}
private isOutOfTime(game: BackendGame, now: number): boolean {
const runningSeat = this.getRunningSeat(game);
return runningSeat !== undefined && getRemainingMs(game.clock, runningSeat, runningSeat, now) <= 0;
}
// The seat whose clock runs; undefined once the game has ended.
private getRunningSeat(game: BackendGame): Seat | undefined {
const { mancalaGame } = game;
if (mancalaGame.state === "ended" || game.clock.turnStartedAt === undefined) return undefined;
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() {
@ -211,7 +254,7 @@ export class GameManager {
this.liveGameStore.removeGame(snapshot.id).catch((err) => this.logStoreError(err));
continue;
}
const game = fromSnapshot(snapshot);
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]) {
@ -278,6 +321,8 @@ export class GameManager {
startedAt: game.startedAt,
moveTimes: game.moveTimes,
spectatorCount: game.spectatorIds.length,
clock: game.clock,
serverTime: this.clock(),
};
}
@ -292,6 +337,7 @@ export class GameManager {
const mancalaGame = new CommonMancalaGame(generateKey(), userKey1, userKey2);
const random = Math.random();
mancalaGame.turnPlayerId = random > 0.5 ? userKey1 : userKey2;
const now = this.clock();
const players: Record<string, PlayerInfo> = {
[userKey1]: this.getPlayerInfo(userKey1),
@ -305,8 +351,10 @@ export class GameManager {
allSpectatorIds: [],
peakSpectators: 0,
players,
startedAt: this.clock(),
startedAt: now,
moveTimes: [],
clock: startClock(this.gameClockMs, now),
moveRemainingMs: [],
};
this.gameStore.set(game.id, game);

View File

@ -2,6 +2,7 @@ import { EndReason, 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";
type HistoryEntry = { playerId: string, moveIndex: number, boardSnapshot: number[], gameSteps: GameStep[] };
@ -56,7 +57,7 @@ export function isLiveGameSnapshot(value: unknown): value is LiveGameSnapshot {
}
// Rebuilds a live game with real engine objects (rules, pits), so it can be played on.
export function fromSnapshot(snapshot: LiveGameSnapshot): BackendGame {
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));
mancalaGame.turnPlayerId = snapshot.turnPlayerId;
@ -76,6 +77,8 @@ export function fromSnapshot(snapshot: LiveGameSnapshot): BackendGame {
players: snapshot.players,
startedAt: snapshot.startedAt,
moveTimes: snapshot.moveTimes,
clock: startClock(params.gameClockMs, params.now),
moveRemainingMs: [],
};
}

View File

@ -1,4 +1,4 @@
import { Game } from "@mancala/core";
import { Game, GameClock } from "@mancala/core";
export interface PlayerInfo {
userId: string;
@ -18,4 +18,7 @@ export interface BackendGame extends Game {
startedAt: number;
// 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[];
}

View File

@ -26,6 +26,7 @@ const BOB = { id: "kc:bob", name: "guest-1a2b3c4d", isGuest: true };
const CAROL = { id: "kc:carol", name: "carol", isGuest: false };
const DAVE = { id: "kc:dave", name: "dave", isGuest: false };
const LEGACY = { id: "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11", isGuest: true };
const CLOCK_MS = 5 * 60_000;
describe("GameManager", () => {
let now: number;
@ -41,6 +42,7 @@ describe("GameManager", () => {
const recorder = new GameRecorder(repository, liveGameStore);
gameManager = new GameManager({
rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore, recorder, clock: () => now,
gameClockMs: CLOCK_MS,
});
return recorder;
};
@ -143,6 +145,91 @@ describe("GameManager", () => {
});
});
describe("clock", () => {
const seatOf = (game: BackendGame, playerId: string) => game.mancalaGame.player1Id === playerId ? "player1Ms" : "player2Ms";
const opponentOf = (playerId: string) => playerId === ALICE.id ? BOB.id : ALICE.id;
it("starts with the full time for both players, running for the player who moves first", () => {
const game = startGame();
expect(game.clock).toEqual({ initialMs: CLOCK_MS, player1Ms: CLOCK_MS, player2Ms: CLOCK_MS, turnStartedAt: now });
expect(rtmt.messagesTo(ALICE.id, channel_on_game_start)[0]).toMatchObject({ clock: game.clock, serverTime: now });
});
it("a move deducts the turn's time from the mover only and starts the opponent's clock", () => {
const game = startGame();
const first = game.mancalaGame.turnPlayerId;
now += 7_000;
playAnyMove(game);
expect(game.mancalaGame.turnPlayerId).toBe(opponentOf(first));
expect(game.clock[seatOf(game, first)]).toBe(CLOCK_MS - 7_000);
expect(game.clock[seatOf(game, opponentOf(first))]).toBe(CLOCK_MS);
expect(game.clock.turnStartedAt).toBe(now);
expect(rtmt.messagesTo(BOB.id, channel_on_game_update).pop()).toMatchObject({ clock: game.clock, serverTime: now });
now += 3_000;
playAnyMove(game);
expect(game.clock[seatOf(game, opponentOf(first))]).toBe(CLOCK_MS - 3_000);
expect(game.moveRemainingMs).toEqual([CLOCK_MS - 7_000, CLOCK_MS - 3_000]);
});
it("an extra turn keeps the mover's clock running", () => {
const game = startGame();
const mover = game.mancalaGame.turnPlayerId;
now += 2_000;
// Four stones from the fourth pit: the last one lands in the mover's store.
rtmt.receive(mover, channel_game_move, { index: 3 });
expect(game.mancalaGame.turnPlayerId).toBe(mover);
now += 1_000;
playAnyMove(game);
expect(game.moveRemainingMs).toEqual([CLOCK_MS - 2_000, CLOCK_MS - 3_000]);
});
it("a move with time left is played", () => {
const game = startGame();
now += CLOCK_MS - 1;
playAnyMove(game);
expect(game.mancalaGame.history).toHaveLength(1);
expect(game.moveRemainingMs).toEqual([1]);
});
it("a move that arrives after the time has run out is not played, and the opponent wins", async () => {
const game = startGame();
const mover = game.mancalaGame.turnPlayerId;
now += CLOCK_MS;
playAnyMove(game);
await flushPromises();
expect(game.mancalaGame.history).toHaveLength(0);
expect(gameStore.get(game.id)).toBeUndefined();
const winnerSeat = game.mancalaGame.player1Id === mover ? "player2" : "player1";
expect(repository.records.get(game.id)).toMatchObject({ endReason: "timeout", result: winnerSeat });
const end = rtmt.messagesTo(mover, channel_on_game_end)[0];
expect(end).toMatchObject({ endReason: "timeout", winnerId: opponentOf(mover) });
expect(end.clock).toEqual({ initialMs: CLOCK_MS, [seatOf(game, mover)]: 0, [seatOf(game, opponentOf(mover))]: CLOCK_MS });
});
it("stops when the game ends, after deducting the running player's time", () => {
const game = startGame();
const mover = game.mancalaGame.turnPlayerId;
now += 4_000;
rtmt.receive(ALICE.id, channel_leave_game);
expect(game.clock).toEqual({
initialMs: CLOCK_MS, [seatOf(game, mover)]: CLOCK_MS - 4_000, [seatOf(game, opponentOf(mover))]: CLOCK_MS,
});
expect(rtmt.messagesTo(BOB.id, channel_on_game_end)[0].clock).toEqual(game.clock);
});
it("stops when the game is completed by the rules", () => {
const game = startGame();
playAnyMove(game);
setUpLastMove(game, ALICE.id);
now += 1_000;
rtmt.receive(ALICE.id, channel_game_move, { index: 5 });
expect(game.clock.turnStartedAt).toBeUndefined();
expect(rtmt.messagesTo(BOB.id, channel_on_game_end)[0].clock).toEqual(game.clock);
});
});
describe("how games end", () => {
it("completed: the winner by the rules, with every move saved", async () => {
const game = startGame();
@ -160,7 +247,7 @@ describe("GameManager", () => {
expect(record.moves.map((m) => m.at)).toEqual(game.moveTimes);
expect(gameStore.get(game.id)).toBeUndefined();
expect(rtmt.messagesTo(BOB.id, channel_on_game_end)).toEqual([
{ gameId: game.id, result: aliceSeat, endReason: "completed", winnerId: ALICE.id },
{ gameId: game.id, result: aliceSeat, endReason: "completed", winnerId: ALICE.id, clock: expect.any(Object) },
]);
});
@ -176,7 +263,10 @@ describe("GameManager", () => {
for (const user of [ALICE, BOB, CAROL]) {
expect(rtmt.messagesTo(user.id, channel_on_game_user_leave)).toEqual([ALICE.id]);
expect(rtmt.messagesTo(user.id, channel_on_game_end)).toEqual([
{ gameId: game.id, result: bobSeat, endReason: "left", winnerId: BOB.id, leftUserId: ALICE.id },
{
gameId: game.id, result: bobSeat, endReason: "left", winnerId: BOB.id, leftUserId: ALICE.id,
clock: expect.any(Object),
},
]);
}
});

View File

@ -5,7 +5,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
- [x] 1. **Core types and helpers**: `GameClock`, `CLOCK_LOW_TIME_MS`, the new optional fields on `Game`,
`GameEndMessage`, `MoveRecord` and `FinishedGameSummary`; `getClockTimes`, `ClockSync`, `getClockAtMove`,
`formatClock` with unit tests; English timeout texts.
- [ ] 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
`serverTime` in client messages; unit tests.
- [ ] 3. **Scheduled service**: `endGamesOutOfTime`, `GameClockScheduler` wired in `index.ts`; remove