1// The viewer's pure logic: levels of detail by scale, map-tile keying and2// ranges, the change log that keeps cached rasters honest across3// revisions (including scrubbing backwards and the recency window), the4// LRU cache, agent hue stability, and the timeline's markers and density.5// A synthetic daily-sized field (~2500 tiles, G ~ 530) checks that a6// viewport needs few map tiles and an event dirties few.7import { assert, assertEquals } from "@std/assert";8import {9 type AgentInfo,10 buildGeometry,11 type CommitEvent,12 type GameEvent,13 healthyHealth,14 type JoinEvent,15 type ManifestEvent,16 type RevealEvent,17} from "../lib/fold.ts";18import {19 cellsPerTile,20 ChangeLog,21 keysForCells,22 levelFor,23 RECENCY_REVS,24 TILE_PX,25 TileCache,26 tileCellRange,27 tilesForView,28 zoomFor,29} from "../lib/viewer/tilemap.ts";30import { agentHues } from "../lib/viewer/hues.ts";31import { density, markers, stepFor } from "../lib/viewer/timeline.ts";32import { Checkpoints } from "../lib/viewer/checkpoints.ts";33import { foldAll } from "../lib/fold.ts";34import { scatterLayout, synthesise, withSeeds } from "./fixture.ts";3536Deno.test("level of detail by view scale, raster zoom never upscales", () => {37 assertEquals(levelFor(1), 0);38 assertEquals(levelFor(2.9), 0);39 assertEquals(levelFor(3), 1);40 assertEquals(levelFor(11.9), 1);41 assertEquals(levelFor(12), 2);42 assertEquals(levelFor(29.9), 2);43 assertEquals(levelFor(30), 3);44 assertEquals(zoomFor(0.1), 0.25);45 assertEquals(zoomFor(1), 1);46 assertEquals(zoomFor(1.01), 2);47 assertEquals(zoomFor(10.8), 16);48 assertEquals(zoomFor(100), 64);49 for (const s of [0.3, 1, 5, 13, 40]) assert(zoomFor(s) >= s || s > 64);50});5152Deno.test("map tiles: keying, ranges and the visible set", () => {53 const G = 100;54 // a 400x300 viewport at 4 px/cell, field origin at (0,0)55 const tiles = tilesForView(G, { scale: 4, ox: 0, oy: 0 }, 400, 300);56 assertEquals(tiles[0].zoom, 4);57 assertEquals(tiles[0].level, 1);58 assertEquals(cellsPerTile(4), 64);59 // 64 cells * 4 px = 256 px per map tile: 2 across, 2 down60 assertEquals(tiles.length, 4);61 assertEquals(tiles.map((t) => t.key), ["4:0:0", "4:1:0", "4:0:1", "4:1:1"]);62 assertEquals(tiles[3].cacheKey, "4:1:1|L1");63 const r = tileCellRange(tiles[3], G);64 assertEquals([r.x0, r.y0, r.x1, r.y1], [64, 64, 99, 99]);65 // panned so only the last map tile shows66 const one = tilesForView(G, { scale: 4, ox: -300, oy: -300 }, 100, 100);67 assertEquals(one.map((t) => t.key), ["4:1:1"]);68 // nothing outside the field69 const none = tilesForView(G, { scale: 4, ox: -1000, oy: -1000 }, 100, 100);70 assertEquals(none.length, 0);71 // at 0.25 px/cell one map tile covers 1024 cells: the whole field72 assertEquals(tilesForView(G, { scale: 0.2, ox: 0, oy: 0 }, 50, 50).length, 1);73 assertEquals(TILE_PX, 256);74});7576Deno.test("keys for cells and the change log across revisions", () => {77 const G = 100;78 const keys = keysForCells(G, [0, 1, 99 * G + 99], [4]);79 assertEquals([...keys].sort(), ["4:0:0", "4:1:1"]);80 const log = new ChangeLog();81 log.add(5, ["4:0:0"]);82 log.add(20, ["4:0:0"]);83 log.add(20, ["4:1:0"]);84 // same revision: always valid85 assert(log.valid("4:0:0", 7, 7));86 // no change between 6 and 19 except the recency tail of rev 587 assert(!log.valid("4:0:0", 6, 10), "the ripple of rev 5 still fades");88 assert(log.valid("4:0:0", 5 + RECENCY_REVS, 19));89 // a change at 20 invalidates 19 -> 21 and 21 -> 19 (scrubbing back)90 assert(!log.valid("4:0:0", 19, 21));91 assert(!log.valid("4:0:0", 21, 19));92 // untouched key is valid at any pair93 assert(log.valid("4:3:3", 0, 1000));94 assertEquals(log.revisions("4:0:0"), [5, 20]);95 assertEquals(log.size(), 2);96});9798Deno.test("raster cache: validity through the log, LRU bound", () => {99 const log = new ChangeLog();100 const cache = new TileCache<string>(2);101 cache.set("4:0:0|L1", "a", 10);102 assertEquals(cache.get("4:0:0|L1", "4:0:0", 10, log), "a");103 assertEquals(cache.get("4:0:0|L1", "4:0:0", 12, log), "a");104 log.add(11, ["4:0:0"]);105 assertEquals(cache.get("4:0:0|L1", "4:0:0", 12, log), undefined);106 cache.set("4:1:0|L1", "b", 12);107 cache.set("4:2:0|L1", "c", 12);108 assertEquals(cache.size(), 2, "bounded");109 assertEquals(cache.get("4:0:0|L1", "4:0:0", 12, log), undefined, "evicted");110 // different level of the same geometry is a separate raster111 cache.set("4:1:0|L2", "b2", 12);112 assertEquals(cache.get("4:1:0|L2", "4:1:0", 12, log), "b2");113});114115Deno.test("agent hues are stable and neighbours start distinct", () => {116 const L = withSeeds(scatterLayout("hues", 120), 6);117 const out = synthesise(L, "hues");118 const geo = buildGeometry(out.manifest);119 const seeds = out.manifest.seed_tiles ?? [];120 const agent = (i: number, seed: number): AgentInfo => ({121 health: healthyHealth(),122 id: `a${i}`,123 name: `agent ${i}`,124 harness: "test",125 model: null,126 tags: {},127 verified: false,128 machine_id: null,129 joined: i,130 seed_tile: seed,131 group: `a${i}`,132 });133 const agents = seeds.map((s, i) => agent(i, s));134 const h1 = agentHues(agents, geo);135 // joining more agents never changes earlier hues136 const more = [...agents, agent(99, seeds[0])];137 const h2 = agentHues(more, geo);138 for (const a of agents) assertEquals(h2.get(a.id), h1.get(a.id));139 // two agents whose seed tiles overlap get different hues140 const pair = [agent(0, seeds[0]), agent(1, geo.adjacency[seeds[0]][0])];141 const h3 = agentHues(pair, geo);142 assert(h3.get("a0") !== h3.get("a1"));143 // deterministic144 assertEquals([...agentHues(agents, geo)], [...h1]);145});146147function fakeEvents(): GameEvent[] {148 const L = withSeeds(scatterLayout("tl", 60), 2);149 const out = synthesise(L, "tl");150 const t0 = "2026-08-26T00:00:00.000Z";151 const evs: GameEvent[] = [];152 const manifest: ManifestEvent = {153 rev: 0,154 t: t0,155 kind: "manifest",156 game_id: "g",157 manifest: out.manifest,158 rules: { guess: "accept", depth: 0 },159 };160 evs.push(manifest);161 const join = (rev: number, agent: string, seed: number): JoinEvent => ({162 rev,163 t: t0,164 kind: "join",165 agent,166 name: agent,167 harness: "test",168 seed_tile: seed,169 });170 evs.push(join(1, "alice", out.manifest.seed_tiles![0]));171 evs.push(join(2, "bob", out.manifest.seed_tiles![1]));172 const reveal = (rev: number, agent: string, tile: number): RevealEvent => ({173 rev,174 t: t0,175 kind: "batch",176 op: "reveal",177 agent,178 tile,179 givens: [[1, 0], [2, 1]],180 seam: [],181 intent: "",182 });183 const commit = (184 rev: number,185 agent: string,186 forced: number,187 guess: number,188 solved: number[] = [],189 meets: { with: string; tile: number }[] = [],190 ): CommitEvent => ({191 rev,192 t: t0,193 kind: "batch",194 op: "commit",195 agent,196 intent: "",197 accepted: [198 ...Array.from({ length: forced }, (_, i) => ({199 id: i,200 v: 0 as const,201 class: "forced" as const,202 rules: ["run"],203 })),204 ...Array.from({ length: guess }, (_, i) => ({205 id: 100 + i,206 v: 1 as const,207 class: "guess" as const,208 rules: [],209 })),210 ],211 rejected: [],212 ignored: [],213 solved,214 meets,215 judge_ms: 1,216 });217 evs.push(reveal(3, "alice", 0));218 evs.push(commit(4, "alice", 5, 1));219 evs.push(commit(5, "alice", 1, 4)); // guess-heavy 1220 evs.push(commit(6, "alice", 0, 3)); // 2221 evs.push(commit(7, "alice", 1, 2, [0])); // 3 -> run marker at 5, solved222 evs.push(commit(8, "bob", 3, 0, [1], [{ with: "alice", tile: 1 }]));223 evs.push({ rev: 9, t: t0, kind: "end" });224 return evs;225}226227Deno.test("timeline markers and density", () => {228 const evs = fakeEvents();229 const ms = markers(evs);230 const kinds = ms.map((m) => `${m.kind}@${m.rev}`);231 assertEquals(kinds, [232 "join@1",233 "join@2",234 "guess-run@5",235 "solved@7",236 "solved@8",237 "meet@8",238 "end@9",239 ]);240 assertEquals(241 ms.find((m) => m.kind === "meet")!.label,242 "bob meets alice at #1",243 );244 const d = density(evs, 5);245 assertEquals(d.length, 5);246 assertEquals(Math.max(...d), 1);247 assertEquals(d[0], 0, "the manifest and joins are not activity");248 // playback stepping249 assertEquals(stepFor(4, 250, 0), { steps: 1, carry: 0 });250 assertEquals(stepFor(1, 100, 0.95).steps, 1);251 assertEquals(stepFor("max", 16, 0).steps, 1e9);252});253254Deno.test("a daily-sized field needs few map tiles per view and per event", () => {255 const L = withSeeds(scatterLayout("daily", 530, 0.7), 9);256 const out = synthesise(L, "daily");257 const geo = buildGeometry(out.manifest);258 assert(259 out.manifest.tiles.length > 1500,260 `tiles ${out.manifest.tiles.length}`,261 );262 const G = out.manifest.G;263 // overview: the whole field in one viewport at ~1.3 px/cell -> zoom 2,264 // 128 cells per map tile265 const fit = 700 / G;266 const over = tilesForView(G, { scale: fit, ox: 0, oy: 0 }, 1200, 700);267 assert(over.length <= 30, `overview map tiles ${over.length}`);268 assertEquals(over[0].level, 0);269 // detail: 16 px/cell, a 1200x700 viewport270 const detail = tilesForView(271 G,272 { scale: 16, ox: -4000, oy: -4000 },273 1200,274 700,275 );276 assert(detail.length <= 30, `detail map tiles ${detail.length}`);277 assertEquals(detail[0].level, 2);278 // an event on one tile dirties few map tiles at every zoom279 const cells = geo.cellsOf[out.manifest.tiles[100].id];280 const keys = keysForCells(G, cells);281 assert(keys.size <= 9 * 8, `dirty keys ${keys.size}`);282 const log = new ChangeLog();283 log.add(1, keys);284 // a map tile far away stays valid285 assert(log.valid("16:0:0", 0, 1));286});287288Deno.test("checkpointed fold equals foldAll at every revision, any order", () => {289 const evs = fakeEvents();290 const all = foldAll(evs);291 const cp = new Checkpoints(3);292 cp.feed(evs);293 const order = [9, 0, 4, 4, 8, 2, 7, 1, 9, 5];294 for (const r of order) assertEquals(cp.at(r), all[r], `rev ${r}`);295 assert(cp.checkpointCount() >= 3);296 // live append: the same object list grown by one keeps the checkpoints297 const grown = [...evs, { rev: 10, t: evs[0].t, kind: "end" as const }];298 cp.feed(grown);299 assertEquals(cp.checkpointCount() >= 3, true);300 assertEquals(cp.at(10).revision, foldAll(grown)[10].revision);301 // a different list resets302 cp.feed(evs.slice(0, 5));303 assertEquals(cp.length, 5);304 assertEquals(cp.at(4), all[4]);305});306307// ---------------------------------------------------------------- growth308309import {310 boxOf,311 cellsOfEvent,312 cellsOfTiles,313 fitBox,314 Occupancy,315 occupiedTilesForView,316} from "../lib/viewer/tilemap.ts";317import type { GrowEvent, TileSpec } from "../lib/fold.ts";318319/** A layout translated onto a G=1000 field (the daily: a cluster at the320 * centre of a large field, cell ids on the full field). */321function centred(seed: string, size: number, G = 1000) {322 const L = withSeeds(scatterLayout(seed, size), 3);323 const off = Math.floor((G - size) / 2);324 const tiles = L.tiles.map((t) => ({ ...t, x0: t.x0 + off, y0: t.y0 + off }));325 return { ...L, G, tiles };326}327328Deno.test("a G=1000 field: fit frames the cluster, empty map tiles are skipped", () => {329 const L = centred("daily1000", 180);330 const out = synthesise(L, "daily1000");331 const m = out.manifest;332 assertEquals(m.G, 1000);333 const box = boxOf(m.tiles)!;334 assert(box.x0 >= 400 && box.x1 <= 600, `cluster box ${JSON.stringify(box)}`);335 // the fit shows the cluster at a scale set by its size, not by G336 const v = fitBox(box, 1200, 700);337 assert(v.scale > 3, `scale ${v.scale}`);338 assert(v.scale * (box.x1 - box.x0 + 1) <= 1200 * 0.94 + 1e-9);339 // the cluster's centre lands at the viewport centre340 const cx = v.ox + ((box.x0 + box.x1 + 1) / 2) * v.scale;341 assert(Math.abs(cx - 600) < 1, `centre ${cx}`);342 // occupancy: at that scale the visible map tiles are only the ones the343 // cluster touches, never the empty field around it344 const occ = new Occupancy(m.G, m.tiles);345 const all = tilesForView(m.G, v, 1200, 700);346 const some = occupiedTilesForView(m.G, v, 1200, 700, occ);347 assert(some.length < all.length, `${some.length} of ${all.length}`);348 assert(some.length <= 25, `visible map tiles ${some.length}`);349 for (const t of some) assert(occ.has(t.zoom, t.tx, t.ty));350 // far from the cluster nothing is occupied at any zoom351 assert(!occ.has(1, 0, 0));352 assert(!occ.has(0.25, 3, 3));353 // at 64 px/cell a map tile is 4 cells; the cluster covers a few thousand354 // of the 62500 map tiles, not all of them355 assert(356 occ.count(64) > 0 && occ.count(64) < 10000,357 `occupied ${occ.count(64)}`,358 );359});360361/** A second ring of tiles around a layout, ids continuing. */362function ring(L: ReturnType<typeof centred>, seed: string, size: number) {363 const inner = boxOf(L.tiles)!;364 const R = scatterLayout(seed, size, 0.6);365 const off = Math.floor((L.G - size) / 2);366 const tiles: TileSpec[] = [];367 let id = L.tiles.length;368 for (const t of R.tiles) {369 const x0 = t.x0 + off, y0 = t.y0 + off;370 // keep tiles outside the inner box (they may overlap its edge tiles)371 const inside = x0 >= inner.x0 && x0 + t.w - 1 <= inner.x1 &&372 y0 >= inner.y0 && y0 + t.h - 1 <= inner.y1;373 if (inside) continue;374 tiles.push({ ...t, id: id++, x0, y0, hash: `h${id}`, overlaps: [] });375 }376 return tiles;377}378379Deno.test("growth: a grow event dirties only the map tiles its tiles touch, marks the timeline, keeps hues", () => {380 const L = centred("grow1000", 150);381 const out = synthesise(L, "grow1000");382 const m = out.manifest;383 const added = ring(L, "grow-ring", 300);384 assert(added.length > 20, `ring tiles ${added.length}`);385 const t0 = "2026-08-26T00:00:00.000Z";386 const grow: GrowEvent = {387 rev: 5,388 t: t0,389 kind: "grow",390 ring: 1,391 tiles: added,392 seed_tiles: [added[0].id],393 };394 // cells of the new tiles come from the event itself, no geometry needed395 const geo0 = buildGeometry(m);396 const cells = cellsOfEvent(grow, geo0);397 assertEquals(cells.length, cellsOfTiles(m.G, added).length);398 assert(cells.length >= added.reduce((a, t) => a + t.cells, 0) * 0.9);399 const keys = keysForCells(m.G, cells);400 const log = new ChangeLog();401 log.add(5, keys);402 // a map tile over the untouched centre stays valid across the grow;403 // one over the ring does not404 const inner = boxOf(m.tiles)!;405 const cx = Math.floor((inner.x0 + inner.x1) / 2),406 cy = Math.floor((inner.y0 + inner.y1) / 2);407 const centreKey = [...keysForCells(m.G, [cy * m.G + cx], [16])][0];408 assert(log.valid(centreKey, 4, 6), "centre map tile untouched by growth");409 const ringKey = [...keysForCells(m.G, [cells[0]], [16])][0];410 assert(!log.valid(ringKey, 4, 6), "ring map tile dirtied by growth");411 // an occupancy of the grown geometry covers strictly more map tiles412 const grown = { ...m, tiles: [...m.tiles, ...added] };413 const occ0 = new Occupancy(m.G, m.tiles),414 occ1 = new Occupancy(m.G, grown.tiles);415 assert(occ1.count(4) > occ0.count(4));416 // timeline marker417 const evs: GameEvent[] = [418 {419 rev: 0,420 t: t0,421 kind: "manifest",422 game_id: "g",423 manifest: m,424 rules: { guess: "accept", depth: 0 },425 },426 grow,427 { rev: 6, t: t0, kind: "end" },428 ];429 const ms = markers(evs);430 assertEquals(ms.map((x) => `${x.kind}@${x.rev}`), ["grow@5", "end@6"]);431 assert(ms[0].label.includes(`${added.length} tiles`));432 // hues: agents assigned on the old geometry keep them on the new one433 const agents: AgentInfo[] = (m.seed_tiles ?? []).map((s, i) => ({434 health: healthyHealth(),435 id: `a${i}`,436 name: `a${i}`,437 harness: "test",438 model: null,439 tags: {},440 verified: false,441 machine_id: null,442 joined: i,443 seed_tile: s,444 group: `a${i}`,445 }));446 const h0 = agentHues(agents, geo0);447 const geo1 = buildGeometry(grown);448 const later = [...agents, {449 ...agents[0],450 id: "late",451 name: "late",452 joined: 9,453 seed_tile: added[0].id,454 group: "late",455 }];456 const h1 = agentHues(later, geo1, 3, h0);457 for (const a of agents) assertEquals(h1.get(a.id), h0.get(a.id));458 assert(h1.has("late"));459});460461Deno.test("growth folded: tiles appear at the grow revision and vanish when scrubbed back", () => {462 const L = centred("fold1000", 120);463 const out = synthesise(L, "fold1000");464 const m = out.manifest;465 const added = ring(L, "fold-ring", 240);466 const t0 = "2026-08-26T00:00:00.000Z";467 const evs: GameEvent[] = [468 {469 rev: 0,470 t: t0,471 kind: "manifest",472 game_id: "g",473 manifest: m,474 rules: { guess: "accept", depth: 0 },475 },476 {477 rev: 1,478 t: t0,479 kind: "join",480 agent: "a0",481 name: "a0",482 harness: "test",483 seed_tile: m.seed_tiles![0],484 },485 {486 rev: 2,487 t: t0,488 kind: "grow",489 ring: 1,490 tiles: added,491 seed_tiles: [added[0].id],492 },493 { rev: 3, t: t0, kind: "end" },494 ];495 const all = foldAll(evs);496 assertEquals(all[1].tiles.length, m.tiles.length);497 assertEquals(all[2].tiles.length, m.tiles.length + added.length);498 assert(all[2].geo !== all[1].geo, "a grow yields a new geometry");499 assertEquals(all[2].geo!.cellsOf.length, m.tiles.length + added.length);500 // the checkpointed fold agrees in any scrub order across the grow501 const cp = new Checkpoints(2);502 cp.feed(evs);503 for (const r of [3, 1, 2, 0, 2, 1]) {504 assertEquals(cp.at(r).tiles.length, all[r].tiles.length, `rev ${r}`);505 assertEquals(506 cp.at(r).geo === all[r].geo ||507 cp.at(r).geo!.cellsOf.length === all[r].geo!.cellsOf.length,508 true,509 );510 }511 // reached: before anyone plays, only the agent's seed tile and what it512 // overlaps are reached; the ring is unreached513 const s2 = all[2] as unknown as { reached?: Uint8Array };514 if (s2.reached) {515 let reached = 0;516 for (const r of s2.reached) reached += r;517 assert(reached < m.tiles.length, `reached ${reached}`);518 for (const t of added) assertEquals(s2.reached[t.id], 0);519 }520});