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>
211 lines
11 KiB
JavaScript
211 lines
11 KiB
JavaScript
// 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 };
|
||
},
|
||
};
|
||
}
|