Files
wizwar6e/packages/server/src/store.ts
T
Eric WagonerandClaude Fable 5 4114386ed8 The tally becomes a ledger of its own — and hotseat tables count
Instead of re-scanning every room's log on demand, stats.json now
accumulates beside the room files: each room is remembered at its
highest counted stage (created → started → finished), so boots and
replays reconcile without double-counting, and the tally will survive
any future pruning of old rooms. Finished games contribute their
moves, table-time, and manner of victory exactly once, at the moment
of victory.

And with an accumulator to receive them, hotseat games finally count:
each local game mints an anonymous id, pings "started" with its
player count, tracks its own between-moves clock, and on the final
move reports counts only — commands, minutes, players, win reason.
No names, no moves leave the device. The server dedupes by id, clamps
everything to sane ranges, and the booklet's tally now shows how many
of the games were hotseat tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 11:38:05 -04:00

84 lines
2.4 KiB
TypeScript

// Durable rooms: one append-only JSONL file per room. The first line is the
// room's birth certificate; every later line is a join, a start, or a game
// command. Because the engine is deterministic, replaying a file rebuilds
// the exact game state — server restarts lose nothing.
import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
export interface RoomMetaLine {
kind: "meta";
id: string;
hostId: string;
/** sha-256 of the host's seat token. Raw tokens never touch disk. */
hostTokenHash?: string;
/** Legacy plaintext token (pre-hashing files only). */
hostToken?: string;
seed: number;
createdAt: string;
}
export interface JoinLine {
kind: "join";
name: string;
tokenHash?: string;
/** Legacy plaintext token (pre-hashing files only). */
token?: string;
}
export interface StartLine {
kind: "start";
expansion: boolean;
/** Final wizard colors, in player join order. */
colors?: number[];
}
export interface CommandLine {
kind: "command";
seq: number;
playerId: string;
command: unknown;
at: string;
}
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine;
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
function fileFor(roomId: string): string {
return join(DATA_DIR, `${roomId}.jsonl`);
}
/** Directory holding stats.json — the parent of the rooms dir. */
export function statsDir(): string {
return join(DATA_DIR, "..");
}
export function ensureDataDir(): void {
mkdirSync(DATA_DIR, { recursive: true });
}
export function appendLine(roomId: string, line: RoomLine): void {
appendFileSync(fileFor(roomId), JSON.stringify(line) + "\n", "utf8");
}
/** Read every persisted room's lines, keyed by room id. */
export function readAllRooms(): Map<string, RoomLine[]> {
ensureDataDir();
const rooms = new Map<string, RoomLine[]>();
for (const file of readdirSync(DATA_DIR)) {
if (!file.endsWith(".jsonl")) continue;
const id = file.slice(0, -".jsonl".length);
try {
const lines = readFileSync(join(DATA_DIR, file), "utf8")
.split("\n")
.filter((l) => l.trim().length > 0)
.map((l) => JSON.parse(l) as RoomLine);
if (lines.length > 0 && lines[0]!.kind === "meta") rooms.set(id, lines);
} catch (e) {
console.error(`skipping unreadable room file ${file}:`, e);
}
}
return rooms;
}