Lane F R30 wave 3 (v7.0-alpha ledger #5 + §9.4): THE GATES + the sell routing — E chain aimed-bin→dig · counter+sellable→C's sell card (removeFind-first onSell, veto-safe) · forgiving-bin · shelf; aim-priority amendment measured both ways (counter-adjacent bin sells, wrong-aim bin still digs); 3 shell seams (sellOpen unlock, sellActive guards). Five STRICT suite gates in flags_check: routing live-loop ($9 dig → +$4 sell → HUD $186·CRATE 0), save/load determinism (byte-equal state+export, plan fp unchanged, weekNight re-key), no-pump 5/5 strictly negative (Σ−$30) + C's negative control (wallet.buy invoked-and-false ⇒ recordFind unreachable, positive arm discriminates), classic purity (Storage-prototype instrument, 0 calls classic/game=0, positive control sees procity-save, plan fp game-on==off), rotation over VERTEX DATA (day 1/2/3 distinct, re-entry + cross-boot byte-identical, game=0==day1). qa.sh --strict --matrix: 7 passed · 0 failed · 0 warn · 0 skipped, MATRIX 10 towns green. NO TAG per brief.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-18 15:10:08 +10:00
parent 5e115688ed
commit 2c9cb94b8e
5 changed files with 662 additions and 7 deletions

View File

@ -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. **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 ## Round 26 (v5.0-beta — EVERY CRATE DIFFERENT) — F: the manifest consumed, the tolerance retired

View File

@ -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 · **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 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. 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 == (31)%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.

View File

@ -1520,6 +1520,531 @@ def smoke_continuity(p):
b.close() 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 == (31)%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 123 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(): def main():
srv = ensure_server() srv = ensure_server()
try: try:
@ -1548,6 +2073,11 @@ def main():
smoke_gigs(p) # R12/13 — the district gig layer (schedule · band · crowd · budget · cover · noassets) 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_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_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_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) smoke_real_crate(p) # R24 — THE REAL CRATE (per-shop atlas identity · soft fall · no-op off the path)
finally: finally:

View File

@ -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 // 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". // doesn't read as "walked out of the shop".
window.addEventListener('procity:digOpen', () => player.unlock()); 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 // [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); // 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 // `?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'); const startPanel = document.getElementById('pc-start');
document.getElementById('pc-startseed').textContent = `${plan.name} · seed ${plan.citySeed}`; document.getElementById('pc-startseed').textContent = `${plan.name} · seed ${plan.citySeed}`;
document.getElementById('pc-go').addEventListener('click', () => { startPanel.style.display = 'none'; player.lock(); }); 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 // 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', () => { addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();

View File

@ -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 { 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 { 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 { 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 EYE = 1.6; // interior eye height (matches interior_test.html; rooms tuned for it)
const RADIUS = 0.30; // player collision radius inside the room 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 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 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 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 _fwd = new THREE.Vector3();
const _right = 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 // 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 // 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. // (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; if (!current) return null;
const bins = []; const bins = [];
current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') bins.push(o); }); 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; while (o && !(o.userData && o.userData.kind === 'bin')) o = o.parent;
return o; return o;
} }
if (strictAim) return null;
// Fallback (matches Lane C's interior_test.html): nearest bin within reach. Uses the bin GROUP's // 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 // 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. // 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) { if (digEnabled || wallet) {
addEventListener('keydown', (e) => { addEventListener('keydown', (e) => {
if (e.code !== 'KeyE' || !current || (dig && dig.active)) return; if (e.code !== 'KeyE' || !current || (dig && dig.active) || (sellCard && sellCard.active)) return;
if (digEnabled) { const bin = binUnderAim(); if (bin) { openDig(bin); return; } } // bin → riffle if (digEnabled) { const bin = binUnderAim(true); if (bin) { openDig(bin); return; } } // AIMED bin → riffle (§9.4 first branch)
buyShelfUnderAim(); // shelf → buy 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; exitArmed = false;
let hasBins = false; let hasBins = false;
if (digEnabled) current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') hasBins = true; }); 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` banner.textContent = `🚪 ${name || shop.name || 'shop'} — walk out the door or press Esc to leave`
+ (gigOn ? ` · 🎸 ${String(tonight.bandName).toUpperCase()} — LIVE` : '') + (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'; banner.style.display = 'block';
return current; return current;
} }
@ -421,6 +463,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
if (!current) return; if (!current) return;
window.PROCITY.audio && window.PROCITY.audio.stopInterior(); // [Lane F R11 audio] fade the interior bed out (0.7s) 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 (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 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 if (crew) { crew.disposeAll(); crewInfo = null; } // [R12] band + crowd out before the room goes
current.dispose(); 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 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 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 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 // [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 // IDENTITY. R23's #7a could go green on "real covers render" while the room held the generic v2 pack