1// PLAN item 5 on manifest v3: replaying the JSONL reproduces the live run2// frame for frame. A scripted player (with access to the private solution)3// plays a scattered field through the referee; the live fold (incremental4// applyEvent over the event stream) must equal the replay fold (from the5// JSONL on disk) at every revision, and gating must refuse what the plan6// says it refuses. The judge is a deterministic stub.7import { assert, assertEquals, assertRejects, assertThrows } from "@std/assert";8import {9 applyEvent,10 emptyState,11 foldAll,12 frontierOf,13 groupOf,14 isHole,15 neighboursOf,16 parseLog,17 type State,18} from "../lib/fold.ts";19import type { GenOutput } from "../lib/engine.ts";20import { Referee, RefereeError } from "../lib/referee.ts";21import { readLog } from "../lib/log.ts";22import {23 gridLayout,24 scatterLayout,25 stubJudge,26 synthesise,27 withSeeds,28} from "./fixture.ts";2930const tmp = await Deno.makeTempDir();31Deno.env.set("MOSAIC_GAMES", tmp + "/");3233const grid = synthesise(gridLayout(2, 14));34const packed = synthesise(scatterLayout("a", 100));3536function solutionOf(out: GenOutput) {37 const G = out.manifest.G;38 return (id: number) =>39 (out.solution[Math.floor(id / G)][id % G] === "1" ? 1 : 0) as 0 | 1;40}4142async function play(43 fixture: GenOutput,44 seed: string,45 guess: "accept" | "reject" = "accept",46) {47 const out = structuredClone(fixture);48 const gameId = crypto.randomUUID();49 const ref = await Referee.create(50 gameId,51 out,52 { guess, depth: 0 },53 stubJudge,54 { seed },55 );56 const live: State[] = [];57 let acc = emptyState();58 const stream: State[] = [];59 ref.log.subscribe((ev) => {60 acc = applyEvent(acc, ev);61 stream.push(acc);62 });63 acc = applyEvent(acc, ref.log.events[0]);64 stream.push(acc);65 live.push(ref.state); // states are immutable: a reference is a snapshot66 const { agent } = await ref.join({ name: "player", harness: "test" });67 live.push(ref.state); // states are immutable: a reference is a snapshot68 const sol = solutionOf(out);69 const geo = ref.state.geo!;70 while (ref.state.status === "playing") {71 const fr = frontierOf(ref.state, agent);72 assert(fr.length > 0, "frontier never empties before the end");73 const id = fr[0];74 if (ref.state.tiles[id] === "hidden") {75 await ref.reveal(ref.revision, id, `reveal #${id}`, agent);76 live.push(ref.state); // states are immutable: a reference is a snapshot77 }78 const unknown = geo.cellsOf[id].filter((c) => !ref.state.cells[c]);79 const batch = unknown.slice(0, 40).map((c) =>80 [c, sol(c)] as [number, number]81 );82 // a wrong one (only in the first batches: thirty wrongs would kill the83 // machine), a duplicate, and a hole to exercise rejected/ignored84 if (unknown.length > 40 && live.length < 12) {85 batch.push([unknown[40], (1 - sol(unknown[40])) as 0 | 1], [86 unknown[0],87 sol(unknown[0]),88 ]);89 }90 const hole = holeOf(ref.state);91 if (hole >= 0) batch.push([hole, 0]);92 await ref.commit(ref.revision, batch, `fill #${id}`, agent);93 live.push(ref.state); // states are immutable: a reference is a snapshot94 }95 return { ref, live, stream, gameId, out, agent };96}9798function holeOf(s: State): number {99 const geo = s.geo!;100 for (let id = 0; id < s.G * s.G; id++) if (isHole(geo, id)) return id;101 return -1;102}103104for (105 const [name, fixture] of [["grid 27", grid], [106 "scatter 100",107 packed,108 ]] as const109) {110 Deno.test(`live fold equals replay fold at every revision (${name})`, async () => {111 const { ref, live, stream, gameId } = await play(112 fixture,113 "fold-" + name,114 );115 const text = await readLog(gameId);116 assert(text !== null, "log written to disk");117 const events = parseLog(text);118 assertEquals(events.length, ref.revision + 1);119 const replay = foldAll(events);120 for (let k = 0; k < events.length; k++) {121 assertEquals(replay[k], stream[k], `stream vs replay at rev ${k}`);122 }123 for (const s of live) {124 assertEquals(replay[s.revision], s, `referee state at rev ${s.revision}`);125 }126 assertEquals(ref.state.status, "solved");127 assertEquals(events.at(-1)!.kind, "end");128 assert(129 ref.state.totals.wrong > 0,130 "a wrong commit was rejected and counted",131 );132 assert(ref.state.totals.forced + ref.state.totals.guess > 0);133 // reward is the sum of cell counts over all tiles once everything is solved134 assertEquals(135 ref.state.totals.reward,136 ref.manifest.tiles.reduce((a, t) => a + t.cells, 0),137 );138 // the solution is never in the log139 assert(!text.includes('"solution"'));140 });141}142143Deno.test("referee gating: frontier, revision, holes, no event on refusal", async () => {144 const out = structuredClone(packed);145 const ref = await Referee.create(146 crypto.randomUUID(),147 out,148 { guess: "accept", depth: 0 },149 stubJudge,150 { persist: false },151 );152 assertEquals(ref.revision, 0);153 assertEquals(frontierOf(ref.state), [ref.manifest.seed_tile]);154 // the first agent gets the manifest's seed tile (the only listed one)155 const j = await ref.join({ name: "one", harness: "test" });156 assertEquals(j.agent, "a0");157 assertEquals(j.seed_tile, ref.manifest.seed_tile);158 const seed = j.seed_tile;159 const seedSpec = ref.manifest.tiles[seed];160 const other = ref.manifest.tiles.find((t) =>161 t.id !== seed && !neighboursOf(seedSpec).includes(t.id)162 )!;163 assertEquals(ref.revision, 1);164 assertEquals(frontierOf(ref.state, "a0"), [seed]);165 await assertRejects(166 () => ref.reveal(1, other.id, "", "a0"),167 RefereeError,168 "not on a0's frontier",169 );170 await assertRejects(171 () => ref.reveal(3, seed, "", "a0"),172 RefereeError,173 "Revision conflict",174 );175 await assertRejects(176 () => ref.reveal(1, ref.manifest.tiles.length + 5, "", "a0"),177 RefereeError,178 "does not exist",179 );180 await assertRejects(181 () => ref.reveal(1, seed, "", "nobody"),182 RefereeError,183 "has not joined",184 );185 // a hole alone is ignored, so nothing is processable186 const hole = holeOf(ref.state);187 assert(hole >= 0, "the scattered fixture has a hole");188 await assertRejects(189 () => ref.commit(1, [[hole, 0]], "", "a0"),190 RefereeError,191 "1 hole",192 );193 assertEquals(ref.revision, 1);194 assertEquals(ref.log.events.length, 2);195 const r = await ref.reveal(1, seed, "seed", "a0");196 assertEquals(r.revision, 2);197 assertEquals(r.cells, seedSpec.cells);198 assertEquals(r.rects, seedSpec.rects);199 assertEquals(200 r.givens.length,201 out.tiles.find((t) => t.id === seed)!.givens.length,202 );203 // nothing is known in the seed tile before any neighbour is solved204 assertEquals(r.seam.length, 0);205 await assertRejects(206 () => ref.reveal(2, seed, "", "a0"),207 RefereeError,208 "already revealed",209 );210 // the seed's neighbours are still not on the frontier: nothing is solved211 for (const u of neighboursOf(seedSpec)) {212 await assertRejects(213 () => ref.reveal(2, u, "", "a0"),214 RefereeError,215 "not on a0's frontier",216 );217 }218 // a given is ignored, not rejected; nothing processable means refusal219 const [gid, gv] = r.givens[0];220 await assertRejects(221 () => ref.commit(2, [[gid, gv]], "", "a0"),222 RefereeError,223 "1 given",224 );225 assertEquals(ref.revision, 2);226 await assertRejects(() => ref.retract(2, [gid], "", "a0"), RefereeError);227 assertEquals(ref.log.events.length, 3);228 // solve the seed tile: its neighbours join the frontier, with its cells as229 // reward, and the tile is attributed to the agent230 const sol = solutionOf(out);231 const unknown = ref.state.geo!.cellsOf[seed].filter((c) =>232 !ref.state.cells[c]233 );234 const c = await ref.commit(235 2,236 unknown.map((id) => [id, sol(id)]),237 "all",238 "a0",239 );240 assertEquals(c.solved, [seed]);241 assertEquals(c.reward, seedSpec.cells);242 assertEquals(c.agent, "a0");243 assertEquals(ref.state.tileAgent[seed], "a0");244 for (const u of neighboursOf(seedSpec)) assert(c.frontier.includes(u));245 assertEquals(ref.state.totals.reward, seedSpec.cells);246 assertEquals(ref.state.analysis.agents[0].tiles, 1);247 assertEquals(ref.state.analysis.agents[0].territory, 1);248});249250// A write without an agent is the old single-player protocol: the implicit251// agent a0 joins on first use with the manifest's seed tile.252Deno.test("writes without an agent join the implicit agent a0", async () => {253 const ref = await Referee.create(254 crypto.randomUUID(),255 structuredClone(grid),256 { guess: "accept", depth: 0 },257 stubJudge,258 { persist: false },259 );260 const r = await ref.reveal(0, ref.manifest.seed_tile, "seed");261 assertEquals(r.revision, 2); // join, then reveal262 assertEquals(ref.state.agents.map((a) => a.id), ["a0"]);263 assertEquals(ref.log.events[1].kind, "join");264});265266Deno.test("guess policy reject: unforced correct cells are rejected, batch still recorded", async () => {267 const out = structuredClone(grid);268 const ref = await Referee.create(269 crypto.randomUUID(),270 out,271 { guess: "reject", depth: 0 },272 stubJudge,273 { persist: false },274 );275 const seed = ref.manifest.seed_tile;276 await ref.join({ harness: "test" });277 await ref.reveal(1, seed, "seed", "a0");278 const sol = solutionOf(out);279 const unknown = ref.state.geo!.cellsOf[seed].filter((id) =>280 !ref.state.cells[id]281 );282 const r = await ref.commit(283 2,284 unknown.map((id) => [id, sol(id)] as [number, number]),285 "everything at once",286 "a0",287 );288 assertEquals(r.revision, 3);289 assert(r.accepted.every((c) => c.class === "forced"));290 assert(r.rejected.some((c) => c.reason === "guess"));291 assert(r.rejected.every((c) => c.reason === "guess"));292 assertEquals(ref.state.totals.guess, 0);293 assertEquals(ref.state.totals.wrong, 0);294 assertEquals(ref.log.events.length, 4);295});296297// v2 item 1: the logs on disk (including the first real packed board) fold298// from revision 0 to their end event, contiguously, to a solved state whose299// reward is the sum of n^2 over the manifest's tiles. Skipped when no logs300// exist (fresh checkout).301Deno.test("on-disk game logs replay to a solved end state", async () => {302 const { listLogs, readLog } = await import("../lib/log.ts");303 const ids = await listLogs();304 if (ids.length === 0) return;305 let packed = 0;306 for (const id of ids) {307 const text = await readLog(id);308 if (text === null) continue;309 const events = parseLog(text);310 if (events.at(-1)?.kind !== "end") continue; // unfinished game311 const manifestEvent = events[0] as {312 manifest?: { version?: number; X?: number };313 };314 const v = manifestEvent.manifest?.version;315 if (v !== 2 && v !== 3 && !manifestEvent.manifest?.X) continue;316 const replay = foldAll(events);317 for (let k = 0; k < events.length; k++) {318 assertEquals(events[k].rev, k, `${id}: contiguous revisions`);319 assertEquals(replay[k].revision, k, `${id}: fold revision`);320 }321 const final = replay.at(-1)!;322 assertEquals(final.status, "solved", `${id}: solved`);323 const m = final.manifest!;324 assertEquals(325 final.totals.reward,326 m.tiles.reduce((a, t) => a + t.cells, 0),327 `${id}: reward`,328 );329 if (m.packer.kind !== "grid") packed++;330 assert(!text.includes('"solution"'), `${id}: no solution in the log`);331 }332 assert(packed > 0, "at least one finished packed (non-grid) board on disk");333});334335// ------------------------------------------------------------ multi-agent336337const field = synthesise(withSeeds(scatterLayout("field", 100, 0.75), 4));338339/** In-process player: reveal by strategy, commit the tile's cells. */340async function player(341 ref: Referee,342 out: GenOutput,343 agent: string,344 strategy: "bfs" | "dfs" | "greedy",345 steps: number,346): Promise<number> {347 const sol = solutionOf(out);348 const geo = ref.state.geo!;349 const a = ref.state.agents.find((x) => x.id === agent)!;350 const dist = (() => {351 const d = new Array(geo.adjacency.length).fill(-1);352 d[a.seed_tile] = 0;353 const q = [a.seed_tile];354 for (let i = 0; i < q.length; i++) {355 for (const u of geo.adjacency[q[i]]) {356 if (d[u] < 0) {357 d[u] = d[q[i]] + 1;358 q.push(u);359 }360 }361 }362 return d;363 })();364 let last: number | null = null;365 let done = 0;366 while (done < steps && ref.state.status === "playing") {367 const fr = frontierOf(ref.state, agent);368 if (fr.length === 0) break;369 const revealed = fr.filter((t) => ref.state.tiles[t] === "revealed");370 let id: number;371 if (revealed.length) id = revealed[0];372 else {373 if (strategy === "dfs" && last !== null) {374 const near = fr.filter((t) => geo.adjacency[last!].includes(t));375 id = near.length376 ? near.reduce((p, q) => (dist[q] > dist[p] ? q : p))377 : fr[0];378 } else if (strategy === "greedy") {379 id = fr.reduce((p, q) =>380 ref.manifest.tiles[q].cells > ref.manifest.tiles[p].cells ? q : p381 );382 } else {383 id = fr.reduce((p, q) => (dist[q] < dist[p] ? q : p));384 }385 await ref.reveal(ref.revision, id, `${strategy} reveal #${id}`, agent);386 }387 const unknown = geo.cellsOf[id].filter((c) => !ref.state.cells[c]);388 const r = await ref.commit(389 ref.revision,390 unknown.map((c) => [c, sol(c)] as [number, number]),391 `fill #${id}`,392 agent,393 );394 if (r.solved.length) last = r.solved[r.solved.length - 1];395 done++;396 }397 return done;398}399400Deno.test("two agents on one field: attribution, per-agent frontier, meeting, replay == live", async () => {401 const out = structuredClone(field);402 const gameId = crypto.randomUUID();403 const ref = await Referee.create(404 gameId,405 out,406 { guess: "accept", depth: 0 },407 stubJudge,408 { seed: "two" },409 );410 const a = await ref.join({ name: "alice", harness: "test-a", model: "m1" });411 const b = await ref.join({ name: "bob", harness: "test-b" });412 assertEquals([a.agent, b.agent], ["a0", "a1"]);413 assert(a.seed_tile !== b.seed_tile, "different seed tiles");414 assert(415 !ref.state.geo!.adjacency[a.seed_tile].includes(b.seed_tile),416 "seed tiles do not touch",417 );418 // each agent's frontier is its own seed tile only419 assertEquals(frontierOf(ref.state, "a0"), [a.seed_tile]);420 assertEquals(frontierOf(ref.state, "a1"), [b.seed_tile]);421 // bob cannot reveal alice's seed tile422 await assertRejects(423 () => ref.reveal(ref.revision, a.seed_tile, "", "a1"),424 RefereeError,425 "not on a1's frontier",426 );427 // alternate until the field is solved (they must meet on a connected field)428 let guard = 0;429 while (ref.state.status === "playing" && guard++ < 2000) {430 const da = await player(ref, out, "a0", "bfs", 1);431 const db = await player(ref, out, "a1", "dfs", 1);432 if (da + db === 0) break;433 }434 assertEquals(ref.state.status, "solved");435 // every solved tile is attributed, to one of the two436 for (const t of ref.manifest.tiles) {437 assert(["a0", "a1"].includes(ref.state.tileAgent[t.id]!), `tile ${t.id}`);438 }439 assert(ref.state.analysis.agents[0].tiles > 0);440 assert(ref.state.analysis.agents[1].tiles > 0);441 // they met: one group, a meet recorded on a commit and in the state442 assert(ref.state.meets.length >= 1, "a meeting happened");443 assertEquals(groupOf(ref.state, "a0"), groupOf(ref.state, "a1"));444 const meetEvent = ref.log.events.find((e) =>445 e.kind === "batch" && e.op === "commit" && (e.meets?.length ?? 0) > 0446 );447 assert(meetEvent, "the commit event records the meeting for readers");448 // territory shares sum to one; the end event carries the analysis449 const shares = ref.state.analysis.agents.reduce((s, x) => s + x.territory, 0);450 assert(Math.abs(shares - 1) < 1e-9, `territory ${shares}`);451 const end = ref.log.events.at(-1)!;452 assertEquals(end.kind, "end");453 assert(454 end.kind === "end" && end.analysis && end.analysis.agents.length === 2,455 );456 // every write is attributed457 for (const e of ref.log.events) {458 if (e.kind === "batch") assert(e.agent === "a0" || e.agent === "a1");459 }460 // replay from disk == live at every revision, analysis included461 const text = await readLog(gameId);462 assert(text !== null);463 const replay = foldAll(parseLog(text));464 assertEquals(replay.length, ref.revision + 1);465 assertEquals(replay.at(-1)!.analysis, ref.state.analysis);466 assertEquals(replay.at(-1)!.agents, ref.state.agents);467 assertEquals(replay.at(-1)!.tileAgent, ref.state.tileAgent);468 assertEquals(replay.at(-1)!.meets, ref.state.meets);469});470471Deno.test("analyzer classifies bfs, dfs and greedy reveal orders", async () => {472 const out = structuredClone(field);473 const ref = await Referee.create(474 crypto.randomUUID(),475 out,476 { guess: "accept", depth: 0 },477 stubJudge,478 { persist: false },479 );480 await ref.join({ name: "b", harness: "t" });481 await ref.join({ name: "d", harness: "t" });482 await ref.join({ name: "g", harness: "t" });483 const steps = 14;484 await player(ref, out, "a0", "bfs", steps);485 await player(ref, out, "a1", "dfs", steps);486 await player(ref, out, "a2", "greedy", steps);487 const by = Object.fromEntries(488 ref.state.analysis.agents.map((x) => [x.agent, x]),489 );490 assertEquals(by.a0.strategy, "bfs", JSON.stringify(by.a0.strategy_counts));491 assertEquals(by.a1.strategy, "dfs", JSON.stringify(by.a1.strategy_counts));492 assertEquals(by.a2.strategy, "greedy", JSON.stringify(by.a2.strategy_counts));493 assert(494 ref.state.analysis.guess_rate > 0 && ref.state.analysis.guess_rate < 1,495 );496 assert(ref.state.analysis.reveal_commit > 0);497});498499Deno.test("scoped observe on a daily-sized field stays under 64 KB", async () => {500 // ~9x9 chunks of G=60 worth of tiles: a 530-cell field at high coverage501 const big = synthesise(502 withSeeds(scatterLayout("daily", 530, 0.85), 12),503 "big",504 );505 const ref = await Referee.create(506 crypto.randomUUID(),507 big,508 { guess: "accept", depth: 0 },509 stubJudge,510 { persist: false },511 );512 assert(513 ref.manifest.tiles.length > 1000,514 `${ref.manifest.tiles.length} tiles`,515 );516 await ref.join({ name: "x", harness: "t" });517 assertThrows(() => ref.observe(), RefereeError, "observe with");518 assertThrows(() => ref.observe({ full: true }), RefereeError, "allowed only");519 // play a while so the territory has some size520 await player(ref, big, "a0", "bfs", 25);521 const obs = ref.observe({ agent: "a0" });522 const bytes = new TextEncoder().encode(JSON.stringify(obs)).length;523 assert(bytes < 64 * 1024, `observe {agent} is ${bytes} bytes`);524 assert(525 obs.tiles.length < ref.manifest.tiles.length / 4,526 "scoped, not everything",527 );528 assert(obs.frontier.every((t) => obs.tiles.some((x) => x.id === t)));529 const rect = ref.observe({ rect: [0, 0, 60, 60] });530 assert(rect.tiles.every((t) => t.x0 < 60 && t.y0 < 60));531 console.log(532 `observe{agent}: ${bytes} bytes, ${obs.tiles.length}/${ref.manifest.tiles.length} tiles`,533 );534});535536Deno.test("concurrent writes: one lands, the other gets a revision conflict, the log stays contiguous", async () => {537 const out = structuredClone(field);538 const ref = await Referee.create(539 crypto.randomUUID(),540 out,541 { guess: "accept", depth: 0 },542 stubJudge,543 { persist: false },544 );545 const a = await ref.join({ name: "a", harness: "t" });546 const b = await ref.join({ name: "b", harness: "t" });547 const rev = ref.revision;548 const results = await Promise.allSettled([549 ref.reveal(rev, a.seed_tile, "a", "a0"),550 ref.reveal(rev, b.seed_tile, "b", "a1"),551 ]);552 const ok = results.filter((r) => r.status === "fulfilled").length;553 const conflicts =554 results.filter((r) =>555 r.status === "rejected" && r.reason instanceof RefereeError &&556 r.reason.code === "revision"557 ).length;558 assertEquals([ok, conflicts], [1, 1]);559 assertEquals(ref.revision, rev + 1);560 ref.log.events.forEach((e, i) => assertEquals(e.rev, i));561 // the loser retries with the new revision and succeeds562 const loser = results[0].status === "rejected" ? "a0" : "a1";563 const tile = loser === "a0" ? a.seed_tile : b.seed_tile;564 const r = await ref.reveal(ref.revision, tile, "retry", loser);565 assertEquals(r.revision, rev + 2);566});567568// A tile revealed by one group is that group's: other agents' commits into569// it are ignored ("territory"), as are their retractions of its cells.570Deno.test("commits and retractions are territory-gated", async () => {571 const out = synthesise(gridLayout(3, 6), "territory");572 const sol = solutionOf(out);573 const ref = await Referee.create(574 crypto.randomUUID(),575 structuredClone(out),576 { guess: "accept", depth: 0 },577 stubJudge,578 {579 persist: false,580 honours: [581 {582 agent: "alice",583 name: "alice",584 model: null,585 user: "alice",586 hue: 0,587 seed_tile: 0,588 day: "d",589 reward: 1,590 },591 {592 agent: "bob",593 name: "bob",594 model: null,595 user: "bob",596 hue: 1,597 seed_tile: 4,598 day: "d",599 reward: 1,600 },601 ],602 },603 );604 const alice = await ref.join({605 name: "alice",606 machine_id: "alice",607 verified: true,608 });609 const bob = await ref.join({610 name: "bob",611 machine_id: "bob",612 verified: true,613 });614 assertEquals([alice.seed_tile, bob.seed_tile], [0, 4]);615 await ref.reveal(ref.revision, 0, "seed", alice.agent);616 const cells = ref.state.geo!.cellsOf[0].filter((c) => !ref.state.cells[c]);617 // bob cannot commit into alice's tile618 await assertRejects(619 () =>620 ref.commit(621 ref.revision,622 cells.slice(0, 4).map((c) => [c, sol(c)] as [number, number]),623 "steal",624 bob.agent,625 ),626 RefereeError,627 "territory",628 );629 // alice can, and bob cannot retract what she committed630 const r = await ref.commit(631 ref.revision,632 cells.slice(0, 4).map((c) => [c, sol(c)] as [number, number]),633 "mine",634 alice.agent,635 );636 assertEquals(r.accepted.length, 4);637 await assertRejects(638 () => ref.retract(ref.revision, cells.slice(0, 2), "undo", bob.agent),639 RefereeError,640 );641 const rr = await ref.retract(642 ref.revision,643 cells.slice(0, 2),644 "undo",645 alice.agent,646 );647 assertEquals(rr.retracted.length, 2);648});649650// The same agent joining again is the same agent (no event); when it has651// nothing playable left it is reseated on a fresh seed tile instead.652Deno.test("join is idempotent and reseats a stranded agent", async () => {653 const out = synthesise(gridLayout(3, 6), "reseat");654 const sol = solutionOf(out);655 const seat = (name: string, tile: number, hue: number) => ({656 agent: name,657 name,658 model: null,659 user: name,660 hue,661 seed_tile: tile,662 day: "d",663 reward: 1,664 });665 const ref = await Referee.create(666 crypto.randomUUID(),667 structuredClone(out),668 { guess: "accept", depth: 0 },669 stubJudge,670 {671 persist: false,672 // a 3x3 grid: alice in a corner, bob and carol on non-touching tiles673 // whose frontiers cover all of alice's neighbours (1, 3, 4)674 honours: [seat("alice", 0, 0), seat("bob", 2, 1), seat("carol", 6, 2)],675 },676 );677 const alice = await ref.join({678 name: "alice",679 machine_id: "alice",680 verified: true,681 });682 const bob = await ref.join({683 name: "bob",684 machine_id: "bob",685 verified: true,686 });687 const carol = await ref.join({688 name: "carol",689 machine_id: "carol",690 verified: true,691 });692 const rev = ref.revision;693 const again = await ref.join({694 name: "alice",695 machine_id: "alice",696 verified: true,697 });698 assertEquals(again.agent, alice.agent);699 assertEquals(ref.revision, rev, "no event for a repeated join");700 assertEquals(ref.state.agents.length, 3);701 const finish = async (tile: number, agent: string) => {702 if (ref.state.tiles[tile] === "hidden") {703 await ref.reveal(ref.revision, tile, "", agent);704 }705 const cells = ref.state.geo!.cellsOf[tile].filter((c) =>706 !ref.state.cells[c]707 );708 await ref.commit(709 ref.revision,710 cells.map((c) => [c, sol(c)] as [number, number]),711 "finish",712 agent,713 );714 };715 await finish(2, bob.agent);716 await ref.reveal(ref.revision, 1, "", bob.agent);717 await ref.reveal(ref.revision, 4, "", bob.agent);718 await finish(6, carol.agent);719 await ref.reveal(ref.revision, 3, "", carol.agent);720 await finish(0, alice.agent);721 assertEquals(ref.state.meets.length, 0, "nobody met");722 // alice's frontier is other groups' revealed tiles only: stranded, so723 // joining again reseats her on a fresh hidden tile that is nobody's seed724 const before = ref.revision;725 const moved = await ref.join({726 name: "alice",727 machine_id: "alice",728 verified: true,729 });730 assertEquals(moved.agent, alice.agent);731 assertEquals(ref.revision, before + 1);732 assert(![0, 2, 6].includes(moved.seed_tile));733 assertEquals(ref.state.tiles[moved.seed_tile], "hidden");734 assertEquals(735 ref.state.agents.find((a) => a.id === alice.agent)!.seed_tile,736 moved.seed_tile,737 );738 assertEquals(ref.state.agents.length, 3);739 assert(frontierOf(ref.state, alice.agent).includes(moved.seed_tile));740});