Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
65 lines
2.9 KiB
TypeScript
65 lines
2.9 KiB
TypeScript
// 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 { SeatId } from '../../src/lib/game/spec';
|
|
|
|
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[]; options?: Record<string, 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: SeatId; name: string; tokenHash?: string; token?: string; bot: boolean }
|
|
/** One seat's move for the round in progress, so a restart keeps it. The turn line that follows carries every input again. */
|
|
| { t: 'input'; at: number; id: SeatId; input: unknown }
|
|
/** A bot sent away by the host before the game began; its seat id is free again. */
|
|
| { t: 'unseat'; id: SeatId }
|
|
/** Ledgers from before revisions were recorded resolve under rules 1. */
|
|
| { t: 'start'; seed: number; rules?: number }
|
|
| { t: 'turn'; at: number; inputs: Record<SeatId, unknown> }
|
|
/** Written after the turn that ends the game, for anything that reads ledgers without the engine. */
|
|
| { t: 'over'; at: number; winner: SeatId | 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: SeatId; text: string; name?: string }
|
|
/** The host let the gallery talk, or hushed it. */
|
|
| { t: 'galleryTalk'; at: number; on: boolean; by: SeatId }
|
|
/** A seat is held for the keeper of the site, called to the table. */
|
|
| { t: 'challenge'; at: number; by: SeatId; keeper: string }
|
|
/** A finished table called for a rematch: where it went, and who called. */
|
|
| { t: 'rematch'; at: number; to: string; by: SeatId };
|
|
|
|
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));
|
|
}
|
|
}
|