clone

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

site/viewer: tilemap with levels of detail, per-agent hue, replay timeline, calmer ink

dami committed c3e6a81
The field is cut into 256 px map tiles at discrete raster zooms, each
rasterised once into an offscreen canvas and cached; a change log keyed
by map tile keeps rasters honest across revisions (scrubbing either way,
recency window); a frame composes cached tiles at the continuous scale
and reports how many it redrew. Levels: L0 territory (agent-hue washes,
unknown dark, holes void), L1 shape outlines and the ripple, L2 values
as discs in a tone of the wash (givens quiet, guesses lighter), L3 the
lines, pins and label of the tile under the centre. Hue is per agent,
assigned at join and stable. Timeline with density strip and markers
(join, solved, meet, guess run, end), playback speeds, follow-agent,
eased zoom across level boundaries. Tests for the pure logic and a
synthetic daily-sized field.
commit
c3e6a813eb6b41ae948a59cdd27f61fdda4adcef
parent
b66f64a6cf3959280a20f3e243712f0c67742310
author
dami <gazey55@gmail.com>
6 files changed, +1609 -222
Msite/islands/Viewer.tsx1091++++++++++++++++++++++++------
Msite/lib/colour.ts59++
Asite/lib/viewer/hues.ts66++
Asite/lib/viewer/tilemap.ts217++++++
Asite/lib/viewer/timeline.ts118+++
Asite/tests/viewer_test.ts280++++++++
M site/islands/Viewer.tsx +869 -222
@@ -1,137 +1,507 @@
1-// The spectator. One canvas (map, cells, ownership washes, glyphs), a console2-// pane, and a scrubber. Live mode follows /events/live over SSE; replay mode3-// loads /log/:id. Both are the same fold. Nothing is clickable except pan,4-// zoom, and the scrubber.1+// The spectator: a tilemap with levels of detail, a sidebar (stats,2+// agents, console) and a replay timeline. Live mode follows /events/live3+// over SSE; replay mode loads /log/:id. Both are the same fold.4+//5+// Rendering: the field is cut into map tiles (TILE_PX screen pixels at a6+// discrete raster zoom), each rasterised once into an offscreen canvas7+// and cached; an event dirties only the map tiles whose cells it touches;8+// a frame composes cached map tiles at the continuous view scale. Levels:9+// L0 territory (agent-hue washes, unknown dark, holes void), L1 + shape10+// outlines and the ripple, L2 + values as discs, L3 + the lines of the11+// tile under the viewport centre (drawn per frame, not cached).512 import { useEffect, useMemo, useRef, useState } from "preact/hooks";613 import {7- type ConsoleEntry,14+ type AgentInfo,815 emptyState,916 foldAll,1017 type GameEvent,1118 type Geometry,12- hueOf,13- meanSolvedCells,1419 parseLog,1520 type State,1621 type TileSpec,1722 type TileStatus,1823 } from "../lib/fold.ts";1924 import {25+ agentColour,2026 cellFill,2127 DARK,28+ inkTone,2229 LIGHT,30+ neutralFill,31+ rgbOf,2332 type Theme,24- tileStroke,2533 } from "../lib/colour.ts";26-27-type View = { scale: number; ox: number; oy: number };34+import {35+ ChangeLog,36+ keysForCells,37+ type Level,38+ levelFor,39+ type MapTile,40+ RECENCY_REVS,41+ TileCache,42+ tileCellRange,43+ tileScreenRect,44+ tilesForView,45+ type View,46+} from "../lib/viewer/tilemap.ts";47+import { agentHues } from "../lib/viewer/hues.ts";48+import {49+ density,50+ type Marker,51+ markers,52+ type Speed,53+ SPEEDS,54+ stepFor,55+} from "../lib/viewer/timeline.ts";2856 2957 const RANK: Record<TileStatus, number> = { hidden: 0, revealed: 1, solved: 2 };58+const RECENCY_STEPS = 4;59+const BUCKET = 32; // cells per spatial-index bucket60+const EASE_MS = 300;3061 31-class Painter {62+// ---------------------------------------------------------------- scene63+64+/** Everything a raster needs, derived per state (cheaply) and per geometry. */65+class Scene {3266 geo: Geometry | null = null;33- hues: number[][] = []; // per cell: owner hues34- fills: string[][] = []; // [id][key]3567 theme: Theme = DARK;68+ buckets = new Map<string, number[]>(); // spatial index: tiles by bucket69+ masks: Uint8Array[] = [];70+ fillCache = new Map<string, string>();3671 37- prepare(geo: Geometry, theme: Theme) {38- if (this.geo === geo && this.theme === theme) return;72+ prepare(geo: Geometry, tiles: TileSpec[], theme: Theme) {3973 if (this.geo !== geo) {40- const N = geo.G * geo.G;41- this.hues = new Array(N);42- for (let id = 0; id < N; id++) this.hues[id] = hueOf(geo, id);74+ this.buckets.clear();75+ this.masks = tiles.map((t) => {76+ const m = new Uint8Array(t.w * t.h);77+ for (const [lx, ly, rw, rh] of t.rects) {78+ for (let y = ly; y < ly + rh; y++) {79+ for (let x = lx; x < lx + rw; x++) m[y * t.w + x] = 1;80+ }81+ }82+ return m;83+ });84+ for (const t of tiles) {85+ for (86+ let by = Math.floor(t.y0 / BUCKET);87+ by <= Math.floor((t.y0 + t.h - 1) / BUCKET);88+ by++89+ ) {90+ for (91+ let bx = Math.floor(t.x0 / BUCKET);92+ bx <= Math.floor((t.x0 + t.w - 1) / BUCKET);93+ bx++94+ ) {95+ const k = `${bx}:${by}`;96+ const list = this.buckets.get(k);97+ if (list) list.push(t.id);98+ else this.buckets.set(k, [t.id]);99+ }100+ }101+ }43102 }103+ if (this.geo !== geo || this.theme !== theme) this.fillCache.clear();44104 this.geo = geo;45105 this.theme = theme;46- this.fills = new Array(geo.G * geo.G);47- for (let i = 0; i < this.fills.length; i++) this.fills[i] = [];48106 }49107 50- /** recency is quantised to RECENCY_STEPS levels so fills stay cacheable */108+ /** Tiles whose bounding box intersects a cell range. */109+ tilesIn(x0: number, y0: number, x1: number, y1: number): Set<number> {110+ const out = new Set<number>();111+ for (112+ let by = Math.floor(y0 / BUCKET);113+ by <= Math.floor(y1 / BUCKET);114+ by++115+ ) {116+ for (117+ let bx = Math.floor(x0 / BUCKET);118+ bx <= Math.floor(x1 / BUCKET);119+ bx++120+ ) {121+ const list = this.buckets.get(`${bx}:${by}`);122+ if (list) { for (const id of list) out.add(id); }123+ }124+ }125+ return out;126+ }127+128+ /** One opaque fill for a cell, by its owners' agent hues and status. */51129 fill(52- id: number,130+ hues: number[],53131 status: TileStatus,54132 known: boolean,55133 recency: number,56134 ): string {57135 const q = Math.round(recency * RECENCY_STEPS);58- const key = (RANK[status] * 2 + (known ? 1 : 0)) * (RECENCY_STEPS + 1) + q;59- let f = this.fills[id][key];136+ const key = `${hues.join(",")}|${status}|${known ? 1 : 0}|${q}`;137+ let f = this.fillCache.get(key);60138 if (!f) {61- f = cellFill(this.hues[id], status, known, this.theme, q / RECENCY_STEPS);62- this.fills[id][key] = f;139+ f = hues.length140+ ? cellFill(hues, status, known, this.theme, q / RECENCY_STEPS)141+ : neutralFill(status, known, this.theme, q / RECENCY_STEPS);142+ this.fillCache.set(key, f);63143 }64144 return f;65145 }66146 }67147 68-const RECENCY_STEPS = 4;69-/** revisions over which a newly known cell's lift fades */70-const RECENCY_REVS = 4;71-/** cells at least this many pixels wide show their value */72-const DETAIL_SCALE = 12;148+/** Per-state lookups the raster reads: who holds each tile, by hue. */149+type Frame = {150+ s: State;151+ tileHue: Int16Array; // per tile: agent hue or -1152+ agentName: Map<string, string>;153+};73154 74-/** Local mask of a tile: which cells of its bounding box belong to it. */75-function tileMask(t: TileSpec): { w: number; h: number; mask: Uint8Array } {76- const w = t.w, h = t.h;77- const mask = new Uint8Array(w * h);78- for (const [lx, ly, rw, rh] of t.rects) {79- for (let y = ly; y < ly + rh; y++) {80- for (let x = lx; x < lx + rw; x++) mask[y * w + x] = 1;155+function frameOf(s: State, hues: Map<string, number>): Frame {156+ const n = s.tiles.length;157+ const tileHue = new Int16Array(n).fill(-1);158+ // a solved tile is its solver's; a revealed one is its revealer's while159+ // it is worked on160+ const revealer = new Map<number, string>();161+ for (const e of s.console) {162+ if (e.op === "reveal" && e.tile !== undefined && e.agent) {163+ revealer.set(e.tile, e.agent);164+ }165+ }166+ for (let t = 0; t < n; t++) {167+ const a = s.tileAgent[t] ?? revealer.get(t) ?? null;168+ if (a !== null) {169+ const h = hues.get(a);170+ if (h !== undefined) tileHue[t] = h;81171 }82172 }83- return { w, h, mask };173+ const agentName = new Map<string, string>();174+ for (const a of s.agents) agentName.set(a.id, a.name || a.id);175+ return { s, tileHue, agentName };176+}177+178+// ---------------------------------------------------------------- raster179+180+type Raster = { canvas: HTMLCanvasElement; ppc: number }; // pixels per cell181+182+function cellsTouched(ev: GameEvent, geo: Geometry | null): number[] {183+ if (!geo) return [];184+ switch (ev.kind) {185+ case "batch":186+ if (ev.op === "reveal") return geo.cellsOf[ev.tile] ?? [];187+ if (ev.op === "commit") {188+ const out = ev.accepted.map((c) => c.id);189+ for (const t of ev.solved) out.push(...(geo.cellsOf[t] ?? []));190+ return out;191+ }192+ return ev.cells;193+ default:194+ return [];195+ }84196 }85197 86-/**87- * Outline of a tile: every edge between one of its cells and a cell that88- * is not its own. Rectangles, L, T and holed shapes all come out right.89- */198+function renderMapTile(199+ mt: MapTile,200+ f: Frame,201+ scene: Scene,202+ dpr: number,203+): Raster {204+ const s = f.s, geo = s.geo!, G = s.G, theme = scene.theme;205+ const level = mt.level;206+ const { x0, y0, x1, y1, cpt } = tileCellRange(mt, G);207+ // rasterise at no less than one pixel per cell; drawImage averages down208+ const ppc = Math.max(1, mt.zoom) * dpr;209+ const size = Math.round(cpt * ppc);210+ const canvas = document.createElement("canvas");211+ canvas.width = size;212+ canvas.height = size;213+ const ctx = canvas.getContext("2d")!;214+ const cx0 = mt.tx * cpt, cy0 = mt.ty * cpt; // cell origin of the map tile215+ const statusOfCell = (id: number): TileStatus | null => {216+ const a = geo.ownerStart[id], b = geo.ownerStart[id + 1];217+ if (a === b) return null;218+ let best: TileStatus = "hidden";219+ for (let i = a; i < b; i++) {220+ const st = s.tiles[geo.ownerList[i]];221+ if (RANK[st] > RANK[best]) best = st;222+ }223+ return best;224+ };225+ const huesOfCell = (id: number, out: number[]) => {226+ out.length = 0;227+ for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {228+ const h = f.tileHue[geo.ownerList[i]];229+ if (h >= 0) out.push(h);230+ }231+ if (out.length > 1) out.sort((p, q) => p - q);232+ };233+ const hues: number[] = [];234+ const fillOf = (id: number): string | null => {235+ const st = statusOfCell(id);236+ const c = s.cells[id];237+ if (st === null) {238+ if (!c) return null; // a hole239+ return scene.fill([], "solved", true, 0); // ownerless known cell (v2 filler)240+ }241+ const age = c ? s.revision - c.rev : RECENCY_REVS;242+ const recency = c && age < RECENCY_REVS ? 1 - age / RECENCY_REVS : 0;243+ huesOfCell(id, hues);244+ return scene.fill(hues, st, !!c, recency);245+ };246+247+ if (ppc <= 2 * dpr) {248+ // small cells: write pixels directly249+ const img = ctx.createImageData(size, size);250+ const d = img.data;251+ const p = Math.round(ppc);252+ for (let y = y0; y <= y1; y++) {253+ for (let x = x0; x <= x1; x++) {254+ const fill = fillOf(y * G + x);255+ if (fill === null) continue;256+ const [r, g, b] = rgbOf(fill);257+ const px0 = Math.round((x - cx0) * ppc),258+ py0 = Math.round((y - cy0) * ppc);259+ for (let dy = 0; dy < p; dy++) {260+ const row = (py0 + dy) * size;261+ if (py0 + dy >= size) break;262+ for (let dx = 0; dx < p; dx++) {263+ if (px0 + dx >= size) break;264+ const i = (row + px0 + dx) * 4;265+ d[i] = r;266+ d[i + 1] = g;267+ d[i + 2] = b;268+ d[i + 3] = 255;269+ }270+ }271+ }272+ }273+ ctx.putImageData(img, 0, 0);274+ } else {275+ for (let y = y0; y <= y1; y++) {276+ for (let x = x0; x <= x1; x++) {277+ const fill = fillOf(y * G + x);278+ if (fill === null) continue;279+ ctx.fillStyle = fill;280+ ctx.fillRect((x - cx0) * ppc, (y - cy0) * ppc, ppc + 0.5, ppc + 0.5);281+ }282+ }283+ }284+ if (level === 0) return { canvas, ppc };285+286+ // L1: shape outlines. A revealed tile (the work in progress) gets a287+ // visible edge in its agent's hue; a solved one a faint edge; a hidden288+ // one none. Soft: sub-pixel width at low alpha, round joins.289+ const tiles = scene.tilesIn(x0, y0, x1, y1);290+ ctx.lineJoin = "round";291+ ctx.lineCap = "round";292+ for (const id of tiles) {293+ const st = s.tiles[id];294+ if (st === "hidden") continue;295+ const t = s.manifest!.tiles[id];296+ const h = f.tileHue[id];297+ const revealed = st === "revealed";298+ ctx.strokeStyle = h >= 0 ? agentColour(h, theme, revealed) : theme.muted;299+ ctx.globalAlpha = revealed ? 0.7 : 0.28;300+ ctx.lineWidth = Math.max(0.6, Math.min(1.6, ppc * 0.05));301+ traceOutline(ctx, t, scene.masks[id], ppc, -cx0 * ppc, -cy0 * ppc);302+ }303+ ctx.globalAlpha = 1;304+ if (level === 1) return { canvas, ppc };305+306+ // L2: values as discs in a tone of the wash. A 1 is a filled disc, a 0307+ // a ring; givens are quieter than commits; a guess is a lighter mark308+ // than a forced cell.309+ for (let y = y0; y <= y1; y++) {310+ for (let x = x0; x <= x1; x++) {311+ const id = y * G + x;312+ const c = s.cells[id];313+ if (!c) continue;314+ const wash = fillOf(id);315+ if (wash === null) continue;316+ const commit = c.kind === "commit";317+ const guess = c.class === "guess";318+ const delta = commit ? (guess ? 0.2 : 0.3) : 0.13;319+ const ink = inkTone(wash, theme, delta);320+ const r = ppc * (guess ? 0.24 : 0.28);321+ const ring = Math.max(0.8, ppc * (guess ? 0.06 : 0.085));322+ const cx = (x - cx0 + 0.5) * ppc, cy = (y - cy0 + 0.5) * ppc;323+ ctx.beginPath();324+ ctx.arc(cx, cy, c.v === 1 ? r : r - ring / 2, 0, Math.PI * 2);325+ if (c.v === 1) {326+ ctx.fillStyle = ink;327+ ctx.fill();328+ } else {329+ ctx.strokeStyle = ink;330+ ctx.lineWidth = ring;331+ ctx.stroke();332+ }333+ }334+ }335+ return { canvas, ppc };336+}337+338+/** Outline of a tile: every edge between one of its cells and a cell that339+ * is not its own, at `ppc` pixels per cell with a pixel offset. */90340 function traceOutline(91341 ctx: CanvasRenderingContext2D,92342 t: TileSpec,93- scale: number,343+ mask: Uint8Array,344+ ppc: number,94345 ox: number,95346 oy: number,96347 ) {97- const { w, h, mask } = tileMask(t);348+ const w = t.w, h = t.h;98349 const inside = (x: number, y: number) =>99350 x >= 0 && y >= 0 && x < w && y < h && mask[y * w + x] === 1;100351 ctx.beginPath();101352 for (let y = 0; y < h; y++) {102353 for (let x = 0; x < w; x++) {103354 if (!inside(x, y)) continue;104- const px = ox + (t.x0 + x) * scale, py = oy + (t.y0 + y) * scale;355+ const px = ox + (t.x0 + x) * ppc, py = oy + (t.y0 + y) * ppc;105356 if (!inside(x, y - 1)) {106357 ctx.moveTo(px, py);107- ctx.lineTo(px + scale, py);358+ ctx.lineTo(px + ppc, py);108359 }109360 if (!inside(x, y + 1)) {110- ctx.moveTo(px, py + scale);111- ctx.lineTo(px + scale, py + scale);361+ ctx.moveTo(px, py + ppc);362+ ctx.lineTo(px + ppc, py + ppc);112363 }113364 if (!inside(x - 1, y)) {114365 ctx.moveTo(px, py);115- ctx.lineTo(px, py + scale);366+ ctx.lineTo(px, py + ppc);116367 }117368 if (!inside(x + 1, y)) {118- ctx.moveTo(px + scale, py);119- ctx.lineTo(px + scale, py + scale);369+ ctx.moveTo(px + ppc, py);370+ ctx.lineTo(px + ppc, py + ppc);120371 }121372 }122373 }123374 ctx.stroke();124375 }125376 126-function draw(127- canvas: HTMLCanvasElement,128- s: State,377+/** Lines of a tile as local cell-index runs, horizontal then vertical. */378+function tileLines(t: TileSpec, mask: Uint8Array): number[][] {379+ const out: number[][] = [];380+ for (let y = 0; y < t.h; y++) {381+ let run: number[] = [];382+ for (let x = 0; x <= t.w; x++) {383+ if (x < t.w && mask[y * t.w + x]) {384+ run.push(y * t.w + x);385+ continue;386+ }387+ if (run.length) out.push(run);388+ run = [];389+ }390+ }391+ for (let x = 0; x < t.w; x++) {392+ let run: number[] = [];393+ for (let y = 0; y <= t.h; y++) {394+ if (y < t.h && mask[y * t.w + x]) {395+ run.push(y * t.w + x);396+ continue;397+ }398+ if (run.length) out.push(run);399+ run = [];400+ }401+ }402+ return out;403+}404+405+/** L3 overlay: the lines, pins, id and agent of the tile under the centre. */406+function drawFocus(407+ ctx: CanvasRenderingContext2D,408+ f: Frame,409+ scene: Scene,129410 view: View,130- painter: Painter,411+ W: number,412+ H: number,131413 theme: Theme,132414 ) {133- const ctx = canvas.getContext("2d");134- if (!ctx) return;415+ const s = f.s, geo = s.geo!, G = s.G;416+ const { scale, ox, oy } = view;417+ const cxCell = Math.floor((W / 2 - ox) / scale);418+ const cyCell = Math.floor((H / 2 - oy) / scale);419+ if (cxCell < 0 || cyCell < 0 || cxCell >= G || cyCell >= G) return;420+ const id = cyCell * G + cxCell;421+ const owners: number[] = [];422+ for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {423+ owners.push(geo.ownerList[i]);424+ }425+ if (!owners.length) return;426+ // prefer the revealed tile being worked, then the most recently solved427+ owners.sort((a, b) => RANK[s.tiles[b]] - RANK[s.tiles[a]]);428+ const tid = owners.find((t) => s.tiles[t] === "revealed") ?? owners[0];429+ const t = s.manifest!.tiles[tid];430+ const mask = scene.masks[tid];431+ const h = f.tileHue[tid];432+ const colour = h >= 0 ? agentColour(h, theme, true) : theme.muted;433+ ctx.save();434+ ctx.lineCap = "round";435+ // a faint bar along each line436+ ctx.strokeStyle = colour;437+ ctx.globalAlpha = 0.16;438+ ctx.lineWidth = Math.max(2, scale * 0.16);439+ for (const run of tileLines(t, mask)) {440+ const a = run[0], b = run[run.length - 1];441+ const ax = ox + (t.x0 + a % t.w + 0.5) * scale;442+ const ay = oy + (t.y0 + Math.floor(a / t.w) + 0.5) * scale;443+ const bx = ox + (t.x0 + b % t.w + 0.5) * scale;444+ const by = oy + (t.y0 + Math.floor(b / t.w) + 0.5) * scale;445+ ctx.beginPath();446+ ctx.moveTo(ax, ay);447+ ctx.lineTo(bx, by);448+ ctx.stroke();449+ }450+ // pins: the overlaps an alternative solution differs on, marked on451+ // their shared cells452+ ctx.globalAlpha = 0.5;453+ ctx.fillStyle = colour;454+ for (const oi of t.pins ?? []) {455+ const o = t.overlaps[oi];456+ if (!o) continue;457+ const other = new Set(geo.cellsOf[o.with]);458+ for (const c of geo.cellsOf[tid]) {459+ if (!other.has(c)) continue;460+ const x = c % G, y = Math.floor(c / G);461+ ctx.beginPath();462+ ctx.arc(463+ ox + (x + 0.5) * scale,464+ oy + (y + 0.5) * scale,465+ scale * 0.1,466+ 0,467+ Math.PI * 2,468+ );469+ ctx.fill();470+ }471+ }472+ // label473+ ctx.globalAlpha = 0.9;474+ ctx.font = "12px system-ui, sans-serif";475+ const agent = s.tileAgent[tid];476+ const label = `#${tid} ${t.cells} cells ${t.w}x${t.h}${477+ agent ? ` ${f.agentName.get(agent) ?? agent}` : ""478+ }`;479+ const lx = ox + t.x0 * scale, ly = oy + t.y0 * scale - 6;480+ ctx.fillStyle = theme.bg;481+ const tw = ctx.measureText(label).width;482+ ctx.globalAlpha = 0.7;483+ ctx.fillRect(lx - 3, ly - 12, tw + 6, 16);484+ ctx.globalAlpha = 1;485+ ctx.fillStyle = colour;486+ ctx.fillText(label, lx, ly);487+ ctx.restore();488+}489+490+// ---------------------------------------------------------------- compose491+492+type Perf = { frames: number[]; redraws: number[]; visible: number[] };493+494+function compose(495+ canvas: HTMLCanvasElement,496+ f: Frame,497+ view: View,498+ scene: Scene,499+ cache: TileCache<Raster>,500+ log: ChangeLog,501+ theme: Theme,502+ perf: Perf,503+): { redrawn: number; visible: number; level: Level } {504+ const ctx = canvas.getContext("2d")!;135505 const dpr = globalThis.devicePixelRatio || 1;136506 const W = canvas.clientWidth, H = canvas.clientHeight;137507 if (
@@ -141,99 +511,59 @@ function draw(
141511 canvas.width = Math.round(W * dpr);142512 canvas.height = Math.round(H * dpr);143513 }514+ const t0 = performance.now();144515 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);145516 ctx.fillStyle = theme.bg;146517 ctx.fillRect(0, 0, W, H);147- if (!s.manifest) {518+ const s = f.s;519+ if (!s.manifest || !s.geo) {148520 ctx.fillStyle = theme.muted;149521 ctx.font = "13px system-ui, sans-serif";150522 ctx.fillText("waiting for a game", 16, 24);151- return;152- }153- const G = s.G, geo = s.geo!;154- painter.prepare(geo, theme);155- const { scale, ox, oy } = view;156-157- const x0 = Math.max(0, Math.floor(-ox / scale));158- const y0 = Math.max(0, Math.floor(-oy / scale));159- const x1 = Math.min(G - 1, Math.ceil((W - ox) / scale));160- const y1 = Math.min(G - 1, Math.ceil((H - oy) / scale));161- const detail = scale >= DETAIL_SCALE;162- const statusOfCell = (id: number): TileStatus | null => {163- const a = geo.ownerStart[id], b = geo.ownerStart[id + 1];164- if (a === b) return null;165- let best: TileStatus = "hidden";166- for (let i = a; i < b; i++) {167- const st = s.tiles[geo.ownerList[i]];168- if (RANK[st] > RANK[best]) best = st;169- }170- return best;171- };172-173- // the surface: one flat wash per cell, no borders. Holes are void; a174- // cell's value never shows here.175- for (let y = y0; y <= y1; y++) {176- for (let x = x0; x <= x1; x++) {177- const id = y * G + x;178- const st = statusOfCell(id);179- const c = s.cells[id];180- if (st === null) {181- if (!c) continue; // a hole182- ctx.fillStyle = painter.fill(id, "solved", true, 0); // ownerless known cell (v2 filler)183- } else {184- const age = c ? s.revision - c.rev : RECENCY_REVS;185- const recency = c && age < RECENCY_REVS ? 1 - age / RECENCY_REVS : 0;186- ctx.fillStyle = painter.fill(id, st, !!c, recency);187- }188- const px = ox + x * scale, py = oy + y * scale;189- ctx.fillRect(px, py, scale + 0.5, scale + 0.5);190- }523+ return { redrawn: 0, visible: 0, level: 0 };191524 }192- if (detail) {193- // values, only when a cell is wide enough to read: a 1 is a filled194- // disc (a pebble), a 0 a ring (a ripple). Givens in the muted ink,195- // the agent's own cells in the full ink.196- const r = scale * 0.3;197- const ring = Math.max(1, scale * 0.09);198- for (let y = y0; y <= y1; y++) {199- for (let x = x0; x <= x1; x++) {200- const c = s.cells[y * G + x];201- if (!c) continue;202- const cx = ox + (x + 0.5) * scale, cy = oy + (y + 0.5) * scale;203- const ink = c.kind === "commit" ? theme.ink : theme.inkGiven;204- ctx.beginPath();205- ctx.arc(cx, cy, c.v === 1 ? r : r - ring / 2, 0, Math.PI * 2);206- if (c.v === 1) {207- ctx.fillStyle = ink;208- ctx.fill();209- } else {210- ctx.strokeStyle = ink;211- ctx.lineWidth = ring;212- ctx.stroke();213- }214- }525+ scene.prepare(s.geo, s.manifest.tiles, theme);526+ const tiles = tilesForView(s.G, view, W, H);527+ let redrawn = 0;528+ ctx.imageSmoothingEnabled = true;529+ ctx.imageSmoothingQuality = "high";530+ for (const mt of tiles) {531+ let r = cache.get(mt.cacheKey, mt.key, s.revision, log);532+ if (!r) {533+ r = renderMapTile(mt, f, scene, dpr);534+ cache.set(mt.cacheKey, r, s.revision);535+ redrawn++;215536 }537+ const { x, y, size } = tileScreenRect(mt, view);538+ // snap to whole device pixels so neighbouring map tiles abut exactly539+ // (a fractional edge would leave a hairline seam between them)540+ const xr = Math.round(x * dpr) / dpr, yr = Math.round(y * dpr) / dpr;541+ const wr = Math.round((x + size) * dpr) / dpr - xr;542+ const hr = Math.round((y + size) * dpr) / dpr - yr;543+ ctx.drawImage(544+ r.canvas,545+ 0,546+ 0,547+ r.canvas.width,548+ r.canvas.height,549+ xr,550+ yr,551+ wr,552+ hr,553+ );216554 }217- // tile edges: a revealed tile (the work in progress) gets a visible218- // edge in its own hue; a solved tile only a faint one, so the finished219- // field reads as washes meeting, not as a quilt of frames; a hidden220- // tile has none. Drawn as the boundary of the tile's cells.221- ctx.lineJoin = "round";222- ctx.lineCap = "round";223- for (const t of s.manifest.tiles) {224- const st = s.tiles[t.id];225- if (st === "hidden") continue;226- const px = ox + t.x0 * scale, py = oy + t.y0 * scale;227- if (px + t.w * scale < 0 || py + t.h * scale < 0 || px > W || py > H) {228- continue;229- }230- const revealed = st === "revealed";231- ctx.strokeStyle = tileStroke(geo.hues[t.id], !revealed);232- ctx.globalAlpha = revealed ? 0.9 : 0.35;233- ctx.lineWidth = revealed ? Math.min(2, 0.8 + scale * 0.06) : 1;234- traceOutline(ctx, t, scale, ox, oy);555+ const level = levelFor(view.scale);556+ if (level === 3) drawFocus(ctx, f, scene, view, W, H, theme);557+ const dt = performance.now() - t0;558+ perf.frames.push(dt);559+ perf.redraws.push(redrawn);560+ perf.visible.push(tiles.length);561+ if (perf.frames.length > 240) {562+ perf.frames.shift();563+ perf.redraws.shift();564+ perf.visible.shift();235565 }236- ctx.globalAlpha = 1;566+ return { redrawn, visible: tiles.length, level };237567 }238568 239569 function fitView(G: number, W: number, H: number): View {
@@ -241,65 +571,144 @@ function fitView(G: number, W: number, H: number): View {
241571 return { scale, ox: (W - G * scale) / 2, oy: (H - G * scale) / 2 };242572 }243573 244-function stats(s: State) {245- const batches = s.console.filter((e) => e.op === "commit");246- const sizes = batches.map((e) => e.accepted + e.rejected + e.ignored);247- const mean = (a: number[]) =>248- a.length ? a.reduce((x, y) => x + y, 0) / a.length : 0;249- const head = mean(sizes.slice(0, 5)), tail = mean(sizes.slice(-5));250- const dts = s.console.map((e) => e.dt_ms).filter((d): d is number =>251- d !== null252- );253- return {254- ratio: s.totals.commits255- ? (s.totals.reveals / s.totals.commits).toFixed(2)256- : "-",257- drift: sizes.length >= 2 ? `${head.toFixed(1)} to ${tail.toFixed(1)}` : "-",258- dt: dts.length ? `${(mean(dts) / 1000).toFixed(1)}s` : "-",259- solved: s.tiles.filter((t) => t === "solved").length,260- rewardPerBatch: s.totals.commits261- ? (s.totals.reward / s.totals.commits).toFixed(1)262- : "-",263- meanCells: meanSolvedCells(s) ? meanSolvedCells(s).toFixed(0) : "-",574+// ---------------------------------------------------------------- timeline575+576+const MARKER_COLOUR: Record<Marker["kind"], string> = {577+ join: "#7aa2f7",578+ solved: "#9ece6a",579+ meet: "#f7768e",580+ "guess-run": "#e0af68",581+ end: "#8c8c92",582+};583+584+function Timeline(props: {585+ events: GameEvent[];586+ rev: number;587+ max: number;588+ live: boolean;589+ onScrub: (rev: number) => void;590+ theme: Theme;591+}) {592+ const ref = useRef<HTMLCanvasElement>(null);593+ const marks = useMemo(() => markers(props.events), [props.events]);594+ const dens = useMemo(() => density(props.events, 240), [props.events]);595+ useEffect(() => {596+ const c = ref.current;597+ if (!c) return;598+ const dpr = globalThis.devicePixelRatio || 1;599+ const W = c.clientWidth, H = c.clientHeight;600+ c.width = Math.round(W * dpr);601+ c.height = Math.round(H * dpr);602+ const ctx = c.getContext("2d")!;603+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);604+ ctx.clearRect(0, 0, W, H);605+ const n = Math.max(1, props.max);606+ const xOf = (rev: number) => (rev / n) * (W - 2) + 1;607+ // density strip608+ ctx.fillStyle = props.theme.name === "dark" ? "#2a2a30" : "#d8d8de";609+ const bw = W / dens.length;610+ for (let i = 0; i < dens.length; i++) {611+ const h = 2 + dens[i] * (H - 14);612+ ctx.fillRect(i * bw, H - 4 - h, Math.max(1, bw - 0.5), h);613+ }614+ // markers615+ for (const m of marks) {616+ ctx.fillStyle = MARKER_COLOUR[m.kind];617+ const x = xOf(m.rev);618+ if (m.kind === "meet" || m.kind === "join") {619+ ctx.beginPath();620+ ctx.arc(x, 6, 3.5, 0, Math.PI * 2);621+ ctx.fill();622+ } else if (m.kind === "guess-run") {623+ ctx.fillRect(x - 1, 2, 2, 8);624+ } else {625+ ctx.fillRect(x - 0.5, 4, 1, 5);626+ }627+ }628+ // playhead629+ const x = xOf(Math.max(0, props.rev));630+ ctx.fillStyle = props.live631+ ? "#7aa2f7"632+ : props.theme.name === "dark"633+ ? "#e8e8ea"634+ : "#141416";635+ ctx.fillRect(x - 1, 0, 2, H);636+ }, [637+ props.events,638+ props.rev,639+ props.max,640+ props.live,641+ props.theme,642+ marks,643+ dens,644+ ]);645+ const pick = (e: PointerEvent) => {646+ const c = ref.current;647+ if (!c || props.max <= 0) return;648+ const rect = c.getBoundingClientRect();649+ const f = Math.max(650+ 0,651+ Math.min(1, (e.clientX - rect.left - 1) / (rect.width - 2)),652+ );653+ let rev = Math.round(f * props.max);654+ // snap to a marker within 4 px655+ const px = rect.width / props.max;656+ for (const m of marks) {657+ if (Math.abs((m.rev - rev) * px) <= 4) {658+ rev = m.rev;659+ break;660+ }661+ }662+ props.onScrub(rev);264663 };664+ const dragging = useRef(false);665+ return (666+ <canvas667+ ref={ref}668+ title="timeline: click or drag to scrub; dots are joins (blue) and meetings (red), bars solved tiles (green) and guess runs (amber)"669+ style="flex:1;height:36px;display:block;cursor:pointer"670+ onPointerDown={(e) => {671+ dragging.current = true;672+ (e.currentTarget as HTMLCanvasElement).setPointerCapture(e.pointerId);673+ pick(e as unknown as PointerEvent);674+ }}675+ onPointerMove={(e) => {676+ if (dragging.current) pick(e as unknown as PointerEvent);677+ }}678+ onPointerUp={() => {679+ dragging.current = false;680+ }}681+ />682+ );265683 }266684 267-function entryLine(e: ConsoleEntry): string {268- switch (e.op) {269- case "start":270- return "start";271- case "join":272- return `join ${e.agent} at #${e.tile}`;273- case "reveal":274- return `reveal #${e.tile} ${e.revealed_cells} cells`;275- case "commit":276- return `commit ${e.accepted} ok (${e.forced} forced, ${e.guess} guess), ${e.rejected} rejected, ${e.ignored} ignored${277- e.solved.length278- ? `, solved ${e.solved.map((i) => `#${i}`).join(" ")} +${e.reward}`279- : ""280- }`;281- case "retract":282- return `retract ${e.retracted}`;283- case "end":284- return "end";285- }286-}685+// ---------------------------------------------------------------- island287686 288687 export default function Viewer(289688 props: { replay: string | null; theme: "dark" | "light" | null },290689 ) {291690 const canvasRef = useRef<HTMLCanvasElement>(null);292691 const paneRef = useRef<HTMLDivElement>(null);293- const painter = useRef(new Painter());692+ const scene = useRef(new Scene());693+ const cache = useRef(new TileCache<Raster>(768));694+ const log = useRef(new ChangeLog());695+ const perf = useRef<Perf>({ frames: [], redraws: [], visible: [] });294696 const [events, setEvents] = useState<GameEvent[]>([]);295697 const [rev, setRev] = useState(-1);296698 const [live, setLive] = useState(props.replay === null);297699 const [gameId, setGameId] = useState<string | null>(null);298700 const [view, setView] = useState<View | null>(null);299701 const [theme, setTheme] = useState<Theme>(DARK);702+ const [playing, setPlaying] = useState(false);703+ const [speed, setSpeed] = useState<Speed>(4);704+ const [follow, setFollow] = useState<string>("");705+ const [drawInfo, setDrawInfo] = useState({706+ redrawn: 0,707+ visible: 0,708+ level: 0 as Level,709+ });710+300711 useEffect(() => {301- // ?theme=dark|light overrides the system scheme (for the page chrome302- // too, through the data-theme attribute the stylesheet keys on)303712 if (props.theme) {304713 document.documentElement.dataset.theme = props.theme;305714 setTheme(props.theme === "dark" ? DARK : LIGHT);
@@ -343,8 +752,6 @@ export default function Viewer(
343752 return () => es.close();344753 }, [props.replay]);345754 346- // one fold per event list, computed during render so replay mode (which347- // never re-renders on its own) sees it immediately; live pins the scrubber348755 const states = useMemo(() => foldAll(events), [events]);349756 useEffect(() => {350757 if (live) setRev(events.length - 1);
@@ -353,8 +760,40 @@ export default function Viewer(
353760 const state: State = rev >= 0 && states[rev]354761 ? states[rev]355762 : (states.length ? states[0] : emptyState());763+ const final = states.length ? states[states.length - 1] : state;764+765+ // hues are assigned from the full agent list, so a scrubbed frame and766+ // the final frame agree767+ const hues = useMemo(() => agentHues(final.agents, final.geo), [768+ final.agents,769+ final.geo,770+ ]);771+ const frame = useMemo(() => frameOf(state, hues), [state, hues]);356772 357- // initial fit once the manifest is known773+ // change log: which map tiles each event touched (fed once per event)774+ const fed = useRef(0);775+ const geoRef = useRef<Geometry | null>(null);776+ useEffect(() => {777+ const geo = final.geo;778+ if (!geo) return;779+ if (geoRef.current !== geo) {780+ geoRef.current = geo;781+ log.current = new ChangeLog();782+ cache.current.clear();783+ fed.current = 0;784+ }785+ for (let i = fed.current; i < events.length; i++) {786+ const ev = events[i];787+ const cells = cellsTouched(ev, geo);788+ if (cells.length) log.current.add(ev.rev, keysForCells(geo.G, cells));789+ }790+ fed.current = events.length;791+ }, [events, final.geo]);792+ useEffect(() => {793+ cache.current.clear();794+ }, [theme, hues]);795+796+ // initial fit358797 useEffect(() => {359798 const c = canvasRef.current;360799 if (!c || !state.manifest || view) return;
@@ -366,14 +805,26 @@ export default function Viewer(
366805 const c = canvasRef.current;367806 if (!c) return;368807 let raf = 0;369- const paint = () =>370- draw(808+ const paint = () => {809+ const info = compose(371810 c,372- state,811+ frame,373812 view ?? fitView(Math.max(1, state.G), c.clientWidth, c.clientHeight),374- painter.current,813+ scene.current,814+ cache.current,815+ log.current,375816 theme,817+ perf.current,818+ );819+ setDrawInfo((d) =>820+ d.redrawn === info.redrawn && d.visible === info.visible &&821+ d.level === info.level822+ ? d823+ : info376824 );825+ (globalThis as unknown as { __mosaicPerf: Perf }).__mosaicPerf =826+ perf.current;827+ };377828 raf = requestAnimationFrame(paint);378829 const ro = new ResizeObserver(() => requestAnimationFrame(paint));379830 ro.observe(c);
@@ -381,7 +832,7 @@ export default function Viewer(
381832 cancelAnimationFrame(raf);382833 ro.disconnect();383834 };384- }, [state, view, theme]);835+ }, [frame, view, theme]);385836 386837 // console follows live387838 useEffect(() => {
@@ -389,11 +840,78 @@ export default function Viewer(
389840 if (p && live) p.scrollTop = p.scrollHeight;390841 }, [state.revision, live]);391842 392- // pan and zoom. Handlers read the latest view through a ref so the effect393- // is not re-registered on every setView, which would drop a drag in394- // progress after its first move.843+ // playback844+ useEffect(() => {845+ if (!playing) return;846+ let raf = 0;847+ let last = performance.now();848+ let carry = 0;849+ const tick = (now: number) => {850+ const { steps, carry: c } = stepFor(speed, now - last, carry);851+ carry = c;852+ last = now;853+ if (steps > 0) {854+ setRev((r) => {855+ const max = events.length - 1;856+ const next = Math.min(max, r + steps);857+ if (next >= max) setPlaying(false);858+ return next;859+ });860+ }861+ raf = requestAnimationFrame(tick);862+ };863+ raf = requestAnimationFrame(tick);864+ return () => cancelAnimationFrame(raf);865+ }, [playing, speed, events.length]);866+867+ // eased view changes (zoom level transitions, follow)868+ const targetRef = useRef<View | null>(null);869+ const animRef = useRef(0);395870 const viewRef = useRef<View | null>(null);396871 viewRef.current = view;872+ const easeTo = (target: View) => {873+ const from = viewRef.current;874+ if (!from) {875+ setView(target);876+ return;877+ }878+ targetRef.current = target;879+ cancelAnimationFrame(animRef.current);880+ const start = performance.now();881+ const step = (now: number) => {882+ const k = Math.min(1, (now - start) / EASE_MS);883+ const e = 1 - Math.pow(1 - k, 3);884+ const t = targetRef.current!;885+ // interpolate in log-scale so zoom feels even886+ const ls = Math.log(from.scale) +887+ (Math.log(t.scale) - Math.log(from.scale)) * e;888+ setView({889+ scale: Math.exp(ls),890+ ox: from.ox + (t.ox - from.ox) * e,891+ oy: from.oy + (t.oy - from.oy) * e,892+ });893+ if (k < 1) animRef.current = requestAnimationFrame(step);894+ };895+ animRef.current = requestAnimationFrame(step);896+ };897+898+ // follow an agent: keep its last solved tile centred at L2899+ useEffect(() => {900+ if (!follow) return;901+ const c = canvasRef.current;902+ const a = state.analysis.agents.find((x) => x.agent === follow);903+ if (!c || !a || a.last_solved === null || !state.manifest) return;904+ const t = state.manifest.tiles[a.last_solved];905+ const scale = 16;906+ const cx = t.x0 + t.w / 2, cy = t.y0 + t.h / 2;907+ easeTo({908+ scale,909+ ox: c.clientWidth / 2 - cx * scale,910+ oy: c.clientHeight / 2 - cy * scale,911+ });912+ }, [follow, state.revision]);913+914+ // pan and zoom397915 const dragRef = useRef<398916 { x: number; y: number; ox: number; oy: number } | null399917 >(null);
@@ -403,6 +921,7 @@ export default function Viewer(
403921 const down = (e: PointerEvent) => {404922 const v = viewRef.current;405923 if (!v || e.button !== 0) return;924+ cancelAnimationFrame(animRef.current);406925 dragRef.current = { x: e.clientX, y: e.clientY, ox: v.ox, oy: v.oy };407926 c.setPointerCapture(e.pointerId);408927 e.preventDefault();
@@ -420,22 +939,30 @@ export default function Viewer(
420939 dragRef.current = null;421940 };422941 const wheel = (e: WheelEvent) => {423- const v = viewRef.current;942+ const v = targetRef.current && animRef.current943+ ? targetRef.current944+ : viewRef.current;424945 if (!v || !state.manifest) return;425946 e.preventDefault();426947 const rect = c.getBoundingClientRect();427948 const mx = e.clientX - rect.left, my = e.clientY - rect.top;428949 const fit = fitView(state.G, c.clientWidth, c.clientHeight).scale;429- const minS = fit * 0.5,430- maxS = Math.min(c.clientWidth, c.clientHeight) / 6;950+ const minS = fit * 0.5, maxS = 64;431951 const k = Math.exp(-e.deltaY * 0.0015);432952 const ns = Math.max(minS, Math.min(maxS, v.scale * k));433953 const f = ns / v.scale;434- setView({954+ const target = {435955 scale: ns,436956 ox: mx - (mx - v.ox) * f,437957 oy: my - (my - v.oy) * f,438- });958+ };959+ // ease only across a level boundary; otherwise track the wheel960+ if (levelFor(ns) !== levelFor(v.scale)) easeTo(target);961+ else {962+ cancelAnimationFrame(animRef.current);963+ animRef.current = 0;964+ setView(target);965+ }439966 };440967 c.addEventListener("pointerdown", down);441968 c.addEventListener("pointermove", move);
@@ -451,8 +978,13 @@ export default function Viewer(
451978 };452979 }, [state.manifest, state.G]);453980 454- const st = stats(state);455981 const max = Math.max(0, events.length - 1);982+ const an = state.analysis;983+ const solved = state.tiles.filter((t) => t === "solved").length;984+ const agentRows = final.agents.map((a: AgentInfo) => {985+ const s = an.agents.find((x) => x.agent === a.id);986+ return { a, s, hue: hues.get(a.id) ?? 0 };987+ });456988 457989 return (458990 <div style="display:flex;flex-direction:column;height:100vh">
@@ -465,22 +997,21 @@ export default function Viewer(
465997 <div style="padding:10px 12px;border-bottom:1px solid var(--rule);color:var(--text-dim);font-size:12px;line-height:1.6">466998 <div>467999 game {gameId ? gameId.slice(0, 8) : "-"}{" "}468- {props.replay ? "replay" : live ? "live" : "scrubbing"}1000+ {props.replay ? "replay" : live ? "live" : "scrubbing"}{" "}1001+ L{drawInfo.level}4691002 </div>4701003 <div>471- revision {state.revision < 0 ? "-" : state.revision} of{" "}472- {max < 0 ? "-" : max}1004+ revision {state.revision < 0 ? "-" : state.revision} of {max}4731005 </div>4741006 <div>4751007 {state.manifest4761008 ? `${state.manifest.tiles.length} tiles (${state.manifest.packer.kind}), ${state.G}x${state.G} field, ${4771009 state.geo?.holes ?? 0478- } hole cells, seed tile #${state.seed_tile}`1010+ } hole cells`4791011 : "no manifest"}4801012 </div>4811013 <div>482- solved {st.solved}/{state.tiles.length} tiles, status{" "}483- {state.status}1014+ solved {solved}/{state.tiles.length} tiles, status {state.status}4841015 </div>4851016 <div>4861017 forced {state.totals.forced}, guess {state.totals.guess}, wrong
@@ -488,12 +1019,54 @@ export default function Viewer(
4881019 {state.totals.wrong}, retracted {state.totals.retracted}4891020 </div>4901021 <div>491- reveal:commit {st.ratio}, batch size {st.drift}, mean gap {st.dt}1022+ guess rate {(an.guess_rate * 100).toFixed(0)}%, reveal:commit{" "}1023+ {an.reveal_commit.toFixed(2)}, batch{" "}1024+ {an.batch_size_mean.toFixed(1)}{" "}1025+ ({an.batch_size_drift >= 0 ? "+" : ""}1026+ {an.batch_size_drift.toFixed(1)}), gap {an.mean_gap_ms === null1027+ ? "-"1028+ : `${(an.mean_gap_ms / 1000).toFixed(1)}s`}4921029 </div>4931030 <div>494- reward {state.totals.reward} ({st.rewardPerBatch}{" "}495- per batch), mean cells per solved tile {st.meanCells}1031+ reward {state.totals.reward} ({an.reward_per_batch.toFixed(1)}1032+ {" "}1033+ per batch), meets {an.meets}4961034 </div>1035+ <div style="color:var(--muted)">1036+ draw: {drawInfo.redrawn}/{drawInfo.visible}{" "}1037+ map tiles redrawn, cache {cache.current.size()}1038+ </div>1039+ </div>1040+ <div style="padding:8px 12px;border-bottom:1px solid var(--rule);font-size:12px;line-height:1.5">1041+ {agentRows.length === 01042+ ? <div style="color:var(--muted)">no agents yet</div>1043+ : null}1044+ {agentRows.map(({ a, s, hue }) => (1045+ <div1046+ key={a.id}1047+ style="display:flex;align-items:baseline;gap:8px;padding:2px 0"1048+ >1049+ <span1050+ style={`display:inline-block;width:10px;height:10px;border-radius:50%;background:${1051+ agentColour(hue, theme, true)1052+ };flex:none;align-self:center`}1053+ />1054+ <span style="color:var(--text-strong);min-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">1055+ {a.name || a.id}1056+ </span>1057+ <span style="color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">1058+ {a.harness}1059+ {a.model ? ` ${a.model}` : ""}1060+ </span>1061+ <span style="color:var(--text-dim);white-space:nowrap">1062+ {s1063+ ? `${s.tiles}t ${s.cells}c ${1064+ (s.territory * 100).toFixed(0)1065+ }% ${s.strategy}${s.meets ? ` m${s.meets}` : ""}`1066+ : "-"}1067+ </span>1068+ </div>1069+ ))}4971070 </div>4981071 <div4991072 ref={paneRef}
@@ -506,6 +1079,7 @@ export default function Viewer(
5061079 >5071080 <div style="color:var(--muted)">5081081 #{e.rev} {e.op}1082+ {e.agent ? ` ${frame.agentName.get(e.agent) ?? e.agent}` : ""}5091083 {e.dt_ms !== null ? ` +${(e.dt_ms / 1000).toFixed(1)}s` : ""}5101084 </div>5111085 <div style="color:var(--text)">{entryLine(e)}</div>
@@ -521,27 +1095,100 @@ export default function Viewer(
5211095 </div>5221096 </div>5231097 </div>524- <div style="display:flex;align-items:center;gap:10px;padding:8px 12px;border-top:1px solid var(--rule);background:var(--chrome)">525- <span style="color:var(--muted);font-size:12px;min-width:60px">1098+ <div style="display:flex;align-items:center;gap:10px;padding:6px 12px;border-top:1px solid var(--rule);background:var(--chrome)">1099+ <button1100+ type="button"1101+ disabled={events.length === 0}1102+ onClick={() => {1103+ if (!playing && rev >= max) setRev(0);1104+ setLive(false);1105+ setPlaying(!playing);1106+ }}1107+ style="font:inherit;background:none;border:1px solid var(--rule);color:var(--text);border-radius:4px;padding:2px 8px;cursor:pointer"1108+ >1109+ {playing ? "pause" : "play"}1110+ </button>1111+ <select1112+ value={String(speed)}1113+ onChange={(e) => {1114+ const v = (e.target as HTMLSelectElement).value;1115+ setSpeed(v === "max" ? "max" : Number(v) as Speed);1116+ }}1117+ style="font:inherit;background:var(--chrome);color:var(--text);border:1px solid var(--rule);border-radius:4px"1118+ >1119+ {SPEEDS.map((sp) => (1120+ <option key={String(sp)} value={String(sp)}>1121+ {sp === "max" ? "max" : `${sp}x`}1122+ </option>1123+ ))}1124+ </select>1125+ <select1126+ value={follow}1127+ onChange={(e) => setFollow((e.target as HTMLSelectElement).value)}1128+ style="font:inherit;background:var(--chrome);color:var(--text);border:1px solid var(--rule);border-radius:4px;max-width:140px"1129+ >1130+ <option value="">follow: none</option>1131+ {final.agents.map((a) => (1132+ <option key={a.id} value={a.id}>follow: {a.name || a.id}</option>1133+ ))}1134+ </select>1135+ <span style="color:var(--muted);font-size:12px;min-width:56px">5261136 rev {rev < 0 ? "-" : rev}5271137 </span>528- <input529- type="range"530- min={0}1138+ <Timeline1139+ events={events}1140+ rev={rev}5311141 max={max}532- value={Math.max(0, rev)}533- disabled={events.length === 0}534- style="flex:1"535- onInput={(e) => {536- const v = Number((e.target as HTMLInputElement).value);1142+ live={live}1143+ theme={theme}1144+ onScrub={(v) => {1145+ setPlaying(false);5371146 setRev(v);5381147 setLive(props.replay === null && v >= max);5391148 }}5401149 />541- <span style="color:var(--muted);font-size:12px;min-width:40px;text-align:right">542- {max < 0 ? "-" : max}1150+ <span style="color:var(--muted);font-size:12px;min-width:36px;text-align:right">1151+ {max}5431152 </span>1153+ {props.replay === null && !live1154+ ? (1155+ <button1156+ type="button"1157+ onClick={() => {1158+ setPlaying(false);1159+ setLive(true);1160+ setRev(max);1161+ }}1162+ style="font:inherit;background:none;border:1px solid var(--rule);color:var(--text);border-radius:4px;padding:2px 8px;cursor:pointer"1163+ >1164+ live1165+ </button>1166+ )1167+ : null}5441168 </div>5451169 </div>5461170 );5471171 }1172+1173+function entryLine(e: State["console"][number]): string {1174+ switch (e.op) {1175+ case "start":1176+ return "start";1177+ case "join":1178+ return `join ${e.agent} at #${e.tile}`;1179+ case "reveal":1180+ return `reveal #${e.tile} ${e.revealed_cells} cells`;1181+ case "commit":1182+ return `commit ${e.accepted} ok (${e.forced} forced, ${e.guess} guess), ${e.rejected} rejected, ${e.ignored} ignored${1183+ e.solved.length1184+ ? `, solved ${e.solved.map((i) => `#${i}`).join(" ")} +${e.reward}`1185+ : ""1186+ }${1187+ e.meets?.length ? `, meets ${e.meets.map((m) => m.with).join(" ")}` : ""1188+ }`;1189+ case "retract":1190+ return `retract ${e.retracted}`;1191+ case "end":1192+ return "end";1193+ }1194+}
M site/lib/colour.ts +59 -0
@@ -190,3 +190,62 @@ export function highestStatus(statuses: TileStatus[]): TileStatus {
190190 for (const s of statuses) if (RANK[s] > RANK[best]) best = s;191191 return best;192192 }193+194+// ---------------------------------------------------------------- ink195+196+const rgbCache = new Map<string, [number, number, number]>();197+198+/** sRGB bytes of a hex fill, cached (for ImageData rasters). */199+export function rgbOf(hex: string): [number, number, number] {200+ let v = rgbCache.get(hex);201+ if (!v) {202+ const [r, g, b] = hexToRgb(hex);203+ v = [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];204+ rgbCache.set(hex, v);205+ }206+ return v;207+}208+209+const inkCache = new Map<string, string>();210+211+/**212+ * Ink as a tone of the wash it sits on, not black or white: the wash213+ * shifted in OKLab lightness away from the page (lighter on dark, darker214+ * on light) by `delta`, chroma kept. Values then read as marks in the215+ * surface rather than stamps on it.216+ */217+export function inkTone(wash: string, theme: Theme, delta: number): string {218+ const key = `${wash}|${theme.name}|${delta}`;219+ let v = inkCache.get(key);220+ if (!v) {221+ const [l, a, b] = srgbToOklab(...hexToRgb(wash));222+ const dir = theme.name === "dark" ? 1 : -1;223+ v = toHex(oklabToSrgb(l + dir * delta, a * 0.9, b * 0.9));224+ inkCache.set(key, v);225+ }226+ return v;227+}228+229+/** The neutral wash for a tile nobody has claimed yet, by status. */230+export function neutralFill(231+ status: TileStatus,232+ known: boolean,233+ theme: Theme,234+ recency = 0,235+): string {236+ const panelLab = panelLabOf(theme.panel);237+ const dir = theme.name === "dark" ? 1 : -1;238+ const lift =239+ (status === "solved" ? 0.05 : status === "revealed" ? 0.035 : 0.012) *240+ (known ? 1 : 0.5) + recency * 0.05;241+ return toHex(oklabToSrgb(panelLab[0] + dir * lift, panelLab[1], panelLab[2]));242+}243+244+/** Solid agent colour for swatches, labels and outlines. */245+export function agentColour(hue: number, theme: Theme, strong = false): string {246+ const [l, a, b] = meanHueLab([hue]);247+ const dl = theme.name === "dark"248+ ? (strong ? 0.18 : 0.1)249+ : (strong ? -0.12 : -0.06);250+ return toHex(oklabToSrgb(l + dl, a, b));251+}
A site/lib/viewer/hues.ts +66 -0
@@ -0,0 +1,66 @@
1+// Hue means the agent who solved a tile. Hues are assigned in join order2+// and never change afterwards (stable per agent id across revisions and3+// regardless of who joins later): each agent takes the smallest hue not4+// held by an earlier agent whose seed tile is within a few overlap steps5+// of its own, so neighbours start distinct; after that, join order mod6+// HUES. Two agents that later meet keep their hues: their territories are7+// the point, and the merge is visible in the meet marker, not in colour.8+9+import { type AgentInfo, type Geometry, HUES } from "../fold.ts";10+11+/** Graph distance over the overlap graph, up to `max`, from one tile. */12+export function nearTiles(geo: Geometry, from: number, max: number): number[] {13+ if (from < 0 || from >= geo.adjacency.length) return [];14+ const dist = new Map<number, number>([[from, 0]]);15+ const q = [from];16+ for (let i = 0; i < q.length; i++) {17+ const t = q[i];18+ const d = dist.get(t)!;19+ if (d >= max) continue;20+ for (const u of geo.adjacency[t]) {21+ if (!dist.has(u)) {22+ dist.set(u, d + 1);23+ q.push(u);24+ }25+ }26+ }27+ return [...dist.keys()];28+}29+30+/** Hue index per agent id, in join order. */31+export function agentHues(32+ agents: AgentInfo[],33+ geo: Geometry | null,34+ reach = 3,35+): Map<string, number> {36+ const out = new Map<string, number>();37+ const seeds: { id: string; hue: number; near: Set<number> }[] = [];38+ agents.forEach((a, i) => {39+ const near = new Set(geo ? nearTiles(geo, a.seed_tile, reach) : []);40+ const used = new Set<number>();41+ for (const s of seeds) {42+ if (43+ s.near.has(a.seed_tile) || near.has(geo ? seedOf(agents, s.id) : -1)44+ ) {45+ used.add(s.hue);46+ }47+ }48+ let hue = i % HUES;49+ if (used.has(hue)) {50+ for (let k = 0; k < HUES; k++) {51+ const h = (i + k) % HUES;52+ if (!used.has(h)) {53+ hue = h;54+ break;55+ }56+ }57+ }58+ out.set(a.id, hue);59+ seeds.push({ id: a.id, hue, near });60+ });61+ return out;62+}63+64+function seedOf(agents: AgentInfo[], id: string): number {65+ return agents.find((a) => a.id === id)?.seed_tile ?? -1;66+}
A site/lib/viewer/tilemap.ts +217 -0
@@ -0,0 +1,217 @@
1+// The viewer is a tilemap with levels of detail. The field is cut into map2+// tiles of TILE_PX screen pixels at a discrete raster zoom (pixels per3+// cell); each map tile is rasterised once and cached; an event dirties4+// only the map tiles whose cells it touches; a frame composes cached map5+// tiles at the continuous view scale. Pure logic here (keys, ranges, dirty6+// tracking); the raster itself lives in the island.7+8+export const TILE_PX = 256;9+10+/** Raster zooms, in pixels per cell. A view scale s is drawn from the11+ * smallest zoom >= s (never upscaled), so the composed image stays sharp. */12+export const ZOOMS = [0.25, 0.5, 1, 2, 4, 8, 16, 32, 64] as const;13+export type Zoom = (typeof ZOOMS)[number];14+15+/** Revisions over which a newly known cell's lift fades (the ripple). */16+export const RECENCY_REVS = 8;17+18+/** Level of detail, by raster zoom: territory, shapes, values, lines. */19+export type Level = 0 | 1 | 2 | 3;20+21+export function zoomFor(scale: number): Zoom {22+ for (const z of ZOOMS) if (z >= scale) return z;23+ return ZOOMS[ZOOMS.length - 1];24+}25+26+/**27+ * L0 under 3 px per cell (territory only), L1 from 3 (shapes), L2 from 1228+ * (values as discs), L3 from 30 (lines), by the continuous view scale. A29+ * cached map tile is keyed by raster zoom and level, so a raster is drawn30+ * at exactly one level.31+ */32+export function levelFor(scale: number): Level {33+ if (scale < 3) return 0;34+ if (scale < 12) return 1;35+ if (scale < 30) return 2;36+ return 3;37+}38+39+/** Cells covered by one map tile at a zoom (fractional under 1 px/cell). */40+export function cellsPerTile(zoom: number): number {41+ return TILE_PX / zoom;42+}43+44+/** Geometry key of a map tile: what the change log tracks. */45+export function tileKey(zoom: number, tx: number, ty: number): string {46+ return `${zoom}:${tx}:${ty}`;47+}48+49+/** Cache key: the geometry key plus the level the raster was drawn at. */50+export function cacheKey(key: string, level: Level): string {51+ return `${key}|L${level}`;52+}53+54+export type View = { scale: number; ox: number; oy: number };55+56+export type MapTile = {57+ zoom: Zoom;58+ level: Level;59+ tx: number;60+ ty: number;61+ key: string; // geometry key (zoom:tx:ty)62+ cacheKey: string; // key plus level63+};64+65+/**66+ * Map tiles intersecting a viewport of W x H screen pixels, at the zoom67+ * the view scale rasterises from. Row-major; never outside the field.68+ */69+export function tilesForView(70+ G: number,71+ view: View,72+ W: number,73+ H: number,74+): MapTile[] {75+ const zoom = zoomFor(view.scale);76+ const level = levelFor(view.scale);77+ const cpt = cellsPerTile(zoom);78+ const n = Math.ceil(G / cpt);79+ const px = cpt * view.scale; // screen pixels per map tile80+ const tx0 = Math.max(0, Math.floor(-view.ox / px));81+ const ty0 = Math.max(0, Math.floor(-view.oy / px));82+ const tx1 = Math.min(n - 1, Math.floor((W - view.ox) / px));83+ const ty1 = Math.min(n - 1, Math.floor((H - view.oy) / px));84+ const out: MapTile[] = [];85+ for (let ty = ty0; ty <= ty1; ty++) {86+ for (let tx = tx0; tx <= tx1; tx++) {87+ const key = tileKey(zoom, tx, ty);88+ out.push({ zoom, level, tx, ty, key, cacheKey: cacheKey(key, level) });89+ }90+ }91+ return out;92+}93+94+/** Map-tile keys (at every zoom) that contain any of the cells. */95+export function keysForCells(96+ G: number,97+ cells: Iterable<number>,98+ zooms: readonly number[] = ZOOMS,99+): Set<string> {100+ const out = new Set<string>();101+ for (const zoom of zooms) {102+ const cpt = cellsPerTile(zoom);103+ for (const id of cells) {104+ const x = id % G, y = Math.floor(id / G);105+ out.add(tileKey(zoom, Math.floor(x / cpt), Math.floor(y / cpt)));106+ }107+ }108+ return out;109+}110+111+/**112+ * Which map tiles each revision changed, so a cached raster drawn at one113+ * revision is known to be valid (or not) at another: it is valid when no114+ * change lies in (min(a, b) - RECENCY_REVS, max(a, b)] — the recency115+ * window is included because a cell's lift fades over those revisions.116+ */117+export class ChangeLog {118+ private changes = new Map<string, number[]>();119+120+ add(rev: number, keys: Iterable<string>) {121+ for (const k of keys) {122+ let list = this.changes.get(k);123+ if (!list) {124+ list = [];125+ this.changes.set(k, list);126+ }127+ if (list.length === 0 || list[list.length - 1] < rev) list.push(rev);128+ }129+ }130+131+ /** True when a raster drawn at `drawn` still shows revision `want`. */132+ valid(key: string, drawn: number, want: number): boolean {133+ if (drawn === want) return true;134+ const list = this.changes.get(key);135+ if (!list) return true;136+ const lo = Math.min(drawn, want) - RECENCY_REVS;137+ const hi = Math.max(drawn, want);138+ // first index with rev > lo139+ let i = 0, j = list.length;140+ while (i < j) {141+ const m = (i + j) >> 1;142+ if (list[m] > lo) j = m;143+ else i = m + 1;144+ }145+ return !(i < list.length && list[i] <= hi);146+ }147+148+ /** Revisions that touched a key (for tests and the timeline). */149+ revisions(key: string): number[] {150+ return this.changes.get(key) ?? [];151+ }152+153+ size(): number {154+ return this.changes.size;155+ }156+}157+158+/** A cache of rasters keyed by map tile, each stamped with the revision it159+ * was drawn at. The image type is generic so the logic is testable. */160+export class TileCache<T> {161+ private entries = new Map<string, { img: T; rev: number }>();162+ constructor(readonly limit = 1024) {}163+164+ /** `key` is the cache key; `geomKey` what the change log is keyed by. */165+ get(166+ key: string,167+ geomKey: string,168+ want: number,169+ log: ChangeLog,170+ ): T | undefined {171+ const e = this.entries.get(key);172+ if (!e) return undefined;173+ if (!log.valid(geomKey, e.rev, want)) return undefined;174+ // refresh insertion order (LRU)175+ this.entries.delete(key);176+ this.entries.set(key, e);177+ return e.img;178+ }179+180+ set(key: string, img: T, rev: number) {181+ this.entries.delete(key);182+ this.entries.set(key, { img, rev });183+ while (this.entries.size > this.limit) {184+ const oldest = this.entries.keys().next().value as string;185+ this.entries.delete(oldest);186+ }187+ }188+189+ clear() {190+ this.entries.clear();191+ }192+193+ size(): number {194+ return this.entries.size;195+ }196+}197+198+/** Screen rectangle of a map tile at the view's continuous scale. */199+export function tileScreenRect(200+ t: MapTile,201+ view: View,202+): { x: number; y: number; size: number } {203+ const px = cellsPerTile(t.zoom) * view.scale;204+ return { x: view.ox + t.tx * px, y: view.oy + t.ty * px, size: px };205+}206+207+/** Cell range (inclusive x0..x1, y0..y1) a map tile covers, clipped to G. */208+export function tileCellRange(209+ t: MapTile,210+ G: number,211+): { x0: number; y0: number; x1: number; y1: number; cpt: number } {212+ const cpt = cellsPerTile(t.zoom);213+ const x0 = Math.floor(t.tx * cpt), y0 = Math.floor(t.ty * cpt);214+ const x1 = Math.min(G - 1, Math.ceil((t.tx + 1) * cpt) - 1);215+ const y1 = Math.min(G - 1, Math.ceil((t.ty + 1) * cpt) - 1);216+ return { x0, y0, x1, y1, cpt };217+}
A site/lib/viewer/timeline.ts +118 -0
@@ -0,0 +1,118 @@
1+// The replay timeline: an event-density strip and markers derived from2+// the event list (pure, so live and replay agree). Markers: an agent3+// joining, a tile solved, a meeting, a guess run (three or more4+// consecutive commits with more guesses than forced cells), the end.5+6+import type { GameEvent } from "../fold.ts";7+8+export type MarkerKind = "join" | "solved" | "meet" | "guess-run" | "end";9+10+export type Marker = {11+ rev: number;12+ kind: MarkerKind;13+ agent?: string;14+ tiles?: number[];15+ label: string;16+};17+18+export const GUESS_RUN = 3;19+20+export function markers(events: GameEvent[]): Marker[] {21+ const out: Marker[] = [];22+ let run = 0;23+ let runStart = -1;24+ for (const ev of events) {25+ if (ev.kind === "join") {26+ out.push({27+ rev: ev.rev,28+ kind: "join",29+ agent: ev.agent,30+ label: `${ev.name ?? ev.agent} joins at #${ev.seed_tile}`,31+ });32+ continue;33+ }34+ if (ev.kind === "end") {35+ out.push({ rev: ev.rev, kind: "end", label: "end" });36+ continue;37+ }38+ if (ev.kind !== "batch") continue;39+ if (ev.op === "commit") {40+ let forced = 0, guess = 0;41+ for (const c of ev.accepted) {42+ if (c.class === "forced") forced++;43+ else guess++;44+ }45+ if (guess > forced && guess > 0) {46+ if (run === 0) runStart = ev.rev;47+ run++;48+ if (run === GUESS_RUN) {49+ out.push({50+ rev: runStart,51+ kind: "guess-run",52+ agent: ev.agent,53+ label: `guess run from #${runStart}`,54+ });55+ }56+ } else {57+ run = 0;58+ }59+ if (ev.solved.length) {60+ out.push({61+ rev: ev.rev,62+ kind: "solved",63+ agent: ev.agent,64+ tiles: ev.solved,65+ label: `solved ${ev.solved.map((t) => `#${t}`).join(" ")}`,66+ });67+ }68+ for (const m of ev.meets ?? []) {69+ out.push({70+ rev: ev.rev,71+ kind: "meet",72+ agent: ev.agent,73+ tiles: [m.tile],74+ label: `${ev.agent ?? "?"} meets ${m.with} at #${m.tile}`,75+ });76+ }77+ } else {78+ run = 0;79+ }80+ }81+ return out;82+}83+84+/**85+ * Activity per bin over the revisions: cells that became known (reveals86+ * and accepted commits) plus retractions, so the strip shows where the87+ * game moved. Returns `bins` values normalised to 0..1.88+ */89+export function density(events: GameEvent[], bins: number): number[] {90+ const n = Math.max(1, events.length);91+ const raw = new Array(bins).fill(0);92+ for (const ev of events) {93+ if (ev.kind !== "batch") continue;94+ let w = 0;95+ if (ev.op === "reveal") w = ev.givens.length + ev.seam.length;96+ else if (ev.op === "commit") w = ev.accepted.length + ev.rejected.length;97+ else w = ev.cells.length;98+ const b = Math.min(bins - 1, Math.floor((ev.rev / n) * bins));99+ raw[b] += w;100+ }101+ const max = Math.max(1, ...raw);102+ return raw.map((v) => v / max);103+}104+105+/** Playback speeds in revisions per second; `max` steps every frame. */106+export const SPEEDS = [1, 4, 16, "max"] as const;107+export type Speed = (typeof SPEEDS)[number];108+109+/** Revisions to advance per frame at a speed and frame interval. */110+export function stepFor(speed: Speed, dtMs: number, carry: number): {111+ steps: number;112+ carry: number;113+} {114+ if (speed === "max") return { steps: 1e9, carry: 0 };115+ const acc = carry + (speed * dtMs) / 1000;116+ const steps = Math.floor(acc);117+ return { steps, carry: acc - steps };118+}
A site/tests/viewer_test.ts +280 -0
@@ -0,0 +1,280 @@
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.7+import { assert, assertEquals } from "@std/assert";8+import {9+ type AgentInfo,10+ buildGeometry,11+ type CommitEvent,12+ type GameEvent,13+ type JoinEvent,14+ type ManifestEvent,15+ type RevealEvent,16+} from "../lib/fold.ts";17+import {18+ cellsPerTile,19+ ChangeLog,20+ keysForCells,21+ levelFor,22+ RECENCY_REVS,23+ TILE_PX,24+ TileCache,25+ tileCellRange,26+ tilesForView,27+ zoomFor,28+} from "../lib/viewer/tilemap.ts";29+import { agentHues } from "../lib/viewer/hues.ts";30+import { density, markers, stepFor } from "../lib/viewer/timeline.ts";31+import { scatterLayout, synthesise, withSeeds } from "./fixture.ts";32+33+Deno.test("level of detail by view scale, raster zoom never upscales", () => {34+ assertEquals(levelFor(1), 0);35+ assertEquals(levelFor(2.9), 0);36+ assertEquals(levelFor(3), 1);37+ assertEquals(levelFor(11.9), 1);38+ assertEquals(levelFor(12), 2);39+ assertEquals(levelFor(29.9), 2);40+ assertEquals(levelFor(30), 3);41+ assertEquals(zoomFor(0.1), 0.25);42+ assertEquals(zoomFor(1), 1);43+ assertEquals(zoomFor(1.01), 2);44+ assertEquals(zoomFor(10.8), 16);45+ assertEquals(zoomFor(100), 64);46+ for (const s of [0.3, 1, 5, 13, 40]) assert(zoomFor(s) >= s || s > 64);47+});48+49+Deno.test("map tiles: keying, ranges and the visible set", () => {50+ const G = 100;51+ // a 400x300 viewport at 4 px/cell, field origin at (0,0)52+ const tiles = tilesForView(G, { scale: 4, ox: 0, oy: 0 }, 400, 300);53+ assertEquals(tiles[0].zoom, 4);54+ assertEquals(tiles[0].level, 1);55+ assertEquals(cellsPerTile(4), 64);56+ // 64 cells * 4 px = 256 px per map tile: 2 across, 2 down57+ assertEquals(tiles.length, 4);58+ assertEquals(tiles.map((t) => t.key), ["4:0:0", "4:1:0", "4:0:1", "4:1:1"]);59+ assertEquals(tiles[3].cacheKey, "4:1:1|L1");60+ const r = tileCellRange(tiles[3], G);61+ assertEquals([r.x0, r.y0, r.x1, r.y1], [64, 64, 99, 99]);62+ // panned so only the last map tile shows63+ const one = tilesForView(G, { scale: 4, ox: -300, oy: -300 }, 100, 100);64+ assertEquals(one.map((t) => t.key), ["4:1:1"]);65+ // nothing outside the field66+ const none = tilesForView(G, { scale: 4, ox: -1000, oy: -1000 }, 100, 100);67+ assertEquals(none.length, 0);68+ // at 0.25 px/cell one map tile covers 1024 cells: the whole field69+ assertEquals(tilesForView(G, { scale: 0.2, ox: 0, oy: 0 }, 50, 50).length, 1);70+ assertEquals(TILE_PX, 256);71+});72+73+Deno.test("keys for cells and the change log across revisions", () => {74+ const G = 100;75+ const keys = keysForCells(G, [0, 1, 99 * G + 99], [4]);76+ assertEquals([...keys].sort(), ["4:0:0", "4:1:1"]);77+ const log = new ChangeLog();78+ log.add(5, ["4:0:0"]);79+ log.add(20, ["4:0:0"]);80+ log.add(20, ["4:1:0"]);81+ // same revision: always valid82+ assert(log.valid("4:0:0", 7, 7));83+ // no change between 6 and 19 except the recency tail of rev 584+ assert(!log.valid("4:0:0", 6, 10), "the ripple of rev 5 still fades");85+ assert(log.valid("4:0:0", 5 + RECENCY_REVS, 19));86+ // a change at 20 invalidates 19 -> 21 and 21 -> 19 (scrubbing back)87+ assert(!log.valid("4:0:0", 19, 21));88+ assert(!log.valid("4:0:0", 21, 19));89+ // untouched key is valid at any pair90+ assert(log.valid("4:3:3", 0, 1000));91+ assertEquals(log.revisions("4:0:0"), [5, 20]);92+ assertEquals(log.size(), 2);93+});94+95+Deno.test("raster cache: validity through the log, LRU bound", () => {96+ const log = new ChangeLog();97+ const cache = new TileCache<string>(2);98+ cache.set("4:0:0|L1", "a", 10);99+ assertEquals(cache.get("4:0:0|L1", "4:0:0", 10, log), "a");100+ assertEquals(cache.get("4:0:0|L1", "4:0:0", 12, log), "a");101+ log.add(11, ["4:0:0"]);102+ assertEquals(cache.get("4:0:0|L1", "4:0:0", 12, log), undefined);103+ cache.set("4:1:0|L1", "b", 12);104+ cache.set("4:2:0|L1", "c", 12);105+ assertEquals(cache.size(), 2, "bounded");106+ assertEquals(cache.get("4:0:0|L1", "4:0:0", 12, log), undefined, "evicted");107+ // different level of the same geometry is a separate raster108+ cache.set("4:1:0|L2", "b2", 12);109+ assertEquals(cache.get("4:1:0|L2", "4:1:0", 12, log), "b2");110+});111+112+Deno.test("agent hues are stable and neighbours start distinct", () => {113+ const L = withSeeds(scatterLayout("hues", 120), 6);114+ const out = synthesise(L, "hues");115+ const geo = buildGeometry(out.manifest);116+ const seeds = out.manifest.seed_tiles ?? [];117+ const agent = (i: number, seed: number): AgentInfo => ({118+ id: `a${i}`,119+ name: `agent ${i}`,120+ harness: "test",121+ model: null,122+ tags: {},123+ joined: i,124+ seed_tile: seed,125+ group: `a${i}`,126+ });127+ const agents = seeds.map((s, i) => agent(i, s));128+ const h1 = agentHues(agents, geo);129+ // joining more agents never changes earlier hues130+ const more = [...agents, agent(99, seeds[0])];131+ const h2 = agentHues(more, geo);132+ for (const a of agents) assertEquals(h2.get(a.id), h1.get(a.id));133+ // two agents whose seed tiles overlap get different hues134+ const pair = [agent(0, seeds[0]), agent(1, geo.adjacency[seeds[0]][0])];135+ const h3 = agentHues(pair, geo);136+ assert(h3.get("a0") !== h3.get("a1"));137+ // deterministic138+ assertEquals([...agentHues(agents, geo)], [...h1]);139+});140+141+function fakeEvents(): GameEvent[] {142+ const L = withSeeds(scatterLayout("tl", 60), 2);143+ const out = synthesise(L, "tl");144+ const t0 = "2026-08-26T00:00:00.000Z";145+ const evs: GameEvent[] = [];146+ const manifest: ManifestEvent = {147+ rev: 0,148+ t: t0,149+ kind: "manifest",150+ game_id: "g",151+ manifest: out.manifest,152+ rules: { guess: "accept", depth: 0 },153+ };154+ evs.push(manifest);155+ const join = (rev: number, agent: string, seed: number): JoinEvent => ({156+ rev,157+ t: t0,158+ kind: "join",159+ agent,160+ name: agent,161+ harness: "test",162+ seed_tile: seed,163+ });164+ evs.push(join(1, "alice", out.manifest.seed_tiles![0]));165+ evs.push(join(2, "bob", out.manifest.seed_tiles![1]));166+ const reveal = (rev: number, agent: string, tile: number): RevealEvent => ({167+ rev,168+ t: t0,169+ kind: "batch",170+ op: "reveal",171+ agent,172+ tile,173+ givens: [[1, 0], [2, 1]],174+ seam: [],175+ intent: "",176+ });177+ const commit = (178+ rev: number,179+ agent: string,180+ forced: number,181+ guess: number,182+ solved: number[] = [],183+ meets: { with: string; tile: number }[] = [],184+ ): CommitEvent => ({185+ rev,186+ t: t0,187+ kind: "batch",188+ op: "commit",189+ agent,190+ intent: "",191+ accepted: [192+ ...Array.from({ length: forced }, (_, i) => ({193+ id: i,194+ v: 0 as const,195+ class: "forced" as const,196+ rules: ["run"],197+ })),198+ ...Array.from({ length: guess }, (_, i) => ({199+ id: 100 + i,200+ v: 1 as const,201+ class: "guess" as const,202+ rules: [],203+ })),204+ ],205+ rejected: [],206+ ignored: [],207+ solved,208+ meets,209+ judge_ms: 1,210+ });211+ evs.push(reveal(3, "alice", 0));212+ evs.push(commit(4, "alice", 5, 1));213+ evs.push(commit(5, "alice", 1, 4)); // guess-heavy 1214+ evs.push(commit(6, "alice", 0, 3)); // 2215+ evs.push(commit(7, "alice", 1, 2, [0])); // 3 -> run marker at 5, solved216+ evs.push(commit(8, "bob", 3, 0, [1], [{ with: "alice", tile: 1 }]));217+ evs.push({ rev: 9, t: t0, kind: "end" });218+ return evs;219+}220+221+Deno.test("timeline markers and density", () => {222+ const evs = fakeEvents();223+ const ms = markers(evs);224+ const kinds = ms.map((m) => `${m.kind}@${m.rev}`);225+ assertEquals(kinds, [226+ "join@1",227+ "join@2",228+ "guess-run@5",229+ "solved@7",230+ "solved@8",231+ "meet@8",232+ "end@9",233+ ]);234+ assertEquals(235+ ms.find((m) => m.kind === "meet")!.label,236+ "bob meets alice at #1",237+ );238+ const d = density(evs, 5);239+ assertEquals(d.length, 5);240+ assertEquals(Math.max(...d), 1);241+ assertEquals(d[0], 0, "the manifest and joins are not activity");242+ // playback stepping243+ assertEquals(stepFor(4, 250, 0), { steps: 1, carry: 0 });244+ assertEquals(stepFor(1, 100, 0.95).steps, 1);245+ assertEquals(stepFor("max", 16, 0).steps, 1e9);246+});247+248+Deno.test("a daily-sized field needs few map tiles per view and per event", () => {249+ const L = withSeeds(scatterLayout("daily", 530, 0.7), 9);250+ const out = synthesise(L, "daily");251+ const geo = buildGeometry(out.manifest);252+ assert(253+ out.manifest.tiles.length > 1500,254+ `tiles ${out.manifest.tiles.length}`,255+ );256+ const G = out.manifest.G;257+ // overview: the whole field in one viewport at ~1.3 px/cell -> zoom 2,258+ // 128 cells per map tile259+ const fit = 700 / G;260+ const over = tilesForView(G, { scale: fit, ox: 0, oy: 0 }, 1200, 700);261+ assert(over.length <= 30, `overview map tiles ${over.length}`);262+ assertEquals(over[0].level, 0);263+ // detail: 16 px/cell, a 1200x700 viewport264+ const detail = tilesForView(265+ G,266+ { scale: 16, ox: -4000, oy: -4000 },267+ 1200,268+ 700,269+ );270+ assert(detail.length <= 30, `detail map tiles ${detail.length}`);271+ assertEquals(detail[0].level, 2);272+ // an event on one tile dirties few map tiles at every zoom273+ const cells = geo.cellsOf[out.manifest.tiles[100].id];274+ const keys = keysForCells(G, cells);275+ assert(keys.size <= 9 * 8, `dirty keys ${keys.size}`);276+ const log = new ChangeLog();277+ log.add(1, keys);278+ // a map tile far away stays valid279+ assert(log.valid("16:0:0", 0, 1));280+});