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>
151 lines
8.3 KiB
JavaScript
151 lines
8.3 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;
|
||
}
|
||
|
||
// 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 };
|
||
},
|
||
};
|
||
}
|