clone

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

daily: nightly prepare on the box under a supervised budget; always a field at rollover

dami committed e187ece
MOSAIC_GENERATE=1 on Fly: from 20:00 UTC the daily generates tomorrow's
field (field --rings all --jobs 2) as a child process the loop
supervises — killed at rollover if it overruns (setsid group kill),
logged, the site serving throughout; at rollover the day starts from
the prepared field, else the last pushed one, else a small emergency
field generated on the spot (3x3, one ring) and said so on /. On-demand
growth stays off unless MOSAIC_GROW_ON_DEMAND=1. The field's source is
recorded so a restart resumes from the same one.
commit
e187ece13a2746ac59e369f60c9951768e2e3bf7
parent
7ecd3461834e18203091c78776188e699d5abbf1
author
dami <gazey55@gmail.com>
7 files changed, +399 -75
Mfly.toml8+-
Msite/lib/daily.ts279++++++++++++++++++++++++------
Msite/lib/engine.ts103++++++++++-
Msite/lib/games.ts9+-
Msite/routes/index.tsx7+
Msite/scripts/push-day.ts4+
Msite/tests/daily_test.ts64+++++++
M fly.toml +6 -2
M site/lib/daily.ts +220 -59
@@ -19,7 +19,9 @@ import {
1919 type FieldOutput,2020 gen,2121 type GenParams,22+ isCached,2223 judge,24+ setEngineLog,2325 unwrapField,2426 } from "./engine.ts";2527 import type { Judge } from "./referee.ts";
@@ -41,9 +43,14 @@ export type DailyConfig = {
4143 grow_minutes: number;4244 rollover_hour_utc: number; // the day changes at this UTC hour4345 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+ /** run the engine on this box (fields can also be pushed) */4647 generate: boolean;48+ /** the nightly prepare of tomorrow's field starts at this UTC hour and49+ * must finish by rollover (its deadline); jobs = chunk processes at once */50+ prepare_hour_utc: number;51+ jobs: number;52+ /** the emergency field generated at rollover when nothing is ready */53+ emergency: { chunks: number; chunk_g: number; rings: number };4754 };4855 4956 export function configFromEnv(): DailyConfig {
@@ -64,9 +71,37 @@ export function configFromEnv(): DailyConfig {
6471 rollover_hour_utc: Number(Deno.env.get("MOSAIC_DAILY_ROLLOVER_UTC") ?? 0),6572 fog: (Deno.env.get("MOSAIC_DAILY_FOG") ?? "1") !== "0",6673 generate: Deno.env.get("MOSAIC_GENERATE") === "1",74+ prepare_hour_utc: Number(Deno.env.get("MOSAIC_DAILY_PREPARE_UTC") ?? 20),75+ jobs: num("MOSAIC_DAILY_JOBS", 2),76+ emergency: { chunks: 3, chunk_g: 60, rings: 1 },6777 };6878 }6979 80+/** The moment the day rolls over after `nowMs` (UTC). */81+export function nextRollover(nowMs: number, cfg: DailyConfig): number {82+ const d = new Date(nowMs);83+ const today = Date.UTC(84+ d.getUTCFullYear(),85+ d.getUTCMonth(),86+ d.getUTCDate(),87+ cfg.rollover_hour_utc,88+ );89+ return today > nowMs ? today : today + 24 * 3600 * 1000;90+}91+92+/** When the nightly prepare for the field after `nowMs` starts: the93+ * prepare hour of the current (rollover-shifted) day, or now if that94+ * has passed. */95+export function prepareAt(nowMs: number, cfg: DailyConfig): number {96+ const roll = nextRollover(nowMs, cfg);97+ // the day that rolls over at `roll` started 24 h earlier98+ const dayStart = roll - 24 * 3600 * 1000;99+ const at = dayStart +100+ (((cfg.prepare_hour_utc - cfg.rollover_hour_utc) % 24 + 24) % 24) *101+ 3600 * 1000;102+ return at;103+}104+70105 /** Where pushed fields live: <data>/fields/<date>.json, the engine's71106 * `field --rings` output (base + rings) as produced by `push-day`. */72107 export function fieldsDir(dir: string): string {
@@ -244,7 +279,13 @@ function parseRow(r: Record<string, string>): DayRow {
244279 };245280 }246281 247-export type DailyState = { date: string; game_id: string; seed: string };282+export type DailyState = {283+ date: string;284+ game_id: string;285+ seed: string;286+ /** where the day's field came from; resume reads the same source */287+ source: "prepared" | "pushed" | "emergency";288+};248289 249290 type Opts = {250291 cfg?: DailyConfig;
@@ -260,14 +301,22 @@ export class Daily {
260301 readonly cfg: DailyConfig;261302 readonly dir: string;262303 readonly now: () => number;263- private generate: Generator;304+ private generate: (305+ p: GenParams,306+ opts?: { timeoutMs?: number },307+ ) => Promise<FieldOutput>;264308 private judgeFn: Judge;265309 private log: (msg: string) => void;266310 current: DailyState | null = null;267311 /** the date that has no field (nothing pushed, generation off) */268312 missing: string | null = null;313+ /** the date whose field is a small emergency one, generated at rollover */314+ emergency: string | null = null;269315 private preparing: string | null = null;270316 private prepared = new Set<string>();317+ private preparedSeeds = new Set<string>();318+ private failedPrepare = new Set<string>();319+ private lastPrepareLog = "";271320 private timer: number | null = null;272321 private ticking: Promise<void> | null = null;273322 index: DaysIndex;
@@ -279,6 +328,9 @@ export class Daily {
279328 this.generate = opts.generate ?? gen;280329 this.judgeFn = opts.judge ?? judge;281330 this.log = opts.log ?? ((m) => console.log(JSON.stringify({ daily: m })));331+ if (!opts.log) {332+ setEngineLog((l) => console.log(JSON.stringify({ engine: l })));333+ }282334 Deno.mkdirSync(this.dir + "days/", { recursive: true });283335 Deno.mkdirSync(fieldsDir(this.dir), { recursive: true });284336 this.index = new DaysIndex(this.dir + "days.sqlite");
@@ -289,23 +341,105 @@ export class Daily {
289341 return this.cfg.generate || this.generate !== gen;290342 }291343 292- /** The field for a date: pushed file first, then the generator when293- * allowed, else null. */344+ private genParams(345+ _date: string,346+ seed: string,347+ rings: number | "all",348+ chunks: number,349+ chunkG: number,350+ ): GenParams {351+ return {352+ seed,353+ G: this.cfg.G,354+ kind: "field",355+ chunks,356+ chunk_g: chunkG,357+ field_g: this.cfg.G,358+ rings,359+ jobs: this.cfg.jobs,360+ };361+ }362+363+ /**364+ * The field for a date, in the order that always yields one when this365+ * box may generate: the prepared field (in the cache from the nightly366+ * job), else a pushed file, else a small emergency field generated now367+ * (3x3, one ring, under a short budget), which is said on `/`.368+ */294369 private async fieldFor(295370 date: string,296- params: GenParams,297- ): Promise<FieldOutput | null> {371+ seed: string,372+ ): Promise<{ out: FieldOutput; source: DailyState["source"] } | null> {373+ const full = this.genParams(374+ date,375+ seed,376+ this.cfg.rings,377+ this.cfg.chunks,378+ this.cfg.chunk_g,379+ );380+ if (this.canGenerate && await this.isPrepared(full)) {381+ return { out: await this.generate(full), source: "prepared" };382+ }298383 const pushed = await loadPushed(this.dir, date);299- if (pushed) return pushed;384+ if (pushed) return { out: pushed, source: "pushed" };300385 if (!this.canGenerate) return null;301- return await this.generate(params);386+ const e = this.cfg.emergency;387+ this.log(388+ `${date}: nothing prepared or pushed; generating an emergency field (${e.chunks}x${e.chunks} of ${e.chunk_g}, ${e.rings} ring)`,389+ );390+ const t0 = this.now();391+ const out = await this.generate(this.emergencyParams(date, seed), {392+ timeoutMs: 40 * 60 * 1000,393+ });394+ this.log(395+ `${date}: emergency field ready in ${396+ Math.round((this.now() - t0) / 1000)397+ } s`,398+ );399+ return { out, source: "emergency" };400+ }401+402+ private emergencyParams(date: string, seed: string): GenParams {403+ const e = this.cfg.emergency;404+ return this.genParams(date, seed, e.rings, e.chunks, e.chunk_g);405+ }406+407+ /** Whether the full field for these params is in the cache (tests'408+ * generators are synchronous stand-ins: treat them as prepared). */409+ private async isPrepared(p: GenParams): Promise<boolean> {410+ if (this.generate !== gen) return this.preparedSeeds.has(p.seed);411+ return await isCached(p);302412 }303413 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);414+ /** A generator for the registry that returns the field this date415+ * started from, whatever params the registry passes: the prepared416+ * cache, the pushed file, or the emergency field (all cache hits). */417+ private generatorFor(418+ date: string,419+ seed: string,420+ source: DailyState["source"],421+ out?: FieldOutput,422+ ): Generator {423+ return async () => {424+ if (out) return out;425+ if (source === "pushed") {426+ const pushed = await loadPushed(this.dir, date);427+ if (!pushed) throw new Error(`pushed field for ${date} is gone`);428+ return pushed;429+ }430+ if (source === "emergency") {431+ return await this.generate(this.emergencyParams(date, seed));432+ }433+ return await this.generate(434+ this.genParams(435+ date,436+ seed,437+ this.cfg.rings,438+ this.cfg.chunks,439+ this.cfg.chunk_g,440+ ),441+ );442+ };309443 }310444 311445 private statePath(): string {
@@ -323,8 +457,8 @@ export class Daily {
323457 324458 /**325459 * On boot: resume today's game from its log if this process ran it326- * before (the private output comes back from the generator cache), or327- * roll over and start. Then tick every minute.460+ * before (the field comes back from the same source it started from),461+ * or roll over and start. Then tick every minute.328462 */329463 async boot(): Promise<void> {330464 try {
@@ -333,20 +467,22 @@ export class Daily {
333467 this.current = null;334468 }335469 if (this.current && this.current.date === this.today()) {336- const p = paramsFor(this.current.date, this.current.seed, this.cfg);470+ const cur = this.current;471+ if (cur.source === "emergency") this.emergency = cur.date;472+ const p = paramsFor(cur.date, cur.seed, this.cfg);337473 const ref = await resumeGame(338- this.current.game_id,474+ cur.game_id,339475 p,340- this.generatorFor(this.current.date),476+ this.generatorFor(cur.date, cur.seed, cur.source ?? "prepared"),341477 this.now,342478 this.judgeFn,343479 );344480 if (ref) {345481 this.log(346- `resumed ${this.current.game_id} at revision ${ref.revision} (${ref.state.agents.length} agents)`,482+ `resumed ${cur.game_id} at revision ${ref.revision} (${ref.state.agents.length} agents)`,347483 );348484 } else {349- this.log(`no live log for ${this.current.game_id}; starting fresh`);485+ this.log(`no live log for ${cur.game_id}; starting fresh`);350486 this.current = null;351487 }352488 }
@@ -367,7 +503,7 @@ export class Daily {
367503 }368504 369505 /** One scheduler step (serialised): rollover if the date changed, start370- * today's game if none, prepare tomorrow's field. */506+ * today's game if none, run the nightly prepare in its window. */371507 tick(): Promise<void> {372508 if (this.ticking) return this.ticking;373509 this.ticking = this.tickOnce().finally(() => {
@@ -385,30 +521,65 @@ export class Daily {
385521 if (!this.current || !getGame(this.current.game_id)) {386522 await this.startDay(today);387523 }388- if (this.canGenerate) this.prepare(nextDate(today));524+ if (this.canGenerate) this.maybePrepare(today);389525 }390526 391- /** Generate a day's field ahead of time (cached; idempotent). */392- prepare(date: string): void {527+ /** The nightly job: from the prepare hour, generate tomorrow's field528+ * with the rest of the day as budget (killed at rollover). Logged once529+ * per state. */530+ private maybePrepare(today: string): void {531+ const tomorrow = nextDate(today);532+ const now = this.now();533+ const at = prepareAt(now, this.cfg);534+ const deadline = nextRollover(now, this.cfg);535+ if (536+ this.prepared.has(tomorrow) || this.failedPrepare.has(tomorrow) ||537+ this.preparing538+ ) return;539+ if (now < at) {540+ const msg = `next prepare: ${tomorrow} at ${541+ new Date(at).toISOString()542+ } (deadline ${543+ new Date(deadline).toISOString()544+ }, jobs ${this.cfg.jobs}, rings ${this.cfg.rings})`;545+ if (msg !== this.lastPrepareLog) {546+ this.lastPrepareLog = msg;547+ this.log(msg);548+ }549+ return;550+ }551+ this.prepare(tomorrow, Math.max(60_000, deadline - now - 5 * 60_000));552+ }553+554+ /** Generate a day's field ahead of time (cached; idempotent), under a555+ * budget; a run that overruns is killed and logged. */556+ prepare(date: string, budgetMs: number): void {393557 if (this.prepared.has(date) || this.preparing === date) return;394558 this.preparing = date;395559 (async () => {396560 const seed = await seedFor(date, this.cfg);397- const p = paramsFor(date, seed, this.cfg);398- const params: GenParams = {561+ const params = this.genParams(562+ date,399563 seed,400- G: p.G,401- kind: "field",402- chunks: p.chunks,403- chunk_g: p.chunk_g,404- field_g: p.field_g,405- rings: p.rings,406- };564+ this.cfg.rings,565+ this.cfg.chunks,566+ this.cfg.chunk_g,567+ );568+ params.timeout_s = Math.floor(budgetMs / 1000);407569 const t0 = this.now();408- await this.generate(params);570+ this.log(571+ `prepare ${date}: started, budget ${572+ Math.round(budgetMs / 60000)573+ } min, jobs ${this.cfg.jobs}, rings ${this.cfg.rings}`,574+ );575+ await this.generate(params, { timeoutMs: budgetMs });409576 this.prepared.add(date);577+ this.preparedSeeds.add(seed);410578 this.log(`prepared ${date} in ${Math.round((this.now() - t0) / 1000)} s`);411- })().catch((e) => this.log(`prepare ${date} failed: ${e}`)).finally(() => {579+ })().catch((e) => {580+ this.failedPrepare.add(date);581+ this.log(`prepare ${date} failed: ${e}`);582+ }).finally(() => {412583 if (this.preparing === date) this.preparing = null;413584 });414585 }
@@ -420,11 +591,8 @@ export class Daily {
420591 if (!prev) return [];421592 const { user } = winnersOf(prev.boards);422593 if (!user) return [];423- // the agent id they had that day, for hue continuity424- const b = prev.boards;425- const agentId = user.key;426594 return [{427- agent: agentId,595+ agent: user.key,428596 name: user.key,429597 model: user.models[0] ?? null,430598 user: user.key,
@@ -432,49 +600,42 @@ export class Daily {
432600 seed_tile: seedTile,433601 day: prev.date,434602 reward: user.reward,435- ...(b.day ? {} : {}),436603 }];437604 }438605 439606 private async startDay(date: string): Promise<void> {440607 const seed = await seedFor(date, this.cfg);441608 const p = paramsFor(date, seed, this.cfg);442- // generate (or take from the cache) first: honours need a seat on443- // the actual field444- const params: GenParams = {445- seed,446- G: p.G,447- kind: "field",448- chunks: p.chunks,449- chunk_g: p.chunk_g,450- field_g: p.field_g,451- rings: p.rings,452- };453- const out = await this.fieldFor(date, params);454- if (!out) {609+ // the field first: honours need a seat on the actual field610+ const field = await this.fieldFor(date, seed);611+ if (!field) {455612 if (this.missing !== date) {456613 this.log(`no field for ${date}: nothing pushed and generation is off`);457614 }458615 this.missing = date;459616 return;460617 }618+ const { out, source } = field;461619 this.missing = null;620+ this.emergency = source === "emergency" ? date : null;462621 this.prepared.add(date);463622 const m = out.manifest as { seed_tile: number; seed_tiles?: number[] };464623 const seat = m.seed_tiles?.[0] ?? m.seed_tile;465624 const honours = this.honoursFor(date, seat);466625 const pending = startGame(467626 { ...p, honours },468- this.generatorFor(date),627+ this.generatorFor(date, seed, source, out),469628 this.now,470629 this.judgeFn,471630 );472631 await whenReady(pending.game_id);473- this.current = { date, game_id: pending.game_id, seed };632+ this.current = { date, game_id: pending.game_id, seed, source };474633 await this.saveState();475634 this.log(476- `started ${pending.game_id} for ${date}` +477- (honours.length ? ` honouring ${honours[0].name}` : ""),635+ `started ${pending.game_id} for ${date} (${source} field, ${636+ (out.manifest as { tiles: unknown[] }).tiles.length637+ } tiles, ${out.rings?.length ?? 0} rings ahead)` +638+ (honours.length ? `, honouring ${honours[0].name}` : ""),478639 );479640 }480641
M site/lib/engine.ts +92 -11
@@ -64,9 +64,26 @@ async function sha256(s: string): Promise<string> {
6464 .join("");6565 }6666 67-async function run(args: string[], stdin?: string): Promise<string> {68- const cmd = new Deno.Command(enginePath(), {69- args,67+/** Log lines from the engine's stderr (the daily supervises generation). */68+export let engineLog: (line: string) => void = () => {};69+export function setEngineLog(f: (line: string) => void): void {70+ engineLog = f;71+}72+73+/**74+ * Run the engine as a child process. With `timeoutMs` the child (and, via75+ * setsid where available, its own chunk processes) is killed when the76+ * budget runs out and the call rejects with "timed out"; the site keeps77+ * serving throughout, since the work is in another process.78+ */79+async function run(80+ args: string[],81+ stdin?: string,82+ timeoutMs?: number,83+): Promise<string> {84+ const useSetsid = timeoutMs !== undefined && await hasSetsid();85+ const cmd = new Deno.Command(useSetsid ? "setsid" : enginePath(), {86+ args: useSetsid ? [enginePath(), ...args] : args,7087 stdin: stdin === undefined ? "null" : "piped",7188 stdout: "piped",7289 stderr: "piped",
@@ -77,15 +94,53 @@ async function run(args: string[], stdin?: string): Promise<string> {
7794 await w.write(new TextEncoder().encode(stdin));7895 await w.close();7996 }97+ let timedOut = false;98+ let timer: number | undefined;99+ if (timeoutMs !== undefined) {100+ timer = setTimeout(() => {101+ timedOut = true;102+ try {103+ // the whole process group when setsid put the engine in its own104+ Deno.kill(useSetsid ? -child.pid : child.pid, "SIGKILL");105+ } catch {106+ try {107+ child.kill("SIGKILL");108+ } catch {109+ // already gone110+ }111+ }112+ }, timeoutMs);113+ }80114 const out = await child.output();115+ if (timer !== undefined) clearTimeout(timer);81116 const text = new TextDecoder().decode(out.stdout);117+ const err = new TextDecoder().decode(out.stderr);118+ for (const line of err.split("\n")) if (line.trim()) engineLog(line);119+ if (timedOut) {120+ throw new Error(`mosaic-engine ${args[0]} timed out after ${timeoutMs} ms`);121+ }82122 if (!out.success) {83- const err = new TextDecoder().decode(out.stderr).trim();84- throw new Error(`mosaic-engine ${args[0]} failed: ${err || text}`);123+ throw new Error(`mosaic-engine ${args[0]} failed: ${err.trim() || text}`);85124 }86125 return text;87126 }88127 128+let setsidKnown: boolean | undefined;129+async function hasSetsid(): Promise<boolean> {130+ if (setsidKnown !== undefined) return setsidKnown;131+ try {132+ const o = await new Deno.Command("setsid", {133+ args: ["--version"],134+ stdout: "null",135+ stderr: "null",136+ }).output();137+ setsidKnown = o.success;138+ } catch {139+ setsidKnown = false;140+ }141+ return setsidKnown;142+}143+89144 /**90145 * Generation parameters: the field side in cells and the layout kind, or a91146 * chunked field (`kind: "field"`: chunks x chunks chunks of side chunk_g,
@@ -104,6 +159,10 @@ export type GenParams = {
104159 /** field only: pre-generate this many growth rings ("all" = until the105160 * field is full); the engine emits {base, rings: [delta, ...]} */106161 rings?: number | "all";162+ /** field only: chunk processes at once (1 on the hosted box) */163+ jobs?: number;164+ /** field only: the engine's own overall budget, seconds */165+ timeout_s?: number;107166 };108167 109168 /** A field with its pre-generated growth rings (deltas in order). */
@@ -122,6 +181,8 @@ export function genArgs(p: GenParams): string[] {
122181 String(p.chunk_g ?? 60),123182 ...(p.field_g ? ["--G", String(p.field_g)] : []),124183 ...(p.rings !== undefined ? ["--rings", String(p.rings)] : []),184+ ...(p.jobs ? ["--jobs", String(p.jobs)] : []),185+ ...(p.timeout_s ? ["--timeout", `${p.timeout_s}s`] : []),125186 ];126187 }127188 const args = ["gen", "--seed", p.seed, "--G", String(p.G), "--kind", p.kind];
@@ -140,22 +201,42 @@ export function version(): Promise<string> {
140201 /** Generation is a batch job: results are cached by parameter hash, which141202 * includes the generator version so a changed generator never serves a142203 * stale board for the same seed. */143-export async function gen(p: GenParams): Promise<FieldOutput> {204+/** The cache file a generation lands in (jobs and timeout do not change205+ * the output, so they are not part of the key). */206+export async function cacheFile(p: GenParams): Promise<string> {144207 const key = await sha256(145208 `${await version()}|${p.seed}|${p.G}|${p.kind}|${p.n ?? ""}|${146209 p.chunks ?? ""147210 }|${p.chunk_g ?? ""}|${p.field_g ?? ""}|${p.rings ?? ""}`,148211 );149- const dir = cacheDir();150- const file = `${dir}${key}.json`;212+ return `${cacheDir()}${key}.json`;213+}214+215+/** Whether a generation is already in the cache (prepared). */216+export async function isCached(p: GenParams): Promise<boolean> {217+ try {218+ await Deno.stat(await cacheFile(p));219+ return true;220+ } catch {221+ return false;222+ }223+}224+225+export async function gen(226+ p: GenParams,227+ opts: { timeoutMs?: number } = {},228+): Promise<FieldOutput> {229+ const file = await cacheFile(p);151230 try {152231 return unwrapField(JSON.parse(await Deno.readTextFile(file)));153232 } catch {154233 // miss155234 }156- const text = await run(genArgs(p));157- await Deno.mkdir(dir, { recursive: true });158- await Deno.writeTextFile(file, text);235+ const text = await run(genArgs(p), undefined, opts.timeoutMs);236+ await Deno.mkdir(cacheDir(), { recursive: true });237+ // written whole, then renamed: a killed run never leaves a half file238+ await Deno.writeTextFile(file + ".tmp", text);239+ await Deno.rename(file + ".tmp", file);159240 return unwrapField(JSON.parse(text));160241 }161242
M site/lib/games.ts +6 -3
M site/routes/index.tsx +7 -0
M site/scripts/push-day.ts +4 -0
M site/tests/daily_test.ts +64 -0
@@ -32,6 +32,9 @@ const cfg: DailyConfig = {
3232 rollover_hour_utc: 0,3333 fog: true,3434 generate: false, // the injected generator stands in for a pushed field35+ prepare_hour_utc: 20,36+ jobs: 1,37+ emergency: { chunks: 1, chunk_g: 40, rings: 1 },3538 };3639 3740 /** A synthetic field per seed: ~20 tiles in the middle of a 140 canvas. */
@@ -222,3 +225,64 @@ Deno.test("a day with nothing pushed and generation off is missing; a push start
222225 d.stop();223226 resetGames();224227 });228+229+Deno.test("nightly prepare waits for its hour, and rollover with nothing ready makes an emergency field", async () => {230+ resetGames();231+ const dir = await Deno.makeTempDir() + "/";232+ let now = Date.parse("2026-11-01T10:00:00Z");233+ const logs: string[] = [];234+ const calls: GenParams[] = [];235+ // a generator that records every call; generation "allowed" because236+ // it is injected (the box's own generator would be gen)237+ const recording = (p: GenParams) => {238+ calls.push(p);239+ return generate(p);240+ };241+ const d = new Daily({242+ cfg: { ...cfg, generate: true },243+ dir,244+ now: () => now,245+ generate: recording,246+ judge: stubJudge,247+ log: (m) => logs.push(m),248+ });249+ await d.boot();250+ // nothing prepared or pushed for today: an emergency field, said so251+ assert(d.current && d.current.date === "2026-11-01");252+ assertEquals(d.current!.source, "emergency");253+ assertEquals(d.emergency, "2026-11-01");254+ assertEquals(calls.at(-1)!.rings, cfg.emergency.rings);255+ assert(logs.some((m) => m.includes("emergency field")));256+ // before 20:00 UTC the nightly prepare only announces itself257+ const before = calls.length;258+ await d.tick();259+ assertEquals(calls.length, before);260+ assert(261+ logs.some((m) =>262+ m.startsWith("next prepare: 2026-11-02 at 2026-11-01T20:00:00")263+ ),264+ );265+ // at 20:00 it runs, with the rest of the day as budget266+ now = Date.parse("2026-11-01T20:00:30Z");267+ await d.tick();268+ await new Promise((r) => setTimeout(r, 20));269+ const prep = calls.at(-1)!;270+ assertEquals(prep.rings, cfg.rings);271+ assertEquals(272+ prep.seed,273+ await (await import("../lib/daily.ts")).seedFor("2026-11-02", cfg),274+ );275+ assert(276+ prep.timeout_s! > 3 * 3600 && prep.timeout_s! < 4 * 3600,277+ `budget ${prep.timeout_s}`,278+ );279+ assert(logs.some((m) => m.startsWith("prepared 2026-11-02")));280+ // rollover: the prepared field is used, not an emergency one281+ now = Date.parse("2026-11-02T00:05:00Z");282+ await d.tick();283+ assertEquals(d.current!.date, "2026-11-02");284+ assertEquals(d.current!.source, "prepared");285+ assertEquals(d.emergency, null);286+ d.stop();287+ resetGames();288+});