lib/daily.ts: one field per date (seed from the date), rings generated ahead, growth by reveal on a timer, rollover ends and archives the day (log, manifest, summary under data/days/<date>/, SQLite index), honours yesterday's top user on the new field (reserved seat and hue, crown in the viewer) and names the top model; restart resumes today's game from its log with the private output from the generator cache. lib/leaderboard.ts: two boards (by model; by user listing the models they ran), daily and cumulative. Routes: / (today, live or replay, copy prompt for this origin), /day/<date>, /calendar, /leaderboard/<date>. Referee: end(), resume(), honours seating, observe capped at 100 tiles. Data root honours MOSAIC_DATA; MCP served for any host with MOSAIC_PUBLIC=1; the scripted client's fetch has a timeout.
- commit
- 5eca750bbb95d195415d71662300a7fee6642f5c
- parent
- b20060529d6b757168ab53bac30a96de21d81f76
- author
- dami <gazey55@gmail.com>
| M | site/ | 23 | +- |
| M | site/ | 27 | +- |
| A | site/ | 26 | ++ |
| M | site/ | 80 | ++++- |
| A | site/ | 504 | ++++++++++++++++++++++++++++++ |
| M | site/ | 4 | +- |
| M | site/ | 29 | ++ |
| M | site/ | 117 | +++++++- |
| A | site/ | 143 | +++++++++ |
| M | site/ | 11 | +- |
| A | site/ | 55 | +++ |
| M | site/ | 195 | ++++++++++-- |
| M | site/ | 7 | + |
| A | site/ | 171 | ++++++++++ |
| A | site/ | 50 | +++ |
| M | site/ | 33 | ++- |
| A | site/ | 43 | +++ |
| M | site/ | 4 | +- |
| M | site/ | 1 | + |
@@ -37,7 +37,7 @@
3737 "npm:@opentelemetry/api@^1.9.0": "1.9.1",3838 "npm:@preact/signals@^2.5.0": "2.11.1_preact@10.29.8__preact-render-to-string@6.7.0_preact-render-to-string@6.7.0__preact@10.29.8",3939 "npm:@preact/signals@^2.5.1": "2.11.1_preact@10.29.8__preact-render-to-string@6.7.0_preact-render-to-string@6.7.0__preact@10.29.8",40- "npm:@prefresh/vite@^2.4.8": "2.4.12_preact@10.29.8__preact-render-to-string@6.7.0_vite@7.3.6_preact-render-to-string@6.7.0__preact@10.29.8",40+ "npm:@prefresh/vite@^2.4.8": "2.4.12_preact@10.29.8__preact-render-to-string@6.7.0_vite@7.3.6__@types+node@22.20.1_@types+node@22.20.1",4141 "npm:esbuild-wasm@~0.25.11": "0.25.12",4242 "npm:esbuild@0.25.7": "0.25.7",4343 "npm:esbuild@~0.25.5": "0.25.12",@@ -46,8 +46,8 @@
4646 "npm:preact@^10.28.2": "10.29.8_preact-render-to-string@6.7.0",4747 "npm:preact@^10.28.3": "10.29.8_preact-render-to-string@6.7.0",4848 "npm:rollup@^4.50.0": "4.62.5",49- "npm:vite@^7.1.3": "7.3.6",50- "npm:vite@^7.1.4": "7.3.6",49+ "npm:vite@^7.1.3": "7.3.6_@types+node@22.20.1",50+ "npm:vite@^7.1.4": "7.3.6_@types+node@22.20.1",5151 "npm:zod@^4.2.0": "4.4.3"5252 },5353 "jsr": {@@ -818,7 +818,7 @@
818818 "@prefresh/utils@1.2.1": {819819 "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw=="820820 },821- "@prefresh/vite@2.4.12_preact@10.29.8__preact-render-to-string@6.7.0_vite@7.3.6_preact-render-to-string@6.7.0__preact@10.29.8": {821+ "@prefresh/vite@2.4.12_preact@10.29.8__preact-render-to-string@6.7.0_vite@7.3.6__@types+node@22.20.1_@types+node@22.20.1": {822822 "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==",823823 "dependencies": [824824 "@babel/core",@@ -965,6 +965,12 @@
965965 "@types/estree@1.0.9": {966966 "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="967967 },968+ "@types/node@22.20.1": {969+ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",970+ "dependencies": [971+ "undici-types"972+ ]973+ },968974 "baseline-browser-mapping@2.11.19": {969975 "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==",970976 "bin": true@@ -1230,6 +1236,9 @@
12301236 "picomatch@4.0.7"12311237 ]12321238 },1239+ "undici-types@6.21.0": {1240+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="1241+ },12331242 "update-browserslist-db@1.3.1_browserslist@4.28.8": {12341243 "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",12351244 "dependencies": [@@ -1239,9 +1248,10 @@
12391248 ],12401249 "bin": true12411250 },1242- "vite@7.3.6": {1251+ "vite@7.3.6_@types+node@22.20.1": {12431252 "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",12441253 "dependencies": [1254+ "@types/node",12451255 "esbuild@0.28.2",12461256 "fdir",12471257 "picomatch@4.0.7",@@ -1252,6 +1262,9 @@
12521262 "optionalDependencies": [12531263 "fsevents"12541264 ],1265+ "optionalPeers": [1266+ "@types/node"1267+ ],12551268 "bin": true12561269 },12571270 "yallist@3.1.1": {@@ -2,31 +2,10 @@
22 // server on 127.0.0.1:8000.33 const URL_ = "http://127.0.0.1:8000";44 5+import { promptFor } from "./lib/prompt.ts";6+57 export function prompt(): string {6- return [7- "Install the mosaic MCP server (streamable HTTP, no auth) and play:",8- "",9- ` claude mcp add --transport http mosaic ${URL_}/mcp`,10- "",11- "Then paste this into the agent:",12- "",13- ` Using the mosaic MCP server at ${URL_}/mcp: call list_games; if a field is`,14- " playing, join it (join with game_id, name, and the model you are, e.g.",15- " model=claude-opus-5); otherwise start one (start_mosaic with kind=scatter and",16- " G=100, or kind=field with chunks=3 for a chunked field) and then join it.",17- " join gives you an agent id and a seed tile at a random spot; every reveal,",18- " commit and retract must carry that agent id. Play autonomously with observe",19- " (agent=<your id>: your territory and its surroundings), reveal and commit",20- " until the status is solved. Read the tool descriptions for the rules (they",21- " are per line of a tile). Reveal your seed tile first; commit in batches",22- " with a one-line intent; when a tile alone is ambiguous, expand from a tile",23- " your group solved or commit a guess; when you meet another agent your",24- " territories merge and you share a frontier. Do not stop until every tile",25- " is solved. Do not ask me anything.",26- "",27- `Spectate at ${URL_}/ (live). Replay a finished game at ${URL_}/?replay=<game_id>.`,28- "",29- ].join("\n");8+ return promptFor(URL_);309 }3110 3211 if (import.meta.main) {@@ -0,0 +1,26 @@
1+// "copy prompt": the bootstrap prompt for this host goes to the clipboard.2+import { useState } from "preact/hooks";3+4+export default function CopyPrompt(props: { text: string }) {5+ const [done, setDone] = useState(false);6+ const copy = async () => {7+ try {8+ await navigator.clipboard.writeText(props.text);9+ setDone(true);10+ setTimeout(() => setDone(false), 1500);11+ } catch {12+ // clipboard blocked: show the text instead13+ alert(props.text);14+ }15+ };16+ return (17+ <button18+ type="button"19+ onClick={copy}20+ title="Copy the prompt that bootstraps an agent onto this field"21+ style="font:inherit;font-size:12px;background:var(--chrome);color:var(--text);border:1px solid var(--rule);border-radius:4px;padding:3px 10px;cursor:pointer"22+ >23+ {done ? "copied" : "copy prompt"}24+ </button>25+ );26+}@@ -439,6 +439,42 @@ function tileLines(t: TileSpec, mask: Uint8Array): number[][] {
439439 return out;440440 }441441 442+/** A crown on each seat reserved for yesterday's winner (L1 and up). */443+function drawHonours(444+ ctx: CanvasRenderingContext2D,445+ s: State,446+ view: View,447+ theme: Theme,448+) {449+ if (!s.honours.length || !s.manifest) return;450+ const { scale, ox, oy } = view;451+ for (const h of s.honours) {452+ const t = s.manifest.tiles[h.seed_tile];453+ if (!t) continue;454+ const cx = ox + (t.x0 + t.w / 2) * scale;455+ const cy = oy + (t.y0 + t.h / 2) * scale;456+ const r = Math.max(6, Math.min(18, scale * 1.2));457+ ctx.save();458+ ctx.globalAlpha = 0.95;459+ ctx.fillStyle = "#d4a83a";460+ ctx.strokeStyle = theme.name === "dark" ? "#141416" : "#f2f2f4";461+ ctx.lineWidth = 1;462+ ctx.beginPath();463+ // a five-point crown464+ ctx.moveTo(cx - r, cy + r * 0.6);465+ ctx.lineTo(cx - r, cy - r * 0.3);466+ ctx.lineTo(cx - r * 0.5, cy + r * 0.1);467+ ctx.lineTo(cx, cy - r * 0.7);468+ ctx.lineTo(cx + r * 0.5, cy + r * 0.1);469+ ctx.lineTo(cx + r, cy - r * 0.3);470+ ctx.lineTo(cx + r, cy + r * 0.6);471+ ctx.closePath();472+ ctx.fill();473+ ctx.stroke();474+ ctx.restore();475+ }476+}477+442478 /** L3 overlay: the lines, pins, id and agent of the tile under the centre. */443479 function drawFocus(444480 ctx: CanvasRenderingContext2D,@@ -592,6 +628,7 @@ function compose(
592628 );593629 }594630 const level = levelFor(view.scale);631+ if (level >= 1) drawHonours(ctx, f.s, view, theme);595632 if (level === 3) drawFocus(ctx, f, scene, view, W, H, theme);596633 const dt = performance.now() - t0;597634 perf.frames.push(dt);@@ -740,7 +777,12 @@ function Timeline(props: {
740777 // ---------------------------------------------------------------- island741778 742779 export default function Viewer(743- props: { replay: string | null; theme: "dark" | "light" | null },780+ props: {781+ replay: string | null;782+ theme: "dark" | "light" | null;783+ /** follow this game live (the daily); null follows the newest game */784+ live?: string | null;785+ },744786 ) {745787 const canvasRef = useRef<HTMLCanvasElement>(null);746788 const paneRef = useRef<HTMLDivElement>(null);@@ -793,7 +835,9 @@ export default function Viewer(
793835 });794836 return;795837 }796- const es = new EventSource("/events/live");838+ const es = new EventSource(839+ props.live ? `/events/${props.live}` : "/events/live",840+ );797841 es.addEventListener("game", (m) => {798842 const { game_id } = JSON.parse((m as MessageEvent).data);799843 setGameId(game_id);@@ -809,7 +853,7 @@ export default function Viewer(
809853 setEvents(eventsRef.current);810854 });811855 return () => es.close();812- }, [props.replay]);856+ }, [props.replay, props.live]);813857 814858 // checkpointed fold: a state every K revisions, folded forward on demand815859 const cps = useRef(new Checkpoints());@@ -833,10 +877,17 @@ export default function Viewer(
833877 // hue reassignment would repaint a whole territory834878 const huesRef = useRef(new Map<string, number>());835879 const hues = useMemo(() => {880+ // yesterday's winner keeps the hue reserved for them881+ for (const h of final.honours) {882+ const a = final.agents.find((x) =>883+ x.id === h.agent || x.id === h.user || x.name === h.name884+ );885+ if (a && !huesRef.current.has(a.id)) huesRef.current.set(a.id, h.hue);886+ }836887 const h = agentHues(final.agents, final.geo, 3, huesRef.current);837888 huesRef.current = h;838889 return h;839- }, [final.agents, final.geo]);890+ }, [final.agents, final.geo, final.honours]);840891 // fog is fog while a game runs or is scrubbed; only the last revision841892 // of a finished game shows what was never explored842893 const frame = useMemo(@@ -1086,13 +1137,17 @@ export default function Viewer(
10861137 let reachedCount = 0;10871138 for (const r of frame.reached) reachedCount += r;10881139 const grown = (state as unknown as { grown?: number }).grown ?? 0;1140+ const honoured = (a: AgentInfo) =>1141+ final.honours.some((h) =>1142+ h.agent === a.id || h.user === a.id || h.name === a.name1143+ );10891144 const agentRows = final.agents.map((a: AgentInfo) => {10901145 const s = an.agents.find((x) => x.agent === a.id);1091- return { a, s, hue: hues.get(a.id) ?? 0 };1092- });1146+ return { a, s, hue: hues.get(a.id) ?? 0, crowned: honoured(a) };1147+ }).sort((x, y) => Number(y.crowned) - Number(x.crowned));10931148 10941149 return (1095- <div style="display:flex;flex-direction:column;height:100vh">1150+ <div style="display:flex;flex-direction:column;height:100%">10961151 <div style="display:flex;flex:1;min-height:0">10971152 <canvas10981153 ref={canvasRef}@@ -1122,6 +1177,14 @@ export default function Viewer(
11221177 reached {reachedCount} of {state.tiles.length} tiles, grown{" "}11231178 {grown} {grown === 1 ? "time" : "times"}11241179 </div>1180+ {state.day && <div>day {state.day}</div>}1181+ {state.honours.map((h) => (1182+ <div key={h.user} style="color:#d4a83a">1183+ ♕ {h.name} ({h.model ?? "model unreported"}) won {h.day}1184+ {" "}1185+ with {h.reward} cells; their seat is tile #{h.seed_tile}1186+ </div>1187+ ))}11251188 <div>11261189 forced {state.totals.forced}, guess {state.totals.guess}, wrong11271190 {" "}@@ -1150,7 +1213,7 @@ export default function Viewer(
11501213 {agentRows.length === 011511214 ? <div style="color:var(--muted)">no agents yet</div>11521215 : null}1153- {agentRows.map(({ a, s, hue }) => (1216+ {agentRows.map(({ a, s, hue, crowned }) => (11541217 <div11551218 key={a.id}11561219 style="display:flex;align-items:baseline;gap:8px;padding:2px 0"@@ -1161,6 +1224,7 @@ export default function Viewer(
11611224 };flex:none;align-self:center`}11621225 />11631226 <span style="color:var(--text-strong);min-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">1227+ {crowned && <span style="color:#d4a83a">♕</span>}11641228 {a.name || a.id}11651229 </span>11661230 <span style="color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">@@ -0,0 +1,504 @@
1+// The daily loop: one field per date. The day's seed is derived from the2+// date; its rings are generated ahead (the night before, cached), so3+// growth by day is reveal; at rollover the game is ended, archived under4+// data/days/<date>/ and indexed, and the next day's game starts with5+// yesterday's top user honoured (crown, reserved hue and seat) and the6+// top model named. A restart resumes today's game from its log. Clock and7+// generator are injectable so two rollovers can be simulated in a test.8+import {9+ dropGame,10+ endGame,11+ type Generator,12+ getGame,13+ resumeGame,14+ startGame,15+ type StartParams,16+ whenReady,17+} from "./games.ts";18+import { gen, type GenParams, judge } from "./engine.ts";19+import type { Judge } from "./referee.ts";20+import { dataDir, readLog } from "./log.ts";21+import { fold, type Honour, type ManifestEvent, parseLog } from "./fold.ts";22+import {23+ type Boards,24+ boardsOf,25+ mergeBoards,26+ winnersOf,27+} from "./leaderboard.ts";28+29+export type DailyConfig = {30+ namespace: string; // seed namespace: change it and every day's field changes31+ chunks: number;32+ chunk_g: number;33+ G: number;34+ rings: number | "all";35+ grow_minutes: number;36+ rollover_hour_utc: number; // the day changes at this UTC hour37+ fog: boolean;38+};39+40+export function configFromEnv(): DailyConfig {41+ const num = (k: string, d: number) => {42+ const v = Number(Deno.env.get(k));43+ return Number.isFinite(v) && v > 0 ? v : d;44+ };45+ const ringsEnv = Deno.env.get("MOSAIC_DAILY_RINGS");46+ return {47+ namespace: Deno.env.get("MOSAIC_DAILY_NAMESPACE") ?? "mosaic-daily",48+ chunks: num("MOSAIC_DAILY_CHUNKS", 3),49+ chunk_g: num("MOSAIC_DAILY_CHUNK_G", 60),50+ G: num("MOSAIC_DAILY_G", 1000),51+ rings: ringsEnv && ringsEnv !== "all"52+ ? num("MOSAIC_DAILY_RINGS", 2)53+ : "all",54+ grow_minutes: num("MOSAIC_DAILY_GROW_MINUTES", 60),55+ rollover_hour_utc: Number(Deno.env.get("MOSAIC_DAILY_ROLLOVER_UTC") ?? 0),56+ fog: (Deno.env.get("MOSAIC_DAILY_FOG") ?? "1") !== "0",57+ };58+}59+60+/** The ISO date of the day a moment belongs to (days roll at rollover_hour_utc). */61+export function dateFor(nowMs: number, cfg: DailyConfig): string {62+ const shifted = new Date(nowMs - cfg.rollover_hour_utc * 3600 * 1000);63+ return shifted.toISOString().slice(0, 10);64+}65+66+export function nextDate(date: string): string {67+ const d = new Date(date + "T00:00:00Z");68+ d.setUTCDate(d.getUTCDate() + 1);69+ return d.toISOString().slice(0, 10);70+}71+72+/** The day's seed: a hash of the namespace and the date (public, so a73+ * day's field is reproducible by anyone with the generator version). */74+export async function seedFor(date: string, cfg: DailyConfig): Promise<string> {75+ const buf = await crypto.subtle.digest(76+ "SHA-256",77+ new TextEncoder().encode(`${cfg.namespace}|${date}`),78+ );79+ return [...new Uint8Array(buf)].slice(0, 12).map((b) =>80+ b.toString(16).padStart(2, "0")81+ ).join("");82+}83+84+export function gameIdFor(date: string, cfg: DailyConfig): string {85+ return `day-${date}` +86+ (cfg.namespace === "mosaic-daily" ? "" : `-${cfg.namespace}`);87+}88+89+export function paramsFor(90+ date: string,91+ seed: string,92+ cfg: DailyConfig,93+): StartParams & { seed: string } {94+ return {95+ G: cfg.G,96+ kind: "field",97+ chunks: cfg.chunks,98+ chunk_g: cfg.chunk_g,99+ field_g: cfg.G,100+ seed,101+ rings: cfg.rings,102+ grow: { every_minutes: cfg.grow_minutes },103+ rules: { fog: cfg.fog },104+ pinned: true,105+ day: date,106+ game_id: gameIdFor(date, cfg),107+ };108+}109+110+// node:sqlite is loaded through a computed specifier: the static import111+// would pull the Node type definitions, which retype every timer in the112+// process as Node's Timeout. This is all the index uses of it.113+type StatementSync = {114+ run(...params: (string | number | null)[]): unknown;115+ get(...params: (string | number | null)[]): unknown;116+ all(...params: (string | number | null)[]): unknown[];117+};118+type DatabaseSync = {119+ exec(sql: string): void;120+ prepare(sql: string): StatementSync;121+ close(): void;122+};123+const sqliteSpecifier = "node:sqlite";124+const sqlite = await import(sqliteSpecifier) as unknown as {125+ DatabaseSync: new (path: string) => DatabaseSync;126+};127+128+export type DayRow = {129+ date: string;130+ game_id: string;131+ seed_commit: string;132+ stats: {133+ tiles: number;134+ solved: number;135+ cells: number;136+ agents: number;137+ revisions: number;138+ guess_rate: number;139+ grown: number;140+ ended: string | null; // ISO time of the end event141+ completed: boolean; // every tile solved before rollover142+ };143+ boards: Boards;144+ winner_user: string | null;145+ winner_model: string | null;146+};147+148+/** The days index: one row per archived day, SQLite on the data volume. */149+export class DaysIndex {150+ private db: DatabaseSync;151+ constructor(path: string) {152+ this.db = new sqlite.DatabaseSync(path);153+ this.db.exec(`CREATE TABLE IF NOT EXISTS days (154+ date TEXT PRIMARY KEY,155+ game_id TEXT NOT NULL,156+ seed_commit TEXT NOT NULL,157+ stats TEXT NOT NULL,158+ boards TEXT NOT NULL,159+ winner_user TEXT,160+ winner_model TEXT161+ )`);162+ }163+ upsert(row: DayRow): void {164+ this.db.prepare(165+ `INSERT INTO days (date, game_id, seed_commit, stats, boards, winner_user, winner_model)166+ VALUES (?, ?, ?, ?, ?, ?, ?)167+ ON CONFLICT(date) DO UPDATE SET game_id=excluded.game_id, seed_commit=excluded.seed_commit,168+ stats=excluded.stats, boards=excluded.boards, winner_user=excluded.winner_user, winner_model=excluded.winner_model`,169+ ).run(170+ row.date,171+ row.game_id,172+ row.seed_commit,173+ JSON.stringify(row.stats),174+ JSON.stringify(row.boards),175+ row.winner_user,176+ row.winner_model,177+ );178+ }179+ get(date: string): DayRow | null {180+ const r = this.db.prepare(`SELECT * FROM days WHERE date = ?`).get(date) as181+ | Record<string, string>182+ | undefined;183+ return r ? parseRow(r) : null;184+ }185+ /** Every day, oldest first; `month` = "YYYY-MM" filters. */186+ list(month?: string): DayRow[] {187+ const rows = month188+ ? this.db.prepare(`SELECT * FROM days WHERE date LIKE ? ORDER BY date`)189+ .all(190+ month + "-%",191+ )192+ : this.db.prepare(`SELECT * FROM days ORDER BY date`).all();193+ return (rows as Record<string, string>[]).map(parseRow);194+ }195+ close(): void {196+ this.db.close();197+ }198+}199+200+function parseRow(r: Record<string, string>): DayRow {201+ return {202+ date: r.date,203+ game_id: r.game_id,204+ seed_commit: r.seed_commit,205+ stats: JSON.parse(r.stats),206+ boards: JSON.parse(r.boards),207+ winner_user: r.winner_user ?? null,208+ winner_model: r.winner_model ?? null,209+ };210+}211+212+export type DailyState = { date: string; game_id: string; seed: string };213+214+type Opts = {215+ cfg?: DailyConfig;216+ dir?: string; // data root217+ now?: () => number;218+ generate?: Generator;219+ judge?: Judge;220+ log?: (msg: string) => void;221+};222+223+/** The daily scheduler. One instance per process. */224+export class Daily {225+ readonly cfg: DailyConfig;226+ readonly dir: string;227+ readonly now: () => number;228+ private generate: Generator;229+ private judgeFn: Judge;230+ private log: (msg: string) => void;231+ current: DailyState | null = null;232+ private preparing: string | null = null;233+ private prepared = new Set<string>();234+ private timer: number | null = null;235+ private ticking: Promise<void> | null = null;236+ index: DaysIndex;237+238+ constructor(opts: Opts = {}) {239+ this.cfg = opts.cfg ?? configFromEnv();240+ this.dir = opts.dir ?? dataDir();241+ this.now = opts.now ?? Date.now;242+ this.generate = opts.generate ?? gen;243+ this.judgeFn = opts.judge ?? judge;244+ this.log = opts.log ?? ((m) => console.log(JSON.stringify({ daily: m })));245+ Deno.mkdirSync(this.dir + "days/", { recursive: true });246+ this.index = new DaysIndex(this.dir + "days.sqlite");247+ }248+249+ private statePath(): string {250+ return this.dir + "daily.json";251+ }252+253+ private async saveState(): Promise<void> {254+ await Deno.writeTextFile(this.statePath(), JSON.stringify(this.current));255+ }256+257+ /** Today's date by the configured rollover. */258+ today(): string {259+ return dateFor(this.now(), this.cfg);260+ }261+262+ /**263+ * On boot: resume today's game from its log if this process ran it264+ * before (the private output comes back from the generator cache), or265+ * roll over and start. Then tick every minute.266+ */267+ async boot(): Promise<void> {268+ try {269+ this.current = JSON.parse(await Deno.readTextFile(this.statePath()));270+ } catch {271+ this.current = null;272+ }273+ if (this.current && this.current.date === this.today()) {274+ const p = paramsFor(this.current.date, this.current.seed, this.cfg);275+ const ref = await resumeGame(276+ this.current.game_id,277+ p,278+ this.generate,279+ this.now,280+ this.judgeFn,281+ );282+ if (ref) {283+ this.log(284+ `resumed ${this.current.game_id} at revision ${ref.revision} (${ref.state.agents.length} agents)`,285+ );286+ } else {287+ this.log(`no live log for ${this.current.game_id}; starting fresh`);288+ this.current = null;289+ }290+ }291+ await this.tick();292+ }293+294+ start(): void {295+ if (this.timer !== null) return;296+ this.timer = setInterval(() => {297+ this.tick().catch((e) => this.log(`tick failed: ${e}`));298+ }, 60 * 1000);299+ Deno.unrefTimer(this.timer);300+ }301+302+ stop(): void {303+ if (this.timer !== null) clearInterval(this.timer);304+ this.timer = null;305+ }306+307+ /** One scheduler step (serialised): rollover if the date changed, start308+ * today's game if none, prepare tomorrow's field. */309+ tick(): Promise<void> {310+ if (this.ticking) return this.ticking;311+ this.ticking = this.tickOnce().finally(() => {312+ this.ticking = null;313+ });314+ return this.ticking;315+ }316+317+ private async tickOnce(): Promise<void> {318+ const today = this.today();319+ if (this.current && this.current.date !== today) {320+ await this.rollover(this.current);321+ this.current = null;322+ }323+ if (!this.current || !getGame(this.current.game_id)) {324+ await this.startDay(today);325+ }326+ this.prepare(nextDate(today));327+ }328+329+ /** Generate a day's field ahead of time (cached; idempotent). */330+ prepare(date: string): void {331+ if (this.prepared.has(date) || this.preparing === date) return;332+ this.preparing = date;333+ (async () => {334+ const seed = await seedFor(date, this.cfg);335+ const p = paramsFor(date, seed, this.cfg);336+ const params: GenParams = {337+ seed,338+ G: p.G,339+ kind: "field",340+ chunks: p.chunks,341+ chunk_g: p.chunk_g,342+ field_g: p.field_g,343+ rings: p.rings,344+ };345+ const t0 = this.now();346+ await this.generate(params);347+ this.prepared.add(date);348+ this.log(`prepared ${date} in ${Math.round((this.now() - t0) / 1000)} s`);349+ })().catch((e) => this.log(`prepare ${date} failed: ${e}`)).finally(() => {350+ if (this.preparing === date) this.preparing = null;351+ });352+ }353+354+ /** Honours for a day: the previous day's top user, seated on the new355+ * field's first seed tile with a reserved hue. */356+ private honoursFor(date: string, seedTile: number): Honour[] {357+ const prev = this.index.list().filter((r) => r.date < date).at(-1);358+ if (!prev) return [];359+ const { user } = winnersOf(prev.boards);360+ if (!user) return [];361+ // the agent id they had that day, for hue continuity362+ const b = prev.boards;363+ const agentId = user.key;364+ return [{365+ agent: agentId,366+ name: user.key,367+ model: user.models[0] ?? null,368+ user: user.key,369+ hue: 0,370+ seed_tile: seedTile,371+ day: prev.date,372+ reward: user.reward,373+ ...(b.day ? {} : {}),374+ }];375+ }376+377+ private async startDay(date: string): Promise<void> {378+ const seed = await seedFor(date, this.cfg);379+ const p = paramsFor(date, seed, this.cfg);380+ // generate (or take from the cache) first: honours need a seat on381+ // the actual field382+ const params: GenParams = {383+ seed,384+ G: p.G,385+ kind: "field",386+ chunks: p.chunks,387+ chunk_g: p.chunk_g,388+ field_g: p.field_g,389+ rings: p.rings,390+ };391+ const out = await this.generate(params);392+ this.prepared.add(date);393+ const m = out.manifest as { seed_tile: number; seed_tiles?: number[] };394+ const seat = m.seed_tiles?.[0] ?? m.seed_tile;395+ const honours = this.honoursFor(date, seat);396+ const pending = startGame(397+ { ...p, honours },398+ this.generate,399+ this.now,400+ this.judgeFn,401+ );402+ await whenReady(pending.game_id);403+ this.current = { date, game_id: pending.game_id, seed };404+ await this.saveState();405+ this.log(406+ `started ${pending.game_id} for ${date}` +407+ (honours.length ? ` honouring ${honours[0].name}` : ""),408+ );409+ }410+411+ /** End, archive and index a day's game. */412+ async rollover(cur: DailyState): Promise<DayRow | null> {413+ const ref = getGame(cur.game_id);414+ if (ref) await endGame(cur.game_id);415+ const text = ref ? ref.log.text() : await readLog(cur.game_id);416+ if (text === null) {417+ this.log(`rollover: no log for ${cur.game_id}`);418+ dropGame(cur.game_id);419+ return null;420+ }421+ const events = parseLog(text);422+ const s = fold(events);423+ const boards = boardsOf(s);424+ const { user, model } = winnersOf(boards);425+ const manifest = (events[0] as ManifestEvent).manifest as {426+ seed_commit?: string;427+ };428+ const ended = events.at(-1)?.kind === "end" ? events.at(-1)!.t : null;429+ const row: DayRow = {430+ date: cur.date,431+ game_id: cur.game_id,432+ seed_commit: manifest.seed_commit ?? "",433+ stats: {434+ tiles: s.tiles.length,435+ solved: s.solvedTiles,436+ cells: s.solvedCells,437+ agents: s.agents.length,438+ revisions: s.revision,439+ guess_rate: s.analysis.guess_rate,440+ grown: s.grown,441+ ended,442+ completed: s.tiles.every((t) => t === "solved"),443+ },444+ boards,445+ winner_user: user?.key ?? null,446+ winner_model: model?.key ?? null,447+ };448+ const dir = `${this.dir}days/${cur.date}/`;449+ await Deno.mkdir(dir, { recursive: true });450+ await Deno.writeTextFile(dir + "log.jsonl", text);451+ await Deno.writeTextFile(452+ dir + "manifest.json",453+ JSON.stringify((events[0] as ManifestEvent).manifest),454+ );455+ await Deno.writeTextFile(456+ dir + "summary.json",457+ JSON.stringify(458+ {459+ ...row,460+ agents: s.agents,461+ meets: s.meets,462+ analysis: s.analysis,463+ cumulative: mergeBoards([464+ ...this.index.list().map((r) => r.boards),465+ boards,466+ ]),467+ },468+ null,469+ 1,470+ ),471+ );472+ this.index.upsert(row);473+ dropGame(cur.game_id);474+ this.log(475+ `archived ${cur.date}: ${row.stats.solved}/${row.stats.tiles} tiles, winner ${476+ row.winner_user ?? "-"477+ } / ${row.winner_model ?? "-"}`,478+ );479+ return row;480+ }481+482+ /** Cumulative boards across every archived day. */483+ cumulative(): ReturnType<typeof mergeBoards> {484+ return mergeBoards(this.index.list().map((r) => r.boards));485+ }486+}487+488+// One daily per process, on globalThis so a dev-server module reload keeps it.489+const g = globalThis as unknown as { __mosaicDaily?: Daily };490+491+export function daily(): Daily | null {492+ return g.__mosaicDaily ?? null;493+}494+495+/** Start the daily loop when MOSAIC_DAILY=1 (idempotent). */496+export async function startDaily(): Promise<Daily | null> {497+ if (Deno.env.get("MOSAIC_DAILY") !== "1") return null;498+ if (g.__mosaicDaily) return g.__mosaicDaily;499+ const d = new Daily();500+ g.__mosaicDaily = d;501+ await d.boot();502+ d.start();503+ return d;504+}@@ -1,5 +1,6 @@
11 // Deno.Command wrapper around mosaic-engine. JSON in, JSON out, no state.22 import type { AnyManifest, LocalRect } from "./fold.ts";3+import { dataDir } from "./log.ts";34 45 /** Private per-tile generator output; never written to the log. */56 export type GenTile = {@@ -53,8 +54,7 @@ export function enginePath(): string {
5354 }5455 5556 export function cacheDir(): string {56- return Deno.env.get("MOSAIC_CACHE") ??57- new URL("../data/cache/", here).pathname;57+ return Deno.env.get("MOSAIC_CACHE") ?? dataDir() + "cache/";5858 }5959 6060 async function sha256(s: string): Promise<string> {@@ -189,6 +189,23 @@ export type ManifestEvent = {
189189 manifest: AnyManifest;190190 rules: GameRules;191191 seed_tile?: [number, number]; // phase-1 logs: the seed tile as (tx, ty)192+ /** the daily: yesterday's winners, each with a reserved seed tile and193+ * hue; a returning winner (same machine id or name) is seated there */194+ honours?: Honour[];195+ /** the daily: the ISO date this field is the board of */196+ day?: string;197+};198+199+/** A winner of a previous day, honoured on this field. */200+export type Honour = {201+ agent: string; // the agent id they had (machine id or provisional)202+ name: string;203+ model: string | null;204+ user: string; // machine id or name: what the leaderboard keyed on205+ hue: number; // reserved hue index206+ seed_tile: number; // reserved seed tile on this field207+ day: string; // the day they won208+ reward: number;192209 };193210 194211 /** An agent joining the field. */@@ -415,6 +432,8 @@ export type State = {
415432 grown: number;416433 seedRings: number[][]; // seed tiles per ring417434 exhausted: boolean; // the last grow found no room435+ honours: Honour[]; // yesterday's winners with reserved seats (the daily)436+ day: string | null; // the daily: the date this field belongs to418437 analysis: Analysis;419438 // analyzer scratch, derived and deterministic (part of the state so that420439 // live == replay holds for it too)@@ -928,6 +947,8 @@ export function emptyState(): State {
928947 grown: 0,929948 seedRings: [],930949 exhausted: false,950+ honours: [],951+ day: null,931952 analysis: emptyAnalysis(),932953 agentStats: {},933954 batchSizes: [],@@ -1011,6 +1032,8 @@ export function isOnFrontier(s: State, tile: number, agent?: string): boolean {
10111032 export function joinCandidates(s: State): number[] {10121033 if (!s.manifest || !s.geo) return [];10131034 const claimed = new Set(s.agents.map((a) => a.seed_tile));1035+ // seats reserved for yesterday's winners are not handed to anyone else1036+ for (const h of s.honours) claimed.add(h.seed_tile);10141037 // newest ring first: a late joiner starts where the field just grew10151038 const rings = s.seedRings.length10161039 ? [...s.seedRings].reverse()@@ -1290,6 +1313,7 @@ function computeReached(s: State): Uint8Array {
12901313 for (const u of geo.adjacency[t]) out[u] = 1;12911314 }12921315 for (const a of s.agents) if (a.seed_tile < n) out[a.seed_tile] = 1;1316+ for (const h of s.honours) if (h.seed_tile < n) out[h.seed_tile] = 1;12931317 return out;12941318 }12951319 @@ -1369,6 +1393,11 @@ export function applyEvent(prev: State, ev: GameEvent): State {
13691393 ];13701394 s.grown = 0;13711395 s.exhausted = false;1396+ s.honours = (ev.honours ?? []).filter((h) =>1397+ Number.isInteger(h.seed_tile) && h.seed_tile >= 0 &&1398+ h.seed_tile < m.tiles.length1399+ );1400+ s.day = ev.day ?? null;13721401 for (const [id, v] of zoneCells(m)) {13731402 markKnown(s, w, id, { v, kind: "zone", rev: 0 });13741403 }@@ -4,7 +4,12 @@
44 // State is rebuilt from the JSONL on restart only for spectating (the55 // private solution is not in the log, so a restarted server cannot66 // continue a game; the demo is one process). Games nobody joined expire.7-import { type GameRules, type GrowRules } from "./fold.ts";7+import {8+ type GameRules,9+ type GrowRules,10+ type Honour,11+ parseLog,12+} from "./fold.ts";813 import {914 type FieldOutput,1015 gen,@@ -15,7 +20,7 @@ import {
1520 mergeGrowth,1621 } from "./engine.ts";1722 import { type Judge, Referee } from "./referee.ts";18-import { logPath } from "./log.ts";23+import { logPath, readLog } from "./log.ts";1924 2025 export type Pending = {2126 status: "generating" | "failed";@@ -29,6 +34,8 @@ type Ready = {
2934 kind: "ready";3035 ref: Referee;3136 started: number;37+ /** the daily's game: never swept, ended only by rollover */38+ pinned: boolean;3239 /** the full private output so far (grows with the field) */3340 priv: GenOutput;3441 seed: string;@@ -103,6 +110,14 @@ export type StartParams = {
103110 /** kind=field: pre-generate this many rings at start ("all" = until104111 * full); growth then reveals a stored ring, the engine is not run by day */105112 rings?: number | "all";113+ /** the daily: yesterday's winners with reserved seats */114+ honours?: Honour[];115+ /** the daily: the date this field is the board of */116+ day?: string;117+ /** the daily: never swept */118+ pinned?: boolean;119+ /** use this id instead of a random one (the daily names its game) */120+ game_id?: string;106121 };107122 108123 export type GameSummary = {@@ -160,7 +175,7 @@ export function startGame(
160175 now: () => number = Date.now,161176 judgeFn: Judge = judge,162177 ): Pending {163- const gameId = crypto.randomUUID();178+ const gameId = p.game_id ?? crypto.randomUUID();164179 const seed = p.seed ?? gameId;165180 const growing = p.kind === "field" && !!p.grow &&166181 (p.grow.every_minutes !== undefined ||@@ -195,12 +210,17 @@ export function startGame(
195210 try {196211 const out = await generate(params);197212 const { rings = [], ...base } = out;198- const ref = await Referee.create(gameId, base, rules, judgeFn, { seed });213+ const ref = await Referee.create(gameId, base, rules, judgeFn, {214+ seed,215+ honours: p.honours,216+ day: p.day,217+ });199218 if (!games.has(gameId)) return; // expired meanwhile200219 const entry: Ready = {201220 kind: "ready",202221 ref,203222 started: pending.started,223+ pinned: p.pinned ?? false,204224 priv: base,205225 seed,206226 grow: growing ? p.grow! : null,@@ -297,6 +317,93 @@ export async function growGame(id: string): Promise<boolean> {
297317 }298318 }299319 320+/**321+ * Resume a game from its log after a restart: the private output comes322+ * back from the generator cache (deterministic per seed), the log is323+ * folded without being re-appended, and every grow in the log is paired324+ * with its stored ring (or regenerated on demand). Returns null when the325+ * log is missing or the game had already ended.326+ */327+export async function resumeGame(328+ gameId: string,329+ p: StartParams & { seed: string },330+ generate: Generator = gen,331+ now: () => number = Date.now,332+ judgeFn: Judge = judge,333+): Promise<Referee | null> {334+ const text = await readLog(gameId);335+ if (text === null) return null;336+ const events = parseLog(text);337+ if (events.length === 0 || events.at(-1)!.kind === "end") return null;338+ const growing = p.kind === "field" && !!p.grow &&339+ (p.grow.every_minutes !== undefined ||340+ p.grow.frontier_coverage !== undefined);341+ const params: GenParams = {342+ seed: p.seed,343+ G: p.G,344+ kind: p.kind,345+ n: p.n,346+ chunks: p.chunks,347+ chunk_g: p.chunk_g,348+ ...(p.kind === "field" && p.field_g ? { field_g: p.field_g } : {}),349+ ...(p.kind === "field" && growing && p.rings !== undefined350+ ? { rings: p.rings }351+ : {}),352+ };353+ const out = await generate(params);354+ const { rings = [], ...base } = out;355+ const grows = events.filter((e) => e.kind === "grow").length;356+ const deltas: GenOutput[] = [];357+ let priv = base;358+ for (let k = 0; k < grows; k++) {359+ const delta = k < rings.length360+ ? rings[k]361+ : await grower(priv, p.seed, k + 1);362+ deltas.push(delta);363+ priv = mergeGrowth(priv, delta);364+ }365+ const ref = Referee.resume(gameId, base, judgeFn, events, deltas, {366+ seed: p.seed,367+ });368+ const entry: Ready = {369+ kind: "ready",370+ ref,371+ started: now(),372+ pinned: p.pinned ?? false,373+ priv,374+ seed: p.seed,375+ grow: growing ? p.grow! : null,376+ rings: rings.slice(grows),377+ growing: false,378+ exhausted: ref.state.exhausted,379+ timer: null,380+ lastGrow: now(),381+ };382+ games.set(gameId, entry);383+ reg.latest = gameId;384+ wireGrowth(gameId, entry, now);385+ for (const l of newGameListeners) l(gameId);386+ return ref;387+}388+389+/** End a game now (the daily's rollover); stops its growth timer. */390+export async function endGame(id: string): Promise<boolean> {391+ const e = games.get(id);392+ if (!e || e.kind !== "ready") return false;393+ if (e.timer !== null) clearInterval(e.timer);394+ e.timer = null;395+ e.exhausted = true;396+ return await e.ref.end();397+}398+399+/** Forget a game (after archiving); its log stays on disk. */400+export function dropGame(id: string): void {401+ const e = games.get(id);402+ if (e?.kind === "ready" && e.timer !== null) clearInterval(e.timer);403+ games.delete(id);404+ if (reg.latest === id) reg.latest = null;405+}406+300407 /** Wait for a starting game to be ready (tests and scripts). */301408 export async function whenReady(302409 id: string,@@ -325,7 +432,7 @@ export async function sweep(now = Date.now()): Promise<string[]> {
325432 const age = now - (e.kind === "ready" ? e.started : e.pending.started);326433 if (age < EXPIRE_MS) continue;327434 if (e.kind === "ready") {328- if (e.ref.state.agents.length > 0) continue;435+ if (e.pinned || e.ref.state.agents.length > 0) continue;329436 if (e.ref.log.events.length <= 1) {330437 try {331438 await Deno.remove(logPath(id));@@ -0,0 +1,143 @@
1+// Two leaderboards over a game's analysis: by model (every agent that2+// reported that model, aggregated: is one model winning overall) and by3+// user (machine key or join name, aggregated across whatever models they4+// ran, listing those models: a person farming the board with unusual5+// model names is visible). Daily boards come from one state; cumulative6+// boards merge days. Pure functions over the fold's output.7+import type { AgentInfo, State } from "./fold.ts";8+9+export type Row = {10+ key: string; // the model, or the user11+ reward: number; // cells solved12+ tiles: number;13+ forced: number;14+ guess: number;15+ wrong: number;16+ meets: number;17+ agents: number; // agent joins aggregated into this row18+ models: string[]; // by user: the models this user ran19+ users: string[]; // by model: the users who ran it20+ days: number; // cumulative: days with at least one join21+};22+23+export type Boards = {24+ day: string | null;25+ game_id: string;26+ by_model: Row[];27+ by_user: Row[];28+};29+30+/** A provisional id (a<N>) is not a user; the user is the machine id or the name. */31+export function userOf(a: AgentInfo): string {32+ if (!/^a\d+$/.test(a.id)) return a.id;33+ return a.name && a.name !== a.id ? a.name : a.id;34+}35+36+export function modelOf(a: AgentInfo): string {37+ return a.model ?? "unreported";38+}39+40+function emptyRow(key: string): Row {41+ return {42+ key,43+ reward: 0,44+ tiles: 0,45+ forced: 0,46+ guess: 0,47+ wrong: 0,48+ meets: 0,49+ agents: 0,50+ models: [],51+ users: [],52+ days: 0,53+ };54+}55+56+function addTo(list: string[], v: string): void {57+ if (!list.includes(v)) list.push(v);58+}59+60+function sortRows(rows: Map<string, Row>): Row[] {61+ return [...rows.values()].sort((a, b) =>62+ b.reward - a.reward || b.tiles - a.tiles || a.guess - b.guess ||63+ a.key.localeCompare(b.key)64+ );65+}66+67+/** The two boards of one game at its current state. */68+export function boardsOf(s: State): Boards {69+ const byModel = new Map<string, Row>();70+ const byUser = new Map<string, Row>();71+ for (const a of s.agents) {72+ const an = s.analysis.agents.find((x) => x.agent === a.id);73+ const model = modelOf(a);74+ const user = userOf(a);75+ for (76+ const [map, key, other, list] of [77+ [byModel, model, user, "users"],78+ [byUser, user, model, "models"],79+ ] as const80+ ) {81+ const row = map.get(key) ?? emptyRow(key);82+ row.agents++;83+ addTo(row[list], other);84+ if (an) {85+ row.reward += an.reward;86+ row.tiles += an.tiles;87+ row.forced += an.forced;88+ row.guess += an.guess;89+ row.wrong += an.wrong;90+ row.meets += an.meets;91+ }92+ row.days = 1;93+ map.set(key, row);94+ }95+ }96+ return {97+ day: s.day,98+ game_id: s.game_id,99+ by_model: sortRows(byModel),100+ by_user: sortRows(byUser),101+ };102+}103+104+/** Cumulative boards over several days' boards. */105+export function mergeBoards(106+ days: Boards[],107+): { by_model: Row[]; by_user: Row[]; days: number } {108+ const merge = (pick: (b: Boards) => Row[]) => {109+ const out = new Map<string, Row>();110+ for (const b of days) {111+ for (const r of pick(b)) {112+ const row = out.get(r.key) ?? emptyRow(r.key);113+ row.reward += r.reward;114+ row.tiles += r.tiles;115+ row.forced += r.forced;116+ row.guess += r.guess;117+ row.wrong += r.wrong;118+ row.meets += r.meets;119+ row.agents += r.agents;120+ row.days += 1;121+ for (const m of r.models) addTo(row.models, m);122+ for (const u of r.users) addTo(row.users, u);123+ out.set(r.key, row);124+ }125+ }126+ return sortRows(out);127+ };128+ return {129+ by_model: merge((b) => b.by_model),130+ by_user: merge((b) => b.by_user),131+ days: days.length,132+ };133+}134+135+/** The winners of a day: the top user (crowned tomorrow) and the top model136+ * (named on tomorrow's start card). Null when nobody scored. */137+export function winnersOf(138+ b: Boards,139+): { user: Row | null; model: Row | null } {140+ const user = b.by_user.find((r) => r.reward > 0) ?? null;141+ const model = b.by_model.find((r) => r.reward > 0) ?? null;142+ return { user, model };143+}@@ -4,9 +4,16 @@ import type { GameEvent } from "./fold.ts";
44 55 const here = new URL(".", import.meta.url);66 7+/** Root of everything persistent: games, cache, days. `MOSAIC_DATA` (a8+ * volume on the hosted box) or `site/data/` in a checkout. */9+export function dataDir(): string {10+ const d = Deno.env.get("MOSAIC_DATA");11+ if (d) return d.endsWith("/") ? d : d + "/";12+ return new URL("../data/", here).pathname;13+}14+715 export function gamesDir(): string {8- return Deno.env.get("MOSAIC_GAMES") ??9- new URL("../data/games/", here).pathname;16+ return Deno.env.get("MOSAIC_GAMES") ?? dataDir() + "games/";1017 }1118 1219 export function logPath(gameId: string): string {@@ -0,0 +1,55 @@
1+// The bootstrap prompt handed to an agent: the one place its wording lives.2+// The origin is the server the agent should talk to (the request's origin3+// on the hosted site, 127.0.0.1:8000 in a checkout).4+5+/** The public base URL of this server: `MOSAIC_PUBLIC_URL` when hosted,6+ * else the local dev server. */7+export function publicOrigin(): string {8+ return (Deno.env.get("MOSAIC_PUBLIC_URL") ?? "http://127.0.0.1:8000")9+ .replace(/\/$/, "");10+}11+12+/** Install line plus the prompt to paste, for a given origin. */13+export function promptFor(origin: string, day?: string | null): string {14+ const o = origin.replace(/\/$/, "");15+ return [16+ "Install the mosaic MCP server (streamable HTTP, no auth) and play:",17+ "",18+ ` claude mcp add --transport http mosaic ${o}/mcp`,19+ "",20+ "Then paste this into the agent:",21+ "",22+ ...bootstrap(o, day).map((l) => " " + l),23+ "",24+ `Spectate at ${o}/ (live). Replay a finished game at ${o}/?replay=<game_id>; past days at ${o}/calendar.`,25+ "",26+ ].join("\n");27+}28+29+/** The prompt itself (what "copy prompt" copies). */30+export function bootstrap(origin: string, day?: string | null): string[] {31+ const o = origin.replace(/\/$/, "");32+ const target = day33+ ? `join today's field (${day}): call list_games and join the game whose day is ${day}`34+ : "call list_games; if a field is playing, join it";35+ return [36+ `Using the mosaic MCP server at ${o}/mcp: ${target} (join with game_id, a`,37+ "short name, machine_id if you have a durable one, and the model you are, e.g.",38+ "model=claude-opus-5). If no field is playing, start one (start_mosaic with",39+ "kind=field, chunks=3, grow={every_minutes: 30}) and join it. join gives you an",40+ "agent id and a seed tile at a random spot; every reveal, commit and retract",41+ "must carry that agent id. Play autonomously with observe (agent=<your id>:",42+ "your territory and its surroundings), reveal and commit. Read the tool",43+ "descriptions for the rules (they are per line of a tile). Reveal your seed",44+ "tile first; commit in batches with a one-line intent; when a tile alone is",45+ "ambiguous, expand from a tile your group solved or commit a guess; when you",46+ "meet another agent your territories merge and you share a frontier. The field",47+ "grows during the day and unexplored tiles appear only once your territory",48+ "reaches them: observe again after every batch. Keep playing until observe",49+ "reports the game ended. Do not ask me anything.",50+ ];51+}52+53+export function bootstrapText(origin: string, day?: string | null): string {54+ return bootstrap(origin, day).join("\n");55+}@@ -19,6 +19,7 @@ import {
1919 type GameRules,2020 groupOf,2121 type GrowEvent,22+ type Honour,2223 IMPLICIT_AGENT,2324 isHole,2425 isOnFrontier,@@ -93,6 +94,9 @@ export const FULL_OBSERVE_MAX_G = 120;
9394 /** Cells around an agent's territory that observe {agent} includes. */9495 export const OBSERVE_RADIUS = 24;9596 97+/** Most tiles one observation lists (keeps a big territory under ~64 KB). */98+export const MAX_OBSERVE_TILES = 100;99+96100 export type RevealResult = {97101 revision: number;98102 tile: number;@@ -131,6 +135,9 @@ export type Observation = {
131135 neighbours: number[];132136 }[];133137 tiles_total: number;138+ /** set when the listing was cut to MAX_OBSERVE_TILES (frontier and139+ * revealed tiles kept, solved ones nearest the agent) */140+ truncated?: boolean;134141 holes: number;135142 field: FieldInfo | null;136143 grown: number;@@ -193,17 +200,14 @@ export class Referee {
193200 gen: GenOutput,194201 rules: GameRules,195202 judgeFn: Judge,196- opts: { persist?: boolean; seed?: string } = {},203+ opts: {204+ persist?: boolean;205+ seed?: string;206+ honours?: Honour[];207+ day?: string;208+ } = {},197209 ): Promise<Referee> {198- if (199- isV1(gen.manifest) ||200- (gen.manifest.version !== 2 && gen.manifest.version !== 3)201- ) {202- throw new RefereeError(203- "Generator output is not manifest v2 or v3.",204- "version",205- );206- }210+ Referee.checkVersion(gen);207211 const r = new Referee(gameId, gen, rules, judgeFn, opts.persist ?? true);208212 r.seed = opts.seed;209213 const ev: ManifestEvent = {@@ -213,11 +217,97 @@ export class Referee {
213217 game_id: gameId,214218 manifest: gen.manifest,215219 rules,220+ ...(opts.honours?.length ? { honours: opts.honours } : {}),221+ ...(opts.day ? { day: opts.day } : {}),216222 };217223 await r.apply(ev);218224 return r;219225 }220226 227+ private static checkVersion(gen: GenOutput): void {228+ if (229+ isV1(gen.manifest) ||230+ (gen.manifest.version !== 2 && gen.manifest.version !== 3)231+ ) {232+ throw new RefereeError(233+ "Generator output is not manifest v2 or v3.",234+ "version",235+ );236+ }237+ }238+239+ /**240+ * Resume a game from its log after a restart: the same private output241+ * the game started from (the generator is deterministic and cached),242+ * the events on disk folded back into state without being re-appended,243+ * and every `grow` in the log paired with its private delta (in order)244+ * so the solution and givens of grown tiles are known again. Writes245+ * continue from the log's last revision.246+ */247+ static resume(248+ gameId: string,249+ gen: GenOutput,250+ judgeFn: Judge,251+ events: GameEvent[],252+ deltas: GenOutput[],253+ opts: { persist?: boolean; seed?: string } = {},254+ ): Referee {255+ Referee.checkVersion(gen);256+ const first = events[0];257+ if (!first || first.kind !== "manifest") {258+ throw new RefereeError("Log has no manifest event.", "log");259+ }260+ const r = new Referee(261+ gameId,262+ gen,263+ first.rules,264+ judgeFn,265+ opts.persist ?? true,266+ );267+ r.seed = opts.seed;268+ let ring = 0;269+ for (const ev of events) {270+ if (ev.kind === "grow") {271+ const delta = deltas[ring++];272+ if (!delta) {273+ throw new RefereeError(274+ `Log has ${ring} grow events but only ${deltas.length} private deltas.`,275+ "log",276+ );277+ }278+ r.absorbDelta(delta, normaliseManifest(delta.manifest));279+ }280+ r.state = applyEvent(r.state, ev);281+ r.log.events.push(ev);282+ }283+ r.manifest = r.state.manifest!;284+ return r;285+ }286+287+ /** The number of grow events folded so far (rings applied). */288+ get grown(): number {289+ return this.state.grown;290+ }291+292+ /**293+ * End the game now (the daily's rollover): one `end` event with the294+ * analysis, whatever is solved. No-op when already ended.295+ */296+ end(): Promise<boolean> {297+ return this.locked(async () => {298+ if (this.state.status === "solved") return false;299+ const end: EndEvent = {300+ rev: this.state.revision + 1,301+ t: new Date().toISOString(),302+ kind: "end",303+ seed: this.seed,304+ analysis: { ...this.state.analysis, revision: this.state.revision + 1 },305+ };306+ await this.apply(end);307+ return true;308+ });309+ }310+221311 private seed?: string;222312 private chain: Promise<unknown> = Promise.resolve();223313 @@ -313,7 +403,17 @@ export class Referee {
313403 if (agentOf(this.state, agent)) {314404 throw new RefereeError(`Agent ${agent} has already joined.`, "joined");315405 }316- let candidates = joinCandidates(this.state);406+ // a returning winner is seated on the seat reserved for them407+ const honour = this.state.honours.find((h) =>408+ (opts.machine_id && h.user === opts.machine_id) ||409+ (opts.name && h.name === opts.name) || h.agent === agent410+ );411+ const seatFree = (t: number) =>412+ this.state.tiles[t] === "hidden" &&413+ !this.state.agents.some((a) => a.seed_tile === t);414+ let candidates = honour && seatFree(honour.seed_tile)415+ ? [honour.seed_tile]416+ : joinCandidates(this.state);317417 if (candidates.length === 0) {318418 // nowhere untouched is left: start next to someone319419 candidates = this.manifest.tiles.filter((t) =>@@ -416,24 +516,7 @@ export class Referee {
416516 );417517 }418518 });419- // merged solution: old cells must be unchanged420- for (let y = 0; y < G; y++) {421- const row = delta.solution[y];422- for (let x = 0; x < G; x++) {423- const ch = row[x];424- const id = cellId(x, y, G);425- const had = this.covered[id];426- if (had && ch !== "." && (ch === "1" ? 1 : 0) !== this.solution[id]) {427- throw new RefereeError(`grow: cell ${id} changed`, "grow");428- }429- if (ch !== ".") this.solution[id] = ch === "1" ? 1 : 0;430- }431- }432- for (const t of dm.tiles) {433- this.specs.set(t.id, t);434- for (const c of tileCells(t, G)) this.covered[c] = 1;435- }436- for (const t of delta.tiles) this.givensByTile.set(t.id, t.givens);519+ this.absorbDelta(delta, dm);437520 const stats = delta.stats as Record<string, unknown> | undefined;438521 const ev: GrowEvent = {439522 rev: this.state.revision + 1,@@ -455,6 +538,29 @@ export class Referee {
455538 return ev;456539 }457540 541+ /** Record a growth delta's private side: merged solution (old cells542+ * must be unchanged), the new tiles' specs, coverage and givens. */543+ private absorbDelta(delta: GenOutput, dm: Manifest): void {544+ const G = this.manifest.G;545+ for (let y = 0; y < G; y++) {546+ const row = delta.solution[y];547+ for (let x = 0; x < G; x++) {548+ const ch = row[x];549+ const id = cellId(x, y, G);550+ const had = this.covered[id];551+ if (had && ch !== "." && (ch === "1" ? 1 : 0) !== this.solution[id]) {552+ throw new RefereeError(`grow: cell ${id} changed`, "grow");553+ }554+ if (ch !== ".") this.solution[id] = ch === "1" ? 1 : 0;555+ }556+ }557+ for (const t of dm.tiles) {558+ this.specs.set(t.id, t);559+ for (const c of tileCells(t, G)) this.covered[c] = 1;560+ }561+ for (const t of delta.tiles) this.givensByTile.set(t.id, t.givens);562+ }563+458564 reveal(459565 expected: number,460566 tile: number,@@ -814,6 +920,34 @@ export class Referee {
814920 const ids = new Set(chosen.map((t) => t.id));815921 frontier = frontier.filter((t) => ids.has(t));816922 }923+ // a territory's observation stays bounded: past the cap, keep the924+ // frontier and every revealed-unsolved tile, then solved tiles nearest925+ // the agent's last solved tile (or seed), and say so926+ let truncated = false;927+ if (chosen.length > MAX_OBSERVE_TILES) {928+ const fr = new Set(frontier);929+ const keep = chosen.filter((t) =>930+ fr.has(t.id) || s.tiles[t.id] === "revealed"931+ );932+ const rest = chosen.filter((t) =>933+ !fr.has(t.id) && s.tiles[t.id] !== "revealed"934+ );935+ let cx = G / 2, cy = G / 2;936+ if (opts.agent !== undefined) {937+ const a = agentOf(s, opts.agent)!;938+ const last = s.analysis.agents.find((x) => x.agent === a.id)939+ ?.last_solved;940+ const ref = this.manifest.tiles[last ?? a.seed_tile];941+ cx = ref.x0 + ref.w / 2;942+ cy = ref.y0 + ref.h / 2;943+ }944+ const d2 = (t: TileSpec) =>945+ (t.x0 + t.w / 2 - cx) ** 2 + (t.y0 + t.h / 2 - cy) ** 2;946+ rest.sort((a, b) => d2(a) - d2(b) || a.id - b.id);947+ const room = Math.max(0, MAX_OBSERVE_TILES - keep.length);948+ truncated = rest.length > room;949+ chosen = [...keep, ...rest.slice(0, room)].sort((a, b) => a.id - b.id);950+ }817951 const known = new Set<number>();818952 const tiles = [];819953 for (const t of chosen) {@@ -860,6 +994,7 @@ export class Referee {
860994 frontier,861995 tiles,862996 tiles_total: this.manifest.tiles.length,997+ ...(truncated ? { truncated: true } : {}),863998 holes: geo.holes,864999 field: this.manifest.field ?? null,8651000 grown: s.grown,@@ -875,7 +1010,7 @@ export class Referee {
8751010 neighbours:8761011 "tile ids sharing at least one cell; a tile solved by your group fixes the shared cells and puts this tile on your frontier",8771012 scope:878- "which tiles are listed: your territory and its surroundings (agent), a rectangle (rect), or everything (full, small fields only); known cells are coded over the listed tiles only",1013+ "which tiles are listed: your territory and its surroundings (agent), a rectangle (rect), or everything (full, small fields only); known cells are coded over the listed tiles only; when truncated is set, solved tiles far from your last solved tile were left out (use rect to see them)",8791014 known_deltas:8801015 "cumulative sum recovers the sorted ids of every known cell in the listed tiles; known_values[i] is the value of the i-th id",8811016 committed_deltas:@@ -1,7 +1,14 @@
11 import { App, staticFiles } from "fresh";22 import { type State } from "./utils.ts";3+import { startDaily } from "./lib/daily.ts";34 45 export const app = new App<State>();56 67 app.use(staticFiles());78 app.fsRoutes();9+10+// The daily loop runs in-process on the hosted box (MOSAIC_DAILY=1):11+// resume today's field from its log, then tick every minute.12+startDaily().catch((e) =>13+ console.error(JSON.stringify({ daily: "failed to start", error: String(e) }))14+);@@ -0,0 +1,171 @@
1+// The calendar: a month grid, one tile per day with its winners and stats;2+// today links to the live field, past days to their replay.3+import { define } from "../utils.ts";4+import { daily, type DayRow } from "../lib/daily.ts";5+6+function monthOf(s: string | null, fallback: string): string {7+ return s && /^\d{4}-\d{2}$/.test(s) ? s : fallback;8+}9+10+function shiftMonth(m: string, by: number): string {11+ const [y, mo] = m.split("-").map(Number);12+ const d = new Date(Date.UTC(y, mo - 1 + by, 1));13+ return d.toISOString().slice(0, 7);14+}15+16+function cell(row: DayRow | undefined, date: string, today: string | null) {17+ const isToday = date === today;18+ const href = isToday ? "/" : row ? `/day/${date}` : null;19+ const box =20+ "min-height:96px;border:1px solid var(--rule);padding:6px;font-size:11px;color:var(--text-dim);" +21+ (isToday ? "background:var(--chrome);" : "");22+ const inner = (23+ <>24+ <div style="color:var(--text-strong);font-size:12px">25+ {date.slice(8)}26+ {isToday && <span style="color:var(--muted)">today, live</span>}27+ </div>28+ {row && (29+ <>30+ <div>31+ {row.winner_user32+ ? <span title="top user">♕ {row.winner_user}</span>33+ : <span style="color:var(--muted)">no winner</span>}34+ </div>35+ {row.winner_model && (36+ <div title="top model">model: {row.winner_model}</div>37+ )}38+ <div>39+ {row.stats.solved}/{row.stats.tiles} tiles, {row.stats.agents}{" "}40+ agents41+ </div>42+ <div>43+ {row.stats.completed ? "completed" : "unfinished"}, guess{" "}44+ {(row.stats.guess_rate * 100).toFixed(0)}%, grown {row.stats.grown}x45+ </div>46+ <div style="color:var(--muted)">47+ {row.boards.by_model.slice(0, 3).map((m) => m.key).join(", ")}48+ </div>49+ </>50+ )}51+ </>52+ );53+ return href54+ ? (55+ <a56+ key={date}57+ href={href}58+ style={box + "display:block;text-decoration:none"}59+ >60+ {inner}61+ </a>62+ )63+ : <div key={date} style={box}>{inner}</div>;64+}65+66+export default define.page(function Calendar(ctx) {67+ const d = daily();68+ const today = d?.current?.date ?? null;69+ const month = monthOf(70+ ctx.url.searchParams.get("month"),71+ (today ?? new Date().toISOString().slice(0, 10)).slice(0, 7),72+ );73+ const rows = new Map((d ? d.index.list(month) : []).map((r) => [r.date, r]));74+ const [y, mo] = month.split("-").map(Number);75+ const first = new Date(Date.UTC(y, mo - 1, 1));76+ const days = new Date(Date.UTC(y, mo, 0)).getUTCDate();77+ const lead = (first.getUTCDay() + 6) % 7; // Monday first78+ const cells = [];79+ for (let i = 0; i < lead; i++) cells.push(<div key={"lead" + i} />);80+ for (let day = 1; day <= days; day++) {81+ const date = `${month}-${String(day).padStart(2, "0")}`;82+ cells.push(cell(rows.get(date), date, today));83+ }84+ const cumulative = d?.cumulative();85+ return (86+ <div style="padding:16px 20px;max-width:1100px;margin:0 auto;font-size:13px">87+ <nav style="display:flex;gap:14px;align-items:center;margin-bottom:12px;color:var(--text-dim)">88+ <a href="/" style="color:var(--text-strong);text-decoration:none">89+ mosaic90+ </a>91+ <a href={`/calendar?month=${shiftMonth(month, -1)}`}>←</a>92+ <span style="color:var(--text-strong)">{month}</span>93+ <a href={`/calendar?month=${shiftMonth(month, 1)}`}>→</a>94+ {!d && <span style="color:var(--muted)">no daily on this server</span>}95+ </nav>96+ <div style="display:grid;grid-template-columns:repeat(7,1fr);gap:4px">97+ {["mon", "tue", "wed", "thu", "fri", "sat", "sun"].map((n) => (98+ <div99+ key={n}100+ style="color:var(--muted);font-size:11px;padding:2px 6px"101+ >102+ {n}103+ </div>104+ ))}105+ {cells}106+ </div>107+ {cumulative && cumulative.days > 0 && (108+ <div style="display:flex;gap:32px;margin-top:20px">109+ <Board title="models, all days" rows={cumulative.by_model} />110+ <Board title="users, all days" rows={cumulative.by_user} />111+ </div>112+ )}113+ </div>114+ );115+});116+117+function Board(118+ props: {119+ title: string;120+ rows: {121+ key: string;122+ reward: number;123+ tiles: number;124+ guess: number;125+ forced: number;126+ days: number;127+ models: string[];128+ }[];129+ },130+) {131+ return (132+ <div style="flex:1;min-width:0">133+ <div style="color:var(--text-strong);margin-bottom:6px">134+ {props.title}135+ </div>136+ <table style="border-collapse:collapse;width:100%;font-size:12px">137+ <thead>138+ <tr style="color:var(--muted);text-align:left">139+ <th>who</th>140+ <th>cells</th>141+ <th>tiles</th>142+ <th>guess %</th>143+ <th>days</th>144+ </tr>145+ </thead>146+ <tbody>147+ {props.rows.slice(0, 20).map((r) => (148+ <tr key={r.key}>149+ <td>150+ {r.key}151+ {r.models.length > 1 && (152+ <span style="color:var(--muted);margin-left:4px">153+ ({r.models.join(", ")})154+ </span>155+ )}156+ </td>157+ <td>{r.reward}</td>158+ <td>{r.tiles}</td>159+ <td>160+ {r.forced + r.guess > 0161+ ? (100 * r.guess / (r.forced + r.guess)).toFixed(0)162+ : "-"}163+ </td>164+ <td>{r.days}</td>165+ </tr>166+ ))}167+ </tbody>168+ </table>169+ </div>170+ );171+}@@ -0,0 +1,50 @@
1+// A past day's replay (today redirects to /).2+import { define } from "../../utils.ts";3+import Viewer from "../../islands/Viewer.tsx";4+import { daily } from "../../lib/daily.ts";5+6+export default define.page(function Day(ctx) {7+ const date = ctx.params.date;8+ const d = daily();9+ if (!d || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {10+ return (11+ <p style="padding:24px;color:var(--muted)">12+ No daily on this server.13+ </p>14+ );15+ }16+ if (d.current?.date === date) {17+ return new Response(null, { status: 302, headers: { Location: "/" } });18+ }19+ const row = d.index.get(date);20+ if (!row) {21+ return (22+ <p style="padding:24px;color:var(--muted)">23+ No field for {date}. <a href="/calendar">calendar</a>24+ </p>25+ );26+ }27+ const t = ctx.url.searchParams.get("theme");28+ const theme = t === "dark" || t === "light" ? t : null;29+ return (30+ <div style="display:flex;flex-direction:column;height:100vh">31+ <nav style="display:flex;gap:14px;align-items:center;padding:6px 12px;border-bottom:1px solid var(--rule);background:var(--chrome);font-size:12px;color:var(--text-dim)">32+ <a href="/" style="color:var(--text-strong);text-decoration:none">33+ mosaic34+ </a>35+ <span>{date}</span>36+ <span>37+ {row.stats.solved}/{row.stats.tiles} tiles, {row.stats.agents}{" "}38+ agents, winner {row.winner_user ?? "-"} ({row.winner_model ?? "-"})39+ </span>40+ <a href="/calendar" style="color:var(--text-dim)">calendar</a>41+ <a href={`/leaderboard/${date}`} style="color:var(--text-dim)">42+ leaderboard43+ </a>44+ </nav>45+ <div style="flex:1;min-height:0">46+ <Viewer replay={row.game_id} theme={theme} live={null} />47+ </div>48+ </div>49+ );50+});@@ -1,9 +1,40 @@
1+// Today: the daily's live field when one is running, else its finished2+// replay; a plain checkout without the daily follows the newest game.13 import { define } from "../utils.ts";24 import Viewer from "../islands/Viewer.tsx";5+import CopyPrompt from "../islands/CopyPrompt.tsx";6+import { daily } from "../lib/daily.ts";7+import { getGame } from "../lib/games.ts";8+import { bootstrapText } from "../lib/prompt.ts";39 410 export default define.page(function Home(ctx) {511 const replay = ctx.url.searchParams.get("replay");612 const t = ctx.url.searchParams.get("theme");713 const theme = t === "dark" || t === "light" ? t : null;8- return <Viewer replay={replay} theme={theme} />;14+ const d = daily();15+ const cur = d?.current ?? null;16+ const liveId = !replay && cur && getGame(cur.game_id) ? cur.game_id : null;17+ const origin = ctx.url.origin;18+ const prompt = bootstrapText(origin, cur?.date ?? null);19+ return (20+ <div style="display:flex;flex-direction:column;height:100vh">21+ <nav style="display:flex;gap:14px;align-items:center;padding:6px 12px;border-bottom:1px solid var(--rule);background:var(--chrome);font-size:12px;color:var(--text-dim)">22+ <a href="/" style="color:var(--text-strong);text-decoration:none">23+ mosaic24+ </a>25+ <span>{cur ? `today ${cur.date}` : replay ? "replay" : "live"}</span>26+ <a href="/calendar" style="color:var(--text-dim)">calendar</a>27+ {cur && (28+ <a href={`/leaderboard/${cur.date}`} style="color:var(--text-dim)">29+ leaderboard30+ </a>31+ )}32+ <span style="flex:1" />33+ <CopyPrompt text={prompt} />34+ </nav>35+ <div style="flex:1;min-height:0">36+ <Viewer replay={replay} theme={theme} live={liveId} />37+ </div>38+ </div>39+ );940 });@@ -0,0 +1,43 @@
1+// Both leaderboards of a day (by model, by user) as JSON: an archived2+// day from the index, today from the live fold; plus the cumulative3+// boards across every archived day.4+import { define } from "../../utils.ts";5+import { daily } from "../../lib/daily.ts";6+import { getGame } from "../../lib/games.ts";7+import { boardsOf, winnersOf } from "../../lib/leaderboard.ts";8+9+export const handler = define.handlers({10+ GET(ctx) {11+ const date = ctx.params.date;12+ const d = daily();13+ if (!d) {14+ return Response.json({ error: "No daily on this server." }, {15+ status: 404,16+ });17+ }18+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {19+ return Response.json({ error: "bad date" }, { status: 400 });20+ }21+ let boards;22+ let live = false;23+ if (d.current?.date === date && getGame(d.current.game_id)) {24+ boards = boardsOf(getGame(d.current.game_id)!.state);25+ live = true;26+ } else {27+ const row = d.index.get(date);28+ if (!row) {29+ return Response.json({ error: "No field for that day." }, {30+ status: 404,31+ });32+ }33+ boards = row.boards;34+ }35+ return Response.json({36+ date,37+ live,38+ ...boards,39+ winners: winnersOf(boards),40+ cumulative: d.cumulative(),41+ });42+ },43+});@@ -1,9 +1,11 @@
1-// Streamable HTTP MCP, authless, localhost only.1+// Streamable HTTP MCP, authless. Localhost only in a checkout; on the2+// hosted box (MOSAIC_PUBLIC=1) any host is served.23 import { createMcpHandler } from "@modelcontextprotocol/server";34 import { define } from "../utils.ts";45 import { createServer } from "../lib/mcp.ts";56 67 function isLocal(req: Request): boolean {8+ if (Deno.env.get("MOSAIC_PUBLIC") === "1") return true;79 const host = req.headers.get("Host")?.split(":")[0];810 return host === "localhost" || host === "127.0.0.1" || host === "[::1]";911 }@@ -38,6 +38,7 @@ const FOG = args.fog !== undefined ? args.fog !== "false" : undefined;
3838 let nextId = 1;3939 async function rpc(method: string, params: unknown): Promise<any> {4040 const res = await fetch(URL_, {41+ signal: AbortSignal.timeout(Number(args.timeout ?? 90000)),4142 method: "POST",4243 headers: {4344 "Content-Type": "application/json",