PROCITY/web/js/world/chunks.js
m3ultra 2f4ab5912f Lane B R26 (v5.0-beta): the stocked shopfront reads from the footpath
Ledger #5 — B's polish pick. A stocked godverse shop now tells you it's
diggable before you reach the door: a crate of seeded spines under the shop
name, and a warm-lamp-over-crates room behind the glass.

Cost: +0 draws / +0 tris. The mark is painted into the chunk's EXISTING
sign-atlas cell; the window cue rides the EXISTING per-shop aTint attribute.
Nothing new is allocated or submitted.

The manifest is the authority, deliberately NOT the godverseShopId key: keyed
means "has a stock identity", the manifest means "a crate is actually here".
Marking keyed-but-unstocked shops would advertise a crate the shop hasn't got
— the street would lie. The mark does not leak tier: "you can dig here" is
equally true of real and mint; provenance belongs on C's price card and in E's
gates. (Second job for ledger #6's manifest, free.)

Measured — true A/B at one pose, manifest 200 vs 404:
  spine-marked sign cells   0 → 3 (18,768 exact-palette px)
  stocked window tint verts 0 → 36
  street_noon               65 draws / 115,502 tris → UNCHANGED
Stocked shopfront (Goulds Books, footpath): 85 draws / 144,730 tris — inside
street law (<=300 / <=200k). Cues agree with ground truth: 3 stocked shops
live → 3 marked cells, 9 stocked window quads.

Covenants re-proved (the prefetch is new code on the boot path), tested with
the manifest present on disk so a stray fetch would 200 and be unmissable:
  ?classic=1        0 keyed, 0 stock fetches, 0 errors  (zero-fetch covenant)
  default synthetic 0 keyed, 0 stock fetches, 0 errors
  manifest 404      18 keyed, fail-soft, 0 page errors

Three traps, self-caught: (1) a module-load fetch would have put a fetch delta
on ?classic=1 — restructured to a lazy reader + plan-driven prefetch, keyed
plans only; (2) the lazy trigger alone raced the first chunk build, so the
first stocked shop you approach built unmarked — prefetch moved to
createChunkManager, before any chunk builds; (3) the first "zero cost" was
VACUOUS (manifest never fetched, so nothing was applied) and the first pixel
detector reported 60 marked cells with no treatment at all — replaced with an
exact-palette signature that reads 0 in control. Vacuous-gate law applies to a
lane's own verification too. Corollary: force-cache serves a deleted file
(transferSize 0, status 200 while disk 404s) — poison the cache or you are
testing your own memory.

B consumes stock_godverse/index.json read-only and emits nothing into it; the
temp manifest used to prove this round was deleted — the directory is G's.
Shape-tolerant + fail-soft, so G's real emission needs no coordination with B.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:49:54 +10:00

137 lines
5.3 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
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()
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).
// Inert unless a consumer sets ctx.onChunkBuilt; citizens currently drive off plan.streets instead.
if (ctx.onChunkBuilt) ctx.onChunkBuilt(key, { cx, cz, buildings: buildings.group, furniture: furniture.group, data });
}
function disposeChunk(key) {
const b = live.get(key);
if (!b) return;
if (ctx.onChunkDisposed) ctx.onChunkDisposed(key, { cx: b.cx, cz: b.cz }); // before the groups are freed
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,
getDoorMeshes: () => doorMeshes,
get count() { return live.size; },
get pending() { return queue.length; },
get lastBuildMs() { return lastBuildMs; },
};
}