The tally: what the ledgers add up to, in the about panel

Ported from the game kit (7aa0553). GET /api/tally scans the ledgers,
replays each duel, and returns the counts, cached a minute; the duel's
own lines come from tallyOf on the final state (wins by slaying or by
surrender, draws, wizards slain and yielded, stopped moments). The hall
fetches it when the about panel opens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-09-23 18:36:32 -04:00
1 parent 66dd3bc10e
commit 7c60d19ad3
6 files changed
+205 -1

No files matched your search

+16
View File
@@ -117,6 +117,21 @@ async function call<T>(method: string, path: string, body?: unknown): Promise<T>
type Seated = { seat: WizardId; token: string; view: RoomView };
/** A report to the keeper, with the exchange under it. */
/** What the ledgers add up to. */
export interface Tally {
tablesOpened: number;
gamesBegun: number;
gamesFinished: number;
namesSeated: number;
turnsPlayed: number;
minutesAtTable: number;
longestGameTurns: number;
gamesWithBot: number;
talkLines: number;
firstGameAt: string | null;
byGame: Record<string, number>;
}
export interface Report {
id: string;
at: string;
@@ -155,6 +170,7 @@ export const api = {
const res = await fetch(`/api/reports/${id}/image`, { method: 'POST', headers: { 'content-type': file.type || 'application/octet-stream' }, body: file });
if (!res.ok) throw new ServerError(((await res.json().catch(() => ({}))) as { error?: string }).error ?? `The server answered ${res.status}.`, res.status);
},
tally: () => call<Tally>('GET', '/api/tally'),
myReports: (seats: { roomId: string; token: string }[]) => call<{ reports: Report[] }>('POST', '/api/reports/mine', { seats }),
answerReport: (id: string, roomId: string, token: string, text: string) => call<{ ok: true }>('POST', `/api/reports/${id}/answer`, { roomId, token, text })
};
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { createGame } from './state';
import { play } from './test-helpers';
import { tallyOf } from './tally';
describe('the tally of a finished duel', () => {
it('counts nothing for a duel still in play', () => {
expect(tallyOf(createGame({ A: 'Ann', B: 'Bo' }, 1))).toEqual({});
});
it('counts a surrender as a win by surrender', () => {
const s = play(createGame({ A: 'Ann', B: 'Bo' }, 1), { left: 'P', right: 'P' });
expect(s.over?.winner).toBe('B');
expect(tallyOf(s)).toEqual({ "duels won by a rival's surrender": 1, 'wizards who yielded, all told': 1 });
});
});
+26
View File
@@ -0,0 +1,26 @@
// The game's own lines for the hall's tally, read off a finished duel. Only
// what the final state records for certain: who is dead, who yielded, and
// how the duel was decided.
import type { GameState } from './state';
export function tallyOf(state: GameState): Record<string, number> {
if (!state.over) return {};
const wizards = state.seats.map((id) => state.wizards[id]);
const dead = wizards.filter((w) => !w.alive).length;
const yielded = wizards.filter((w) => w.alive && w.surrendered).length;
const out: Record<string, number> = {};
const add = (label: string, n = 1) => {
if (n > 0) out[label] = (out[label] ?? 0) + n;
};
if (state.over.winner !== null) {
const losers = wizards.filter((w) => w.id !== state.over!.winner);
if (losers.every((w) => !w.alive)) add('duels won with every rival slain');
else add("duels won by a rival's surrender");
} else if (dead === wizards.length) add('duels drawn with every wizard dead');
else add('duels drawn with every wizard yielding');
add('wizards slain, all told', dead);
add('wizards who yielded, all told', yielded);
add('stopped moments taken', wizards.reduce((n, w) => n + w.history.filter((h) => h.phase === 'timestop').length, 0));
return out;
}