clone

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

daily: pushed fields, no generation on the hosted machine

dami committed 6133b0f
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>
8 files changed, +269 -7
Mfly.toml3+-
Msite/deno.json3+-
Msite/lib/daily.ts80++++++++++++++++++++++++++++--
Msite/lib/games.ts14+++++
Msite/routes/index.tsx8+++
Asite/routes/push/[date].ts49++++++++++++++++++
Asite/scripts/push-day.ts75++++++++++++++++++++++++++++
Msite/tests/daily_test.ts44+++++++++++++++++
M fly.toml +2 -1
M site/deno.json +2 -1
M site/lib/daily.ts +75 -5
@@ -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 );
M site/lib/games.ts +14 -0
M site/routes/index.tsx +8 -0
A site/routes/push/[date].ts +49 -0
@@ -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+});
A site/scripts/push-day.ts +75 -0
@@ -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);
M site/tests/daily_test.ts +44 -0
@@ -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+});