Files
wizwar6e/packages/server/src/store.ts
T
Eric WagonerandClaude Fable 5 3bd7e3c81d Credibility pass: the duels leave the code, staying in the ledgers
Third pass, scoped from 7a32370. Three blind reviewers (engine, web,
server/tools) each concluded the work is coherent engineering with
seam-level tells; every finding was verified before touching a line.

Session biography left the comments: the bot brain's heuristics no
longer cite the opponent who taught them, the RNRX coma parenthetical
and the thief-chase citation are gone, the seat-wallet comments state
their invariants without the war stories, and process-named test
groups now name the behaviors they pin. The incident record lives
where history belongs — commit messages and the ledgers.

Structural dedup: one VISIONSTONE one-edge-sight loop serves both
LOS paths; one creature-arrival touch handler serves walking and
warp-stepping (error text aligned); one facingWedge helper draws both
keymap ribbons; one spriteVisibleInCol rule serves the draw pass and
the hover test (which also stops re-sorting per pointermove); and
deepestFacing joins the director, replacing four copied scans.

Test hardening exposed real rot the tells were hiding: the tight
CreatureState cast caught two literals with a bogus field masking
three missing ones; the number-hoarding rig had NEVER run (its
column didn't exist on seed 42 — it now carves its own geometry);
the bank-guard rig now drives the whole table to an arrival
assertion; the bent-trace test walls off straight sight so the bend
must answer. Silent `return`-on-rig-failure became loud throws, and
can-never-fail assertions were removed.

Sweep-up: the eyeTurn ghost comment, the stacked leave() doc
comments (leave now delegates to leaveLocal), the dead ternary in
the seat client, the kick handler's name-coercion drift, kick ledger
lines gain timestamps, archiveRoomFile reuses fileFor, the RULES_REV
alias retires in favor of the engine constant, hitTest un-exports,
the NUL-sentinel hover shape becomes an honest "none" variant, and
the steering holds get named constants. The bezel's stride cluster
also centers per the table's note.

288 tests, 24 ledgers verified, all workspaces typecheck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:43:24 -04:00

129 lines
3.7 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, renameSync } 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;
/** The automaton's difficulty tier. */
tier?: 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 interface KickLine {
kind: "kick";
/** The seat the host removed from an unstarted room. */
name: string;
at: string;
}
export interface AbandonLine {
kind: "abandon";
by: string;
at: string;
}
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine;
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;
}
/** Retire an abandoned room's ledger to the graveyard — never deleted,
* only moved out of the living rooms directory. */
export function archiveRoomFile(roomId: string): void {
const src = fileFor(roomId);
if (!existsSync(src)) return;
const graveyard = join(DATA_DIR, "..", "rooms-abandoned");
mkdirSync(graveyard, { recursive: true });
renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`));
}