// One append-only JSONL file per room. A room is its ledger replayed from the // top: the engine is deterministic given the seed and the recorded inputs, so // nothing else needs saving. import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import type { TurnInput, WizardId } from '../../src/lib/game/state'; export type LedgerLine = /** `rematchOf` and `expected` mark a table opened for a rematch: whose it follows, and whose seats are held. */ | { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[] } /** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */ | { t: 'seat'; id: WizardId; name: string; tokenHash?: string; token?: string; bot: boolean } /** * A wizard's seat got a new token, from the keeper or a phrase the host made, for a player whose browser lost the * old one; the old one opens nothing after. A seat a bot was standing in for is the wizard's again. */ | { t: 'reseat'; at: number; id: WizardId; tokenHash: string } /** The host gave a wizard's seat to a bot, which duels in it from here on; a reseat gives it back. */ | { t: 'standIn'; at: number; id: WizardId; by: WizardId } /** One seat's move for the turn in progress, so a restart keeps it. The turn line that follows carries every input again. */ | { t: 'input'; at: number; id: WizardId; input: TurnInput } /** A bot sent away by the host before the duel began; its seat id is free again. */ | { t: 'unseat'; id: WizardId } /** Ledgers from before revisions were recorded resolve under rules 1. */ | { t: 'start'; seed: number; rules?: number } | { t: 'turn'; at: number; inputs: Record } /** Written after the turn that ends the duel, for anything that reads ledgers without the engine. */ | { t: 'over'; at: number; winner: WizardId | null; reason: string } /** A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). */ | { t: 'chat'; at: number; id: WizardId; text: string; name?: string; keeper?: true } /** The host let the gallery talk, or hushed it. */ | { t: 'galleryTalk'; at: number; on: boolean; by: WizardId } /** A seat is held for the keeper of the site, called to the table. */ | { t: 'challenge'; at: number; by: WizardId; keeper: string } /** A finished table called for a rematch: where it went, and who called. */ | { t: 'rematch'; at: number; to: string; by: WizardId }; export class Store { constructor(private dir: string) { mkdirSync(dir, { recursive: true }); } private path(roomId: string): string { return join(this.dir, `${roomId}.jsonl`); } append(roomId: string, line: LedgerLine): void { appendFileSync(this.path(roomId), JSON.stringify(line) + '\n'); } read(roomId: string): LedgerLine[] { const path = this.path(roomId); if (!existsSync(path)) return []; return readFileSync(path, 'utf8') .split('\n') .filter((l) => l.trim()) .map((l) => JSON.parse(l) as LedgerLine); } /** Whether a ledger exists on disk, loaded or not; a code must not be reissued over one. */ exists(roomId: string): boolean { return existsSync(this.path(roomId)); } roomIds(): string[] { return readdirSync(this.dir) .filter((f) => f.endsWith('.jsonl')) .map((f) => f.slice(0, -'.jsonl'.length)); } }