The micro-task, done and verified. In doing it I found why F photographed
empty road: it is not the pose.
1. THE DELIVERABLE — DBG.poseForShopFront(sel, dist = 8)
Poses at a NAMED shop's frontage: door centred, sign band + R26 crate mark in
shot, under the verandah. clusterPose() answers "where is the town's HEART",
which is the wrong question for a named shop — Red Hill's heart doesn't contain
Monster Robot, which is exactly how F got three frames of empty road.
Keyed through ONE shop selector: F's R22/R24 resolver lived inside enterShop(),
so I extracted it rather than grow a second key scheme beside it. A selector bug
resolves the wrong shop AND looks like it worked (F's own first cut proved it),
so the id-namespace law now has exactly one home.
poseForShopFront(31) → Wholefood Cafe (PLAN id) + ambiguous: "31 is
also the godverseShopId of Silky Oak Furnature"
poseForShopFront('g:31') → Silky Oak Furnature (the prefix IS the fence)
poseForShopFront('g:999999') → {error} — never throws, never guesses
Framing proven by projection, not by eye: doorCentreNdcX = 0.0000 exactly, door
+ sign + mark inside the frustum. Derived from buildings.js, not hardcoded: door
is centred on the lot front (off = 0, unlike poseForShop's off = 1.0); sign band
= min(h,FACADE_H)-0.35 ± 0.25 ⇒ y 2.40-2.90; mark = y 2.45-2.52; camY 2.0 sits
below AWNING_Y 2.95, so a ray rising to a target ≤2.9 can never cross it — the
sign is clear BY GEOMETRY at any distance.
2. CORRECTING MY OWN R27 INTEL — I gave F two wrong numbers. I said the mark
"rides high (y≈3.3)" and the vinyl blocks 4% of it. Both wrong: the mark is at
y 2.45-2.52, and I had scanned y 3.0-3.6 — empty awning air above the sign,
which is why it read ~0. Re-measured: the novelty_record disc (top y 2.50)
reaches INTO the mark band and clips ~15% of the strip from EVERY offset;
moving the camera only changes which 15%. "Shoot head-on" survives, but for the
opposite reason to the one I gave.
3. THE FINDING — every real town's shops face away from their street, since R18.
From Musgrave Road (Monster Robot's own frontEdge 1316) the shop is a blank grey
back wall: no sign, no door, no mark. Measured:
redhill_godverse 35 shop lots: 0 facing, 35 backwards (dot = -1.000)
katoomba_real 72 shop lots: 0 facing, 72 backwards (dot = -1.000)
synthetic correct — always has been
dot = -1.000 exactly on every lot in both towns: a convention, not a defect.
Katoomba's street_noon — the bookmark the tours have shot for nine rounds — is
46 draws / 19,680 tris of bare road.
Root cause: CITY_SPEC contradicts itself and each generator implements one half.
CITY_SPEC:71 (lots contract) "World facing = (-sin ry, -cos ry)" ⇒ -Z facade
CITY_SPEC:335 (posters, R15) "the +Z street facade (B's canon)" ⇒ +Z facade
plan_osm.js:443 obeys the first (its comment: "facade (-Z) faces road").
plan.js, buildings.js, venue.js and the R15 posters obey the second. Synthetic —
the town every golden tests — follows B's canon and looks right, which is exactly
why nine rounds of real-town work never caught it. The epoch's own signature
species: a rule meeting a case written after it.
Proven, then REVERTED: one line in A's plan_osm.js (ry = atan2(-nx,-nz)) flips
katoomba 0/72 → 72/72 facing, and turns poseForShopFront's camera from 26m out in
a paddock into 4m from Musgrave Road — the money shot F specified. Not committed:
it is A's file, it moves every real-town plan (goldens re-pin under amendment
law), and a self-contradicting spec is Fable's to rule on, not mine to pick a
side of.
This also re-reads my R22 diagnosis: I recorded "chunk-buildings 348 tris (0.2%)"
in katoomba's busiest block and credited the instancing work. 348 tris is ~29
boxes — that was not efficiency, it was the buildings being ABSENT from frame. I
read the evidence as a win for five rounds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
393 lines
26 KiB
JavaScript
393 lines
26 KiB
JavaScript
// PROCITY Lane B — dbg.js
|
||
// The QA debug harness hook (window.DBG), installed by the shell only with ?dbg=1. It drives Lane
|
||
// F's tools/shots.py + tools/soak.py (LANE_F_NOTES §4) and mirrors 90sDJsim's window.DBG.shot()
|
||
// contract: named camera bookmarks, teleport, time-of-day, scripted shop entry, and a perf/heap
|
||
// readout. Every pose is derived from the LIVE plan so bookmarks work on any seed; the day/night
|
||
// cycle is paused on the first scripted segment change so captures are deterministic.
|
||
//
|
||
// Lives in its own module (not the shell) so it can grow without touching index.html — the shell
|
||
// just calls installDBG(deps) with the systems it owns when ?dbg=1 is present.
|
||
|
||
import { busShelterStops } from './furniture.js';
|
||
|
||
export function installDBG(deps) {
|
||
const { plan, camera, renderer, composer, chunks, lighting, player, hud,
|
||
setMode, getMode, enterShop, interiorMode = null } = deps;
|
||
const CS = 64;
|
||
|
||
// Force the renderer + composer + camera to the live viewport before every harness render, then
|
||
// reset perf counters and render one full composited frame (bloom + output). Headless captures
|
||
// otherwise letterbox: EffectComposer's render targets don't rebuild on the initial resize and the
|
||
// Playwright viewport settles across the first frames (LANE_F_NOTES §9.2). We re-sync every shot
|
||
// (only ~10 of them) so a captured frame always fills the canvas regardless of settle order.
|
||
// Pin all three (renderer drawing buffer, composer render targets, camera aspect) to the live
|
||
// viewport UNCONDITIONALLY each harness render — they settle independently in headless and any
|
||
// change-guard races the settle. It's ~10 shots, so the extra setSize calls are free.
|
||
const syncSize = () => {
|
||
const w = Math.max(1, innerWidth), h = Math.max(1, innerHeight);
|
||
renderer.setSize(w, h, false); // false: don't rewrite canvas CSS (it's 100vw/100vh)
|
||
composer.setSize(w, h);
|
||
if (camera.isPerspectiveCamera) { camera.aspect = w / h; camera.updateProjectionMatrix(); }
|
||
};
|
||
const render = () => { syncSize(); renderer.info.reset(); composer.render(); };
|
||
|
||
const info = () => {
|
||
const r = renderer.info;
|
||
return {
|
||
drawCalls: r.render.calls, tris: r.render.triangles,
|
||
fps: hud && hud.getFps ? hud.getFps() : null,
|
||
heapMB: (performance.memory ? +(performance.memory.usedJSHeapSize / 1048576).toFixed(1) : null),
|
||
geometries: r.memory.geometries, textures: r.memory.textures,
|
||
chunk: `${Math.round(player.position.x / CS)},${Math.round(player.position.z / CS)}`,
|
||
chunks: chunks.count, mode: getMode(),
|
||
};
|
||
};
|
||
|
||
// ── camera pose facing a shop's front ──
|
||
// Front normal = local +Z rotated by lot.ry; the shopfront sits d/2 out from the lot centre, so we
|
||
// stand `dist` m IN FRONT OF THE SHOPFRONT (not the centre — deep lots like the dept anchor would
|
||
// otherwise put the camera inside the facade), offset slightly for a 3/4 angle, looking at it.
|
||
const lotOf = (shop) => plan.lots?.find((l) => l.id === shop.lot);
|
||
const poseForShop = (shop, dist = 10) => {
|
||
const lot = shop && lotOf(shop); if (!lot) return null;
|
||
const ry = lot.ry || 0, fx = Math.sin(ry), fz = Math.cos(ry); // front normal
|
||
const rx = Math.cos(ry), rz = -Math.sin(ry); // right vector
|
||
const half = (lot.d || 8) / 2, off = 1.0; // near dead-on (terraced neighbours flank the frame)
|
||
const frontX = lot.x + fx * half, frontZ = lot.z + fz * half; // shopfront centre
|
||
return {
|
||
pos: [frontX + fx * dist + rx * off, 2.0, frontZ + fz * dist + rz * off],
|
||
look: [frontX, 2.2, frontZ],
|
||
};
|
||
};
|
||
// ── ROUND22 (Lane G's bug): per-town bookmark anchoring ────────────────────────────────────────
|
||
// Every "nearest" resolver below used to sort by distance from the WORLD ORIGIN, which is only the
|
||
// town centre on a SYNTHETIC plan. A real OSM town is projected about its cache's bbox centre while
|
||
// its shops cluster hundreds of metres away, so origin-sorted bookmarks teleported to arbitrary
|
||
// far-flung geometry (measured: the origin pick sits 408 m from the retail heart on katoomba, 626 m
|
||
// on newtown, 371 m on fremantle). Fix: sort about the town's DENSEST SHOP CLUSTER instead.
|
||
//
|
||
// Gated on the REAL-ROADS signal (`edges > 200`, the same primitive the R20 tram fence uses) so
|
||
// SYNTHETIC + the marched fixtures keep the exact origin-centred behaviour they've always had —
|
||
// their bookmarks (and F's release-tour control shot) do not move. Verified: synthetic's anchor is
|
||
// only 117 m out but DOES re-pick, hence the gate rather than an unconditional switch.
|
||
const townAnchor = (() => {
|
||
const CELL = 120; // ~a block; the heaviest bin is the retail heart
|
||
const pts = (plan.shops || []).map(lotOf).filter(Boolean);
|
||
if (!pts.length) return { x: 0, z: 0, n: 0 };
|
||
const bins = new Map();
|
||
for (const p of pts) { const k = `${Math.round(p.x / CELL)},${Math.round(p.z / CELL)}`; if (!bins.has(k)) bins.set(k, []); bins.get(k).push(p); }
|
||
let best = null; for (const arr of bins.values()) if (!best || arr.length > best.length) best = arr;
|
||
return { x: best.reduce((a, p) => a + p.x, 0) / best.length, z: best.reduce((a, p) => a + p.z, 0) / best.length, n: best.length };
|
||
})();
|
||
const REAL_ROADS = (plan.streets?.edges?.length || 0) > 200;
|
||
const REF = REAL_ROADS ? townAnchor : { x: 0, z: 0 }; // synthetic/fixtures → origin, exactly as before
|
||
const dRef = (x, z) => Math.hypot((x || 0) - REF.x, (z || 0) - REF.z);
|
||
|
||
const byDist = () => [...(plan.shops || [])].sort((a, b) => {
|
||
const la = lotOf(a), lb = lotOf(b);
|
||
return dRef(la?.x, la?.z) - dRef(lb?.x, lb?.z);
|
||
});
|
||
// nearest shop matching pred, else nearest shop overall → a pose is always produced
|
||
const pickShop = (pred) => { const s = byDist(); return poseForShop(s.find(pred) || s[0]); };
|
||
|
||
// ── the shop selector: THE ID-NAMESPACE LAW, one implementation ─────────────────────────────
|
||
// [Lane F R22/R24 wrote this inside enterShop(); extracted verbatim in R27 w4 so the frontage
|
||
// pose below obeys the SAME law instead of growing a second, subtly different key scheme. A
|
||
// selector bug that resolves the wrong shop looks like it works — F's first cut proved that —
|
||
// so there is exactly one place to get it right.]
|
||
// resolveShop(116) → by PLAN id
|
||
// resolveShop('g:3962749') → by godverseShopId (the prefix IS the namespace fence)
|
||
// resolveShop('record') → first shop of that registry type
|
||
// resolveShop('The Exchange Hotel') → exact name, else name substring (case-insensitive)
|
||
// resolveShop(shopObject) → passed through
|
||
// Resolution order is most-specific-first so an id-shaped string can never be shadowed by a name.
|
||
// THE TWO ID SPACES COLLIDE: a GODVERSE shop has a plan id (local, renumbered per town, 1..N) and
|
||
// a godverseShopId (the real POS/dealgod id — what the atlas and every G-side query names it by).
|
||
// Monster Robot is plan 8 / godverse 3962749. Silky Oak's godverse id is 31 and another shop's
|
||
// PLAN id is also 31 — so a bare number is genuinely ambiguous. Bare number ⇒ plan id (unchanged,
|
||
// back-compatible); 'g:<n>' ⇒ godverse id, explicitly. When a bare number matches BOTH we say so
|
||
// in the return rather than pick quietly.
|
||
const resolveShop = (sel) => {
|
||
const shops = plan.shops || [];
|
||
let s = null, ambiguousWith = null;
|
||
if (sel && typeof sel === 'object') {
|
||
s = shops.includes(sel) ? sel : (sel.id != null ? shops.find((x) => x.id === sel.id) : null);
|
||
} else if (typeof sel === 'number') {
|
||
s = shops.find((x) => x.id === sel) || null;
|
||
const g = shops.find((x) => x.godverseShopId === sel) || null;
|
||
if (s && g && g !== s) ambiguousWith = { godverseShopId: sel, name: g.name };
|
||
if (!s) s = g;
|
||
} else if (typeof sel === 'string') {
|
||
const q = sel.trim().toLowerCase();
|
||
const gm = /^g(?:odverse)?:(\d+)$/.exec(q);
|
||
if (gm) {
|
||
const n = Number(gm[1]);
|
||
s = shops.find((x) => x.godverseShopId === n) || null;
|
||
if (!s) return { error: `no shop with godverseShopId ${n}` };
|
||
} else {
|
||
s = shops.find((x) => String(x.id) === q) // id-as-string
|
||
|| shops.find((x) => (x.name || '').toLowerCase() === q) // exact name
|
||
|| shops.find((x) => (x.type || '').toLowerCase() === q) // registry type
|
||
|| shops.find((x) => (x.name || '').toLowerCase().includes(q)); // name substring
|
||
}
|
||
}
|
||
if (!s) return { error: `no shop matching ${JSON.stringify(sel)}` };
|
||
return { shop: s, ambiguousWith };
|
||
};
|
||
const ambiguityNote = (sel, a, verb) => `${sel} is also the godverseShopId of "${a.name}" ` +
|
||
`— ${verb} by PLAN id; use 'g:${sel}' for the godverse shop`;
|
||
|
||
// ── frontage pose (R27 w4 — Lane F's ask for the godverse tour frames) ──────────────────────
|
||
// clusterPose() answers "where is the town's HEART", which is the wrong question for a named
|
||
// shop: Red Hill's heart doesn't contain Monster Robot, so three tour frames photographed empty
|
||
// road. This poses at a SHOP instead. Measured against buildings.js rather than eyeballed:
|
||
// • door — buildings.js centres it on the lot front (dw 1.2, y 0.02–2.2), so off = 0
|
||
// (NOT poseForShop's off = 1.0) puts it on the frame's centre line.
|
||
// • sign+mark — sign band is y (min(h,FACADE_H) − 0.35) ± 0.25 ⇒ 2.40–2.90 for a normal shop;
|
||
// B's R26 crate mark is the strip at y ≈ 2.45–2.52 (atlas cell rows 128–152).
|
||
// • verandah — the awning slab sits at y 2.95 and reaches 2.2 m over the footpath. Any camera
|
||
// BELOW it sees the whole sign, because a ray rising from camY < 2.9 to a target
|
||
// ≤ 2.9 can never cross 2.95. Hence camY 2.0: eye-level, and clear by geometry.
|
||
// The novelty_record landmark (a 1.47 m vinyl disc, top y 2.50) stands ~1.3 m proud of record
|
||
// shopfronts and unavoidably clips ~15% of the mark strip from EVERY offset — moving the camera
|
||
// only changes WHICH 15%, so there is nothing to dodge and the repeating spines still read.
|
||
const poseForShopFront = (sel, dist = 8) => {
|
||
const r = resolveShop(sel);
|
||
if (r.error) return r;
|
||
const lot = lotOf(r.shop);
|
||
if (!lot) return { error: `shop "${r.shop.name}" has no lot` };
|
||
const ry = lot.ry || 0, fx = Math.sin(ry), fz = Math.cos(ry);
|
||
const half = (lot.d || 8) / 2;
|
||
const frontX = lot.x + fx * half, frontZ = lot.z + fz * half;
|
||
const out = {
|
||
pos: [frontX + fx * dist, 2.0, frontZ + fz * dist], // off = 0 ⇒ the door is the centre line
|
||
look: [frontX, 1.9, frontZ], // near-level: door low, sign band high
|
||
shop: r.shop.name, id: r.shop.id, godverseShopId: r.shop.godverseShopId ?? null,
|
||
};
|
||
if (r.ambiguousWith) out.ambiguous = ambiguityNote(sel, r.ambiguousWith, 'posed');
|
||
return out;
|
||
};
|
||
|
||
// ── lot- and node-based poses (the district bookmarks aren't all shopfronts) ──
|
||
const poseForLot = (lot, dist = 11) => {
|
||
if (!lot) return null;
|
||
const ry = lot.ry || 0, fx = Math.sin(ry), fz = Math.cos(ry), rx = Math.cos(ry), rz = -Math.sin(ry);
|
||
const half = (lot.d || 8) / 2, off = 1.2;
|
||
const frontX = lot.x + fx * half, frontZ = lot.z + fz * half;
|
||
return { pos: [frontX + fx * dist + rx * off, 1.9, frontZ + fz * dist + rz * off], look: [frontX, 2.0, frontZ] };
|
||
};
|
||
const blockKindOfLot = (lot) => plan.blocks?.find((b) => b.id === lot.block)?.kind;
|
||
const lotsByDist = () => [...(plan.lots || [])].sort((a, b) => dRef(a.x, a.z) - dRef(b.x, b.z));
|
||
// nearest (or farthest = "fringe") lot matching pred, else nearest lot overall
|
||
const pickLot = (pred, farthest = false) => {
|
||
const L = lotsByDist(); const hit = L.filter(pred);
|
||
return poseForLot(hit.length ? (farthest ? hit[hit.length - 1] : hit[0]) : L[0]);
|
||
};
|
||
// stand on a street and look ALONG it (shops recede on both sides) — the classic strip shot.
|
||
// Dead-on shopfront poses jam the camera against a big neighbour (dept anchor / market shed),
|
||
// filling half the frame with a blank wall — that was the "letterbox". Looking down the street
|
||
// frames the whole strip and fills the frame.
|
||
const streetViewPose = (kinds = ['main'], { far = false } = {}) => {
|
||
const nn = new Map((plan.streets?.nodes || []).map((n) => [n.id, n]));
|
||
const es = (plan.streets?.edges || []).filter((e) => kinds.includes(e.kind) && nn.get(e.a) && nn.get(e.b));
|
||
if (!es.length) return crossroadsPose();
|
||
let best = null, bd = 1e18; // long edge, near REF (or far, for the fringe)
|
||
for (const e of es) {
|
||
const a = nn.get(e.a), b = nn.get(e.b);
|
||
const len = Math.hypot(b.x - a.x, b.z - a.z) || 1;
|
||
const dist = dRef((a.x + b.x) / 2, (a.z + b.z) / 2); // R22: about the town anchor, not the origin
|
||
const score = (far ? -dist : dist) - len * 0.8; // favour long open runs; `far` picks the outer ring
|
||
if (score < bd) { bd = score; best = { a, b, len, width: e.width || 8 }; }
|
||
}
|
||
const { a, b, len, width } = best;
|
||
const ux = (b.x - a.x) / len, uz = (b.z - a.z) / len; // along the street
|
||
const px = -uz, pz = ux; // perpendicular (toward a footpath)
|
||
const off = Math.min(5, width * 0.2); // stay inside the corridor (narrow arcade → near-centre)
|
||
return {
|
||
pos: [a.x + ux * (len * 0.14) + px * off, 1.7, a.z + uz * (len * 0.14) + pz * off],
|
||
look: [a.x + ux * (len * 0.9) + px * off, 2.3, a.z + uz * (len * 0.9) + pz * off],
|
||
};
|
||
};
|
||
|
||
// stand back from the busiest intersection (max-degree node) and look through it down a street
|
||
const crossroadsPose = () => {
|
||
const edges = plan.streets?.edges || [], nodes = plan.streets?.nodes || [];
|
||
const deg = new Map(); for (const e of edges) { deg.set(e.a, (deg.get(e.a) || 0) + 1); deg.set(e.b, (deg.get(e.b) || 0) + 1); }
|
||
// busiest node — R22: on a real town, tie-break toward the anchor so we frame the town's own
|
||
// crossroads, not a highway junction kilometres away. Synthetic REF is the origin ⇒ unchanged.
|
||
let node = null, bd = -1, bdist = 1e18;
|
||
for (const n of nodes) {
|
||
const d = deg.get(n.id) || 0, dist = dRef(n.x, n.z);
|
||
// strict `>` alone on synthetic/fixtures = the pre-R22 "first max-degree node wins" (identical);
|
||
// the anchor tie-break only engages on real-roads towns.
|
||
if (d > bd || (REAL_ROADS && d === bd && dist < bdist)) { bd = d; bdist = dist; node = n; }
|
||
}
|
||
if (!node) return pickShop(() => true);
|
||
const e = edges.find((ed) => ed.a === node.id || ed.b === node.id);
|
||
const o = e && nodes.find((n) => n.id === (e.a === node.id ? e.b : e.a));
|
||
let dx = 1, dz = 0; if (o) { const L = Math.hypot(o.x - node.x, o.z - node.z) || 1; dx = (o.x - node.x) / L; dz = (o.z - node.z) / L; }
|
||
return { pos: [node.x - dx * 15, 1.7, node.z - dz * 15], look: [node.x + dx * 25, 2.2, node.z + dz * 25] };
|
||
};
|
||
|
||
// A 3/4 view of the first bus-shelter stop (Lane B's tram stop), from the road side.
|
||
const stopPose = () => {
|
||
const stops = busShelterStops(plan);
|
||
if (!stops.length) return streetViewPose(['main']);
|
||
const s = [...stops].sort((a, b) => dRef(a.x, a.z) - dRef(b.x, b.z))[0]; // nearest the town anchor (origin on synthetic)
|
||
const px = Math.sin(s.yaw), pz = Math.cos(s.yaw), ex = Math.cos(s.yaw), ez = -Math.sin(s.yaw);
|
||
return { pos: [s.x - px * 6 + ex * 4, 2.0, s.z - pz * 6 + ez * 4], look: [s.x, 1.3, s.z] };
|
||
};
|
||
// nearest openLate (video) shop, for the night/rain money shots
|
||
const videoShop = () => (plan.shops || []).find((s) => s.hours && s.hours[1] >= 22);
|
||
// the gig-layer venues (?gigs): stand out in front so the lit frontage + queue + a poster all frame.
|
||
const venueShops = () => (plan.shops || []).filter((s) => s.venue);
|
||
const poseForVenue = (kind, dist = 13) => {
|
||
const vs = venueShops();
|
||
const shop = (kind ? vs.find((s) => (s.venueKind || s.type) === kind) : null) || vs[0]
|
||
|| (plan.shops || []).find((s) => s.type === 'pub');
|
||
return shop ? poseForShop(shop, dist) : null;
|
||
};
|
||
// John's release hero: a CLOSE 3/4 on the pub door during doors/on, biased to the venue's +right
|
||
// (the queue side) so punters' faces read and a verge poster enters frame. The queue + posters are
|
||
// spawned by the live shell loop when the venue opens (seg 5 = NIGHT / 'on'), so F drives this shot
|
||
// live; this just frames the door + marquee + queue zone. Geometry mirrors venue.js's frontage.
|
||
const poseForVenueQueue = () => {
|
||
const vs = venueShops();
|
||
const shop = vs.find((s) => (s.venueKind || s.type) === 'pub') || vs[0]
|
||
|| (plan.shops || []).find((s) => s.type === 'pub');
|
||
const lot = shop && lotOf(shop); if (!lot) return null;
|
||
const ry = lot.ry || 0, fx = Math.sin(ry), fz = Math.cos(ry), rx = Math.cos(ry), rz = -Math.sin(ry);
|
||
const w = lot.w || 8, d = lot.d || 10;
|
||
const cx = lot.x + fx * (d / 2), cz = lot.z + fz * (d / 2); // door centre on the street (venue.js frontage)
|
||
const mw = Math.min(w * 0.92, 6.5); // marquee width (queue head ~ +right of it)
|
||
const DIST = 7.0, SIDE = mw * 0.5 + 2.2, EYE = 2.2; // close standoff, biased to the queue side
|
||
return {
|
||
pos: [cx + fx * DIST + rx * SIDE, EYE, cz + fz * DIST + rz * SIDE],
|
||
look: [cx + rx * (mw * 0.30), 2.7, cz + rz * (mw * 0.30)], // door, lifted to catch the marquee
|
||
};
|
||
};
|
||
|
||
// Named bookmarks (LANE_F_NOTES §4). seg = day segment index (0 DAWN … 5 NIGHT).
|
||
// Predicates cover both Lane A's taxonomy (dept/stall/record/…) and the fixture's; each falls back
|
||
// to the nearest shop, so every bookmark always yields a valid street pose.
|
||
const BOOKMARKS = {
|
||
street_noon: { seg: 2, resolve: () => streetViewPose(['main']) }, // look down the main strip
|
||
arcade: { seg: 3, resolve: () => streetViewPose(['side']) }, // an open side street (the 5m arcade lane is too tight to frame cleanly from inside)
|
||
market_square: { seg: 1, resolve: () => pickShop((s) => s.type === 'stall' || s.type === 'market') },
|
||
milkbar_dusk: { seg: 4, resolve: () => pickShop((s) => s.type === 'milkbar') },
|
||
// the §3.5 money shot: the one openLate shop (video rental) glowing amid its CLOSED neighbours
|
||
night_neon: { seg: 5, resolve: () => { const v = (plan.shops || []).find((s) => s.hours && s.hours[1] >= 22); return v ? poseForShop(v, 12) : streetViewPose(['main']); } },
|
||
// the 3 poses Lane F's v1_tour also uses (LANE_F_NOTES §9.1) — were falling back to street_noon
|
||
crossroads_busy: { seg: 2, resolve: () => crossroadsPose() },
|
||
residential_collar: { seg: 3, resolve: () => pickLot((l) => l.use === 'house' || blockKindOfLot(l) === 'residential' || blockKindOfLot(l) === 'backstreets') },
|
||
warehouse_fringe: { seg: 4, resolve: () => streetViewPose(['side', 'main'], { far: true }) }, // down the outer-ring street
|
||
// ── v2 tour (round 9): the town's new life. F shoots these with the matching flags (noted per pose). ──
|
||
rain_verandah: { seg: 5, resolve: () => { const v = videoShop(); return v ? poseForShop(v, 6) : streetViewPose(['main']); } }, // ?winmap=1&weather=rain — rain outside the lit video-shop window
|
||
patronage_door: { seg: 2, resolve: () => pickShop((s) => s.hours && s.hours[0] <= 12 && s.hours[1] > 13 && s.type !== 'video') }, // peds at an OPEN shop's door (default roster/patronage)
|
||
dig_real: { seg: 2, resolve: () => pickShop((s) => s.type === 'record') }, // ?stock=real&dig=1 — F enters + riffles a real-cover bin
|
||
katoomba_main: { seg: 2, resolve: () => streetViewPose(['main']) }, // ?plansrc=osm&town=katoomba — plan-agnostic main-strip view
|
||
tram_stop: { seg: 2, resolve: () => stopPose() }, // ?tram=1 — a bus shelter (tram pauses here)
|
||
rain_street: { seg: 2, resolve: () => streetViewPose(['main']) }, // ?weather=rain — wet strip, rain down the street
|
||
night_crowd: { seg: 5, resolve: () => { const v = videoShop(); return v ? poseForShop(v, 16) : streetViewPose(['main']); } }, // night video-shop draw (wider, shows the crowd)
|
||
shopfront_detail: { seg: 3, resolve: () => pickShop((s) => s.type === 'opshop' || s.type === 'record' || s.type === 'milkbar') }, // verandah + sign + window up close
|
||
// ── v3 gig district (round 13, ?gigs=1): the street money shots F scripts via tools/qa/gig_shot.py ──
|
||
venue_night: { seg: 5, resolve: () => poseForVenue('pub', 13) || streetViewPose(['main']) }, // ?gigs=1 — lit pub frontage + streetlamp pools + queue + posters at night
|
||
district_posters: { seg: 5, resolve: () => streetViewPose(['main']) }, // ?gigs=1 — the spine at night: poster poles + lit venue frontages receding
|
||
queue_night: { seg: 5, resolve: () => poseForVenueQueue() || poseForVenue('pub', 11) || streetViewPose(['main']) }, // ?gigs=1 — John's hero: close on the pub door at 'on', queue + marquee + poster, punters facing the door
|
||
};
|
||
|
||
const DBG = {
|
||
// true once the initial chunks are built and the queue is drained (textures may still decode)
|
||
get ready() { return chunks.count > 0 && chunks.pending === 0; },
|
||
bookmarks: Object.keys(BOOKMARKS),
|
||
info,
|
||
|
||
// ── R22: the town-anchoring primitive, published for Lane F's release tour (ledger #4) ──
|
||
// `townAnchor` = the centroid of the densest shop cluster (the retail heart). `anchored` says
|
||
// whether the bookmarks are using it (real-roads towns) or the origin (synthetic/fixtures —
|
||
// unchanged behaviour). `clusterPose()` is the shared poser: stand on the road at the densest
|
||
// cluster and look along it — the same primitive B's bookmarks now resolve through.
|
||
townAnchor: { ...townAnchor, anchored: REAL_ROADS, ref: { x: REF.x, z: REF.z } },
|
||
clusterPose: (kinds = ['main']) => streetViewPose(kinds),
|
||
|
||
// ── R27 w4: the frontage pose, published for Lane F's godverse tour frames ──
|
||
// clusterPose finds the town's heart; this finds a NAMED SHOP's frontage — door centred, sign
|
||
// band and B's crate mark in shot, under the verandah. Keyed through the one shop selector, so
|
||
// it inherits the id-namespace law: `poseForShopFront('g:3962749')` for a godverse shop,
|
||
// a bare number for a plan id (and it TELLS you when that number is ambiguous).
|
||
// DBG.poseForShopFront('g:3962749') → Monster Robot Party's frontage at 8 m
|
||
// DBG.poseForShopFront('Egg Records', 7) → by name, closer
|
||
// Returns { pos, look, shop, id, godverseShopId } — the same {pos,look} every bookmark uses —
|
||
// or { error } for an unknown selector / a shop with no lot. Never throws, never guesses.
|
||
poseForShopFront,
|
||
|
||
// time-of-day (hands the day cycle to the harness so captures are deterministic)
|
||
setSegment(seg) { lighting.setPaused(true); lighting.setSegment(seg | 0); return lighting.getClock(); },
|
||
|
||
// drive the soak walk: place the player and stream the destination in before the next step/shot
|
||
teleport(x, z, ry = 0) {
|
||
if (getMode() !== 'street') setMode('street');
|
||
player.teleport(x, z, ry);
|
||
chunks.warmup(player.position);
|
||
render();
|
||
return info();
|
||
},
|
||
|
||
// snap to a named bookmark, settle, and return the frame's stats
|
||
shot(name) {
|
||
const bm = BOOKMARKS[name] || BOOKMARKS.street_noon;
|
||
const pose = bm.resolve();
|
||
if (getMode() !== 'street') setMode('street');
|
||
if (bm.seg != null) { lighting.setPaused(true); lighting.setSegment(bm.seg); }
|
||
if (pose) {
|
||
player.teleport(pose.pos[0], pose.pos[2], 0);
|
||
camera.position.set(pose.pos[0], pose.pos[1], pose.pos[2]);
|
||
chunks.warmup(camera.position);
|
||
camera.lookAt(pose.look[0], pose.look[1], pose.look[2]);
|
||
camera.updateMatrixWorld();
|
||
}
|
||
render(); render(); // two frames so bloom + the segment change settle before capture
|
||
return { name, ...info() };
|
||
},
|
||
|
||
// scripted interior visit (drives the record_interior shot + the soak memory-baseline gate)
|
||
// [Lane F R22 — Lane G's ask] enterShop takes a SELECTOR, not just an id, so cross-repo QA is a
|
||
// one-liner on any town without looking ids up first:
|
||
// DBG.enterShop(116) → by id
|
||
// DBG.enterShop('record') → first shop of that registry type
|
||
// DBG.enterShop('The Exchange Hotel')→ by exact name, else name substring (case-insensitive)
|
||
// Resolution order is most-specific-first so an id-shaped string can never be shadowed by a name.
|
||
// [R24] `godverseShopId` joins the selector — carefully, because THE TWO ID SPACES COLLIDE.
|
||
// A GODVERSE shop has two ids: its plan id (local, renumbered per town, 1..N) and its
|
||
// godverseShopId (the real POS/dealgod id — what the atlas, every G-side query and every
|
||
// cross-repo bug report names it by). Monster Robot is plan 8 / godverse 3962749. But Silky Oak's
|
||
// godverse id is 31 and some *other* shop's PLAN id is also 31 — so a bare number is genuinely
|
||
// ambiguous, and F's first cut at this silently entered the wrong shop (a cafe) while looking
|
||
// like it worked. Bare number ⇒ plan id (unchanged, back-compatible); `'g:<n>'` ⇒ godverse id,
|
||
// explicitly. When a bare number matches BOTH, we say so in the return rather than pick quietly.
|
||
enterShop(sel) {
|
||
const r = resolveShop(sel);
|
||
if (r.error) return r;
|
||
const s = r.shop;
|
||
enterShop(s.id, s.name);
|
||
const out = { entered: s.id, name: s.name, type: s.type,
|
||
godverseShopId: s.godverseShopId || null };
|
||
if (r.ambiguousWith) out.ambiguous = ambiguityNote(sel, r.ambiguousWith, 'entered');
|
||
return out;
|
||
},
|
||
exitShop() { window.dispatchEvent(new CustomEvent('procity:exitShop')); return { exited: true }; },
|
||
|
||
// [R24] The identity of the stock in the open room — the falsifiable the #7a gate asserts on.
|
||
// Returns null with no room open (the caller must enter first; a null here FAILS the gate rather
|
||
// than passing quietly — vacuous-gate law).
|
||
stockInfo() {
|
||
if (!interiorMode) return { error: 'interiorMode not wired into DBG' };
|
||
return interiorMode.stockInfo || { error: 'no interior open — enterShop() first' };
|
||
},
|
||
};
|
||
|
||
window.DBG = DBG;
|
||
console.log('[procity] DBG harness ready (?dbg=1) — shot/teleport/setSegment/enterShop/exitShop/info/ready');
|
||
return DBG;
|
||
}
|