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:
parent
5e115688ed
commit
2c9cb94b8e
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user