clone

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

main
mosaic / site / tests / daily_test.ts
daily_test.ts 11.8K
1// The daily loop with an injected clock and a synthetic generator: two2// rollovers archive two days with winners, both replay from the archive,3// the second day honours the first day's winner (seat and crown), and a4// restart resumes the running day at the same revision.5import { assert, assertEquals } from "@std/assert";6import { Daily, type DailyConfig, dateFor, nextDate } from "../lib/daily.ts";7import { getGame, resetGames } from "../lib/games.ts";8import type { FieldOutput, GenOutput, GenParams } from "../lib/engine.ts";9import { fold, frontierOf, parseLog } from "../lib/fold.ts";10import { pushedPath } from "../lib/daily.ts";11import { readLog } from "../lib/log.ts";12import { boardsOf } from "../lib/leaderboard.ts";13import type { Referee } from "../lib/referee.ts";14import {15 embed,16 scatterLayout,17 stubJudge,18 synthesise,19 withSeeds,20} from "./fixture.ts";2122const tmp = await Deno.makeTempDir();23Deno.env.set("MOSAIC_GAMES", tmp + "/games/");2425const cfg: DailyConfig = {26 namespace: "test",27 chunks: 1,28 chunk_g: 60,29 G: 140,30 grow_minutes: 1440, // never during the test31 rollover_hour_utc: 0,32 fog: true,33 generate: false, // the injected generator stands in for a pushed field34 emergency: { chunks: 1, chunk_g: 40, rings: 1 },35};3637/** A synthetic field per seed: ~20 tiles in the middle of a 140 canvas. */38function generate(p: GenParams): Promise<FieldOutput> {39 const L = embed(withSeeds(scatterLayout(p.seed, 60), 3), p.G, 40, 40);40 const out = synthesise(L, p.seed);41 out.manifest.field = { chunks: 1, chunk_g: 60, band: 0, grown: 0 };42 return Promise.resolve({ ...(out as GenOutput), rings: [] });43}4445const solutions = new Map<string, (id: number) => 0 | 1>();46async function solutionFor(seed: string, G: number) {47 if (!solutions.has(seed)) {48 const out = await generate({ seed, G, kind: "field" });49 solutions.set(50 seed,51 (id: number) =>52 (out.solution[Math.floor(id / G)][id % G] === "1" ? 1 : 0) as 0 | 1,53 );54 }55 return solutions.get(seed)!;56}5758/** Reveal and finish n frontier tiles for an agent. */59async function play(ref: Referee, agent: string, seed: string, n: number) {60 const sol = await solutionFor(seed, ref.state.G);61 for (let k = 0; k < n; k++) {62 const fr = frontierOf(ref.state, agent);63 if (!fr.length) return;64 const id = fr[0];65 if (ref.state.tiles[id] === "hidden") {66 await ref.reveal(ref.revision, id, `reveal #${id}`, agent);67 }68 const unknown = ref.state.geo!.cellsOf[id].filter((c) =>69 !ref.state.cells[c]70 );71 if (unknown.length) {72 await ref.commit(73 ref.revision,74 unknown.map((c) => [c, sol(c)] as [number, number]),75 `finish #${id}`,76 agent,77 );78 }79 }80}8182Deno.test("two rollovers: archive, winners, honours, replay, resume", async () => {83 resetGames();84 let now = Date.parse("2026-09-01T10:00:00Z");85 const clock = () => now;86 const logs: string[] = [];87 const d = new Daily({88 cfg,89 dir: tmp + "/",90 now: clock,91 generate,92 judge: stubJudge,93 log: (m) => logs.push(m),94 });95 await d.boot();96 const day1 = dateFor(now, cfg);97 assertEquals(day1, "2026-09-01");98 assert(d.current && d.current.date === day1, "day 1 started");99 const g1 = getGame(d.current!.game_id)!;100 assertEquals(g1.state.day, day1);101102 // two agents: alice (model-a) wins by cells, bob (model-b) trails103 const a = await g1.join({104 name: "alice",105 machine_id: "alice",106 verified: true,107 model: "model-a",108 });109 const b = await g1.join({110 name: "bob",111 machine_id: "bob",112 verified: true,113 model: "model-b",114 });115 await play(g1, a.agent, d.current!.seed, 3);116 await play(g1, b.agent, d.current!.seed, 1);117 const boards = boardsOf(g1.state);118 assertEquals(boards.by_user[0].key, "alice");119 assertEquals(boards.by_model[0].key, "model-a");120 assertEquals(boards.by_user[0].models, ["model-a"]);121 const rev1 = g1.revision;122123 // a restart mid-day resumes at the same revision124 const d2 = new Daily({125 cfg,126 dir: tmp + "/",127 now: clock,128 generate,129 judge: stubJudge,130 log: (m) => logs.push(m),131 });132 resetGames();133 await d2.boot();134 const g1b = getGame(d2.current!.game_id)!;135 assertEquals(g1b.revision, rev1);136 assertEquals(g1b.state.agents.length, 2);137 assert(logs.some((m) => m.startsWith("resumed")), "resume logged");138 // and play continues from there (the private solution is back)139 await play(g1b, a.agent, d2.current!.seed, 1);140 assert(g1b.revision > rev1);141142 // day 2: rollover archives day 1 and honours alice143 now = Date.parse("2026-09-02T00:30:00Z");144 await d2.tick();145 const day2 = nextDate(day1);146 assertEquals(d2.current!.date, day2);147 const row1 = d2.index.get(day1)!;148 assertEquals(row1.winner_user, "alice");149 assertEquals(row1.winner_model, "model-a");150 assert(row1.stats.solved >= 4);151 assertEquals(row1.stats.completed, false);152 assert(row1.stats.ended !== null, "ended at rollover");153 // the archive replays to the same state as the log on disk154 const archived = await Deno.readTextFile(`${tmp}/days/${day1}/log.jsonl`);155 const s1 = fold(parseLog(archived));156 assertEquals(s1.status, "solved"); // ended157 assertEquals(s1.revision, row1.stats.revisions);158 assertEquals((await readLog(row1.game_id))!, archived);159 // the private side is released with the record: keys.json and the160 // summary carry the seed commitment (and the seed/key when the field161 // had them; the fixture predates gen-v7)162 const keys = (await d2.keysOf(day1))!;163 assertEquals(keys.seed_commit, row1.seed_commit);164 assertEquals(keys.scheme, "mosaic-tile-v1");165 const summary = JSON.parse(166 await Deno.readTextFile(`${tmp}/days/${day1}/summary.json`),167 );168 assertEquals(summary.keys, keys);169 assertEquals(await d2.keysOf(day2), null);170171 const g2 = getGame(d2.current!.game_id)!;172 assertEquals(g2.state.day, day2);173 assertEquals(g2.state.honours.length, 1);174 assertEquals(g2.state.honours[0].name, "alice");175 assertEquals(g2.state.honours[0].model, "model-a");176 const seat = g2.state.honours[0].seed_tile;177 // a stranger is not seated on the reserved seat; alice is178 const c = await g2.join({179 name: "carol",180 machine_id: "carol",181 verified: true,182 model: "model-c",183 });184 assert(c.seed_tile !== seat, "stranger not on the reserved seat");185 const a2 = await g2.join({186 name: "alice",187 machine_id: "alice",188 verified: true,189 model: "model-a",190 });191 assertEquals(a2.seed_tile, seat);192 await play(g2, a2.agent, d2.current!.seed, 2);193194 // day 3: both days archived, cumulative boards add up195 now = Date.parse("2026-09-03T00:10:00Z");196 await d2.tick();197 const days = d2.index.list();198 assertEquals(days.map((r) => r.date), [day1, day2]);199 assertEquals(days[1].winner_user, "alice");200 const cum = d2.cumulative();201 assertEquals(cum.days, 2);202 const alice = cum.by_user.find((r) => r.key === "alice")!;203 assertEquals(alice.days, 2);204 assertEquals(205 alice.reward,206 days[0].boards.by_user[0].reward + days[1].boards.by_user[0].reward,207 );208 assert(cum.by_model.find((r) => r.key === "model-a")!.reward >= alice.reward);209 assertEquals(d2.current!.date, "2026-09-03");210 d2.stop();211 d.stop();212 resetGames();213});214215Deno.test("a day with nothing pushed and generation off is missing; a push starts it", async () => {216 resetGames();217 const dir = await Deno.makeTempDir() + "/";218 let now = Date.parse("2026-10-01T09:00:00Z");219 const logs: string[] = [];220 // the real generator (gen) with generate=false: the daily must not call it221 const d = new Daily({222 cfg,223 dir,224 now: () => now,225 judge: stubJudge,226 log: (m) => logs.push(m),227 });228 await d.boot();229 assertEquals(d.current, null);230 assertEquals(d.missing, "2026-10-01");231 assertEquals(d.status().phase, "missing");232 assertEquals(d.status().latest_archived, null);233 assert(logs.some((m) => m.startsWith("no field for 2026-10-01")));234 // a push lands the day's field; the next tick starts it235 const out = await generate({ seed: "pushed", G: cfg.G, kind: "field" });236 await Deno.mkdir(dir + "fields", { recursive: true });237 await Deno.writeTextFile(238 pushedPath(dir, "2026-10-01"),239 JSON.stringify({ base: out, rings: [] }),240 );241 await d.tick();242 assert(d.current && d.current.date === "2026-10-01", "started from the push");243 assertEquals(d.missing, null);244 const g = getGame(d.current!.game_id)!;245 assertEquals(246 g.manifest.tiles.length,247 (out.manifest as { tiles: unknown[] }).tiles.length,248 );249 // rollover with nothing pushed for the next day: archived, then missing250 now = Date.parse("2026-10-02T01:00:00Z");251 await d.tick();252 assertEquals(d.index.list().map((r) => r.date), ["2026-10-01"]);253 assertEquals(d.current, null);254 assertEquals(d.missing, "2026-10-02");255 // the front page shows the last finished day's replay meanwhile256 const st = d.status();257 assertEquals(st.phase, "missing");258 assertEquals(st.latest_archived?.date, "2026-10-01");259 assertEquals(st.live, null);260 d.stop();261 resetGames();262});263264Deno.test("rollover with nothing pushed makes an emergency field and says so", async () => {265 resetGames();266 const dir = await Deno.makeTempDir() + "/";267 const now = Date.parse("2026-11-01T10:00:00Z");268 const logs: string[] = [];269 const calls: GenParams[] = [];270 const recording = (p: GenParams) => {271 calls.push(p);272 return generate(p);273 };274 const d = new Daily({275 cfg: { ...cfg, generate: true },276 dir,277 now: () => now,278 generate: recording,279 judge: stubJudge,280 log: (m) => logs.push(m),281 });282 await d.boot();283 assert(d.current && d.current.date === "2026-11-01");284 assertEquals(d.current!.source, "emergency");285 assertEquals(d.emergency, "2026-11-01");286 assertEquals(d.status().phase, "live");287 assertEquals(d.status().emergency, true);288 assertEquals(d.status().tomorrow, { date: "2026-11-02", pushed: false });289 assertEquals(calls.at(-1)!.rings, cfg.emergency.rings);290 assert(logs.some((m) => m.includes("emergency field")));291 d.stop();292 resetGames();293});294295Deno.test("reset wipes a day's play and restarts it from the same field", async () => {296 resetGames();297 let now = Date.parse("2026-10-01T10:00:00Z");298 const clock = () => now;299 const resetLogs: string[] = [];300 const d = new Daily({301 cfg,302 dir: tmp + "/reset/",303 now: clock,304 generate,305 judge: stubJudge,306 log: (m) => resetLogs.push(m),307 });308 await d.boot();309 const day = dateFor(now, cfg);310 const g = getGame(d.current!.game_id)!;311 const id = d.current!.game_id;312 const manifestId = g.state.manifest!.id;313 const a = await g.join({314 name: "alice",315 machine_id: "alice",316 verified: true,317 model: "model-a",318 });319 await play(g, a.agent, d.current!.seed, 2);320 const rev = g.revision;321 assert(rev > 3, `played to ${rev}`);322 assertEquals(d.status().reset, 0);323324 const r = await d.reset(day);325 assertEquals(r, { game_id: id, reset: 1 });326 const g2 = getGame(id)!;327 assert(g2 !== g, "a fresh referee");328 assert(g2, "the day restarted");329 assertEquals(g2.revision, 0);330 assertEquals(g2.state.agents.length, 0);331 assertEquals(g2.state.manifest!.id, manifestId);332 assertEquals(d.status().reset, 1);333 assertEquals(d.index.get(day), null);334 // the old log is kept aside, the live one starts over335 const moved = await Deno.readTextFile(336 `${Deno.env.get("MOSAIC_GAMES")}${id}.reset-1.jsonl`,337 ).catch(() => null);338 assert(moved, `the old log was set aside: ${resetLogs.join("; ")}`);339 const movedEvents = moved.split("\n").filter(Boolean);340 assert(movedEvents.length >= rev + 1, "every event kept");341 assertEquals(JSON.parse(movedEvents.at(-1)!).kind, "end");342 const fresh = await readLog(id);343 assert(fresh, "a fresh log");344 assertEquals(fresh.split("\n").filter(Boolean).length, 1);345 // play again from scratch with clean health346 const b = await g2.join({347 name: "alice",348 machine_id: "alice",349 verified: true,350 model: "model-a",351 });352 assertEquals(g2.state.agents[0].health.state, "healthy");353 await play(g2, b.agent, d.current!.seed, 1);354 assert(g2.revision > 1);355 now += 1;356});