diff --git a/F-progress.md b/F-progress.md index ba6a3c7..d80263f 100644 --- a/F-progress.md +++ b/F-progress.md @@ -4,6 +4,23 @@ --- +## Round 32 (v7.0-beta wave 1) — THE BIBLE + scarcity + the §31.6 queue (Fable, solo, JING5) + +Guide bands derived in `js/interiors/bible.js` (E's thresholds mirrored, no pack re-emit) → +the pull/sell cards carry the **guide stamp**; parody bins roll seeded bands + 10% collector/ +grail **gems** (asking under the keeper's bandLow offer — margin by construction); §9.1 sell +basis swapped to bandLow exactly as pre-ratified (alpha entries keep the pricePaid basis). +**Scarcity:** optional `pulls` on procity-save/1 — a bought dig slot stays gone all day +(pick-then-omit, zero collateral), pruned on sleep; real-sourced pulls never expire. §31.6 +queue: cluster-pose spawn (main-street doors, classic keeps the frozen pose) · THE CRATE + +game bar live in interiors (C anywhere, yields to the cards) · DAWN dome swaps to high-cirrus +(weather override wins). **Gates:** new STRICT `smoke_bible` (band law over every bin · +ledger closes to the cent on the GEM's bin, Σask $333 → Σoffer $135, gems +$21 · emptied bin +0 across reload · `?game=0` control re-opens 16/16) + sell_routing amended to the exact +formula (the fence moved from the receipt to the band). Full record: LANE_F_NOTES §32. + +--- + ## Round 31 (v7.0-alpha CLOSE) — THE DIG FLIP + the tag **Fable's wave-0 playtest found the one alpha-blocker: the dig was default-OFF on a plain boot.** diff --git a/README.md b/README.md index 2cbaa4c..4af4586 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ Click **START** to lock the mouse. Then: | **W A S D** + mouse | walk / look (first person) | | walk into a shop door | enter the interior (open shops only — check the hours tooltip) | | **E** (inside a shop) | open the crate at a record bin — then it's cursor-driven: click sleeves to riffle, click BUY | -| **E** (at the counter) · **C** (street) · **SLEEP** (top bar) | sell a find to the keeper (always under what you paid) · open THE CRATE — your collection · sleep = save, the day advances, stock rotates | +| **E** (at the counter) · **C** (anywhere) · **SLEEP** (bottom bar) | sell a find to the keeper (he pays under the **guide band**, never over) · open THE CRATE — your collection, in shops too now · sleep = save, the day advances, stock rotates | +| read the **guide** stamp on every price card | the 90s price-guide band ("guide $8–24"). An item **asking under its band is a gem** — buy it, sell it to a keeper, pocket the margin. Bought slots stay gone for the day (the bin remembers) | | click a book spine / toy box | pull-and-buy card (real price stickers in godverse shops) | | walk out the door / **Esc** | leave the shop (Esc also frees the mouse anywhere) | | **M** | 2D town map (street only) | diff --git a/docs/LANES/LANE_F_NOTES.md b/docs/LANES/LANE_F_NOTES.md index 9fd2286..2fd0aec 100644 --- a/docs/LANES/LANE_F_NOTES.md +++ b/docs/LANES/LANE_F_NOTES.md @@ -1754,3 +1754,62 @@ Fable's preview holds :8130, untouched): 5. Automation-only, unverified in person: **dig Esc-close under synthetic events**. 6. *(carried from §30.6)* no per-day `gone` tracking on parody/mint dig stock — scarcity-per-day needs the save to carry pull records; rule it with the guide bands. + +## §32 — R32: v7.0-beta wave 1 — THE BIBLE + scarcity + the §31.6 queue (Fable, solo round, JING5) + +**The round in one line:** the guide bands land (derived, not baked), gems exist (parody bins only, +by construction), a bought slot stays gone for the day (pulls ride the save), and §31.6 items 1–3 +are fixed. All gates green; the no-pump law survives the §9.1 basis swap structurally. + +### §32.1 What shipped, by seam + +- **`js/interiors/bible.js`** (C) — the band table mirroring E's pipeline thresholds + (record 8/25/60 · book 5/15/40 · toy 6/20/50), `bandRange`/`guideText`/`sellBasis`. Derivation + ruling: NO pack re-emit (charter risk #3 never opens); if E's thresholds move, bible.js moves in + the same commit. +- **dig.js** (C) — parody sleeves roll seeded bands (25/45/22/8%) + a 10% gem on collector/grail + (asking strictly under the keeper's own bandLow offer: collector $2–9 vs $12 · grail $5–20 vs + $30); fixed 9-draw stream shape per item; `rare` now ≡ grail (matches real packs). Real offers + carry `band` off `price_band`. `open({gone})` omits pulled slots AFTER the draw (the R27 + pick-then-omit seam, zero collateral) — real via `'sku:'`, parody via original offer index + (`o.i` survives filtering, identity preserved). The pull card + sell card gain the **guide + stamp** (`guide $8–24` / `$60+`) — present-fields-only, and the card NEVER says "gem": spotting + asking-under-guide is the player's skill. +- **sell.js** (C) — §9.1 basis swap executed exactly as pre-ratified: `sellOffer(sellBasis(entry))` + = `min(bandLow−1, max(1, floor(bandLow·0.5)))` when banded, pricePaid basis for alpha-era + entries (they sell exactly as before — no migration needed). +- **save.js** (F) — optional `pulls: string[]` on procity-save/1 (validated loudly when present; + absent on alpha saves — old savers drop it silently, never reject). `recordPull`/`pullsFor` + seams; pruned on sleep/import (day-tagged keys from other days are unreachable by construction; + untagged = real-sourced, kept — sold means gone). Cap 600 FIFO. Collection entries gain + optional `band` (recordFind stamps dig + shelf buys). +- **interior_mode.js** (F) — pull keys `${shopId}|${binKey}${pullTag}#${slot}`; pullTag = + `@d` for rotating crates (INCLUDING day 1 — the seed stays untagged per the day-1 + convention, the ledger doesn't), `''` for real-sourced. The SEED input is untouched (A's + rotation boundary holds). `digOffers` honest surface (index/ask/band/sku) for the gate. +- **hud.js + index.html** (B/F) — §31.6-2: `#pc-game` rides document.body (position:fixed, z 30), + ticks in every mode (`hud.tickGame()` from the interior/map branches), yields to the dig/sell + cards and the map; C toggles the crate anywhere. §31.6-1: cluster-pose spawn — main-street + shop doors (block kind, both generators), stand at the door nearest the cluster heart, face the + longer run of shopfronts; pure plan math, zero draws; `?classic=1` keeps the frozen pose. + §31.6-3: DAWN swaps the dome to `high-cirrus` (lighting `dawnSky` opt, null under classic); + a weather `setSky` is now an explicit override that always wins. + +### §32.2 THE GATE (`smoke_bible`, STRICT) + the R30 amendment + +Scans EVERY bin of the record shop off `digOffers`: band law on all offers (in-band unless a +collector/grail gem), then runs the buy-EVERYTHING ledger on **the gem's bin** (leg is +non-vacuous by construction — 80 offers/5 bins, bin1 holds 2 gems on the pinned seed, grail +asking $19 vs keeper $30). Ledger closes exactly: Σask $333 → Σoffer $135 (gems alone +$21, +every non-gem strictly negative). Scarcity: the emptied bin re-opens at 0, STAYS 0 across a full +reload (16 pulls in the save); negative control: `?game=0` re-opens it at 16 — the detector +discriminates. 0 console errors. **R30 amendment (sell_routing):** `credited < paid` was the +alpha inequality; the gate now asserts the exact formula (`credited == min(basis−1, basis//2)`, +basis = bandLow-or-pricePaid, `credited < basis`) — the fence moved from the receipt to the +band, which is what charter law #2 always said. + +### §32.3 Environment note (JING5) + +`web/assets/models/` is GITIGNORED build output — a fresh checkout runs stock=real/book-toy/ +no-pump gates RED with 404s (the R31-green tree lives on m3ultra). Fixed here by rsync from +m3ultra. If a third machine joins the rotation, sync that dir (49 M) or re-run E's pipeline. diff --git a/docs/LANES/ROUND32_INSTRUCTIONS.md b/docs/LANES/ROUND32_INSTRUCTIONS.md new file mode 100644 index 0000000..3e56aa2 --- /dev/null +++ b/docs/LANES/ROUND32_INSTRUCTIONS.md @@ -0,0 +1,61 @@ +# PROCITY — Round 32 instructions (from Fable, integrator) + +Date: 2026-07-20 · **R31 closed** — v7.0-alpha tagged and pushed; the loop plays. Round 32 opens +**v7.0-beta — THE THRILL, wave 1**: the guide bands land ([V7_THE_GAME.md](../V7_THE_GAME.md) +stage 2), scarcity rides the save, and the alpha playtest's friction queue (LANE_F_NOTES §31.6) +gets its cosmetic items. **Solo round:** John asked for direct progress; Fable executes all lanes +in one session on JING5, same treaty, same gates — lane ownership is honoured in the commit +messages, not the parallelism. + +## The design rulings this round encodes (from the ratified charter + §31.6 queue) + +1. **THE BIBLE derives, it is not baked.** E's band thresholds (build_stock_pack.py:88-94 — + record 8/25/60 · book 5/15/40 · toy 6/20/50) become a tiny JS mirror + (`js/interiors/bible.js`). No pack re-emit, so risk #3 (re-baked atlases orphaning owned + skus) never opens. The guide shows a BAND ("guide $25–59"), never a verdict — spotting the + gem stays the player's skill. +2. **Gems live in the parody bins.** Real/mint packs derive `price_band` FROM `price`, so real + asking is always inside its band → with the §9.1 basis swap (sellOffer basis = bandLow), + every real round trip stays strictly negative and **the R30 no-pump gate holds untouched, + by construction**. Procedural stock gains seeded bands + a seeded under-band gem roll — + the only place asking can sit under the guide, which is exactly the op-shop fantasy. +3. **Scarcity is what makes the gem loop safe** (§31.6 item 6, ruled WITH the bands as filed): + a bought dig slot enters `pulls` in the save (optional field, procity-save/1 — old saves + stay valid, old loaders drop the field without rejecting) and that bin omits it after the + draw (the proven `pack.gone` pick-then-omit seam — zero collateral). A gem is profit ONCE; + tomorrow's rotation re-keys the bin. Growth is bounded by finds, not arbitrage (charter + law #2's real meaning). +4. **§31.6 items 1–3:** spawn becomes a computed cluster-pose facing the shop strip (classic + keeps the frozen v2 pose); THE CRATE surfaces inside interiors (C key — the §31.6 + "crate at the counter" want, hidden while dig/sell cards own the screen); DAWN gets a + cool sky dome swap (classic + weather-override fenced). + +## Gate additions (F) + +`smoke_bible` in flags_check.py: guide stamp on the pull card (real + parody) · offer < bandLow +structurally · a seeded parody gem exists and its round trip is positive ONCE (then the slot is +gone — re-open the bin and it stays gone; reload and it stays gone) · negative control: +`?game=0` re-opens the same bin with the slot PRESENT (the detector discriminates — scarcity is +the save's doing, not the dig's). Classic purity untouched (no new module constructs under +`?classic=1` / `?dig=0`). + +## ROUND CLOSED (Fable) — ALL GREEN + +`qa.sh --strict --matrix`: **7 passed · 0 failed · 0 warn · 0 skipped**, MATRIX 10 towns green. +`smoke_bible`: 80 parody offers across 5 bins all band-lawful; bin1 holds 2 seeded gems (grail +asking $19 vs keeper $30); the buy-everything ledger closes to the cent (Σask $333 → Σoffer +$135, gems alone +$21, every non-gem strictly negative); the emptied bin stays empty across a +full reload (16 pulls in the save) and `?game=0` re-opens it 16/16 (the control discriminates); +0 console errors. sell_routing now asserts the exact §9.1 formula and passed on a BANDED item +(standard, basis $8 → +$4). Money shots: `docs/shots/v7_beta/guide_card.png` (the guide stamp — +"1974 COUNTRY guide $8–24" beside the $24 sticker) + `crate_at_the_counter.png` (THE CRATE open +inside a shop, game bar riding bottom-right). Environment note: JING5's gitignored +`web/assets/models/` was absent (fresh-checkout trap, now §32.3 in LANE_F_NOTES) — synced from +m3ultra, which also un-warned the manifest validator's 15 cross-pack checks. Full record: +LANE_F_NOTES §32. Shipped: bible.js + guide stamps + parody bands/gems (C) · pulls on the save ++ gone-wiring + digOffers surface (F) · crate-in-interiors + cluster-pose spawn + dawn sky (B) +· smoke_bible + the routing amendment (F) · README/docs. + +**The beta's remaining charter** (next rounds): the WANTLIST + seeded rumors (D's keepers +finally gossip) · travel costs a day (23 towns become the map) · §31.6-5 (dig Esc under real +hands) · then v7.0 = GodBay offline auctions (G) + onboarding + the tour. diff --git a/docs/shots/v7_beta/crate_at_the_counter.png b/docs/shots/v7_beta/crate_at_the_counter.png new file mode 100644 index 0000000..01f7086 Binary files /dev/null and b/docs/shots/v7_beta/crate_at_the_counter.png differ diff --git a/docs/shots/v7_beta/guide_card.png b/docs/shots/v7_beta/guide_card.png new file mode 100644 index 0000000..2aa48c4 Binary files /dev/null and b/docs/shots/v7_beta/guide_card.png differ diff --git a/tools/flags_check.py b/tools/flags_check.py index 5f5bea3..9a713f5 100644 --- a/tools/flags_check.py +++ b/tools/flags_check.py @@ -1633,16 +1633,17 @@ def _game_state(pg): 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("""() => { +def _at_bin(pg, idx=0): + """Stand at the idx-th bin of the open room, aimed at it (smoke_buy's proven setup).""" + return pg.evaluate("""(idx) => { 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); + if (idx >= bins.length) return { err: 'bin index out of range', bins: bins.length }; + const bp = new P.THREE.Vector3(); bins[idx].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 }; - }""") + return { ok: true, bins: bins.length }; + }""", idx) def _dig_buy(pg): @@ -1757,7 +1758,7 @@ def smoke_sell_routing(p): 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) }; }""") + return { type: e.type, pricePaid: e.pricePaid, hasId: !!(e.sku || e.slotId), band: e.band || null }; }""") 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: @@ -1778,10 +1779,21 @@ def smoke_sell_routing(p): 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']}") + # [R32 amendment] the no-pump law's beta form: the offer is EXACTLY min(basis−1, max(1, basis//2)) + # where basis = bandLow when the entry carries a band (the §9.1 swap), pricePaid otherwise. The + # old `credited < paid` held for every alpha sale but a banded GEM legitimately credits above + # what was paid — the fence moved from the receipt to the guide band, so the gate measures the + # formula itself, not the alpha inequality. + R32_LOW = {'bargain': 2, 'standard': 8, 'collector': 25, 'grail': 60} + basis = R32_LOW.get(ent.get('band'), paid) + want = min(basis - 1, max(1, basis // 2)) if basis >= 1 else 0 + if rs.get('sold') == 1 and after['n'] == n_before - 1 and credited == want and credited < basis: + OK(f"routing: SELL — +${credited} credited == min(basis−1, basis/2) with basis ${basis} " + f"({'band ' + ent['band'] if ent.get('band') else 'pricePaid'}; < basis, no-pump by construction), " + f"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']}") + FAIL(f"routing: sale delta wrong — sold={rs.get('sold')}, credited {credited} want {want} " + f"(basis {basis}), 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']}") @@ -1983,6 +1995,147 @@ def smoke_nopump(p): b.close() +def smoke_bible(p): + """R32 gate — THE BIBLE + per-day scarcity (v7.0-beta wave 1). Legs: (a) the pull card carries the + guide stamp on a NAKED boot (lesson #4 — no explicit flags; parody bin); (b) structural: every parody + offer prices INSIDE its band unless it's a seeded gem (asking < bandLow, collector/grail only) — + read off F's digOffers honest surface, checked against the BIBLE mirror of E's thresholds; + (c) buy the WHOLE bin, sell it all: cash moves exactly Σoffer−Σask with offer = min(low−1, + max(1, low//2)) per item — gems positive, everything else negative, the ledger closes to the cent; + (d) scarcity: the emptied bin re-opens EMPTY, stays empty across a reload (pulls ride the save), + and (e) negative control: ?game=0 re-opens the same bin FULL — the detector discriminates + (scarcity is the save's doing, not the dig's). (f) real stock carries the stamp too.""" + head('GATE R32: THE BIBLE — guide bands · gems · per-day scarcity (+ ?game=0 control)') + BIBLE = {'bargain': (2, 7), 'standard': (8, 24), 'collector': (25, 59), 'grail': (60, None)} + exp_offer = lambda band: min(BIBLE[band][0] - 1, max(1, BIBLE[band][0] // 2)) + b, pg, errs = new_page(p) + try: + boot(pg, '') # NAKED — dig/game default-on carry the feature + r0 = _enter_record_shop(pg) + if r0.get('err'): FAIL(f"bible: {r0['err']}"); return + # bankroll the buy-everything leg through the REAL import path (5000 covers any 16-sleeve bin) + rich = pg.evaluate("""() => { const g = window.PROCITY.game; + const ex = JSON.parse(g.export()); ex.cash = 5000; ex.savedAt = Date.now(); + return { ok: g.import(JSON.stringify(ex)), cash: g.cash }; }""") + if not (rich['ok'] and rich['cash'] == 5000): + FAIL(f"bible: bankroll import failed — {rich}"); return + pg.evaluate("() => window.DBG.setSegment(2)") # import's onDay woke us at DAWN — back to midday + # ── (a)+(b): scan EVERY bin in the room off the honest surface — band law on all of them, and + # find a bin holding a GEM (seeded: whichever bin it is today, it's that bin forever). The + # buy-everything ledger leg then runs on THE GEM'S BIN, so the finder-margin arithmetic is + # exercised for real, not vacuously (a 0-gem bin would prove only the losing half). + n_bins = (_at_bin(pg) or {}).get('bins', 1) + bad, all_offers, gem_bin, gems = [], {}, None, [] + for bi in range(n_bins): + _at_bin(pg, bi) + pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))") + pg.wait_for_timeout(350) + offs = pg.evaluate("() => window.PROCITY.interiorMode.digOffers") + pg.evaluate("() => { const o = document.querySelector('.pcdg-out'); if (o) o.click(); }") + pg.wait_for_timeout(200) + if not offs: bad.append(f"bin{bi} unreadable"); continue + all_offers[bi] = offs + bin_gems = [] + for o in offs: + band = o.get('band') + if band not in BIBLE: bad.append(f"bin{bi}#{o['i']} band={band!r}"); continue + lo, hi = BIBLE[band] + if o['price'] < lo: + if band in ('collector', 'grail'): bin_gems.append(o) + else: bad.append(f"bin{bi}#{o['i']} {band} ${o['price']} under-band but not gem-eligible") + elif hi is not None and o['price'] > hi: + bad.append(f"bin{bi}#{o['i']} {band} ${o['price']} over band") + if bin_gems and gem_bin is None: gem_bin, gems = bi, bin_gems + if bad: FAIL(f"bible: band-law violations — {bad[:3]}") + else: OK(f"bible: {sum(len(v) for v in all_offers.values())} parody offers across {n_bins} bin(s) " + f"all inside their bands (or seeded gems)") + if gem_bin is None: + FAIL(f"bible: NO gem in any of {n_bins} bins — the thrill leg is unreachable on this seed"); return + for g in gems: + if not g['price'] < exp_offer(g['band']): + FAIL(f"bible: gem #{g['i']} asks ${g['price']} ≥ keeper offer ${exp_offer(g['band'])} — no finder margin") + OK(f"bible: bin{gem_bin} holds {len(gems)} gem(s) — e.g. {gems[0]['band']} asking ${gems[0]['price']} " + f"vs keeper ${exp_offer(gems[0]['band'])} (margin by construction)") + offers = all_offers[gem_bin] + _at_bin(pg, gem_bin) + pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))") + pg.wait_for_timeout(350) + # pull the front sleeve → the guide stamp on the card + 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(300) + stamp = pg.evaluate("""() => { const p = document.querySelector('.pcdg-panel'); + return p && p.style.display === 'block' ? p.textContent : null; }""") + if stamp and 'guide $' in stamp: OK('bible: the pull card carries the guide stamp (naked boot, parody)') + else: FAIL(f"bible: no guide stamp on the pull card — {stamp[:80] if stamp else 'no panel'}") + pg.evaluate("""() => { const p = document.querySelector('.pcdg-panel'); + const x = p && p.querySelector('.x'); if (x) x.click(); }""") + # ── (c): buy the WHOLE bin through the real card, then sell it all at the counter ── + cash0 = pg.evaluate("() => window.PROCITY.wallet.cash()") + bought = 0 + for _ in range(len(offers)): + rb = _dig_buy(pg) + if rb.get('err') or rb.get('disabled'): break + bought += 1 + if bought < len(offers): _at_bin(pg, gem_bin) # walk-out re-closed the dig; re-aim the gem's bin + if bought != len(offers): + FAIL(f"bible: bought {bought}/{len(offers)} — the buy-everything leg died early"); return + ask_total = sum(o['price'] for o in offers) + offer_total = sum(exp_offer(o['band']) for o in offers) + rs = _counter_sell(pg, max_sales=bought + 2) + sell_card = pg.evaluate("() => { const p = document.querySelector('.pcsl-panel'); return p ? p.textContent : ''; }") + if rs.get('err') or rs.get('sold', 0) != bought: + FAIL(f"bible: sold {rs.get('sold')}/{bought} — {rs}"); return + cash1 = pg.evaluate("() => window.PROCITY.wallet.cash()") + gem_profit = sum(exp_offer(o['band']) - o['price'] for o in gems) + if cash1 - cash0 == offer_total - ask_total: + OK(f"bible: the ledger closes — Σask ${ask_total} → Σoffer ${offer_total} " + f"(net {cash1 - cash0:+d}; gems alone {gem_profit:+d}, every non-gem strictly negative)") + else: + FAIL(f"bible: ledger mismatch — cash moved {cash1 - cash0:+d}, expected {offer_total - ask_total:+d} " + f"(basis law broken somewhere)") + # ── (d): scarcity — the emptied bin is EMPTY on re-open, and STAYS empty across a reload ── + _at_bin(pg, gem_bin) + pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))") + pg.wait_for_timeout(300) + n_re = pg.evaluate("() => (window.PROCITY.interiorMode.digOffers || []).length") + pg.evaluate("() => { const o = document.querySelector('.pcdg-out'); if (o) o.click(); }") + pulls_n = pg.evaluate("() => (JSON.parse(window.PROCITY.game.export()).pulls || []).length") + if n_re == 0 and pulls_n >= bought: + OK(f"bible/scarcity: re-opened bin is cleaned out (0 of {bought}) · save carries {pulls_n} pull(s)") + else: + FAIL(f"bible/scarcity: re-open shows {n_re} sleeves (want 0) · pulls in save {pulls_n}") + boot(pg, '') # full reload, same origin — the save must carry it + r1 = _enter_record_shop(pg) + if r1.get('err'): FAIL(f"bible/scarcity reload: {r1['err']}") + else: + _at_bin(pg, gem_bin) + pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))") + pg.wait_for_timeout(300) + n_reload = pg.evaluate("() => (window.PROCITY.interiorMode.digOffers || []).length") + if n_reload == 0: OK('bible/scarcity: still cleaned out after a full reload — pulls rode the save') + else: FAIL(f"bible/scarcity: reload restocked the bin ({n_reload} sleeves) — pulls not persisted") + # ── (e): negative control — ?game=0 must re-open the SAME bin FULL (rotation gate: game=0 ≡ day1) ── + boot(pg, 'game=0') + r2 = _enter_record_shop(pg) + if r2.get('err'): FAIL(f"bible/control: {r2['err']}") + else: + _at_bin(pg, gem_bin) + pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))") + pg.wait_for_timeout(300) + n_ctl = pg.evaluate("() => (window.PROCITY.interiorMode.digOffers || []).length") + if n_ctl == len(offers): OK(f"bible/control: ?game=0 re-opens the bin FULL ({n_ctl}) — " + "scarcity is the save's doing, the detector discriminates") + else: FAIL(f"bible/control: ?game=0 bin shows {n_ctl}, want {len(offers)}") + if 'guide $' in (sell_card or ''): OK('bible: the sell card carries the guide line too') + else: WARN('bible: no guide line read off the sell card (cosmetic leg)') + if errs: FAIL(f"bible: {len(errs)} console error(s); first: {errs[0][:140]}") + else: OK('bible: 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 @@ -2136,6 +2289,7 @@ def main(): 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_bible(p) # R32 — THE BIBLE (bands · gems · ledger) + per-day scarcity + ?game=0 control 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: