The tally in the hall's about panel, with the board played and how each game was won
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c553902d8a
commit
bac7182915
@@ -12,6 +12,7 @@
|
||||
// POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the round
|
||||
// POST /api/reports/:id/image <image bytes> a screenshot for a report just filed
|
||||
// POST /api/reports/mine {seats: [{roomId, token}]} your reports and the keeper's replies
|
||||
// GET /api/tally what the ledgers add up to, for the hall
|
||||
// POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply
|
||||
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token,
|
||||
// a seat in the Peanut Gallery, counted for the table
|
||||
@@ -27,6 +28,7 @@ import { RateLimit } from './ratelimit';
|
||||
import { IMAGE_MAX_BYTES, Reports } from './reports';
|
||||
import { RoomError, Rooms, SPECTATOR } from './rooms';
|
||||
import { Store } from './store';
|
||||
import { Tallies } from './tally';
|
||||
|
||||
const PORT = Number(process.env.PORT ?? '8789');
|
||||
const HOST = process.env.HOST ?? '127.0.0.1';
|
||||
@@ -49,6 +51,7 @@ process.on('unhandledRejection', (reason) => {
|
||||
const rooms = new Rooms(game, new Store(DATA_DIR), { name: KEEPER, zone: KEEPER_TZ });
|
||||
/** Reports live beside the room ledgers, not among them. */
|
||||
const reports = new Reports(dirname(DATA_DIR));
|
||||
const tallies = new Tallies(game, new Store(DATA_DIR));
|
||||
/** Opening rooms and taking seats are open to anyone; a script gets a few dozen an hour, not thousands. */
|
||||
const doors = new RateLimit(40, 60 * 60 * 1000);
|
||||
/** Table talk: a lively table says a few lines a minute, not hundreds. */
|
||||
@@ -178,6 +181,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts);
|
||||
if (parts[0] === 'api' && parts[1] === 'tally' && parts.length === 2 && req.method === 'GET') return send(res, 200, tallies.get());
|
||||
if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') {
|
||||
if (!claims.allow(clientOf(req))) throw new RoomError('Too many claims from here just now; try again later.', 429);
|
||||
const body = await readBody(req);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// 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.
|
||||
|
||||
import type { GameSpec, SeatId } from '../../src/lib/game/spec';
|
||||
import type { LedgerLine, Store } from './store';
|
||||
|
||||
export interface Tally {
|
||||
tablesOpened: number;
|
||||
gamesBegun: number;
|
||||
gamesFinished: number;
|
||||
/** Distinct names of people who have taken a seat; a bot's name is not counted. */
|
||||
namesSeated: number;
|
||||
turnsPlayed: number;
|
||||
/** Minutes at the table: gaps under ten minutes between one move and the next, summed. */
|
||||
minutesAtTable: number;
|
||||
longestGameTurns: number;
|
||||
gamesWithBot: number;
|
||||
talkLines: number;
|
||||
firstGameAt: string | null;
|
||||
/** The game's own lines, summed over finished games. */
|
||||
byGame: Record<string, number>;
|
||||
}
|
||||
|
||||
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()) {
|
||||
const lines = this.store.read(id);
|
||||
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') seats.splice(seats.findIndex((s) => s.id === line.id), 1);
|
||||
else if (line.t === 'start') {
|
||||
t.gamesBegun += 1;
|
||||
if (hasBot) t.gamesWithBot += 1;
|
||||
if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt;
|
||||
try {
|
||||
state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1, lines[0].options);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user