clone

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

viewer: cluster-sized fit on a large field, growth mid-log, fog wash

dami committed bcfdf76
The daily field is G=1000 with a cluster at the centre that grows: the
fit frames the generated tiles' bounding box, an Occupancy per geometry
skips map tiles that hold no tile (never rasterised or iterated), the
change log is per game rather than per geometry so a grow event dirties
exactly the map tiles its new tiles touch (cellsOfEvent reads them from
the event), the Scene keeps its derived data per geometry so scrubbing
across a grow does not rebuild, hues once shown are fixed across
growth, the timeline gets a grow chevron, and unreached tiles draw as a
fog wash at L0/L1 and a faint outline only at L2+; the sidebar reports
reached/grown. Tests: fit and occupancy on a G=1000 field, growth
dirtying/marker/hue stability, growth folded and scrubbed back.
commit
bcfdf766e4b91f952f7662b20eb6c015dab1c34c
parent
483723c3b5f767a537afee7143422c56e746e2a1
author
dami <gazey55@gmail.com>
6 files changed, +538 -63
Msite/islands/Viewer.tsx194+++++++++++++++++++---------
Msite/lib/colour.ts13++
Msite/lib/viewer/hues.ts14++-
Msite/lib/viewer/tilemap.ts144++++++++++++++++++++
Msite/lib/viewer/timeline.ts24+++-
Msite/tests/viewer_test.ts212++++++++++++++++++++++++++++++
M site/islands/Viewer.tsx +133 -61
@@ -24,6 +24,7 @@ import {
2424 agentColour,2525 cellFill,2626 DARK,27+ fogFill,2728 inkTone,2829 LIGHT,2930 neutralFill,
@@ -31,16 +32,20 @@ import {
3132 type Theme,3233 } from "../lib/colour.ts";3334 import {35+ boxOf,36+ cellsOfEvent,3437 ChangeLog,38+ fitBox,3539 keysForCells,3640 type Level,3741 levelFor,3842 type MapTile,43+ Occupancy,44+ occupiedTilesForView,3945 RECENCY_REVS,4046 TileCache,4147 tileCellRange,4248 tileScreenRect,43- tilesForView,4449 type View,4550 } from "../lib/viewer/tilemap.ts";4651 import { agentHues } from "../lib/viewer/hues.ts";
@@ -62,45 +67,63 @@ const EASE_MS = 300;
6267 // ---------------------------------------------------------------- scene6368 6469 /** Everything a raster needs, derived per state (cheaply) and per geometry. */70+type GeoDerived = {71+ buckets: Map<string, number[]>; // spatial index: tiles by bucket72+ masks: Uint8Array[];73+ occ: Occupancy; // which map tiles hold any tile74+};75+6576 class Scene {6677 geo: Geometry | null = null;6778 theme: Theme = DARK;68- buckets = new Map<string, number[]>(); // spatial index: tiles by bucket79+ buckets = new Map<string, number[]>();6980 masks: Uint8Array[] = [];81+ occ: Occupancy | null = null;7082 fillCache = new Map<string, string>();83+ // per geometry: a grown field has one geometry per growth, and scrubbing84+ // across a grow alternates between them85+ private derived = new WeakMap<Geometry, GeoDerived>();7186 7287 prepare(geo: Geometry, tiles: TileSpec[], theme: Theme) {7388 if (this.geo !== geo) {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;89+ let d = this.derived.get(geo);90+ if (!d) {91+ const buckets = new Map<string, number[]>();92+ const masks = tiles.map((t) => {93+ const m = new Uint8Array(t.w * t.h);94+ for (const [lx, ly, rw, rh] of t.rects) {95+ for (let y = ly; y < ly + rh; y++) {96+ for (let x = lx; x < lx + rw; x++) m[y * t.w + x] = 1;97+ }8098 }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- ) {99+ return m;100+ });101+ for (const t of tiles) {90102 for (91- let bx = Math.floor(t.x0 / BUCKET);92- bx <= Math.floor((t.x0 + t.w - 1) / BUCKET);93- bx++103+ let by = Math.floor(t.y0 / BUCKET);104+ by <= Math.floor((t.y0 + t.h - 1) / BUCKET);105+ by++94106 ) {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]);107+ for (108+ let bx = Math.floor(t.x0 / BUCKET);109+ bx <= Math.floor((t.x0 + t.w - 1) / BUCKET);110+ bx++111+ ) {112+ const k = `${bx}:${by}`;113+ const list = buckets.get(k);114+ if (list) list.push(t.id);115+ else buckets.set(k, [t.id]);116+ }99117 }100118 }119+ d = { buckets, masks, occ: new Occupancy(geo.G, tiles) };120+ this.derived.set(geo, d);101121 }122+ this.buckets = d.buckets;123+ this.masks = d.masks;124+ this.occ = d.occ;102125 }103- if (this.geo !== geo || this.theme !== theme) this.fillCache.clear();126+ if (this.theme !== theme) this.fillCache.clear();104127 this.geo = geo;105128 this.theme = theme;106129 }
@@ -150,8 +173,16 @@ type Frame = {
150173 s: State;151174 tileHue: Int16Array; // per tile: agent hue or -1152175 agentName: Map<string, string>;176+ reached: Uint8Array; // per tile: 1 if some territory has reached it153177 };154178 179+/** `State.reached` once the fold provides it; every tile reached before. */180+function reachedOf(s: State): Uint8Array {181+ const r = (s as unknown as { reached?: Uint8Array }).reached;182+ if (r && r.length === s.tiles.length) return r;183+ return new Uint8Array(s.tiles.length).fill(1);184+}185+155186 function frameOf(s: State, hues: Map<string, number>): Frame {156187 const n = s.tiles.length;157188 const tileHue = new Int16Array(n).fill(-1);
@@ -172,29 +203,13 @@ function frameOf(s: State, hues: Map<string, number>): Frame {
172203 }173204 const agentName = new Map<string, string>();174205 for (const a of s.agents) agentName.set(a.id, a.name || a.id);175- return { s, tileHue, agentName };206+ return { s, tileHue, agentName, reached: reachedOf(s) };176207 }177208 178209 // ---------------------------------------------------------------- raster179210 180211 type Raster = { canvas: HTMLCanvasElement; ppc: number }; // pixels per cell181212 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- }196-}197-198213 function renderMapTile(199214 mt: MapTile,200215 f: Frame,
@@ -230,6 +245,15 @@ function renderMapTile(
230245 }231246 if (out.length > 1) out.sort((p, q) => p - q);232247 };248+ // unreached: every owner is a hidden tile no territory has reached249+ const unreachedCell = (id: number): boolean => {250+ for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {251+ const t = geo.ownerList[i];252+ if (f.reached[t] === 1 || s.tiles[t] !== "hidden") return false;253+ }254+ return true;255+ };256+ const fog = level <= 1 ? fogFill(theme) : null;233257 const hues: number[] = [];234258 const fillOf = (id: number): string | null => {235259 const st = statusOfCell(id);
@@ -238,6 +262,7 @@ function renderMapTile(
238262 if (!c) return null; // a hole239263 return scene.fill([], "solved", true, 0); // ownerless known cell (v2 filler)240264 }265+ if (st === "hidden" && !c && unreachedCell(id)) return fog; // the fog: void at L2+241266 const age = c ? s.revision - c.rev : RECENCY_REVS;242267 const recency = c && age < RECENCY_REVS ? 1 - age / RECENCY_REVS : 0;243268 huesOfCell(id, hues);
@@ -291,7 +316,18 @@ function renderMapTile(
291316 ctx.lineCap = "round";292317 for (const id of tiles) {293318 const st = s.tiles[id];294- if (st === "hidden") continue;319+ if (st === "hidden") {320+ // at L2+ an unreached tile is only its outline, faint: the shape321+ // of what is there to explore, nothing more322+ if (level >= 2 && f.reached[id] === 0) {323+ const t = s.manifest!.tiles[id];324+ ctx.strokeStyle = theme.muted;325+ ctx.globalAlpha = 0.14;326+ ctx.lineWidth = Math.max(0.6, Math.min(1.2, ppc * 0.04));327+ traceOutline(ctx, t, scene.masks[id], ppc, -cx0 * ppc, -cy0 * ppc);328+ }329+ continue;330+ }295331 const t = s.manifest!.tiles[id];296332 const h = f.tileHue[id];297333 const revealed = st === "revealed";
@@ -525,7 +561,7 @@ function compose(
525561 return { redrawn: 0, visible: 0, level: 0 };526562 }527563 scene.prepare(s.geo, s.manifest.tiles, theme);528- const tiles = tilesForView(s.G, view, W, H);564+ const tiles = occupiedTilesForView(s.G, view, W, H, scene.occ);529565 let redrawn = 0;530566 ctx.imageSmoothingEnabled = true;531567 ctx.imageSmoothingQuality = "high";
@@ -568,9 +604,16 @@ function compose(
568604 return { redrawn, visible: tiles.length, level };569605 }570606 571-function fitView(G: number, W: number, H: number): View {572- const scale = (Math.min(W, H) * 0.94) / G;573- return { scale, ox: (W - G * scale) / 2, oy: (H - G * scale) / 2 };607+/** Frame the generated tiles (the cluster), not the G x G field: a daily608+ * field is G=1000 with a small cluster at the centre that grows. */609+function fitView(s: State, W: number, H: number): View {610+ const box = s.manifest ? boxOf(s.manifest.tiles) : null;611+ if (!box) {612+ const G = Math.max(1, s.G);613+ const scale = (Math.min(W, H) * 0.94) / G;614+ return { scale, ox: (W - G * scale) / 2, oy: (H - G * scale) / 2 };615+ }616+ return fitBox(box, W, H);574617 }575618 576619 // ---------------------------------------------------------------- timeline
@@ -580,6 +623,7 @@ const MARKER_COLOUR: Record<Marker["kind"], string> = {
580623 solved: "#9ece6a",581624 meet: "#f7768e",582625 "guess-run": "#e0af68",626+ grow: "#bb9af7",583627 end: "#8c8c92",584628 };585629
@@ -623,6 +667,14 @@ function Timeline(props: {
623667 ctx.fill();624668 } else if (m.kind === "guess-run") {625669 ctx.fillRect(x - 1, 2, 2, 8);670+ } else if (m.kind === "grow") {671+ // a small chevron: the field opened here672+ ctx.beginPath();673+ ctx.moveTo(x - 4, 10);674+ ctx.lineTo(x, 2);675+ ctx.lineTo(x + 4, 10);676+ ctx.closePath();677+ ctx.fill();626678 } else {627679 ctx.fillRect(x - 0.5, 4, 1, 5);628680 }
@@ -667,7 +719,7 @@ function Timeline(props: {
667719 return (668720 <canvas669721 ref={ref}670- title="timeline: click or drag to scrub; dots are joins (blue) and meetings (red), bars solved tiles (green) and guess runs (amber)"722+ title="timeline: click or drag to scrub; dots are joins (blue) and meetings (red), bars solved tiles (green) and guess runs (amber), chevrons the field growing (violet)"671723 style="flex:1;height:36px;display:block;cursor:pointer"672724 onPointerDown={(e) => {673725 dragging.current = true;
@@ -776,31 +828,42 @@ export default function Viewer(
776828 777829 // hues are assigned from the full agent list, so a scrubbed frame and778830 // the final frame agree779- const hues = useMemo(() => agentHues(final.agents, final.geo), [780- final.agents,781- final.geo,782- ]);831+ // a hue, once shown, never changes: growth may change adjacency, and a832+ // hue reassignment would repaint a whole territory833+ const huesRef = useRef(new Map<string, number>());834+ const hues = useMemo(() => {835+ const h = agentHues(final.agents, final.geo, 3, huesRef.current);836+ huesRef.current = h;837+ return h;838+ }, [final.agents, final.geo]);783839 const frame = useMemo(() => frameOf(state, hues), [state, hues]);784840 785841 // change log: which map tiles each event touched (fed once per event)786842 const fed = useRef(0);787- const geoRef = useRef<Geometry | null>(null);843+ const logKey = useRef<string>("");788844 useEffect(() => {789845 const geo = final.geo;790- if (!geo) return;791- if (geoRef.current !== geo) {792- geoRef.current = geo;846+ if (!geo || !final.manifest) return;847+ // a new game (or a different field size) starts a new log; a grown848+ // geometry of the same game keeps it, the grow event dirties exactly849+ // the map tiles its new tiles touch850+ const key = `${final.manifest.id}|${geo.G}`;851+ if (logKey.current !== key || fed.current > events.length) {852+ logKey.current = key;793853 log.current = new ChangeLog();794854 cache.current.clear();795855 fed.current = 0;796856 }797857 for (let i = fed.current; i < events.length; i++) {798858 const ev = events[i];799- const cells = cellsTouched(ev, geo);859+ const cells = cellsOfEvent(860+ ev as unknown as { kind: string; [k: string]: unknown },861+ geo,862+ );800863 if (cells.length) log.current.add(ev.rev, keysForCells(geo.G, cells));801864 }802865 fed.current = events.length;803- }, [events, final.geo]);866+ }, [events, final.geo, final.manifest]);804867 useEffect(() => {805868 cache.current.clear();806869 }, [theme, hues]);
@@ -809,7 +872,7 @@ export default function Viewer(
809872 useEffect(() => {810873 const c = canvasRef.current;811874 if (!c || !state.manifest || view) return;812- setView(fitView(state.G, c.clientWidth, c.clientHeight));875+ setView(fitView(final, c.clientWidth, c.clientHeight));813876 }, [state.manifest, view]);814877 815878 // draw
@@ -821,7 +884,7 @@ export default function Viewer(
821884 const info = compose(822885 c,823886 frame,824- view ?? fitView(Math.max(1, state.G), c.clientWidth, c.clientHeight),887+ view ?? fitView(final, c.clientWidth, c.clientHeight),825888 scene.current,826889 cache.current,827890 log.current,
@@ -973,7 +1036,7 @@ export default function Viewer(
9731036 e.preventDefault();9741037 const rect = c.getBoundingClientRect();9751038 const mx = e.clientX - rect.left, my = e.clientY - rect.top;976- const fit = fitView(state.G, c.clientWidth, c.clientHeight).scale;1039+ const fit = fitView(final, c.clientWidth, c.clientHeight).scale;9771040 const minS = fit * 0.5, maxS = 64;9781041 const k = Math.exp(-e.deltaY * 0.0015);9791042 const ns = Math.max(minS, Math.min(maxS, v.scale * k));
@@ -1008,6 +1071,9 @@ export default function Viewer(
10081071 const max = Math.max(0, events.length - 1);10091072 const an = state.analysis;10101073 const solved = state.tiles.filter((t) => t === "solved").length;1074+ let reachedCount = 0;1075+ for (const r of frame.reached) reachedCount += r;1076+ const grown = (state as unknown as { grown?: number }).grown ?? 0;10111077 const agentRows = final.agents.map((a: AgentInfo) => {10121078 const s = an.agents.find((x) => x.agent === a.id);10131079 return { a, s, hue: hues.get(a.id) ?? 0 };
@@ -1040,6 +1106,10 @@ export default function Viewer(
10401106 <div>10411107 solved {solved}/{state.tiles.length} tiles, status {state.status}10421108 </div>1109+ <div>1110+ reached {reachedCount} of {state.tiles.length} tiles, grown{" "}1111+ {grown} {grown === 1 ? "time" : "times"}1112+ </div>10431113 <div>10441114 forced {state.totals.forced}, guess {state.totals.guess}, wrong10451115 {" "}
@@ -1215,6 +1285,8 @@ function entryLine(e: State["console"][number]): string {
12151285 }`;12161286 case "retract":12171287 return `retract ${e.retracted}`;1288+ case "grow":1289+ return "the field grows";12181290 case "end":12191291 return "end";12201292 }
M site/lib/colour.ts +13 -0
M site/lib/viewer/hues.ts +13 -1
M site/lib/viewer/tilemap.ts +144 -0
@@ -215,3 +215,147 @@ export function tileCellRange(
215215 const y1 = Math.min(G - 1, Math.ceil((t.ty + 1) * cpt) - 1);216216 return { x0, y0, x1, y1, cpt };217217 }218+219+// ---------------------------------------------------------------- field extent220+221+/** Bounding box (inclusive cells) of a set of tiles; null when empty. */222+export type Box = { x0: number; y0: number; x1: number; y1: number };223+224+export function boxOf(225+ tiles: readonly { x0: number; y0: number; w: number; h: number }[],226+): Box | null {227+ if (tiles.length === 0) return null;228+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;229+ for (const t of tiles) {230+ if (t.x0 < x0) x0 = t.x0;231+ if (t.y0 < y0) y0 = t.y0;232+ if (t.x0 + t.w - 1 > x1) x1 = t.x0 + t.w - 1;233+ if (t.y0 + t.h - 1 > y1) y1 = t.y0 + t.h - 1;234+ }235+ return { x0, y0, x1, y1 };236+}237+238+/** A view that frames a box (the generated tiles, not the field) in a239+ * W x H viewport with a margin. */240+export function fitBox(box: Box, W: number, H: number, margin = 0.94): View {241+ const bw = box.x1 - box.x0 + 1, bh = box.y1 - box.y0 + 1;242+ const scale = Math.min((W * margin) / bw, (H * margin) / bh);243+ return {244+ scale,245+ ox: (W - bw * scale) / 2 - box.x0 * scale,246+ oy: (H - bh * scale) / 2 - box.y0 * scale,247+ };248+}249+250+/**251+ * Which map tiles hold any tile at all, per raster zoom. On a G=1000252+ * field with a cluster at the centre most map tiles are empty space; they253+ * are neither rasterised nor iterated. Built once per geometry.254+ */255+export class Occupancy {256+ private bits = new Map<number, { n: number; set: Uint8Array }>();257+258+ constructor(259+ G: number,260+ tiles: readonly { x0: number; y0: number; w: number; h: number }[],261+ zooms: readonly number[] = ZOOMS,262+ ) {263+ for (const zoom of zooms) {264+ const cpt = cellsPerTile(zoom);265+ const n = Math.ceil(G / cpt);266+ const set = new Uint8Array(n * n);267+ for (const t of tiles) {268+ const tx0 = Math.floor(t.x0 / cpt), ty0 = Math.floor(t.y0 / cpt);269+ const tx1 = Math.floor((t.x0 + t.w - 1) / cpt);270+ const ty1 = Math.floor((t.y0 + t.h - 1) / cpt);271+ for (let ty = ty0; ty <= ty1; ty++) {272+ for (let tx = tx0; tx <= tx1; tx++) set[ty * n + tx] = 1;273+ }274+ }275+ this.bits.set(zoom, { n, set });276+ }277+ }278+279+ has(zoom: number, tx: number, ty: number): boolean {280+ const b = this.bits.get(zoom);281+ if (!b) return true;282+ if (tx < 0 || ty < 0 || tx >= b.n || ty >= b.n) return false;283+ return b.set[ty * b.n + tx] === 1;284+ }285+286+ /** Occupied map tiles at a zoom (for tests and the perf counter). */287+ count(zoom: number): number {288+ const b = this.bits.get(zoom);289+ if (!b) return 0;290+ let c = 0;291+ for (const v of b.set) c += v;292+ return c;293+ }294+}295+296+/** tilesForView restricted to map tiles that hold any tile. */297+export function occupiedTilesForView(298+ G: number,299+ view: View,300+ W: number,301+ H: number,302+ occ: Occupancy | null,303+): MapTile[] {304+ const all = tilesForView(G, view, W, H);305+ if (!occ) return all;306+ return all.filter((t) => occ.has(t.zoom, t.tx, t.ty));307+}308+309+/** Cells covered by tiles given only their shapes (a `grow` event lists310+ * the new tiles before any geometry has them). */311+export function cellsOfTiles(312+ G: number,313+ tiles: readonly {314+ x0: number;315+ y0: number;316+ w: number;317+ h: number;318+ rects: readonly (readonly [number, number, number, number])[];319+ }[],320+): number[] {321+ const out: number[] = [];322+ for (const t of tiles) {323+ const seen = new Set<number>();324+ for (const [lx, ly, rw, rh] of t.rects) {325+ for (let y = ly; y < ly + rh; y++) {326+ for (let x = lx; x < lx + rw; x++) {327+ const id = (t.y0 + y) * G + t.x0 + x;328+ if (!seen.has(id)) {329+ seen.add(id);330+ out.push(id);331+ }332+ }333+ }334+ }335+ }336+ return out;337+}338+339+/**340+ * Cells an event touched, for dirtying map tiles. `geo` is the fullest341+ * geometry known (a reveal of a tile that only exists after a grow still342+ * resolves); a `grow` event's cells come from its own tile list.343+ */344+export function cellsOfEvent(345+ ev: { kind: string; [k: string]: unknown },346+ geo: { G: number; cellsOf: number[][] } | null,347+): number[] {348+ if (ev.kind === "grow") {349+ const tiles = ev.tiles as Parameters<typeof cellsOfTiles>[1] | undefined;350+ return tiles && geo ? cellsOfTiles(geo.G, tiles) : [];351+ }352+ if (!geo || ev.kind !== "batch") return [];353+ const op = ev.op as string;354+ if (op === "reveal") return geo.cellsOf[ev.tile as number] ?? [];355+ if (op === "commit") {356+ const out = (ev.accepted as { id: number }[]).map((c) => c.id);357+ for (const t of ev.solved as number[]) out.push(...(geo.cellsOf[t] ?? []));358+ return out;359+ }360+ return (ev.cells as number[]) ?? [];361+}
M site/lib/viewer/timeline.ts +23 -1
M site/tests/viewer_test.ts +212 -0
@@ -299,3 +299,215 @@ Deno.test("checkpointed fold equals foldAll at every revision, any order", () =>
299299 assertEquals(cp.length, 5);300300 assertEquals(cp.at(4), all[4]);301301 });302+303+// ---------------------------------------------------------------- growth304+305+import {306+ boxOf,307+ cellsOfEvent,308+ cellsOfTiles,309+ fitBox,310+ Occupancy,311+ occupiedTilesForView,312+} from "../lib/viewer/tilemap.ts";313+import type { GrowEvent, TileSpec } from "../lib/fold.ts";314+315+/** A layout translated onto a G=1000 field (the daily: a cluster at the316+ * centre of a large field, cell ids on the full field). */317+function centred(seed: string, size: number, G = 1000) {318+ const L = withSeeds(scatterLayout(seed, size), 3);319+ const off = Math.floor((G - size) / 2);320+ const tiles = L.tiles.map((t) => ({ ...t, x0: t.x0 + off, y0: t.y0 + off }));321+ return { ...L, G, tiles };322+}323+324+Deno.test("a G=1000 field: fit frames the cluster, empty map tiles are skipped", () => {325+ const L = centred("daily1000", 180);326+ const out = synthesise(L, "daily1000");327+ const m = out.manifest;328+ assertEquals(m.G, 1000);329+ const box = boxOf(m.tiles)!;330+ assert(box.x0 >= 400 && box.x1 <= 600, `cluster box ${JSON.stringify(box)}`);331+ // the fit shows the cluster at a scale set by its size, not by G332+ const v = fitBox(box, 1200, 700);333+ assert(v.scale > 3, `scale ${v.scale}`);334+ assert(v.scale * (box.x1 - box.x0 + 1) <= 1200 * 0.94 + 1e-9);335+ // the cluster's centre lands at the viewport centre336+ const cx = v.ox + ((box.x0 + box.x1 + 1) / 2) * v.scale;337+ assert(Math.abs(cx - 600) < 1, `centre ${cx}`);338+ // occupancy: at that scale the visible map tiles are only the ones the339+ // cluster touches, never the empty field around it340+ const occ = new Occupancy(m.G, m.tiles);341+ const all = tilesForView(m.G, v, 1200, 700);342+ const some = occupiedTilesForView(m.G, v, 1200, 700, occ);343+ assert(some.length < all.length, `${some.length} of ${all.length}`);344+ assert(some.length <= 25, `visible map tiles ${some.length}`);345+ for (const t of some) assert(occ.has(t.zoom, t.tx, t.ty));346+ // far from the cluster nothing is occupied at any zoom347+ assert(!occ.has(1, 0, 0));348+ assert(!occ.has(0.25, 3, 3));349+ // at 64 px/cell a map tile is 4 cells; the cluster covers a few thousand350+ // of the 62500 map tiles, not all of them351+ assert(352+ occ.count(64) > 0 && occ.count(64) < 10000,353+ `occupied ${occ.count(64)}`,354+ );355+});356+357+/** A second ring of tiles around a layout, ids continuing. */358+function ring(L: ReturnType<typeof centred>, seed: string, size: number) {359+ const inner = boxOf(L.tiles)!;360+ const R = scatterLayout(seed, size, 0.6);361+ const off = Math.floor((L.G - size) / 2);362+ const tiles: TileSpec[] = [];363+ let id = L.tiles.length;364+ for (const t of R.tiles) {365+ const x0 = t.x0 + off, y0 = t.y0 + off;366+ // keep tiles outside the inner box (they may overlap its edge tiles)367+ const inside = x0 >= inner.x0 && x0 + t.w - 1 <= inner.x1 &&368+ y0 >= inner.y0 && y0 + t.h - 1 <= inner.y1;369+ if (inside) continue;370+ tiles.push({ ...t, id: id++, x0, y0, hash: `h${id}`, overlaps: [] });371+ }372+ return tiles;373+}374+375+Deno.test("growth: a grow event dirties only the map tiles its tiles touch, marks the timeline, keeps hues", () => {376+ const L = centred("grow1000", 150);377+ const out = synthesise(L, "grow1000");378+ const m = out.manifest;379+ const added = ring(L, "grow-ring", 300);380+ assert(added.length > 20, `ring tiles ${added.length}`);381+ const t0 = "2026-08-26T00:00:00.000Z";382+ const grow: GrowEvent = {383+ rev: 5,384+ t: t0,385+ kind: "grow",386+ ring: 1,387+ tiles: added,388+ seed_tiles: [added[0].id],389+ };390+ // cells of the new tiles come from the event itself, no geometry needed391+ const geo0 = buildGeometry(m);392+ const cells = cellsOfEvent(grow, geo0);393+ assertEquals(cells.length, cellsOfTiles(m.G, added).length);394+ assert(cells.length >= added.reduce((a, t) => a + t.cells, 0) * 0.9);395+ const keys = keysForCells(m.G, cells);396+ const log = new ChangeLog();397+ log.add(5, keys);398+ // a map tile over the untouched centre stays valid across the grow;399+ // one over the ring does not400+ const inner = boxOf(m.tiles)!;401+ const cx = Math.floor((inner.x0 + inner.x1) / 2),402+ cy = Math.floor((inner.y0 + inner.y1) / 2);403+ const centreKey = [...keysForCells(m.G, [cy * m.G + cx], [16])][0];404+ assert(log.valid(centreKey, 4, 6), "centre map tile untouched by growth");405+ const ringKey = [...keysForCells(m.G, [cells[0]], [16])][0];406+ assert(!log.valid(ringKey, 4, 6), "ring map tile dirtied by growth");407+ // an occupancy of the grown geometry covers strictly more map tiles408+ const grown = { ...m, tiles: [...m.tiles, ...added] };409+ const occ0 = new Occupancy(m.G, m.tiles),410+ occ1 = new Occupancy(m.G, grown.tiles);411+ assert(occ1.count(4) > occ0.count(4));412+ // timeline marker413+ const evs: GameEvent[] = [414+ {415+ rev: 0,416+ t: t0,417+ kind: "manifest",418+ game_id: "g",419+ manifest: m,420+ rules: { guess: "accept", depth: 0 },421+ },422+ grow,423+ { rev: 6, t: t0, kind: "end" },424+ ];425+ const ms = markers(evs);426+ assertEquals(ms.map((x) => `${x.kind}@${x.rev}`), ["grow@5", "end@6"]);427+ assert(ms[0].label.includes(`${added.length} tiles`));428+ // hues: agents assigned on the old geometry keep them on the new one429+ const agents: AgentInfo[] = (m.seed_tiles ?? []).map((s, i) => ({430+ id: `a${i}`,431+ name: `a${i}`,432+ harness: "test",433+ model: null,434+ tags: {},435+ joined: i,436+ seed_tile: s,437+ group: `a${i}`,438+ }));439+ const h0 = agentHues(agents, geo0);440+ const geo1 = buildGeometry(grown);441+ const later = [...agents, {442+ ...agents[0],443+ id: "late",444+ name: "late",445+ joined: 9,446+ seed_tile: added[0].id,447+ group: "late",448+ }];449+ const h1 = agentHues(later, geo1, 3, h0);450+ for (const a of agents) assertEquals(h1.get(a.id), h0.get(a.id));451+ assert(h1.has("late"));452+});453+454+Deno.test("growth folded: tiles appear at the grow revision and vanish when scrubbed back", () => {455+ const L = centred("fold1000", 120);456+ const out = synthesise(L, "fold1000");457+ const m = out.manifest;458+ const added = ring(L, "fold-ring", 240);459+ const t0 = "2026-08-26T00:00:00.000Z";460+ const evs: GameEvent[] = [461+ {462+ rev: 0,463+ t: t0,464+ kind: "manifest",465+ game_id: "g",466+ manifest: m,467+ rules: { guess: "accept", depth: 0 },468+ },469+ {470+ rev: 1,471+ t: t0,472+ kind: "join",473+ agent: "a0",474+ name: "a0",475+ harness: "test",476+ seed_tile: m.seed_tiles![0],477+ },478+ {479+ rev: 2,480+ t: t0,481+ kind: "grow",482+ ring: 1,483+ tiles: added,484+ seed_tiles: [added[0].id],485+ },486+ { rev: 3, t: t0, kind: "end" },487+ ];488+ const all = foldAll(evs);489+ assertEquals(all[1].tiles.length, m.tiles.length);490+ assertEquals(all[2].tiles.length, m.tiles.length + added.length);491+ assert(all[2].geo !== all[1].geo, "a grow yields a new geometry");492+ assertEquals(all[2].geo!.cellsOf.length, m.tiles.length + added.length);493+ // the checkpointed fold agrees in any scrub order across the grow494+ const cp = new Checkpoints(2);495+ cp.feed(evs);496+ for (const r of [3, 1, 2, 0, 2, 1]) {497+ assertEquals(cp.at(r).tiles.length, all[r].tiles.length, `rev ${r}`);498+ assertEquals(499+ cp.at(r).geo === all[r].geo ||500+ cp.at(r).geo!.cellsOf.length === all[r].geo!.cellsOf.length,501+ true,502+ );503+ }504+ // reached: before anyone plays, only the agent's seed tile and what it505+ // overlaps are reached; the ring is unreached506+ const s2 = all[2] as unknown as { reached?: Uint8Array };507+ if (s2.reached) {508+ let reached = 0;509+ for (const r of s2.reached) reached += r;510+ assert(reached < m.tiles.length, `reached ${reached}`);511+ for (const t of added) assertEquals(s2.reached[t.id], 0);512+ }513+});