diff --git a/F-progress.md b/F-progress.md index 583a6d0..2e078d0 100644 --- a/F-progress.md +++ b/F-progress.md @@ -42,6 +42,35 @@ parody stock — hash the VERTEX data). Full record: `LANE_F_NOTES` §30. **Held for the later F session (per the brief):** ledger #5 gates + the sell-card E-key routing. +### Wave 3 — THE GATES SESSION: the routing landed, five STRICT gates, the loop closes end-to-end + +**The held join is in.** E inside a shop now routes: **aimed bin → dig · near counter with sellables → +C's sell card · forgiving nearest-bin → dig · shelf-buy** (`interior_mode.js`; 3 marked shell seams in +`index.html` — `procity:sellOpen` unlock + `sellActive` on the Esc/relock guards, the dig's exact +contract). `onSell` does F's two §9.3 halves in the safe order: `removeFind` first (veto ⇒ nothing +moves), credit second. **The loop, measured through the real DOM:** dig pull **$9 debited** → walk to +the counter → E → card → SELL **+$4** (< $9, the clamp) → collection 1→0 → street HUD **$186 · +CRATE 0**. One amendment filed with its measurement: the old nearest-bin fallback ignored aim and would +have swallowed E at any counter near a bin — aimed-raycast now outranks the sell trigger, the +forgiving pick runs after it; both branches proven live (Moe Vinyl counter: card opens; Kelpie Sound +bin, wrong aim: dig opens). + +**The gates (ledger #5), all landed as STRICT suite smokes in `flags_check.py` — no more session-local +instruments:** (a) **save/load determinism** — scripted buy 2/sell 1/sleep 2× via B's real button → +export → fresh browser → import **byte-equal** (state 243 B, normalized export 269 B), plan fp +`0xb1c7843e` **unchanged** across the import, weekNight re-keyed 2; (b) **no-pump v0** — 5 real +UI round trips **all strictly negative** (Σ −$30), plus **C's negative control**: broke player, +`wallet.buy` invoked-and-false on the real shelf path ⇒ **recordFind unreachable, zero entry** (dig BUY +disables outright), with a positive arm proving the control discriminates; (c) **classic purity** — +Storage-prototype instrument pre-page-script: `?classic=1`/`?game=0` **0 calls, 0 game DOM ids**, +positive control sees the default boot's `procity-save` read + all 7 ids, plan fp game-on == game-off; +(d) **rotation determinism over VERTEX DATA** (the wave-3 binding) — day 1/2/3 fps all distinct, +re-entry byte-identical each day, **cross-boot byte-identical off the persisted save**, `?game=0` == +day 1 (88,320 hashed bytes / 20 stock meshes printed — the subject provably present). Harness bug #3 +caught en route: the HUD is hidden in interiors by design; the gate read it on the street. +**NO TAG** — per the brief, the alpha tags when it plays (Fable's call, likely R31). Full record: +`LANE_F_NOTES` §30.6. + --- ## Round 26 (v5.0-beta — EVERY CRATE DIFFERENT) — F: the manifest consumed, the tolerance retired diff --git a/docs/LANES/LANE_F_NOTES.md b/docs/LANES/LANE_F_NOTES.md index 2285093..8891dab 100644 --- a/docs/LANES/LANE_F_NOTES.md +++ b/docs/LANES/LANE_F_NOTES.md @@ -1613,3 +1613,53 @@ collection/SLEEP surface in flight. The contract-first order did exactly what it **Held / next session (per the brief):** ledger #5 gates (save/load determinism scripted session · no-pump v0 · classic-purity localStorage gate · rotation determinism) — a LATER F session. The sell-card E-key routing (dig → sell → shelf priority, §9.4) rides the sell wiring pass alongside it. + +### §30.6 THE GATES SESSION (wave 3) — the routing landed, five gates wired STRICT, all green + +All measured on the house harness (:8130, fresh Playwright contexts per gate — no module cache to lie). +Committed as five new STRICT smokes in `tools/flags_check.py` (`smoke_sell_routing` · +`smoke_save_determinism` · `smoke_nopump` · `smoke_classic_purity` · `smoke_rotation`), so every future +`qa.sh` run re-proves them; the session-local instruments from §30.5 are now suite property. + +**THE E-KEY SELL ROUTING (§9.4, the held join) — landed + verified live through the real UI:** +`interior_mode.js` grew `trySell()` (C's `createSell`/`nearCounter`, lazily built, only when the game is +on) and the E chain is now: **AIMED bin → dig · else near counter with ≥1 sellable → sell card · else +forgiving nearest-bin → dig · else shelf-buy.** F does both §9.3 halves in `onSell`, in this order: +`game.removeFind(item)` FIRST (false ⇒ keeper veto — no credit can exist for an item not in the +collection), then `wallet.sell(item, offer)`. Shell seams (3 lines, `index.html`): `procity:sellOpen` → +`player.unlock()`, and `sellActive` guards the Esc-leaves-shop + click-relock paths (the dig's exact +contract). The full loop, measured through the real DOM: **dig pull $9 debited → entry +{type:'record', sku|slotId, pricePaid:9} → E at the counter → C's card → SELL +$4 (< $9, the clamp) → +collection 1→0 → back on the street B's HUD reads $186 · CRATE 0.** Negative legs: empty collection ⇒ +E opens NOTHING (open() false, zero DOM); `?game=0` ⇒ no game, no card, zero errors. + +**One routing amendment, filed with the measurement (F's own file, F's own routing):** `binUnderAim`'s +forgiving fallback picks the nearest bin within 2.5 m of the camera and **ignores aim** — at a counter +that sits near a bin it would swallow every E and the sell card could never open there. The chain now +asks aimed-raycast FIRST (the §9.4 contract's literal "aimed bin"), lets the sell trigger decline, and +only then runs the forgiving pick. Both sides proven live: at Moe Vinyl's counter-adjacent bin the card +now opens (`sell:true, dig:false` — the old chain opened the dig); by a bin away from the counter with +a sellable held and aim wrong, the dig still opens (`dig:true, sell:false` — the fallback survives). + +**THE GATES (ledger #5) — each with its subject named and touched:** + +| gate | verdict + numbers | +|---|---| +| (a) save/load determinism | scripted session buy 2 · sell 1 · sleep 2× (via **B's real SLEEP button**) → day 3 · $177 · 1 item → export → FRESH browser → import: state **byte-equal** (243 B) + savedAt-normalized export **byte-equal** (269 B) · plan fp **unchanged** `0xb1c7843e` across export→import (the delta law's falsifiable form) · weekNight re-keyed to 2 == (3−1)%7 · 0 errors both contexts | +| (b) no-pump v0 | 5 real round trips (dig UI pull+BUY → counter card SELL-all): **5/5 strictly negative, deltas [−6,−6,−6,−6,−6], Σ −$30** — identical deltas are the deterministic re-seeded crate, not a stuck instrument (the controls prove discrimination) | +| (b′) C's negative control | broke player ($0 via import): dig BUY **disabled**, clicked anyway — zero debit, zero entry · shelf path: `wallet.buy` **INVOKED once (counted, not mocked), returned false, recordFind unreachable** — no entry, cash still $0 · positive arm: same aim with cash → debit $14 == entry.pricePaid, 1 entry — **C's pump is dead, and the control can tell** | +| (c) classic purity | Storage **prototype** instrumented before any page script: `?classic=1` AND `?game=0` → **0 calls**, game null, **0 of B's 7 game DOM ids** · POSITIVE control: default boot → instrument saw the `procity-save` getItem + all 7 ids present (a zero nobody proved the instrument could see would be vacuous) · plan fp game-on **==** game-off `0xb1c7843e` (the layer moves zero plan bytes; classic's own fp differs by design — its golden is classic_regression's) | +| (d) rotation determinism | **VERTEX-DATA fp (position+uv arrays), per the wave-3 binding** — transforms measured vacuous in §30.5: day 1 `0x50edd91b` (**88,320 bytes / 20 stock meshes** — subject present, printed) · re-entry byte-identical · day 2 `0x2121f187` ≠ day 1, re-entry identical · day 3 `0x2f4a9b93` ≠ both · **cross-boot: day-3 fp byte-identical after a full reboot** off the persisted save · `?game=0` fp **== day-1 fp** (the pre-v7 town IS day 1, measured again from the suite) | +| (e) the suite | all five wired **STRICT** into flags_check (FAIL-level, house pattern) · `qa.sh --strict --matrix` full run: see §30.6 numbers below | + +**Harness bug #3 of the round (recorded per house habit):** the routing gate first read B's HUD +*inside* the shop and called it stale — but the HUD is **hidden and unrefreshed in interior mode by +design** (`hud.setVisible(m === 'street')`; `gameUI.refresh()` rides the street loop). The gate now +walks back onto the street and reads it where a player could — $186 · CRATE 0, correct. Same species +as §30.5's closed-door: a verify that forgets the world's rules reads the rules as bugs. + +**Design fact, filed not fixed (alpha-acceptable, for the beta ledger):** parody/mint dig stock has no +`gone` tracking — the same $11 sleeve is back in the bin on re-entry (the crate is a seeded stream, not +an inventory). Buying it again is possible **and loses money every time** (the −$6/trip measurement is +exactly this); scarcity-per-day would need the save to carry per-day pull records. The no-pump clamp is +what makes the infinite crate safe. Rule it in beta with the guide bands, not here. diff --git a/tools/flags_check.py b/tools/flags_check.py index 6484cea..99794c8 100644 --- a/tools/flags_check.py +++ b/tools/flags_check.py @@ -1520,6 +1520,531 @@ def smoke_continuity(p): b.close() +# ═══ R30 v7.0-alpha — THE GATES (ledger #5). Every gate below names its subject and proves it +# touched it (the vacuous-gate law): storage counts come from a prototype instrument with a POSITIVE +# control, the rotation fp hashes VERTEX DATA (the wave-3 binding — F measured the transform fp +# vacuous over batched parody stock), and the no-pump run drives the REAL dig/sell UI, not the API. ═══ + +GAME_DOM_IDS = ['pc-game', 'pc-gamebar', 'pc-crate', 'pc-crate-btn', 'pc-sleep-btn', 'pc-day', 'pc-cash'] + +# Installed BEFORE any page script (add_init_script) — counts every Storage-prototype method call. +# "Measure, don't trust": save.js promises zero module-scope storage access; this sees through it. +STORAGE_INSTRUMENT = """(() => { + window.__storageCalls = []; + for (const m of ['getItem', 'setItem', 'removeItem', 'clear', 'key']) { + const orig = Storage.prototype[m]; + Storage.prototype[m] = function (...a) { + window.__storageCalls.push(m + ':' + String(a.length ? a[0] : '')); + return orig.apply(this, a); + }; + } +})();""" + +# In-page FNV-1a over the serialized plan — the world fingerprint for the delta-law asserts. +JS_PLAN_FP = """() => { const s = JSON.stringify(window.PROCITY.plan); let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } + return h >>> 0; }""" + +# The stock fingerprint: FNV-1a over the position + uv ARRAYS of every isStock/buyMesh mesh in the +# open room. NOT transforms — batchRoom merges parody stock at identity transform, so a transform +# hash reads constants and reports "no rotation" over stock that rotates (F's own R30 harness bug). +# The day-salted picks/jitters live in the vertex data; bytes/meshes come back so the caller can +# prove the subject was present (a hash over zero bytes is a green light over nothing). +JS_VERTEX_FP = """() => { + const P = window.PROCITY; let h = 0x811c9dc5 >>> 0, bytes = 0, meshes = 0; + const mix = (arr) => { const u = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength); + for (let i = 0; i < u.length; i++) { h = (h ^ u[i]) >>> 0; h = Math.imul(h, 0x01000193) >>> 0; } + bytes += u.length; }; + P.interiorMode.current.group.traverse((o) => { + const u = o.userData || {}; + if (!(u.isStock || u.buyMesh) || !o.geometry) return; + meshes++; + const pos = o.geometry.getAttribute('position'), uv = o.geometry.getAttribute('uv'); + if (pos) mix(pos.array); if (uv) mix(uv.array); + }); + return { fp: h >>> 0, bytes, meshes }; +}""" + + +def _game_state(pg): + """The byte-comparable game state + a savedAt-normalized export (both stringified IN PAGE so key + order is identical on every read — python re-serialization would reorder and fake a diff).""" + return pg.evaluate("""() => { const g = window.PROCITY.game; + const ex = JSON.parse(g.export()); delete ex.savedAt; + return { state: JSON.stringify({ day: g.day, cash: g.cash, town: g.townKey, collection: g.collection }), + exNorm: JSON.stringify(ex), day: g.day, cash: g.cash, n: g.collection.length }; }""") + + +def _at_bin(pg): + """Stand at the first bin of the open room, aimed at it (smoke_buy's proven setup).""" + return pg.evaluate("""() => { + const P = window.PROCITY; + 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 { ok: true }; + }""") + + +def _dig_buy(pg): + """One REAL buy: E at the aimed bin → riffle → pull the front sleeve → click BUY → WALK OUT. + Returns {cash0,n0,disabled,cash1,n1} read off the live wallet + collection.""" + pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))") + pg.wait_for_timeout(500) + 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(350) + r = pg.evaluate("""() => { + const P = window.PROCITY, g = P.game, w = P.wallet; + const btn = document.querySelector('.pcdg-panel button'); + if (!btn) return { err: 'no pull panel (pull failed)' }; + const cash0 = w.cash(), n0 = g.collection.length, disabled = btn.disabled; + btn.click(); + const out = document.querySelector('.pcdg-out'); if (out) out.click(); + return { cash0, n0, disabled, cash1: w.cash(), n1: g.collection.length }; + }""") + pg.wait_for_timeout(250) + return r + + +def _counter_sell(pg, max_sales=10): + """Walk to the counter, press E — THE REAL ROUTING opens C's sell card — then click SELL up to + max_sales times (card closes itself when nothing sellable remains). Camera looks UP so the + bin-aim fallback can't win the E (aimed bin → dig is the routing's first branch, by contract).""" + r = pg.evaluate("""() => { + const P = window.PROCITY, room = P.interiorMode.current; + const c = room.counter && room.counter.pose; + if (!c) return { err: 'no counter pose' }; + P.camera.position.set(c.x, 1.6, c.z + 1.2); + P.camera.lookAt(c.x, 4.0, c.z); P.camera.updateMatrixWorld(); // aim above the room — no bin under aim + window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' })); + const panel = document.querySelector('.pcsl-panel'); + return { opened: !!(panel && panel.style.display === 'block'), sellActive: P.interiorMode.sellActive }; + }""") + if r.get('err') or not r.get('opened'): + return r + sold = 0 + for _ in range(max_sales): + s = pg.evaluate("""() => { + const p = document.querySelector('.pcsl-panel'); + if (!p || p.style.display !== 'block') return { open: false }; + const b = p.querySelector('button'); + if (!b || b.disabled) return { open: true, stuck: true }; + b.click(); return { open: true, sold: true }; + }""") + if not s.get('open') or s.get('stuck'): + break + sold += 1 + pg.wait_for_timeout(60) + pg.evaluate("""() => { const p = document.querySelector('.pcsl-panel'); + if (p && p.style.display === 'block') { const x = p.querySelector('.x'); if (x) x.click(); } }""") + pg.wait_for_timeout(100) + r['sold'] = sold + return r + + +def _enter_record_shop(pg): + """Midday, first OPEN record shop whose counter sits > 2.6 m from every bin (so counter-E can + never fall into binUnderAim's 2.5 m nearest-bin fallback — the routing's dig branch would win). + Returns the shop id it settled in, or an err.""" + return pg.evaluate("""() => { + const P = window.PROCITY, D = window.DBG; D.setSegment(2); + const recs = (P.plan.shops || []).filter(s => s.type === 'record' && P.isOpen(s)); + if (!recs.length) return { err: 'no open record shop' }; + for (const s of recs.slice(0, 6)) { + D.enterShop(s.id); + const room = P.interiorMode.current; + const c = room.counter && room.counter.pose; + const bins = []; room.group.traverse(o => { if (o.userData && o.userData.kind === 'bin') bins.push(o); }); + if (c && bins.length) { + const wp = new P.THREE.Vector3(); + const near = bins.some(b => { b.getWorldPosition(wp); return Math.hypot(wp.x - c.x, wp.z - c.z) < 2.6; }); + if (!near) return { shop: s.id, name: s.name, bins: bins.length }; + } + D.exitShop(); + } + return { err: 'no record shop with counter clear of bins in the first 6' }; + }""") + + +def smoke_sell_routing(p): + """R30 §9.4 — THE HELD JOIN, verified live: dig → buy → walk to the counter → E → C's sell card + → SELL → wallet credits, collection shrinks via game.removeFind, B's HUD follows. Also the two + negative routing legs: E at the counter with NOTHING sellable opens no card; ?game=0 never routes.""" + head('GATE R30·routing: the E-key sell routing (dig → counter → sell card → credit + removeFind + HUD)') + b, pg, errs = new_page(p) + try: + boot(pg, 'dig=1') + # HUD present (B's game bar — the gate reads it after the sale) + try: + pg.wait_for_function("() => !!document.getElementById('pc-cash')", timeout=8000) + except Exception: + pass + r0 = _enter_record_shop(pg) + if r0.get('err'): FAIL(f"routing: {r0['err']}"); return + # negative leg FIRST: empty collection ⇒ E at the counter opens NOTHING (open() false, no DOM) + r_neg = _counter_sell(pg, max_sales=0) + if r_neg.get('opened'): FAIL('routing: sell card opened over an EMPTY collection (open() must be false)') + else: OK('routing: E at the counter with nothing sellable → no card, no DOM (fail-closed)') + # buy one through the real dig + _at_bin(pg) + rb = _dig_buy(pg) + if rb.get('err') or rb.get('disabled'): FAIL(f"routing: dig buy failed — {rb}"); return + paid = rb['cash0'] - rb['cash1'] + if rb['n1'] == rb['n0'] + 1 and paid > 0: + OK(f"routing: dig buy — ${paid} debited, collection {rb['n0']}→{rb['n1']}") + else: + FAIL(f"routing: dig buy wallet/collection delta wrong — {rb}"); return + ent = pg.evaluate("""() => { const g = window.PROCITY.game; + const e = g.collection[g.collection.length - 1]; + return { type: e.type, pricePaid: e.pricePaid, hasId: !!(e.sku || e.slotId) }; }""") + if ent['type'] == 'record' and ent['hasId'] and ent['pricePaid'] == paid: + OK(f"routing: entry shape honest — type=record, sku|slotId set, pricePaid == debit (${paid})") + else: + FAIL(f"routing: entry shape wrong — {ent}") + # the sale, through the card + cash_before = pg.evaluate("() => window.PROCITY.wallet.cash()") + n_before = pg.evaluate("() => window.PROCITY.game.collection.length") + rs = _counter_sell(pg, max_sales=1) + if rs.get('err') or not rs.get('opened'): + FAIL(f"routing: E at the counter did not open the sell card — {rs}"); return + # HUD refreshes in STREET mode only (it is hidden inside a shop by design — setVisible(m==='street')), + # so "the HUD follows" is measured where the player can see it: back on the street, frames run. + pg.evaluate("() => window.DBG.exitShop()") + pg.wait_for_timeout(500) + after = pg.evaluate("""() => { const P = window.PROCITY; + const cashEl = document.getElementById('pc-cash'), crateBtn = document.getElementById('pc-crate-btn'); + return { cash: P.wallet.cash(), n: P.game.collection.length, + hudCash: cashEl ? cashEl.textContent : null, + hudCrate: crateBtn ? crateBtn.textContent : null }; }""") + credited = after['cash'] - cash_before + if rs.get('sold') == 1 and after['n'] == n_before - 1 and 0 < credited < paid: + OK(f"routing: SELL — +${credited} credited (< ${paid} paid, no-pump by construction), collection {n_before}→{after['n']}") + else: + FAIL(f"routing: sale delta wrong — sold={rs.get('sold')}, credited {credited} vs paid {paid}, n {n_before}→{after['n']}") + if after['hudCash'] is not None: + if after['hudCash'] == str(after['cash']) and after['hudCrate'] == f"CRATE {after['n']}": + OK(f"routing: B's HUD follows — $" + after['hudCash'] + f" · {after['hudCrate']}") + else: + FAIL(f"routing: HUD stale — hudCash {after['hudCash']} vs {after['cash']}, {after['hudCrate']} vs CRATE {after['n']}") + else: + WARN('routing: game bar not built in this context — HUD leg unobserved (B verified it R30w2)') + if errs: FAIL(f"routing: {len(errs)} console error(s); first: {errs[0][:140]}") + else: OK('routing: 0 console errors') + finally: + b.close() + + # ?game=0: the routing must be inert — E at the counter does nothing, zero sell DOM, zero errors + b, pg, errs = new_page(p) + try: + boot(pg, 'dig=1&game=0') + r = 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)); + D.enterShop(rec.id); + const c = P.interiorMode.current.counter.pose; + P.camera.position.set(c.x, 1.6, c.z + 1.2); P.camera.lookAt(c.x, 4.0, c.z); P.camera.updateMatrixWorld(); + window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' })); + return { game: !!P.game, panels: document.querySelectorAll('.pcsl-panel').length, + sellActive: P.interiorMode.sellActive }; + }""") + if not r['game'] and r['panels'] == 0 and not r['sellActive']: + OK('routing: ?game=0 — no game, E at the counter is a no-op, zero sell DOM') + else: + FAIL(f"routing: ?game=0 leaked — {r}") + if errs: FAIL(f"routing/game=0: {len(errs)} console error(s); first: {errs[0][:140]}") + finally: + b.close() + + +def smoke_save_determinism(p): + """R30 gate (a) — scripted session (buy 2, sell 1, sleep 2×) → export → FRESH boot → import → + byte-equal game state AND world fingerprint unchanged (the delta law's falsifiable form).""" + head('GATE R30a: save/load determinism (buy 2 · sell 1 · sleep 2× → export → fresh boot → import)') + b, pg, errs = new_page(p) + exported = None; snap0 = None; fp0 = None + try: + boot(pg, 'dig=1') + r0 = _enter_record_shop(pg) + if r0.get('err'): FAIL(f"save-det: {r0['err']}"); return + for i in (1, 2): + _at_bin(pg) + rb = _dig_buy(pg) + if rb.get('err') or rb.get('disabled') or rb['n1'] != rb['n0'] + 1: + FAIL(f"save-det: scripted buy {i} failed — {rb}"); return + rs = _counter_sell(pg, max_sales=1) + if rs.get('err') or not rs.get('opened') or rs.get('sold') != 1: + FAIL(f"save-det: scripted sell failed — {rs}"); return + pg.evaluate("() => window.DBG.exitShop()") + pg.wait_for_timeout(200) + try: # B's game bar builds lazily on street frames — prefer the REAL surface + pg.wait_for_function("() => !!document.getElementById('pc-sleep-btn')", timeout=4000) + except Exception: + pass + slept = pg.evaluate("""() => { const g = window.PROCITY.game; + const btn = document.getElementById('pc-sleep-btn'); + if (btn) { btn.click(); btn.click(); } else { g.sleep(); g.sleep(); } + return { day: g.day, viaBtn: !!btn }; }""") + if slept['day'] != 3: FAIL(f"save-det: 2 sleeps → day {slept['day']} (want 3)"); return + via = "via B's SLEEP button" if slept['viaBtn'] else "via game.sleep() — HUD bar absent in this context" + OK(f"save-det: scripted session done — bought 2, sold 1, slept 2× → day 3 ({via})") + snap0 = _game_state(pg) + exported = pg.evaluate("() => window.PROCITY.game.export()") + fp0 = pg.evaluate(JS_PLAN_FP) + print(f" subject: day {snap0['day']} · ${snap0['cash']} · {snap0['n']} item(s) · plan fp {fp0:#010x}") + if errs: FAIL(f"save-det/session: {len(errs)} console error(s); first: {errs[0][:140]}"); return + finally: + b.close() + + b, pg, errs = new_page(p) + try: + boot(pg, 'dig=1') # fresh browser = fresh localStorage = a day-1 game + fp1 = pg.evaluate(JS_PLAN_FP) + if fp1 != fp0: FAIL(f"save-det: fresh boot plan fp {fp1:#010x} != {fp0:#010x} (seeded world drifted?)"); return + r = pg.evaluate("""(saved) => { const g = window.PROCITY.game; + const fresh = { day: g.day, n: g.collection.length }; + const ok = g.import(saved); + return { ok, fresh, week: window.PROCITY.gigs ? window.PROCITY.gigs.weekNight : null }; }""", exported) + if not (r['ok'] and r['fresh']['day'] == 1 and r['fresh']['n'] == 0): + FAIL(f"save-det: import on a fresh boot — {r}"); return + snap1 = _game_state(pg) + fp2 = pg.evaluate(JS_PLAN_FP) + if snap1['state'] == snap0['state'] and snap1['exNorm'] == snap0['exNorm']: + OK(f"save-det: BYTE-EQUAL after import — state ({len(snap1['state'])} bytes) and savedAt-normalized " + f"export ({len(snap1['exNorm'])} bytes) identical to the exporting session") + else: + FAIL(f"save-det: state diverged — day {snap1['day']} vs {snap0['day']}, cash {snap1['cash']} vs " + f"{snap0['cash']}, n {snap1['n']} vs {snap0['n']}") + if fp2 == fp0: + OK(f"save-det: world fingerprint UNCHANGED across export→import ({fp2:#010x}) — the delta law holds") + else: + FAIL(f"save-det: import MOVED THE WORLD — plan fp {fp2:#010x} != {fp0:#010x}") + if r['week'] == 2: + OK('save-det: day-derived state applied on import — weekNight 2 == (3−1)%7') + else: + FAIL(f"save-det: weekNight {r['week']} after importing day 3 (want 2)") + if errs: FAIL(f"save-det/import: {len(errs)} console error(s); first: {errs[0][:140]}") + else: OK('save-det: 0 console errors both contexts') + finally: + b.close() + + +def smoke_nopump(p): + """R30 gate (b) — buy-then-immediately-sell ×5 through the REAL dig + sell-card paths → strictly + negative per trip. PLUS C's negative control (the wave-3 binding): with cash 0, the real buy paths + invoke wallet.buy, it returns false, and NO entry enters the collection — the pump C found is dead.""" + head('GATE R30b: the no-pump gate v0 (5 real round trips → monotonic loss · the debit-gate negative control)') + b, pg, errs = new_page(p) + try: + boot(pg, 'dig=1&stock=real') + _pack_ready(pg, 'book') # the negative control's shelf needs the book pack + r0 = _enter_record_shop(pg) + if r0.get('err'): FAIL(f"no-pump: {r0['err']}"); return + deltas = [] + for i in range(5): + _at_bin(pg) + cash0 = pg.evaluate("() => window.PROCITY.wallet.cash()") + rb = _dig_buy(pg) + if rb.get('err') or rb.get('disabled') or rb['n1'] != rb['n0'] + 1: + FAIL(f"no-pump: trip {i + 1} buy failed — {rb}"); return + rs = _counter_sell(pg) # sell EVERYTHING so no held item distorts the trip + if rs.get('err') or not rs.get('opened') or rs.get('sold', 0) < 1: + FAIL(f"no-pump: trip {i + 1} sell failed — {rs}"); return + cash1 = pg.evaluate("() => window.PROCITY.wallet.cash()") + deltas.append(cash1 - cash0) + if all(d <= -1 for d in deltas): + OK(f"no-pump: 5/5 round trips strictly negative — deltas {deltas} (Σ {sum(deltas)}) — " + f"the spread is the house's, by the sellOffer clamp") + else: + FAIL(f"no-pump: a round trip did NOT lose money — deltas {deltas}") + + # ── C's negative control: no debit ⇒ no entry, on the REAL paths ── + broke = pg.evaluate("""() => { const g = window.PROCITY.game; + const ex = JSON.parse(g.export()); ex.cash = 0; ex.savedAt = Date.now(); + const ok = g.import(JSON.stringify(ex)); + return { ok, cash: g.cash, n: g.collection.length }; }""") + if not (broke['ok'] and broke['cash'] == 0): + FAIL(f"no-pump: could not set up the broke player — {broke}"); return + # arm 1: the dig — pull panel's BUY must be disabled; clicking it moves nothing + _at_bin(pg) + rd = _dig_buy(pg) + if rd.get('err'): + FAIL(f"no-pump/control: dig arm — {rd}") + elif rd['disabled'] and rd['n1'] == rd['n0'] and rd['cash1'] == 0: + OK('no-pump/control dig: BUY disabled at $0 — clicked anyway, zero debit, zero entry') + else: + FAIL(f"no-pump/control dig: {rd} — an unpaid entry got in or cash moved") + # arm 2: the shelf — wallet.buy IS invoked (counted, behavior untouched), returns false, + # and recordFind stays unreachable. This is the exact pump C built by accident, proven dead. + pg.evaluate("() => window.DBG.exitShop()") + pg.wait_for_timeout(200) + ra = pg.evaluate("""() => { + const P = window.PROCITY, D = window.DBG; D.setSegment(2); + const bk = (P.plan.shops || []).find(s => s.type === 'book' && P.isOpen(s)); + if (!bk) return { err: 'no open book shop' }; + D.enterShop(bk.id); + const meshes = []; P.interiorMode.current.group.traverse(o => { + if (o.userData && o.userData.buyMesh && o.userData.buyItems && o.userData.buyItems.length) meshes.push(o); }); + if (!meshes.length) return { err: 'no buy meshes (book pack absent)' }; + const m = meshes[0], it = m.userData.buyItems[0], wp = new P.THREE.Vector3(); + m.localToWorld(wp.copy(it.center)); + P.camera.position.set(wp.x, wp.y, wp.z + 1.0); P.camera.lookAt(wp.x, wp.y, wp.z); P.camera.updateMatrixWorld(); + const g = P.game, w = P.wallet, n0 = g.collection.length; + const orig = w.buy; const calls = []; // instrument the REAL facade — count, don't mock + w.buy = function (o) { const r = orig.call(this, o); calls.push(r); return r; }; + window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' })); + w.buy = orig; + return { calls, n0, n1: g.collection.length, cash: w.cash() }; + }""") + if ra.get('err'): + FAIL(f"no-pump/control shelf: {ra['err']}") + elif ra['calls'] == [False] and ra['n1'] == ra['n0'] and ra['cash'] == 0: + OK("no-pump/control shelf: wallet.buy INVOKED once, returned false, recordFind unreachable — " + "zero entry, cash still $0 (C's pump is dead)") + else: + FAIL(f"no-pump/control shelf: {ra} — buy not invoked, or an entry minted without a debit") + # positive arm (the control's control): with cash restored the SAME aim buys — the gate discriminates + rp = pg.evaluate("""() => { const g = window.PROCITY.game, w = window.PROCITY.wallet; + const ex = JSON.parse(g.export()); ex.cash = 100; ex.savedAt = Date.now(); + g.import(JSON.stringify(ex)); + const n0 = g.collection.length, cash0 = w.cash(); + window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' })); + const n1 = g.collection.length, cash1 = w.cash(); + const e = g.collection[g.collection.length - 1] || {}; + return { n0, n1, cash0, cash1, paidMatches: n1 === n0 + 1 && e.pricePaid === cash0 - cash1 }; }""") + if rp['n1'] == rp['n0'] + 1 and rp['cash1'] < rp['cash0'] and rp['paidMatches']: + OK(f"no-pump/control positive: same aim with cash — debit ${rp['cash0'] - rp['cash1']} == entry.pricePaid, " + f"1 entry in (the control can tell success from failure)") + else: + FAIL(f"no-pump/control positive: {rp} — the negative control was measuring a dead path") + if errs: FAIL(f"no-pump: {len(errs)} console error(s); first: {errs[0][:140]}") + else: OK('no-pump: 0 console errors') + finally: + b.close() + + +def smoke_classic_purity(p): + """R30 gate (c) — ?classic=1 and ?game=0: ZERO Storage-prototype calls (instrumented before any + page script), zero game DOM (B's ids), game null. Default boot is the POSITIVE control (the + instrument must SEE calls, and the game DOM must exist) — without it a broken instrument passes + forever. Plus the plan delta: game layer on vs off moves ZERO plan bytes.""" + head('GATE R30c: classic purity (instrumented Storage calls · zero game DOM · plan fp game-on == game-off)') + res = {} + for q, name in (('classic=1', 'classic'), ('game=0', 'game0'), ('', 'default')): + b, pg, errs = new_page(p) + try: + pg.add_init_script(STORAGE_INSTRUMENT) + boot(pg, q) + if name == 'default': + try: pg.wait_for_function("() => !!document.getElementById('pc-game')", timeout=8000) + except Exception: pass + r = pg.evaluate("""(ids) => ({ + count: window.__storageCalls.length, first: window.__storageCalls.slice(0, 6), + saveTouched: window.__storageCalls.some(c => c.includes('procity-save')), + game: !!(window.PROCITY && window.PROCITY.game), + dom: ids.filter(id => !!document.getElementById(id)), + })""", GAME_DOM_IDS) + r['fp'] = pg.evaluate(JS_PLAN_FP) + r['errs'] = list(errs) + res[name] = r + finally: + b.close() + for name in ('classic', 'game0'): + r = res[name] + if r['count'] == 0 and not r['game'] and not r['dom']: + OK(f"?{'classic=1' if name == 'classic' else 'game=0'}: ZERO Storage calls · game null · zero game DOM") + else: + FAIL(f"?{name}: storage calls {r['count']} (first {r['first']}), game={r['game']}, DOM leak {r['dom']}") + if r['errs']: FAIL(f"?{name}: {len(r['errs'])} console error(s); first: {r['errs'][0][:140]}") + d = res['default'] + if d['count'] > 0 and d['saveTouched'] and d['game']: + OK(f"positive control: default boot — instrument SAW {d['count']} Storage call(s) incl. procity-save, game live") + else: + FAIL(f"positive control BROKEN: default boot count {d['count']}, saveTouched {d['saveTouched']}, game {d['game']}" + " — the zero readings above are unproven") + if d['dom'] == GAME_DOM_IDS: + OK('positive control: all 7 game DOM ids present on the default boot') + else: + WARN(f"positive control: game DOM partial on default boot — {d['dom']} (headless rAF timing?)") + if res['game0']['fp'] == d['fp']: + OK(f"plan fp game-on == game-off ({d['fp']:#010x}) — the game layer moves ZERO plan bytes") + else: + FAIL(f"plan fp DIFFERS with the game layer: game0 {res['game0']['fp']:#010x} vs default {d['fp']:#010x}") + print(f" (?classic plan fp {res['classic']['fp']:#010x} differs by design — gig-less plan; its golden is classic_regression's)") + + +def smoke_rotation(p): + """R30 gate (d) — rotation determinism over VERTEX DATA (the wave-3 binding): same (seed, day) → + byte-identical stock fp (re-entry AND across a reboot); day 1→2→3 each actually different; + ?game=0 == day 1 (the day-1 convention). Subject proven present: bytes and mesh count printed.""" + head('GATE R30d: rotation determinism (vertex-data fp · same day byte-identical · day N ≠ N+1 · cross-boot)') + b, pg, errs = new_page(p) + shop_id = None; fps = {} + try: + boot(pg, '') + r0 = _enter_record_shop(pg) + if r0.get('err'): FAIL(f"rotation: {r0['err']}"); return + shop_id = r0['shop'] + + def fp_of(day_label): + r = pg.evaluate(JS_VERTEX_FP) + if r['bytes'] == 0 or r['meshes'] == 0: + FAIL(f"rotation: VACUOUS — {day_label}: 0 bytes / 0 stock meshes hashed (no subject)"); return None + return r + + def reenter(): + pg.evaluate("(id) => { window.DBG.exitShop(); }", shop_id); pg.wait_for_timeout(150) + pg.evaluate("(id) => { window.DBG.setSegment(2); window.DBG.enterShop(id); }", shop_id) + pg.wait_for_timeout(150) + + a1 = fp_of('day1'); reenter(); a2 = fp_of('day1 re-entry') + if not a1 or not a2: return + if a1['fp'] == a2['fp']: OK(f"day 1: re-entry byte-identical (fp {a1['fp']:#010x}, {a1['bytes']:,} bytes / {a1['meshes']} meshes)") + else: FAIL(f"day 1: re-entry fp moved {a1['fp']:#010x} → {a2['fp']:#010x} — same (seed, day) not deterministic") + pg.evaluate("() => { window.DBG.exitShop(); window.PROCITY.game.sleep(); }") + reenter(); b1 = fp_of('day2'); reenter(); b2 = fp_of('day2 re-entry') + if not b1 or not b2: return + if b1['fp'] != a1['fp']: OK(f"day 2 ≠ day 1: stock ROTATED ({a1['fp']:#010x} → {b1['fp']:#010x})") + else: FAIL(f"day 2 == day 1: fp {b1['fp']:#010x} — rotation is not happening (or the fp is vacuous)") + if b1['fp'] == b2['fp']: OK(f"day 2: re-entry byte-identical ({b1['fp']:#010x})") + else: FAIL(f"day 2: re-entry fp moved — not deterministic within the day") + pg.evaluate("() => { window.DBG.exitShop(); window.PROCITY.game.sleep(); }") + reenter(); c1 = fp_of('day3') + if not c1: return + if c1['fp'] != b1['fp'] and c1['fp'] != a1['fp']: + OK(f"day 3 ≠ day 2 ≠ day 1 ({c1['fp']:#010x}) — each day is its own crate") + else: + FAIL(f"day 3 fp {c1['fp']:#010x} collides (day1 {a1['fp']:#010x}, day2 {b1['fp']:#010x})") + pg.evaluate("() => window.PROCITY.game.save()") + fps = { 'day1': a1['fp'], 'day3': c1['fp'] } + # cross-boot: reload the SAME browser context (localStorage carries day 3) — same (seed, day) again + boot(pg, '') + day = pg.evaluate("() => window.PROCITY.game.day") + if day != 3: FAIL(f"rotation: reboot loaded day {day} (want 3 — the save didn't carry)"); return + pg.evaluate("(id) => { window.DBG.setSegment(2); window.DBG.enterShop(id); }", shop_id) + pg.wait_for_timeout(150) + r = pg.evaluate(JS_VERTEX_FP) + if r['fp'] == fps['day3']: OK(f"cross-boot: day-3 fp byte-identical after a full reboot ({r['fp']:#010x})") + else: FAIL(f"cross-boot: day-3 fp {r['fp']:#010x} != pre-reboot {fps['day3']:#010x} — rotation not seed-stable") + if errs: FAIL(f"rotation: {len(errs)} console error(s); first: {errs[0][:140]}") + finally: + b.close() + if not fps: return + # the day-1 convention: a ?game=0 boot's stock is byte-identical to a fresh game's day 1 + b, pg, errs = new_page(p) + try: + boot(pg, 'game=0') + pg.evaluate("(id) => { window.DBG.setSegment(2); window.DBG.enterShop(id); }", shop_id) + pg.wait_for_timeout(150) + r = pg.evaluate(JS_VERTEX_FP) + if r['fp'] == fps['day1']: OK(f"?game=0 == day 1 ({r['fp']:#010x}) — the pre-v7 town IS day 1, measured") + else: FAIL(f"?game=0 fp {r['fp']:#010x} != day-1 fp {fps['day1']:#010x} — the day-1 convention broke") + finally: + b.close() + + def main(): srv = ensure_server() try: @@ -1548,6 +2073,11 @@ def main(): smoke_gigs(p) # R12/13 — the district gig layer (schedule · band · crowd · budget · cover · noassets) smoke_continuity(p) # R15 — identity continuity (the ped you followed is in the crowd · settled tris) smoke_glance(p) # R29 — Spike 1: v6's first clip in the game (classic zero-fetch) + smoke_sell_routing(p) # R30 — the E-key sell routing (§9.4, the held join, live loop) + smoke_save_determinism(p) # R30 gate a — export → fresh boot → import byte-equal, world untouched + smoke_nopump(p) # R30 gate b — 5 real round trips monotonic loss + C's negative control + smoke_classic_purity(p) # R30 gate c — instrumented Storage zero + zero game DOM + positive control + smoke_rotation(p) # R30 gate d — vertex-data rotation determinism (the wave-3 binding) smoke_tier2(p) # R27 — the kill-the-server gate (charter law #1) smoke_real_crate(p) # R24 — THE REAL CRATE (per-shop atlas identity · soft fall · no-op off the path) finally: diff --git a/web/index.html b/web/index.html index e806b6d..8e8f6cc 100644 --- a/web/index.html +++ b/web/index.html @@ -285,6 +285,8 @@ const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, // 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 @@ -419,9 +421,9 @@ 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) leaveShop(); }); // [Lane F] Esc leaves the shop — but not when the unlock is us opening a dig (§F2) +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 && (MODE === 'street' || MODE === 'interior')) player.lock(); }); // [Lane F §F2] re-lock for interior walk after closing a dig +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(); diff --git a/web/js/world/interior_mode.js b/web/js/world/interior_mode.js index fc55864..a8fc536 100644 --- a/web/js/world/interior_mode.js +++ b/web/js/world/interior_mode.js @@ -20,6 +20,7 @@ import { KeeperManager } from '../citizens/keepers.js'; // Lane D — shopkeep import { GigCrew } from '../citizens/band.js'; // Lane D — band trio + audience (R12, ?gigs=1) import { createDig, binSeed } from '../interiors/dig.js'; // Lane C — crate-riffle (v2, gated on ?dig=1) import { collapseBuyItem } from '../interiors/stockpack.js'; // Lane C — R9 buy-anywhere (book spines / toy boxes) +import { createSell, nearCounter } from '../interiors/sell.js'; // Lane C — R30 sell counter (F owns this hook, §9.4) const EYE = 1.6; // interior eye height (matches interior_test.html; rooms tuned for it) const RADIUS = 0.30; // player collision radius inside the room @@ -119,6 +120,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null let doorReturn = null; // { x, y, z, ry } on the street to restore on exit let exitArmed = false; // disarmed at spawn (spawn sits by the door); arms once player steps inside let dig = null; // [F2] Lane C crate-riffle instance, created lazily on first use (only if ?dig=1) + let sellCard = null; // [R30 §9.4] Lane C sell card, created lazily on first counter-E (only when the game is on) const _fwd = new THREE.Vector3(); const _right = new THREE.Vector3(); @@ -129,7 +131,12 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null // in-game *input/mode* hook (its LANE_C_NOTES contract) — the same consumer pattern as Lane C's // interior_test.html: aim at a record bin, press E → open the riffle, which owns the frame + cursor // (pointer must be UNLOCKED — the shell handles that on procity:digOpen). Flag off ⇒ none of this runs. - function binUnderAim() { + // [R30 §9.4 amendment] strictAim skips the proximity fallback. The fallback ignores aim entirely + // (nearest bin within 2.5 m of the CAMERA), so at a counter that sits near a bin it would swallow + // the E and the sell card could never open there. The routing below therefore asks twice: first + // AIMED-only (the contract's "aimed bin → dig"), then — only after the sell trigger has declined — + // the forgiving nearest-bin pick, so imperfect aim at a bin still opens the riffle away from a sale. + function binUnderAim(strictAim = false) { if (!current) return null; const bins = []; current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') bins.push(o); }); @@ -141,6 +148,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null while (o && !(o.userData && o.userData.kind === 'bin')) o = o.parent; return o; } + if (strictAim) return null; // Fallback (matches Lane C's interior_test.html): nearest bin within reach. Uses the bin GROUP's // world position, so it works even before the bin's GLB mesh has finished loading (async) and is // forgiving of imperfect aim — stand next to a bin, press E. @@ -224,11 +232,41 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null } } + // [Lane F R30 — THE SELL ROUTING (C's §9.4, F's held join)] E at the keeper's counter holding items + // this shop trades in → C's sell card. F does both halves of the §9.3 seam, in this order: removal + // via THE GAME API first (identity — removeFind false ⇒ keeper veto, nothing moves, and no credit + // can ever be minted for an item that is not in the collection), then the credit through the SAME + // wallet facade every buy seam uses. C's card never touches game state; this closure is the only + // place a sale moves money or the collection. No game layer ⇒ trySell is a no-op (classic-pure, C §9.5). + function trySell() { + if (!game || !wallet || !current || !currentShop) return false; + if (!nearCounter(current, camera.position)) return false; // §9.4: within SELL_DIST of the bench + if (!sellCard) sellCard = createSell(); + if (sellCard.active) return true; + const opened = sellCard.open({ + shopType: currentShop.type, + shopName: (current.recipe && current.recipe.label) || currentShop.name, + items: game.collection, // READ-ONLY — sell.js cards a filtered copy (§9.3) + getCash: () => wallet.cash(), + emitters: current.audio && current.audio.emitters, + onSell: (item, offer) => { + if (!game.removeFind(item)) return false; // not in the collection ⇒ veto: card unchanged, $0 moves + wallet.sell(item, offer); // credit rides the proven seam (mirrors wallet.js sell) + return true; + }, + onClose: () => window.dispatchEvent(new CustomEvent('procity:sellClose')), + }); + if (opened) window.dispatchEvent(new CustomEvent('procity:sellOpen')); // shell releases pointer-lock (dig precedent) + return opened; + } + if (digEnabled || wallet) { addEventListener('keydown', (e) => { - if (e.code !== 'KeyE' || !current || (dig && dig.active)) return; - if (digEnabled) { const bin = binUnderAim(); if (bin) { openDig(bin); return; } } // bin → riffle - buyShelfUnderAim(); // shelf → buy + if (e.code !== 'KeyE' || !current || (dig && dig.active) || (sellCard && sellCard.active)) return; + if (digEnabled) { const bin = binUnderAim(true); if (bin) { openDig(bin); return; } } // AIMED bin → riffle (§9.4 first branch) + if (trySell()) return; // [R30 §9.4] near the counter with ≥1 sellable → the sell card + if (digEnabled) { const bin = binUnderAim(); if (bin) { openDig(bin); return; } } // forgiving nearest-bin (away from a sale) + buyShelfUnderAim(); // else shelf → buy }); } @@ -361,9 +399,13 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null exitArmed = false; let hasBins = false; if (digEnabled) current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') hasBins = true; }); + // [R30 §9.4] the sell hint shows only when the player actually HOLDS something this shop trades in + // (type = type, C's fail-closed matcher) — a hint over nothing sellable would advertise a dead E. + const canSell = !!(game && game.collection && game.collection.some((it) => it && it.type === shop.type)); banner.textContent = `🚪 ${name || shop.name || 'shop'} — walk out the door or press Esc to leave` + (gigOn ? ` · 🎸 ${String(tonight.bandName).toUpperCase()} — LIVE` : '') - + (hasBins ? ' · E: riffle a record bin' : ''); + + (hasBins ? ' · E: riffle a record bin' : '') + + (canSell ? ' · E at the counter: sell' : ''); banner.style.display = 'block'; return current; } @@ -421,6 +463,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null if (!current) return; window.PROCITY.audio && window.PROCITY.audio.stopInterior(); // [Lane F R11 audio] fade the interior bed out (0.7s) if (dig && dig.active) dig.close(); // [F2] close an open riffle before leaving (frees its per-open GPU) + if (sellCard && sellCard.active) sellCard.close(); // [R30] the sell card never outlives the room keepers.disposeAll(); // Lane D: free the keeper actor(s) before the room if (crew) { crew.disposeAll(); crewInfo = null; } // [R12] band + crowd out before the room goes current.dispose(); @@ -447,6 +490,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null get crew() { return crew; }, // [R12] Lane D GigCrew (null until a gig has been entered) get crewInfo() { return crewInfo; }, // [R12] { band, crowd } actually spawned — F's smokes assert on this get digActive() { return !!(dig && dig.active); }, // [F2] shell reads this to keep pointer-lock/Esc sane while riffling + get sellActive() { return !!(sellCard && sellCard.active); }, // [R30 §9.4] same contract for the sell card // [Lane F R24 — the honest-gate surface, per the vacuous-gate law] What stock is in THIS room, BY // IDENTITY. R23's #7a could go green on "real covers render" while the room held the generic v2 pack