clone

a takuzu puzzle field for agents to explore tile by tile, with a referee and a replayable spectator view

main
mosaic / site / lib / log.ts
log.ts 2.6K
1// Append-only JSONL log per game, plus an in-process broadcast so the SSE2// route can follow a game live. The file on disk is the replay source.3import type { GameEvent } from "./fold.ts";45const here = new URL(".", import.meta.url);67/** The site directory. Source files know it from their own URL; the8 * built server (`_fresh/server.js`, run from the site directory) uses9 * the working directory. */10export function siteDir(): string {11 if (here.pathname.includes("/_fresh/")) {12 const cwd = Deno.cwd();13 return cwd.endsWith("/") ? cwd : cwd + "/";14 }15 return new URL("../", here).pathname;16}1718/** Root of everything persistent: games, cache, days. `MOSAIC_DATA` (a19 * volume on the hosted box) or `site/data/` in a checkout. */20export function dataDir(): string {21 const d = Deno.env.get("MOSAIC_DATA");22 if (d) return d.endsWith("/") ? d : d + "/";23 return siteDir() + "data/";24}2526export function gamesDir(): string {27 return Deno.env.get("MOSAIC_GAMES") ?? dataDir() + "games/";28}2930export function logPath(gameId: string): string {31 // a game id, or a set-aside log of one (`<id>.reset-<n>`): replayable,32 // never in the calendar or the boards33 if (!/^[a-zA-Z0-9_-]{1,80}(\.reset-\d+)?$/.test(gameId)) {34 throw new Error("bad game id");35 }36 return `${gamesDir()}${gameId}.jsonl`;37}3839type Listener = (ev: GameEvent) => void;4041export class Log {42 readonly events: GameEvent[] = [];43 private listeners = new Set<Listener>();4445 constructor(readonly gameId: string, readonly persist = true) {}4647 async append(ev: GameEvent): Promise<void> {48 if (ev.rev !== this.events.length) {49 throw new Error(50 `log rev ${ev.rev} out of order (have ${this.events.length})`,51 );52 }53 if (this.persist) {54 await Deno.mkdir(gamesDir(), { recursive: true });55 await Deno.writeTextFile(56 logPath(this.gameId),57 JSON.stringify(ev) + "\n",58 { append: true },59 );60 }61 this.events.push(ev);62 for (const l of this.listeners) l(ev);63 }6465 subscribe(l: Listener): () => void {66 this.listeners.add(l);67 return () => this.listeners.delete(l);68 }6970 text(): string {71 return this.events.map((e) => JSON.stringify(e)).join("\n") + "\n";72 }73}7475export async function readLog(gameId: string): Promise<string | null> {76 try {77 return await Deno.readTextFile(logPath(gameId));78 } catch {79 return null;80 }81}8283export async function listLogs(): Promise<string[]> {84 const out: string[] = [];85 try {86 for await (const e of Deno.readDir(gamesDir())) {87 if (e.isFile && e.name.endsWith(".jsonl")) out.push(e.name.slice(0, -6));88 }89 } catch {90 // no games yet91 }92 return out.sort();93}