fold: join and meet semantics, State.agents/tileAgent/meets/analysis, frontierOf(s, agent) with reserved seed tiles, strategy classification (bfs | dfs | greedy | mixed), analysis recomputed per event so live == replay. referee: join (seed tile drawn from unclaimed seed tiles, else a hidden tile touching nothing revealed), agent-carrying writes, meets recorded on commit events, analysis on the end event, observe scoped by agent or rect (full only up to G=120). mcp: list_games, join, scoped observe, agent on reveal/commit/retract, harness from the request envelope when present. engine: kind=field (chunks, chunk_g). log route: ?summary=1. solve.ts: --join, --agent, --model, --strategy bfs|dfs|greedy, revision-conflict retry. Tests: two agents on one field (attribution, gating, meeting, replay == live incl. analysis), classifier, observe under 64 KB on a 2203-tile field.
- commit
- 21e0f8db6b72e9fefa60e8e54ec926f9ca8844c9
- parent
- 7a66be04b749d12e6697eaa692b8a575acba0b53
- author
- dami <gazey55@gmail.com>
| M | site/ | 21 | +- |
| M | site/ | 2 | + |
| M | site/ | 27 | +- |
| M | site/ | 534 | +++++++++++++++++++++++++++++- |
| M | site/ | 30 | ++- |
| M | site/ | 234 | +++++++++++-- |
| M | site/ | 292 | +++++++++++++++- |
| M | site/ | 13 | + |
| M | site/ | 172 | ++++++++-- |
| M | site/ | 51 | +++ |
| M | site/ | 308 | ++++++++++++++++-- |
@@ -10,14 +10,19 @@ export function prompt(): string {
1010 "",1111 "Then paste this into the agent:",1212 "",13- ` Using the mosaic MCP server at ${URL_}/mcp: start a mosaic (start_mosaic with`,14- " kind=scatter and G=100: a field of tiles of mixed shape and size that overlap",15- " by shared cells, with holes; or kind=grid, G=27, n=14 for the 2x2 of 14x14) and",16- " solve it autonomously with observe, reveal, and commit until the status is",17- " solved. Read the tool descriptions for the rules (they are per line of a tile).",18- " Reveal the seed tile first; commit in batches with a one-line intent; when a",19- " tile alone is ambiguous, expand from a solved neighbour or commit a guess. Do",20- " not stop until every tile is solved. Do not ask me anything.",13+ ` Using the mosaic MCP server at ${URL_}/mcp: call list_games; if a field is`,14+ " playing, join it (join with game_id, name, and the model you are, e.g.",15+ " model=claude-opus-5); otherwise start one (start_mosaic with kind=scatter and",16+ " G=100, or kind=field with chunks=3 for a chunked field) and then join it.",17+ " join gives you an agent id and a seed tile at a random spot; every reveal,",18+ " commit and retract must carry that agent id. Play autonomously with observe",19+ " (agent=<your id>: your territory and its surroundings), reveal and commit",20+ " until the status is solved. Read the tool descriptions for the rules (they",21+ " are per line of a tile). Reveal your seed tile first; commit in batches",22+ " with a one-line intent; when a tile alone is ambiguous, expand from a tile",23+ " your group solved or commit a guess; when you meet another agent your",24+ " territories merge and you share a frontier. Do not stop until every tile",25+ " is solved. Do not ask me anything.",2126 "",2227 `Spectate at ${URL_}/ (live). Replay a finished game at ${URL_}/?replay=<game_id>.`,2328 "",@@ -268,6 +268,8 @@ function entryLine(e: ConsoleEntry): string {
268268 switch (e.op) {269269 case "start":270270 return "start";271+ case "join":272+ return `join ${e.agent} at #${e.tile}`;271273 case "reveal":272274 return `reveal #${e.tile} ${e.revealed_cells} cells`;273275 case "commit":@@ -88,16 +88,35 @@ async function run(args: string[], stdin?: string): Promise<string> {
8888 return text;8989 }9090 91-/** Packing parameters: the board side in cells and the packer kind. */91+/**92+ * Generation parameters: the field side in cells and the layout kind, or a93+ * chunked field (`kind: "field"`: chunks x chunks chunks of side chunk_g,94+ * generated as a checkerboard of processes by `mosaic-engine field`).95+ */9296 export type GenParams = {9397 seed: string;9498 G: number;95- kind: "scatter" | "grid" | "big-first" | "bands";99+ kind: "scatter" | "grid" | "big-first" | "bands" | "field";96100 n?: number; // grid only101+ chunks?: number; // field only102+ chunk_g?: number; // field only97103 };98104 99105 /** The single place the generator's command line is spelled out. */100106 export function genArgs(p: GenParams): string[] {107+ if (p.kind === "field") {108+ return [109+ "field",110+ "--seed",111+ p.seed,112+ "--chunks",113+ String(p.chunks ?? 3),114+ "--chunk-G",115+ String(p.chunk_g ?? 60),116+ "--kind",117+ "scatter",118+ ];119+ }101120 const args = ["gen", "--seed", p.seed, "--G", String(p.G), "--kind", p.kind];102121 if (p.kind === "grid") args.push("--n", String(p.n ?? 14));103122 return args;@@ -116,7 +135,9 @@ export function version(): Promise<string> {
116135 * stale board for the same seed. */117136 export async function gen(p: GenParams): Promise<GenOutput> {118137 const key = await sha256(119- `${await version()}|${p.seed}|${p.G}|${p.kind}|${p.n ?? ""}`,138+ `${await version()}|${p.seed}|${p.G}|${p.kind}|${p.n ?? ""}|${139+ p.chunks ?? ""140+ }|${p.chunk_g ?? ""}`,120141 );121142 const dir = cacheDir();122143 const file = `${dir}${key}.json`;@@ -3,6 +3,33 @@
33 // the referee, the SSE viewer, and replay all use this same fold, so cells44 // are derived from events and never stored. A v2 manifest (square tiles,55 // seam segments, filler zones) is converted on load, so old logs replay.6+//7+// State contract (for the viewer and the analyzer readers):8+//9+// agents[] {id, name, harness, model, tags, joined, seed_tile, group}10+// one per `join` event; `group` is the id of the agent whose11+// territory this agent's has merged into (union-find root;12+// an agent's own id until it meets someone)13+// tileAgent[] per tile id: the agent that solved it (null while unsolved)14+// meets[] {rev, agents: [a, b], tile}: the tile solved by a that15+// touched a tile solved by b's group; the groups merged then16+// frontierOf(s, agent) hidden or revealed-unsolved tiles overlapping a17+// tile solved by the agent's group, plus the agent's own seed18+// tile while unsolved; frontierOf(s) is the union over all19+// agents plus every unclaimed seed tile20+// analysis totals and per-agent summaries, recomputed on every event21+// (pure fold, so live == replay at every revision):22+// guess rate, reveal:commit, retractions, reward per batch,23+// batch size drift, mean wall gap, per agent tiles/cells/24+// reward/forced/guess/wrong/retracts/reveals/territory share/25+// group/meets/strategy (bfs | dfs | greedy | mixed)26+// events `join` (agent, name?, harness, model?, tags?, seed_tile),27+// `batch` (op reveal|commit|retract, carries `agent`;28+// commit also records `solved` and `meets` for readers,29+// both derived by the fold), `end` (carries the final30+// `analysis` summary), `manifest`31+// Logs written before agents existed fold with one implicit agent `a0`32+// whose seed tile is the manifest's seed tile.633 734 /** A rectangle in a tile's local coordinates: [lx, ly, w, h]. */835 export type LocalRect = [number, number, number, number];@@ -45,6 +72,8 @@ export type Manifest = {
4572 m_by_n?: Record<string, number>;4673 rules: Record<string, unknown>;4774 seed_tile: number;75+ seed_tiles?: number[]; // one per chunk on a field; candidates for joining agents76+ field?: { chunks: number; chunk_g: number; band: number };4877 tiles: TileSpec[];4978 zones?: ZoneSpec[]; // only on manifests converted from v25079 k: string | number;@@ -105,11 +134,30 @@ export type ManifestEvent = {
105134 rules: GameRules;106135 };107136 137+/** An agent joining the field. */138+export type JoinEvent = {139+ rev: number;140+ t: string;141+ kind: "join";142+ agent: string;143+ name?: string;144+ harness: string;145+ harness_source?: "envelope" | "client_version" | "argument" | "none";146+ model?: string;147+ tags?: Record<string, string>;148+ machine_id?: string;149+ seed_tile: number;150+};151+152+/** Recorded on a commit for readers: a meeting the fold derives itself. */153+export type Meet = { with: string; tile: number };154+108155 export type RevealEvent = {109156 rev: number;110157 t: string;111158 kind: "batch";112159 op: "reveal";160+ agent?: string;113161 tile: number;114162 givens: [number, 0 | 1][];115163 seam: [number, 0 | 1][];@@ -121,11 +169,13 @@ export type CommitEvent = {
121169 t: string;122170 kind: "batch";123171 op: "commit";172+ agent?: string;124173 intent: string;125174 accepted: AcceptedCell[];126175 rejected: RejectedCell[];127176 ignored: number[];128177 solved: number[];178+ meets?: Meet[];129179 judge_ms: number;130180 };131181 @@ -134,6 +184,7 @@ export type RetractEvent = {
134184 t: string;135185 kind: "batch";136186 op: "retract";187+ agent?: string;137188 cells: number[];138189 intent: string;139190 };@@ -144,15 +195,75 @@ export type EndEvent = {
144195 kind: "end";145196 seed?: string;146197 nonce?: string;198+ analysis?: Analysis;147199 };148200 149201 export type GameEvent =150202 | ManifestEvent203+ | JoinEvent151204 | RevealEvent152205 | CommitEvent153206 | RetractEvent154207 | EndEvent;155208 209+export const IMPLICIT_AGENT = "a0";210+211+export type AgentInfo = {212+ id: string;213+ name: string;214+ harness: string;215+ model: string | null;216+ tags: Record<string, string>;217+ joined: number; // revision of the join event (0 for the implicit agent)218+ seed_tile: number;219+ group: string; // union-find root agent id220+};221+222+export type Strategy = "bfs" | "dfs" | "greedy" | "mixed" | "none";223+224+export type AgentAnalysis = {225+ agent: string;226+ group: string;227+ tiles: number; // solved by this agent228+ cells: number;229+ reward: number;230+ reveals: number;231+ commits: number;232+ forced: number;233+ guess: number;234+ wrong: number;235+ retracts: number;236+ retracted: number;237+ territory: number; // cells solved by this agent / cells solved by anyone238+ meets: number;239+ strategy: Strategy;240+ strategy_counts: {241+ bfs: number;242+ dfs: number;243+ greedy: number;244+ reveals: number;245+ };246+ last_solved: number | null; // tile id247+};248+249+export type Analysis = {250+ revision: number;251+ guess_rate: number; // guess / (forced + guess)252+ reveal_commit: number; // reveals / commits253+ retractions: number;254+ reward_per_batch: number;255+ batch_size_mean: number;256+ batch_size_recent: number; // mean over the last 10 commits257+ batch_size_drift: number; // recent - mean258+ mean_gap_ms: number | null; // wall-clock between consecutive batches259+ solved_tiles: number;260+ solved_cells: number;261+ meets: number;262+ agents: AgentAnalysis[];263+ k: string | number;264+ k_hist?: Record<string, number>;265+};266+156267 export type TileStatus = "hidden" | "revealed" | "solved";157268 158269 export type CellInfo = {@@ -166,9 +277,11 @@ export type CellInfo = {
166277 export type ConsoleEntry = {167278 rev: number;168279 t: string;169- op: "start" | "reveal" | "commit" | "retract" | "end";280+ op: "start" | "join" | "reveal" | "commit" | "retract" | "end";281+ agent?: string;170282 intent: string;171283 tile?: number;284+ meets?: Meet[];172285 accepted: number;173286 forced: number;174287 guess: number;@@ -219,6 +332,34 @@ export type State = {
219332 reward: number;220333 };221334 last_t: string | null;335+ agents: AgentInfo[];336+ tileAgent: (string | null)[]; // by tile id337+ meets: { rev: number; agents: [string, string]; tile: number }[];338+ analysis: Analysis;339+ // analyzer scratch, derived and deterministic (part of the state so that340+ // live == replay holds for it too)341+ agentStats: Record<string, AgentStats>;342+ batchSizes: number[];343+ gaps: number[];344+};345+346+/** Per-agent counters the analysis is computed from. */347+export type AgentStats = {348+ reveals: number;349+ commits: number;350+ forced: number;351+ guess: number;352+ wrong: number;353+ retracts: number;354+ retracted: number;355+ tiles: number;356+ cells: number;357+ meets: number;358+ bfs: number;359+ dfs: number;360+ greedy: number;361+ last_solved: number | null;362+ seed_dist: number[]; // BFS distance from the agent's seed tile, by tile id222363 };223364 224365 // ---------------------------------------------------------------- geometry@@ -453,6 +594,25 @@ export function zoneCells(m: Manifest): [number, 0 | 1][] {
453594 454595 // -------------------------------------------------------------------- fold455596 597+export function emptyAnalysis(): Analysis {598+ return {599+ revision: -1,600+ guess_rate: 0,601+ reveal_commit: 0,602+ retractions: 0,603+ reward_per_batch: 0,604+ batch_size_mean: 0,605+ batch_size_recent: 0,606+ batch_size_drift: 0,607+ mean_gap_ms: null,608+ solved_tiles: 0,609+ solved_cells: 0,610+ meets: 0,611+ agents: [],612+ k: "unmeasured",613+ };614+}615+456616 export function emptyState(): State {457617 return {458618 game_id: "",@@ -477,26 +637,105 @@ export function emptyState(): State {
477637 reward: 0,478638 },479639 last_t: null,640+ agents: [],641+ tileAgent: [],642+ meets: [],643+ analysis: emptyAnalysis(),644+ agentStats: {},645+ batchSizes: [],646+ gaps: [],480647 };481648 }482649 483-/** Frontier: unsolved tiles that are the seed tile or share a cell with a solved tile. */484-export function frontierOf(s: State): number[] {650+/** The agent record, or undefined. */651+export function agentOf(s: State, id: string): AgentInfo | undefined {652+ return s.agents.find((a) => a.id === id);653+}654+655+/** Union-find root of an agent's territory group. */656+export function groupOf(s: State, agent: string): string {657+ return agentOf(s, agent)?.group ?? agent;658+}659+660+/** Agents whose territory has merged with this agent's. */661+export function groupMembers(s: State, agent: string): string[] {662+ const g = groupOf(s, agent);663+ return s.agents.filter((a) => a.group === g).map((a) => a.id);664+}665+666+function tileInGroup(s: State, tile: number, group: string): boolean {667+ const a = s.tileAgent[tile];668+ return a !== null && a !== undefined && groupOf(s, a) === group;669+}670+671+/**672+ * Frontier of one agent: unsolved tiles overlapping a tile solved by its673+ * group, plus its own seed tile while unsolved. Without an agent: the union674+ * over every agent plus every seed tile nobody has claimed yet (the675+ * manifest's seed tile and `seed_tiles`).676+ */677+export function frontierOf(s: State, agent?: string): number[] {485678 if (!s.manifest || !s.geo) return [];486679 const out: number[] = [];680+ if (agent !== undefined) {681+ const a = agentOf(s, agent);682+ const group = groupOf(s, agent);683+ // another agent's seed tile is reserved for it until it reveals it684+ const reserved = new Set(685+ s.agents.filter((x) => x.id !== agent && s.tiles[x.seed_tile] === "hidden")686+ .map((x) => x.seed_tile),687+ );688+ for (const t of s.manifest.tiles) {689+ if (s.tiles[t.id] === "solved") continue;690+ const isSeed = a !== undefined && t.id === a.seed_tile;691+ if (!isSeed && reserved.has(t.id)) continue;692+ const near = s.geo.adjacency[t.id].some((u) => tileInGroup(s, u, group));693+ if (isSeed || near) out.push(t.id);694+ }695+ return out;696+ }697+ const seeds = new Set<number>([698+ s.seed_tile,699+ ...(s.manifest.seed_tiles ?? []),700+ ]);701+ for (const a of s.agents) seeds.add(a.seed_tile);487702 for (const t of s.manifest.tiles) {488703 if (s.tiles[t.id] === "solved") continue;489- const isSeed = t.id === s.seed_tile;490704 const nearSolved = s.geo.adjacency[t.id].some((u) =>491705 s.tiles[u] === "solved"492706 );493- if (isSeed || nearSolved) out.push(t.id);707+ if (seeds.has(t.id) || nearSolved) out.push(t.id);494708 }495709 return out;496710 }497711 498-export function isOnFrontier(s: State, tile: number): boolean {499- return frontierOf(s).includes(tile);712+export function isOnFrontier(s: State, tile: number, agent?: string): boolean {713+ return frontierOf(s, agent).includes(tile);714+}715+716+/**717+ * Seed tiles available to a joining agent, in order of preference: the718+ * manifest's unclaimed seed tiles first (one per chunk on a field), then719+ * every hidden tile that overlaps no revealed or solved tile.720+ */721+export function joinCandidates(s: State): number[] {722+ if (!s.manifest || !s.geo) return [];723+ const claimed = new Set(s.agents.map((a) => a.seed_tile));724+ const listed = [s.manifest.seed_tile, ...(s.manifest.seed_tiles ?? [])]725+ .filter((t, i, arr) => arr.indexOf(t) === i);726+ const first = listed.filter((t) =>727+ !claimed.has(t) && s.tiles[t] === "hidden" &&728+ s.geo!.adjacency[t].every((u) => s.tiles[u] === "hidden")729+ );730+ if (first.length) return first;731+ const out: number[] = [];732+ for (const t of s.manifest.tiles) {733+ if (s.tiles[t.id] !== "hidden" || claimed.has(t.id)) continue;734+ if (s.geo.adjacency[t.id].every((u) => s.tiles[u] === "hidden")) {735+ out.push(t.id);736+ }737+ }738+ return out;500739 }501740 502741 function tileKnownCount(s: State, t: TileSpec): number {@@ -505,20 +744,119 @@ function tileKnownCount(s: State, t: TileSpec): number {
505744 return k;506745 }507746 508-/** Recompute solved status; returns tiles newly solved and their reward. */509-function settle(s: State): { newly: number[]; reward: number } {747+/** BFS distance over the overlap graph from one tile. */748+export function distancesFrom(geo: Geometry, from: number): number[] {749+ const dist = new Array(geo.adjacency.length).fill(-1);750+ dist[from] = 0;751+ const q = [from];752+ for (let i = 0; i < q.length; i++) {753+ const t = q[i];754+ for (const u of geo.adjacency[t]) {755+ if (dist[u] < 0) {756+ dist[u] = dist[t] + 1;757+ q.push(u);758+ }759+ }760+ }761+ return dist;762+}763+764+function newAgentStats(geo: Geometry, seed: number): AgentStats {765+ return {766+ reveals: 0,767+ commits: 0,768+ forced: 0,769+ guess: 0,770+ wrong: 0,771+ retracts: 0,772+ retracted: 0,773+ tiles: 0,774+ cells: 0,775+ meets: 0,776+ bfs: 0,777+ dfs: 0,778+ greedy: 0,779+ last_solved: null,780+ seed_dist: distancesFrom(geo, seed),781+ };782+}783+784+/** Register an agent in the state (mutates s). */785+function addAgent(786+ s: State,787+ info: Omit<AgentInfo, "group">,788+): AgentInfo {789+ const a: AgentInfo = { ...info, group: info.id };790+ s.agents = [...s.agents, a];791+ s.agentStats = {792+ ...s.agentStats,793+ [a.id]: newAgentStats(s.geo!, a.seed_tile),794+ };795+ return a;796+}797+798+/** The agent an event belongs to, creating the implicit one on old logs. */799+function ensureAgent(s: State, id: string | undefined): string {800+ const agent = id ?? IMPLICIT_AGENT;801+ if (!agentOf(s, agent)) {802+ addAgent(s, {803+ id: agent,804+ name: agent,805+ harness: "unknown",806+ model: null,807+ tags: {},808+ joined: 0,809+ seed_tile: s.seed_tile,810+ });811+ }812+ return agent;813+}814+815+/**816+ * Recompute solved status after a write by `agent`; newly solved tiles are817+ * attributed to it. Returns the tiles, their reward, and the meetings: a818+ * newly solved tile touching a tile solved by another group merges the819+ * groups (the agent's group takes the other's root).820+ */821+function settle(822+ s: State,823+ agent: string,824+ rev: number,825+): { newly: number[]; reward: number; meets: Meet[] } {510826 const newly: number[] = [];827+ const meets: Meet[] = [];511828 let reward = 0;512- if (!s.manifest) return { newly, reward };829+ if (!s.manifest) return { newly, reward, meets };513830 for (const t of s.manifest.tiles) {514831 if (s.tiles[t.id] !== "revealed") continue;515832 if (tileKnownCount(s, t) === t.cells) {516833 s.tiles[t.id] = "solved";834+ s.tileAgent[t.id] = agent;517835 newly.push(t.id);518836 reward += t.cells;519837 }520838 }521- return { newly, reward };839+ if (newly.length) {840+ const st = s.agentStats[agent];841+ st.tiles += newly.length;842+ st.cells += reward;843+ st.last_solved = newly[newly.length - 1];844+ }845+ for (const t of newly) {846+ for (const u of s.geo!.adjacency[t]) {847+ const other = s.tileAgent[u];848+ if (other === null || other === undefined) continue;849+ const ga = groupOf(s, agent), gb = groupOf(s, other);850+ if (ga === gb) continue;851+ // merge: everyone in the agent's group joins the other's root852+ s.agents = s.agents.map((x) => x.group === ga ? { ...x, group: gb } : x);853+ meets.push({ with: other, tile: t });854+ s.meets = [...s.meets, { rev, agents: [agent, other], tile: t }];855+ s.agentStats[agent].meets++;856+ s.agentStats[other].meets++;857+ }858+ }859+ return { newly, reward, meets };522860 }523861 524862 function dt(s: State, t: string): number | null {@@ -526,6 +864,109 @@ function dt(s: State, t: string): number | null {
526864 return new Date(t).getTime() - new Date(s.last_t).getTime();527865 }528866 867+function mean(xs: number[]): number {868+ return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;869+}870+871+function classify(st: AgentStats): Strategy {872+ if (st.reveals === 0) return "none";873+ const shares = [874+ ["bfs", st.bfs / st.reveals],875+ ["dfs", st.dfs / st.reveals],876+ ["greedy", st.greedy / st.reveals],877+ ] as const;878+ const top = shares.reduce((a, b) => (b[1] > a[1] ? b : a));879+ if (st.reveals < 3 || top[1] < 0.6) return "mixed";880+ return top[0];881+}882+883+/** Recompute the analysis from the state's counters (mutates s.analysis). */884+function analyse(s: State): void {885+ const t = s.totals;886+ let solvedCells = 0, solvedTiles = 0;887+ if (s.manifest) {888+ for (const tile of s.manifest.tiles) {889+ if (s.tiles[tile.id] === "solved") {890+ solvedTiles++;891+ solvedCells += tile.cells;892+ }893+ }894+ }895+ const recent = s.batchSizes.slice(-10);896+ const agents: AgentAnalysis[] = s.agents.map((a) => {897+ const st = s.agentStats[a.id];898+ return {899+ agent: a.id,900+ group: a.group,901+ tiles: st.tiles,902+ cells: st.cells,903+ reward: st.cells,904+ reveals: st.reveals,905+ commits: st.commits,906+ forced: st.forced,907+ guess: st.guess,908+ wrong: st.wrong,909+ retracts: st.retracts,910+ retracted: st.retracted,911+ territory: solvedCells ? st.cells / solvedCells : 0,912+ meets: st.meets,913+ strategy: classify(st),914+ strategy_counts: {915+ bfs: st.bfs,916+ dfs: st.dfs,917+ greedy: st.greedy,918+ reveals: st.reveals,919+ },920+ last_solved: st.last_solved,921+ };922+ });923+ s.analysis = {924+ revision: s.revision,925+ guess_rate: t.forced + t.guess ? t.guess / (t.forced + t.guess) : 0,926+ reveal_commit: t.commits ? t.reveals / t.commits : 0,927+ retractions: t.retracts,928+ reward_per_batch: t.commits ? t.reward / t.commits : 0,929+ batch_size_mean: mean(s.batchSizes),930+ batch_size_recent: mean(recent),931+ batch_size_drift: mean(recent) - mean(s.batchSizes),932+ mean_gap_ms: s.gaps.length ? mean(s.gaps) : null,933+ solved_tiles: solvedTiles,934+ solved_cells: solvedCells,935+ meets: s.meets.length,936+ agents,937+ k: s.manifest?.k ?? "unmeasured",938+ ...(s.manifest?.k_hist ? { k_hist: s.manifest.k_hist } : {}),939+ };940+}941+942+/**943+ * Score a reveal for the strategy classifier, against the agent's frontier944+ * as it was before the reveal: bfs when the tile is at the minimal seed945+ * distance available, dfs when it touches the agent's last solved tile,946+ * greedy when it is the largest available. A reveal can count for several.947+ */948+function scoreReveal(949+ s: State,950+ agent: string,951+ tile: number,952+ frontier: number[],953+) {954+ const st = s.agentStats[agent];955+ const m = s.manifest!;956+ st.reveals++;957+ const dist = st.seed_dist;958+ const minDist = Math.min(959+ ...frontier.map((t) => (dist[t] < 0 ? Infinity : dist[t])),960+ );961+ if (dist[tile] >= 0 && dist[tile] === minDist) st.bfs++;962+ if (963+ st.last_solved !== null &&964+ s.geo!.adjacency[st.last_solved].includes(tile)965+ ) st.dfs++;966+ const maxCells = Math.max(...frontier.map((t) => m.tiles[t].cells));967+ if (m.tiles[tile].cells === maxCells) st.greedy++;968+}969+529970 /** Apply one event, returning a new state. The input is not mutated. */530971 export function applyEvent(prev: State, ev: GameEvent): State {531972 const s: State = {@@ -534,6 +975,15 @@ export function applyEvent(prev: State, ev: GameEvent): State {
534975 cells: { ...prev.cells },535976 console: [...prev.console],536977 totals: { ...prev.totals },978+ tileAgent: [...prev.tileAgent],979+ agentStats: Object.fromEntries(980+ Object.entries(prev.agentStats).map(([k, v]) => [k, {981+ ...v,982+ seed_dist: v.seed_dist,983+ }]),984+ ),985+ batchSizes: [...prev.batchSizes],986+ gaps: [...prev.gaps],537987 };538988 if (ev.rev !== prev.revision + 1) {539989 throw new Error(@@ -565,36 +1015,68 @@ export function applyEvent(prev: State, ev: GameEvent): State {
5651015 s.seed_tile = m.seed_tile;5661016 s.rules = ev.rules;5671017 s.tiles = new Array(m.tiles.length).fill("hidden");1018+ s.tileAgent = new Array(m.tiles.length).fill(null);5681019 for (const [id, v] of zoneCells(m)) {5691020 s.cells[id] = { v, kind: "zone", rev: 0 };5701021 }5711022 const sizes = m.tiles.map((t) => t.cells);1023+ const seeds = 1 +1024+ (m.seed_tiles?.filter((t) => t !== m.seed_tile).length ?? 0);5721025 s.console.push({5731026 ...base,5741027 op: "start",5751028 intent: `${m.tiles.length} tiles of ${Math.min(...sizes)}..${5761029 Math.max(...sizes)577- } cells (${m.packer.kind}), ${s.G}x${s.G} field, ${s.geo.holes} hole cells, seed tile #${s.seed_tile}`,1030+ } cells (${m.packer.kind}), ${s.G}x${s.G} field, ${s.geo.holes} hole cells, ${seeds} seed tile${1031+ seeds === 1 ? "" : "s"1032+ }`,1033+ });1034+ break;1035+ }1036+ case "join": {1037+ addAgent(s, {1038+ id: ev.agent,1039+ name: ev.name ?? ev.agent,1040+ harness: ev.harness,1041+ model: ev.model ?? null,1042+ tags: ev.tags ?? {},1043+ joined: ev.rev,1044+ seed_tile: ev.seed_tile,1045+ });1046+ s.console.push({1047+ ...base,1048+ op: "join",1049+ agent: ev.agent,1050+ tile: ev.seed_tile,1051+ intent: `${ev.name ?? ev.agent} joined (${ev.harness}${1052+ ev.model ? ", " + ev.model : ""1053+ }) at seed tile #${ev.seed_tile}`,5781054 });5791055 break;5801056 }5811057 case "batch": {1058+ const agent = ensureAgent(s, ev.agent);1059+ const st = s.agentStats[agent];5821060 if (ev.op === "reveal") {1061+ const frontier = frontierOf(s, agent); // before the reveal lands5831062 s.tiles[ev.tile] = "revealed";5841063 for (const [id, v] of ev.givens) {5851064 if (!s.cells[id]) s.cells[id] = { v, kind: "given", rev: ev.rev };5861065 }5871066 s.totals.reveals++;588- const { newly, reward } = settle(s);1067+ scoreReveal(s, agent, ev.tile, frontier.length ? frontier : [ev.tile]);1068+ const { newly, reward, meets } = settle(s, agent, ev.rev);5891069 s.totals.reward += reward;5901070 s.console.push({5911071 ...base,5921072 op: "reveal",1073+ agent,5931074 intent: ev.intent,5941075 tile: ev.tile,5951076 revealed_cells: ev.givens.length + ev.seam.length,5961077 solved: newly,5971078 reward,1079+ ...(meets.length ? { meets } : {}),5981080 });5991081 } else if (ev.op === "commit") {6001082 for (const c of ev.accepted) {@@ -605,18 +1087,27 @@ export function applyEvent(prev: State, ev: GameEvent): State {
6051087 rules: c.rules,6061088 rev: ev.rev,6071089 };608- if (c.class === "forced") s.totals.forced++;609- else s.totals.guess++;1090+ if (c.class === "forced") {1091+ s.totals.forced++;1092+ st.forced++;1093+ } else {1094+ s.totals.guess++;1095+ st.guess++;1096+ }6101097 }6111098 s.totals.commits++;612- s.totals.wrong += ev.rejected.filter((r) =>613- r.reason === "wrong"614- ).length;615- const { newly, reward } = settle(s);1099+ st.commits++;1100+ const wrong = ev.rejected.filter((r) => r.reason === "wrong").length;1101+ s.totals.wrong += wrong;1102+ st.wrong += wrong;1103+ s.batchSizes.push(ev.accepted.length);1104+ if (base.dt_ms !== null) s.gaps.push(base.dt_ms);1105+ const { newly, reward, meets } = settle(s, agent, ev.rev);6161106 s.totals.reward += reward;6171107 s.console.push({6181108 ...base,6191109 op: "commit",1110+ agent,6201111 intent: ev.intent,6211112 accepted: ev.accepted.length,6221113 forced: ev.accepted.filter((c) => c.class === "forced").length,@@ -625,6 +1116,7 @@ export function applyEvent(prev: State, ev: GameEvent): State {
6251116 ignored: ev.ignored.length,6261117 solved: newly,6271118 reward,1119+ ...(meets.length ? { meets } : {}),6281120 });6291121 } else if (ev.op === "retract") {6301122 let n = 0;@@ -636,9 +1128,12 @@ export function applyEvent(prev: State, ev: GameEvent): State {
6361128 }6371129 s.totals.retracts++;6381130 s.totals.retracted += n;1131+ st.retracts++;1132+ st.retracted += n;6391133 s.console.push({6401134 ...base,6411135 op: "retract",1136+ agent,6421137 intent: ev.intent,6431138 retracted: n,6441139 });@@ -652,6 +1147,7 @@ export function applyEvent(prev: State, ev: GameEvent): State {
6521147 }6531148 }6541149 s.last_t = ev.t;1150+ analyse(s);6551151 return s;6561152 }6571153 @@ -40,14 +40,42 @@ export type StartParams = {
4040 G: number;4141 kind: GenParams["kind"];4242 n?: number;43+ chunks?: number;44+ chunk_g?: number;4345 seed?: string;4446 rules?: Partial<GameRules>;4547 };4648 49+/** Live games, newest first: id, status, revision, agents. */50+export function listGames(): {51+ game_id: string;52+ status: string;53+ revision: number;54+ G: number;55+ tiles: number;56+ agents: string[];57+}[] {58+ return [...games.values()].reverse().map((ref) => ({59+ game_id: ref.gameId,60+ status: ref.state.status,61+ revision: ref.state.revision,62+ G: ref.state.G,63+ tiles: ref.manifest.tiles.length,64+ agents: ref.state.agents.map((a) => a.id),65+ }));66+}67+4768 export async function startGame(p: StartParams): Promise<Referee> {4869 const gameId = crypto.randomUUID();4970 const seed = p.seed ?? gameId;50- const out = await gen({ seed, G: p.G, kind: p.kind, n: p.n });71+ const out = await gen({72+ seed,73+ G: p.G,74+ kind: p.kind,75+ n: p.n,76+ chunks: p.chunks,77+ chunk_g: p.chunk_g,78+ });5179 const rules: GameRules = {5280 guess: p.rules?.guess ?? "accept",5381 depth: p.rules?.depth ?? 0,@@ -2,11 +2,71 @@
22 // write, cell arrays, a short intent string shown verbatim in the spectator.33 import { McpServer } from "@modelcontextprotocol/server";44 import { z } from "zod";5-import { getGame, startGame } from "./games.ts";6-import { RefereeError } from "./referee.ts";5+import { getGame, listGames, startGame } from "./games.ts";6+import { type JoinOptions, RefereeError } from "./referee.ts";7+8+/**9+ * The client's identity as the transport knows it. On a 2026-07-28-era10+ * request the per-request `_meta` envelope carries the client's name and11+ * version (`ctx.mcpReq.envelope`); on a 2025-era stateless request the12+ * server instance is per request and the initialize-scoped13+ * `getClientVersion()` is normally empty. Whatever is found wins; else the14+ * `harness` argument the client passed to join; else "unknown".15+ */16+function harnessOf(17+ ctx: unknown,18+ server: McpServer,19+ argument: string | undefined,20+): { harness: string; source: JoinOptions["harness_source"] } {21+ const c = ctx as {22+ mcpReq?: {23+ envelope?: Record<string, unknown>;24+ _meta?: Record<string, unknown>;25+ };26+ } | undefined;27+ const found = findImplementation(c?.mcpReq?.envelope) ??28+ findImplementation(c?.mcpReq?._meta);29+ if (found) return { harness: found, source: "envelope" };30+ const cv = (server as unknown as {31+ server?: {32+ getClientVersion?: () => { name?: string; version?: string } | undefined;33+ };34+ }).server?.getClientVersion?.();35+ if (cv?.name) {36+ return {37+ harness: `${cv.name}${cv.version ? " " + cv.version : ""}`,38+ source: "client_version",39+ };40+ }41+ if (argument) return { harness: argument, source: "argument" };42+ return { harness: "unknown", source: "none" };43+}44+45+/** Depth-2 search for an {name, version} object (the client implementation). */46+function findImplementation(o: unknown): string | undefined {47+ if (!o || typeof o !== "object") return undefined;48+ const rec = o as Record<string, unknown>;49+ const isImpl = (v: unknown): v is { name: string; version?: string } =>50+ !!v && typeof v === "object" &&51+ typeof (v as { name?: unknown }).name === "string";52+ if (isImpl(rec) && Object.keys(rec).length <= 4) {53+ return `${rec.name}${rec.version ? " " + rec.version : ""}`;54+ }55+ for (const v of Object.values(rec)) {56+ if (isImpl(v)) return `${v.name}${v.version ? " " + v.version : ""}`;57+ }58+ for (const v of Object.values(rec)) {59+ if (v && typeof v === "object") {60+ for (const w of Object.values(v as Record<string, unknown>)) {61+ if (isImpl(w)) return `${w.name}${w.version ? " " + w.version : ""}`;62+ }63+ }64+ }65+ return undefined;66+}767 868 const RULES_TEXT =9- `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), that differ on some cells it shares with a neighbour; a solved neighbour fixes those cells and pins it. Tiles are referred to by id; observe lists each tile's id, x0, y0, w, h, rects, cells, m, status and neighbours. Givens are hidden until you reveal a tile, and reveal is only accepted on the frontier: the seed tile at first, then hidden tiles sharing a cell with a solved tile. The 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 your correct commits); solving a tile earns its cell count as reward. Commits are checked per cell: wrong values are rejected (and reveal only that they were wrong); correct ones are accepted and classified forced or guess. Forced means the referee's own propagation of the three rules over the revealed cells (single-cell deductions to a fixpoint, plus bifurcation only to the game's configured depth, default 0) reaches that value; a cell that holds in every solution but needs deeper search is still 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. Use observe to read the state, reveal to earn givens, commit to fill cells in batches with a one-line intent, retract to withdraw your own commits. Every write carries expected_revision; on conflict, observe again. Writing code to do the deduction is allowed; where to expand (small tiles open the field, large tiles pin more) 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), that differ on some cells it shares with a neighbour; a solved neighbour fixes those cells and pins it. 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, its surroundings and your frontier, 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: wrong values are rejected (and reveal only that they were wrong); correct ones are accepted and classified forced or guess. Forced means the referee's own propagation of the three rules over the revealed cells (single-cell deductions to a fixpoint, plus bifurcation only to the game's configured depth, default 0) reaches that value; a cell that holds in every solution but needs deeper search is still 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. 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.`;1070 1171 const gameIdSchema = z.string().uuid();1272 @@ -29,26 +89,31 @@ function requireGame(id: string) {
2989 }3090 3191 export function createServer(): McpServer {32- const server = new McpServer({ name: "mosaic", version: "0.2.0" });92+ const server = new McpServer({ name: "mosaic", version: "0.3.0" });3393 3494 server.registerTool(3595 "start_mosaic",3696 {3797 title: "Start a mosaic",3898 description:39- `Create a new mosaic and open it in the spectator at http://127.0.0.1:8000/ . Defaults: kind=scatter with G=100, a field of tiles of random shapes and sizes scattered with overlaps and holes; kind=grid with n=14 and G=27 is the classic 2x2 of 14x14 squares (G-1 must be a multiple of n-1). G up to 1000. Then play autonomously with observe, reveal, commit, retract until status is solved. ${RULES_TEXT}`,99+ `Create a new field and open it in the spectator at http://127.0.0.1:8000/ . Defaults: kind=scatter with G=100, a field of tiles of random shapes and sizes scattered with overlaps and holes; kind=field with chunks=3 (chunk_g=60) is a larger field generated chunk by chunk; kind=grid with n=14 and G=27 is the classic 2x2 of 14x14 squares (G-1 must be a multiple of n-1). Starting does not seat you: call join next. Then play autonomously with observe, reveal, commit, retract until status is solved. ${RULES_TEXT}`,40100 inputSchema: z.object({41101 G: z.number().int().min(6).max(1000).default(100).describe(42102 "Cells per side of the field. For kind=grid, G-1 must be a multiple of n-1 (27 for a 2x2 of 14x14).",43103 ),44- kind: z.enum(["scatter", "grid", "big-first", "bands"]).default(45- "scatter",46- ).describe(47- "Layout: scatter (shapes of random size dropped with overlaps and holes; the default), grid (all tiles the same n, squares), big-first and bands (square packings).",48- ),104+ kind: z.enum(["scatter", "field", "grid", "big-first", "bands"])105+ .default("scatter").describe(106+ "Layout: scatter (shapes of random size dropped with overlaps and holes; the default), field (chunks x chunks scatter chunks of side chunk_g, generated in parallel; G is derived), grid (all tiles the same n, squares), big-first and bands (square packings).",107+ ),49108 n: z.number().int().min(6).max(16).default(14).describe(50109 "Tile size for kind=grid, even; 14 is the standard.",51110 ),111+ chunks: z.number().int().min(2).max(12).default(3).describe(112+ "kind=field: chunks per side.",113+ ),114+ chunk_g: z.number().int().min(40).max(120).default(60).describe(115+ "kind=field: cells per side of one chunk.",116+ ),52117 seed: z.string().min(1).max(80).optional().describe(53118 "Seed string; omitted means a fresh random mosaic.",54119 ),@@ -62,7 +127,7 @@ export function createServer(): McpServer {
62127 }).optional(),63128 }),64129 },65- async ({ G, kind, n, seed, rules }) => {130+ async ({ G, kind, n, chunks, chunk_g, seed, rules }) => {66131 try {67132 if (n % 2 !== 0) return errorResult(new Error("n must be even."));68133 if (kind === "grid" && (G - 1) % (n - 1) !== 0) {@@ -70,18 +135,31 @@ export function createServer(): McpServer {
70135 new Error(`kind=grid needs G-1 to be a multiple of n-1.`),71136 );72137 }73- const ref = await startGame({ G, kind, n, seed, rules });74- const obs = ref.observe();138+ const ref = await startGame({139+ G,140+ kind,141+ n,142+ chunks,143+ chunk_g,144+ seed,145+ rules,146+ });75147 const m = ref.manifest;148+ const seeds = 1 +149+ (m.seed_tiles?.filter((t) => t !== m.seed_tile).length ?? 0);76150 return textResult(77- `Started mosaic ${ref.gameId}: ${m.tiles.length} tiles on a ${obs.G}x${obs.G} field (${obs.holes} hole cells). Seed tile is #${obs.seed_tile}; reveal it first. Revision ${obs.revision}.`,151+ `Started field ${ref.gameId}: ${m.tiles.length} tiles on a ${m.G}x${m.G} field (${152+ ref.state.geo!.holes153+ } hole cells, ${seeds} seed tile${154+ seeds === 1 ? "" : "s"155+ }). Call join with this game_id to get your agent id and seed tile. Revision ${ref.revision}.`,78156 {79157 game_id: ref.gameId,80- revision: obs.revision,81- manifest: m,82- seed_tile: obs.seed_tile,83- frontier: obs.frontier,84- layout: obs.encoding,158+ revision: ref.revision,159+ G: m.G,160+ tiles: m.tiles.length,161+ holes: ref.state.geo!.holes,162+ seed_tiles: seeds,85163 spectator_url: "http://127.0.0.1:8000/",86164 },87165 );@@ -91,22 +169,103 @@ export function createServer(): McpServer {
91169 },92170 );93171 172+ server.registerTool(173+ "list_games",174+ {175+ title: "List fields",176+ description:177+ "List the fields this server is running (newest first): game_id, status, revision, G, tiles, and the agents that joined. Join a playing one, or start your own.",178+ inputSchema: z.object({}),179+ annotations: { readOnlyHint: true },180+ },181+ () => {182+ const games = listGames();183+ return textResult(184+ games.length185+ ? games.map((g) =>186+ `${g.game_id}: ${g.status} at rev ${g.revision}, ${g.G}x${g.G}, ${g.tiles} tiles, agents ${187+ JSON.stringify(g.agents)188+ }`189+ ).join("\n")190+ : "No field is running; start one with start_mosaic.",191+ { games },192+ );193+ },194+ );195+196+ server.registerTool(197+ "join",198+ {199+ title: "Join a field",200+ description:201+ "Join a field as an agent. Returns your agent id (your machine_id if you pass one, else a provisional a<N>), your seed tile (a hidden tile at a random spot away from everyone; reveal it first) and the revision. State your model (self-reported, e.g. claude-opus-5) and a short name; the harness is read from the transport when it identifies itself, else from the harness argument. Every reveal, commit and retract must carry your agent id.",202+ inputSchema: z.object({203+ game_id: gameIdSchema,204+ machine_id: z.string().min(1).max(80).optional().describe(205+ "Your durable machine id (a public key fingerprint), if you have one.",206+ ),207+ name: z.string().min(1).max(40).optional(),208+ model: z.string().min(1).max(80).optional().describe(209+ "The model you are, as you know it (self-reported).",210+ ),211+ harness: z.string().min(1).max(80).optional().describe(212+ "The client/harness you run in, used only when the transport does not identify it.",213+ ),214+ tags: z.record(z.string(), z.string()).optional().describe(215+ "Free tags for the analyzer (batch size policy, code use, ...).",216+ ),217+ }),218+ annotations: { destructiveHint: false, idempotentHint: false },219+ },220+ async ({ game_id, machine_id, name, model, harness, tags }, ctx) => {221+ try {222+ const ref = requireGame(game_id);223+ const h = harnessOf(ctx, server, harness);224+ const r = await ref.join({225+ machine_id,226+ name,227+ model,228+ tags,229+ harness: h.harness,230+ harness_source: h.source,231+ });232+ const t = ref.manifest.tiles[r.seed_tile];233+ return textResult(234+ `Joined ${game_id} as ${r.agent}. Your seed tile is #${r.seed_tile} (${t.cells} cells, ${t.w}x${t.h} at ${t.x0},${t.y0}); reveal it first. Revision ${r.revision}.`,235+ { ...r, harness: h.harness, harness_source: h.source },236+ );237+ } catch (error) {238+ return errorResult(error);239+ }240+ },241+ );242+94243 server.registerTool(95244 "observe",96245 {97246 title: "Observe the mosaic",98247 description:99- "Read the current revision, every tile (id, x0, y0, w, h, rects, cells, m, status, neighbours), the frontier, and every known cell (delta-coded ids plus a value string; decode by cumulative sum). Known cells are givens of revealed tiles, cells fixed by solved neighbours, and your accepted commits. The payload is under structuredContent.observation; cell id = y*G + x.",100- inputSchema: z.object({ game_id: gameIdSchema }),248+ "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.",249+ inputSchema: z.object({250+ game_id: gameIdSchema,251+ agent: z.string().min(1).max(80).optional(),252+ rect: z.tuple([253+ z.number().int().nonnegative(),254+ z.number().int().nonnegative(),255+ z.number().int().positive(),256+ z.number().int().positive(),257+ ]).optional(),258+ full: z.boolean().optional(),259+ }),101260 annotations: { readOnlyHint: true },102261 },103- ({ game_id }) => {262+ ({ game_id, agent, rect, full }) => {104263 try {105264 const ref = requireGame(game_id);106- const obs = ref.observe();265+ const obs = ref.observe({ agent, rect, full });107266 const solved = obs.tiles.filter((t) => t.status === "solved").length;108267 return textResult(109- `Game ${game_id} is ${obs.status} at revision ${obs.revision}; ${solved}/${obs.tiles.length} tiles solved; reward ${obs.totals.reward}; frontier ${268+ `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 ${110269 JSON.stringify(obs.frontier)111270 }.`,112271 { observation: obs },@@ -122,19 +281,20 @@ export function createServer(): McpServer {
122281 {123282 title: "Reveal a tile",124283 description:125- "Reveal the givens of a hidden frontier tile, by tile id. Returns the tile's shape (x0, y0, w, h, rects, cells), its givens, and any of its cells already known (fixed by solved neighbours), as [cell_id, value] pairs. Appends one batch event.",284+ "Reveal the givens of a hidden tile on your frontier, by tile id. Returns the tile's shape (x0, y0, w, h, rects, cells), its givens, and any of its cells already known (fixed by solved neighbours), as [cell_id, value] pairs. Appends one batch event.",126285 inputSchema: z.object({127286 game_id: gameIdSchema,287+ agent: z.string().min(1).max(80).describe("Your agent id from join."),128288 expected_revision: z.number().int().nonnegative(),129289 tile: z.number().int().nonnegative().describe("Tile id."),130290 intent: z.string().max(160).default(""),131291 }),132292 annotations: { destructiveHint: false, idempotentHint: false },133293 },134- async ({ game_id, expected_revision, tile, intent }) => {294+ async ({ game_id, agent, expected_revision, tile, intent }) => {135295 try {136296 const ref = requireGame(game_id);137- const r = await ref.reveal(expected_revision, tile, intent);297+ const r = await ref.reveal(expected_revision, tile, intent, agent);138298 return textResult(139299 `Revealed tile #${r.tile} (${r.cells} cells in a ${r.w}x${r.h} box at ${r.x0},${r.y0}): ${r.givens.length} givens, ${r.seam.length} cells already known. Revision ${r.revision}.`,140300 { ...r },@@ -153,6 +313,7 @@ export function createServer(): McpServer {
153313 "Commit a batch of [cell_id, value] pairs inside revealed tiles, with a one-line intent (<=160 chars) stating your reason; it is shown verbatim to spectators. Each cell is accepted (correct; classified forced or guess with the rules that force it), rejected (wrong value, or an unforced guess when the game rejects guesses), or ignored (already known, a given, a hole, duplicate, or outside revealed tiles). Reports newly solved tile ids, the reward earned, and the new frontier.",154314 inputSchema: z.object({155315 game_id: gameIdSchema,316+ agent: z.string().min(1).max(80).describe("Your agent id from join."),156317 expected_revision: z.number().int().nonnegative(),157318 cells: z.array(158319 z.tuple([@@ -164,22 +325,30 @@ export function createServer(): McpServer {
164325 }),165326 annotations: { destructiveHint: true, idempotentHint: false },166327 },167- async ({ game_id, expected_revision, cells, intent }) => {328+ async ({ game_id, agent, expected_revision, cells, intent }) => {168329 try {169330 const ref = requireGame(game_id);170331 const r = await ref.commit(171332 expected_revision,172333 cells as [number, number][],173334 intent,335+ agent,174336 );175337 const forced = r.accepted.filter((c) => c.class === "forced").length;338+ const met = r.meets.length339+ ? ` You met ${340+ r.meets.map((m) => m.with).join(", ")341+ }: territories merged.`342+ : "";176343 const text = r.status === "solved"177- ? `Mosaic solved at revision ${r.revision}: ${r.accepted.length} accepted, reward ${r.reward}.`344+ ? `Field solved at revision ${r.revision}: ${r.accepted.length} accepted, reward ${r.reward}.${met}`178345 : `Batch ${r.revision}: ${r.accepted.length} accepted (${forced} forced, ${179346 r.accepted.length - forced180347 } guess), ${r.rejected.length} rejected, ${r.ignored.length} ignored; solved ${181348 JSON.stringify(r.solved)182- } for ${r.reward} reward; frontier ${JSON.stringify(r.frontier)}.`;349+ } for ${r.reward} reward; frontier ${350+ JSON.stringify(r.frontier)351+ }.${met}`;183352 return textResult(text, { ...r });184353 } catch (error) {185354 return errorResult(error);@@ -195,16 +364,17 @@ export function createServer(): McpServer {
195364 "Withdraw cells you committed earlier (by cell id). Givens and cells fixed by neighbours cannot be retracted. Counted by the analyzer.",196365 inputSchema: z.object({197366 game_id: gameIdSchema,367+ agent: z.string().min(1).max(80).describe("Your agent id from join."),198368 expected_revision: z.number().int().nonnegative(),199369 cells: z.array(z.number().int().nonnegative()).min(1).max(8192),200370 intent: z.string().max(160).default(""),201371 }),202372 annotations: { destructiveHint: true, idempotentHint: false },203373 },204- async ({ game_id, expected_revision, cells, intent }) => {374+ async ({ game_id, agent, expected_revision, cells, intent }) => {205375 try {206376 const ref = requireGame(game_id);207- const r = await ref.retract(expected_revision, cells, intent);377+ const r = await ref.retract(expected_revision, cells, intent, agent);208378 return textResult(209379 `Retracted ${r.retracted.length} cells. Revision ${r.revision}.`,210380 { ...r },@@ -4,6 +4,7 @@
44 // per accepted call. Refusals throw RefereeError and append nothing.55 import {66 type AcceptedCell,7+ agentOf,78 allSolved,89 applyEvent,910 cellId,@@ -14,8 +15,12 @@ import {
1415 frontierOf,1516 type GameEvent,1617 type GameRules,18+ groupOf,19+ IMPLICIT_AGENT,1720 isHole,1821 isOnFrontier,22+ joinCandidates,23+ type JoinEvent,1924 type LocalRect,2025 type ManifestEvent,2126 neighboursOf,@@ -41,15 +46,46 @@ export type Judge = (input: JudgeInput) => Promise<JudgeOutput>;
4146 4247 export type CommitResult = {4348 revision: number;49+ agent: string;4450 accepted: AcceptedCell[];4551 rejected: RejectedCell[];4652 ignored: { id: number; reason: string }[];4753 solved: number[];54+ meets: { with: string; tile: number }[];4855 reward: number;4956 frontier: number[];5057 status: State["status"];5158 };5259 60+export type JoinOptions = {61+ machine_id?: string;62+ name?: string;63+ harness?: string;64+ harness_source?: JoinEvent["harness_source"];65+ model?: string;66+ tags?: Record<string, string>;67+};68+69+export type JoinResult = {70+ revision: number;71+ agent: string;72+ seed_tile: number;73+ frontier: number[];74+};75+76+export type ObserveOptions = {77+ agent?: string;78+ rect?: [number, number, number, number];79+ full?: boolean;80+ radius?: number;81+};82+83+/** Above this field side, observe needs an agent or a rect. */84+export const FULL_OBSERVE_MAX_G = 120;85+86+/** Cells around an agent's territory that observe {agent} includes. */87+export const OBSERVE_RADIUS = 24;88+5389 export type RevealResult = {5490 revision: number;5591 tile: number;@@ -70,6 +106,8 @@ export type Observation = {
70106 status: State["status"];71107 G: number;72108 seed_tile: number;109+ scope: { kind: "full" | "agent" | "rect"; agent?: string; rect?: number[] };110+ agents: { id: string; name: string; group: string; seed_tile: number }[];73111 frontier: number[];74112 tiles: {75113 id: number;@@ -82,8 +120,10 @@ export type Observation = {
82120 m: number;83121 status: string;84122 known: number;123+ agent: string | null;85124 neighbours: number[];86125 }[];126+ tiles_total: number;87127 holes: number;88128 known_deltas: number[];89129 known_values: string;@@ -195,12 +235,116 @@ export class Referee {
195235 return out;196236 }197237 238+ /** Deterministic rng for seed-tile draws: seed commit plus join index. */239+ private joinRng(index: number): () => number {240+ let h = 2166136261;241+ for (const ch of `${this.manifest.seed_commit}|join|${index}`) {242+ h = Math.imul(h ^ ch.charCodeAt(0), 16777619);243+ }244+ return () => {245+ h = Math.imul(h ^ (h >>> 15), 2246822507);246+ h = Math.imul(h ^ (h >>> 13), 3266489909);247+ h ^= h >>> 16;248+ return (h >>> 0) / 4294967296;249+ };250+ }251+252+ /**253+ * An agent joins: gets an id (its machine id, or a provisional a<N>) and a254+ * seed tile drawn from the unclaimed seed tiles first, else from the hidden255+ * tiles that touch nothing revealed, at random from the seed commit. Appends256+ * one join event.257+ */258+ async join(opts: JoinOptions = {}): Promise<JoinResult> {259+ if (this.state.status === "solved") {260+ throw new RefereeError("The mosaic is already solved.", "ended");261+ }262+ const index = this.state.agents.length;263+ const agent =264+ opts.machine_id && /^[a-zA-Z0-9_:.-]{1,80}$/.test(opts.machine_id)265+ ? opts.machine_id266+ : `a${index}`;267+ if (agentOf(this.state, agent)) {268+ throw new RefereeError(`Agent ${agent} has already joined.`, "joined");269+ }270+ let candidates = joinCandidates(this.state);271+ if (candidates.length === 0) {272+ // nowhere untouched is left: start next to someone273+ candidates = this.manifest.tiles.filter((t) =>274+ this.state.tiles[t.id] === "hidden"275+ ).map((t) => t.id);276+ }277+ if (candidates.length === 0) {278+ throw new RefereeError("No hidden tile is left to start on.", "full");279+ }280+ const r = this.joinRng(index);281+ const seedTile = candidates[Math.floor(r() * candidates.length)];282+ const ev: JoinEvent = {283+ rev: this.state.revision + 1,284+ t: new Date().toISOString(),285+ kind: "join",286+ agent,287+ ...(opts.name ? { name: clip(opts.name).slice(0, 40) } : {}),288+ harness: (opts.harness ?? "unknown").slice(0, 80),289+ harness_source: opts.harness_source ??290+ (opts.harness ? "argument" : "none"),291+ ...(opts.model ? { model: opts.model.slice(0, 80) } : {}),292+ ...(opts.tags ? { tags: clipTags(opts.tags) } : {}),293+ ...(opts.machine_id ? { machine_id: opts.machine_id } : {}),294+ seed_tile: seedTile,295+ };296+ await this.apply(ev);297+ return {298+ revision: this.state.revision,299+ agent,300+ seed_tile: seedTile,301+ frontier: frontierOf(this.state, agent),302+ };303+ }304+305+ /**306+ * The agent a write belongs to. Writes without an agent are accepted only307+ * from the single implicit player (old clients): it is joined on first use308+ * with the manifest's seed tile.309+ */310+ private async resolveAgent(agent: string | undefined): Promise<string> {311+ if (agent !== undefined) {312+ if (!agentOf(this.state, agent)) {313+ throw new RefereeError(314+ `Agent ${agent} has not joined this game; call join first.`,315+ "agent",316+ );317+ }318+ return agent;319+ }320+ if (this.state.agents.length === 0) {321+ const ev: JoinEvent = {322+ rev: this.state.revision + 1,323+ t: new Date().toISOString(),324+ kind: "join",325+ agent: IMPLICIT_AGENT,326+ harness: "unknown",327+ harness_source: "none",328+ seed_tile: this.manifest.seed_tile,329+ };330+ await this.apply(ev);331+ return IMPLICIT_AGENT;332+ }333+ if (this.state.agents.length === 1) return this.state.agents[0].id;334+ throw new RefereeError(335+ "Several agents have joined; every write needs an agent id.",336+ "agent",337+ );338+ }339+198340 async reveal(199341 expected: number,200342 tile: number,201343 intent = "",344+ agentId?: string,202345 ): Promise<RevealResult> {203346 this.checkRevision(expected);347+ const agent = await this.resolveAgent(agentId);204348 const t = this.spec(tile);205349 if (this.state.tiles[t.id] !== "hidden") {206350 throw new RefereeError(@@ -208,10 +352,10 @@ export class Referee {
208352 "not_hidden",209353 );210354 }211- if (!isOnFrontier(this.state, t.id)) {355+ if (!isOnFrontier(this.state, t.id, agent)) {212356 throw new RefereeError(213- `Tile ${t.id} is not on the frontier. Frontier: ${214- JSON.stringify(frontierOf(this.state))357+ `Tile ${t.id} is not on ${agent}'s frontier (tiles overlapping a tile your group solved, or your seed tile). Frontier: ${358+ JSON.stringify(frontierOf(this.state, agent))215359 }.`,216360 "frontier",217361 );@@ -223,6 +367,7 @@ export class Referee {
223367 t: new Date().toISOString(),224368 kind: "batch",225369 op: "reveal",370+ agent,226371 tile: t.id,227372 givens,228373 seam,@@ -240,7 +385,7 @@ export class Referee {
240385 cells: t.cells,241386 givens,242387 seam,243- frontier: frontierOf(this.state),388+ frontier: frontierOf(this.state, agent),244389 };245390 }246391 @@ -259,8 +404,10 @@ export class Referee {
259404 expected: number,260405 cells: [number, number][],261406 intent = "",407+ agentId?: string,262408 ): Promise<CommitResult> {263409 this.checkRevision(expected);410+ const agent = await this.resolveAgent(agentId);264411 const G = this.state.G;265412 const geo = this.state.geo!;266413 const ignored: { id: number; reason: string }[] = [];@@ -349,6 +496,7 @@ export class Referee {
349496 t: new Date().toISOString(),350497 kind: "batch",351498 op: "commit",499+ agent,352500 intent: clip(intent),353501 accepted,354502 rejected,@@ -356,11 +504,17 @@ export class Referee {
356504 solved: [],357505 judge_ms: judged.ms,358506 };359- // solved tiles are derived by the fold; record them on the event for readers507+ // solved tiles and meetings are derived by the fold; record them on the508+ // event for readers360509 const preview = applyEvent(this.state, ev);361510 ev.solved = preview.tiles.map((s, i) => [s, i] as const)362511 .filter(([s, i]) => s === "solved" && before[i] !== "solved")363512 .map(([, i]) => i);513+ const meets = preview.meets.slice(this.state.meets.length).map((m) => ({514+ with: m.agents[1],515+ tile: m.tile,516+ }));517+ if (meets.length) ev.meets = meets;364518 await this.apply(ev);365519 const reward = ev.solved.reduce((a, i) => a + this.specs.get(i)!.cells, 0);366520 if (allSolved(this.state)) {@@ -369,17 +523,20 @@ export class Referee {
369523 t: new Date().toISOString(),370524 kind: "end",371525 seed: this.seed,526+ analysis: { ...this.state.analysis, revision: this.state.revision + 1 },372527 };373528 await this.apply(end);374529 }375530 return {376531 revision: this.state.revision,532+ agent,377533 accepted,378534 rejected,379535 ignored,380536 solved: ev.solved,537+ meets,381538 reward,382- frontier: frontierOf(this.state),539+ frontier: frontierOf(this.state, agent),383540 status: this.state.status,384541 };385542 }@@ -388,8 +545,10 @@ export class Referee {
388545 expected: number,389546 ids: number[],390547 intent = "",548+ agentId?: string,391549 ): Promise<{ revision: number; retracted: number[]; ignored: number[] }> {392550 this.checkRevision(expected);551+ const agent = await this.resolveAgent(agentId);393552 const retracted: number[] = [];394553 const ignored: number[] = [];395554 for (const id of ids) {@@ -410,6 +569,7 @@ export class Referee {
410569 t: new Date().toISOString(),411570 kind: "batch",412571 op: "retract",572+ agent,413573 cells: retracted,414574 intent: clip(intent),415575 };@@ -417,16 +577,86 @@ export class Referee {
417577 return { revision: this.state.revision, retracted, ignored };418578 }419579 420- observe(): Observation {580+ /**581+ * Observe, scoped: with `agent`, the tiles of the agent's group plus every582+ * tile within `radius` cells of them and the group's frontier; with `rect`,583+ * the tiles intersecting it; `full` (or neither, on a small field) lists584+ * everything. Known cells are coded within the returned tiles only.585+ */586+ observe(opts: ObserveOptions = {}): Observation {421587 const s = this.state;422- const ids = Object.keys(s.cells).map(Number).sort((a, b) => a - b);423- const committed = ids.filter((id) => s.cells[id].kind === "commit");588+ const geo = s.geo!;589+ const G = s.G;590+ let scope: Observation["scope"];591+ let chosen: TileSpec[];592+ let frontier: number[];593+ if (opts.agent !== undefined) {594+ const a = agentOf(s, opts.agent);595+ if (!a) {596+ throw new RefereeError(597+ `Agent ${opts.agent} has not joined this game; call join first.`,598+ "agent",599+ );600+ }601+ const group = groupOf(s, opts.agent);602+ const mine = this.manifest.tiles.filter((t) => {603+ const ta = s.tileAgent[t.id];604+ return (ta !== null && groupOf(s, ta) === group) ||605+ s.agents.some((x) => x.group === group && x.seed_tile === t.id);606+ });607+ const radius = opts.radius ?? OBSERVE_RADIUS;608+ const boxes = mine.map((t) => ({609+ x0: t.x0 - radius,610+ y0: t.y0 - radius,611+ x1: t.x0 + t.w + radius,612+ y1: t.y0 + t.h + radius,613+ }));614+ frontier = frontierOf(s, opts.agent);615+ const fr = new Set(frontier);616+ chosen = this.manifest.tiles.filter((t) =>617+ fr.has(t.id) ||618+ boxes.some((b) =>619+ t.x0 < b.x1 && t.x0 + t.w > b.x0 && t.y0 < b.y1 && t.y0 + t.h > b.y0620+ )621+ );622+ scope = { kind: "agent", agent: opts.agent };623+ } else if (opts.rect) {624+ const [x, y, w, h] = opts.rect;625+ chosen = this.manifest.tiles.filter((t) =>626+ t.x0 < x + w && t.x0 + t.w > x && t.y0 < y + h && t.y0 + t.h > y627+ );628+ const ids = new Set(chosen.map((t) => t.id));629+ frontier = frontierOf(s).filter((t) => ids.has(t));630+ scope = { kind: "rect", rect: opts.rect };631+ } else {632+ if (!opts.full && G > FULL_OBSERVE_MAX_G) {633+ throw new RefereeError(634+ `The field is ${G}x${G}: observe with {agent} (your territory and its surroundings) or {rect: [x, y, w, h]}; full=true is allowed only up to G=${FULL_OBSERVE_MAX_G}.`,635+ "scope",636+ );637+ }638+ if (opts.full && G > FULL_OBSERVE_MAX_G) {639+ throw new RefereeError(640+ `full observation is allowed only up to G=${FULL_OBSERVE_MAX_G}; use {agent} or {rect}.`,641+ "scope",642+ );643+ }644+ chosen = this.manifest.tiles;645+ frontier = frontierOf(s);646+ scope = { kind: "full" };647+ }648+ const known = new Set<number>();424649 const tiles = [];425- for (const t of this.manifest.tiles) {650+ for (const t of chosen) {426651 const st = s.tiles[t.id];427- let known = 0;652+ let k = 0;428653 if (st !== "hidden") {429- for (const id of s.geo!.cellsOf[t.id]) if (s.cells[id]) known++;654+ for (const id of geo.cellsOf[t.id]) {655+ if (s.cells[id]) {656+ k++;657+ known.add(id);658+ }659+ }430660 }431661 tiles.push({432662 id: t.id,@@ -438,19 +668,30 @@ export class Referee {
438668 cells: t.cells,439669 m: t.m,440670 status: st,441- known,671+ known: k,672+ agent: s.tileAgent[t.id] ?? null,442673 neighbours: neighboursOf(t),443674 });444675 }676+ const ids = [...known].sort((a, b) => a - b);677+ const committed = ids.filter((id) => s.cells[id].kind === "commit");445678 return {446679 game_id: this.gameId,447680 revision: s.revision,448681 status: s.status,449- G: s.G,682+ G,450683 seed_tile: s.seed_tile,451- frontier: frontierOf(s),684+ scope,685+ agents: s.agents.map((a) => ({686+ id: a.id,687+ name: a.name,688+ group: a.group,689+ seed_tile: a.seed_tile,690+ })),691+ frontier,452692 tiles,453- holes: s.geo!.holes,693+ tiles_total: this.manifest.tiles.length,694+ holes: geo.holes,454695 known_deltas: deltaEncode(ids),455696 known_values: ids.map((id) => String(s.cells[id].v)).join(""),456697 committed_deltas: deltaEncode(committed),@@ -460,10 +701,15 @@ export class Referee {
460701 tile_shape:461702 "tile {id, x0, y0, w, h, rects} is the union of rects [lx, ly, rw, rh] in local coordinates inside the w x h box at (x0, y0); cells is its cell count; tiles overlap neighbours by rectangles of shared cells",462703 neighbours:463- "tile ids sharing at least one cell; a solved neighbour fixes the shared cells and puts this tile on the frontier",704+ "tile ids sharing at least one cell; a tile solved by your group fixes the shared cells and puts this tile on your frontier",705+ scope:706+ "which tiles are listed: your territory and its surroundings (agent), a rectangle (rect), or everything (full, small fields only); known cells are coded over the listed tiles only",464707 known_deltas:465- "cumulative sum recovers the sorted ids of every known cell; known_values[i] is the value of the i-th id",466- committed_deltas: "same coding; the subset you committed (retractable)",708+ "cumulative sum recovers the sorted ids of every known cell in the listed tiles; known_values[i] is the value of the i-th id",709+ committed_deltas:710+ "same coding; the subset that was committed (retractable)",711+ agent:712+ "on a tile: who solved it; agents[].group is the territory it belongs to (groups merge when they meet)",467713 holes: "cells no tile covers; never playable",468714 },469715 };@@ -474,6 +720,14 @@ function clip(s: string): string {
474720 return (s ?? "").slice(0, 160);475721 }476722 723+function clipTags(tags: Record<string, string>): Record<string, string> {724+ const out: Record<string, string> = {};725+ for (const [k, v] of Object.entries(tags).slice(0, 16)) {726+ out[String(k).slice(0, 32)] = String(v).slice(0, 80);727+ }728+ return out;729+}730+477731 function summarise(ignored: { id: number; reason: string }[]): string {478732 const counts: Record<string, number> = {};479733 for (const i of ignored) counts[i.reason] = (counts[i.reason] ?? 0) + 1;@@ -1,7 +1,9 @@
11 // JSONL download for replay: the same events the fold consumes, from disk.2+// `?summary=1` folds the log and returns the analysis at its last revision.23 import { define } from "../../utils.ts";34 import { getGame } from "../../lib/games.ts";45 import { readLog } from "../../lib/log.ts";6+import { fold, parseLog } from "../../lib/fold.ts";57 68 export const handler = define.handlers({79 async GET(ctx) {@@ -14,6 +16,17 @@ export const handler = define.handlers({
1416 if (text === null) {1517 return Response.json({ error: "Game not found." }, { status: 404 });1618 }19+ if (ctx.url.searchParams.get("summary") === "1") {20+ const s = fold(parseLog(text));21+ return Response.json({22+ game_id: id,23+ status: s.status,24+ revision: s.revision,25+ agents: s.agents,26+ meets: s.meets,27+ analysis: s.analysis,28+ });29+ }1730 return new Response(text, {1831 headers: {1932 "Content-Type": "application/x-ndjson; charset=utf-8",@@ -4,7 +4,14 @@
44 // cells with the three rules, commit what every solution agrees on, then55 // guess one cell of the first solution and learn from the verdict.66 //7-// deno run -A scripts/solve.ts [--url http://127.0.0.1:8000/mcp] [--G 100] [--kind scatter|grid|big-first|bands] [--n 14] [--seed s] [--limit 6]7+// 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]8+// [--join <game_id>] [--agent name] [--model m] [--strategy bfs|dfs|greedy] [--max-batches N]9+//10+// --join plays an existing field as one more agent (several solve.ts11+// processes on one game_id share it); --strategy picks the next tile to12+// reveal: bfs = the frontier tile nearest my seed tile in the overlap graph,13+// dfs = a frontier tile adjacent to the tile I solved last, greedy = the14+// largest frontier tile.815 916 const args = Object.fromEntries(1017 Deno.args.map((a, i, all) =>@@ -16,6 +23,10 @@ const G_ARG = Number(args.G ?? 100);
1623 const KIND = args.kind ?? "scatter";1724 const N_ARG = Number(args.n ?? 14);1825 const LIMIT = Number(args.limit ?? 6);26+const CHUNKS = Number(args.chunks ?? 3);27+const STRATEGY = (args.strategy ?? "bfs") as "bfs" | "dfs" | "greedy";28+const MAX_BATCHES = Number(args["max-batches"] ?? Infinity);29+const AGENT_NAME = args.agent ?? `solve-${STRATEGY}`;1930 2031 let nextId = 1;2132 async function rpc(method: string, params: unknown): Promise<any> {@@ -216,19 +227,94 @@ const init = await rpc("initialize", {
216227 });217228 console.log("server", init.serverInfo?.name, init.protocolVersion);218229 219-const started = await call("start_mosaic", {220- G: G_ARG,221- kind: KIND,222- n: N_ARG,223- ...(args.seed ? { seed: args.seed } : {}),230+let gameId: string;231+if (args.join) {232+ gameId = args.join;233+} else {234+ const started = await call("start_mosaic", {235+ G: G_ARG,236+ kind: KIND,237+ n: N_ARG,238+ chunks: CHUNKS,239+ ...(args.seed ? { seed: args.seed } : {}),240+ });241+ gameId = started.game_id;242+ console.log(243+ "game",244+ gameId,245+ started.tiles,246+ "tiles, seed tiles",247+ started.seed_tiles,248+ );249+}250+const joined = await call("join", {251+ game_id: gameId,252+ name: AGENT_NAME,253+ harness: "mosaic-solve 0.3.0",254+ ...(args.model ? { model: args.model } : {}),255+ tags: { strategy: STRATEGY, limit: String(LIMIT) },224256 });225-const gameId: string = started.game_id;226-console.log("game", gameId, "seed tile", started.seed_tile);257+const AGENT: string = joined.agent;258+const SEED_TILE: number = joined.seed_tile;259+console.log(260+ "agent",261+ AGENT,262+ "seed tile",263+ SEED_TILE,264+ "harness",265+ joined.harness,266+ `(${joined.harness_source})`,267+);227268 228269 const t0 = Date.now();229-let obs = (await call("observe", { game_id: gameId })).observation;270+const observe = async () =>271+ (await call("observe", { game_id: gameId, agent: AGENT })).observation;272+let obs = await observe();230273 const G: number = obs.G;231274 275+// overlap graph over the tiles seen so far (observe is scoped; neighbours276+// are listed for every tile it returns)277+const neighbours = new Map<number, number[]>();278+const cellsOfTile = new Map<number, number>();279+function learn(o: any) {280+ for (const t of o.tiles) {281+ neighbours.set(t.id, t.neighbours);282+ cellsOfTile.set(t.id, t.cells);283+ }284+}285+learn(obs);286+/** BFS distance from my seed tile over the tiles seen so far. */287+function distFromSeed(): Map<number, number> {288+ const d = new Map<number, number>([[SEED_TILE, 0]]);289+ const q = [SEED_TILE];290+ for (let i = 0; i < q.length; i++) {291+ for (const u of neighbours.get(q[i]) ?? []) {292+ if (!d.has(u)) {293+ d.set(u, d.get(q[i])! + 1);294+ q.push(u);295+ }296+ }297+ }298+ return d;299+}300+let lastSolved: number | null = null;301+function pickReveal(frontier: number[]): number {302+ if (STRATEGY === "dfs" && lastSolved !== null) {303+ const adj = new Set(neighbours.get(lastSolved) ?? []);304+ const near = frontier.filter((t) => adj.has(t));305+ if (near.length) return near[0];306+ }307+ if (STRATEGY === "greedy") {308+ return frontier.reduce((a, b) =>309+ (cellsOfTile.get(b) ?? 0) > (cellsOfTile.get(a) ?? 0) ? b : a310+ );311+ }312+ const d = distFromSeed();313+ return frontier.reduce((a, b) =>314+ (d.get(b) ?? Infinity) < (d.get(a) ?? Infinity) ? b : a315+ );316+}317+232318 function knownMap(o: any): Map<number, number> {233319 const m = new Map<number, number>();234320 let acc = 0;@@ -253,22 +339,47 @@ const tileIds = (t: Shape): number[] => {
253339 254340 let batches = 0, guesses = 0;255341 const wrongKnown = new Map<number, number>(); // id -> value we learned by rejection256-while (obs.status !== "solved") {342+/** A write that lost the revision race (other agents) is retried after observing. */343+async function write(name: string, a: Record<string, unknown>): Promise<any> {344+ for (let attempt = 0; attempt < 20; attempt++) {345+ try {346+ return await call(name, {347+ ...a,348+ game_id: gameId,349+ agent: AGENT,350+ expected_revision: obs.revision,351+ });352+ } catch (e) {353+ const msg = String(e);354+ if (!msg.includes("Revision conflict")) throw e;355+ obs = await observe();356+ learn(obs);357+ }358+ }359+ throw new Error(`${name}: gave up after 20 revision conflicts`);360+}361+362+while (obs.status !== "solved" && batches < MAX_BATCHES) {257363 const frontier: number[] = obs.frontier;258- // prefer a revealed-but-unsolved tile (finish what we started), else reveal one259- const revealed = obs.tiles.filter((t: any) => t.status === "revealed");364+ // prefer a revealed-but-unsolved tile of mine (finish what we started), else reveal one365+ const revealed = obs.tiles.filter((t: any) =>366+ t.status === "revealed" && frontier.includes(t.id)367+ );260368 let tile: any;261369 if (revealed.length) {262370 tile = revealed[0];371+ } else if (frontier.length === 0) {372+ // my group has nothing left to do; someone else finishes the field373+ console.log(`agent ${AGENT}: frontier empty, waiting for others`);374+ await new Promise((r) => setTimeout(r, 500));375+ obs = await observe();376+ learn(obs);377+ continue;263378 } else {264- const id = frontier[0];265- await call("reveal", {266- game_id: gameId,267- expected_revision: obs.revision,268- tile: id,269- intent: `reveal #${id} from the frontier`,270- });271- obs = (await call("observe", { game_id: gameId })).observation;379+ const id = pickReveal(frontier);380+ await write("reveal", { tile: id, intent: `reveal #${id} (${STRATEGY})` });381+ obs = await observe();382+ learn(obs);272383 continue;273384 }274385 const shape: Shape = tile;@@ -306,27 +417,28 @@ while (obs.status !== "solved") {
306417 intent =307418 `tile #${tile.id} (${label}): ${sols.length} solutions disagree on ${disputed.length} cells, guessing one`;308419 }309- const r = await call("commit", {310- game_id: gameId,311- expected_revision: obs.revision,312- cells: batch,313- intent,314- });420+ const r = await write("commit", { cells: batch, intent });315421 batches++;316422 for (const rej of r.rejected) {317423 const v = batch.find(([id]) => id === rej.id)![1];318424 wrongKnown.set(rej.id, 1 - v);319425 }426+ if (r.solved.length) lastSolved = r.solved[r.solved.length - 1];320427 const forced = r.accepted.filter((c: any) => c.class === "forced").length;321428 console.log(322- `#${r.revision} tile ${tile.id} (${label}) ${r.accepted.length} ok (${forced} forced), ${r.rejected.length} rejected, solved ${429+ `#${r.revision} ${AGENT} tile ${tile.id} (${label}) ${r.accepted.length} ok (${forced} forced), ${r.rejected.length} rejected, solved ${323430 JSON.stringify(r.solved)324- } +${r.reward}, status ${r.status}`,431+ } +${r.reward}${432+ r.meets?.length ? " met " + r.meets.map((m: any) => m.with).join(",") : ""433+ }, status ${r.status}`,325434 );326- obs = (await call("observe", { game_id: gameId })).observation;435+ obs = await observe();436+ learn(obs);327437 }328438 console.log(329- `solved ${gameId} in ${batches} batches, ${guesses} guesses, ${439+ `${440+ obs.status === "solved" ? "solved" : "stopped"441+ } ${gameId} as ${AGENT} in ${batches} batches, ${guesses} guesses, ${330442 ((Date.now() - t0) / 1000).toFixed(1)331443 }s; totals ${JSON.stringify(obs.totals)}`,332444 );@@ -18,6 +18,7 @@ export type Layout = {
1818 G: number;1919 kind: string;2020 seed_tile: number;21+ seed_tiles?: number[];2122 tiles: Omit<TileSpec, "overlaps" | "hash">[];2223 };2324 @@ -200,6 +201,55 @@ export function scatterLayout(seed: string, G: number, target = 0.7): Layout {
200201 return { G, kind: "scatter", seed_tile: 0, tiles };201202 }202203 204+/**205+ * Choose n seed tiles spread over the layout by farthest-point sampling206+ * over the overlap graph (a stand-in for the field generator's one seed207+ * per chunk). The first is the layout's seed tile.208+ */209+export function withSeeds(L: Layout, n: number): Layout {210+ const tiles: TileSpec[] = L.tiles.map((t) => ({ ...t, overlaps: [] }));211+ const geo = buildGeometry({212+ version: 3,213+ id: "s",214+ seed_commit: "s",215+ G: L.G,216+ packer: { kind: L.kind },217+ rules: {},218+ seed_tile: L.seed_tile,219+ tiles,220+ k: "unmeasured",221+ generator: "fixture",222+ library: null,223+ });224+ const dist = (from: number) => {225+ const d = new Array(tiles.length).fill(-1);226+ d[from] = 0;227+ const q = [from];228+ for (let i = 0; i < q.length; i++) {229+ for (const u of geo.adjacency[q[i]]) {230+ if (d[u] < 0) {231+ d[u] = d[q[i]] + 1;232+ q.push(u);233+ }234+ }235+ }236+ return d;237+ };238+ const seeds = [L.seed_tile];239+ let best = dist(L.seed_tile).map((d) => (d < 0 ? 0 : d));240+ while (seeds.length < n) {241+ let pick = -1;242+ for (let t = 0; t < tiles.length; t++) {243+ if (!seeds.includes(t) && (pick < 0 || best[t] > best[pick])) pick = t;244+ }245+ if (pick < 0) break;246+ seeds.push(pick);247+ const d = dist(pick);248+ best = best.map((b, t) => Math.min(b, d[t] < 0 ? b : d[t]));249+ }250+ return { ...L, seed_tiles: seeds };251+}252+203253 /** Wrap a layout as generator output: random solution, ~30% givens per tile. */204254 export function synthesise(205255 L: Layout,@@ -220,6 +270,7 @@ export function synthesise(
220270 packer: { kind: L.kind },221271 rules: { overlaps: true, cross_tile: false },222272 seed_tile: L.seed_tile,273+ ...(L.seed_tiles ? { seed_tiles: L.seed_tiles } : {}),223274 tiles,224275 k: "unmeasured",225276 generator: "fixture",@@ -4,12 +4,13 @@
44 // applyEvent over the event stream) must equal the replay fold (from the55 // JSONL on disk) at every revision, and gating must refuse what the plan66 // says it refuses. The judge is a deterministic stub.7-import { assert, assertEquals, assertRejects } from "@std/assert";7+import { assert, assertEquals, assertRejects, assertThrows } from "@std/assert";88 import {99 applyEvent,1010 emptyState,1111 foldAll,1212 frontierOf,13+ groupOf,1314 isHole,1415 neighboursOf,1516 parseLog,@@ -18,7 +19,13 @@ import {
1819 import type { GenOutput } from "../lib/engine.ts";1920 import { Referee, RefereeError } from "../lib/referee.ts";2021 import { readLog } from "../lib/log.ts";21-import { gridLayout, scatterLayout, stubJudge, synthesise } from "./fixture.ts";22+import {23+ gridLayout,24+ scatterLayout,25+ stubJudge,26+ synthesise,27+ withSeeds,28+} from "./fixture.ts";2229 2330 const tmp = await Deno.makeTempDir();2431 Deno.env.set("MOSAIC_GAMES", tmp + "/");@@ -56,14 +63,16 @@ async function play(
5663 acc = applyEvent(acc, ref.log.events[0]);5764 stream.push(acc);5865 live.push(structuredClone(ref.state));66+ const { agent } = await ref.join({ name: "player", harness: "test" });67+ live.push(structuredClone(ref.state));5968 const sol = solutionOf(out);6069 const geo = ref.state.geo!;6170 while (ref.state.status === "playing") {62- const fr = frontierOf(ref.state);71+ const fr = frontierOf(ref.state, agent);6372 assert(fr.length > 0, "frontier never empties before the end");6473 const id = fr[0];6574 if (ref.state.tiles[id] === "hidden") {66- await ref.reveal(ref.revision, id, `reveal #${id}`);75+ await ref.reveal(ref.revision, id, `reveal #${id}`, agent);6776 live.push(structuredClone(ref.state));6877 }6978 const unknown = geo.cellsOf[id].filter((c) => !ref.state.cells[c]);@@ -79,10 +88,10 @@ async function play(
7988 }8089 const hole = holeOf(ref.state);8190 if (hole >= 0) batch.push([hole, 0]);82- await ref.commit(ref.revision, batch, `fill #${id}`);91+ await ref.commit(ref.revision, batch, `fill #${id}`, agent);8392 live.push(structuredClone(ref.state));8493 }85- return { ref, live, stream, gameId, out };94+ return { ref, live, stream, gameId, out, agent };8695 }8796 8897 function holeOf(s: State): number {@@ -139,40 +148,51 @@ Deno.test("referee gating: frontier, revision, holes, no event on refusal", asyn
139148 stubJudge,140149 { persist: false },141150 );142- const seed = ref.manifest.seed_tile;151+ assertEquals(ref.revision, 0);152+ assertEquals(frontierOf(ref.state), [ref.manifest.seed_tile]);153+ // the first agent gets the manifest's seed tile (the only listed one)154+ const j = await ref.join({ name: "one", harness: "test" });155+ assertEquals(j.agent, "a0");156+ assertEquals(j.seed_tile, ref.manifest.seed_tile);157+ const seed = j.seed_tile;143158 const seedSpec = ref.manifest.tiles[seed];144159 const other = ref.manifest.tiles.find((t) =>145160 t.id !== seed && !neighboursOf(seedSpec).includes(t.id)146161 )!;147- assertEquals(ref.revision, 0);148- assertEquals(frontierOf(ref.state), [seed]);162+ assertEquals(ref.revision, 1);163+ assertEquals(frontierOf(ref.state, "a0"), [seed]);149164 await assertRejects(150- () => ref.reveal(0, other.id),165+ () => ref.reveal(1, other.id, "", "a0"),151166 RefereeError,152- "not on the frontier",167+ "not on a0's frontier",153168 );154169 await assertRejects(155- () => ref.reveal(3, seed),170+ () => ref.reveal(3, seed, "", "a0"),156171 RefereeError,157172 "Revision conflict",158173 );159174 await assertRejects(160- () => ref.reveal(0, ref.manifest.tiles.length + 5),175+ () => ref.reveal(1, ref.manifest.tiles.length + 5, "", "a0"),161176 RefereeError,162177 "does not exist",163178 );179+ await assertRejects(180+ () => ref.reveal(1, seed, "", "nobody"),181+ RefereeError,182+ "has not joined",183+ );164184 // a hole alone is ignored, so nothing is processable165185 const hole = holeOf(ref.state);166186 assert(hole >= 0, "the scattered fixture has a hole");167187 await assertRejects(168- () => ref.commit(0, [[hole, 0]]),188+ () => ref.commit(1, [[hole, 0]], "", "a0"),169189 RefereeError,170190 "1 hole",171191 );172- assertEquals(ref.revision, 0);173- assertEquals(ref.log.events.length, 1);174- const r = await ref.reveal(0, seed, "seed");175- assertEquals(r.revision, 1);192+ assertEquals(ref.revision, 1);193+ assertEquals(ref.log.events.length, 2);194+ const r = await ref.reveal(1, seed, "seed", "a0");195+ assertEquals(r.revision, 2);176196 assertEquals(r.cells, seedSpec.cells);177197 assertEquals(r.rects, seedSpec.rects);178198 assertEquals(@@ -182,38 +202,64 @@ Deno.test("referee gating: frontier, revision, holes, no event on refusal", asyn
182202 // nothing is known in the seed tile before any neighbour is solved183203 assertEquals(r.seam.length, 0);184204 await assertRejects(185- () => ref.reveal(1, seed),205+ () => ref.reveal(2, seed, "", "a0"),186206 RefereeError,187207 "already revealed",188208 );189209 // the seed's neighbours are still not on the frontier: nothing is solved190210 for (const u of neighboursOf(seedSpec)) {191211 await assertRejects(192- () => ref.reveal(1, u),212+ () => ref.reveal(2, u, "", "a0"),193213 RefereeError,194- "not on the frontier",214+ "not on a0's frontier",195215 );196216 }197217 // a given is ignored, not rejected; nothing processable means refusal198218 const [gid, gv] = r.givens[0];199219 await assertRejects(200- () => ref.commit(1, [[gid, gv]]),220+ () => ref.commit(2, [[gid, gv]], "", "a0"),201221 RefereeError,202222 "1 given",203223 );204- assertEquals(ref.revision, 1);205- await assertRejects(() => ref.retract(1, [gid]), RefereeError);206- assertEquals(ref.log.events.length, 2);207- // solve the seed tile: its neighbours join the frontier, with its cells as reward224+ assertEquals(ref.revision, 2);225+ await assertRejects(() => ref.retract(2, [gid], "", "a0"), RefereeError);226+ assertEquals(ref.log.events.length, 3);227+ // solve the seed tile: its neighbours join the frontier, with its cells as228+ // reward, and the tile is attributed to the agent208229 const sol = solutionOf(out);209230 const unknown = ref.state.geo!.cellsOf[seed].filter((c) =>210231 !ref.state.cells[c]211232 );212- const c = await ref.commit(1, unknown.map((id) => [id, sol(id)]), "all");233+ const c = await ref.commit(234+ 2,235+ unknown.map((id) => [id, sol(id)]),236+ "all",237+ "a0",238+ );213239 assertEquals(c.solved, [seed]);214240 assertEquals(c.reward, seedSpec.cells);241+ assertEquals(c.agent, "a0");242+ assertEquals(ref.state.tileAgent[seed], "a0");215243 for (const u of neighboursOf(seedSpec)) assert(c.frontier.includes(u));216244 assertEquals(ref.state.totals.reward, seedSpec.cells);245+ assertEquals(ref.state.analysis.agents[0].tiles, 1);246+ assertEquals(ref.state.analysis.agents[0].territory, 1);247+});248+249+// A write without an agent is the old single-player protocol: the implicit250+// agent a0 joins on first use with the manifest's seed tile.251+Deno.test("writes without an agent join the implicit agent a0", async () => {252+ const ref = await Referee.create(253+ crypto.randomUUID(),254+ structuredClone(grid),255+ { guess: "accept", depth: 0 },256+ stubJudge,257+ { persist: false },258+ );259+ const r = await ref.reveal(0, ref.manifest.seed_tile, "seed");260+ assertEquals(r.revision, 2); // join, then reveal261+ assertEquals(ref.state.agents.map((a) => a.id), ["a0"]);262+ assertEquals(ref.log.events[1].kind, "join");217263 });218264 219265 Deno.test("guess policy reject: unforced correct cells are rejected, batch still recorded", async () => {@@ -226,23 +272,25 @@ Deno.test("guess policy reject: unforced correct cells are rejected, batch still
226272 { persist: false },227273 );228274 const seed = ref.manifest.seed_tile;229- await ref.reveal(0, seed, "seed");275+ await ref.join({ harness: "test" });276+ await ref.reveal(1, seed, "seed", "a0");230277 const sol = solutionOf(out);231278 const unknown = ref.state.geo!.cellsOf[seed].filter((id) =>232279 !ref.state.cells[id]233280 );234281 const r = await ref.commit(235- 1,282+ 2,236283 unknown.map((id) => [id, sol(id)] as [number, number]),237284 "everything at once",285+ "a0",238286 );239- assertEquals(r.revision, 2);287+ assertEquals(r.revision, 3);240288 assert(r.accepted.every((c) => c.class === "forced"));241289 assert(r.rejected.some((c) => c.reason === "guess"));242290 assert(r.rejected.every((c) => c.reason === "guess"));243291 assertEquals(ref.state.totals.guess, 0);244292 assertEquals(ref.state.totals.wrong, 0);245- assertEquals(ref.log.events.length, 3);293+ assertEquals(ref.log.events.length, 4);246294 });247295 248296 // v2 item 1: the logs on disk (including the first real packed board) fold@@ -280,3 +328,201 @@ Deno.test("on-disk game logs replay to a solved end state", async () => {
280328 }281329 assert(packed > 0, "at least one finished packed (non-grid) board on disk");282330 });331+332+// ------------------------------------------------------------ multi-agent333+334+const field = synthesise(withSeeds(scatterLayout("field", 100, 0.75), 4));335+336+/** In-process player: reveal by strategy, commit the tile's cells. */337+async function player(338+ ref: Referee,339+ out: GenOutput,340+ agent: string,341+ strategy: "bfs" | "dfs" | "greedy",342+ steps: number,343+): Promise<number> {344+ const sol = solutionOf(out);345+ const geo = ref.state.geo!;346+ const a = ref.state.agents.find((x) => x.id === agent)!;347+ const dist = (() => {348+ const d = new Array(geo.adjacency.length).fill(-1);349+ d[a.seed_tile] = 0;350+ const q = [a.seed_tile];351+ for (let i = 0; i < q.length; i++) {352+ for (const u of geo.adjacency[q[i]]) {353+ if (d[u] < 0) {354+ d[u] = d[q[i]] + 1;355+ q.push(u);356+ }357+ }358+ }359+ return d;360+ })();361+ let last: number | null = null;362+ let done = 0;363+ while (done < steps && ref.state.status === "playing") {364+ const fr = frontierOf(ref.state, agent);365+ if (fr.length === 0) break;366+ const revealed = fr.filter((t) => ref.state.tiles[t] === "revealed");367+ let id: number;368+ if (revealed.length) id = revealed[0];369+ else {370+ if (strategy === "dfs" && last !== null) {371+ id = fr.find((t) => geo.adjacency[last!].includes(t)) ?? fr[0];372+ } else if (strategy === "greedy") {373+ id = fr.reduce((p, q) =>374+ ref.manifest.tiles[q].cells > ref.manifest.tiles[p].cells ? q : p375+ );376+ } else {377+ id = fr.reduce((p, q) => (dist[q] < dist[p] ? q : p));378+ }379+ await ref.reveal(ref.revision, id, `${strategy} reveal #${id}`, agent);380+ }381+ const unknown = geo.cellsOf[id].filter((c) => !ref.state.cells[c]);382+ const r = await ref.commit(383+ ref.revision,384+ unknown.map((c) => [c, sol(c)] as [number, number]),385+ `fill #${id}`,386+ agent,387+ );388+ if (r.solved.length) last = r.solved[r.solved.length - 1];389+ done++;390+ }391+ return done;392+}393+394+Deno.test("two agents on one field: attribution, per-agent frontier, meeting, replay == live", async () => {395+ const out = structuredClone(field);396+ const gameId = crypto.randomUUID();397+ const ref = await Referee.create(398+ gameId,399+ out,400+ { guess: "accept", depth: 0 },401+ stubJudge,402+ { seed: "two" },403+ );404+ const a = await ref.join({ name: "alice", harness: "test-a", model: "m1" });405+ const b = await ref.join({ name: "bob", harness: "test-b" });406+ assertEquals([a.agent, b.agent], ["a0", "a1"]);407+ assert(a.seed_tile !== b.seed_tile, "different seed tiles");408+ assert(409+ !ref.state.geo!.adjacency[a.seed_tile].includes(b.seed_tile),410+ "seed tiles do not touch",411+ );412+ // each agent's frontier is its own seed tile only413+ assertEquals(frontierOf(ref.state, "a0"), [a.seed_tile]);414+ assertEquals(frontierOf(ref.state, "a1"), [b.seed_tile]);415+ // bob cannot reveal alice's seed tile416+ await assertRejects(417+ () => ref.reveal(ref.revision, a.seed_tile, "", "a1"),418+ RefereeError,419+ "not on a1's frontier",420+ );421+ // alternate until the field is solved (they must meet on a connected field)422+ let guard = 0;423+ while (ref.state.status === "playing" && guard++ < 2000) {424+ const da = await player(ref, out, "a0", "bfs", 1);425+ const db = await player(ref, out, "a1", "dfs", 1);426+ if (da + db === 0) break;427+ }428+ assertEquals(ref.state.status, "solved");429+ // every solved tile is attributed, to one of the two430+ for (const t of ref.manifest.tiles) {431+ assert(["a0", "a1"].includes(ref.state.tileAgent[t.id]!), `tile ${t.id}`);432+ }433+ assert(ref.state.analysis.agents[0].tiles > 0);434+ assert(ref.state.analysis.agents[1].tiles > 0);435+ // they met: one group, a meet recorded on a commit and in the state436+ assert(ref.state.meets.length >= 1, "a meeting happened");437+ assertEquals(groupOf(ref.state, "a0"), groupOf(ref.state, "a1"));438+ const meetEvent = ref.log.events.find((e) =>439+ e.kind === "batch" && e.op === "commit" && (e.meets?.length ?? 0) > 0440+ );441+ assert(meetEvent, "the commit event records the meeting for readers");442+ // territory shares sum to one; the end event carries the analysis443+ const shares = ref.state.analysis.agents.reduce((s, x) => s + x.territory, 0);444+ assert(Math.abs(shares - 1) < 1e-9, `territory ${shares}`);445+ const end = ref.log.events.at(-1)!;446+ assertEquals(end.kind, "end");447+ assert(448+ end.kind === "end" && end.analysis && end.analysis.agents.length === 2,449+ );450+ // every write is attributed451+ for (const e of ref.log.events) {452+ if (e.kind === "batch") assert(e.agent === "a0" || e.agent === "a1");453+ }454+ // replay from disk == live at every revision, analysis included455+ const text = await readLog(gameId);456+ assert(text !== null);457+ const replay = foldAll(parseLog(text));458+ assertEquals(replay.length, ref.revision + 1);459+ assertEquals(replay.at(-1)!.analysis, ref.state.analysis);460+ assertEquals(replay.at(-1)!.agents, ref.state.agents);461+ assertEquals(replay.at(-1)!.tileAgent, ref.state.tileAgent);462+ assertEquals(replay.at(-1)!.meets, ref.state.meets);463+});464+465+Deno.test("analyzer classifies bfs, dfs and greedy reveal orders", async () => {466+ const out = structuredClone(field);467+ const ref = await Referee.create(468+ crypto.randomUUID(),469+ out,470+ { guess: "accept", depth: 0 },471+ stubJudge,472+ { persist: false },473+ );474+ await ref.join({ name: "b", harness: "t" });475+ await ref.join({ name: "d", harness: "t" });476+ await ref.join({ name: "g", harness: "t" });477+ const steps = 14;478+ await player(ref, out, "a0", "bfs", steps);479+ await player(ref, out, "a1", "dfs", steps);480+ await player(ref, out, "a2", "greedy", steps);481+ const by = Object.fromEntries(482+ ref.state.analysis.agents.map((x) => [x.agent, x]),483+ );484+ assertEquals(by.a0.strategy, "bfs", JSON.stringify(by.a0.strategy_counts));485+ assertEquals(by.a1.strategy, "dfs", JSON.stringify(by.a1.strategy_counts));486+ assertEquals(by.a2.strategy, "greedy", JSON.stringify(by.a2.strategy_counts));487+ assert(488+ ref.state.analysis.guess_rate > 0 && ref.state.analysis.guess_rate < 1,489+ );490+ assert(ref.state.analysis.reveal_commit > 0);491+});492+493+Deno.test("scoped observe on a daily-sized field stays under 64 KB", async () => {494+ // ~9x9 chunks of G=60 worth of tiles: a 530-cell field at high coverage495+ const big = synthesise(496+ withSeeds(scatterLayout("daily", 530, 0.85), 12),497+ "big",498+ );499+ const ref = await Referee.create(500+ crypto.randomUUID(),501+ big,502+ { guess: "accept", depth: 0 },503+ stubJudge,504+ { persist: false },505+ );506+ assert(507+ ref.manifest.tiles.length > 1000,508+ `${ref.manifest.tiles.length} tiles`,509+ );510+ await ref.join({ name: "x", harness: "t" });511+ assertThrows(() => ref.observe(), RefereeError, "observe with");512+ assertThrows(() => ref.observe({ full: true }), RefereeError, "allowed only");513+ // play a while so the territory has some size514+ await player(ref, big, "a0", "bfs", 25);515+ const obs = ref.observe({ agent: "a0" });516+ const bytes = new TextEncoder().encode(JSON.stringify(obs)).length;517+ assert(bytes < 64 * 1024, `observe {agent} is ${bytes} bytes`);518+ assert(519+ obs.tiles.length < ref.manifest.tiles.length / 4,520+ "scoped, not everything",521+ );522+ assert(obs.frontier.every((t) => obs.tiles.some((x) => x.id === t)));523+ const rect = ref.observe({ rect: [0, 0, 60, 60] });524+ assert(rect.tiles.every((t) => t.x0 < 60 && t.y0 < 60));525+ console.log(526+ `observe{agent}: ${bytes} bytes, ${obs.tiles.length}/${ref.manifest.tiles.length} tiles`,527+ );528+});