Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
98 lines
3.6 KiB
TypeScript
98 lines
3.6 KiB
TypeScript
// The tally: what the ledgers add up to, for the hall's about panel. Read
|
|
// from the files on demand and cached for a minute, so a curious visitor
|
|
// costs nothing and a busy hall costs one scan a minute. The counts are
|
|
// the kit's; a game may add its own lines through GameSpec.tally (from a
|
|
// finished game) and GameSpec.tallyBegun (from any game begun).
|
|
|
|
import { cleanOptions, type GameSpec, type SeatId } from '../../src/lib/game/spec';
|
|
import type { Tally } from '../../src/lib/net/view';
|
|
import type { LedgerLine, Store } from './store';
|
|
|
|
const GAP_LIMIT_MS = 10 * 60 * 1000;
|
|
const CACHE_MS = 60 * 1000;
|
|
|
|
export class Tallies<State, Input> {
|
|
private cached: { at: number; tally: Tally } | null = null;
|
|
|
|
constructor(
|
|
private game: GameSpec<State, Input>,
|
|
private store: Store
|
|
) {}
|
|
|
|
get(): Tally {
|
|
if (this.cached && Date.now() - this.cached.at < CACHE_MS) return this.cached.tally;
|
|
const tally = this.count();
|
|
this.cached = { at: Date.now(), tally };
|
|
return tally;
|
|
}
|
|
|
|
private count(): Tally {
|
|
const t: Tally = { tablesOpened: 0, gamesBegun: 0, gamesFinished: 0, namesSeated: 0, turnsPlayed: 0, minutesAtTable: 0, longestGameTurns: 0, gamesWithBot: 0, talkLines: 0, firstGameAt: null, byGame: {} };
|
|
const names = new Set<string>();
|
|
let firstStart = Infinity;
|
|
for (const id of this.store.roomIds()) {
|
|
let lines: LedgerLine[];
|
|
try {
|
|
lines = this.store.read(id);
|
|
} catch {
|
|
continue; // an unreadable ledger is left out of the count, as its room is left unloaded
|
|
}
|
|
if (lines[0]?.t !== 'room') continue;
|
|
t.tablesOpened += 1;
|
|
const seats: { id: SeatId; name: string; bot: boolean }[] = [];
|
|
let state: State | null = null;
|
|
let turns = 0;
|
|
let lastMoveAt = 0;
|
|
let hasBot = false;
|
|
let over: LedgerLine | null = null;
|
|
for (const line of lines) {
|
|
if (line.t === 'seat') {
|
|
seats.push(line);
|
|
if (line.bot) hasBot = true;
|
|
else names.add(line.name.toLowerCase());
|
|
} else if (line.t === 'unseat') {
|
|
const i = seats.findIndex((s) => s.id === line.id);
|
|
if (i >= 0) seats.splice(i, 1);
|
|
} else if (line.t === 'start') {
|
|
t.gamesBegun += 1;
|
|
if (hasBot) t.gamesWithBot += 1;
|
|
if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt;
|
|
for (const [k, v] of Object.entries(this.game.tallyBegun?.(cleanOptions(this.game, lines[0].options)) ?? {})) t.byGame[k] = (t.byGame[k] ?? 0) + v;
|
|
try {
|
|
state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, cleanOptions(this.game, lines[0].options), line.rankings);
|
|
} catch {
|
|
state = null;
|
|
}
|
|
} else if (line.t === 'input') {
|
|
if (lastMoveAt && line.at - lastMoveAt < GAP_LIMIT_MS) t.minutesAtTable += (line.at - lastMoveAt) / 60000;
|
|
lastMoveAt = line.at;
|
|
} else if (line.t === 'turn') {
|
|
turns += 1;
|
|
if (lastMoveAt && line.at - lastMoveAt < GAP_LIMIT_MS) t.minutesAtTable += (line.at - lastMoveAt) / 60000;
|
|
lastMoveAt = line.at;
|
|
if (state) {
|
|
try {
|
|
state = this.game.resolve(state, line.inputs as Record<SeatId, Input>);
|
|
} catch {
|
|
state = null;
|
|
}
|
|
}
|
|
} else if (line.t === 'chat') t.talkLines += 1;
|
|
else if (line.t === 'over') over = line;
|
|
}
|
|
t.turnsPlayed += turns;
|
|
if (turns > t.longestGameTurns) t.longestGameTurns = turns;
|
|
if (over) {
|
|
t.gamesFinished += 1;
|
|
if (state && this.game.tally) {
|
|
for (const [k, v] of Object.entries(this.game.tally(state))) t.byGame[k] = (t.byGame[k] ?? 0) + v;
|
|
}
|
|
}
|
|
}
|
|
t.namesSeated = names.size;
|
|
t.minutesAtTable = Math.round(t.minutesAtTable);
|
|
t.firstGameAt = Number.isFinite(firstStart) ? new Date(firstStart).toISOString() : null;
|
|
return t;
|
|
}
|
|
}
|