1// The daily loop: one field per date. The day's seed is random and2// private (the field is regenerable from it; the manifest publishes its3// commitment and the archive releases it); its rings are generated the4// night before on slate and pushed, so growth by day is reveal; at rollover the game is ended, archived under5// data/days/<date>/ and indexed, and the next day's game starts with6// yesterday's top user honoured (crown, reserved hue and seat) and the7// top model named. A restart resumes today's game from its log. Clock and8// generator are injectable so two rollovers can be simulated in a test.9import {10 dropGame,11 endGame,12 type Generator,13 getGame,14 resumeGame,15 startGame,16 type StartParams,17 whenReady,18} from "./games.ts";19import {20 type FieldOutput,21 gen,22 type GenParams,23 judge,24 setEngineLog,25 unwrapField,26} from "./engine.ts";27import type { Judge } from "./referee.ts";28import { dataDir, gamesDir, logPath, readLog } from "./log.ts";29import { fold, type Honour, type ManifestEvent, parseLog } from "./fold.ts";30import {31 type Boards,32 boardsOf,33 mergeBoards,34 winnersOf,35} from "./leaderboard.ts";3637export type DailyConfig = {38 namespace: string; // seed namespace: change it and every day's field changes39 chunks: number;40 chunk_g: number;41 G: number;42 grow_minutes: number;43 rollover_hour_utc: number; // the day changes at this UTC hour44 fog: boolean;45 /** run the engine on this box for the emergency field (the day's46 * field itself is pushed by the nightly) */47 generate: boolean;48 /** the emergency field generated at rollover when nothing is pushed */49 emergency: { chunks: number; chunk_g: number; rings: number };50};5152export function configFromEnv(): DailyConfig {53 const num = (k: string, d: number) => {54 const v = Number(Deno.env.get(k));55 return Number.isFinite(v) && v > 0 ? v : d;56 };57 return {58 namespace: Deno.env.get("MOSAIC_DAILY_NAMESPACE") ?? "mosaic-daily",59 chunks: num("MOSAIC_DAILY_CHUNKS", 3),60 chunk_g: num("MOSAIC_DAILY_CHUNK_G", 60),61 G: num("MOSAIC_DAILY_G", 1000),62 grow_minutes: num("MOSAIC_DAILY_GROW_MINUTES", 60),63 rollover_hour_utc: Number(Deno.env.get("MOSAIC_DAILY_ROLLOVER_UTC") ?? 0),64 fog: (Deno.env.get("MOSAIC_DAILY_FOG") ?? "1") !== "0",65 generate: Deno.env.get("MOSAIC_GENERATE") === "1",66 emergency: { chunks: 3, chunk_g: 60, rings: 1 },67 };68}6970/** The moment the day rolls over after `nowMs` (UTC). */71export function nextRollover(nowMs: number, cfg: DailyConfig): number {72 const d = new Date(nowMs);73 const today = Date.UTC(74 d.getUTCFullYear(),75 d.getUTCMonth(),76 d.getUTCDate(),77 cfg.rollover_hour_utc,78 );79 return today > nowMs ? today : today + 24 * 3600 * 1000;80}8182/** Where pushed fields live: <data>/fields/<date>.json, the engine's83 * `field --rings` output (base + rings) as produced by `push-day`. */84export function fieldsDir(dir: string): string {85 return dir + "fields/";86}8788export function pushedPath(dir: string, date: string): string {89 if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error("bad date");90 return fieldsDir(dir) + date + ".json";91}9293/** A pushed field for the date, if one was uploaded. */94export async function loadPushed(95 dir: string,96 date: string,97): Promise<FieldOutput | null> {98 try {99 return unwrapField(100 JSON.parse(await Deno.readTextFile(pushedPath(dir, date))),101 );102 } catch {103 return null;104 }105}106107/** The ISO date of the day a moment belongs to (days roll at rollover_hour_utc). */108export function dateFor(nowMs: number, cfg: DailyConfig): string {109 const shifted = new Date(nowMs - cfg.rollover_hour_utc * 3600 * 1000);110 return shifted.toISOString().slice(0, 10);111}112113export function nextDate(date: string): string {114 const d = new Date(date + "T00:00:00Z");115 d.setUTCDate(d.getUTCDate() + 1);116 return d.toISOString().slice(0, 10);117}118119/** A day seed: 32 random bytes, hex. Private until the day is archived:120 * the field, its solution and the tile-hash key all derive from it. The121 * manifest publishes sha256("mosaic-day-seed-v1|" + seed) as seed_commit. */122export function randomSeed(): string {123 const b = crypto.getRandomValues(new Uint8Array(32));124 return [...b].map((x) => x.toString(16).padStart(2, "0")).join("");125}126127/** The private side of an archived day, released with its record. */128export type DayKeys = {129 scheme: string;130 seed_commit: string;131 day_seed: string | null;132 hash_key_commit: string | null;133 hash_key: string | null;134};135136async function sha256hex(data: Uint8Array | string): Promise<string> {137 const bytes = typeof data === "string"138 ? new TextEncoder().encode(data)139 : data;140 const buf = await crypto.subtle.digest(141 "SHA-256",142 bytes as unknown as ArrayBuffer,143 );144 return [...new Uint8Array(buf)].map((x) => x.toString(16).padStart(2, "0"))145 .join("");146}147148function fromHex(h: string): Uint8Array {149 const out = new Uint8Array(h.length / 2);150 for (let i = 0; i < out.length; i++) {151 out[i] = parseInt(h.slice(2 * i, 2 * i + 2), 16);152 }153 return out;154}155156/** Recompute the day's commitments from its released keys (what a157 * reader can do with the archive alone). */158export async function verifyKeys(159 k: DayKeys,160): Promise<{ seed: boolean | null; key: boolean | null }> {161 const seed = k.day_seed162 ? (await sha256hex("mosaic-day-seed-v1|" + k.day_seed)) === k.seed_commit163 : null;164 const key = k.hash_key && k.hash_key_commit165 ? (await sha256hex(fromHex(k.hash_key))) === k.hash_key_commit166 : null;167 return { seed, key };168}169170export function gameIdFor(date: string, cfg: DailyConfig): string {171 return `day-${date}` +172 (cfg.namespace === "mosaic-daily" ? "" : `-${cfg.namespace}`);173}174175export function paramsFor(176 date: string,177 seed: string,178 cfg: DailyConfig,179): StartParams & { seed: string } {180 return {181 G: cfg.G,182 kind: "field",183 chunks: cfg.chunks,184 chunk_g: cfg.chunk_g,185 field_g: cfg.G,186 seed,187 rings: "all",188 grow: { every_minutes: cfg.grow_minutes },189 rules: { fog: cfg.fog },190 pinned: true,191 day: date,192 game_id: gameIdFor(date, cfg),193 };194}195196// node:sqlite is loaded through a computed specifier: the static import197// would pull the Node type definitions, which retype every timer in the198// process as Node's Timeout. This is all the index uses of it.199type StatementSync = {200 run(...params: (string | number | null)[]): unknown;201 get(...params: (string | number | null)[]): unknown;202 all(...params: (string | number | null)[]): unknown[];203};204type DatabaseSync = {205 exec(sql: string): void;206 prepare(sql: string): StatementSync;207 close(): void;208};209const sqliteSpecifier = "node:sqlite";210const sqlite = await import(sqliteSpecifier) as unknown as {211 DatabaseSync: new (path: string) => DatabaseSync;212};213214export type DayRow = {215 date: string;216 game_id: string;217 seed_commit: string;218 stats: {219 tiles: number;220 solved: number;221 cells: number;222 agents: number;223 revisions: number;224 guess_rate: number;225 grown: number;226 ended: string | null; // ISO time of the end event227 completed: boolean; // every tile solved before rollover228 };229 boards: Boards;230 winner_user: string | null;231 winner_model: string | null;232};233234/** The days index: one row per archived day, SQLite on the data volume. */235export class DaysIndex {236 private db: DatabaseSync;237 constructor(path: string) {238 this.db = new sqlite.DatabaseSync(path);239 this.db.exec(`CREATE TABLE IF NOT EXISTS days (240 date TEXT PRIMARY KEY,241 game_id TEXT NOT NULL,242 seed_commit TEXT NOT NULL,243 stats TEXT NOT NULL,244 boards TEXT NOT NULL,245 winner_user TEXT,246 winner_model TEXT247 )`);248 }249 upsert(row: DayRow): void {250 this.db.prepare(251 `INSERT INTO days (date, game_id, seed_commit, stats, boards, winner_user, winner_model)252 VALUES (?, ?, ?, ?, ?, ?, ?)253 ON CONFLICT(date) DO UPDATE SET game_id=excluded.game_id, seed_commit=excluded.seed_commit,254 stats=excluded.stats, boards=excluded.boards, winner_user=excluded.winner_user, winner_model=excluded.winner_model`,255 ).run(256 row.date,257 row.game_id,258 row.seed_commit,259 JSON.stringify(row.stats),260 JSON.stringify(row.boards),261 row.winner_user,262 row.winner_model,263 );264 }265 delete(date: string): void {266 this.db.prepare(`DELETE FROM days WHERE date = ?`).run(date);267 }268 get(date: string): DayRow | null {269 const r = this.db.prepare(`SELECT * FROM days WHERE date = ?`).get(date) as270 | Record<string, string>271 | undefined;272 return r ? parseRow(r) : null;273 }274 /** Every day, oldest first; `month` = "YYYY-MM" filters. */275 list(month?: string): DayRow[] {276 const rows = month277 ? this.db.prepare(`SELECT * FROM days WHERE date LIKE ? ORDER BY date`)278 .all(279 month + "-%",280 )281 : this.db.prepare(`SELECT * FROM days ORDER BY date`).all();282 return (rows as Record<string, string>[]).map(parseRow);283 }284 close(): void {285 this.db.close();286 }287}288289function parseRow(r: Record<string, string>): DayRow {290 return {291 date: r.date,292 game_id: r.game_id,293 seed_commit: r.seed_commit,294 stats: JSON.parse(r.stats),295 boards: JSON.parse(r.boards),296 winner_user: r.winner_user ?? null,297 winner_model: r.winner_model ?? null,298 };299}300301/** The state `/` renders and polls. */302export type DailyStatus = {303 date: string;304 phase: "live" | "generating" | "missing";305 live: string | null; // today's game id when playing306 since_s: number | null; // seconds spent generating so far307 emergency: boolean;308 latest_archived: { date: string; game_id: string } | null;309 /** tomorrow's field, pushed to this box by the nightly */310 tomorrow: { date: string; pushed: boolean };311 /** times today was reset (play wiped, same field restarted) */312 reset: number;313};314315export type DailyState = {316 date: string;317 game_id: string;318 seed: string;319 /** where the day's field came from; resume reads the same source */320 source: "pushed" | "emergency";321 /** times this day was reset (its earlier logs are kept as .reset-<n>) */322 reset?: number;323};324325type Opts = {326 cfg?: DailyConfig;327 dir?: string; // data root328 now?: () => number;329 generate?: Generator;330 judge?: Judge;331 log?: (msg: string) => void;332};333334/** The daily scheduler. One instance per process. */335export class Daily {336 readonly cfg: DailyConfig;337 readonly dir: string;338 readonly now: () => number;339 private generate: (340 p: GenParams,341 opts?: { timeoutMs?: number },342 ) => Promise<FieldOutput>;343 private judgeFn: Judge;344 private log: (msg: string) => void;345 current: DailyState | null = null;346 /** the date that has no field (nothing pushed, generation off) */347 missing: string | null = null;348 /** the date whose field is a small emergency one, generated at rollover */349 emergency: string | null = null;350 /** today's field being produced right now (emergency generation or a351 * pushed one loading), with when that started */352 generating: { date: string; since: number } | null = null;353 private timer: number | null = null;354 private ticking: Promise<void> | null = null;355 index: DaysIndex;356357 constructor(opts: Opts = {}) {358 this.cfg = opts.cfg ?? configFromEnv();359 this.dir = opts.dir ?? dataDir();360 this.now = opts.now ?? Date.now;361 this.generate = opts.generate ?? gen;362 this.judgeFn = opts.judge ?? judge;363 this.log = opts.log ?? ((m) => console.log(JSON.stringify({ daily: m })));364 if (!opts.log) {365 setEngineLog((l) => console.log(JSON.stringify({ engine: l })));366 }367 Deno.mkdirSync(this.dir + "days/", { recursive: true });368 Deno.mkdirSync(fieldsDir(this.dir), { recursive: true });369 this.index = new DaysIndex(this.dir + "days.sqlite");370 }371372 /** Whether this box may run the engine (tests inject a generator). */373 private get canGenerate(): boolean {374 return this.cfg.generate || this.generate !== gen;375 }376377 private genParams(378 _date: string,379 seed: string,380 rings: number | "all",381 chunks: number,382 chunkG: number,383 ): GenParams {384 return {385 seed,386 G: this.cfg.G,387 kind: "field",388 chunks,389 chunk_g: chunkG,390 field_g: this.cfg.G,391 rings,392 };393 }394395 /**396 * The field for a date: the pushed one (made by the nightly on slate),397 * else, when this box may generate, a small emergency field generated398 * now (3x3, one ring, under a short budget), which is said on `/`.399 */400 private async fieldFor(401 date: string,402 ): Promise<403 { out: FieldOutput; source: DailyState["source"]; seed: string } | null404 > {405 const pushed = await loadPushed(this.dir, date);406 if (pushed) {407 // gen-v7 fields carry their (private) day seed; older ones do not,408 // and any string does for their seat placement and growth409 return { out: pushed, source: "pushed", seed: pushed.day_seed ?? date };410 }411 if (!this.canGenerate) return null;412 const seed = randomSeed();413 const e = this.cfg.emergency;414 this.log(415 `${date}: nothing pushed; generating an emergency field (${e.chunks}x${e.chunks} of ${e.chunk_g}, ${e.rings} ring)`,416 );417 const t0 = this.now();418 const out = await this.generate(this.emergencyParams(date, seed), {419 timeoutMs: 40 * 60 * 1000,420 });421 this.log(422 `${date}: emergency field ready in ${423 Math.round((this.now() - t0) / 1000)424 } s`,425 );426 return { out, source: "emergency", seed };427 }428429 private emergencyParams(date: string, seed: string): GenParams {430 const e = this.cfg.emergency;431 return this.genParams(date, seed, e.rings, e.chunks, e.chunk_g);432 }433434 /** A generator for the registry that returns the field this date435 * started from, whatever params the registry passes: the pushed file436 * or the emergency field (a cache hit). */437 private generatorFor(438 date: string,439 seed: string,440 source: DailyState["source"],441 out?: FieldOutput,442 ): Generator {443 return async () => {444 if (out) return out;445 if (source === "pushed") {446 const pushed = await loadPushed(this.dir, date);447 if (!pushed) throw new Error(`pushed field for ${date} is gone`);448 return pushed;449 }450 return await this.generate(this.emergencyParams(date, seed));451 };452 }453454 private statePath(): string {455 return this.dir + "daily.json";456 }457458 private async saveState(): Promise<void> {459 await Deno.writeTextFile(this.statePath(), JSON.stringify(this.current));460 }461462 /** Today's date by the configured rollover. */463 today(): string {464 return dateFor(this.now(), this.cfg);465 }466467 /**468 * On boot: resume today's game from its log if this process ran it469 * before (the field comes back from the same source it started from),470 * or roll over and start. Then tick every minute.471 */472 async boot(): Promise<void> {473 try {474 this.current = JSON.parse(await Deno.readTextFile(this.statePath()));475 } catch {476 this.current = null;477 }478 if (this.current && this.current.date === this.today()) {479 const cur = this.current;480 if (cur.source === "emergency") this.emergency = cur.date;481 const p = paramsFor(cur.date, cur.seed, this.cfg);482 const ref = await resumeGame(483 cur.game_id,484 p,485 this.generatorFor(cur.date, cur.seed, cur.source ?? "pushed"),486 this.now,487 this.judgeFn,488 );489 if (ref) {490 this.log(491 `resumed ${cur.game_id} at revision ${ref.revision} (${ref.state.agents.length} agents)`,492 );493 } else {494 this.log(`no live log for ${cur.game_id}; starting fresh`);495 this.current = null;496 }497 }498 await this.tick();499 }500501 start(): void {502 if (this.timer !== null) return;503 this.timer = setInterval(() => {504 this.tick().catch((e) => this.log(`tick failed: ${e}`));505 }, 60 * 1000);506 Deno.unrefTimer(this.timer);507 }508509 stop(): void {510 if (this.timer !== null) clearInterval(this.timer);511 this.timer = null;512 }513514 /** One scheduler step (serialised): rollover if the date changed, start515 * today's game if none. */516 tick(): Promise<void> {517 if (this.ticking) return this.ticking;518 this.ticking = this.tickOnce().finally(() => {519 this.ticking = null;520 });521 return this.ticking;522 }523524 private async tickOnce(): Promise<void> {525 const today = this.today();526 if (this.current && this.current.date !== today) {527 await this.rollover(this.current);528 this.current = null;529 }530 if (!this.current || !getGame(this.current.game_id)) {531 await this.startDay(today);532 }533 }534535 /** Honours for a day: the previous day's top user, seated on the new536 * field's first seed tile with a reserved hue. */537 private honoursFor(date: string, seedTile: number): Honour[] {538 const prev = this.index.list().filter((r) => r.date < date).at(-1);539 if (!prev) return [];540 const { user } = winnersOf(prev.boards);541 if (!user) return [];542 return [{543 agent: user.key,544 name: user.name ?? user.key,545 model: user.models[0] ?? null,546 user: user.key,547 hue: 0,548 seed_tile: seedTile,549 day: prev.date,550 reward: user.reward,551 }];552 }553554 private async startDay(date: string): Promise<void> {555 // the field first: honours need a seat on the actual field556 this.generating = { date, since: this.now() };557 let field: Awaited<ReturnType<typeof this.fieldFor>>;558 try {559 field = await this.fieldFor(date);560 } finally {561 this.generating = null;562 }563 if (!field) {564 if (this.missing !== date) {565 this.log(`no field for ${date}: nothing pushed and generation is off`);566 }567 this.missing = date;568 return;569 }570 const { out, source, seed } = field;571 const p = paramsFor(date, seed, this.cfg);572 this.missing = null;573 this.emergency = source === "emergency" ? date : null;574 const m = out.manifest as { seed_tile: number; seed_tiles?: number[] };575 const seat = m.seed_tiles?.[0] ?? m.seed_tile;576 const honours = this.honoursFor(date, seat);577 const pending = startGame(578 { ...p, honours },579 this.generatorFor(date, seed, source, out),580 this.now,581 this.judgeFn,582 );583 this.generating = { date, since: this.now() };584 try {585 await whenReady(pending.game_id);586 } finally {587 this.generating = null;588 }589 this.current = { date, game_id: pending.game_id, seed, source };590 await this.saveState();591 this.log(592 `started ${pending.game_id} for ${date} (${source} field, ${593 (out.manifest as { tiles: unknown[] }).tiles.length594 } tiles, ${out.rings?.length ?? 0} rings ahead)` +595 (honours.length ? `, honouring ${honours[0].name}` : ""),596 );597 }598599 /** End, archive and index a day's game. */600 async rollover(cur: DailyState): Promise<DayRow | null> {601 const ref = getGame(cur.game_id);602 if (ref) await endGame(cur.game_id);603 const text = ref ? ref.log.text() : await readLog(cur.game_id);604 if (text === null) {605 this.log(`rollover: no log for ${cur.game_id}`);606 dropGame(cur.game_id);607 return null;608 }609 const events = parseLog(text);610 const s = fold(events);611 const boards = boardsOf(s);612 const { user, model } = winnersOf(boards);613 const manifest = (events[0] as ManifestEvent).manifest as {614 seed_commit?: string;615 hash_key_commit?: string;616 hash_scheme?: string;617 };618 // the private side, released with the record: the referee's field if619 // it is still up, else the pushed file (an emergency field that is620 // gone with the process keeps its seed in daily.json only)621 const priv = ref?.gen ?? (await loadPushed(this.dir, cur.date)) ?? null;622 const keys: DayKeys = {623 scheme: manifest.hash_scheme ?? "mosaic-tile-v1",624 seed_commit: manifest.seed_commit ?? "",625 day_seed: priv?.day_seed ??626 (cur.source === "emergency" ? cur.seed : null),627 hash_key_commit: manifest.hash_key_commit ?? null,628 hash_key: priv?.hash_key ?? null,629 };630 const ended = events.at(-1)?.kind === "end" ? events.at(-1)!.t : null;631 const row: DayRow = {632 date: cur.date,633 game_id: cur.game_id,634 seed_commit: manifest.seed_commit ?? "",635 stats: {636 tiles: s.tiles.length,637 solved: s.solvedTiles,638 cells: s.solvedCells,639 agents: s.agents.length,640 revisions: s.revision,641 guess_rate: s.analysis.guess_rate,642 grown: s.grown,643 ended,644 completed: s.tiles.every((t) => t === "solved"),645 },646 boards,647 winner_user: user?.key ?? null,648 winner_model: model?.key ?? null,649 };650 const dir = `${this.dir}days/${cur.date}/`;651 await Deno.mkdir(dir, { recursive: true });652 await Deno.writeTextFile(dir + "log.jsonl", text);653 await Deno.writeTextFile(654 dir + "manifest.json",655 JSON.stringify((events[0] as ManifestEvent).manifest),656 );657 await Deno.writeTextFile(658 dir + "summary.json",659 JSON.stringify(660 {661 ...row,662 keys,663 agents: s.agents,664 meets: s.meets,665 analysis: s.analysis,666 cumulative: mergeBoards([667 ...this.index.list().map((r) => r.boards),668 boards,669 ]),670 },671 null,672 1,673 ),674 );675 await Deno.writeTextFile(dir + "keys.json", JSON.stringify(keys, null, 1));676 this.index.upsert(row);677 dropGame(cur.game_id);678 this.log(679 `archived ${cur.date}: ${row.stats.solved}/${row.stats.tiles} tiles, winner ${680 row.winner_user ?? "-"681 } / ${row.winner_model ?? "-"}`,682 );683 return row;684 }685686 /** The released keys of an archived day, if any. */687 async keysOf(date: string): Promise<DayKeys | null> {688 if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;689 try {690 return JSON.parse(691 await Deno.readTextFile(`${this.dir}days/${date}/keys.json`),692 );693 } catch {694 return null;695 }696 }697698 /**699 * Wipe a day's play and start it again from the same field: the700 * running game ends without being archived (its log moves to701 * `<game>.reset-<n>.jsonl`, kept but outside the calendar and the702 * boards), the day's index row and archive go, and the day restarts at703 * revision 0 from the same pushed field, seed, rings and honours, so704 * agents can join again with clean health.705 */706 async reset(date: string): Promise<{ game_id: string; reset: number }> {707 if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error("bad date");708 const gameId = gameIdFor(date, this.cfg);709 const cur = this.current?.date === date ? this.current : null;710 const n = (cur?.reset ?? 0) + 1;711 if (getGame(gameId)) {712 // end it under the write lock (any growth in flight lands first),713 // then forget it; the log is set aside as it stands, replayable714 await endGame(gameId);715 dropGame(gameId);716 }717 // set the log aside (nothing is lost, nothing is archived)718 try {719 await Deno.rename(720 logPath(gameId),721 `${gamesDir()}${gameId}.reset-${n}.jsonl`,722 );723 } catch (e) {724 this.log(`reset ${date}: no log to set aside (${e})`);725 }726 this.index.delete(date);727 try {728 await Deno.remove(`${this.dir}days/${date}/`, { recursive: true });729 } catch {730 // not archived731 }732 this.current = null;733 if (date === this.today()) {734 await this.startDay(date);735 if (this.current) {736 (this.current as DailyState).reset = n;737 await this.saveState();738 }739 }740 this.log(`reset ${date} (${n}): ${gameId} restarted from its field`);741 return { game_id: gameId, reset: n };742 }743744 /** What `/` should show right now. */745 status(): DailyStatus {746 const cur = this.current;747 const tomorrow = nextDate(this.today());748 let pushed = false;749 try {750 Deno.statSync(pushedPath(this.dir, tomorrow));751 pushed = true;752 } catch {753 // not yet754 }755 const live = cur && getGame(cur.game_id) ? cur.game_id : null;756 const latest = this.index.list().at(-1) ?? null;757 const today = this.today();758 let phase: DailyStatus["phase"] = "live";759 if (!live) phase = this.generating ? "generating" : "missing";760 return {761 date: cur?.date ?? this.generating?.date ?? today,762 phase,763 live,764 since_s: this.generating765 ? Math.round((this.now() - this.generating.since) / 1000)766 : null,767 emergency: live !== null && this.emergency === cur?.date,768 latest_archived: latest769 ? { date: latest.date, game_id: latest.game_id }770 : null,771 tomorrow: { date: tomorrow, pushed },772 reset: cur?.reset ?? 0,773 };774 }775776 /** Cumulative boards across every archived day. */777 cumulative(): ReturnType<typeof mergeBoards> {778 return mergeBoards(this.index.list().map((r) => r.boards));779 }780}781782// One daily per process, on globalThis so a dev-server module reload keeps it.783const g = globalThis as unknown as { __mosaicDaily?: Daily };784785export function daily(): Daily | null {786 return g.__mosaicDaily ?? null;787}788789/** Start the daily loop when MOSAIC_DAILY=1 (idempotent). */790export async function startDaily(): Promise<Daily | null> {791 if (Deno.env.get("MOSAIC_DAILY") !== "1") return null;792 if (g.__mosaicDaily) return g.__mosaicDaily;793 const d = new Daily();794 g.__mosaicDaily = d;795 await d.boot();796 d.start();797 return d;798}