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/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/: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
|
// 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
|
// 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,
|
// 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
|
// 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 { IMAGE_MAX_BYTES, Reports } from './reports';
|
||||||
import { RoomError, Rooms, SPECTATOR } from './rooms';
|
import { RoomError, Rooms, SPECTATOR } from './rooms';
|
||||||
import { Store } from './store';
|
import { Store } from './store';
|
||||||
|
import { Tallies } from './tally';
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT ?? '8789');
|
const PORT = Number(process.env.PORT ?? '8789');
|
||||||
const HOST = process.env.HOST ?? '127.0.0.1';
|
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 });
|
const rooms = new Rooms(game, new Store(DATA_DIR), { name: KEEPER, zone: KEEPER_TZ });
|
||||||
/** Reports live beside the room ledgers, not among them. */
|
/** Reports live beside the room ledgers, not among them. */
|
||||||
const reports = new Reports(dirname(DATA_DIR));
|
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. */
|
/** 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);
|
const doors = new RateLimit(40, 60 * 60 * 1000);
|
||||||
/** Table talk: a lively table says a few lines a minute, not hundreds. */
|
/** 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 url = new URL(req.url ?? '/', 'http://localhost');
|
||||||
const parts = url.pathname.split('/').filter(Boolean);
|
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] === '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 (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);
|
if (!claims.allow(clientOf(req))) throw new RoomError('Too many claims from here just now; try again later.', 429);
|
||||||
const body = await readBody(req);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
// about panel with the rules, the guide and a way to reach the keeper.
|
// about panel with the rules, the guide and a way to reach the keeper.
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { otherGames, type FamilyGame } from '$lib/family';
|
import { otherGames, type FamilyGame } from '$lib/family';
|
||||||
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report } from '$lib/net/client';
|
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report, type Tally } from '$lib/net/client';
|
||||||
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
|
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
|
||||||
import { unreadTalk } from '$lib/net/talk';
|
import { unreadTalk } from '$lib/net/talk';
|
||||||
import type { RoomView } from '$lib/net/view';
|
import type { RoomView } from '$lib/net/view';
|
||||||
@@ -20,6 +20,12 @@
|
|||||||
let seats = $state(allSeats());
|
let seats = $state(allSeats());
|
||||||
let games = $state<Record<string, RoomView<State> | null>>({});
|
let games = $state<Record<string, RoomView<State> | null>>({});
|
||||||
let aboutOpen = $state(false);
|
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. */
|
/** The other games of the Kestrel's Nest, for the about panel. */
|
||||||
let family = $state<FamilyGame[]>([]);
|
let family = $state<FamilyGame[]>([]);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -261,6 +267,23 @@
|
|||||||
<p>It is free and keeps no accounts. A game between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first move, and each player is shown only what the rules let them see.</p>
|
<p>It is free and keeps no accounts. A game between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first move, and each player is shown only what the rules let them see.</p>
|
||||||
<h3>Send word</h3>
|
<h3>Send word</h3>
|
||||||
<p>A rule read wrong, a bug, a game 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>
|
<p>A rule read wrong, a bug, a game 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 ? 'game' : 'games'} played 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 recorded; {tally.longestGameTurns} in the longest game</dd>
|
||||||
|
<dt>{hours(tally.minutesAtTable)}</dt><dd>at the table, by the clock between moves</dd>
|
||||||
|
<dt>{tally.gamesWithBot}</dt><dd>{tally.gamesWithBot === 1 ? 'game' : 'games'} with a housecarl 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 game began {new Date(tally.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}.{/if}
|
||||||
|
Names are counted, not people: one player under two names is two here. Table time counts only the minutes between moves, so a game left open overnight adds nothing for the night.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{#if family.length}
|
{#if family.length}
|
||||||
<h3>More games at the Kestrel's Nest</h3>
|
<h3>More games at the Kestrel's Nest</h3>
|
||||||
<ul class="family">
|
<ul class="family">
|
||||||
@@ -593,4 +616,19 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tally dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+12
-1
@@ -409,5 +409,16 @@ export const game: GameSpec<State, Input> = {
|
|||||||
return { from: Number.isInteger(r.from) ? (r.from as number) : -1, to: Number.isInteger(r.to) ? (r.to as number) : -1 };
|
return { from: Number.isInteger(r.from) ? (r.from as number) : -1, to: Number.isInteger(r.to) ? (r.to as number) : -1 };
|
||||||
},
|
},
|
||||||
validate: validateMove,
|
validate: validateMove,
|
||||||
nameOf: (state, seat) => state.players[seat]?.name ?? seat
|
nameOf: (state, seat) => state.players[seat]?.name ?? seat,
|
||||||
|
tally: (state) => {
|
||||||
|
const lines: Record<string, number> = {};
|
||||||
|
lines[state.set === 'tablut' ? 'played as Tablut' : 'played by Copenhagen rules'] = 1;
|
||||||
|
const reason = state.over?.reason ?? '';
|
||||||
|
if (/reaches/.test(reason)) lines["won by the king's escape"] = 1;
|
||||||
|
else if (/taken/.test(reason)) lines["won by taking the king"] = 1;
|
||||||
|
else if (/edge fort/.test(reason)) lines['won by an edge fort'] = 1;
|
||||||
|
else if (/sealed/.test(reason)) lines['won by encirclement'] = 1;
|
||||||
|
else if (/repetition|no move/.test(reason)) lines['decided by repetition or a stalemate'] = 1;
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,4 +66,6 @@ export interface GameSpec<State, Input> {
|
|||||||
validate?(state: State, seat: SeatId, input: Input): string | null;
|
validate?(state: State, seat: SeatId, input: Input): string | null;
|
||||||
/** A seat's name from the state, for the hall and the chronicle. */
|
/** A seat's name from the state, for the hall and the chronicle. */
|
||||||
nameOf(state: State, seat: SeatId): string;
|
nameOf(state: State, seat: SeatId): string;
|
||||||
|
/** The game's own lines for the hall's tally, counted from a finished game: a label and how many it adds. */
|
||||||
|
tally?(state: State): Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,21 @@ export interface Report {
|
|||||||
image?: string;
|
image?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 function makeApi<State, Input>() {
|
export function makeApi<State, Input>() {
|
||||||
type Seated = { seat: SeatId; token: string; view: RoomView<State> };
|
type Seated = { seat: SeatId; token: string; view: RoomView<State> };
|
||||||
type View = RoomView<State>;
|
type View = RoomView<State>;
|
||||||
@@ -156,6 +171,7 @@ export function makeApi<State, Input>() {
|
|||||||
const res = await fetch(`/api/reports/${id}/image`, { method: 'POST', headers: { 'content-type': file.type || 'application/octet-stream' }, body: file });
|
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);
|
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 }),
|
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 })
|
answerReport: (id: string, roomId: string, token: string, text: string) => call<{ ok: true }>('POST', `/api/reports/${id}/answer`, { roomId, token, text })
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user