mancala/apps/backend/tests/game/fakes.ts
Halit Aksoy 7239bbb6ed feat(backend): save every game and keep live games across restarts
- Games record start and move times, spectator peak and distinct count
- Games end as completed, left (the leaver loses), timeout (5 minutes
  offline on your turn while the opponent is online) or inactive (both
  offline for 30 minutes); everyone gets on_game_end
- Connection info carries offlineSince for the countdown
- A new game is rejected while the user has one in progress
- Live games are written to Valkey after every change and restored on
  startup; finished games are saved to Postgres with public ids, and
  saves that fail are kept and retried every 30 seconds
- Clients get a trimmed game without spectator ids
- Startup waits for Valkey, restores games, then accepts connections;
  migrations are retried while Postgres is unreachable
2026-10-16 00:18:33 +03:00

66 lines
2.4 KiB
TypeScript

import { FinishedGameSummary, GameRecord } from "@mancala/core";
import { GameHistoryRepository } from "../../src/history/GameHistoryRepository";
import { LiveGameStore } from "../../src/live/LiveGameStore";
export class InMemoryLiveGameStore implements LiveGameStore {
readonly games = new Map<string, unknown>();
readonly pending = new Map<string, GameRecord>();
failing = false;
private check() {
if (this.failing) return Promise.reject(new Error("Valkey is down"));
return Promise.resolve();
}
async saveGame(gameId: string, snapshot: unknown) {
await this.check();
this.games.set(gameId, JSON.parse(JSON.stringify(snapshot)));
}
async removeGame(gameId: string) {
await this.check();
this.games.delete(gameId);
}
async loadGames() {
await this.check();
return [...this.games.values()];
}
async savePendingRecord(record: GameRecord) {
await this.check();
this.pending.set(record.id, record);
}
async removePendingRecord(gameId: string) {
await this.check();
this.pending.delete(gameId);
}
async loadPendingRecords() {
await this.check();
return [...this.pending.values()];
}
}
export class InMemoryGameHistoryRepository implements GameHistoryRepository {
readonly records = new Map<string, GameRecord>();
failing = false;
async save(record: GameRecord) {
if (this.failing) throw new Error("database is down");
if (!this.records.has(record.id)) this.records.set(record.id, record);
}
async get(id: string) {
if (this.failing) throw new Error("database is down");
return this.records.get(id);
}
async getRecentForUser(userId: string, limit: number): Promise<FinishedGameSummary[]> {
return [...this.records.values()]
.filter((r) => r.player1.userId === userId || r.player2.userId === userId)
.sort((a, b) => b.endedAt - a.endedAt).slice(0, limit);
}
async getPopular(limit: number): Promise<FinishedGameSummary[]> {
return [...this.records.values()]
.sort((a, b) => b.peakSpectators - a.peakSpectators || b.endedAt - a.endedAt).slice(0, limit);
}
}
// Lets fire-and-forget promises (saving to Valkey or the database) settle.
export const flushPromises = () => new Promise((resolve) => setImmediate(resolve));