John looked at R41's proof shots and named three faults. All three are fixed, at zero draw cost, and every lane corrected somebody's numbers on the way — including its own. E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief, quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library called colour was baked AO. So the fix was a palette imposed on colour already present, in place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints and origins provably did not move. (A first-pass bbox check ignored node rotations and "found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s office teal and went burgundy; the beer umbrella was white and went brewery green. THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench = 1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl, red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43, not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage). E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the R41 deny-list's failure class through a different door: an innocent name from another pipeline. Only rendering all 24 and LOOKING found it. Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran 65..99 bones; named characters carry facial/hair bones). THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo, Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real. B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then measured: the atlas canvases are already byte-identical across boots, the game ships NO webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600, ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks 2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default). THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached 0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed otherwise — the side term was already right, which is why it survived 41 rounds of reading. Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth. C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control. E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every booth part sat 25mm sunk into its own worktop. D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex is in the identity signature and the impostor atlas; removal would renumber the town). Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a fresh default boot fetches 32 ped GLBs, none retired. THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only 3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently dropping six tracks per clip since it shipped. Testing the thing being thrown away found it. Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s race against a live sim), not deterministically red. F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law (0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified; CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict. R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there — the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with everything else frozen was the last thing moving at 53-62 px. ?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed. Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647, 0x5f76e76 unmoved. No tag — John's playtest rules the epoch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
438 lines
29 KiB
JavaScript
438 lines
29 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(),
|
||
// [Lane B R40 §40.3] the draw/tri law THIS boot answers to ({draws, tris, radius, auto,
|
||
// diagnostic}) — a harness asserts info().budget.draws, never a hardcoded 300, so a ?r=
|
||
// diagnostic boot is measured against its own declared ceiling (420) and a default boot
|
||
// against the street law. Read off window.PROCITY so DBG needs no new wiring.
|
||
budget: (window.PROCITY && window.PROCITY.budget) || null,
|
||
};
|
||
};
|
||
|
||
// ── 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(); },
|
||
|
||
// ── [Lane B R42 §42.5] freeze() — the missing half of a reproducible capture ──────────────────
|
||
// R41 ruled no gate may pin a screenshot byte-hash, because four bookmarks gave three distinct
|
||
// hashes over three boots, blamed on the sign-atlas font race. MEASURED this round: the atlas
|
||
// canvases are byte-identical across boots and it was never the font (see buildings.js). What
|
||
// moves is THE CROWD — the sim advances on wall-clock time, so two boots settled for the same
|
||
// 2.5 s are two different streets. With the crowd out, `street_noon` is pixel-identical across
|
||
// fresh contexts (0 of 921,600 px changed); with it in, 983 px move, all of them in the
|
||
// pedestrian band (rows 361–418). This is the switch.
|
||
//
|
||
// WHY IT EXISTS AT ALL: the recipe every lane has been quoting — `?roster=v1&pop=0` — does NOT
|
||
// empty the street. `index.html` reads `(parseInt(params.get('pop'), 10) || 140)`, and 0 is
|
||
// falsy, so `?pop=0` silently boots 140 citizens (96 active in the window, measured). That line
|
||
// is Lane F's file, so this is the knob B can offer; the flag itself is filed to F.
|
||
//
|
||
// Returns what it froze and what is STILL moving, so a harness asserts instead of assuming.
|
||
// Nothing here exists off `?dbg=1`, and it touches no game state a player can reach.
|
||
freeze() {
|
||
const P = window.PROCITY || {};
|
||
const C = P.citizens || null;
|
||
const active = C && C.stats ? C.stats.active : null;
|
||
lighting.setPaused(true);
|
||
render();
|
||
const drawsBefore = renderer.info.render.calls;
|
||
// Two mechanisms, because the roster has two modes: setPopulation(0) empties the FIXED roster
|
||
// (?roster=v1), and hiding the group covers the CHUNK-STREAMED default, whose active set is
|
||
// per-chunk density and ignores `target` entirely (citizens/sim.js _activeSet).
|
||
if (C && C.setPopulation) C.setPopulation(0);
|
||
if (C && C.group) C.group.visible = false;
|
||
render();
|
||
return {
|
||
// the draw delta IS the proof the crowd left the frame — a harness can assert on it
|
||
draws: { before: drawsBefore, after: renderer.info.render.calls },
|
||
peds: { active, hidden: !!(C && C.group) },
|
||
// everything else that runs on wall-clock time: null ⇒ already off for this boot, true ⇒
|
||
// still live and the caller must boot with its `=0` flag for a byte-stable frame.
|
||
stillLive: { weather: !!(P.weather && P.weather.state && P.weather.state !== 'clear'),
|
||
tram: !!P.tram, magpie: !!P.magpie, washing: !!P.washing, gigs: !!P.gigs },
|
||
};
|
||
},
|
||
|
||
// 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;
|
||
}
|