The hosted daily starts a day from data/fields/<date>.json, uploaded by 'deno task push-day <date>' (generated on a machine that can afford the engine; POST /push/<date> with the MOSAIC_PUSH_TOKEN bearer). The engine runs on the box only with MOSAIC_GENERATE=1 (off in fly.toml): no nightly generation, no on-demand growth past the pushed rings. A day with nothing pushed is reported on / as 'no field for <date> yet'.
- commit
- 6133b0f54f8dafa9c80e2d669715951b65a22eb2
- parent
- 78069c3d474e3fe9754faa542944447042bc52cf
- author
- dami <gazey55@gmail.com>
| M | fly.toml | 3 | +- |
| M | site/ | 3 | +- |
| M | site/ | 80 | ++++++++++++++++++++++++++++-- |
| M | site/ | 14 | +++++ |
| M | site/ | 8 | +++ |
| A | site/ | 49 | ++++++++++++++++++ |
| A | site/ | 75 | ++++++++++++++++++++++++++++ |
| M | site/ | 44 | +++++++++++++++++ |
@@ -10,9 +10,10 @@ primary_region = 'ams'
1010 dockerfile = 'Dockerfile'1111 1212 [env]13+ # no generation on this machine: fields are pushed (deno task push-day)14+ MOSAIC_GENERATE = '0'1315 MOSAIC_DAILY = '1'1416 MOSAIC_DAILY_GROW_MINUTES = '60'15- MOSAIC_DAILY_RINGS = '3'1617 MOSAIC_DATA = '/data/'1718 MOSAIC_PUBLIC = '1'1819 MOSAIC_PUBLIC_URL = 'https://mosaic-daily.fly.dev'@@ -7,7 +7,8 @@
77 "build": "vite build",88 "start": "deno serve -A --host 127.0.0.1 --port 8000 _fresh/server.js",99 "engine": "cd ../engine && go build -o bin/mosaic-engine .",10- "solve": "deno run -A scripts/solve.ts"10+ "solve": "deno run -A scripts/solve.ts",11+ "push-day": "deno run -A scripts/push-day.ts"1112 },1213 "lint": {1314 "rules": {@@ -15,7 +15,13 @@ import {
1515 type StartParams,1616 whenReady,1717 } from "./games.ts";18-import { gen, type GenParams, judge } from "./engine.ts";18+import {19+ type FieldOutput,20+ gen,21+ type GenParams,22+ judge,23+ unwrapField,24+} from "./engine.ts";1925 import type { Judge } from "./referee.ts";2026 import { dataDir, readLog } from "./log.ts";2127 import { fold, type Honour, type ManifestEvent, parseLog } from "./fold.ts";@@ -35,6 +41,9 @@ export type DailyConfig = {
3541 grow_minutes: number;3642 rollover_hour_utc: number; // the day changes at this UTC hour3743 fog: boolean;44+ /** run the engine on this box (off on the hosted machine: fields are45+ * pushed from a machine that can afford the generator) */46+ generate: boolean;3847 };3948 4049 export function configFromEnv(): DailyConfig {@@ -54,9 +63,35 @@ export function configFromEnv(): DailyConfig {
5463 grow_minutes: num("MOSAIC_DAILY_GROW_MINUTES", 60),5564 rollover_hour_utc: Number(Deno.env.get("MOSAIC_DAILY_ROLLOVER_UTC") ?? 0),5665 fog: (Deno.env.get("MOSAIC_DAILY_FOG") ?? "1") !== "0",66+ generate: Deno.env.get("MOSAIC_GENERATE") === "1",5767 };5868 }5969 70+/** Where pushed fields live: <data>/fields/<date>.json, the engine's71+ * `field --rings` output (base + rings) as produced by `push-day`. */72+export function fieldsDir(dir: string): string {73+ return dir + "fields/";74+}75+76+export function pushedPath(dir: string, date: string): string {77+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error("bad date");78+ return fieldsDir(dir) + date + ".json";79+}80+81+/** A pushed field for the date, if one was uploaded. */82+export async function loadPushed(83+ dir: string,84+ date: string,85+): Promise<FieldOutput | null> {86+ try {87+ return unwrapField(88+ JSON.parse(await Deno.readTextFile(pushedPath(dir, date))),89+ );90+ } catch {91+ return null;92+ }93+}94+6095 /** The ISO date of the day a moment belongs to (days roll at rollover_hour_utc). */6196 export function dateFor(nowMs: number, cfg: DailyConfig): string {6297 const shifted = new Date(nowMs - cfg.rollover_hour_utc * 3600 * 1000);@@ -229,6 +264,8 @@ export class Daily {
229264 private judgeFn: Judge;230265 private log: (msg: string) => void;231266 current: DailyState | null = null;267+ /** the date that has no field (nothing pushed, generation off) */268+ missing: string | null = null;232269 private preparing: string | null = null;233270 private prepared = new Set<string>();234271 private timer: number | null = null;@@ -243,9 +280,34 @@ export class Daily {
243280 this.judgeFn = opts.judge ?? judge;244281 this.log = opts.log ?? ((m) => console.log(JSON.stringify({ daily: m })));245282 Deno.mkdirSync(this.dir + "days/", { recursive: true });283+ Deno.mkdirSync(fieldsDir(this.dir), { recursive: true });246284 this.index = new DaysIndex(this.dir + "days.sqlite");247285 }248286 287+ /** Whether this box may run the engine (tests inject a generator). */288+ private get canGenerate(): boolean {289+ return this.cfg.generate || this.generate !== gen;290+ }291+292+ /** The field for a date: pushed file first, then the generator when293+ * allowed, else null. */294+ private async fieldFor(295+ date: string,296+ params: GenParams,297+ ): Promise<FieldOutput | null> {298+ const pushed = await loadPushed(this.dir, date);299+ if (pushed) return pushed;300+ if (!this.canGenerate) return null;301+ return await this.generate(params);302+ }303+304+ /** A generator for the registry that serves the pushed file when there305+ * is one (so resume and start read the same field). */306+ private generatorFor(date: string): Generator {307+ return async (params: GenParams) =>308+ (await loadPushed(this.dir, date)) ?? await this.generate(params);309+ }310+249311 private statePath(): string {250312 return this.dir + "daily.json";251313 }@@ -275,7 +337,7 @@ export class Daily {
275337 const ref = await resumeGame(276338 this.current.game_id,277339 p,278- this.generate,340+ this.generatorFor(this.current.date),279341 this.now,280342 this.judgeFn,281343 );@@ -323,7 +385,7 @@ export class Daily {
323385 if (!this.current || !getGame(this.current.game_id)) {324386 await this.startDay(today);325387 }326- this.prepare(nextDate(today));388+ if (this.canGenerate) this.prepare(nextDate(today));327389 }328390 329391 /** Generate a day's field ahead of time (cached; idempotent). */@@ -388,14 +450,22 @@ export class Daily {
388450 field_g: p.field_g,389451 rings: p.rings,390452 };391- const out = await this.generate(params);453+ const out = await this.fieldFor(date, params);454+ if (!out) {455+ if (this.missing !== date) {456+ this.log(`no field for ${date}: nothing pushed and generation is off`);457+ }458+ this.missing = date;459+ return;460+ }461+ this.missing = null;392462 this.prepared.add(date);393463 const m = out.manifest as { seed_tile: number; seed_tiles?: number[] };394464 const seat = m.seed_tiles?.[0] ?? m.seed_tile;395465 const honours = this.honoursFor(date, seat);396466 const pending = startGame(397467 { ...p, honours },398- this.generate,468+ this.generatorFor(date),399469 this.now,400470 this.judgeFn,401471 );@@ -257,6 +257,12 @@ export function setGrower(g: Grower): void {
257257 grower = g;258258 }259259 260+/** Whether this box may run the engine for growth when no pre-generated261+ * ring is stored (off on the hosted machine; tests set a grower). */262+function growthAllowed(): boolean {263+ return grower !== engineGrow || Deno.env.get("MOSAIC_GENERATE") === "1";264+}265+260266 /**261267 * Growth policy: a timer (`every_minutes`), a coverage threshold checked262268 * after every write (`frontier_coverage`: share of the current tiles@@ -300,6 +306,14 @@ export async function growGame(id: string): Promise<boolean> {
300306 return false;301307 }302308 if (e.ref.state.status === "solved") return false;309+ if (!e.rings?.length && !growthAllowed()) {310+ // nothing pre-generated is left and this box may not generate: the311+ // field is as big as it gets312+ e.exhausted = true;313+ if (e.timer !== null) clearInterval(e.timer);314+ e.timer = null;315+ return false;316+ }303317 e.growing = true;304318 try {305319 const ring = e.ref.state.grown + 1;@@ -14,6 +14,7 @@ export default define.page(function Home(ctx) {
1414 const d = daily();1515 const cur = d?.current ?? null;1616 const liveId = !replay && cur && getGame(cur.game_id) ? cur.game_id : null;17+ const missing = !replay && d?.missing && !liveId ? d.missing : null;1718 const origin = ctx.url.origin;1819 const prompt = bootstrapText(origin, cur?.date ?? null);1920 return (@@ -32,6 +33,13 @@ export default define.page(function Home(ctx) {
3233 <span style="flex:1" />3334 <CopyPrompt text={prompt} />3435 </nav>36+ {missing && (37+ <div style="padding:8px 12px;color:var(--muted);font-size:12px;border-bottom:1px solid var(--rule)">38+ no field for {missing}{" "}39+ yet: today's field has not been pushed to this server. Past days:{" "}40+ <a href="/calendar">calendar</a>.41+ </div>42+ )}3543 <div style="flex:1;min-height:0">3644 <Viewer replay={replay} theme={theme} live={liveId} />3745 </div>@@ -0,0 +1,49 @@
1+// A pushed field: the engine's `field --rings` output for a date, uploaded2+// by `deno task push-day <date>` from a machine that can afford the3+// generator. Bearer token in MOSAIC_PUSH_TOKEN; the daily starts the day4+// from it (at once, if that day was missing).5+import { define } from "../../utils.ts";6+import { daily, pushedPath } from "../../lib/daily.ts";7+import { dataDir } from "../../lib/log.ts";8+import { unwrapField } from "../../lib/engine.ts";9+10+export const handler = define.handlers({11+ async POST(ctx) {12+ const token = Deno.env.get("MOSAIC_PUSH_TOKEN");13+ const auth = ctx.req.headers.get("Authorization") ?? "";14+ if (!token || auth !== `Bearer ${token}`) {15+ return Response.json({ error: "unauthorised" }, { status: 401 });16+ }17+ const date = ctx.params.date;18+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {19+ return Response.json({ error: "bad date" }, { status: 400 });20+ }21+ const text = await ctx.req.text();22+ let tiles = 0, rings = 0;23+ try {24+ const out = unwrapField(JSON.parse(text));25+ const m = out.manifest as { tiles?: unknown[]; version?: number };26+ if (!Array.isArray(m.tiles) || m.version !== 3) {27+ throw new Error("not a v3 field");28+ }29+ tiles = m.tiles.length;30+ rings = out.rings?.length ?? 0;31+ } catch (e) {32+ return Response.json({ error: `not a field: ${e}` }, { status: 400 });33+ }34+ const d = daily();35+ const dir = d?.dir ?? dataDir();36+ const path = pushedPath(dir, date);37+ await Deno.mkdir(path.slice(0, path.lastIndexOf("/")), { recursive: true });38+ await Deno.writeTextFile(path, text);39+ if (d) await d.tick();40+ return Response.json({41+ ok: true,42+ date,43+ tiles,44+ rings,45+ bytes: text.length,46+ started: d?.current?.date === date ? d.current.game_id : null,47+ });48+ },49+});@@ -0,0 +1,75 @@
1+// Generate a day's field on this machine and push it to the hosted daily:2+//3+// deno task push-day 2026-08-28 [--host https://mosaic-daily.fly.dev]4+// [--chunks 3] [--chunk-g 60] [--G 1000] [--rings 2] [--namespace mosaic-daily]5+//6+// The token comes from MOSAIC_PUSH_TOKEN or ~/.config/mosaic/push-token.7+// The field parameters must match the hosted daily's configuration (its8+// seed is derived from the namespace and the date, so the same date9+// always yields the same field).10+import { gen } from "../lib/engine.ts";11+import { seedFor } from "../lib/daily.ts";12+13+const args = Object.fromEntries(14+ Deno.args.map((a, i, all) =>15+ a.startsWith("--") ? [a.slice(2), all[i + 1] ?? ""] : []16+ ).filter((p) => p.length),17+);18+const date = Deno.args.find((a) => /^\d{4}-\d{2}-\d{2}$/.test(a));19+if (!date) {20+ console.error(21+ "usage: deno task push-day YYYY-MM-DD [--host URL] [--rings N]",22+ );23+ Deno.exit(2);24+}25+const host = (args.host ?? "https://mosaic-daily.fly.dev").replace(/\/$/, "");26+const cfg = {27+ namespace: args.namespace ?? "mosaic-daily",28+ chunks: Number(args.chunks ?? 3),29+ chunk_g: Number(args["chunk-g"] ?? 60),30+ G: Number(args.G ?? 1000),31+ rings: args.rings === "all" ? "all" as const : Number(args.rings ?? 2),32+ grow_minutes: 60,33+ rollover_hour_utc: 0,34+ fog: true,35+ generate: true,36+};37+async function token(): Promise<string> {38+ const env = Deno.env.get("MOSAIC_PUSH_TOKEN");39+ if (env) return env;40+ const home = Deno.env.get("HOME") ?? "";41+ return (await Deno.readTextFile(`${home}/.config/mosaic/push-token`)).trim();42+}43+const seed = await seedFor(date, cfg);44+console.log(45+ `generating ${date} (seed ${seed}, ${cfg.chunks}x${cfg.chunks} of ${cfg.chunk_g}, ${cfg.rings} rings ahead on G=${cfg.G})`,46+);47+const t0 = Date.now();48+const out = await gen({49+ seed,50+ G: cfg.G,51+ kind: "field",52+ chunks: cfg.chunks,53+ chunk_g: cfg.chunk_g,54+ field_g: cfg.G,55+ rings: cfg.rings,56+});57+const { rings = [], ...base } = out;58+const body = JSON.stringify({ base, rings });59+console.log(60+ `generated in ${Math.round((Date.now() - t0) / 1000)} s: ${61+ (base.manifest as { tiles: unknown[] }).tiles.length62+ } tiles + ${rings.length} rings, ${63+ (body.length / 1e6).toFixed(1)64+ } MB; pushing to ${host}`,65+);66+const res = await fetch(`${host}/push/${date}`, {67+ method: "POST",68+ headers: {69+ "Authorization": `Bearer ${await token()}`,70+ "Content-Type": "application/json",71+ },72+ body,73+});74+console.log(res.status, await res.text());75+if (!res.ok) Deno.exit(1);@@ -7,6 +7,7 @@ import { Daily, type DailyConfig, dateFor, nextDate } from "../lib/daily.ts";
77 import { getGame, resetGames } from "../lib/games.ts";88 import type { FieldOutput, GenOutput, GenParams } from "../lib/engine.ts";99 import { fold, frontierOf, parseLog } from "../lib/fold.ts";10+import { pushedPath } from "../lib/daily.ts";1011 import { readLog } from "../lib/log.ts";1112 import { boardsOf } from "../lib/leaderboard.ts";1213 import type { Referee } from "../lib/referee.ts";@@ -30,6 +31,7 @@ const cfg: DailyConfig = {
3031 grow_minutes: 1440, // never during the test3132 rollover_hour_utc: 0,3233 fog: true,34+ generate: false, // the injected generator stands in for a pushed field3335 };3436 3537 /** A synthetic field per seed: ~20 tiles in the middle of a 140 canvas. */@@ -178,3 +180,45 @@ Deno.test("two rollovers: archive, winners, honours, replay, resume", async () =
178180 d.stop();179181 resetGames();180182 });183+184+Deno.test("a day with nothing pushed and generation off is missing; a push starts it", async () => {185+ resetGames();186+ const dir = await Deno.makeTempDir() + "/";187+ let now = Date.parse("2026-10-01T09:00:00Z");188+ const logs: string[] = [];189+ // the real generator (gen) with generate=false: the daily must not call it190+ const d = new Daily({191+ cfg,192+ dir,193+ now: () => now,194+ judge: stubJudge,195+ log: (m) => logs.push(m),196+ });197+ await d.boot();198+ assertEquals(d.current, null);199+ assertEquals(d.missing, "2026-10-01");200+ assert(logs.some((m) => m.startsWith("no field for 2026-10-01")));201+ // a push lands the day's field; the next tick starts it202+ const out = await generate({ seed: "pushed", G: cfg.G, kind: "field" });203+ await Deno.mkdir(dir + "fields", { recursive: true });204+ await Deno.writeTextFile(205+ pushedPath(dir, "2026-10-01"),206+ JSON.stringify({ base: out, rings: [] }),207+ );208+ await d.tick();209+ assert(d.current && d.current.date === "2026-10-01", "started from the push");210+ assertEquals(d.missing, null);211+ const g = getGame(d.current!.game_id)!;212+ assertEquals(213+ g.manifest.tiles.length,214+ (out.manifest as { tiles: unknown[] }).tiles.length,215+ );216+ // rollover with nothing pushed for the next day: archived, then missing217+ now = Date.parse("2026-10-02T01:00:00Z");218+ await d.tick();219+ assertEquals(d.index.list().map((r) => r.date), ["2026-10-01"]);220+ assertEquals(d.current, null);221+ assertEquals(d.missing, "2026-10-02");222+ d.stop();223+ resetGames();224+});