A game may count its own lines from every game begun, not only those finished

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-29 12:20:03 -04:00
co-authored by Claude Opus 5.5
parent 403232087d
commit d97d709212
4 changed files with 43 additions and 3 deletions
+5 -2
View File
@@ -99,7 +99,8 @@ README.
- **The tally** sits in the about panel: what the ledgers add up to - **The tally** sits in the about panel: what the ledgers add up to
(tables, games begun and finished, names seated, turns, time at the (tables, games begun and finished, names seated, turns, time at the
table, games with a bot, talk), counted on demand from the files and table, games with a bot, talk), counted on demand from the files and
cached a minute. A game adds its own lines through `GameSpec.tally`. cached a minute. A game adds its own lines through `GameSpec.tally` and
`GameSpec.tallyBegun`.
- **Table options** are the host's choices when opening a table (a - **Table options** are the host's choices when opening a table (a
variant, a side, a length): the game declares them in `GameSpec.options`, variant, a side, a length): the game declares them in `GameSpec.options`,
the hall renders them as selects, the room line records them, and the hall renders them as selects, the room line records them, and
@@ -157,7 +158,9 @@ if it were never there.
`botInput` receives the same press, so a bot can keep what was agreed. `botInput` receives the same press, so a bot can keep what was agreed.
`parting(press, viewer)` gives the bots' last words once the game is `parting(press, viewer)` gives the bots' last words once the game is
over. Without `botPress`, private talk to a bot is refused. over. Without `botPress`, private talk to a bot is refused.
- `tally(state)`: the game's own lines for the tally, from a finished game. - `tally(state)`: the game's own lines for the tally, from a finished game;
`tallyBegun(options)`: lines from every game begun, finished or not, by
its table's options (games begun on each variant).
- `preferences`, `options`: the preferences panel's settings and the - `preferences`, `options`: the preferences panel's settings and the
host's table choices (above). host's table choices (above).
+33
View File
@@ -0,0 +1,33 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { game, type Input, type State } from '../../src/lib/game';
import type { GameSpec } from '../../src/lib/game/spec';
import { Rooms } from './rooms';
import { Store } from './store';
import { Tallies } from './tally';
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'tally-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe('the tally', () => {
it("adds a game's own lines from every game begun, finished or not", () => {
const counted: GameSpec<State, Input> = { ...game, tallyBegun: () => ({ 'games of High Card begun': 1 }) };
const rooms = new Rooms(counted, new Store(dir));
for (let i = 0; i < 2; i++) {
const host = rooms.create('Ann', 2);
rooms.addBot(host.room, host.token);
rooms.begin(host.room, host.token);
}
rooms.create('Bo', 2);
const t = new Tallies(counted, new Store(dir)).get();
expect(t).toMatchObject({ tablesOpened: 3, gamesBegun: 2, gamesFinished: 0 });
expect(t.byGame).toEqual({ 'games of High Card begun': 2 });
});
});
+3 -1
View File
@@ -1,7 +1,8 @@
// The tally: what the ledgers add up to, for the hall's about panel. Read // 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 // 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 // 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. // 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 { cleanOptions, type GameSpec, type SeatId } from '../../src/lib/game/spec';
import type { Tally } from '../../src/lib/net/view'; import type { Tally } from '../../src/lib/net/view';
@@ -56,6 +57,7 @@ export class Tallies<State, Input> {
t.gamesBegun += 1; t.gamesBegun += 1;
if (hasBot) t.gamesWithBot += 1; if (hasBot) t.gamesWithBot += 1;
if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt; 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 { 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); 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 { } catch {
+2
View File
@@ -139,6 +139,8 @@ export interface GameSpec<State, Input> {
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. */ /** 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>; tally?(state: State): Record<string, number>;
/** The game's own lines counted from every game begun, finished or not, by its table's options (games begun on each variant). */
tallyBegun?(options: TableOptions): Record<string, number>;
} }
/** /**