PROCITY/web/js/world/discovery.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

211 lines
11 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;
}
// [Lane B R40 §40.3 — FOG SIGNAGE] createStreetLocator(plan) → "where am I standing?" for the HUD
// and the map caption. The same plan-derived spatial hash the probe uses (edges by padded bbox,
// shopfronts by point), read-only: it answers with IDS — `edgeAt(x,z)` → the street edge under your
// feet (kerb + footpath tolerance, nearest wins at an intersection) and `shopNear(x,z)` → the
// nearest shopfront within `shopRadius` — and the consumer asks Lane A's address layer for the
// words (streetOf / localityOf, LANE_A_NOTES §39: print `label`, never branch on town type).
// Pure plan math, zero draws, no store, no probe side effects: knowing the name of the street you
// are on is signage, not discovery, so it is deliberately NOT keyed to the fog ledger. Classic
// never constructs it (the shell's address block is `!CLASSIC`-gated for signage), so classic's
// HUD/map DOM stays byte-identical by construction.
export function createStreetLocator(plan, { streetPad = FOG_STREET_PAD_M, shopRadius = 28 } = {}) {
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 });
}
const fronts = shopFronts(plan);
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);
}
const R2 = shopRadius * shopRadius; // shopRadius < CELL, so the 3×3 ring always covers it
return {
edgeAt(x, z) {
let best = null, bd = Infinity;
const cx = Math.floor(x / CELL), cz = Math.floor(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 e of c.e) {
const d2 = segDist2(x, z, e.ax, e.az, e.bx, e.bz);
if (d2 <= e.pad2 && d2 < bd) { bd = d2; best = e.id; }
}
}
return best;
},
shopNear(x, z) {
let best = null, bd = R2;
const cx = Math.floor(x / CELL), cz = Math.floor(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) {
const d2 = (x - f.x) ** 2 + (z - f.z) ** 2;
if (d2 <= bd) { bd = d2; best = f.id; }
}
}
return best;
},
};
}
// 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 };
},
};
}