"Random" read as random ACTIONS, and a revealed random pick is barely a mystery anyway. The fourth workshop button is now "mystery": the server rolls the temperament and keeps it — the join line records it for replay, the drive loop plays it, but the room tells the table only "mystery". Roster and scoresheet show ⚙ mystery; the machine's behavior is the only tell. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
104 lines
3.0 KiB
TypeScript
104 lines
3.0 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, existsSync, 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;
|
|
/** An automaton seat: no token; the server plays it. */
|
|
bot?: true;
|
|
/** The automaton's temperament. */
|
|
style?: string;
|
|
/** A mystery machine: the temperament is not revealed to the table. */
|
|
secret?: true;
|
|
}
|
|
|
|
export interface StartLine {
|
|
kind: "start";
|
|
expansion: boolean;
|
|
/** Final wizard colors, in player join order. */
|
|
colors?: number[];
|
|
/** Deck revision the game was dealt from; absent = original build. */
|
|
deckRev?: number;
|
|
}
|
|
|
|
export interface CommandLine {
|
|
kind: "command";
|
|
seq: number;
|
|
playerId: string;
|
|
command: unknown;
|
|
at: string;
|
|
}
|
|
|
|
export interface ChatLine {
|
|
kind: "chat";
|
|
player: string;
|
|
text: string;
|
|
at: string;
|
|
}
|
|
|
|
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine;
|
|
|
|
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 });
|
|
}
|
|
|
|
/** A room file may exist even when the room failed to restore into memory. */
|
|
export function roomFileExists(roomId: string): boolean {
|
|
return existsSync(fileFor(roomId));
|
|
}
|
|
|
|
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;
|
|
}
|