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:
co-authored by
Claude Fable 5.1
parent
66dd3bc10e
commit
7c60d19ad3
@@ -20,6 +20,7 @@
|
||||
// POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the turn
|
||||
// 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
|
||||
//
|
||||
// Caddy serves the static site and proxies /api and /ws here.
|
||||
@@ -31,6 +32,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';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 8788);
|
||||
@@ -53,6 +55,7 @@ process.on('unhandledRejection', (reason) => {
|
||||
const rooms = new Rooms(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(new Store(DATA_DIR));
|
||||
const PUBLIC_URL = (process.env.WH_PUBLIC_URL ?? 'https://hands.kestrelsnest.social').replace(/\/$/, '');
|
||||
/** 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);
|
||||
@@ -196,6 +199,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,102 @@
|
||||
// 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; the duel's own lines come from tallyOf.
|
||||
|
||||
import { resolveTurn } from '../../src/lib/game/resolve';
|
||||
import { createGame, type GameState, type WizardId } from '../../src/lib/game/state';
|
||||
import { tallyOf } from '../../src/lib/game/tally';
|
||||
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 duel's own lines, summed over finished duels. */
|
||||
byGame: Record<string, number>;
|
||||
}
|
||||
|
||||
const GAP_LIMIT_MS = 10 * 60 * 1000;
|
||||
const CACHE_MS = 60 * 1000;
|
||||
|
||||
export class Tallies {
|
||||
private cached: { at: number; tally: Tally } | null = null;
|
||||
|
||||
constructor(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);
|
||||
const first = lines[0];
|
||||
if (first?.t !== 'room') continue;
|
||||
t.tablesOpened += 1;
|
||||
const seats: { id: WizardId; name: string; bot: boolean }[] = [];
|
||||
let state: GameState | 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 (first.createdAt < firstStart) firstStart = first.createdAt;
|
||||
try {
|
||||
state = createGame(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1);
|
||||
} 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 = resolveTurn(state, line.inputs);
|
||||
} 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) for (const [k, v] of Object.entries(tallyOf(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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { otherGames, type FamilyGame } from '$lib/family';
|
||||
import { allSeats, api, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report } from '$lib/game/client';
|
||||
import { allSeats, api, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report, type Tally } from '$lib/game/client';
|
||||
import { Duel, duel, NAME_MAX, playerName, rememberName } from '$lib/game/duel.svelte';
|
||||
import { unreadTalk } from '$lib/game/talk';
|
||||
import type { RoomView } from '$lib/game/view';
|
||||
@@ -13,6 +13,12 @@
|
||||
let seats = $state(allSeats());
|
||||
let games = $state<Record<string, RoomView | null>>({});
|
||||
let aboutOpen = $state(false);
|
||||
/** The ledgers' tally, fetched when the about panel opens. */
|
||||
let tally = $state<Tally | null>(null);
|
||||
$effect(() => {
|
||||
if (aboutOpen && !tally) api.tally().then((t) => (tally = t)).catch(() => (tally = null));
|
||||
});
|
||||
const hours = (minutes: number) => (minutes < 90 ? `${minutes} minutes` : `${Math.round(minutes / 60)} hours`);
|
||||
/** The other games of the Kestrel's Nest, for the about panel. */
|
||||
let family = $state<FamilyGame[]>([]);
|
||||
$effect(() => {
|
||||
@@ -290,6 +296,23 @@
|
||||
<p>It is free and keeps no accounts. A duel against the bot lives in your own browser. A duel between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first gesture, and each wizard is shown only what the rules let them see.</p>
|
||||
<h3>Send word</h3>
|
||||
<p>A rule read wrong, a bug, a duel you would like to tell of: write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
|
||||
{#if tally}
|
||||
<h3>The tally</h3>
|
||||
<dl class="tally">
|
||||
<dt>{tally.gamesFinished}</dt><dd>{tally.gamesFinished === 1 ? 'duel' : 'duels'} fought to a finish, of {tally.gamesBegun} begun at {tally.tablesOpened} {tally.tablesOpened === 1 ? 'table' : 'tables'}</dd>
|
||||
<dt>{tally.namesSeated}</dt><dd>{tally.namesSeated === 1 ? 'name has' : 'names have'} taken a seat</dd>
|
||||
<dt>{tally.turnsPlayed}</dt><dd>turns of gestures written; {tally.longestGameTurns} in the longest duel</dd>
|
||||
<dt>{hours(tally.minutesAtTable)}</dt><dd>at the table, by the clock between reveals</dd>
|
||||
<dt>{tally.gamesWithBot}</dt><dd>{tally.gamesWithBot === 1 ? 'duel' : 'duels'} with the bot seated; {tally.talkLines} {tally.talkLines === 1 ? 'line' : 'lines'} of table talk</dd>
|
||||
{#each Object.entries(tally.byGame) as [label, n] (label)}
|
||||
<dt>{n}</dt><dd>{label}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
<p class="muted small">
|
||||
{#if tally.firstGameAt}The first duel began {new Date(tally.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}.{/if}
|
||||
Names are counted, not people: one wizard under two names is two here. Table time counts only the minutes between reveals, so a duel left open overnight adds nothing for the night.
|
||||
</p>
|
||||
{/if}
|
||||
{#if family.length}
|
||||
<h3>More games at the Kestrel's Nest</h3>
|
||||
<ul class="family">
|
||||
@@ -664,6 +687,23 @@
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.tally {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.25rem 0.9rem;
|
||||
margin: 0.4rem 0 0.6rem;
|
||||
}
|
||||
|
||||
.tally dt {
|
||||
text-align: right;
|
||||
font-weight: 500;
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.tally dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.about-body em {
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user