Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -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;
|
||||
}
|
||||
|
||||
33
apps/backend/src/game/GameClock.ts
Normal file
33
apps/backend/src/game/GameClock.ts
Normal 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 };
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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),
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user