clone

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

main
mosaic / site / tests / grow_test.ts
grow_test.ts 14.3K
1// Growing fields and fog (goal items 11 and 12, site side): a field grown2// twice while an agent plays folds identically live and from the log; the3// geometry after a grow equals the geometry of the union; under fog observe4// never lists a tile the caller's group has not reached; a joiner is seated5// on the newest ring; the registry grows on coverage and instead of ending.6import { assert, assertEquals } from "@std/assert";7import {8 allSolved,9 applyEvent,10 buildGeometry,11 emptyState,12 frontierOf,13 groupOf,14 parseLog,15 reachedByGroup,16 type State,17} from "../lib/fold.ts";18import type { GenOutput } from "../lib/engine.ts";19import { Referee } from "../lib/referee.ts";20import {21 growGame,22 resetGames,23 setGrower,24 startGame,25 whenReady,26} from "../lib/games.ts";27import {28 embed,29 growLayout,30 scatterLayout,31 stubJudge,32 synthesise,33 synthesiseDelta,34 withSeeds,35} from "./fixture.ts";3637const tmp = await Deno.makeTempDir();38Deno.env.set("MOSAIC_GAMES", tmp + "/");3940function solutionOf(out: GenOutput) {41 const G = out.manifest.G;42 return (id: number) =>43 (out.solution[Math.floor(id / G)][id % G] === "1" ? 1 : 0) as 0 | 1;44}4546/** A cluster of ~20 tiles in the middle of a 140-cell canvas. */47function base() {48 const L = embed(withSeeds(scatterLayout("grow-base", 60), 3), 140, 40, 40);49 const out = synthesise(L, "grow-base");50 out.manifest.field = { chunks: 1, chunk_g: 60, band: 0, grown: 0 };51 return out;52}5354/** A write that lost the revision race (a grow landed) is retried, as clients do. */55async function retrying<T>(fn: () => Promise<T>): Promise<T> {56 for (let i = 0; i < 10; i++) {57 try {58 return await fn();59 } catch (e) {60 if (!String(e).includes("Revision conflict")) throw e;61 }62 }63 throw new Error("gave up after 10 revision conflicts");64}6566/** Solve one frontier tile of the agent (reveal + commit everything). */67async function solveOne(68 ref: Referee,69 sol: (id: number) => 0 | 1,70 agent: string,71 live: State[],72): Promise<number | null> {73 const fr = frontierOf(ref.state, agent);74 if (!fr.length) return null;75 const id = fr[0];76 if (ref.state.tiles[id] === "hidden") {77 await retrying(() => ref.reveal(ref.revision, id, `reveal #${id}`, agent));78 live.push(ref.state);79 }80 const unknown = ref.state.geo!.cellsOf[id].filter((c) => !ref.state.cells[c]);81 if (unknown.length) {82 await retrying(() =>83 ref.commit(84 ref.revision,85 unknown.map((c) => [c, sol(c)] as [number, number]),86 `finish #${id}`,87 agent,88 )89 );90 live.push(ref.state);91 }92 return id;93}9495Deno.test("a field grown twice folds identically live and from the log; geometry equals the union", async () => {96 let out = base();97 const gameId = crypto.randomUUID();98 const ref = await Referee.create(99 gameId,100 structuredClone(out),101 { guess: "accept", depth: 0, fog: true, grow: { every_minutes: 60 } },102 stubJudge,103 { seed: "grow-base" },104 );105 const live: State[] = [];106 let acc = emptyState();107 const stream: State[] = [];108 ref.log.subscribe((ev) => {109 acc = applyEvent(acc, ev);110 stream.push(acc);111 });112 acc = applyEvent(acc, ref.log.events[0]);113 stream.push(acc);114 live.push(ref.state);115 const { agent } = await ref.join({116 name: "p",117 harness: "test",118 machine_id: "m-p",119 verified: true,120 });121 live.push(ref.state);122 let sol = solutionOf(out);123 for (let i = 0; i < 3; i++) await solveOne(ref, sol, agent, live);124 const beforeTiles = ref.manifest.tiles.length;125 for (let ring = 1; ring <= 2; ring++) {126 const layout = growLayout(127 {128 G: out.manifest.G,129 kind: "scatter",130 seed_tile: out.manifest.seed_tile,131 tiles: out.manifest.tiles,132 },133 "grow-base",134 ring,135 );136 assert(layout.tiles.length >= 4, `ring ${ring} has tiles`);137 const delta = synthesiseDelta(out, layout, ring);138 const ev = await ref.grow(delta, ring);139 live.push(ref.state);140 assertEquals(ev.tiles.length, layout.tiles.length);141 // the private state grows the same way the registry merges it142 out = {143 manifest: {144 ...out.manifest,145 tiles: [...out.manifest.tiles, ...delta.manifest.tiles],146 seed_tiles: [147 ...(out.manifest.seed_tiles ?? []),148 ...(delta.manifest.seed_tiles ?? []),149 ],150 field: delta.manifest.field,151 },152 solution: delta.solution,153 tiles: [...out.tiles, ...delta.tiles],154 };155 sol = solutionOf(out);156 // geometry after the grow == geometry of the union (hues aside)157 const union = buildGeometry(out.manifest);158 const geo = ref.state.geo!;159 assertEquals(geo.ownerStart, union.ownerStart);160 assertEquals(geo.ownerList, union.ownerList);161 assertEquals(geo.cellsOf, union.cellsOf);162 assertEquals(geo.adjacency, union.adjacency);163 assertEquals(geo.holes, union.holes);164 assertEquals(ref.state.grown, ring);165 assertEquals(ref.state.seedRings.length, ring + 1);166 // new tiles are unsolved: the field is not solved after a grow167 assert(!allSolved(ref.state));168 assertEquals(ref.state.tiles.length, out.manifest.tiles.length);169 for (let i = 0; i < 3; i++) await solveOne(ref, sol, agent, live);170 }171 assert(ref.manifest.tiles.length > beforeTiles + 8);172 // live == replay at every revision173 const text = await Deno.readTextFile(`${tmp}/${gameId}.jsonl`);174 const events = parseLog(text);175 assertEquals(events.length, ref.revision + 1);176 assertEquals(events.filter((e) => e.kind === "grow").length, 2);177 let s = emptyState();178 const replay: State[] = [];179 for (const ev of events) {180 s = applyEvent(s, ev);181 replay.push(s);182 }183 for (let k = 0; k < events.length; k++) {184 assertEquals(replay[k], stream[k], `stream vs replay at rev ${k}`);185 }186 for (const st of live) {187 assertEquals(188 replay[st.revision],189 st,190 `referee state at rev ${st.revision}`,191 );192 }193 // reached: every revealed/solved tile, its neighbours, and seeds; nothing else194 const last = replay.at(-1)!;195 for (let t = 0; t < last.tiles.length; t++) {196 const expect = last.tiles[t] !== "hidden" ||197 last.geo!.adjacency[t].some((u) => last.tiles[u] !== "hidden") ||198 last.agents.some((a) => a.seed_tile === t);199 assertEquals(!!last.reached[t], expect, `reached[${t}]`);200 }201 assert(!text.includes('"solution"'));202});203204Deno.test("fog: observe never lists a hidden tile the caller's group has not reached", async () => {205 const out = base();206 const ref = await Referee.create(207 crypto.randomUUID(),208 structuredClone(out),209 { guess: "accept", depth: 0, fog: true },210 stubJudge,211 { persist: false },212 );213 const sol = solutionOf(out);214 const a = (await ref.join({215 name: "a",216 harness: "t",217 machine_id: "m-a",218 verified: true,219 })).agent;220 const b = (await ref.join({221 name: "b",222 harness: "t",223 machine_id: "m-b",224 verified: true,225 })).agent;226 const live: State[] = [];227 for (let i = 0; i < 2; i++) {228 await solveOne(ref, sol, a, live);229 await solveOne(ref, sol, b, live);230 }231 const s = ref.state;232 for (const who of [a, b]) {233 const obs = ref.observe({ agent: who });234 assert(obs.fog);235 const group = groupOf(s, who);236 for (const t of obs.tiles) {237 if (t.status !== "hidden") continue;238 assert(239 reachedByGroup(s, t.id, group),240 `${who} sees unreached hidden tile ${t.id}`,241 );242 }243 // its own seed tile and its frontier are listed244 const seed = s.agents.find((x) => x.id === who)!.seed_tile;245 assert(obs.tiles.some((t) => t.id === seed));246 for (const f of obs.frontier) assert(obs.tiles.some((t) => t.id === f));247 }248 // without an agent, a rectangle lists only revealed and solved tiles249 const rect = ref.observe({ rect: [0, 0, 140, 140] });250 assert(rect.tiles.length > 0);251 for (const t of rect.tiles) assert(t.status !== "hidden");252 // a hidden tile away from everyone is listed to nobody253 const far = s.manifest!.tiles.find((t) =>254 s.tiles[t.id] === "hidden" &&255 !s.reached[t.id]256 );257 assert(far, "some tile is unreached");258 for (const who of [a, b]) {259 assert(!ref.observe({ agent: who }).tiles.some((t) => t.id === far.id));260 }261 // without fog the same tile is listed to an agent whose radius covers it262 const open = await Referee.create(263 crypto.randomUUID(),264 structuredClone(out),265 { guess: "accept", depth: 0, fog: false },266 stubJudge,267 { persist: false },268 );269 const c = (await open.join({270 name: "c",271 harness: "t",272 machine_id: "m-c",273 verified: true,274 })).agent;275 assert(!open.observe({ agent: c }).fog);276});277278Deno.test("a joiner after a grow is seated on the newest ring", async () => {279 const out = base();280 const ref = await Referee.create(281 crypto.randomUUID(),282 structuredClone(out),283 { guess: "accept", depth: 0, fog: true, grow: { every_minutes: 60 } },284 stubJudge,285 { persist: false },286 );287 const layout = growLayout(288 {289 G: out.manifest.G,290 kind: "scatter",291 seed_tile: out.manifest.seed_tile,292 tiles: out.manifest.tiles,293 },294 "grow-base",295 1,296 );297 const delta = synthesiseDelta(out, layout, 1);298 await ref.grow(delta, 1);299 const j = await ref.join({300 name: "late",301 harness: "t",302 machine_id: "m-late",303 verified: true,304 });305 assert(306 delta.manifest.seed_tiles!.includes(j.seed_tile),307 `seat ${j.seed_tile} in ring 1 seeds ${delta.manifest.seed_tiles}`,308 );309 const j2 = await ref.join({310 name: "later",311 harness: "t",312 machine_id: "m-later",313 verified: true,314 });315 assert(delta.manifest.seed_tiles!.includes(j2.seed_tile));316 // the ring's seeds are used up: the next joiner falls back to ring 0317 const j3 = await ref.join({318 name: "latest",319 harness: "t",320 machine_id: "m-latest",321 verified: true,322 });323 assert(!delta.manifest.seed_tiles!.includes(j3.seed_tile));324});325326Deno.test("registry: growth on coverage, and instead of ending when every tile is solved", async () => {327 resetGames();328 let out = base();329 const gen = () => Promise.resolve(structuredClone(out));330 let rings = 0;331 setGrower((state, _seed, ring) => {332 rings++;333 const layout = growLayout(334 {335 G: out.manifest.G,336 kind: "scatter",337 seed_tile: out.manifest.seed_tile,338 tiles: (state.manifest as unknown as { tiles: never[] }).tiles,339 },340 "grow-base",341 ring,342 );343 if (ring >= 3) layout.tiles = []; // the field runs out of room344 const delta = synthesiseDelta(out, layout, ring);345 out = {346 manifest: {347 ...out.manifest,348 tiles: [...out.manifest.tiles, ...delta.manifest.tiles],349 seed_tiles: [350 ...(out.manifest.seed_tiles ?? []),351 ...(delta.manifest.seed_tiles ?? []),352 ],353 },354 solution: delta.solution,355 tiles: [...out.tiles, ...delta.tiles],356 };357 return Promise.resolve(delta);358 });359 const pending = startGame(360 {361 G: 140,362 kind: "field",363 chunks: 1,364 chunk_g: 60,365 grow: { frontier_coverage: 0.5 },366 field_g: 140,367 },368 gen,369 Date.now,370 stubJudge,371 );372 const ref = await whenReady(pending.game_id);373 assert(ref.fog, "fog defaults on for a growing field");374 const agent = (await ref.join({375 name: "p",376 harness: "t",377 machine_id: "m-p",378 verified: true,379 })).agent;380 const live: State[] = [];381 // solve until coverage crosses 0.5: growth is triggered in the background382 const n0 = ref.manifest.tiles.length;383 for (let i = 0; i < 40 && ref.state.grown === 0; i++) {384 const done = await solveOne(ref, solutionOf(out), agent, live);385 if (done === null) break;386 await new Promise((r) => setTimeout(r, 20));387 }388 await new Promise((r) => setTimeout(r, 100));389 assertEquals(ref.state.grown, 1, "grew once on coverage");390 assert(ref.manifest.tiles.length > n0);391 // solve everything: the game does not end while it can grow392 for (let guard = 0; guard < 400 && ref.state.status === "playing"; guard++) {393 const done = await solveOne(ref, solutionOf(out), agent, live);394 if (done === null) {395 await new Promise((r) => setTimeout(r, 30));396 if (frontierOf(ref.state, agent).length === 0 && rings >= 3) break;397 }398 }399 await new Promise((r) => setTimeout(r, 100));400 assert(rings >= 3, `grower called ${rings} times`);401 assert(ref.state.exhausted, "the last ring found no room");402 // growGame is a no-op once exhausted403 assertEquals(await growGame(pending.game_id), false);404 resetGames();405});406407Deno.test("registry: stored rings are revealed without the engine; the engine is the fallback", async () => {408 resetGames();409 const out = base();410 const mk = (b: typeof out, ring: number) =>411 synthesiseDelta(412 b,413 growLayout(414 {415 G: b.manifest.G,416 kind: "scatter",417 seed_tile: b.manifest.seed_tile,418 tiles: b.manifest.tiles,419 },420 "grow-base",421 ring,422 ),423 ring,424 );425 const d1 = mk(out, 1);426 const union1 = {427 ...out,428 manifest: {429 ...out.manifest,430 tiles: [...out.manifest.tiles, ...d1.manifest.tiles],431 },432 solution: d1.solution,433 tiles: [...out.tiles, ...d1.tiles],434 };435 const d2 = mk(union1, 2);436 let engineCalls = 0;437 setGrower((state, _seed, ring) => {438 engineCalls++;439 // the on-demand fallback: an empty ring (field full) over the state440 const empty = mk(out, ring);441 empty.manifest.tiles = [];442 empty.manifest.seed_tiles = [];443 empty.solution = state.solution;444 empty.tiles = [];445 empty.stats = { exhausted: 1 };446 return Promise.resolve(empty);447 });448 const pending = startGame(449 {450 G: 140,451 kind: "field",452 chunks: 1,453 chunk_g: 60,454 grow: { every_minutes: 60 },455 field_g: 140,456 rings: 2,457 },458 () => Promise.resolve({ ...structuredClone(out), rings: [d1, d2] }),459 Date.now,460 stubJudge,461 );462 const ref = await whenReady(pending.game_id);463 const n0 = ref.manifest.tiles.length;464 assertEquals(await growGame(pending.game_id), true);465 assertEquals(ref.state.grown, 1);466 assertEquals(ref.manifest.tiles.length, n0 + d1.manifest.tiles.length);467 assertEquals(await growGame(pending.game_id), true);468 assertEquals(ref.state.grown, 2);469 assertEquals(engineCalls, 0, "stored rings need no engine");470 // rings used up: the engine is asked, finds no room, the field is exhausted471 assertEquals(await growGame(pending.game_id), true);472 assertEquals(engineCalls, 1);473 assert(ref.state.exhausted);474 assertEquals(await growGame(pending.game_id), false);475 resetGames();476});