Lane F R8: wire town/weather/patronage/buy + graduate harness + new smokes

Shell seams (F owns index.html): A &town=<key> -> generatePlanFor(seed,'osm',{town}) (Katoomba);
B weather (createWeather on ?weather=1; PROCITY.weather={state,intensity}, {clear,0} off; update in
loop); C session wallet bound to the dig BUY (getCash/onBuy in interior_mode); D patronage door
points -> citizens.setShops + ?patronage=0 off-switch + setWeather each frame.

Harness: stock=real + weather=rain graduated warn -> STRICT. New warn smokes: buy-v0 (dig BUY ->
wallet, cash 191->179 bag 0->1), patronage (7 peds visit shop doors/~18s), book/toy stock (6/11
real atlas meshes). Determinism gate: pinned osm/katoomba golden 0x0f652510 keyed on (seed,plansrc,
town) + fixed a hex zero-pad bug the katoomba golden exposed (0x0f652510 vs unpadded 0xf652510).

No patronage baseline re-pin needed (nets fewer draws, within tolerance). qa.sh --strict GREEN 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-15 09:09:11 +10:00
parent 3daf8df3ec
commit 9cc00ad29c
7 changed files with 195 additions and 51 deletions

View File

@ -4,7 +4,22 @@
---
## Round 7 (the flip round → v2.0-beta) — IN PROGRESS
## Round 8 (catch-up + the town comes alive → v2.0-rc) — DONE ✅
All lanes landed (E packs, B weather, A 2nd town, C buy, D patronage). F wired the shell seams,
graduated the harness, tagged.
| task | status |
|---|---|
| **shell wiring (F owns index.html)** | ✅ **A** `&town=<key>``generatePlanFor(seed,'osm',{town})` (Katoomba, 19 shops, verified). **B** weather (3-line contract): `createWeather` on `?weather=1`, `PROCITY.weather={state,intensity}` ({clear,0} when off for D), `weather.update` in loop. **C** session `wallet` (createWallet(seed)) bound to the dig BUY (`getCash`/`onBuy` in interior_mode). **D** patronage: shop door-points per chunk → `citizens.setShops`, `?patronage=0` off-switch, `citizens.setWeather` each frame. All boot clean (0 console errors, settled). |
| **F1 — smoke graduations + additions** | ✅ `stock=real` + `weather=rain` **graduated warn→STRICT**. New warn smokes: **buy-v0** (dig BUY→wallet: cash 191→179, bag 0→1), **patronage** (7 peds visit shop doors/~18s), **book/toy stock** (6/11 real atlas meshes). Katoomba golden `0x0f652510` pinned in the determinism gate (fixed a hex zero-pad bug it caught). |
| **F2 — patronage baseline** | ✅ No re-pin needed — flags-off regression held (patronage nets *fewer* draws, inside tolerance; ≤300 cap + draws-±40% both green). |
| **F3 — tag v2.0-rc** | ✅ **TAGGED.** qa.sh --strict GREEN 5/5 (harness strict). |
| **Marshal** | All lanes committed round-8; no slips this round. |
---
## Round 7 (the flip round → v2.0-beta) — DONE ✅ (v2.0-beta tagged)
Critical path resolved: E1 ped-merge (`447188a`), E2 stock pack (`0c12dac`), **D's GO** (`43edabf`),
C1 `?stock=real` (`3724a65`) all landed. F executes flip → strict harness → tag.

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 834 KiB

View File

@ -245,51 +245,138 @@ def interior_budget(p):
finally:
b.close()
def _enter_type(pg, t, extra=''):
"""Enter an open shop of type `t`; returns the shop name or {err}."""
return pg.evaluate("""(t) => {
const P=window.PROCITY, D=window.DBG; D.setSegment(2);
const s=(P.plan.shops||[]).find(x=>x.type===t && P.isOpen(x));
if(!s) return {err:'no open '+t+' shop'};
D.enterShop(s.id); return { shop: s.name };
}""", t)
def _pack_ready(pg, t):
try:
pg.wait_for_function("async () => { const m = await import('./js/interiors/interiors.js');"
f" return !!(m.getStockPack && m.getStockPack('{t}')); }}", timeout=12000)
return True
except Exception:
return False
def smoke_stock(p):
"""NEW FLAG (warn-level, decision #4): ?stock=real → real GODVERSE sleeves in the crates.
Assert non-parody: real sleeves are batched atlas meshes tagged userData.isStock (Lane C recipe)."""
head('SMOKE: stock=real (?stock=real — real sleeves; new flag → warn-level)')
"""GRADUATED STRICT (R8): ?stock=real → real GODVERSE sleeves (batched userData.isStock atlas meshes)."""
head('SMOKE: stock=real (?stock=real — real record sleeves; STRICT r8)')
b, pg, errs = new_page(p)
try:
boot(pg, 'stock=real&dig=1')
ready = False
try: # the shell preloads the pack async — wait for getStockPack('record') to resolve
pg.wait_for_function(
"async () => { const m = await import('./js/interiors/interiors.js');"
" return !!(m.getStockPack && m.getStockPack('record')); }", timeout=12000)
ready = True
except Exception:
pass
res = pg.evaluate("""() => {
const P=window.PROCITY, D=window.DBG; D.setSegment(2);
const rec=(P.plan.shops||[]).find(s=>s.type==='record' && P.isOpen(s));
if(!rec) return {err:'no open record shop'};
D.enterShop(rec.id);
let n=0; P.interiorMode.current.group.traverse(o=>{ if(o.userData&&o.userData.isStock&&o.material&&o.material.map) n++; });
return { shop: rec.name, isStock: n };
}""")
if res.get('err'): WARN(f"stock=real: {res['err']}")
elif not ready: WARN(f"stock=real: pack didn't preload in 12s — parody fallback (fail-soft); {res['isStock']} isStock meshes")
elif res['isStock'] > 0: OK(f"stock=real: {res['isStock']} real atlas sleeve mesh(es) in '{res['shop']}' — non-parody, batched")
else: WARN(f"stock=real: pack loaded but 0 isStock atlas meshes in '{res['shop']}'")
if errs: WARN(f"stock=real: {len(errs)} console error(s); first: {errs[0][:140]}")
ready = _pack_ready(pg, 'record')
r = _enter_type(pg, 'record')
if r.get('err'): FAIL(f"stock=real: {r['err']}"); return
n = pg.evaluate("() => { let n=0; window.PROCITY.interiorMode.current.group.traverse(o=>{ if(o.userData&&o.userData.isStock&&o.material&&o.material.map) n++; }); return n; }")
if not ready: FAIL(f"stock=real: record pack didn't preload in 12s (E ships it) — got {n} isStock meshes")
elif n > 0: OK(f"stock=real: {n} real atlas sleeve mesh(es) in '{r['shop']}' — non-parody, batched")
else: FAIL(f"stock=real: pack loaded but 0 isStock atlas meshes in '{r['shop']}'")
if errs: FAIL(f"stock=real: {len(errs)} console error(s); first: {errs[0][:140]}")
finally:
b.close()
def smoke_weather(p):
"""NEW FLAG (warn-level): ?weather=1 (Lane B). Auto-skips if not landed yet."""
"""GRADUATED STRICT (R8): ?weather=rain → rain THREE.Points layer + PROCITY.weather.state=='rain'."""
head('SMOKE: weather (?weather=rain — rain particles + wet ground; STRICT r8)')
b, pg, errs = new_page(p)
try:
boot(pg, 'weather=1')
w = pg.evaluate("() => { const P=window.PROCITY||{}; const w=P.weather||(P.lighting&&P.lighting.weather); "
"return w ? { active:!!w.active, particles:!!(w.particles||w.rain), wet:!!w.wet } : null; }")
if not w:
print("· weather=1 not landed (no weather subsystem) — skipping (Lane B)")
return
head('SMOKE: weather=1 (new flag → warn-level)')
if w['active'] and w['particles']: OK(f"weather=1 active with particle layer: {w}")
else: WARN(f"weather=1 present but incomplete: {w}")
if errs: WARN(f"weather=1: {len(errs)} console error(s); first: {errs[0][:140]}")
boot(pg, 'weather=rain')
st = pg.evaluate("() => { const P=window.PROCITY; return { state: P.weather&&P.weather.state, "
"intensity: P.weather&&P.weather.intensity, rainPts: !!P.scene.getObjectByProperty('type','Points') }; }")
if st['state'] == 'rain' and st['rainPts']:
OK(f"weather=rain: rain Points present + PROCITY.weather.state=='rain' (intensity {st['intensity']})")
else: FAIL(f"weather=rain broken: {st}")
if errs: FAIL(f"weather=rain: {len(errs)} console error(s); first: {errs[0][:140]}")
finally:
b.close()
def smoke_buy(p):
"""NEW (warn): buy-v0 — open dig, pull + BUY a sleeve, assert wallet cash dropped + item banked."""
head('SMOKE: buy-v0 (dig BUY → session wallet; new → warn-level)')
b, pg, errs = new_page(p)
try:
boot(pg, 'dig=1')
setup = pg.evaluate("""() => {
const P=window.PROCITY, D=window.DBG; D.setSegment(2);
const rec=(P.plan.shops||[]).find(s=>s.type==='record' && P.isOpen(s));
if(!rec) return {err:'no record shop'};
D.enterShop(rec.id);
const bins=[]; P.interiorMode.current.group.traverse(o=>{if(o.userData&&o.userData.kind==='bin')bins.push(o)});
if(!bins.length) return {err:'no bins'};
const bp=new P.THREE.Vector3(); bins[0].getWorldPosition(bp);
P.camera.position.set(bp.x,1.6,bp.z+1.4); P.camera.lookAt(bp.x,bp.y+0.4,bp.z); P.camera.updateMatrixWorld();
return { cash0: P.wallet.cash(), count0: P.wallet.count() };
}""")
if setup.get('err'): WARN(f"buy-v0: {setup['err']}"); return
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown',{code:'KeyE'}))") # open dig
pg.wait_for_timeout(600)
# pull the front sleeve (canvas click, no drag → dig.pull) then click its BUY IT button
pg.evaluate("""() => { const c=window.PROCITY.renderer.domElement;
c.dispatchEvent(new PointerEvent('pointerdown',{clientX:640,clientY:360,bubbles:true}));
c.dispatchEvent(new PointerEvent('pointerup',{clientX:640,clientY:360,bubbles:true})); }""")
pg.wait_for_timeout(400)
res = pg.evaluate("""() => {
const w=window.PROCITY.wallet, cash0=w.cash(), count0=w.count();
const btn=document.querySelector('.pcdg-panel button');
if(!btn) return {err:'no BUY panel (pull failed)'};
if(btn.disabled) return {err:'BUY disabled (broke?)'};
btn.click();
return { cash0, count0, cash1:w.cash(), count1:w.count() };
}""")
if res.get('err'): WARN(f"buy-v0: {res['err']}")
elif res['cash1'] < res['cash0'] and res['count1'] == res['count0'] + 1:
OK(f"buy-v0: bought 1 — cash {res['cash0']}{res['cash1']}, bag {res['count0']}{res['count1']}")
else: WARN(f"buy-v0: unexpected wallet delta {res}")
if errs: WARN(f"buy-v0: {len(errs)} console error(s); first: {errs[0][:140]}")
finally:
b.close()
def smoke_patronage(p):
"""NEW (warn): peds duck into open shops — assert ≥1 ped parks at a shop door over N seconds."""
head('SMOKE: patronage (peds visit open shops; new → warn-level)')
b, pg, errs = new_page(p)
try:
boot(pg, '') # default: streamed roster + patronage on
# advance the sim ~12s of ticks at a busy midday node; count peds flagged as inside/at-door
res = pg.evaluate("""() => {
const P=window.PROCITY, D=window.DBG; D.setSegment(2);
const plan=P.plan, deg={}; for(const e of plan.streets.edges){deg[e.a]=(deg[e.a]||0)+1;deg[e.b]=(deg[e.b]||0)+1;}
let best=null,bd=0; for(const n of plan.streets.nodes){const d=deg[n.id]||0;if(d>bd){bd=d;best=n;}}
D.teleport(best.x,best.z,0); P.citizens.setPopulation(200);
let everInside=0; const seen=new Set();
for(let i=0;i<360;i++){ P.citizens.update(0.05);
const list=P.citizens._activeList||[];
for(const c of list){ if(c && (c.patronInside||c.inside||c.patronTarget) && !seen.has(c.id)){ seen.add(c.id); everInside++; } }
}
return { active:(P.citizens._activeList||[]).length, everVisited: everInside, patronOn: P.citizens.patronageOn };
}""")
if not res['patronOn']: WARN("patronage: off (patronageOn=false) — expected default-on")
elif res['everVisited'] > 0: OK(f"patronage: {res['everVisited']} ped(s) headed to/into a shop door over ~18s (active {res['active']})")
else: WARN(f"patronage: 0 peds visited a shop over ~18s (active {res['active']}) — check door points / timing")
if errs: WARN(f"patronage: {len(errs)} console error(s); first: {errs[0][:140]}")
finally:
b.close()
def smoke_booktoy(p):
"""NEW (warn): book/toy packs — ?stock=real static shelf stock (real spine/box atlas meshes)."""
head('SMOKE: book/toy stock (?stock=real — real spines/boxes; new → warn-level)')
b, pg, errs = new_page(p)
try:
boot(pg, 'stock=real')
for t in ('book', 'toy'):
_pack_ready(pg, t)
r = _enter_type(pg, t)
if r.get('err'): WARN(f"book/toy: {r['err']}"); continue
n = pg.evaluate("() => { let n=0; window.PROCITY.interiorMode.current.group.traverse(o=>{ if(o.userData&&o.userData.isStock&&o.material&&o.material.map) n++; }); return n; }")
if n > 0: OK(f"{t} stock: {n} real atlas mesh(es) in '{r['shop']}'")
else: WARN(f"{t} stock: 0 isStock atlas meshes in '{r['shop']}' (pack missing → parody fallback?)")
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
pg.wait_for_timeout(150)
if errs: WARN(f"book/toy: {len(errs)} console error(s); first: {errs[0][:140]}")
finally:
b.close()
@ -308,8 +395,11 @@ def main():
for fl in flags:
smoke(p, fl.split('=')[0], fl)
smoke(p, 'ALL-ON combo', '&'.join(flags), is_combo=True)
smoke_stock(p) # new flag (warn-level) — ?stock=real
smoke_weather(p) # new flag (warn-level) — ?weather=1 (auto-skips if not landed)
smoke_stock(p) # STRICT (r8) — ?stock=real real record sleeves
smoke_weather(p) # STRICT (r8) — ?weather=rain
smoke_buy(p) # new (warn) — buy-v0
smoke_patronage(p) # new (warn) — peds visit open shops
smoke_booktoy(p) # new (warn) — book/toy real stock
finally:
if srv: srv.terminate()

View File

@ -214,20 +214,23 @@ if (existsSync(R('web/js/citygen/plan.js'))) {
info(`plan(1234): ${p1?.shops?.length ?? '?'} shops, ${p1?.lots?.length ?? '?'} lots, ` +
`${p1?.streets?.edges?.length ?? '?'} edges, name "${p1?.name ?? '?'}"`);
// [F2] plan-source selector (?plansrc=osm) — pin BOTH goldens keyed by (seed, plansrc).
// Lane A contract (LANE_A_NOTES F2): synthetic 0x3fa36874 (selfcheck), osm 0x34cfdec0.
// [F2/R8] plan-source selector — pin goldens keyed by (seed, plansrc, TOWN).
// Lane A contract: synthetic 0x3fa36874 (selfcheck) · osm/melbourne 0x34cfdec0 · osm/katoomba 0x0f652510.
// Guarded: no-op until A ships generatePlanFor + generatePlanOSM.
try {
const bar = await import(R('web/js/citygen/index.js'));
if (typeof bar.generatePlanFor === 'function' && typeof bar.generatePlanOSM === 'function') {
const { xmur3 } = await import(R('web/js/core/prng.js'));
const oa = bar.generatePlanOSM(20261990), ob = bar.generatePlanOSM(20261990);
assert(JSON.stringify(oa) === JSON.stringify(ob),
'generatePlanOSM(20261990) is deterministic across two runs', 'OSM plan NON-DETERMINISTIC');
const oh = (xmur3(JSON.stringify(oa))() >>> 0).toString(16);
assert(`0x${oh}` === '0x34cfdec0',
`osm plan fingerprint == golden 0x34cfdec0 ("${oa.name}", ${oa.shops?.length} shops)`,
`osm plan fingerprint DRIFTED: 0x${oh} (expect 0x34cfdec0)`);
const TOWN_GOLDENS = { melbourne: '0x34cfdec0', katoomba: '0x0f652510' };
for (const [town, golden] of Object.entries(TOWN_GOLDENS)) {
const a = bar.generatePlanFor(20261990, 'osm', { town }), b2 = bar.generatePlanFor(20261990, 'osm', { town });
assert(JSON.stringify(a) === JSON.stringify(b2),
`osm/${town} plan is deterministic across two runs`, `osm/${town} plan NON-DETERMINISTIC`);
const h = (xmur3(JSON.stringify(a))() >>> 0).toString(16).padStart(8, '0'); // pad: goldens are 0x + 8 hex
assert(`0x${h}` === golden,
`osm/${town} fingerprint == golden ${golden} ("${a.name}", ${a.shops?.length} shops)`,
`osm/${town} fingerprint DRIFTED: 0x${h} (expect ${golden})`);
}
assert(bar.generatePlanFor(20261990, 'osm').source === 'osm'
&& (bar.generatePlanFor(20261990, 'synthetic').source || 'synthetic') === 'synthetic',
'generatePlanFor routes synthetic|osm correctly', 'generatePlanFor selector mis-routes');

View File

@ -53,7 +53,9 @@ import { createPlayer } from './js/world/player.js';
import { createHUD } from './js/world/hud.js';
import { createMinimap } from './js/world/minimap.js';
import { createInteriorMode } 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 { loadPedFleet } from './js/citizens/rigs.js'; // [Lane F §3.4] Lane D rig fleet (GLB peds/keepers)
import { preloadManifest, preloadStockPack } from './js/interiors/interiors.js'; // [Lane F] Lane E manifest + Lane C/E stock pack
@ -75,14 +77,15 @@ const NOASSETS = params.has('noassets') && params.get('noassets') !== '0';
// 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)
let plan;
try {
const citygen = await import('./js/citygen/index.js');
const gen = citygen.generatePlanFor
? (s) => citygen.generatePlanFor(s, PLANSRC) // A's selector (synthetic | osm)
? (s) => citygen.generatePlanFor(s, PLANSRC, { town: TOWN }) // A's selector (synthetic | osm[+town])
: (citygen.generatePlan || citygen.default); // pre-R6 fallback (synthetic only)
plan = gen ? gen(seed) : fixturePlan(seed);
console.log(`[procity] plan source: ${PLANSRC} — ${plan.name}, ${plan.shops.length} shops`);
console.log(`[procity] plan source: ${PLANSRC}${TOWN ? '/' + TOWN : ''} — ${plan.name}, ${plan.shops.length} shops`);
} catch (e) {
plan = fixturePlan(seed);
console.log('[procity] Lane A citygen absent — running on fixture_plan');
@ -146,7 +149,10 @@ const DIG_ON = params.has('dig') && params.get('dig') !== '0';
// 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;
if (STOCK_REAL) preloadStockPack('record');
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS, dig: DIG_ON, stockReal: STOCK_REAL });
// [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.
const wallet = createWallet(seed);
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS, dig: DIG_ON, stockReal: STOCK_REAL, wallet });
// [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".
@ -170,9 +176,31 @@ if (!rosterV1) {
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 });
}
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.
const _wp = params.get('weather');
const weather = (_wp && _wp !== '0')
? createWeather({ scene, plan, skins, lighting, camera, force: /^(clear|overcast|rain)$/.test(_wp) ? _wp : null })
: null;
const weatherState = weather ? weather.state : { state: 'clear', intensity: 0 };
// spawn on the footpath near the west end of the strip, looking east down main street
player.teleport(-96, 9, -Math.PI / 2);
chunks.warmup(player.position);
@ -276,6 +304,8 @@ function frame() {
citizens.setTimeOfDay((clk.seg + clk.frac) / 6); // [Lane F] 0..1 day fraction → density (busy midday, empty at night)
// [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
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
@ -295,6 +325,8 @@ 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,
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 (session wallet: cash/buy/count)
THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks
// [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.

View File

@ -26,7 +26,7 @@ const ARM_DIST = 2.0; // must step this far from the door before the exit arms
const EXIT_DIST = 1.0; // …then coming back within this distance leaves to the street
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
dig: digEnabled = false, stockReal = false }) {
dig: digEnabled = false, stockReal = false, wallet = null }) {
const scene = new THREE.Scene();
scene.background = new THREE.Color('#0e0b07');
@ -81,6 +81,10 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
shopName: (current && current.recipe && current.recipe.label) || (currentShop && currentShop.name),
shop: currentShop,
stockAdapter: currentAdapter, // [F2 §stock=real] real covers in the riffle (null → parody)
// [F R8 — Lane C buy loop v0] bind the session wallet so pulling a sleeve deducts cash + banks it.
// dig removes the pulled sleeve from the bin; onBuy returns false when broke (no state change).
getCash: wallet ? () => wallet.cash() : undefined,
onBuy: wallet ? (item) => wallet.buy(item) : undefined,
onClose: () => window.dispatchEvent(new CustomEvent('procity:digClose')),
});
window.dispatchEvent(new CustomEvent('procity:digOpen')); // shell releases pointer-lock for the cursor