PROCITY/web/js/world/discovery.js
m3ultra 0ee7a84710 Lane B R39 39.2: THE FOG — the map shows what you walked past, and is finally readable
minimap.js drew every lot in the plan from the first frame (493 shops on the synthetic, one M
press, against 5,330 m / 4.8 game-days of walking to earn it). It now draws what has been walked
past AND FRAMES ITSELF TO IT — a shop lot goes 0.83x1.11 px -> 29.4x39.2 px on bowral_real, 35.3x.

ZERO DRAWS, measured as an A/B: two port-isolated no-store servers, HEAD vs treatment, the same 15
stations x 4 yaws compared as JSON before either sweep runs. Draw calls IDENTICAL on 60/60 samples
at noon and at night; with the gig layer off both sides (the venue queue's async rigs were the only
noise) NIGHT is identical in draws AND triangles. Map mode cannot cost a draw structurally: the
frame loop never calls composer.render() in the map branch.

- discovery.js (new): a 25 m PROXIMITY PROBE, not onChunkBuilt (R=2 => the outermost live chunk
  corner is 181 m out), on a plan-derived spatial hash — 1.09-1.37 us a probe, every 6th street
  frame = every 0.46 m at WALK. Plus a side test: you must be in FRONT of the shopfront. Control
  with a red arm: walking the synthetic's two service laneways, radius-only reveals 93 shops, the
  shipped probe 59 — 34 refused through the back wall; on the arcade it refuses 0 of 17.
- save.js: optional `known` block under the delta law, keyed by TOWN (re-derived: 23 caches x 3
  seeds, shop/edge ids and lot geometry all identical; the synthetic alone keys @<seed>). Bounded
  by the plan (max 493 shops / 2,593 edges) at 8,192, 64 towns FIFO, ints only. Whole corpus fully
  learned = 137,547 B (2.6% of quota); 482 B after a 500 m outing. Eight reject arms demonstrated.
- classic BY CONSTRUCTION: createMinimap(plan, null) is the pre-R39 map and the file has no flag
  test at all. ?classic=1 / ?fog=0 / ?game=0 map canvases are hash-identical to HEAD's.
- Lane A's address layer consumed (getTownCache + createAddresses): street names along walked
  streets, deduped; the synthetic gets district labels from the same line. No town-type branch.

FILED, NOT FIXED: index.html:384 (Lane D patronage door points) and :450 (the R32 spawn) use
(-sin ry, -cos ry) — the BACK of the building. buildings.js:410/583-597, dbg.js:53 and minimap.js
all use local +Z. Measured against each lot's own frontEdge the +sin point is 0.00 m from the kerb
and the shipped one 15.25 m the wrong side, 493/493 and 72/72 shops. At the shipped spawn 0 of the
shops within 25 m are in front of you on all four towns tested; at the corrected one, all of them.
Two characters, but it moves the default boot's opening pose mid-round — Fable's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:04:34 +10:00

151 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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@<seed>`.
//
// 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 };
},
};
}