PROCITY/web/index.html
m3ultra 4514cb94b5 Lane B R38 (v8 WAVE 1, INHABITATION): all four runtime items — the whole round costs +1 walked draw
The town is inhabited: 23 towns finally differ, 181 back yards have a Hills Hoist with washing on it,
a magpie owns one street for six weeks of the year, and six point sources put a life off-screen.

MEASURED, walked, not bookmarked — two port-isolated no-store servers (:8472 = `git archive HEAD`,
:8471 = treatment, assets symlinked so E's mid-round wave is identical on both sides), 102 8-metre
spine steps at yaw 0, each frame fully streamed, through the shell's own composer:

  worst draws  224 -> 225   (+1)
  worst tris   89,502 -> 93,298   (+3,796 of ~75k spare)
  sum draws over 102 frames  16,676 -> 16,663   (-13)

The citizen sim and the tram are hidden identically on both sides, and that IS the measurement: with
them live the same step read control 346 vs treatment 232, because peds spawn on wall-clock time.
`?yards=0` reproduces HEAD exactly (224 / 89,502 / 16,676). Isolated at a fixed pose: washing +1 draw
/ +512 tris, magpie +1 draw / +182 tris, every yard box prop +0 draws.

§1.2 THE CHARACTER VECTOR — +0 draws, +0 tris, classic byte-identical BY CONSTRUCTION.
`townCharacter(null)` returns the frozen v1 literals and a null tint keeps the SAME material cache key,
so classic gets the same object, not a copy of it: default-boot ground hashes identical to HEAD
mesh-for-mesh, `?classic=1` hashes unmoved from R37. There is no `if (classic)` in the ground path.
The sky feeds the BASE skyName, never `setSky` (a one-way latch that would have killed R32's dawn dome
on all 23 towns). The vacuous arm is per-town: `newtown_godverse` / `redhill_godverse` carry `state: ''`,
so a per-corpus arm would have dropped exactly those two silently. 21 distinct palettes across 23 towns.
`gravel` and `reddust` were not unwired but UNWIREABLE — their `use` values named no slot ground.js had;
making the slot addressable fixed it at the root, and both are selected now. E's three new grounds
landed mid-round and the upgrade was a name change in one table, which is what procedural-first is for.

§1.4 BACKYARD AUSTRALIA — inside boxMats, +0 draws. Houses inset off their boundaries so there is a
yard; 36 `yard` lots that had rendered as blank shopfront sheds since v1 are now fenced blocks with a
hoist, a shed and often a tank. Collision byte-identical (the lot rect is where the fence stands).

THE WASHING SHIPS, and I wrote the inverted weight rather than dropping it. The shipped top-weighted
mode moves a hem 0.10 cm calm / 0.67 cm gusty; `pegged` gives 4.5 cm / 31 cm — ~52x, and at the hem
instead of the pegs. The `canopy` string is byte-for-byte unchanged, so the gum-tree covenant argument
is untouched. It is ONE TOWN-WIDE InstancedMesh (1 draw at any N, cap 256, proximity-first off the R37
chunk seam) rather than per-chunk, which would have been +1 x 31 live chunks = 4x the whole margin.
Zero on every real town (no hoists ⇒ no mesh) and zero at night.

§1.3 THE MAGPIE — +1 draw on screen, exactly 0 otherwise; 182 tris of 900. Territory scales with the
road graph: 6 on the 6.78 km synthetic town, 157 on bendigo's 188.6 km / 2,593 edges, nearest 120 m
from spawn. The latch sets, the day roll clears it, localStorage gains no key and the save never
mentions it. `swoopPos()` is exported and pure. The hat stays cut.

MY OWN SEASON MECHANISM WAS VACUOUS AND THE SWEEP CAUGHT IT: keyed on citySeed alone it produced a
CONSTANT, because the town key is deliberately off the plan — 24 of 24 towns were 214 days from
swooping season, and it would have gated green because `?magpie=1` forces it. Re-keyed on
(citySeed, townKey): `newtown_real` is in season on arrival on the default seed, marrickville is 8 days
out, and 12.9% of 1,000 seeds put the synthetic default in season against a design ratio of 11.5%.

§1.1 THE AUDIO LAYER — six new mixer layers, zero draws, zero tris, seeded anchors, distance falloff,
AND A StereoPannerNode PER LAYER, so direction is real (measured spread -0.71..+0.85) instead of a
limitation written into the notes. Day-of-week derived locally. All six anchored on the default boot;
3 of 6 report `no-anchor` on real towns rather than being faked onto shopfronts. The falsifiability
control — one manifest key renamed away, served from a third port — makes that source report
`no-entry`, 0 plays, never `audible`, with the other five unchanged and zero console errors.

TWO DEFECTS MY OWN GREEN NUMBERS COULD NOT SEE, both caught by a screenshot: the washing shipped at 256
instances / +1 draw / cap respected and was INVISIBLE behind its own back fence (per-instance peg height,
and a fence taller than the hoist), and the magpie perched on THIN AIR over the road. R37's lesson
relearned at full price. Fence 1.60 / hoist 2.15 measured against the object; territories now snap to
furniture.js's own streetlight rule.

LANE E'S PROPS, ADJUDICATED: hoist + aerial permanently primitive (E's spike killed them, and primitive
is also the +0-draw mechanism). `paling_fence` at 1,121 tris x 905 runs = 1.01M triangles — no. The
`magpie` GLB is HELD FOR THE TINT: it reads as a crow, and a magpie seen for 1.2 s in peripheral vision
IS its white bar. My 182-tri bird already carries it, at a fifth of the triangles and no fetch.
furniture.js's "novelty_record is 26.5k tris" was stale by six epochs — corrected to 8,000.

Zero fetch delta: `?noassets=1` fetches the same 23 assets, same list, on HEAD and on R38.
0 console errors on 10 boots (default, classic, yards=0, washing=0&magpie=0&ambient=0, noassets,
magpie=1, darwin, hobart, bendigo, the patched-manifest control) beyond the known PointerLock ones.

Shots: docs/shots/laneB_r38/. Detail + every number: LANE_B_NOTES §38.
2026-08-03 18:53:26 +10:00

679 lines
47 KiB
HTML
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.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>PROCITY — walkable town</title>
<style>
html, body { margin: 0; height: 100%; background: #0c0e0a; overflow: hidden; color: #eee;
font: 14px -apple-system, Segoe UI, Roboto, sans-serif; }
#c { display: block; width: 100vw; height: 100vh; }
#pc-start { position: fixed; inset: 0; z-index: 40; display: flex; flex-direction: column;
align-items: center; justify-content: center; text-align: center; gap: 14px;
background: radial-gradient(circle at 50% 40%, #24281e, #0c0e0a); }
#pc-start h1 { font-size: 34px; margin: 0; letter-spacing: 2px; color: #ffd75e; text-shadow: 0 2px 8px #000; }
#pc-start .sub { opacity: .85; max-width: 420px; line-height: 1.5; }
#pc-start button { pointer-events: auto; cursor: pointer; border: 0; border-radius: 10px;
padding: 12px 26px; font-size: 16px; background: #ffd75e; color: #241d06; font-weight: 700; }
#pc-start .seed { opacity: .55; font-size: 12px; }
#pc-load { position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%); opacity: .7; }
</style>
<script type="importmap">
{ "imports": {
"three": "./vendor/three.module.js",
"three/addons/": "./vendor/addons/"
} }
</script>
</head>
<body>
<canvas id="c"></canvas>
<div id="pc-start">
<h1>PROCITY</h1>
<div class="sub">A procedurally generated, walkable 90s-Australian shopping town.
Every door opens. Dig the crates, sell the flips, sleep — the town remembers.</div>
<div class="sub" id="pc-hunt" style="color:#ffd75e;display:none"></div>
<button id="pc-go">walk into town</button>
<div class="seed" id="pc-startseed"></div>
<div class="sub" style="font-size:12px;opacity:.7">WASD move · shift run · mouse look · click a door · E riffle a bin / sell at the counter · C your crate · SLEEP to save · [ ] time · M map</div>
</div>
<script type="module">
import * as THREE from 'three';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
import { fixturePlan } from './js/world/fixture_plan.js';
import { createSkinLibrary } from './js/world/skins.js';
import { buildGround } from './js/world/ground.js';
import { townCharacter, describeCharacter } from './js/world/character.js'; // [Lane B R38 §1.2] per-town palette/sky/ambience
import { createLighting } from './js/world/lighting.js';
import { createChunkManager } from './js/world/chunks.js';
import { createPlayer } from './js/world/player.js';
import { createHUD } from './js/world/hud.js';
import { createMinimap } from './js/world/minimap.js';
import { createInteriorMode, godverseBaseFor } from './js/world/interior_mode.js'; // [Lane F integration] Lane C interior bridge
import { createWallet } from './js/interiors/wallet.js'; // [Lane F R8] Lane C buy loop v0 (session wallet)
import { CitizenSim } from './js/citizens/sim.js'; // [Lane F integration] Lane D street pedestrians
import { createWeather } from './js/world/weather.js'; // [Lane F R8] Lane B seeded weather (?weather=1)
import { createTram } from './js/world/tram.js'; // [Lane F R9] Lane B tram (?tram=1, non-blocking)
import { createWashing } from './js/world/washing.js'; // [Lane B R38 §1.4] town-wide washing (1 draw, cap 256)
import { createMagpie } from './js/world/magpie.js'; // [Lane B R38 §1.3] the magpie (+1 draw while on screen)
import { createGigState } from './js/world/gig_state.js'; // [Lane F R12] the Friday-night state machine (?gigs=1)
import { createVenuePresentation } from './js/world/venue.js'; // [Lane F R12] Lane B venue frontage + posters (?gigs=1)
import { loadPedFleet } from './js/citizens/rigs.js'; // [Lane F §3.4] Lane D rig fleet (GLB peds/keepers)
import { VenueQueue } from './js/citizens/queue.js'; // [Lane F R13] Lane D outdoor gig queue (?gigs=1)
import { preloadManifest, preloadStockPack } from './js/interiors/interiors.js'; // [Lane F] Lane E manifest + Lane C/E stock pack
import { createGame } from './js/world/save.js'; // [Lane F R30] v7 THE SAVE CORE (contract: LANE_F_NOTES §30)
import { ARTISTS } from './js/interiors/dig.js'; // [R35] the parody roster — a fresh game's seeded first want
import { rng, pick } from './js/core/prng.js'; // [R35] seeded first-want draw (runtime stream, fresh key)
main().catch((err) => {
console.error('[procity] init failed:', err);
const el = document.getElementById('pc-startseed');
if (el) el.textContent = 'INIT ERROR: ' + (err && err.message || err);
});
async function main() {
// ── plan: prefer Lane A's generatePlan if it has landed, else the fixture ──
const params = new URLSearchParams(location.search);
const seed = (parseInt(params.get('seed'), 10) || 20261990) >>> 0;
// [Lane F §3.4] asset-free gate: ?noassets=1 → skip the manifest + rig fleet + interior GLB upgrades so
// the whole town runs 100% on primitives/flat-colour with ZERO network. Everything below is fail-soft
// anyway (primitives persist if a GLB never lands); this flag makes "no network at all" explicit + testable.
const NOASSETS = params.has('noassets') && params.get('noassets') !== '0';
// [Lane F §F2] plan-source selector (v2, ?plansrc=osm). Default 'synthetic' = the byte-identical
// golden-hash generator (prime law); 'osm' = Lane A's real-data fixture importer (generatePlanFor,
// LANE_A_NOTES F2). Zero network either way. Guarded: falls back to synthetic generatePlan, then fixture.
const PLANSRC = params.get('plansrc') === 'osm' ? 'osm' : 'synthetic';
const TOWN = params.get('town') || undefined; // [Lane F R8] &town=<key> selects an OSM town (A: melbourne|katoomba)
// [Lane F R16 — THE FLIP (v3.1)] The town shows its best by DEFAULT: gigs / weather / winmap / tram are ON
// unless individually opted out (?gigs=0, ?weather=0, …) or ?classic=1 forces the exact v2 boot. The prime
// law is REWRITTEN, not repealed: the byte-identical covenant (synthetic fingerprint 0x3fa36874, zero gig
// layer, zero fetch delta) moved from "flags off" to "?classic=1". `flagOn(name)`: default-on, `=0` opts
// out, classic is the master off. winmap flips the same way in buildings.js; the drummer's sit clip rides !CLASSIC.
const CLASSIC = params.has('classic') && params.get('classic') !== '0';
const flagOn = (name) => !CLASSIC && params.get(name) !== '0';
// [Lane F R12/16] The gig layer — now DEFAULT-ON: Lane A's post-hoc augmentation (venues, plan.gigs,
// plan.posters) + everything F wires off it (the state machine, B's frontage/spill, C's rooms, D's
// band/crowd/queue, the cover charge). ?gigs=0 or ?classic=1 ⇒ generatePlanFor returns the byte-identical
// v2 plan and NONE of the gig modules are constructed (the classic-regression gate enforces exactly that).
const GIGS_ON = flagOn('gigs');
// John's OPTIONAL band-name drop-in (web/assets/custom_bands.json — Lane A owns the file + schema; edit it
// and reload, the names get priority on the town's posters). Absent file → pure generator, zero errors.
// ZERO fetch when the flag is off or under ?noassets=1 (asset law). `no-cache` on purpose: this file exists
// to be hand-edited between reloads, so a stale 304 would make John's edit look broken.
async function loadCustomBands() {
if (!GIGS_ON || NOASSETS) return [];
try {
const r = await fetch('assets/custom_bands.json', { cache: 'no-cache' });
if (!r.ok) return []; // 404 = John deleted the file = generator takes over. Not an error.
const j = await r.json();
return Array.isArray(j && j.bands) ? j.bands : [];
} catch { return []; } // malformed / blocked → generator. Never breaks the boot.
}
const customBands = await loadCustomBands();
let plan;
try {
const citygen = await import('./js/citygen/index.js');
// [Lane F R17 — real-town caches] a ?plansrc=osm&town=<key> whose key is NOT a checked-in fixture is one of
// Lane E's real AU town caches (web/assets/towns/<key>.json). Fetch + register it before the plan generates,
// so real towns boot in the engine (the v4 scout). Fail-soft: 404 / invalid cache / ?noassets → plan_osm
// falls back to the default fixture. The scout data is opt-in — never on the default or ?classic path.
if (PLANSRC === 'osm' && TOWN && !NOASSETS && citygen.registerTownCache
&& !(citygen.osmTownKeys && citygen.osmTownKeys().includes(TOWN))) {
try {
const r = await fetch(`assets/towns/${TOWN}.json`, { cache: 'force-cache' });
if (r.ok) { citygen.registerTownCache(TOWN, await r.json()); console.log(`[procity] real town cache registered: ${TOWN}`); }
} catch (e) { console.warn(`[procity] town cache '${TOWN}' not loaded (${e && e.message || e}) — falling back to a fixture`); }
}
const gen = citygen.generatePlanFor
? (s) => citygen.generatePlanFor(s, PLANSRC, { town: TOWN, gigs: GIGS_ON, customBands }) // A's selector (synthetic | osm[+town]) + the v3 gig layer
: (citygen.generatePlan || citygen.default); // pre-R6 fallback (synthetic only)
plan = gen ? gen(seed) : fixturePlan(seed);
console.log(`[procity] plan source: ${PLANSRC}${TOWN ? '/' + TOWN : ''}${plan.name}, ${plan.shops.length} shops`
+ (GIGS_ON ? ` · gigs: ${(plan.gigs || []).length} nights, ${(plan.posters || []).length} posters`
+ `, ${customBands.length} custom band name(s)` : ''));
} catch (e) {
plan = fixturePlan(seed);
console.log('[procity] Lane A citygen absent — running on fixture_plan');
}
// ── renderer + post ──
// Adaptive to plan density: a large generated city (Lane A) has content in nearly every chunk, so
// a smaller radius + no sun-shadow pass keeps draw calls near the ≤300 gate. The sparse fixture
// gets the fuller radius 3 + shadows (measured ~140 draws). Override via ?r= / ?shadows=0|1.
// [Lane F R19 — B's proven one-liner] a real town can have FEW shops but a HUGE road graph (real Katoomba:
// 20 shops / 799 edges / 5 km) — the shop-count heuristic alone left it on small-town streaming and blew the
// tri budget (280k > 200k). Extend the trigger to the graph size so big-graph towns stream like big cities.
const BIG_CITY = (plan.shops?.length || 0) > 120 || (plan.streets?.edges?.length || 0) > 200;
const RADIUS = parseInt(params.get('r'), 10) || (BIG_CITY ? 2 : 3);
const SHADOWS = params.has('shadows') ? params.get('shadows') !== '0' : !BIG_CITY;
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = SHADOWS;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.info.autoReset = false; // EffectComposer renders many passes/frame; we reset once per frame
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 800);
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.55, 0.5, 0.9);
composer.addPass(bloom);
composer.addPass(new OutputPass());
// ── world systems ──
const skins = createSkinLibrary();
// [Lane B R38 §1.2 — THE PER-TOWN CHARACTER VECTOR] Which town this is never reaches the plan on
// purpose (plan_osm.js:515 keeps the town key OFF the plan so the Melbourne golden stays frozen), so
// the shell — which is the only thing that knows — resolves it here and hands it to the ground and
// the sky. A synthetic boot has NO town key ⇒ townCharacter(null) ⇒ the frozen v1 literals ⇒ the
// default boot and ?classic=1 are byte-identical BY CONSTRUCTION (no flag test in ground.js at all).
// `?plansrc=osm` with no &town= boots plan_osm's DEFAULT_TOWN ('melbourne'), so name it rather than
// letting the character silently fall through on a boot that does have a town.
const CHAR_TOWN = PLANSRC === 'osm' ? (TOWN || 'melbourne') : null;
const character = townCharacter(CHAR_TOWN);
if (CHAR_TOWN) console.log(`[procity] ${describeCharacter(character)}`
+ (character.matched ? '' : ' ⚠ NO ENTRY for this town key — palette unchanged (character.js)'));
const ground = buildGround(plan, skins, { townKey: CHAR_TOWN });
scene.add(ground.group);
const lighting = createLighting(scene, plan, skins, { radius: RADIUS, shadows: SHADOWS,
dawnSky: CLASSIC ? null : 'high-cirrus', // [R32 §31.6-3] dawn reads dawn; classic keeps the frozen dome
sky: CLASSIC ? null : character.sky }); // [R38 §1.2] the town's base dome — NOT setSky (a one-way latch)
lighting.attachRenderer(renderer);
const ctx = { skins, citySeed: plan.citySeed, plan, night: lighting.isNight(), radius: RADIUS };
const chunks = createChunkManager(plan, scene, ctx);
lighting.onNightChange = (night) => { chunks.setNight(night); bloom.strength = night ? 0.9 : 0.55; };
const player = createPlayer(camera, canvas, { getColliders: (x, z) => chunks.getColliders(x, z) });
const hud = createHUD({
camera, renderer, plan,
getDoorMeshes: () => chunks.getDoorMeshes(),
onEnterShop: (shopId, name) => enterShop(shopId, name),
});
const minimap = createMinimap(plan);
// [Lane F §3.4] asset upgrades — OFF entirely under ?noassets. Fail-soft: primitives persist until (and
// unless) a GLB lands, so the town is fully playable the instant it boots.
// • rig fleet: models/peds/*.glb → shared GLB actors; CitizenSim + keepers upgrade from placeholders.
// • manifest: Lane E's assets/manifest.json → interior fitting GLBs (loaded from the 3GOD depot).
const fleet = NOASSETS ? null : loadPedFleet('models/peds/', { sit: !CLASSIC, look: !CLASSIC, dance: !CLASSIC }); // [R16] sit.glb (the seated drummer) + [R29] look.glb (the street glance) on the default boot; ?classic keeps the zero-fetch-delta covenant
if (!NOASSETS) preloadManifest(); // prime the manifest so the first interior can upgrade its fittings
// [Lane F integration] interior bridge — owns the 'interior' branch (Lane C buildInterior + Lane D keeper)
// [Lane F R31 — THE DIG FLIP] The crate-riffle is DEFAULT-ON: `?dig=0` opts out, `?classic=1` forces it off
// — the exact flagOn pattern the other flipped flags ride. The v2-era opt-in (`params.has('dig')`) survived
// here six epochs after the README's flags table claimed "dig on", and every R30 game-loop gate booted
// `dig=1` explicitly — so the whole suite was green while the plain boot (the boot every player gets) had
// no dig at all: E at a bin did nothing (Fable's R31 wave-0 playtest, the alpha-blocker). The default-boot
// gate now asserts dig REACHABILITY on a naked boot, so un-flipping this line goes red in qa, not in a
// player's hands. ?noassets composes unchanged: the dig falls to parody canvas (existing law).
const DIG_ON = flagOn('dig');
// [Lane F §F2 stock=real] real GODVERSE sleeves in the crates (v2). Preload the record pack at boot so
// the first interior/dig can batch real covers; fail-soft (missing pack → parody canvas). Off under ?noassets.
const STOCK_REAL = params.get('stock') === 'real' && !NOASSETS;
// [Lane F R27 §2 — TIER 2, OPT-IN] `?live=1` → the default GODVERSE base; `?live=<url>` → that base;
// absent → NO tier 2 at all, byte-identical to the beta. Opt-in is deliberate and is the R26 lesson
// applied to a server: a default boot that probed for a GODVERSE host would ask a question with no
// answer on every machine that isn't John's, and "it only logs an error when the server's away" is
// exactly the blind probe the manifest was built to end. Tier 2 is a place you go, not a thing that
// happens to you. Auto-discovery, if ever, is a v5.x question with a reachability contract behind it.
const LIVE_ARG = NOASSETS ? null : params.get('live');
const LIVE_BASE = !LIVE_ARG || LIVE_ARG === '0' ? null
: (LIVE_ARG === '1' ? 'http://127.0.0.1:8778' : LIVE_ARG.replace(/\/$/, ''));
let STOCK_SOURCING = null; // (godverseShopId) → 'real'|'mint'|null, from G's manifest (set below)
// [Lane F R9] preload ALL three packs so record dig sleeves AND book-spine/toy-box shelves (buy-anywhere)
// are real. Fail-soft per pack (missing → parody canvas). getStockPack(type) is what buildInterior reads.
// [Lane F R24 §6a — THE REAL CRATE] The town-wide packs stay exactly as they were (every shop without a
// GODVERSE identity keeps them, byte-identically). On TOP of them, every shop carrying A's `godverseShopId`
// preloads its OWN pack under its OWN base — because `getStockPack` is sync and the room is built the
// instant you open the door, so a pack first requested at enter() would not be resolved in time and the
// real shop would show parody on the walk-in. Cache identity is (type, base) since C's R24 fix, so these
// coexist with the town-wide packs rather than clobbering them. A keyed shop with no atlas 404s → null →
// parody: fail-soft runs through the same path, and that is the tier-0 rung of the ladder, live.
if (STOCK_REAL) {
preloadStockPack('record'); preloadStockPack('book'); preloadStockPack('toy');
// [Lane F R26 §7 — THE MANIFEST KILLS THE PROBES] R24 preloaded a pack for EVERY keyed shop and ate a
// 404 for each one that had no atlas. That was fail-soft working, but it was also console errors on
// every godverse boot, and F's smoke could only tolerate them by NAMING them — a tolerance is a
// confession, not a fix. G's manifest (F's R24 finding #4/#7) says exactly which (shop, type) pairs
// exist, so now we ask for nothing else: zero 404s because there is nothing absent to ask for, not
// because we stopped looking. Fail-soft still covers the real case — manifest says yes, file is gone.
// Only fetched when the town actually has keyed shops, so a plain town stays byte-identical (no fetch).
if ((plan.shops || []).some((s) => s.godverseShopId)) {
try {
const r = await fetch('assets/stock_godverse/index.json', { cache: 'force-cache' });
if (r.ok) {
const man = await r.json();
const byId = new Map((man.shops || []).map((s) => [s.godverseShopId, s]));
for (const s of (plan.shops || [])) {
const e = byId.get(s.godverseShopId);
if (e && (e.types || []).includes(s.type)) preloadStockPack(s.type, { base: godverseBaseFor(s) });
}
// [R27 §2] the same manifest already knows real vs mint — the tier-2 reader asks only real shops.
STOCK_SOURCING = (id) => (byId.get(id) || {}).sourcing || null;
}
} catch (e) { /* no manifest ⇒ no per-shop packs ⇒ parody. The ladder's bottom rung, unchanged. */ }
}
}
// [Lane F R8 — Lane C buy loop v0] ONE session wallet (seeded start cash) bound to the dig BUY across shops.
// Runtime-only: never writes back into the plan/room build, so goldens + draw-counts are unaffected.
// [Lane F R30 — v7 THE SAVE CORE] The game layer, default-ON (the v3.1 flip precedent): ?game=0 opts out,
// ?classic=1 forces its total absence (createGame never called ⇒ ZERO localStorage touches — the covenant
// stays a pure v2 boot). When the game is on, PROCITY.wallet IS game.wallet — Lane C's exact v0 interface
// (buy/sell/canBuy/…) backed by game-owned cash, so a loaded save's cash is authoritative and every
// existing consumer (dig onBuy, shelf buy, the cover charge, C's onSell) rides the proven seam unchanged.
// A fresh game opens with Lane C's seeded start cash, so day 1 is byte-identical money to a ?game=0 boot.
const GAME_ON = flagOn('game');
const TOWNKEY = `${PLANSRC}/${TOWN || 'default'}@${seed}`;
const baseWallet = createWallet(seed);
const game = GAME_ON ? createGame({
townKey: TOWNKEY, startCash: baseWallet.start(),
// fired after sleep()/import() moves the day: re-key the gig week (day % 7 into the existing schedule),
// wake at dawn (segment 0 — fires procity:segment, so every latch observes the morning), tell John.
onDay: (day) => {
if (gigState) gigState.setWeekNight(weekNightOf(day)); // (day 1) % 7 — see the seam note below
lighting.setSegment(0);
// [R34] the morning paper: GODBAY resolved before this hook fired — fold the results in
const news = game ? game.takeAuctionNews() : [];
hud.showToast(`🌅 day ${day}` + (news.length ? ' · ' + news.join(' · ') : ''), news.length ? 6 : 2.6);
},
}) : null;
const wallet = game ? game.wallet : baseWallet;
// [Lane F R12] the gig state machine (F-owned, js/world/gig_state.js): quiet → doors (DUSK) → on (NIGHT)
// → done, off Lane B's clock and Lane A's plan.gigs[0] (alpha's "tonight"). Constructed ONLY under ?gigs=1
// with a real schedule, so window.PROCITY.gigs stays undefined flags-off — which is precisely what Lane B's
// audio engine tests to decide there is no gig layer at all (byte-identical, no spill).
const gigState = (GIGS_ON && plan.gigs && plan.gigs.length) ? createGigState({ plan, lighting }) : null;
// [Lane F R30 — SLEEP=TOMORROW] key tonight's gigs off the game day: weekNight = (day 1) % 7, so a
// loaded save wakes mid-week and every sleep walks the EXISTING seeded week schedule. Measured against
// the brief's literal `day % 7`: day starts at 1, so the literal form would boot a FRESH game on night 1
// — flipping the default boot's gigs off night 0, breaking the R13 cover/band gates and B's night-0-keyed
// frontage before the player ever sleeps. Day 1 IS night 0 (the pre-v7 tonight), same convention as the
// day-1 stock streams (§30.3). Skipped entirely when either layer is off.
const weekNightOf = (day) => (Math.max(1, day) - 1) % 7;
if (game && gigState) gigState.setWeekNight(weekNightOf(game.day));
// [R33 — TRAVEL COSTS A DAY] this boot adopted a save from another town: the ride ate a day (save.js
// adopt() did the increment + pull-pruning; weekNight above already read the incremented day). Tell John.
// [R34] the ride also resolved any GODBAY listings the day moved past — the arrival toast carries the news.
if (game) {
const bootNews = game.takeAuctionNews();
if (game.traveled) hud.showToast(`🚌 ${plan.name || 'a new town'} — the ride cost a day. Day ${game.day}`
+ (bootNews.length ? ' · ' + bootNews.join(' · ') : ''), bootNews.length ? 7 : 4);
else if (bootNews.length) hud.showToast(bootNews.join(' · '), 6);
}
// ── [R35 — THE FIRST FIVE MINUTES (charter #7)] ────────────────────────────────────────────────
// A stranger arrives with cash and A WANT, and three toast-beats walk the whole hustle: dig →
// buy → sell-or-list. FRESH games only (day 1, empty crate, empty auction house) — a returning
// save gets its town back, not a tutorial. ?game=0 / ?classic=1 never reach this block. The
// first want is SEEDED (citySeed → the parody roster), so the splash line, the keeper rumors,
// and the tour all point at the same hunt on a given seed — deterministic, gate-checkable.
if (game && game.day === 1 && !game.collection.length && !game.listings.length) {
if (!game.wants.length) game.recordWant({ artist: pick(rng(plan.citySeed, 'firstwant', 0), ARTISTS) });
const hunt = document.getElementById('pc-hunt');
if (hunt && game.wants.length) {
hunt.textContent = `You're hunting ${game.wants[0].artist}$${game.cash} in your pocket.`;
hunt.style.display = 'block';
}
let stage = 1;
const go = document.getElementById('pc-go');
if (go) go.addEventListener('click', () =>
setTimeout(() => { if (stage === 1) hud.showToast('① find a record shop — walk in, press E at a crate', 7); }, 900),
{ once: true });
window.addEventListener('procity:digOpen', () => {
if (stage === 1) { stage = 2; hud.showToast('② riffle with the wheel · pull a sleeve · mind the guide — BUY', 7); }
});
let n3 = 0;
const offBeats = game.wallet.onChange(() => {
if (stage <= 2 && game.collection.length) {
stage = 3; n3 = game.collection.length;
hud.showToast('③ yours now. Sell it at a counter (E) — or ⚖ LIST it in the crate and SLEEP on it', 8);
} else if (stage === 3 && (game.listings.length || game.collection.length < n3)) {
stage = 4;
hud.showToast('Thats the whole hustle. ☆ want things · keepers gossip · 🚌 towns cost a day. Good digging.', 9);
offBeats();
}
});
}
// [Lane F R9] occupancyOf closure defers to `citizens` (declared just below); only invoked at enter()
// time (door click), long after citizens is initialized — so the forward reference is safe.
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS,
dig: DIG_ON, stockReal: STOCK_REAL, wallet, occupancyOf: (id) => citizens.occupancyOf(id),
rosterOf: (id) => citizens.tonightRoster(id), gigState, // [R15] identity continuity: the crowd IS tonight's roster
liveBase: LIVE_BASE, sourcingOf: (id) => (STOCK_SOURCING ? STOCK_SOURCING(id) : null), // [R27] tier 2
game }); // [R30] the collection (buy seam records finds) + the day salt (stock rotation, §30.3)
// [Lane F §F2] the riffle is cursor-driven (DOM buttons + click-a-sleeve), so release pointer-lock while it's
// open and re-lock (on click) after. digActive gates the unlock→leaveShop guard below so opening a bin
// doesn't read as "walked out of the shop".
window.addEventListener('procity:digOpen', () => player.unlock());
// [Lane F R30 §9.4] the sell card is cursor-driven too (SELL button, cycling) — same unlock contract.
window.addEventListener('procity:sellOpen', () => player.unlock());
// [Lane F §F1 — ROUND 7 ROSTER FLIP · prime-law amendment] The chunk-streamed roster is now the
// DEFAULT: the town is permanently busier. `?roster=v1` restores the old fixed roster (escape hatch);
// `?roster=stream` is still accepted (now a no-op — stream is default). Gated on Lane D's GO: E1's
// ped sub-mesh merge makes each near-rig ~1 draw, so full density (perChunk 16) = 241 worst-street
// draws ≤300. Plan goldens do NOT move (roster doesn't touch the plan). See LANE_D_NOTES → Lane F.
const rosterV1 = params.get('roster') === 'v1';
const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed, graph: plan.streets,
fleet, chunkStream: rosterV1 ? null : { radius: 2 } }); // default-on
citizens.setPopulation((parseInt(params.get('pop'), 10) || 140) >>> 0); // fixed-roster (?roster=v1) size; stream = per-chunk density
// [Lane F] hours-aware: keep the open-late block lively at night (Lane D3 setNightLivelyChunks). Keys via
// sim.chunkKeyAt so they match sim's own chunk-key format; openLate shop = hours close ≥22 (Lane A contract).
if (!rosterV1) {
const late = plan.shops.find((s) => s.hours && s.hours[1] >= 22);
const lot = late && plan.lots.find((l) => l.id === late.lot);
if (lot) {
const keys = [];
for (let dx = -1; dx <= 1; dx++) for (let dz = -1; dz <= 1; dz++) keys.push(citizens.chunkKeyAt(lot.x + dx * 64, lot.z + dz * 64));
citizens.setNightLivelyChunks(keys);
}
// [Lane F R8 — Lane D patronage] shop door points per chunk (facade normal → the street), fed once to the
// sim so streamed peds can duck into open shops they pass. Computed from the plan (sim stays graph-only).
const shopsByChunk = new Map();
for (const s of plan.shops) {
const l = plan.lots.find((x) => x.id === s.lot); if (!l) continue;
const ry = l.ry || 0, fx = -Math.sin(ry), fz = -Math.cos(ry);
const x = l.x + fx * (l.d / 2 + 0.6), z = l.z + fz * (l.d / 2 + 0.6);
const k = citizens.chunkKeyAt(x, z);
if (!shopsByChunk.has(k)) shopsByChunk.set(k, []);
shopsByChunk.get(k).push({ x, z, hours: s.hours, shopId: s.id }); // [R9] shopId → occupancy truth (Lane D)
}
citizens.setShops(shopsByChunk);
if (params.get('patronage') === '0') citizens.setPatronage(false); // off-switch
}
document.addEventListener('visibilitychange', () => citizens.setPaused(document.hidden));
// [Lane F R8 — Lane B weather] ?weather=1 seeded (seed 20261990 default town rolls 'clear'); ?weather=rain|
// overcast|clear forces a state. Self-contained module; flag-off never constructs it (byte-identical). D reads
// window.PROCITY.weather (always a valid {state,intensity} — {clear,0} when off), it does NOT import weather.js.
// [Lane F R16 flip] weather DEFAULT-ON (seeded — the default town rolls its own state); ?weather=rain|overcast|
// clear forces one; ?weather=0 or ?classic=1 → off (byte-identical). Self-contained; D reads window.PROCITY.weather.
const _wp = params.get('weather');
const weather = flagOn('weather')
? createWeather({ scene, plan, skins, lighting, camera, force: /^(clear|overcast|rain)$/.test(_wp || '') ? _wp : null })
: null;
const weatherState = weather ? weather.state : { state: 'clear', intensity: 0 };
// [Lane F R9 — Lane B tram] ?tram=1: one seeded tram looping the main-street spine, door-dwell at the
// bus shelters. Self-contained (2 draws); flag-off never constructs it (byte-identical). Non-blocking for v2.0.
const tram = flagOn('tram') ? createTram({ scene, plan, camera, lighting }) : null; // [R16 flip] default-on; ?tram=0 / ?classic=1 → off
// [Lane B R38 §1.4 — BACKYARD AUSTRALIA, the washing] The fence/hoist/aerial/tank ride buildings.js's
// existing boxMats InstancedMesh at +0 draws; only the cloth needs a mesh of its own. It is ONE
// TOWN-WIDE InstancedMesh (1 draw at any N, cap 256, spent proximity-first off the R37 chunk seam) —
// deliberately NOT per-chunk, which would have been +1 × up to 31 live chunks against 8 of margin.
// Costs exactly 0 on every real town (no house/yard lots ⇒ no hoists ⇒ no mesh) and at night (count 0).
const washing = (flagOn('washing') && flagOn('yards'))
? createWashing({ scene, plan, citySeed: plan.citySeed, chunks, camera, lighting }) : null;
// [Lane B R38 §1.3 — THE MAGPIE] One bird, one street, six weeks of the town's year. ONE town-wide
// InstancedMesh with count 0 or 1 ⇒ +1 draw while he is on screen and exactly 0 otherwise. Territory
// count scales with the road graph (one per 1.2 km of street), and only the nearest is instantiated,
// refreshed off ctx.onChunkBuilt — so bendigo_real's 188 km does not become a town with a magpie you
// never meet. `?magpie=1` forces the season open (the season is seeded per town); ?magpie=0 /
// ?classic=1 never construct it. The latch he sets is runtime-only: no localStorage, ever.
const magpie = flagOn('magpie')
? createMagpie({ scene, plan, citySeed: plan.citySeed, townKey: CHAR_TOWN, camera, chunks, lighting,
force: params.get('magpie') === '1' }) : null;
// [Lane F R12 — Lane B venue.js] the street side of the gig: E's poster skins at A's plan.posters (band name
// overprinted like a shop sign) + the lit pub frontage, whose marquee/sign glow F ramps from the gig state.
// Self-contained module; never constructed flags-off (byte-identical), like weather/tram.
const venuePresentation = gigState ? createVenuePresentation(plan, skins, scene) : null;
// [Lane F R13 — the outdoor queue] Lane D's VenueQueue, one per venue, wired from the street-side gig state
// (D shipped the class and left the street-side lifecycle to F — its documented seam). A seeded line forms at
// the frontage when the doors open, idle-shuffles + auto-drains each street frame, and is disposed by 'done'.
// B publishes the zone (venuePresentation.queueZoneFor); D's rig path is gate-protected (no giants) + a
// placeholder line under ?noassets. Built lazily per venue when its doors open; never touched flags-off.
const venueQueues = gigState ? new Map() : null; // venueShopId → VenueQueue
const queueCountOf = (id) => { const q = venueQueues && venueQueues.get(id); return q ? q.count() : 0; };
// [R32 §31.6-1 — THE CLUSTER-POSE SPAWN] The frozen pose (-96, 9, looking east) was tuned for the
// synthetic fixture; on real OSM towns it faced dirt and building-backs (the R31 playtest's #1).
// Non-classic boots now spawn at one END of the shop cluster's dominant axis, ON the footpath door-line
// (a point every shop already computes for patronage — known walkable), looking down the strip toward
// the cluster's heart. Deterministic (pure plan math, no draws — no stream moves), and ?classic=1
// keeps the frozen pose forever (the covenant is a POSE here, not just a plan).
let SPAWN = { x: -96, z: 9, yaw: -Math.PI / 2 };
if (!CLASSIC && plan.shops && plan.shops.length >= 2) {
// main-street shop doors only (block kind — both generators stamp it); every-door fallback
const msBlocks = new Set((plan.blocks || []).filter((b) => b.kind === 'mainstreet').map((b) => b.id));
const doorOf = (s) => {
const l = plan.lots.find((x) => x.id === s.lot); if (!l) return null;
const ry = l.ry || 0;
return { x: l.x - Math.sin(ry) * (l.d / 2 + 2.6), z: l.z - Math.cos(ry) * (l.d / 2 + 2.6), block: l.block };
};
let doors = plan.shops.map(doorOf).filter(Boolean);
const ms = doors.filter((d) => msBlocks.has(d.block));
if (ms.length >= 2) doors = ms;
if (doors.length >= 2) {
let cx = 0, cz = 0; for (const d of doors) { cx += d.x; cz += d.z; } cx /= doors.length; cz /= doors.length;
let vx = 0, vz = 0; for (const d of doors) { vx += (d.x - cx) ** 2; vz += (d.z - cz) ** 2; }
const ax = vx >= vz ? 'x' : 'z'; // the strip's dominant axis
let stand = doors[0], bd = Infinity; // stand at the door nearest the cluster's heart
for (const d of doors) { const dd = (d.x - cx) ** 2 + (d.z - cz) ** 2; if (dd < bd) { bd = dd; stand = d; } }
let lo = Infinity, hi = -Infinity;
for (const d of doors) { lo = Math.min(lo, d[ax]); hi = Math.max(hi, d[ax]); }
const dir = (hi - stand[ax] >= stand[ax] - lo) ? 1 : -1; // face the longer run of shopfronts
SPAWN = { x: stand.x, z: stand.z, yaw: ax === 'x' ? Math.atan2(-dir, 0) : Math.atan2(0, -dir) };
}
}
player.teleport(SPAWN.x, SPAWN.z, SPAWN.yaw);
chunks.warmup(player.position);
lighting.update(0.0001, player.position);
// ── mode state machine: map | street | interior ──
let MODE = 'street';
function setMode(m) {
MODE = m;
hud.setVisible(m === 'street');
minimap.setVisible(m === 'map');
keys.clear();
if (m === 'map') player.unlock(); // [Lane F] interior keeps first-person lock; street re-locks on click
}
// ── shop hours (Lane F §3.5) — Lane A ships hours:[open,close] as 24h ints + exactly one
// openLate:true landmark per town (always the video rental, closes ≥22). Current hour comes from
// lighting's 6-segment clock (getClock().hour is 'HH:MM'); at night only the openLate shop is open.
function currentHour() { const [h, m] = lighting.getClock().hour.split(':').map(Number); return h + (m || 0) / 60; }
function isOpen(shop) { return shop && shop.hours && currentHour() >= shop.hours[0] && currentHour() < shop.hours[1]; }
// ── door → interior hand-off (Lane B seam → Lane F bridge → Lane C buildInterior) ──
function enterShop(shopId, name) {
window.dispatchEvent(new CustomEvent('procity:enterShop', { detail: { shopId } }));
const shop = plan.shops.find((s) => s.id === shopId);
if (!shop) { hud.showToast(`🚪 ${name}`); return; }
// [Lane F §3.5] closed shops are locked; at night the openLate video shop is the one place still open
if (!isOpen(shop)) { hud.showToast(`🔒 ${name} — CLOSED · opens ${shop.hours[0]}:00`); return; }
// [Lane F R13 — cover charge, PER VENUE] John's ruling: free AND paid nights. A's `cover` is seeded per
// gig (~half 0). Paid → debit the R8 session wallet on the way in with a toast; too skint → a polite
// knockback and NO entry (the wallet stays honest); free → walk straight in. Re-entry the same night is
// free: the stamp lives in gigState, runtime only, so nothing persists and no plan/golden moves. THE
// DISTRICT: keyed by the venue being ENTERED (…Of(shopId)), so a night at two venues is two covers. Zero
// new UI — it rides the round-8 buy seam; the venue is a normal shop in every other respect.
if (gigState && gigState.openOf(shopId) && gigState.coverOf(shopId) > 0 && !gigState.paidOf(shopId)) {
const cover = gigState.coverOf(shopId), BAND = String(gigState.bandNameOf(shopId) || 'live').toUpperCase();
if (!wallet.canBuy(cover)) {
hud.showToast(`🚫 $${cover} cover — ${BAND} · you've got $${wallet.cash()}`);
return;
}
wallet.buy({ title: `cover — ${gigState.bandNameOf(shopId)}`, price: cover });
gigState.markPaidOf(shopId);
hud.showToast(`$${cover} cover — ${BAND}`);
}
// [Lane F integration] attach lot dims + open Lane C's interior, switch to interior mode
const lot = plan.lots.find((l) => l.id === shop.lot);
interiorMode.enter({ ...shop, lot: lot ? { w: lot.w, d: lot.d } : null }, name);
setMode('interior');
}
function leaveShop() { // [Lane F] dispose the interior + restore the player to the street door
interiorMode.exit();
setMode('street'); // pointer stayed locked while inside, so walking out resumes at once; Esc-exit re-locks on click
hud.showToast('🚪 back on the street');
}
window.addEventListener('procity:exitShop', () => { if (MODE === 'interior') leaveShop(); });
// ── input ──
const keys = new Set();
addEventListener('keydown', (e) => {
if (e.repeat) return;
keys.add(e.code);
if (e.code === 'BracketRight') lighting.stepSegment(1);
else if (e.code === 'BracketLeft') lighting.stepSegment(-1);
else if (e.code === 'KeyP') { if (MODE === 'street') takeShots(); } // [Lane F] street-only
else if (e.code === 'KeyM') { if (MODE !== 'interior') setMode(MODE === 'map' ? 'street' : 'map'); } // [Lane F] not while inside a shop
else if (e.code === 'KeyT') lighting.togglePaused();
});
addEventListener('keyup', (e) => keys.delete(e.code));
addEventListener('blur', () => keys.clear());
const startPanel = document.getElementById('pc-start');
document.getElementById('pc-startseed').textContent = `${plan.name} · seed ${plan.citySeed}`;
document.getElementById('pc-go').addEventListener('click', () => { startPanel.style.display = 'none'; player.lock(); });
player.controls.addEventListener('unlock', () => { keys.clear(); if (MODE === 'interior' && !interiorMode.digActive && !interiorMode.sellActive) leaveShop(); }); // [Lane F] Esc leaves the shop — but not when the unlock is us opening a dig (§F2) or the sell card (R30 §9.4)
// click the world to (re)grab the pointer when walking
canvas.addEventListener('click', () => { if (!player.isLocked && !interiorMode.digActive && !interiorMode.sellActive && (MODE === 'street' || MODE === 'interior')) player.lock(); }); // [Lane F §F2] re-lock for interior walk after closing a dig/sell card
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
composer.setSize(innerWidth, innerHeight);
});
// ── screenshot harness (P): three fixed cameras → PNG downloads into the browser ──
const SHOTS = [
{ name: 'main-street', pos: [-96, 1.7, 6], look: [0, 1.7, 6] },
{ name: 'plaza', pos: [0, 22, 40], look: [0, 0, 0] },
{ name: 'backstreets', pos: [3, 1.7, 30], look: [3, 1.7, 95] },
];
function takeShots() {
const savePos = camera.position.clone(), saveQuat = camera.quaternion.clone();
const wasMap = MODE; if (MODE !== 'street') setMode('street');
SHOTS.forEach((s, i) => {
camera.position.set(...s.pos); camera.lookAt(new THREE.Vector3(...s.look));
chunks.update(camera.position);
composer.render();
const a = document.createElement('a');
a.download = `procity-laneB-${String(i + 1).padStart(2, '0')}-${s.name}.png`;
a.href = canvas.toDataURL('image/png'); a.click();
});
camera.position.copy(savePos); camera.quaternion.copy(saveQuat);
if (wasMap !== 'street') setMode(wasMap); // restore the mode P was pressed in
hud.showToast('📸 3 shots saved to downloads');
}
// ── main loop ──
const clock = new THREE.Clock();
function frame() {
requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
if (MODE === 'street') {
player.update(dt, player.isLocked ? keys : EMPTY);
chunks.update(player.position);
lighting.update(dt, player.position);
const clk = lighting.getClock();
citizens.setTimeOfDay((clk.seg + clk.frac) / 6); // [Lane F] 0..1 day fraction → density (busy midday, empty at night)
// [Lane F R13 — THE DISTRICT] Friday night, off the clock: tick EVERY venue's latch, ramp Lane B's
// per-venue marquee/sign glow off the byVenue map, and point Lane D's patronage surge at each venue whose
// doors are open — several concurrent gigs each pull their own block (setGig(id,false) ends one → normal
// day patronage there). Lane B's audio engine reads window.PROCITY.gigs.byVenue on its own rAF for the
// per-genre muffled spill.
if (gigState) {
gigState.update(); // tick all latches
if (venuePresentation) venuePresentation.update(gigState.byVenue); // per-venue frontage/spill
for (const id of gigState.venueShopIds) {
const open = gigState.openOf(id);
citizens.setGig(id, open); // concurrent patronage surges
if (venueQueues) { // the outdoor line: forms while the doors are open, drains + disposes by 'done'
let q = venueQueues.get(id);
if (open && !q) {
q = new VenueQueue({ citySeed: plan.citySeed, fleet });
q.spawn(scene, { queueZone: venuePresentation && venuePresentation.queueZoneFor(id),
gigId: (gigState.gigOf(id) || {}).gigId | 0,
// [R15] identity continuity: relay each queue admit → D's tonight roster, so the
// punter who queued turns up in the interior crowd (clear is automatic at setGig(id,false)).
onAdmit: (entry) => citizens.recordVenueEntry(id, entry) });
venueQueues.set(id, q);
} else if (!open && q) { q.disposeAll(); venueQueues.delete(id); }
if (q) q.update(dt);
}
}
}
// [Lane F R4] no setExposure call: D3 (a5e4b64) made the impostor toneMapped:true, so three /
// OutputPass tone-map it globally via renderer.toneMappingExposure — the passthrough is now a no-op.
if (weather) weather.update(dt); // [Lane F R8] Lane B rain particles + wet ground
if (tram) tram.update(dt); // [Lane F R9] Lane B tram loop
if (washing) washing.update(dt); // [Lane B R38 §1.4] event-driven refill; early-outs most frames
if (magpie) magpie.update(dt); // [Lane B R38 §1.3] the swoop (pure fn of the launch record)
citizens.setWeather(weatherState); // [Lane F R8] Lane D rain reaction (thin + shelter)
citizens.update(dt); // [Lane F] reads camera pos internally for LOD tiers
hud.tickToast(dt);
hud.update(dt, { clock: clk, chunks: chunks.count }); // reads prev frame's total
renderer.info.reset(); // zero the counters, then let the composer's passes accumulate this frame
composer.render();
} else if (MODE === 'interior') {
hud.tickGame(); // [R32 §31.6-2] the crate/day/cash surface lives in shops too (yields to the cards)
if (interiorMode.update(dt, player.isLocked ? keys : EMPTY)) leaveShop(); // [Lane F] walk to the door → back to street
} else if (MODE === 'map') {
hud.tickGame(); // [R32] map mode: the same check hides the surface (the map owns the screen)
camera.getWorldDirection(_fwd);
minimap.draw(player.position, { x: _fwd.x, z: _fwd.z });
}
}
const EMPTY = new Set();
const _fwd = new THREE.Vector3();
frame();
// expose for debugging / Lane F
window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skins,
interiorMode, citizens, enterShop, leaveShop, isOpen, currentHour, fleet, noassets: NOASSETS,
townKey: CHAR_TOWN, // [Lane B R38 §1.2] the ?town= key (the plan deliberately doesn't carry it)
character, // [Lane B R38 §1.2] ground palette + sky bias + §1.1 ambience mix; frozen literals when null
weather: weatherState, // [Lane F R8] Lane B contract: {state,intensity}, {clear,0} when off (Lane D reads this)
wallet, // [Lane F R8] Lane C buy loop v0 (cash/buy/sell/count) — game-backed facade when the game is on
game, // [Lane F R30] THE GAME (LANE_F_NOTES §30 contract) — NULL under ?classic=1 / ?game=0
gigs: gigState, // [Lane F R12] the gig state machine — NULL flags-off (Lane B's audio tests this to
// decide there's no gig layer at all). `.state` is a live getter: quiet|doors|on|done
venuePresentation, // [Lane F R12] Lane B frontage + posters (null flags-off)
venueQueues, queueCountOf, // [Lane F R13] Lane D outdoor queue per venue + count accessor (F's smoke reads it)
washing, // [Lane B R38 §1.4] the town-wide washing layer (null flags-off / classic) — `.state`
magpie, // [Lane B R38 §1.3] the magpie (null flags-off / classic) — `.state`, `.swoopPos` (pure)
tram, // [Lane F R21] Lane B's tram — `.routeInfo` carries the R20 shop-adjacency verdict
// ({fenced, reason, shopsFronted}) + `.stops`; B built it FOR F's smoke. Null flags-off.
// [Lane F R16 — THE FLIP] the flag-intent surface: what SHOULD be on this boot (the gates verify intent vs
// reality). Default boot = all four flipped flags true; ?classic=1 = all false + classic true; each `=0` opts out.
flags: { classic: CLASSIC, gigs: GIGS_ON, weather: flagOn('weather'), winmap: flagOn('winmap'), tram: flagOn('tram'),
game: GAME_ON, // [R30] the v7 game layer (default-on; ?game=0 / ?classic=1 → off)
dig: DIG_ON, // [R31] the dig flip (default-on; ?dig=0 / ?classic=1 → off) — gate reads intent here
// [R38 wave 1] yards is read by buildings.js (?yards=0 reverts the pre-R38 house/yard geometry);
// washing/magpie are shell-constructed; ambient is the §1.1 audio layer (read by audio.js).
yards: flagOn('yards'), washing: flagOn('washing') && flagOn('yards'),
magpie: flagOn('magpie'), ambient: flagOn('ambient'), verge: ground.verge },
THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks
// [Lane B R11 audio] street WebAudio engine — self-unlocking on the first gesture; silent with
// zero/blocked assets or ?mute=1; ?noassets=1 ⇒ no audio fetches. Exposes window.PROCITY.audio so
// Lane F can play interior beds through it (playInterior/stopInterior via Lane C's room.audio).
import('./js/world/audio.js')
.then((m) => { window.PROCITY.audio = m.createAudioEngine(window.PROCITY, { noassets: NOASSETS }); })
.catch((e) => console.warn('[procity] audio engine load failed:', e));
// [Lane B] QA debug harness (window.DBG) — loaded only with ?dbg=1 (LANE_F_NOTES §4). Logic lives
// in js/world/dbg.js; the shell just hands it the systems it owns so shots.py/soak.py can drive it.
if (params.get('dbg')) {
import('./js/world/dbg.js')
.then((m) => m.installDBG({ plan, camera, renderer, composer, chunks, lighting, player, hud,
setMode, getMode: () => MODE, enterShop, interiorMode })) // [R24] interiorMode → DBG.stockInfo()
.catch((e) => console.warn('[procity] DBG harness load failed:', e));
}
} // end main()
</script>
</body>
</html>