PROCITY/web/js/world/chunks.js
m3ultra 066cc1775b Lane B R40 §40.3 (+E's §40.4 rule): the 307 was fiction, the town speaks its street names
THE ?r=3 BREACH — the carried number was stale by 84 draws. Re-measured with the pin
method of record (stepwalk 2m, 802 stations, fresh headless contexts, no-store server):
default 282/291 (≤300, 9 margin at night) · classic 269 byte-exact to the R38 pin incl.
90,580 tris · ?r=3 382/391, NOT 307. Decision: GATE, not shave — the 91-draw excess is
the R+1 live-chunk window (48 vs 31 chunks) on a per-chunk cost already collapsed to
~1 draw/kind/chunk, so shaving it means cutting default-boot content to legalise a
diagnostic. ?r= above auto now declares PROCITY.budget {draws:420, diagnostic:true} and
console-warns its own law; HUD threshold + DBG.info().budget read it; new
tools/qa/r40_lane_b.py enforces it (386 ≤ 420, and >300 so the exemption is provably
load-bearing).

?classic=1 TOWN SELECTOR (Fable ruled): true by construction, now deliberate and
qa-asserted — 27 options, one fetch (the named POST_V2_EXCEPTION), picks stay classic,
zero draws, 0 errors.

FOG SIGNAGE: three silent surfaces consume A's address layer via new
createStreetLocator(plan) — HUD street row, door tooltip ('Little Paris Cafe ·
Katoomba Street'), fog-map caption. Never branches on town type; classic-gated by
construction (#pc-street absent, tooltip pre-R40-byte).

E's arcade rule applied verbatim (§40.4): roof spans, a spanned roof has no posts;
district.kind keying, explicit ARCADEKIT classic gate + ?arcadekit=0 control.
-34 post instances exactly, lane draws 120→120 (+0). Shot pair vs E's reference.

Goldens 157,647/157,647, 0x5f76e76 unmoved after every wave.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:28:08 +10:00

186 lines
8.5 KiB
JavaScript

// PROCITY Lane B — chunks.js
// The streamer. Around the player it keeps chunks within Chebyshev radius R built and disposes
// anything past R+1, so the world is never rebuilt wholesale. Chunk builds are queued nearest-first
// and drained under a 4ms/frame budget so streaming never hitches. Each chunk aggregates its
// buildings (buildings.js) + furniture (furniture.js); ground/sky/lighting are global.
import { chunkIndex, chunkCoord, chunkKey, parseKey } from './planutil.js';
import { buildChunkBuildings, prefetchStockIndex } from './buildings.js';
import { buildChunkFurniture } from './furniture.js';
const BUILD_BUDGET_MS = 4;
export function createChunkManager(plan, scene, ctx) {
const R = ctx.radius ?? 3;
const DISPOSE_R = R + 1;
const index = chunkIndex(plan);
prefetchStockIndex(plan); // R26 #5: keyed plans only — warm the stock manifest before any chunk builds
// [Lane B R40 §40.4 — THE ARCADE KIT, district plumbing] The builder needs to know which BLOCKS
// sit in an arcade DISTRICT (lots carry `block`, but block→district lives only on the plan).
// Keyed on `district.kind`, NEVER `edge.kind` — the R39 rule: real towns carry 1,102 arcade-KIND
// edges (39 km of ordinary covered footpath) and zero arcade DISTRICTS, so an edge-kind test
// would re-roof half a real town. Derived once per manager, read by buildings.js's awning block.
const kindById = new Map((plan.districts || []).map((d) => [d.id, d.kind]));
ctx.arcadeBlocks = new Set((plan.blocks || [])
.filter((b) => kindById.get(b.district) === 'arcade').map((b) => b.id));
const live = new Map(); // key → { buildings, furniture, colliders, cx, cz }
const doorMeshes = []; // live door meshes (for HUD raycast)
let queue = []; // keys pending build, nearest-first
let queued = new Set();
let lastCx = null, lastCz = null;
let lastBuildMs = 0; // rolling measure for the HUD/notes
const _colliders = []; // scratch, refilled each getColliders()
// ── the per-chunk lifecycle seam (ROUND37 §0.4) ──────────────────────────────────────────────
// The producer below has fired `ctx.onChunkBuilt` / `ctx.onChunkDisposed` since R3 and Lane D's
// sim has carried the matching `onChunkBuilt(key)` / `onChunkDisposed(key)` methods just as long —
// but nothing could ever connect them: `ctx` is the shell's local object and the manager never
// handed it back, so a consumer holding only the manager (which is all `PROCITY.chunks` is) had
// nowhere to attach. Two ways in now, both inert until someone uses them:
// • `chunks.onChunkBuilt(fn)` → returns an unsubscribe. MANY consumers, none clobbering another
// (the single `ctx` slot is last-writer-wins, and the v8 slate has several per-chunk systems).
// • `chunks.ctx.onChunkBuilt = fn` — the original single slot, still first to fire, and now
// reachable exactly as LANE_D_NOTES §35 documents it.
// Zero cost when nobody listens: the detail object is only allocated inside the guard, so a
// subscriber-free boot does the same two truthiness tests it has done since R3 and nothing else.
const builtSubs = [], disposedSubs = [];
const subscribe = (list, fn) => {
if (typeof fn !== 'function') return () => {};
list.push(fn);
return () => { const i = list.indexOf(fn); if (i >= 0) list.splice(i, 1); };
};
// A throwing subscriber must never take the streamer down with it — a half-built chunk leaks its
// groups and the player walks into a void. Report and carry on.
const fire = (fn, key, detail, what) => {
try { fn(key, detail); } catch (err) { console.warn(`[procity] ${what} subscriber threw for ${key}`, err); }
};
const emit = (slot, list, key, detail, what) => {
if (slot) fire(slot, key, detail, what);
for (let i = 0; i < list.length; i++) fire(list[i], key, detail, what);
};
function buildChunk(key) {
if (live.has(key)) return;
const data = index.get(key);
if (!data) return;
const { cx, cz } = parseKey(key);
const t0 = performance.now();
const buildings = buildChunkBuildings(data, ctx);
const furniture = buildChunkFurniture(data, ctx, cx, cz);
lastBuildMs = performance.now() - t0;
scene.add(buildings.group, furniture.group);
if (buildings.doorMesh) doorMeshes.push(buildings.doorMesh);
// player colliders = building/yard rects + any furniture keep-outs (bus shelters)
const colliders = furniture.colliders && furniture.colliders.length
? buildings.colliders.concat(furniture.colliders) : buildings.colliders;
live.set(key, { buildings, furniture, colliders, cx, cz });
// optional lifecycle hook (Lane D/F per-chunk spawning, ambient, LOD — LANE_F_NOTES §8).
if (ctx.onChunkBuilt || builtSubs.length) {
emit(ctx.onChunkBuilt, builtSubs, key,
{ cx, cz, buildings: buildings.group, furniture: furniture.group, data }, 'onChunkBuilt');
}
}
function disposeChunk(key) {
const b = live.get(key);
if (!b) return;
// fires BEFORE the groups are freed, so a consumer can still read what it is losing
if (ctx.onChunkDisposed || disposedSubs.length) {
emit(ctx.onChunkDisposed, disposedSubs, key, { cx: b.cx, cz: b.cz }, 'onChunkDisposed');
}
scene.remove(b.buildings.group, b.furniture.group);
if (b.buildings.doorMesh) {
const i = doorMeshes.indexOf(b.buildings.doorMesh);
if (i >= 0) doorMeshes.splice(i, 1);
}
b.buildings.dispose();
b.furniture.dispose();
live.delete(key);
}
function recompute(pcx, pcz) {
// dispose anything beyond R+1
for (const key of [...live.keys()]) {
const { cx, cz } = parseKey(key);
if (Math.max(Math.abs(cx - pcx), Math.abs(cz - pcz)) > DISPOSE_R) disposeChunk(key);
}
// enqueue desired-but-missing, nearest first
const want = [];
for (let dz = -R; dz <= R; dz++) {
for (let dx = -R; dx <= R; dx++) {
const key = chunkKey(pcx + dx, pcz + dz);
if (index.has(key) && !live.has(key)) want.push({ key, d: dx * dx + dz * dz });
}
}
want.sort((a, b) => a.d - b.d);
queue = want.map((w) => w.key);
queued = new Set(queue);
}
function drainQueue() {
if (!queue.length) return;
const t0 = performance.now();
while (queue.length && performance.now() - t0 < BUILD_BUDGET_MS) {
const key = queue.shift();
queued.delete(key);
buildChunk(key);
}
}
function update(playerPos) {
const pcx = chunkCoord(playerPos.x), pcz = chunkCoord(playerPos.z);
if (pcx !== lastCx || pcz !== lastCz) { lastCx = pcx; lastCz = pcz; recompute(pcx, pcz); }
drainQueue();
}
// Prebuild everything within radius synchronously (used once at spawn so the player never
// starts inside an unbuilt void).
function warmup(playerPos) {
const pcx = chunkCoord(playerPos.x), pcz = chunkCoord(playerPos.z);
lastCx = pcx; lastCz = pcz;
recompute(pcx, pcz);
while (queue.length) { const key = queue.shift(); queued.delete(key); buildChunk(key); }
}
// Colliders from the player's chunk + its 8 neighbours (refills a scratch array; no per-frame alloc).
function getColliders(x, z) {
const pcx = chunkCoord(x), pcz = chunkCoord(z);
_colliders.length = 0;
for (let dz = -1; dz <= 1; dz++) {
for (let dx = -1; dx <= 1; dx++) {
const b = live.get(chunkKey(pcx + dx, pcz + dz));
if (b) for (let i = 0; i < b.colliders.length; i++) _colliders.push(b.colliders[i]);
}
}
return _colliders;
}
function setNight(night) {
ctx.night = night;
for (const b of live.values()) { b.buildings.applyNight(night); b.furniture.applyNight(night); }
}
function dispose() {
for (const key of [...live.keys()]) disposeChunk(key);
queue = []; queued.clear();
}
return {
update, warmup, getColliders, setNight, dispose,
// §0.4 — the lifecycle seam, subscribable. `onChunkBuilt(fn)` → unsubscribe(); `ctx` is handed
// back so the original single-slot form works too. Keys are planutil chunk keys ("cx,cz"),
// which sim.js derives identically (sim.js:52).
onChunkBuilt: (fn) => subscribe(builtSubs, fn),
onChunkDisposed: (fn) => subscribe(disposedSubs, fn),
get subscriberCount() { return builtSubs.length + disposedSubs.length; },
ctx,
getDoorMeshes: () => doorMeshes,
get count() { return live.size; },
get pending() { return queue.length; },
get lastBuildMs() { return lastBuildMs; },
};
}