clone

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

site: growing fields and fog

dami committed 8810595
grow event: the engine's field --grow delta is applied by the referee
(tiles appended with continuing ids, givens and solution cells merged,
old cells asserted unchanged); the fold extends the geometry in place
(owner index, adjacency, holes rebuilt for the union; old hues kept),
tracks seed tiles per ring (a joiner is seated on the newest ring), and
derives reached[] per tile and tileRevealer[]. Growth policy on
start_mosaic {grow: {every_minutes, frontier_coverage}}: a timer, a
coverage check after every write, and always when every current tile is
solved (no end event while the field can grow; the game ends when a ring
finds no room). Fog (rules.fog, default on for growing fields): observe
lists a hidden tile only once the caller's group reaches it. solve.ts
gains --grow-every, --grow-coverage, --fog, --until-grown. Tests: two
growths fold identically live and from the log with geometry equal to
the union; fog never lists an unreached tile; newest-ring seating;
registry growth on coverage and instead of ending.
commit
8810595132778e2471c8027f0e1abc8a9bd96011
parent
bcfdf766e4b91f952f7662b20eb6c015dab1c34c
author
dami <gazey55@gmail.com>
8 files changed, +1080 -31
Msite/lib/engine.ts70++++++-
Msite/lib/fold.ts198++++++++++++++++-
Msite/lib/games.ts132++++++++++-
Msite/lib/mcp.ts37++-
Msite/lib/referee.ts121++++++++++-
Msite/scripts/solve.ts39+++-
Msite/tests/fixture.ts152+++++++++++++
Asite/tests/grow_test.ts362++++++++++++++++++++++++++++++
M site/lib/engine.ts +69 -1
@@ -100,6 +100,9 @@ export type GenParams = {
100100 n?: number; // grid only101101 chunks?: number; // field only102102 chunk_g?: number; // field only103+ /** field only: allocate the field at this side (>= the chunk grid) with104+ * the initial cluster centred, so rings can grow around it */105+ field_g?: number;103106 };104107 105108 /** The single place the generator's command line is spelled out. */
@@ -115,6 +118,7 @@ export function genArgs(p: GenParams): string[] {
115118 String(p.chunk_g ?? 60),116119 "--kind",117120 "scatter",121+ ...(p.field_g ? ["--G", String(p.field_g)] : []),118122 ];119123 }120124 const args = ["gen", "--seed", p.seed, "--G", String(p.G), "--kind", p.kind];
@@ -137,7 +141,7 @@ export async function gen(p: GenParams): Promise<GenOutput> {
137141 const key = await sha256(138142 `${await version()}|${p.seed}|${p.G}|${p.kind}|${p.n ?? ""}|${139143 p.chunks ?? ""140- }|${p.chunk_g ?? ""}`,144+ }|${p.chunk_g ?? ""}|${p.field_g ?? ""}`,141145 );142146 const dir = cacheDir();143147 const file = `${dir}${key}.json`;
@@ -156,3 +160,67 @@ export async function judge(input: JudgeInput): Promise<JudgeOutput> {
156160 const text = await run(["judge"], JSON.stringify(input));157161 return JSON.parse(text) as JudgeOutput;158162 }163+164+/**165+ * Growth: one ring of chunks around the current field. `state` is the166+ * full private output so far (manifest, merged solution, private tiles);167+ * the engine returns a delta with only the new tiles. Cached like gen,168+ * keyed by seed and ring (the state is a deterministic function of both).169+ */170+export async function grow(171+ state: GenOutput,172+ seed: string,173+ ring: number,174+): Promise<GenOutput> {175+ const key = await sha256(176+ `${await version()}|grow|${seed}|${ring}|${state.manifest.G}|${177+ (state.manifest as { tiles: unknown[] }).tiles.length178+ }`,179+ );180+ const dir = cacheDir();181+ const file = `${dir}${key}.json`;182+ try {183+ return JSON.parse(await Deno.readTextFile(file)) as GenOutput;184+ } catch {185+ // miss186+ }187+ await Deno.mkdir(dir, { recursive: true });188+ const stateFile = `${dir}state-${key}.json`;189+ await Deno.writeTextFile(stateFile, JSON.stringify(state));190+ try {191+ const text = await run([192+ "field",193+ "--grow",194+ "--seed",195+ seed,196+ "--state",197+ stateFile,198+ "--ring",199+ "1", // rings to add; which ring it is follows from the state200+ ]);201+ await Deno.writeTextFile(file, text);202+ return JSON.parse(text) as GenOutput;203+ } finally {204+ await Deno.remove(stateFile).catch(() => {});205+ }206+}207+208+/** Merge a growth delta into the private state (pure). */209+export function mergeGrowth(state: GenOutput, delta: GenOutput): GenOutput {210+ const m = state.manifest as unknown as Record<string, unknown>;211+ const d = delta.manifest as unknown as Record<string, unknown>;212+ return {213+ manifest: {214+ ...m,215+ tiles: [...(m.tiles as unknown[]), ...(d.tiles as unknown[])],216+ seed_tiles: [217+ ...((m.seed_tiles as number[] | undefined) ?? []),218+ ...((d.seed_tiles as number[] | undefined) ?? []),219+ ],220+ ...(d.field ? { field: d.field } : {}),221+ } as unknown as AnyManifest,222+ solution: delta.solution,223+ tiles: [...state.tiles, ...delta.tiles],224+ stats: delta.stats,225+ };226+}
M site/lib/fold.ts +188 -10
@@ -30,6 +30,32 @@
3030 // `analysis` summary), `manifest`3131 // Logs written before agents existed fold with one implicit agent `a0`3232 // whose seed tile is the manifest's seed tile.33+//34+// Growing fields (manifest v3 with `field`, `rules.grow`):35+// events `grow` (ring, tiles: TileSpec[] appended with ids that36+// continue, seed_tiles of the new ring, field) — cell ids37+// never change (the field is allocated at its final G)38+// geo may be a NEW object after a `grow` event: tiles appended,39+// ids stable, hues of old tiles unchanged; readers must not40+// cache a geometry across a grow (compare `s.geo` identity)41+// grown number of grow events folded so far42+// reached Uint8Array per tile: 1 if the tile is revealed or solved,43+// overlaps a revealed or solved tile, or is some agent's44+// seed tile; 0 otherwise (the spectator draws unreached45+// tiles as the faintest wash; observe under fog lists a46+// hidden tile only when the caller's own group reaches it)47+// tileRevealer[] per tile id: the agent that revealed it (null while hidden)48+// seedRings seed tiles per ring (ring 0 = the initial field); a49+// joining agent is seated on the newest ring first50+51+/** The chunk grid of a field; the engine adds what growth needs. */52+export type FieldInfo = {53+ chunks: number;54+ chunk_g: number;55+ band: number;56+ grown?: number;57+ [k: string]: unknown;58+};3359 3460 /** A rectangle in a tile's local coordinates: [lx, ly, w, h]. */3561 export type LocalRect = [number, number, number, number];
@@ -73,7 +99,7 @@ export type Manifest = {
7399 rules: Record<string, unknown>;74100 seed_tile: number;75101 seed_tiles?: number[]; // one per chunk on a field; candidates for joining agents76- field?: { chunks: number; chunk_g: number; band: number };102+ field?: FieldInfo;77103 tiles: TileSpec[];78104 zones?: ZoneSpec[]; // only on manifests converted from v279105 k: string | number;
@@ -128,7 +154,20 @@ export function isV1(m: AnyManifest): m is ManifestV1 {
128154 return !("version" in m) && "X" in m;129155 }130156 131-export type GameRules = { guess: "accept" | "reject"; depth: number };157+/** Growth policy of a field: a timer, a coverage threshold, or both. */158+export type GrowRules = {159+ every_minutes?: number;160+ /** grow when this share of the current tiles is revealed or solved */161+ frontier_coverage?: number;162+};163+164+export type GameRules = {165+ guess: "accept" | "reject";166+ depth: number;167+ /** hide unreached tiles from observe (default: on for growing fields) */168+ fog?: boolean;169+ grow?: GrowRules;170+};132171 133172 export type Class = "forced" | "guess";134173 export type CellKind = "given" | "commit" | "zone";
@@ -207,6 +246,18 @@ export type RetractEvent = {
207246 intent: string;208247 };209248 249+/** A ring of chunks generated around the cluster: tiles appended. */250+export type GrowEvent = {251+ rev: number;252+ t: string;253+ kind: "grow";254+ ring: number;255+ tiles: TileSpec[];256+ seed_tiles: number[];257+ field?: FieldInfo;258+ exhausted?: boolean;259+};260+210261 export type EndEvent = {211262 rev: number;212263 t: string;
@@ -222,6 +273,7 @@ export type GameEvent =
222273 | RevealEvent223274 | CommitEvent224275 | RetractEvent276+ | GrowEvent225277 | EndEvent;226278 227279 export const IMPLICIT_AGENT = "a0";
@@ -295,7 +347,7 @@ export type CellInfo = {
295347 export type ConsoleEntry = {296348 rev: number;297349 t: string;298- op: "start" | "join" | "reveal" | "commit" | "retract" | "end";350+ op: "start" | "join" | "reveal" | "commit" | "retract" | "grow" | "end";299351 agent?: string;300352 intent: string;301353 tile?: number;
@@ -357,7 +409,12 @@ export type State = {
357409 last_t: string | null;358410 agents: AgentInfo[];359411 tileAgent: (string | null)[]; // by tile id412+ tileRevealer: (string | null)[]; // by tile id360413 meets: { rev: number; agents: [string, string]; tile: number }[];414+ reached: Uint8Array; // by tile id; see the contract above415+ grown: number;416+ seedRings: number[][]; // seed tiles per ring417+ exhausted: boolean; // the last grow found no room361418 analysis: Analysis;362419 // analyzer scratch, derived and deterministic (part of the state so that363420 // live == replay holds for it too)
@@ -702,6 +759,32 @@ export function buildGeometry(m: Manifest): Geometry {
702759 };703760 }704761 762+/**763+ * Geometry after a grow: the manifest now holds the old tiles plus the new764+ * ones (ids continuing). The owner index, cell lists, adjacency and holes765+ * are rebuilt for the union; hues of existing tiles are kept and the new766+ * tiles get greedy hues against their neighbours, so nothing already drawn767+ * changes colour. Returns a new object; the old geometry is untouched.768+ */769+export function extendGeometry(prev: Geometry, m: Manifest): Geometry {770+ const next = buildGeometry(m);771+ const hues = new Uint8Array(m.tiles.length).fill(255);772+ hues.set(prev.hues.subarray(0, Math.min(prev.hues.length, hues.length)));773+ for (let i = prev.hues.length; i < m.tiles.length; i++) {774+ const used = new Set<number>();775+ for (const j of next.adjacency[i]) if (hues[j] !== 255) used.add(hues[j]);776+ let h = 0;777+ while (used.has(h) && h < HUES) h++;778+ if (h === HUES) {779+ const counts = new Array(HUES).fill(0);780+ for (const j of next.adjacency[i]) if (hues[j] !== 255) counts[hues[j]]++;781+ h = counts.indexOf(Math.min(...counts));782+ }783+ hues[i] = h;784+ }785+ return { ...next, hues };786+}787+705788 /**706789 * Greedy 7-colouring in smallest-last order (the contact graph of a square707790 * packing is planar, so this never needs more than 6). Deterministic.
@@ -839,7 +922,12 @@ export function emptyState(): State {
839922 last_t: null,840923 agents: [],841924 tileAgent: [],925+ tileRevealer: [],842926 meets: [],927+ reached: new Uint8Array(0),928+ grown: 0,929+ seedRings: [],930+ exhausted: false,843931 analysis: emptyAnalysis(),844932 agentStats: {},845933 batchSizes: [],
@@ -923,13 +1011,18 @@ export function isOnFrontier(s: State, tile: number, agent?: string): boolean {
9231011 export function joinCandidates(s: State): number[] {9241012 if (!s.manifest || !s.geo) return [];9251013 const claimed = new Set(s.agents.map((a) => a.seed_tile));926- const listed = [s.manifest.seed_tile, ...(s.manifest.seed_tiles ?? [])]927- .filter((t, i, arr) => arr.indexOf(t) === i);928- const first = listed.filter((t) =>929- !claimed.has(t) && s.tiles[t] === "hidden" &&930- s.geo!.adjacency[t].every((u) => s.tiles[u] === "hidden")931- );932- if (first.length) return first;1014+ // newest ring first: a late joiner starts where the field just grew1015+ const rings = s.seedRings.length1016+ ? [...s.seedRings].reverse()1017+ : [[s.manifest.seed_tile, ...(s.manifest.seed_tiles ?? [])]];1018+ for (const ring of rings) {1019+ const listed = ring.filter((t, i, arr) => arr.indexOf(t) === i);1020+ const first = listed.filter((t) =>1021+ !claimed.has(t) && s.tiles[t] === "hidden" &&1022+ s.geo!.adjacency[t].every((u) => s.tiles[u] === "hidden")1023+ );1024+ if (first.length) return first;1025+ }9331026 const out: number[] = [];9341027 for (const t of s.manifest.tiles) {9351028 if (s.tiles[t.id] !== "hidden" || claimed.has(t.id)) continue;
@@ -1186,6 +1279,36 @@ function scoreReveal(
11861279 }11871280 11881281 /** Apply one event, returning a new state. The input is not mutated. */1282+/** Recompute `reached` (see the contract) from tiles, adjacency and seeds. */1283+function computeReached(s: State): Uint8Array {1284+ const n = s.tiles.length;1285+ const out = new Uint8Array(n);1286+ const geo = s.geo!;1287+ for (let t = 0; t < n; t++) {1288+ if (s.tiles[t] === "hidden") continue;1289+ out[t] = 1;1290+ for (const u of geo.adjacency[t]) out[u] = 1;1291+ }1292+ for (const a of s.agents) if (a.seed_tile < n) out[a.seed_tile] = 1;1293+ return out;1294+}1295+1296+/** Whether the tile is reached by one group: revealed/solved by it, next to a tile it revealed/solved, or its member's seed. */1297+export function reachedByGroup(s: State, tile: number, group: string): boolean {1298+ const mine = (t: number) => {1299+ const r = s.tileRevealer[t], a = s.tileAgent[t];1300+ return (r !== null && r !== undefined && groupOf(s, r) === group) ||1301+ (a !== null && a !== undefined && groupOf(s, a) === group);1302+ };1303+ if (mine(tile)) return true;1304+ if (1305+ s.agents.some((x) => groupOf(s, x.id) === group && x.seed_tile === tile)1306+ ) {1307+ return true;1308+ }1309+ return s.geo!.adjacency[tile].some(mine);1310+}1311+11891312 export function applyEvent(prev: State, ev: GameEvent): State {11901313 const w = new StoreWriter(prev.cellStore);11911314 const s: State = {
@@ -1195,6 +1318,7 @@ export function applyEvent(prev: State, ev: GameEvent): State {
11951318 console: [...prev.console],11961319 totals: { ...prev.totals },11971320 tileAgent: [...prev.tileAgent],1321+ tileRevealer: [...prev.tileRevealer],11981322 agentStats: Object.fromEntries(11991323 Object.entries(prev.agentStats).map(([k, v]) => [k, {12001324 ...v,
@@ -1236,7 +1360,15 @@ export function applyEvent(prev: State, ev: GameEvent): State {
12361360 s.rules = ev.rules;12371361 s.tiles = new Array(m.tiles.length).fill("hidden");12381362 s.tileAgent = new Array(m.tiles.length).fill(null);1363+ s.tileRevealer = new Array(m.tiles.length).fill(null);12391364 s.knownCount = new Uint32Array(m.tiles.length);1365+ s.seedRings = [1366+ [m.seed_tile, ...(m.seed_tiles ?? [])].filter((t, i, a) =>1367+ a.indexOf(t) === i1368+ ),1369+ ];1370+ s.grown = 0;1371+ s.exhausted = false;12401372 for (const [id, v] of zoneCells(m)) {12411373 markKnown(s, w, id, { v, kind: "zone", rev: 0 });12421374 }
@@ -1282,6 +1414,7 @@ export function applyEvent(prev: State, ev: GameEvent): State {
12821414 const tile = tileIdOf(s, ev.tile);12831415 const frontier = frontierOf(s, agent); // before the reveal lands12841416 s.tiles[tile] = "revealed";1417+ s.tileRevealer[tile] = agent;12851418 const touched: number[] = [tile];12861419 for (const [id, v] of ev.givens) {12871420 markKnown(s, w, id, { v, kind: "given", rev: ev.rev });
@@ -1363,12 +1496,57 @@ export function applyEvent(prev: State, ev: GameEvent): State {
13631496 }13641497 break;13651498 }1499+ case "grow": {1500+ const m = s.manifest!;1501+ const before = m.tiles.length;1502+ const bad = ev.tiles.find((t, i) => t.id !== before + i);1503+ if (bad) {1504+ throw new Error(1505+ `grow: tile ids must continue from ${before}, got ${bad.id}`,1506+ );1507+ }1508+ const tiles = [...m.tiles, ...ev.tiles];1509+ s.manifest = {1510+ ...m,1511+ tiles,1512+ seed_tiles: [...(m.seed_tiles ?? []), ...ev.seed_tiles],1513+ ...(ev.field ? { field: ev.field } : {}),1514+ };1515+ s.geo = extendGeometry(s.geo!, s.manifest);1516+ s.tiles = [...s.tiles, ...ev.tiles.map(() => "hidden" as TileStatus)];1517+ s.tileAgent = [...s.tileAgent, ...ev.tiles.map(() => null)];1518+ s.tileRevealer = [...s.tileRevealer, ...ev.tiles.map(() => null)];1519+ const kc = new Uint32Array(tiles.length);1520+ kc.set(s.knownCount);1521+ s.knownCount = kc;1522+ s.seedRings = [...s.seedRings, ev.seed_tiles];1523+ s.grown++;1524+ if (ev.exhausted) s.exhausted = true;1525+ // distances from every seed change with the new tiles1526+ for (const a of s.agents) {1527+ s.agentStats[a.id] = {1528+ ...s.agentStats[a.id],1529+ seed_dist: distancesFrom(s.geo, a.seed_tile),1530+ };1531+ }1532+ s.console.push({1533+ ...base,1534+ op: "grow",1535+ intent: ev.tiles.length1536+ ? `ring ${ev.ring}: ${ev.tiles.length} tiles grew around the field (${tiles.length} now), ${ev.seed_tiles.length} new seed tile${1537+ ev.seed_tiles.length === 1 ? "" : "s"1538+ }`1539+ : `ring ${ev.ring}: no room left to grow`,1540+ });1541+ break;1542+ }13661543 case "end": {13671544 s.status = "solved";13681545 s.console.push({ ...base, op: "end", intent: "all tiles solved" });13691546 break;13701547 }13711548 }1549+ s.reached = computeReached(s);13721550 s.last_t = ev.t;13731551 s.cellStore = w.store();13741552 s.cells = cellsFacade(s.cellStore);
M site/lib/games.ts +126 -6
@@ -4,9 +4,16 @@
44 // State is rebuilt from the JSONL on restart only for spectating (the55 // private solution is not in the log, so a restarted server cannot66 // continue a game; the demo is one process). Games nobody joined expire.7-import { type GameRules } from "./fold.ts";8-import { gen, type GenOutput, type GenParams, judge } from "./engine.ts";9-import { Referee } from "./referee.ts";7+import { type GameRules, type GrowRules } from "./fold.ts";8+import {9+ gen,10+ type GenOutput,11+ type GenParams,12+ grow as engineGrow,13+ judge,14+ mergeGrowth,15+} from "./engine.ts";16+import { type Judge, Referee } from "./referee.ts";1017 import { logPath } from "./log.ts";1118 1219 export type Pending = {
@@ -17,11 +24,20 @@ export type Pending = {
1724 error?: string;1825 };1926 20-type Entry = { kind: "pending"; pending: Pending } | {27+type Ready = {2128 kind: "ready";2229 ref: Referee;2330 started: number;31+ /** the full private output so far (grows with the field) */32+ priv: GenOutput;33+ seed: string;34+ grow: GrowRules | null;35+ growing: boolean;36+ exhausted: boolean;37+ timer: number | null;38+ lastGrow: number; // ms since epoch of the last growth (or start)2439 };40+type Entry = { kind: "pending"; pending: Pending } | Ready;2541 2642 type NewGameListener = (id: string) => void;2743 type Registry = {
@@ -76,6 +92,11 @@ export type StartParams = {
7692 chunk_g?: number;7793 seed?: string;7894 rules?: Partial<GameRules>;95+ /** kind=field: growth policy */96+ grow?: GrowRules;97+ /** kind=field: allocate the field at this side with the initial chunks98+ * centred, so rings can grow around them (1000 for the daily) */99+ field_g?: number;79100 };80101 81102 export type GameSummary = {
@@ -85,6 +106,9 @@ export type GameSummary = {
85106 G: number;86107 tiles: number;87108 agents: string[];109+ grown?: number;110+ growing?: boolean;111+ fog?: boolean;88112 error?: string;89113 };90114
@@ -99,6 +123,9 @@ export function listGames(): GameSummary[] {
99123 G: e.ref.state.G,100124 tiles: e.ref.manifest.tiles.length,101125 agents: e.ref.state.agents.map((a) => a.id),126+ grown: e.ref.state.grown,127+ growing: e.growing,128+ fog: e.ref.fog,102129 }103130 : {104131 game_id: e.pending.game_id,
@@ -123,9 +150,13 @@ export function startGame(
123150 p: StartParams,124151 generate: Generator = gen,125152 now: () => number = Date.now,153+ judgeFn: Judge = judge,126154 ): Pending {127155 const gameId = crypto.randomUUID();128156 const seed = p.seed ?? gameId;157+ const growing = p.kind === "field" && !!p.grow &&158+ (p.grow.every_minutes !== undefined ||159+ p.grow.frontier_coverage !== undefined);129160 const params: GenParams = {130161 seed,131162 G: p.G,
@@ -133,6 +164,7 @@ export function startGame(
133164 n: p.n,134165 chunks: p.chunks,135166 chunk_g: p.chunk_g,167+ ...(p.kind === "field" && p.field_g ? { field_g: p.field_g } : {}),136168 };137169 const pending: Pending = {138170 status: "generating",
@@ -145,14 +177,29 @@ export function startGame(
145177 const rules: GameRules = {146178 guess: p.rules?.guess ?? "accept",147179 depth: p.rules?.depth ?? 0,180+ fog: p.rules?.fog ?? (p.kind === "field"),181+ ...(growing ? { grow: p.grow } : {}),148182 };149183 (async () => {150184 try {151185 const out = await generate(params);152- const ref = await Referee.create(gameId, out, rules, judge, { seed });186+ const ref = await Referee.create(gameId, out, rules, judgeFn, { seed });153187 if (!games.has(gameId)) return; // expired meanwhile154- games.set(gameId, { kind: "ready", ref, started: pending.started });188+ const entry: Ready = {189+ kind: "ready",190+ ref,191+ started: pending.started,192+ priv: out,193+ seed,194+ grow: growing ? p.grow! : null,195+ growing: false,196+ exhausted: false,197+ timer: null,198+ lastGrow: now(),199+ };200+ games.set(gameId, entry);155201 reg.latest = gameId;202+ wireGrowth(gameId, entry, now);156203 for (const l of newGameListeners) l(gameId);157204 } catch (e) {158205 pending.status = "failed";
@@ -162,6 +209,75 @@ export function startGame(
162209 return pending;163210 }164211 212+export type Grower = (213+ state: GenOutput,214+ seed: string,215+ ring: number,216+) => Promise<GenOutput>;217+218+/** Test hook: the growth engine call (the real one runs `field --grow`). */219+export let grower: Grower = engineGrow;220+export function setGrower(g: Grower): void {221+ grower = g;222+}223+224+/**225+ * Growth policy: a timer (`every_minutes`), a coverage threshold checked226+ * after every write (`frontier_coverage`: share of the current tiles227+ * revealed or solved), and always when every current tile is solved.228+ * Growth runs in the background; the `grow` event lands between batches229+ * under the referee's write lock. Stops when a ring finds no room.230+ */231+function wireGrowth(id: string, e: Ready, now: () => number): void {232+ if (!e.grow) return;233+ e.ref.canGrow = () => !e.exhausted;234+ e.ref.onNeedGrow = () => {235+ growGame(id).catch(() => {});236+ };237+ if (e.grow.every_minutes) {238+ const ms = Math.max(5, e.grow.every_minutes * 60) * 1000;239+ e.timer = setInterval(() => {240+ if (now() - e.lastGrow >= ms - 1) growGame(id).catch(() => {});241+ }, Math.min(ms, 15000));242+ Deno.unrefTimer(e.timer);243+ }244+ if (e.grow.frontier_coverage !== undefined) {245+ const threshold = e.grow.frontier_coverage;246+ e.ref.log.subscribe((ev) => {247+ if (ev.kind !== "batch" || e.growing || e.exhausted) return;248+ const st = e.ref.state;249+ const n = st.tiles.length;250+ const active = st.tiles.filter((t) => t !== "hidden").length;251+ if (n > 0 && active / n >= threshold) growGame(id).catch(() => {});252+ });253+ }254+}255+256+/** Grow a field by one ring now (idempotent while a growth is running). */257+export async function growGame(id: string): Promise<boolean> {258+ const e = games.get(id);259+ if (!e || e.kind !== "ready" || !e.grow || e.growing || e.exhausted) {260+ return false;261+ }262+ if (e.ref.state.status === "solved") return false;263+ e.growing = true;264+ try {265+ const ring = e.ref.state.grown + 1;266+ const delta = await grower(e.priv, e.seed, ring);267+ const ev = await e.ref.grow(delta, ring);268+ e.priv = mergeGrowth(e.priv, delta);269+ e.lastGrow = Date.now();270+ if (ev.exhausted) {271+ e.exhausted = true;272+ if (e.timer !== null) clearInterval(e.timer);273+ e.timer = null;274+ }275+ return true;276+ } finally {277+ e.growing = false;278+ }279+}280+165281 /** Wait for a starting game to be ready (tests and scripts). */166282 export async function whenReady(167283 id: string,
@@ -201,6 +317,7 @@ export async function sweep(now = Date.now()): Promise<string[]> {
201317 } else if (e.pending.status === "generating") {202318 continue; // still working; it will be judged once ready203319 }320+ if (e.kind === "ready" && e.timer !== null) clearInterval(e.timer);204321 games.delete(id);205322 if (reg.latest === id) reg.latest = null;206323 dropped.push(id);
@@ -219,6 +336,9 @@ function ensureSweeper(): void {
219336 220337 /** Test hook: forget every game. */221338 export function resetGames(): void {339+ for (const e of games.values()) {340+ if (e.kind === "ready" && e.timer !== null) clearInterval(e.timer);341+ }222342 games.clear();223343 reg.latest = null;224344 }
M site/lib/mcp.ts +30 -7
@@ -66,7 +66,7 @@ function findImplementation(o: unknown): string | undefined {
6666 }6767 6868 const RULES_TEXT =69- `Rules of a mosaic. The board is a G x G field of binary cells partly covered by tiles. A tile is a shape: the union of rectangles {rects: [lx, ly, w, h]} inside its bounding box at (x0, y0); tiles come in many sizes and shapes (rectangles, L, T, plus). Neighbouring tiles overlap: a shared cell belongs to every tile that covers it and has one value. Cells no tile covers are holes: never playable. Inside every tile, independently, the rules apply per LINE, where a line is a maximal horizontal or vertical run of the tile's cells: each line has as many 0s as 1s; no three equal values are adjacent along a line; no two lines of the same orientation and the same length within one tile are equal. Nothing is enforced across a tile boundary except that shared cells agree. Every tile ALONE (its givens only) has 2..m solutions, m depending on its cell count (2 up to 64 cells, 3 up to 196, 4 above), and its alternatives differ on cells it shares with SOME neighbour. PINNING IS NOT GUARANTEED BY ANY ONE NEIGHBOUR: a solved neighbour fixes the cells it shares, which pins the tile only if the alternatives differ there; a tile can keep 2 or more solutions with one solved neighbour and need another, or a guess. AGENTS: several agents play one field. Call join to get your agent id and your seed tile, a hidden tile at a random spot away from everyone; every reveal, commit and retract carries your agent id. Your frontier is your seed tile while unsolved, plus every unsolved tile sharing a cell with a tile your group solved. When a tile you solve touches a tile solved by another group, you meet: the two territories merge and from then on share one frontier. Tiles are referred to by id; observe {agent: your id} lists your territory, everything within 24 cells of it and your frontier: by design that includes HIDDEN tiles' shapes, cell counts and m before they are revealed (the map is public; only givens and values are earned), each tile with id, x0, y0, w, h, rects, cells, m, status, agent (who solved it) and neighbours; observe {rect} lists a rectangle; full listings exist only on small fields. Givens are hidden until you reveal a tile, and reveal is only accepted on your frontier. Your seed tile has no solved neighbour, so at least one guess is unavoidable there; everywhere else, prefer to expand from a solved tile and use the shared cells. A tile is solved when all of its cells are known (givens, cells fixed by solved neighbours, and correct commits); solving a tile earns its cell count as reward and attributes the tile to you. COMMITS are checked per cell against the true solution: a wrong value is rejected and reported as {id, reason: "wrong", v}, and since cells are binary a rejection is full information: the other value is correct, commit it next. Correct cells are accepted and classified forced or guess. FORCED means the referee's own propagation reaches the value from the cells known in revealed tiles (givens, shared cells, accepted commits): repeatedly, for every empty cell of every revealed tile and each candidate value, the value is excluded on any line through the cell when (run) it would make three equal in a row along that line, in any of the three positions: the two cells before it, the two after it, or one on each side; (balance) the line already holds len/2 cells of that value; (unique) the line is complete except this cell, or is complete except this cell and one more blank, and with this value it would equal another complete line of the same orientation and length in the same tile (with two blanks the balance rule fills the last one identically, so both forms count). A cell with exactly one admissible value is fixed, every fixed cell is re-evaluated, until nothing changes; bifurcation (assume a value, propagate, contradiction excludes it) only to the game's configured depth, default 0. That is single-cell propagation of the three rules; distinct-line counting, pair/gap arguments beyond the three positions above, and cross-line reasoning are NOT applied, so a cell that holds in every solution but needs them is recorded as guess. Commit propagation-forced cells first if you care about that split. Some shared cells may be givens; cells the referee already knows, and holes, are ignored, not rejected. Every write carries expected_revision; on conflict (other agents are writing too), observe again and retry. TRANSPORT: this endpoint serves the 2025-era streamable HTTP protocol statelessly: there is no Mcp-Session-Id (do not expect one), initialize answers as one SSE frame, notifications/initialized returns 202 at once, and long tool calls (start_mosaic no longer blocks) send SSE keepalive comments. Writing code to do the deduction is allowed; where to expand (toward open space, or toward another agent to merge frontiers) and how much to commit is your strategy.`;69+ `Rules of a mosaic. The board is a G x G field of binary cells partly covered by tiles. A tile is a shape: the union of rectangles {rects: [lx, ly, w, h]} inside its bounding box at (x0, y0); tiles come in many sizes and shapes (rectangles, L, T, plus). Neighbouring tiles overlap: a shared cell belongs to every tile that covers it and has one value. Cells no tile covers are holes: never playable. Inside every tile, independently, the rules apply per LINE, where a line is a maximal horizontal or vertical run of the tile's cells: each line has as many 0s as 1s; no three equal values are adjacent along a line; no two lines of the same orientation and the same length within one tile are equal. Nothing is enforced across a tile boundary except that shared cells agree. Every tile ALONE (its givens only) has 2..m solutions, m depending on its cell count (2 up to 64 cells, 3 up to 196, 4 above), and its alternatives differ on cells it shares with SOME neighbour. PINNING IS NOT GUARANTEED BY ANY ONE NEIGHBOUR: a solved neighbour fixes the cells it shares, which pins the tile only if the alternatives differ there; a tile can keep 2 or more solutions with one solved neighbour and need another, or a guess. AGENTS: several agents play one field. Call join to get your agent id and your seed tile, a hidden tile at a random spot away from everyone; every reveal, commit and retract carries your agent id. Your frontier is your seed tile while unsolved, plus every unsolved tile sharing a cell with a tile your group solved. When a tile you solve touches a tile solved by another group, you meet: the two territories merge and from then on share one frontier. Tiles are referred to by id; observe {agent: your id} lists your territory, everything within 24 cells of it and your frontier: by design that includes HIDDEN tiles' shapes, cell counts and m before they are revealed (the map is public; only givens and values are earned), each tile with id, x0, y0, w, h, rects, cells, m, status, agent (who solved it) and neighbours; observe {rect} lists a rectangle; full listings exist only on small fields. Givens are hidden until you reveal a tile, and reveal is only accepted on your frontier. Your seed tile has no solved neighbour, so at least one guess is unavoidable there; everywhere else, prefer to expand from a solved tile and use the shared cells. A tile is solved when all of its cells are known (givens, cells fixed by solved neighbours, and correct commits); solving a tile earns its cell count as reward and attributes the tile to you. COMMITS are checked per cell against the true solution: a wrong value is rejected and reported as {id, reason: "wrong", v}, and since cells are binary a rejection is full information: the other value is correct, commit it next. Correct cells are accepted and classified forced or guess. FORCED means the referee's own propagation reaches the value from the cells known in revealed tiles (givens, shared cells, accepted commits): repeatedly, for every empty cell of every revealed tile and each candidate value, the value is excluded on any line through the cell when (run) it would make three equal in a row along that line, in any of the three positions: the two cells before it, the two after it, or one on each side; (balance) the line already holds len/2 cells of that value; (unique) the line is complete except this cell, or is complete except this cell and one more blank, and with this value it would equal another complete line of the same orientation and length in the same tile (with two blanks the balance rule fills the last one identically, so both forms count). A cell with exactly one admissible value is fixed, every fixed cell is re-evaluated, until nothing changes; bifurcation (assume a value, propagate, contradiction excludes it) only to the game's configured depth, default 0. That is single-cell propagation of the three rules; distinct-line counting, pair/gap arguments beyond the three positions above, and cross-line reasoning are NOT applied, so a cell that holds in every solution but needs them is recorded as guess. Commit propagation-forced cells first if you care about that split. Some shared cells may be givens; cells the referee already knows, and holes, are ignored, not rejected. Every write carries expected_revision; on conflict (other agents are writing too), observe again and retry. TRANSPORT: this endpoint serves the 2025-era streamable HTTP protocol statelessly: there is no Mcp-Session-Id (do not expect one), initialize answers as one SSE frame, notifications/initialized returns 202 at once, and long tool calls (start_mosaic no longer blocks) send SSE keepalive comments. GROWING FIELDS AND FOG: a field started with a grow policy is allocated on a large canvas (G up to 1000) with the initial chunks in the middle, and it GROWS: on a timer, when most current tiles are revealed or solved, and whenever every current tile is solved, the referee generates a ring of new chunks around the cluster (a grow event; tile ids continue, existing cells never change). Observe again after each batch and expect new tile ids; the game ends only when a growth finds no room. Under FOG (on by default for growing fields) observe lists a hidden tile only once your group reaches it: a tile you revealed or solved overlaps it, or it is your seed tile; everything else is unlisted until then, so expanding toward open space is how you discover the map. Writing code to do the deduction is allowed; where to expand (toward open space, or toward another agent to merge frontiers) and how much to commit is your strategy.`;7070 7171 const gameIdSchema = z.string().uuid();7272
@@ -137,10 +137,23 @@ export function createServer(): McpServer {
137137 depth: z.number().int().min(0).max(2).default(0).describe(138138 "Bifurcation depth the referee uses to call a cell forced.",139139 ),140+ fog: z.boolean().optional().describe(141+ "Hide unreached tiles from observe (default: on for growing fields, off otherwise).",142+ ),140143 }).optional(),144+ grow: z.object({145+ every_minutes: z.number().min(0.1).max(1440).optional().describe(146+ "kind=field: grow one ring of chunks this often.",147+ ),148+ frontier_coverage: z.number().min(0.1).max(1).optional().describe(149+ "kind=field: grow when this share of the current tiles is revealed or solved.",150+ ),151+ }).optional().describe(152+ "kind=field only: growth policy. The field is then allocated at G (default 1000) with the initial chunks centred; it grows outward in rings and ends when no room is left.",153+ ),141154 }),142155 },143- ({ G, kind, n, chunks, chunk_g, seed, rules }) => {156+ ({ G, kind, n, chunks, chunk_g, seed, rules, grow }) => {144157 try {145158 if (n % 2 !== 0) return errorResult(new Error("n must be even."));146159 if (kind === "grid" && (G - 1) % (n - 1) !== 0) {
@@ -148,6 +161,9 @@ export function createServer(): McpServer {
148161 new Error(`kind=grid needs G-1 to be a multiple of n-1.`),149162 );150163 }164+ const growing = kind === "field" && !!grow &&165+ (grow.every_minutes !== undefined ||166+ grow.frontier_coverage !== undefined);151167 const pending = startGame({152168 G,153169 kind,
@@ -156,6 +172,7 @@ export function createServer(): McpServer {
156172 chunk_g,157173 seed,158174 rules,175+ ...(growing ? { grow, field_g: G >= 200 ? G : 1000 } : {}),159176 });160177 return textResult(161178 `Started field ${pending.game_id}: generating in the background (kind ${kind}, G ${G}). Poll observe {game_id: "${pending.game_id}"} or list_games every 10-20 s until status is playing, then join with this game_id.`,
@@ -191,9 +208,11 @@ export function createServer(): McpServer {
191208 ? `${g.game_id}: ${g.status}${192209 g.error ? " (" + g.error + ")" : ""193210 }, G ${g.G}`194- : `${g.game_id}: ${g.status} at rev ${g.revision}, ${g.G}x${g.G}, ${g.tiles} tiles, agents ${195- JSON.stringify(g.agents)196- }`211+ : `${g.game_id}: ${g.status} at rev ${g.revision}, ${g.G}x${g.G}, ${g.tiles} tiles${212+ g.grown213+ ? `, grown ${g.grown} ring${g.grown === 1 ? "" : "s"}`214+ : ""215+ }${g.fog ? ", fog" : ""}, agents ${JSON.stringify(g.agents)}`197216 ).join("\n")198217 : "No field is running; start one with start_mosaic.",199218 { games },
@@ -253,7 +272,7 @@ export function createServer(): McpServer {
253272 {254273 title: "Observe the mosaic",255274 description:256- "Read the current revision and a scope of tiles (id, x0, y0, w, h, rects, cells, m, status, agent, neighbours) with the frontier and every known cell in them (delta-coded ids plus a value string; decode by cumulative sum). Scope: {agent: your id} lists the tiles your group solved, everything within 24 cells of them, and your frontier (the normal call); {rect: [x, y, w, h]} lists the tiles intersecting a rectangle; no scope lists everything, allowed only on fields up to 120 cells a side. Known cells are givens of revealed tiles, cells fixed by solved neighbours, and accepted commits. The payload is under structuredContent.observation; cell id = y*G + x.",275+ "Read the current revision and a scope of tiles (id, x0, y0, w, h, rects, cells, m, status, agent, neighbours) with the frontier and every known cell in them (delta-coded ids plus a value string; decode by cumulative sum). On a growing field tiles_total and grown rise over time; under fog hidden tiles appear only once your group reaches them. Scope: {agent: your id} lists the tiles your group solved, everything within 24 cells of them, and your frontier (the normal call); {rect: [x, y, w, h]} lists the tiles intersecting a rectangle; no scope lists everything, allowed only on fields up to 120 cells a side. Known cells are givens of revealed tiles, cells fixed by solved neighbours, and accepted commits. The payload is under structuredContent.observation; cell id = y*G + x.",257276 inputSchema: z.object({258277 game_id: gameIdSchema,259278 agent: z.string().min(1).max(80).optional(),
@@ -283,7 +302,11 @@ export function createServer(): McpServer {
283302 const obs = ref.observe({ agent, rect, full });284303 const solved = obs.tiles.filter((t) => t.status === "solved").length;285304 return textResult(286- `Game ${game_id} is ${obs.status} at revision ${obs.revision}; ${solved}/${obs.tiles.length} listed tiles solved (${obs.tiles_total} in the field); reward ${obs.totals.reward}; ${obs.agents.length} agents; frontier ${305+ `Game ${game_id} is ${obs.status} at revision ${obs.revision}; ${solved}/${obs.tiles.length} listed tiles solved (${obs.tiles_total} in the field${306+ obs.grown ? `, grown ${obs.grown}` : ""307+ }${308+ obs.fog ? ", fog" : ""309+ }); reward ${obs.totals.reward}; ${obs.agents.length} agents; frontier ${287310 JSON.stringify(obs.frontier)288311 }.`,289312 { observation: obs },
M site/lib/referee.ts +117 -4
@@ -13,10 +13,12 @@ import {
1313 deltaEncode,1414 emptyState,1515 type EndEvent,16+ type FieldInfo,1617 frontierOf,1718 type GameEvent,1819 type GameRules,1920 groupOf,21+ type GrowEvent,2022 IMPLICIT_AGENT,2123 isHole,2224 isOnFrontier,
@@ -29,10 +31,12 @@ import {
2931 neighboursOf,3032 normaliseManifest,3133 ownersOf,34+ reachedByGroup,3235 type RejectedCell,3336 type RetractEvent,3437 type RevealEvent,3538 type State,39+ tileCells,3640 type TileSpec,3741 } from "./fold.ts";3842 import type { GenOutput, JudgeInput, JudgeOutput } from "./engine.ts";
@@ -128,6 +132,9 @@ export type Observation = {
128132 }[];129133 tiles_total: number;130134 holes: number;135+ field: FieldInfo | null;136+ grown: number;137+ fog: boolean;131138 known_deltas: number[];132139 known_values: string;133140 committed_deltas: number[];
@@ -142,8 +149,17 @@ export class Referee {
142149 private givensByTile = new Map<number, [number, 0 | 1][]>();143150 private specs = new Map<number, TileSpec>();144151 145- /** the manifest as v3 (converted when the generator wrote v2) */146- readonly manifest: Manifest;152+ /** the manifest as v3 (converted when the generator wrote v2); grows */153+ manifest: Manifest;154+ /**155+ * Set by the game registry: whether the field can still grow (a grow156+ * policy is set and the last ring was not exhausted). With growth157+ * pending, solving every current tile does not end the game; the158+ * registry grows it instead.159+ */160+ canGrow: () => boolean = () => false;161+ /** Set by the registry: called (not awaited) when every tile is solved and growth is pending. */162+ onNeedGrow: () => void = () => {};147163 148164 private constructor(149165 readonly gameId: string,
@@ -162,9 +178,16 @@ export class Referee {
162178 }163179 }164180 for (const t of gen.tiles) this.givensByTile.set(t.id, t.givens);165- for (const t of this.manifest.tiles) this.specs.set(t.id, t);181+ this.covered = new Uint8Array(G * G);182+ for (const t of this.manifest.tiles) {183+ this.specs.set(t.id, t);184+ for (const c of tileCells(t, G)) this.covered[c] = 1;185+ }166186 }167187 188+ /** cells some tile covers (grows with the field) */189+ private covered: Uint8Array;190+168191 static async create(169192 gameId: string,170193 gen: GenOutput,
@@ -360,6 +383,73 @@ export class Referee {
360383 );361384 }362385 386+ /** Fog: unreached hidden tiles are not listed to agents. */387+ get fog(): boolean {388+ return this.rules.fog ?? false;389+ }390+391+ /**392+ * Apply a growth delta from the engine (`field --grow`): the new tiles393+ * are appended (ids must continue), their givens and solution cells394+ * recorded, and one `grow` event appended. Existing cells never change395+ * (asserted against the delta's merged solution).396+ */397+ grow(delta: GenOutput, ring: number): Promise<GrowEvent> {398+ return this.locked(() => this.growLocked(delta, ring));399+ }400+401+ private async growLocked(delta: GenOutput, ring: number): Promise<GrowEvent> {402+ if (this.state.status === "solved") {403+ throw new RefereeError("The mosaic is already solved.", "ended");404+ }405+ const dm = normaliseManifest(delta.manifest);406+ const G = this.manifest.G;407+ if (dm.G !== G) {408+ throw new RefereeError(`grow: field side ${dm.G} != ${G}`, "grow");409+ }410+ const before = this.manifest.tiles.length;411+ dm.tiles.forEach((t, i) => {412+ if (t.id !== before + i) {413+ throw new RefereeError(414+ `grow: tile ids must continue from ${before}, got ${t.id}`,415+ "grow",416+ );417+ }418+ });419+ // merged solution: old cells must be unchanged420+ for (let y = 0; y < G; y++) {421+ const row = delta.solution[y];422+ for (let x = 0; x < G; x++) {423+ const ch = row[x];424+ const id = cellId(x, y, G);425+ const had = this.covered[id];426+ if (had && ch !== "." && (ch === "1" ? 1 : 0) !== this.solution[id]) {427+ throw new RefereeError(`grow: cell ${id} changed`, "grow");428+ }429+ if (ch !== ".") this.solution[id] = ch === "1" ? 1 : 0;430+ }431+ }432+ for (const t of dm.tiles) {433+ this.specs.set(t.id, t);434+ for (const c of tileCells(t, G)) this.covered[c] = 1;435+ }436+ for (const t of delta.tiles) this.givensByTile.set(t.id, t.givens);437+ const stats = delta.stats as Record<string, unknown> | undefined;438+ const ev: GrowEvent = {439+ rev: this.state.revision + 1,440+ t: new Date().toISOString(),441+ kind: "grow",442+ ring,443+ tiles: dm.tiles,444+ seed_tiles: dm.seed_tiles ?? [],445+ ...(dm.field ? { field: dm.field } : {}),446+ ...(stats?.exhausted || dm.tiles.length === 0 ? { exhausted: true } : {}),447+ };448+ await this.apply(ev);449+ this.manifest = this.state.manifest!;450+ return ev;451+ }452+363453 reveal(364454 expected: number,365455 tile: number,
@@ -563,7 +653,11 @@ export class Referee {
563653 if (meets.length) ev.meets = meets;564654 await this.apply(ev);565655 const reward = ev.solved.reduce((a, i) => a + this.specs.get(i)!.cells, 0);566- if (allSolved(this.state)) {656+ if (allSolved(this.state) && this.canGrow()) {657+ // every current tile is solved but the field can still grow: no end,658+ // the registry grows it (the next observe shows the new ring)659+ this.onNeedGrow();660+ } else if (allSolved(this.state)) {567661 const end: EndEvent = {568662 rev: this.state.revision + 1,569663 t: new Date().toISOString(),
@@ -702,6 +796,19 @@ export class Referee {
702796 frontier = frontierOf(s);703797 scope = { kind: "full" };704798 }799+ if (this.fog) {800+ // fog: a hidden tile is listed only when the caller's group reaches801+ // it (a tile the group revealed or solved overlaps it, or it is a802+ // member's seed tile); without an agent only revealed and solved803+ // tiles are listed804+ const group = opts.agent !== undefined ? groupOf(s, opts.agent) : null;805+ chosen = chosen.filter((t) =>806+ s.tiles[t.id] !== "hidden" ||807+ (group !== null && reachedByGroup(s, t.id, group))808+ );809+ const ids = new Set(chosen.map((t) => t.id));810+ frontier = frontier.filter((t) => ids.has(t));811+ }705812 const known = new Set<number>();706813 const tiles = [];707814 for (const t of chosen) {
@@ -749,6 +856,9 @@ export class Referee {
749856 tiles,750857 tiles_total: this.manifest.tiles.length,751858 holes: geo.holes,859+ field: this.manifest.field ?? null,860+ grown: s.grown,861+ fog: this.fog,752862 known_deltas: deltaEncode(ids),753863 known_values: ids.map((id) => String(cellAt(s, id)!.v)).join(""),754864 committed_deltas: deltaEncode(committed),
@@ -768,6 +878,9 @@ export class Referee {
768878 agent:769879 "on a tile: who solved it; agents[].group is the territory it belongs to (groups merge when they meet)",770880 holes: "cells no tile covers; never playable",881+ fog:882+ "when true, hidden tiles are listed only once your group reaches them (a tile you revealed or solved overlaps them, or they are your seed); the field may also grow: observe again after a grow and expect new tile ids",883+ grown: "how many rings of chunks have grown around the initial field",771884 },772885 };773886 }
M site/scripts/solve.ts +36 -3
@@ -6,6 +6,7 @@
66 //77 // deno run -A scripts/solve.ts [--url http://127.0.0.1:8000/mcp] [--G 100] [--kind scatter|grid|big-first|bands|field] [--chunks 3] [--n 14] [--seed s] [--limit 6]88 // [--join <game_id>] [--agent name] [--model m] [--strategy bfs|dfs|greedy] [--max-batches N]9+// [--grow-every MIN] [--grow-coverage F] [--fog true|false] [--until-grown N]910 //1011 // --join plays an existing field as one more agent (several solve.ts1112 // processes on one game_id share it); --strategy picks the next tile to
@@ -27,6 +28,12 @@ const CHUNKS = Number(args.chunks ?? 3);
2728 const STRATEGY = (args.strategy ?? "bfs") as "bfs" | "dfs" | "greedy";2829 const MAX_BATCHES = Number(args["max-batches"] ?? Infinity);2930 const AGENT_NAME = args.agent ?? `solve-${STRATEGY}`;31+const GROW_EVERY = args["grow-every"] ? Number(args["grow-every"]) : undefined;32+const GROW_COVERAGE = args["grow-coverage"]33+ ? Number(args["grow-coverage"])34+ : undefined;35+const UNTIL_GROWN = Number(args["until-grown"] ?? 0);36+const FOG = args.fog !== undefined ? args.fog !== "false" : undefined;3037 3138 let nextId = 1;3239 async function rpc(method: string, params: unknown): Promise<any> {
@@ -231,12 +238,22 @@ let gameId: string;
231238 if (args.join) {232239 gameId = args.join;233240 } else {241+ const grow = GROW_EVERY !== undefined || GROW_COVERAGE !== undefined242+ ? {243+ ...(GROW_EVERY !== undefined ? { every_minutes: GROW_EVERY } : {}),244+ ...(GROW_COVERAGE !== undefined245+ ? { frontier_coverage: GROW_COVERAGE }246+ : {}),247+ }248+ : undefined;234249 const started = await call("start_mosaic", {235250 G: G_ARG,236251 kind: KIND,237252 n: N_ARG,238253 chunks: CHUNKS,239254 ...(args.seed ? { seed: args.seed } : {}),255+ ...(grow ? { grow } : {}),256+ ...(FOG !== undefined ? { rules: { fog: FOG } } : {}),240257 });241258 gameId = started.game_id;242259 console.log("game", gameId, started.status ?? "");
@@ -344,6 +361,7 @@ const tileIds = (t: Shape): number[] => {
344361 };345362 346363 let batches = 0, guesses = 0;364+let idleLogged = -1;347365 const wrongKnown = new Map<number, number>(); // id -> value we learned by rejection348366 /** A write that lost the revision race (other agents) is retried after observing. */349367 async function write(name: string, a: Record<string, unknown>): Promise<any> {
@@ -375,9 +393,24 @@ while (obs.status !== "solved" && batches < MAX_BATCHES) {
375393 if (revealed.length) {376394 tile = revealed[0];377395 } else if (frontier.length === 0) {378- // my group has nothing left to do; someone else finishes the field379- console.log(`agent ${AGENT}: frontier empty, waiting for others`);380- await new Promise((r) => setTimeout(r, 500));396+ // my group has nothing left to do: wait for others, or for the field397+ // to grow; with --until-grown N, leave once N growths have landed and398+ // there is still nothing to do399+ if (UNTIL_GROWN > 0 && (obs.grown ?? 0) >= UNTIL_GROWN) {400+ console.log(401+ `agent ${AGENT}: frontier empty after ${obs.grown} growths, done`,402+ );403+ break;404+ }405+ if (idleLogged !== obs.revision) {406+ console.log(407+ `agent ${AGENT}: frontier empty at rev ${obs.revision} (grown ${408+ obs.grown ?? 0409+ }), waiting`,410+ );411+ idleLogged = obs.revision;412+ }413+ await new Promise((r) => setTimeout(r, 1000));381414 obs = await observe();382415 learn(obs);383416 continue;
M site/tests/fixture.ts +152 -0
@@ -317,3 +317,155 @@ export function stubJudge(input: JudgeInput): Promise<JudgeOutput> {
317317 })),318318 });319319 }320+321+/** Place a layout on a larger canvas, shifted by (dx, dy): a cluster with room to grow. */322+export function embed(L: Layout, G: number, dx: number, dy: number): Layout {323+ return {324+ ...L,325+ G,326+ tiles: L.tiles.map((t) => ({ ...t, x0: t.x0 + dx, y0: t.y0 + dy })),327+ };328+}329+330+/**331+ * A synthetic growth ring: rectangles dropped just outside the cluster's332+ * bounding box, each overlapping some existing border tile by at least 4333+ * cells (so it is pinnable) and no other new tile. Ids continue from the334+ * base; two of the new tiles become seed tiles. Returns the delta layout335+ * (new tiles only) with `seed_tiles` for the ring.336+ */337+export function growLayout(338+ base: Layout,339+ seed: string,340+ ring: number,341+): Layout {342+ const r = rng(`${seed}|grow|${ring}`);343+ const G = base.G;344+ const owners = new Uint8Array(G * G);345+ const cellsOf = (346+ t: { x0: number; y0: number; w: number; h: number; rects: LocalRect[] },347+ ) => {348+ const mask = tileMask(t as TileSpec);349+ const ids: number[] = [];350+ for (let y = 0; y < t.h; y++) {351+ for (let x = 0; x < t.w; x++) {352+ if (353+ mask[y * t.w + x] && t.x0 + x >= 0 && t.y0 + y >= 0 && t.x0 + x < G &&354+ t.y0 + y < G355+ ) {356+ ids.push(cellId(t.x0 + x, t.y0 + y, G));357+ }358+ }359+ }360+ return ids;361+ };362+ for (const t of base.tiles) for (const id of cellsOf(t)) owners[id] = 1;363+ let x0 = G, y0 = G, x1 = 0, y1 = 0;364+ for (const t of base.tiles) {365+ x0 = Math.min(x0, t.x0);366+ y0 = Math.min(y0, t.y0);367+ x1 = Math.max(x1, t.x0 + t.w);368+ y1 = Math.max(y1, t.y0 + t.h);369+ }370+ const fresh = new Uint8Array(G * G);371+ const tiles: Layout["tiles"] = [];372+ let id = base.tiles.length;373+ const tryPlace = (tx: number, ty: number, w: number, h: number) => {374+ if (tx < 0 || ty < 0 || tx + w > G || ty + h > G) return;375+ const t = { x0: tx, y0: ty, w, h, rects: [[0, 0, w, h]] as LocalRect[] };376+ const ids = cellsOf(t);377+ let shared = 0;378+ for (const c of ids) {379+ if (fresh[c]) return; // new tiles do not overlap each other380+ if (owners[c]) shared++;381+ }382+ if (shared < 4 || shared > ids.length / 4) return;383+ for (const c of ids) fresh[c] = 1;384+ tiles.push({ id: id++, ...t, cells: ids.length, m: mFor(ids.length) });385+ };386+ // walk the four sides of the box, dropping tiles that straddle it387+ for (let x = x0 - 2; x < x1; x += 6 + Math.floor(r() * 4)) {388+ const w = 8, h = 6 + 2 * Math.floor(r() * 3);389+ tryPlace(x, y0 - h + 2 + Math.floor(r() * 2), w, h);390+ tryPlace(x, y1 - 2 - Math.floor(r() * 2), w, h);391+ }392+ for (let y = y0 - 2; y < y1; y += 6 + Math.floor(r() * 4)) {393+ const w = 6 + 2 * Math.floor(r() * 3), h = 8;394+ tryPlace(x0 - w + 2 + Math.floor(r() * 2), y, w, h);395+ tryPlace(x1 - 2 - Math.floor(r() * 2), y, w, h);396+ }397+ const seeds = tiles.length >= 2398+ ? [tiles[0].id, tiles[tiles.length - 1].id]399+ : tiles.map((t) => t.id);400+ return {401+ G,402+ kind: base.kind,403+ seed_tile: base.seed_tile,404+ seed_tiles: seeds,405+ tiles,406+ };407+}408+409+/**410+ * Wrap a growth ring as the engine's delta output over a base output: the411+ * manifest holds only the new tiles (overlaps computed on the union), the412+ * solution is the base solution with the new cells filled in (old cells413+ * unchanged), the private tiles are the new ones.414+ */415+export function synthesiseDelta(416+ base: GenOutput & { manifest: Manifest },417+ delta: Layout,418+ ring: number,419+ seed = "fixture",420+): GenOutput & { manifest: Manifest } {421+ const r = rng(`${seed}|delta|${ring}`);422+ const G = base.manifest.G;423+ const union: Manifest = {424+ ...base.manifest,425+ tiles: [426+ ...base.manifest.tiles,427+ ...delta.tiles.map((t) => ({ ...t, hash: `h${t.id}`, overlaps: [] })),428+ ],429+ };430+ const geo = buildGeometry(union);431+ const newTiles: TileSpec[] = delta.tiles.map((t) => ({432+ ...t,433+ hash: `h${t.id}`,434+ overlaps: overlapsOf(geo, t.id),435+ }));436+ const solution = base.solution.map((row, y) => {437+ let out = "";438+ for (let x = 0; x < G; x++) {439+ const id = cellId(x, y, G);440+ const covered = geo.ownerStart[id] !== geo.ownerStart[id + 1];441+ out += row[x] !== "." ? row[x] : covered ? (r() < 0.5 ? "0" : "1") : ".";442+ }443+ return out;444+ });445+ const genTiles = newTiles.map((t) => {446+ const givens: [number, 0 | 1][] = [];447+ for (const id of geo.cellsOf[t.id]) {448+ if (geo.ownerStart[id + 1] - geo.ownerStart[id] > 1) continue;449+ if (r() < 0.3) {450+ givens.push([id, solution[Math.floor(id / G)][id % G] === "1" ? 1 : 0]);451+ }452+ }453+ return { id: t.id, givens };454+ });455+ const field = {456+ ...(base.manifest.field ?? { chunks: 0, chunk_g: 0, band: 0 }),457+ grown: ring,458+ };459+ const manifest: Manifest = {460+ ...base.manifest,461+ tiles: newTiles,462+ seed_tiles: delta.seed_tiles ?? [],463+ field,464+ };465+ return {466+ manifest,467+ solution,468+ tiles: genTiles,469+ stats: { exhausted: newTiles.length === 0 ? 1 : 0 },470+ };471+}
A site/tests/grow_test.ts +362 -0
@@ -0,0 +1,362 @@
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.6+import { assert, assertEquals } from "@std/assert";7+import {8+ applyEvent,9+ buildGeometry,10+ emptyState,11+ frontierOf,12+ groupOf,13+ parseLog,14+ reachedByGroup,15+ type State,16+} from "../lib/fold.ts";17+import type { GenOutput } from "../lib/engine.ts";18+import { Referee } from "../lib/referee.ts";19+import {20+ growGame,21+ resetGames,22+ setGrower,23+ startGame,24+ whenReady,25+} from "../lib/games.ts";26+import {27+ embed,28+ growLayout,29+ scatterLayout,30+ stubJudge,31+ synthesise,32+ synthesiseDelta,33+ withSeeds,34+} from "./fixture.ts";35+36+const tmp = await Deno.makeTempDir();37+Deno.env.set("MOSAIC_GAMES", tmp + "/");38+39+function solutionOf(out: GenOutput) {40+ const G = out.manifest.G;41+ return (id: number) =>42+ (out.solution[Math.floor(id / G)][id % G] === "1" ? 1 : 0) as 0 | 1;43+}44+45+/** A cluster of ~20 tiles in the middle of a 140-cell canvas. */46+function base() {47+ const L = embed(withSeeds(scatterLayout("grow-base", 60), 3), 140, 40, 40);48+ const out = synthesise(L, "grow-base");49+ out.manifest.field = { chunks: 1, chunk_g: 60, band: 0, grown: 0 };50+ return out;51+}52+53+/** A write that lost the revision race (a grow landed) is retried, as clients do. */54+async function retrying<T>(fn: () => Promise<T>): Promise<T> {55+ for (let i = 0; i < 10; i++) {56+ try {57+ return await fn();58+ } catch (e) {59+ if (!String(e).includes("Revision conflict")) throw e;60+ }61+ }62+ throw new Error("gave up after 10 revision conflicts");63+}64+65+/** Solve one frontier tile of the agent (reveal + commit everything). */66+async function solveOne(67+ ref: Referee,68+ sol: (id: number) => 0 | 1,69+ agent: string,70+ live: State[],71+): Promise<number | null> {72+ const fr = frontierOf(ref.state, agent);73+ if (!fr.length) return null;74+ const id = fr[0];75+ if (ref.state.tiles[id] === "hidden") {76+ await retrying(() => ref.reveal(ref.revision, id, `reveal #${id}`, agent));77+ live.push(ref.state);78+ }79+ const unknown = ref.state.geo!.cellsOf[id].filter((c) => !ref.state.cells[c]);80+ if (unknown.length) {81+ await retrying(() =>82+ ref.commit(83+ ref.revision,84+ unknown.map((c) => [c, sol(c)] as [number, number]),85+ `finish #${id}`,86+ agent,87+ )88+ );89+ live.push(ref.state);90+ }91+ return id;92+}93+94+Deno.test("a field grown twice folds identically live and from the log; geometry equals the union", async () => {95+ let out = base();96+ const gameId = crypto.randomUUID();97+ const ref = await Referee.create(98+ gameId,99+ structuredClone(out),100+ { guess: "accept", depth: 0, fog: true, grow: { every_minutes: 60 } },101+ stubJudge,102+ { seed: "grow-base" },103+ );104+ const live: State[] = [];105+ let acc = emptyState();106+ const stream: State[] = [];107+ ref.log.subscribe((ev) => {108+ acc = applyEvent(acc, ev);109+ stream.push(acc);110+ });111+ acc = applyEvent(acc, ref.log.events[0]);112+ stream.push(acc);113+ live.push(ref.state);114+ const { agent } = await ref.join({ name: "p", harness: "test" });115+ live.push(ref.state);116+ let sol = solutionOf(out);117+ for (let i = 0; i < 3; i++) await solveOne(ref, sol, agent, live);118+ const beforeTiles = ref.manifest.tiles.length;119+ for (let ring = 1; ring <= 2; ring++) {120+ const layout = growLayout(121+ {122+ G: out.manifest.G,123+ kind: "scatter",124+ seed_tile: out.manifest.seed_tile,125+ tiles: out.manifest.tiles,126+ },127+ "grow-base",128+ ring,129+ );130+ assert(layout.tiles.length >= 4, `ring ${ring} has tiles`);131+ const delta = synthesiseDelta(out, layout, ring);132+ const ev = await ref.grow(delta, ring);133+ live.push(ref.state);134+ assertEquals(ev.tiles.length, layout.tiles.length);135+ // the private state grows the same way the registry merges it136+ out = {137+ manifest: {138+ ...out.manifest,139+ tiles: [...out.manifest.tiles, ...delta.manifest.tiles],140+ seed_tiles: [141+ ...(out.manifest.seed_tiles ?? []),142+ ...(delta.manifest.seed_tiles ?? []),143+ ],144+ field: delta.manifest.field,145+ },146+ solution: delta.solution,147+ tiles: [...out.tiles, ...delta.tiles],148+ };149+ sol = solutionOf(out);150+ // geometry after the grow == geometry of the union (hues aside)151+ const union = buildGeometry(out.manifest);152+ const geo = ref.state.geo!;153+ assertEquals(geo.ownerStart, union.ownerStart);154+ assertEquals(geo.ownerList, union.ownerList);155+ assertEquals(geo.cellsOf, union.cellsOf);156+ assertEquals(geo.adjacency, union.adjacency);157+ assertEquals(geo.holes, union.holes);158+ assertEquals(ref.state.grown, ring);159+ assertEquals(ref.state.seedRings.length, ring + 1);160+ assertEquals(ref.state.tiles.length, out.manifest.tiles.length);161+ for (let i = 0; i < 3; i++) await solveOne(ref, sol, agent, live);162+ }163+ assert(ref.manifest.tiles.length > beforeTiles + 8);164+ // live == replay at every revision165+ const text = await Deno.readTextFile(`${tmp}/${gameId}.jsonl`);166+ const events = parseLog(text);167+ assertEquals(events.length, ref.revision + 1);168+ assertEquals(events.filter((e) => e.kind === "grow").length, 2);169+ let s = emptyState();170+ const replay: State[] = [];171+ for (const ev of events) {172+ s = applyEvent(s, ev);173+ replay.push(s);174+ }175+ for (let k = 0; k < events.length; k++) {176+ assertEquals(replay[k], stream[k], `stream vs replay at rev ${k}`);177+ }178+ for (const st of live) {179+ assertEquals(180+ replay[st.revision],181+ st,182+ `referee state at rev ${st.revision}`,183+ );184+ }185+ // reached: every revealed/solved tile, its neighbours, and seeds; nothing else186+ const last = replay.at(-1)!;187+ for (let t = 0; t < last.tiles.length; t++) {188+ const expect = last.tiles[t] !== "hidden" ||189+ last.geo!.adjacency[t].some((u) => last.tiles[u] !== "hidden") ||190+ last.agents.some((a) => a.seed_tile === t);191+ assertEquals(!!last.reached[t], expect, `reached[${t}]`);192+ }193+ assert(!text.includes('"solution"'));194+});195+196+Deno.test("fog: observe never lists a hidden tile the caller's group has not reached", async () => {197+ const out = base();198+ const ref = await Referee.create(199+ crypto.randomUUID(),200+ structuredClone(out),201+ { guess: "accept", depth: 0, fog: true },202+ stubJudge,203+ { persist: false },204+ );205+ const sol = solutionOf(out);206+ const a = (await ref.join({ name: "a", harness: "t" })).agent;207+ const b = (await ref.join({ name: "b", harness: "t" })).agent;208+ const live: State[] = [];209+ for (let i = 0; i < 2; i++) {210+ await solveOne(ref, sol, a, live);211+ await solveOne(ref, sol, b, live);212+ }213+ const s = ref.state;214+ for (const who of [a, b]) {215+ const obs = ref.observe({ agent: who });216+ assert(obs.fog);217+ const group = groupOf(s, who);218+ for (const t of obs.tiles) {219+ if (t.status !== "hidden") continue;220+ assert(221+ reachedByGroup(s, t.id, group),222+ `${who} sees unreached hidden tile ${t.id}`,223+ );224+ }225+ // its own seed tile and its frontier are listed226+ const seed = s.agents.find((x) => x.id === who)!.seed_tile;227+ assert(obs.tiles.some((t) => t.id === seed));228+ for (const f of obs.frontier) assert(obs.tiles.some((t) => t.id === f));229+ }230+ // without an agent, a rectangle lists only revealed and solved tiles231+ const rect = ref.observe({ rect: [0, 0, 140, 140] });232+ assert(rect.tiles.length > 0);233+ for (const t of rect.tiles) assert(t.status !== "hidden");234+ // a hidden tile away from everyone is listed to nobody235+ const far = s.manifest!.tiles.find((t) =>236+ s.tiles[t.id] === "hidden" &&237+ !s.reached[t.id]238+ );239+ assert(far, "some tile is unreached");240+ for (const who of [a, b]) {241+ assert(!ref.observe({ agent: who }).tiles.some((t) => t.id === far.id));242+ }243+ // without fog the same tile is listed to an agent whose radius covers it244+ const open = await Referee.create(245+ crypto.randomUUID(),246+ structuredClone(out),247+ { guess: "accept", depth: 0, fog: false },248+ stubJudge,249+ { persist: false },250+ );251+ const c = (await open.join({ name: "c", harness: "t" })).agent;252+ assert(!open.observe({ agent: c }).fog);253+});254+255+Deno.test("a joiner after a grow is seated on the newest ring", async () => {256+ const out = base();257+ const ref = await Referee.create(258+ crypto.randomUUID(),259+ structuredClone(out),260+ { guess: "accept", depth: 0, fog: true, grow: { every_minutes: 60 } },261+ stubJudge,262+ { persist: false },263+ );264+ const layout = growLayout(265+ {266+ G: out.manifest.G,267+ kind: "scatter",268+ seed_tile: out.manifest.seed_tile,269+ tiles: out.manifest.tiles,270+ },271+ "grow-base",272+ 1,273+ );274+ const delta = synthesiseDelta(out, layout, 1);275+ await ref.grow(delta, 1);276+ const j = await ref.join({ name: "late", harness: "t" });277+ assert(278+ delta.manifest.seed_tiles!.includes(j.seed_tile),279+ `seat ${j.seed_tile} in ring 1 seeds ${delta.manifest.seed_tiles}`,280+ );281+ const j2 = await ref.join({ name: "later", harness: "t" });282+ assert(delta.manifest.seed_tiles!.includes(j2.seed_tile));283+ // the ring's seeds are used up: the next joiner falls back to ring 0284+ const j3 = await ref.join({ name: "latest", harness: "t" });285+ assert(!delta.manifest.seed_tiles!.includes(j3.seed_tile));286+});287+288+Deno.test("registry: growth on coverage, and instead of ending when every tile is solved", async () => {289+ resetGames();290+ let out = base();291+ const gen = () => Promise.resolve(structuredClone(out));292+ let rings = 0;293+ setGrower((state, _seed, ring) => {294+ rings++;295+ const layout = growLayout(296+ {297+ G: out.manifest.G,298+ kind: "scatter",299+ seed_tile: out.manifest.seed_tile,300+ tiles: (state.manifest as unknown as { tiles: never[] }).tiles,301+ },302+ "grow-base",303+ ring,304+ );305+ if (ring >= 3) layout.tiles = []; // the field runs out of room306+ const delta = synthesiseDelta(out, layout, ring);307+ out = {308+ manifest: {309+ ...out.manifest,310+ tiles: [...out.manifest.tiles, ...delta.manifest.tiles],311+ seed_tiles: [312+ ...(out.manifest.seed_tiles ?? []),313+ ...(delta.manifest.seed_tiles ?? []),314+ ],315+ },316+ solution: delta.solution,317+ tiles: [...out.tiles, ...delta.tiles],318+ };319+ return Promise.resolve(delta);320+ });321+ const pending = startGame(322+ {323+ G: 140,324+ kind: "field",325+ chunks: 1,326+ chunk_g: 60,327+ grow: { frontier_coverage: 0.5 },328+ field_g: 140,329+ },330+ gen,331+ Date.now,332+ stubJudge,333+ );334+ const ref = await whenReady(pending.game_id);335+ assert(ref.fog, "fog defaults on for a growing field");336+ const agent = (await ref.join({ name: "p", harness: "t" })).agent;337+ const live: State[] = [];338+ // solve until coverage crosses 0.5: growth is triggered in the background339+ const n0 = ref.manifest.tiles.length;340+ for (let i = 0; i < 40 && ref.state.grown === 0; i++) {341+ const done = await solveOne(ref, solutionOf(out), agent, live);342+ if (done === null) break;343+ await new Promise((r) => setTimeout(r, 20));344+ }345+ await new Promise((r) => setTimeout(r, 100));346+ assertEquals(ref.state.grown, 1, "grew once on coverage");347+ assert(ref.manifest.tiles.length > n0);348+ // solve everything: the game does not end while it can grow349+ for (let guard = 0; guard < 400 && ref.state.status === "playing"; guard++) {350+ const done = await solveOne(ref, solutionOf(out), agent, live);351+ if (done === null) {352+ await new Promise((r) => setTimeout(r, 30));353+ if (frontierOf(ref.state, agent).length === 0 && rings >= 3) break;354+ }355+ }356+ await new Promise((r) => setTimeout(r, 100));357+ assert(rings >= 3, `grower called ${rings} times`);358+ assert(ref.state.exhausted, "the last ring found no room");359+ // growGame is a no-op once exhausted360+ assertEquals(await growGame(pending.game_id), false);361+ resetGames();362+});