// PROCITY Lane B — discovery.js [v9 §Layer-2 THE FOG, round 39] // // WHAT YOU HAVE WALKED PAST. The map used to hand over every shop in the plan on the first M press // (minimap.js:52-63, ungated) — 493 shops on the synthetic, and 5,330 m / 9,622 m / 5,797 m of walking // (4.8 / 8.7 / 5.3 game-days at WALK 4.6 m/s against lighting.js's 240 s day) is what earning that // legitimately costs. This module is the ledger of what has actually been earned. // // THREE LAWS, all load-bearing: // // 1. DISCOVERY IS A PROXIMITY PROBE, NOT A STREAMING EVENT. `chunks.onChunkBuilt` looks like the // seam and is not: every real town is BIG_CITY (index.html:151) ⇒ RADIUS 2, so the outermost live // chunk centre is 128 m away and its far corner 181 m. Chunk-built discovery would hand you shops // three blocks away through a wall. We probe by DISTANCE (FOG_RADIUS_M) and, so that a walk down // the back lane does not reveal the shopfronts on the far side of the buildings, by SIDE: you must // be in front of the shopfront plane. (At R=2 anything within 25 m is always in a live chunk, so // the probe never disagrees with what is streamed — it just doesn't depend on it.) // // 2. DISCOVERY IS PLAYER STATE (THE DELTA LAW). It is saved — in the save, next to cash and the // collection — and NEVER on the plan. The world still regenerates from seed with nothing // remembered about it; what is remembered is what the player saw. save.js owns the storage and the // bound (§39, `game.knownStore(fogKey)`); this module owns the geometry of "walked past". // // 3. IT IS KEYED BY TOWN, NOT BY townKey. Measured across all 23 shipped caches at seeds 20261990 / 7 // / 424242: shop ids, edge ids, and every lot's geometry+frontEdge are IDENTICAL across seeds // (only `storeys`/`hours` re-jitter). A re-seed is the same Katoomba with different secrets, so the // map you learned survives it. The synthetic is the stated exception — its shop count itself moves // with the seed (493 / 479 / 465) — so it keys `default@`. // // Pure except for the store it is handed: no THREE, no DOM, no fetch, no plan mutation. It is // constructed ONLY when the game layer is on, so ?classic=1 / ?game=0 never build it at all. export const FOG_RADIUS_M = 25; // "walked past a shopfront" — the probe radius export const FOG_FRONT_M = 0; // ...and on the street side of it (dot with the facade normal) export const FOG_STREET_PAD_M = 4; // "walked this street" — kerb (width/2) + footpath tolerance export const PROBE_EVERY = 6; // frames — the cadence hud.js:337 already raycasts doors on const CELL = 32; // spatial-hash cell (m). > FOG_RADIUS_M so 3×3 cells always cover it. // The fog key. `plansrc`/`town`/`seed` are exactly what the shell already computed for TOWNKEY; the // difference is deliberate and is law 3 above: the real towns drop the seed, the synthetic keeps it. export function fogKeyFor(plansrc, town, seed) { return plansrc === 'osm' ? `osm/${town || 'melbourne'}` : `synthetic/default@${seed >>> 0}`; } // Every shopfront as a point + an outward normal. // // THE SIGN IS buildings.js's, NOT THE SHELL'S, AND THEY DISAGREE. `buildShopfront` puts the facade, // the door and the doorRect at local +Z — `toWorld(lot,0,0,d/2)` = `lot + (sin ry, cos ry)·d/2` // (buildings.js:410-416, :583-597) — and minimap.js's own front-edge tick has always been drawn at // local +Z too. The shell's patronage door points (index.html:384) and cluster spawn (:450) use // `(-sin ry, -cos ry)`, i.e. the BACK of the building. Measured, taking each lot's own front and back // point and asking which is nearer the kerb: the back point wins on **471 of 493** synthetic shops and // **70 of 72** on katoomba_real. My first cut copied the shell's sign and the walk read 4 shops known // / 1,231 in-radius rejections on the synthetic — the fog was hiding the whole town from a player // standing in front of it. Filed for the shell; this module follows the geometry that actually renders. export function shopFronts(plan) { const lots = new Map((plan.lots || []).map((l) => [l.id, l])); const out = []; for (const s of plan.shops || []) { const l = lots.get(s.lot); if (!l) continue; const ry = l.ry || 0, nx = Math.sin(ry), nz = Math.cos(ry); out.push({ id: s.id, x: l.x + nx * (l.d / 2), z: l.z + nz * (l.d / 2), nx, nz }); } return out; } function segDist2(px, pz, ax, az, bx, bz) { const vx = bx - ax, vz = bz - az, wx = px - ax, wz = pz - az; const vv = vx * vx + vz * vz; let t = vv > 0 ? (wx * vx + wz * vz) / vv : 0; t = t < 0 ? 0 : t > 1 ? 1 : t; const dx = px - (ax + vx * t), dz = pz - (az + vz * t); return dx * dx + dz * dz; } // createDiscovery({ plan, known, radius? }) → the probe. // known — the KNOWN STORE (save.js `game.knownStore(fogKey)`): addShop/addEdge/hasShop/hasEdge/ // counts/version. Anything with that shape works (the headless gates pass a plain object). export function createDiscovery({ plan, known, radius = FOG_RADIUS_M, streetPad = FOG_STREET_PAD_M } = {}) { const R2 = radius * radius; const fronts = shopFronts(plan); const nodes = new Map((plan.streets?.nodes || []).map((n) => [n.id, n])); const edges = []; for (const e of plan.streets?.edges || []) { const a = nodes.get(e.a), b = nodes.get(e.b); if (!a || !b) continue; const pad = (e.width || 0) / 2 + streetPad; edges.push({ id: e.id, ax: a.x, az: a.z, bx: b.x, bz: b.z, pad2: pad * pad, pad }); } // spatial hash — shops by their front point, edges by every cell their padded bbox touches const cells = new Map(); const key = (cx, cz) => cx + ',' + cz; const cellOf = (cx, cz) => { const k = key(cx, cz); let c = cells.get(k); if (!c) cells.set(k, c = { s: [], e: [] }); return c; }; for (const f of fronts) cellOf(Math.floor(f.x / CELL), Math.floor(f.z / CELL)).s.push(f); for (const e of edges) { const x0 = Math.floor((Math.min(e.ax, e.bx) - e.pad) / CELL), x1 = Math.floor((Math.max(e.ax, e.bx) + e.pad) / CELL); const z0 = Math.floor((Math.min(e.az, e.bz) - e.pad) / CELL), z1 = Math.floor((Math.max(e.az, e.bz) + e.pad) / CELL); for (let cx = x0; cx <= x1; cx++) for (let cz = z0; cz <= z1; cz++) cellOf(cx, cz).e.push(e); } let frame = 0, probes = 0, rejectedBehind = 0, lastNew = 0; // the front test's falsifiability control: WHICH shops came inside the radius and were refused for // being behind their own facade. `behind.size − (those later learned from the front)` is the count // of shops a radius-only probe would have handed over through a wall. const behind = new Set(); // probe(pos) → number of NEW things learned. Called directly by the gates; the shell goes through // update(), which owns the cadence. function probe(pos) { probes++; let n = 0; const cx = Math.floor(pos.x / CELL), cz = Math.floor(pos.z / CELL); for (let ix = cx - 1; ix <= cx + 1; ix++) for (let iz = cz - 1; iz <= cz + 1; iz++) { const c = cells.get(key(ix, iz)); if (!c) continue; for (const f of c.s) { if (known.hasShop(f.id)) continue; const dx = pos.x - f.x, dz = pos.z - f.z; if (dx * dx + dz * dz > R2) continue; // ...and you must be in FRONT of it. A shopfront seen through its own back wall is not seen. if (dx * f.nx + dz * f.nz <= FOG_FRONT_M) { rejectedBehind++; behind.add(f.id); continue; } if (known.addShop(f.id)) n++; } for (const e of c.e) { if (known.hasEdge(e.id)) continue; if (segDist2(pos.x, pos.z, e.ax, e.az, e.bx, e.bz) > e.pad2) continue; if (known.addEdge(e.id)) n++; } } if (n) lastNew = probes; return n; } return { probe, // the shell's per-street-frame call — same throttle class as hud.js's door raycast update(pos) { frame++; return (frame % PROBE_EVERY === 0) ? probe(pos) : 0; }, // you walked in the door: you know the shop, whatever the geometry says discoverShop(id) { return known.addShop(id) ? 1 : 0; }, get store() { return known; }, get stats() { const c = known.counts(); let behindOnly = 0; for (const id of behind) if (!known.hasShop(id)) behindOnly++; return { ...c, shopsTotal: fronts.length, edgesTotal: edges.length, probes, rejectedBehind, behindShops: behind.size, behindOnly, lastNew, radius, streetPad }; }, }; }