1// deno-lint-ignore-file no-explicit-any2// Scripted MCP client: plays a whole mosaic through the public tools only.3// Reveal a frontier tile, enumerate the tile window's solutions from known4// cells with the three rules, commit what every solution agrees on, then5// guess one cell of the first solution and learn from the verdict.6//7// deno run -A scripts/solve.ts [--url http://127.0.0.1:8000/mcp] [--G 100] [--kind scatter|grid|field] [--chunks 3] [--n 14] [--seed s] [--limit 6]8// [--join <game_id>] [--agent name] [--model m] [--strategy bfs|dfs|greedy|probe] [--max-batches N]9// [--identity ~/.config/mosaic/identity.json] [--bearer msc_...] [--session label]10// [--grow-every MIN] [--grow-coverage F] [--fog true|false] [--until-grown N]11//12// --join plays an existing field as one more agent (several solve.ts13// processes on one game_id share it); --strategy picks the next tile to14// reveal: bfs = the frontier tile nearest my seed tile in the overlap graph,15// dfs = a frontier tile adjacent to the tile I solved last, greedy = the16// largest frontier tile.1718const args = Object.fromEntries(19 Deno.args.map((a, i, all) =>20 a.startsWith("--") ? [a.slice(2), all[i + 1] ?? ""] : []21 ).filter((p) => p.length),22);23const URL_ = args.url ?? "http://127.0.0.1:8000/mcp";24const G_ARG = Number(args.G ?? 100);25const KIND = args.kind ?? "scatter";26const N_ARG = Number(args.n ?? 14);27const LIMIT = Number(args.limit ?? 6);28const CHUNKS = Number(args.chunks ?? 3);29const STRATEGY = (args.strategy ?? "bfs") as30 | "bfs"31 | "dfs"32 | "greedy"33 | "probe";34const MAX_BATCHES = Number(args["max-batches"] ?? Infinity);35const AGENT_NAME = args.agent ?? `solve-${STRATEGY}`;36const GROW_EVERY = args["grow-every"] ? Number(args["grow-every"]) : undefined;37const GROW_COVERAGE = args["grow-coverage"]38 ? Number(args["grow-coverage"])39 : undefined;40const UNTIL_GROWN = Number(args["until-grown"] ?? 0);41const FOG = args.fog !== undefined ? args.fog !== "false" : undefined;4243let nextId = 1;44async function rpc(method: string, params: unknown): Promise<any> {45 const res = await fetch(URL_, {46 signal: AbortSignal.timeout(Number(args.timeout ?? 90000)),47 method: "POST",48 headers: {49 "Content-Type": "application/json",50 "Accept": "application/json, text/event-stream",51 "MCP-Protocol-Version": "2025-06-18",52 ...(args.bearer ? { Authorization: `Bearer ${args.bearer}` } : {}),53 },54 body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method, params }),55 });56 const text = await res.text();57 if (!res.ok) {58 throw new Error(`${method}: HTTP ${res.status} ${text.slice(0, 300)}`);59 }60 const msg = JSON.parse(unframe(text));61 if (msg.error) throw new Error(`${method}: ${JSON.stringify(msg.error)}`);62 return msg.result;63}6465/** The server may answer as JSON or as one SSE frame; take the last data line. */66function unframe(text: string): string {67 // SSE: comment lines (": keepalive", sent while a long tool call such as68 // a generation on cache miss runs), event: and data: lines69 const t = text.trimStart();70 if (!t.startsWith("event:") && !t.startsWith("data:") && !t.startsWith(":")) {71 return text;72 }73 const data = text.split("\n").filter((l) => l.startsWith("data:")).map((l) =>74 l.slice(5).trim()75 );76 return data.at(-1) ?? "";77}7879async function call(name: string, a: Record<string, unknown>): Promise<any> {80 const r = await rpc("tools/call", { name, arguments: a });81 if (r.isError) throw new Error(`${name}: ${r.content?.[0]?.text}`);82 return r.structuredContent ?? r;83}8485// ---------------------------------------------------------------- solver86//87// A tile is a shape: the union of rects inside a w x h box. The rules apply88// per line (a maximal horizontal or vertical run of the tile's cells):89// balance, no three in a row, and no two equal lines of the same90// orientation and length. Cells outside the shape are absent (-2).9192type Grid = Int8Array; // -1 empty, -2 absent, 0/1 known93type Shape = {94 x0: number;95 y0: number;96 w: number;97 h: number;98 rects: [number, number, number, number][];99};100101type Lines = {102 lines: number[][]; // local indices along each line103 rowLine: Int32Array; // local index -> line id (or -1)104 colLine: Int32Array;105 pos: Int32Array; // position of the cell in its row line106 cpos: Int32Array; // position in its column line107 horizontal: boolean[];108};109110function maskOf(t: Shape): Uint8Array {111 const mask = new Uint8Array(t.w * t.h);112 for (const [lx, ly, rw, rh] of t.rects) {113 for (let y = ly; y < ly + rh; y++) {114 for (let x = lx; x < lx + rw; x++) mask[y * t.w + x] = 1;115 }116 }117 return mask;118}119120function linesOf(t: Shape, mask: Uint8Array): Lines {121 const lines: number[][] = [];122 const horizontal: boolean[] = [];123 const rowLine = new Int32Array(t.w * t.h).fill(-1);124 const colLine = new Int32Array(t.w * t.h).fill(-1);125 const pos = new Int32Array(t.w * t.h);126 const cpos = new Int32Array(t.w * t.h);127 for (let y = 0; y < t.h; y++) {128 let run: number[] = [];129 for (let x = 0; x <= t.w; x++) {130 const i = y * t.w + x;131 if (x < t.w && mask[i]) run.push(i);132 else if (run.length) {133 run.forEach((c, k) => {134 rowLine[c] = lines.length;135 pos[c] = k;136 });137 lines.push(run);138 horizontal.push(true);139 run = [];140 }141 }142 }143 for (let x = 0; x < t.w; x++) {144 let run: number[] = [];145 for (let y = 0; y <= t.h; y++) {146 const i = y * t.w + x;147 if (y < t.h && mask[i]) run.push(i);148 else if (run.length) {149 run.forEach((c, k) => {150 colLine[c] = lines.length;151 cpos[c] = k;152 });153 lines.push(run);154 horizontal.push(false);155 run = [];156 }157 }158 }159 return { lines, rowLine, colLine, pos, cpos, horizontal };160}161162function enumerate(cells: Grid, t: Shape, limit: number): Grid[] {163 const out: Grid[] = [];164 const g = Int8Array.from(cells);165 const L = linesOf(t, maskOf(t));166 const lineOk = (li: number, at: number, v: number): boolean => {167 const line = L.lines[li];168 const n = line.length;169 const p = L.horizontal[li] ? L.pos[at] : L.cpos[at];170 const val = (k: number) => (line[k] === at ? v : g[line[k]]);171 // run172 if (p >= 2 && val(p - 1) === v && val(p - 2) === v) return false;173 if (p >= 1 && p < n - 1 && val(p - 1) === v && val(p + 1) === v) {174 return false;175 }176 if (p <= n - 3 && val(p + 1) === v && val(p + 2) === v) return false;177 // balance178 let same = 0, filled = 0;179 for (let k = 0; k < n; k++) {180 const c = val(k);181 if (c >= 0) {182 filled++;183 if (c === v) same++;184 }185 }186 if (same > n / 2) return false;187 // uniqueness against complete lines of the same orientation and length188 if (filled === n) {189 for (let lj = 0; lj < L.lines.length; lj++) {190 if (lj === li || L.horizontal[lj] !== L.horizontal[li]) continue;191 const other = L.lines[lj];192 if (other.length !== n) continue;193 let eq = true;194 for (let k = 0; k < n && eq; k++) {195 const o = g[other[k]];196 if (o < 0 || o !== val(k)) eq = false;197 }198 if (eq) return false;199 }200 }201 return true;202 };203 const canPlace = (i: number, v: number): boolean =>204 lineOk(L.rowLine[i], i, v) && lineOk(L.colLine[i], i, v);205 const rec = (): boolean => {206 if (out.length >= limit) return true;207 // MRV: the empty cell with the fewest admissible values208 let bi = -1, bc = 3, bv: number[] = [];209 for (let i = 0; i < g.length; i++) {210 if (g[i] !== -1) continue;211 const vals = [0, 1].filter((v) => canPlace(i, v));212 if (vals.length === 0) return false;213 if (vals.length < bc) {214 bi = i;215 bc = vals.length;216 bv = vals;217 if (bc === 1) break;218 }219 }220 if (bi < 0) {221 out.push(Int8Array.from(g));222 return out.length >= limit;223 }224 for (const v of bv) {225 g[bi] = v;226 if (rec()) return true;227 g[bi] = -1;228 }229 return false;230 };231 rec();232 return out;233}234235// ------------------------------------------------------------------ play236237const init = await rpc("initialize", {238 protocolVersion: "2025-06-18",239 capabilities: {},240 clientInfo: { name: "mosaic-solve", version: "0.2.0" },241});242console.log("server", init.serverInfo?.name, init.protocolVersion);243244let gameId: string;245if (args.join) {246 gameId = args.join;247} else {248 const grow = GROW_EVERY !== undefined || GROW_COVERAGE !== undefined249 ? {250 ...(GROW_EVERY !== undefined ? { every_minutes: GROW_EVERY } : {}),251 ...(GROW_COVERAGE !== undefined252 ? { frontier_coverage: GROW_COVERAGE }253 : {}),254 }255 : undefined;256 const started = await call("start_mosaic", {257 G: G_ARG,258 kind: KIND,259 n: N_ARG,260 chunks: CHUNKS,261 ...(args.seed ? { seed: args.seed } : {}),262 ...(grow ? { grow } : {}),263 ...(FOG !== undefined ? { rules: { fog: FOG } } : {}),264 });265 gameId = started.game_id;266 console.log("game", gameId, started.status ?? "");267 // generation runs in the background: poll list_games until the field is268 // playing (observe without a scope is refused on large fields)269 for (;;) {270 const games = (await call("list_games", {})).games as {271 game_id: string;272 status: string;273 error?: string;274 }[];275 const g = games.find((x) => x.game_id === gameId);276 if (!g) throw new Error("game vanished");277 if (g.status === "failed") throw new Error(`generation failed: ${g.error}`);278 if (g.status !== "generating") break;279 await new Promise((r) => setTimeout(r, 2000));280 }281}282// --identity <file>: sign the join with this machine's key (identity.ts)283let identityFields: Record<string, string> = {};284if (args.identity) {285 const { signJoin } = await import("../lib/identity.ts");286 const id = JSON.parse(await Deno.readTextFile(args.identity));287 const p = await signJoin(id, gameId, Date.now(), args.session ?? "1");288 identityFields = {289 machine_id: p.machine_id,290 public_key: p.public_key,291 proof: p.proof,292 ...(args.session ? { session: args.session } : {}),293 };294}295const joined = await call("join", {296 game_id: gameId,297 name: AGENT_NAME,298 harness: "mosaic-solve 0.3.0",299 ...(args.model ? { model: args.model } : {}),300 ...identityFields,301 ...(args.session ? { session: args.session } : {}),302 tags: { strategy: STRATEGY, limit: String(LIMIT) },303});304const AGENT: string = joined.agent;305const SEED_TILE: number = joined.seed_tile;306console.log(307 "agent",308 AGENT,309 "seed tile",310 SEED_TILE,311 "harness",312 joined.harness,313 `(${joined.harness_source})`,314);315316const t0 = Date.now();317const observe = async () =>318 (await call("observe", { game_id: gameId, agent: AGENT })).observation;319let obs = await observe();320const G: number = obs.G;321322// overlap graph over the tiles seen so far (observe is scoped; neighbours323// are listed for every tile it returns)324const neighbours = new Map<number, number[]>();325const cellsOfTile = new Map<number, number>();326function learn(o: any) {327 for (const t of o.tiles) {328 neighbours.set(t.id, t.neighbours);329 cellsOfTile.set(t.id, t.cells);330 }331}332learn(obs);333/** BFS distance from my seed tile over the tiles seen so far. */334function distFromSeed(): Map<number, number> {335 const d = new Map<number, number>([[SEED_TILE, 0]]);336 const q = [SEED_TILE];337 for (let i = 0; i < q.length; i++) {338 for (const u of neighbours.get(q[i]) ?? []) {339 if (!d.has(u)) {340 d.set(u, d.get(q[i])! + 1);341 q.push(u);342 }343 }344 }345 return d;346}347let lastSolved: number | null = null;348function pickReveal(frontier: number[]): number {349 if (STRATEGY === "dfs" && lastSolved !== null) {350 // go deep: among the tiles touching the last solved one, the farthest351 // from my seed352 const adj = new Set(neighbours.get(lastSolved) ?? []);353 const near = frontier.filter((t) => adj.has(t));354 if (near.length) {355 const d = distFromSeed();356 return near.reduce((a, b) => (d.get(b) ?? 0) > (d.get(a) ?? 0) ? b : a);357 }358 }359 if (STRATEGY === "greedy") {360 return frontier.reduce((a, b) =>361 (cellsOfTile.get(b) ?? 0) > (cellsOfTile.get(a) ?? 0) ? b : a362 );363 }364 const d = distFromSeed();365 return frontier.reduce((a, b) =>366 (d.get(b) ?? Infinity) < (d.get(a) ?? Infinity) ? b : a367 );368}369370function knownMap(o: any): Map<number, number> {371 const m = new Map<number, number>();372 let acc = 0;373 o.known_deltas.forEach((d: number, i: number) => {374 acc += d;375 m.set(acc, Number(o.known_values[i]));376 });377 return m;378}379380/** Global id per local index of the tile's box; -1 where the shape is absent. */381const tileIds = (t: Shape): number[] => {382 const mask = maskOf(t);383 const ids: number[] = [];384 for (let y = 0; y < t.h; y++) {385 for (let x = 0; x < t.w; x++) {386 ids.push(mask[y * t.w + x] ? (t.y0 + y) * G + t.x0 + x : -1);387 }388 }389 return ids;390};391392let batches = 0, guesses = 0;393let idleLogged = -1;394const wrongKnown = new Map<number, number>(); // id -> value we learned by rejection395/** A write that lost the revision race (other agents) is retried after observing. */396async function write(name: string, a: Record<string, unknown>): Promise<any> {397 for (let attempt = 0; attempt < 20; attempt++) {398 try {399 return await call(name, {400 ...a,401 game_id: gameId,402 agent: AGENT,403 expected_revision: obs.revision,404 });405 } catch (e) {406 const msg = String(e);407 if (!msg.includes("Revision conflict")) throw e;408 obs = await observe();409 learn(obs);410 }411 }412 throw new Error(`${name}: gave up after 20 revision conflicts`);413}414415while (obs.status !== "solved" && batches < MAX_BATCHES) {416 const frontier: number[] = obs.frontier;417 // prefer a revealed-but-unsolved tile of mine (finish what we started), else reveal one418 const revealed = obs.tiles.filter((t: any) =>419 t.status === "revealed" && frontier.includes(t.id)420 );421 let tile: any;422 if (revealed.length) {423 tile = revealed[0];424 } else if (frontier.length === 0) {425 // my group has nothing left to do: wait for others, or for the field426 // to grow; with --until-grown N, leave once N growths have landed and427 // there is still nothing to do428 if (UNTIL_GROWN > 0 && (obs.grown ?? 0) >= UNTIL_GROWN) {429 console.log(430 `agent ${AGENT}: frontier empty after ${obs.grown} growths, done`,431 );432 break;433 }434 if (idleLogged !== obs.revision) {435 console.log(436 `agent ${AGENT}: frontier empty at rev ${obs.revision} (grown ${437 obs.grown ?? 0438 }), waiting`,439 );440 idleLogged = obs.revision;441 }442 await new Promise((r) => setTimeout(r, 1000));443 obs = await observe();444 learn(obs);445 continue;446 } else {447 const id = pickReveal(frontier);448 try {449 await write("reveal", {450 tile: id,451 intent: `reveal #${id} (${STRATEGY})`,452 });453 } catch (e) {454 // another agent got there first (shared frontier after a meeting):455 // observe again and pick anew456 const msg = String(e);457 if (!msg.includes("already") && !msg.includes("frontier")) throw e;458 }459 obs = await observe();460 learn(obs);461 continue;462 }463 const shape: Shape = tile;464 const label = `${tile.cells} cells, ${shape.w}x${shape.h}`;465 const known = knownMap(obs);466 const ids = tileIds(shape);467 const cells = new Int8Array(shape.w * shape.h).fill(-1);468 ids.forEach((id, i) => {469 if (id < 0) cells[i] = -2;470 else if (known.has(id)) cells[i] = known.get(id)!;471 else if (wrongKnown.has(id)) cells[i] = wrongKnown.get(id)!;472 });473 let batch: [number, number][];474 let intent: string;475 const common: [number, number][] = [];476 const disputed: number[] = [];477 let sols: Int8Array[] = [];478 if (STRATEGY === "probe") {479 // the oracle exploit, kept as the regression for health: no deduction,480 // every unknown cell as 0, then the complements of the rejections481 ids.forEach((id, i) => {482 if (id < 0 || known.has(id)) return;483 if (wrongKnown.has(id)) common.push([id, wrongKnown.get(id)!]);484 else common.push([id, cells[i] === -1 ? 0 : cells[i]]);485 });486 } else {487 sols = enumerate(cells, shape, LIMIT);488 if (sols.length === 0) {489 throw new Error(`tile #${tile.id} has no solution from known cells`);490 }491 ids.forEach((id, i) => {492 if (id < 0 || known.has(id)) return;493 const v = sols[0][i];494 if (sols.every((s) => s[i] === v)) common.push([id, v]);495 else disputed.push(i);496 });497 }498 if (!common.length && !disputed.length) {499 // nothing left to commit here (every cell known, e.g. filled by a500 // neighbour meanwhile): observe again501 obs = await observe();502 learn(obs);503 continue;504 }505 if (common.length) {506 batch = common;507 intent = STRATEGY === "probe"508 ? `tile #${tile.id} (${label}): probing ${common.length} cells`509 : `tile #${tile.id} (${label}): ${sols.length} solution(s) from known cells, committing the ${common.length} cells they agree on`;510 } else {511 const i = disputed[0];512 batch = [[ids[i], sols[0][i]]];513 guesses++;514 intent =515 `tile #${tile.id} (${label}): ${sols.length} solutions disagree on ${disputed.length} cells, guessing one`;516 }517 let r: any;518 try {519 r = await write("commit", { cells: batch, intent });520 } catch (e) {521 if (String(e).includes("out for the day")) {522 console.log(`${AGENT} is dead: ${String(e).slice(0, 200)}`);523 break;524 }525 // the cells were filled meanwhile (shared cells, another agent): observe526 // again and continue527 if (!String(e).includes("No cell to process")) throw e;528 obs = await observe();529 learn(obs);530 continue;531 }532 if (r.health) {533 const h = r.health;534 console.log(535 `${AGENT} health ${h.state} ${536 (h.rate * 100).toFixed(1)537 }% (${h.wrong}/${h.committed})${r.note ? " - " + r.note : ""}`,538 );539 if (h.state === "dead") {540 console.log(`${AGENT} is dead; stopping.`);541 break;542 }543 }544 batches++;545 for (const rej of r.rejected) {546 const v = batch.find(([id]) => id === rej.id)![1];547 wrongKnown.set(rej.id, 1 - v);548 }549 if (r.solved.length) lastSolved = r.solved[r.solved.length - 1];550 const forced = r.accepted.filter((c: any) => c.class === "forced").length;551 console.log(552 `#${r.revision} ${AGENT} tile ${tile.id} (${label}) ${r.accepted.length} ok (${forced} forced), ${r.rejected.length} rejected, solved ${553 JSON.stringify(r.solved)554 } +${r.reward}${555 r.meets?.length ? " met " + r.meets.map((m: any) => m.with).join(",") : ""556 }, status ${r.status}`,557 );558 obs = await observe();559 learn(obs);560}561console.log(562 `${563 obs.status === "solved" ? "solved" : "stopped"564 } ${gameId} as ${AGENT} in ${batches} batches, ${guesses} guesses, ${565 ((Date.now() - t0) / 1000).toFixed(1)566 }s; totals ${JSON.stringify(obs.totals)}`,567);568console.log(`replay: http://127.0.0.1:8000/?replay=${gameId}`);