diff --git a/web/js/interiors/bible.js b/web/js/interiors/bible.js
new file mode 100644
index 0000000..b6de7a4
--- /dev/null
+++ b/web/js/interiors/bible.js
@@ -0,0 +1,58 @@
+// PROCITY Lane C — bible.js [v7.0-beta wave 1, R32 — THE GUIDE + THE GEM (charter system #3)]
+// The in-game price guide: thriftgod's BIBLE concept as a VALUE BAND per item. The band is the
+// 90s guide book, not an oracle — it names a range ("guide $25–59"), never a verdict. A **gem**
+// is an item ASKING under its band's low edge; spotting one is the player's skill, so nothing in
+// any UI ever says "gem".
+//
+// THE DERIVATION RULING (R32): band NUMBERS mirror Lane E's pipeline thresholds
+// (pipeline/build_stock_pack.py price_band(): record 8/25/60 · book 5/15/40 · toy 6/20/50 —
+// duplicated verbatim in godverse_stock.py, validated by validate_pack.py). We derive ranges in
+// JS from the band LABEL the packs already carry instead of re-baking band data into atlases —
+// so charter risk #3 (a re-emitted pack orphaning owned skus) never opens. If E's thresholds
+// move, this table moves with them in the same commit (the two files name each other).
+//
+// THE STRUCTURAL CONSEQUENCE (why the no-pump law survives the beta, LANE_C_PUB §9.1):
+// real/mint pack items derive `price_band` FROM `price`, so real asking always sits INSIDE its
+// band ⇒ bandLow ≤ asking ⇒ sellOffer(bandLow) ≤ bandLow − 1 < asking: every real round trip
+// stays strictly negative with the basis swapped. Only PARODY stock (proceduralSleeves) rolls a
+// seeded under-band gem — and per-day scarcity (the save's `pulls`) makes each gem profit ONCE.
+// Growth is bounded by finds, not arbitrage loops — charter law #2, made structural.
+//
+// Zero DOM, zero draws, zero fetch, zero storage: a pure table + pure functions. ?classic=1 /
+// ?dig=0 never import a caller, and importing this module alone has no effect at all.
+
+// kind → band → [low, high]; `high: null` = open-ended (the card renders "$60+").
+// Lows/highs are E's threshold edges: a band [t0, t1) renders as $t0–(t1−1).
+export const BANDS = {
+ record: { bargain: [2, 7], standard: [8, 24], collector: [25, 59], grail: [60, null] },
+ book: { bargain: [1, 4], standard: [5, 14], collector: [15, 39], grail: [40, null] },
+ toy: { bargain: [2, 5], standard: [6, 19], collector: [20, 49], grail: [50, null] },
+};
+export const BAND_ORDER = ['bargain', 'standard', 'collector', 'grail'];
+
+// bandRange('record','collector') → [25, 59] | null on unknown kind/band (fail-closed: no range,
+// no guide line, no basis swap — never an invented number).
+export function bandRange(kind, band) {
+ const k = BANDS[kind];
+ const r = k && k[String(band || '').toLowerCase()];
+ return r ? [r[0], r[1]] : null;
+}
+
+// The guide line for a card stamp — "guide $25–59" / "guide $60+" / '' when unknowable.
+export function guideText(kind, band) {
+ const r = bandRange(kind, band);
+ if (!r) return '';
+ return r[1] == null ? `guide $${r[0]}+` : `guide $${r[0]}–${r[1]}`;
+}
+
+// The sell basis for a collection entry (LANE_C_PUB §9.1, the pre-ratified beta swap): bandLow
+// when the entry carries a band the BIBLE knows, else the alpha basis (pricePaid) — so every
+// alpha-era find (no band field) sells exactly as it did before this round. The sellOffer()
+// CLAMP SHAPE is sell.js's and survives untouched; this function only chooses the number fed in.
+export function sellBasis(entry) {
+ if (entry && entry.band) {
+ const r = bandRange(entry.type, entry.band);
+ if (r) return r[0];
+ }
+ return entry ? (entry.pricePaid != null ? entry.pricePaid : entry.price) : 0;
+}
diff --git a/web/js/interiors/dig.js b/web/js/interiors/dig.js
index 9d0b81b..21c1199 100644
--- a/web/js/interiors/dig.js
+++ b/web/js/interiors/dig.js
@@ -10,6 +10,7 @@
// runs unless the shell/test-page opens it, so `?dig=0` is byte-identical to v1.
import { mulberry32, xmur3 } from '../core/prng.js';
+import { BANDS, guideText } from './bible.js'; // [R32] THE BIBLE — band ranges + the card's guide stamp
const MAXFLIP = 1.95, BACK = -0.12;
const SLEEVE = [0.31, 0.31, 0.0060]; // w, h, thickness (packed a touch chunkier than vinyl)
@@ -23,21 +24,33 @@ const TITLES = ['Sunburnt', 'Back o’ Bourke', 'Servo at Midnight', 'Long Weeke
const CONDS = ['VG+', 'VG', 'G+', 'NM', 'EX'];
// Deterministic sleeve list for a bin — the "existing procedural stock" as riffle-able offers.
+// [R32 — THE BIBLE + THE GEM] Every parody sleeve now rolls a seeded BAND (the guide's range) and an
+// ASKING price with seeded variance: non-gems price INSIDE their band; a 10% gem roll (collector/grail
+// only — an under-band bargain is not a thrill) prices UNDER the band's low edge, which is the entire
+// op-shop fantasy made mechanical. Draw COUNT is fixed per item (9 rolls, outcome-independent) so the
+// stream stays deterministic and re-orderable never. `rare` (the ★) now means grail, matching the real
+// packs — the card never says "gem"; spotting the asking-under-guide gap is the player's skill.
+const PARODY_BAND = (roll) => (roll < 0.25 ? 'bargain' : roll < 0.70 ? 'standard' : roll < 0.92 ? 'collector' : 'grail');
+const GRAIL_ASK_HI = 120; // parody grail asking ceiling (band renders "$60+")
function proceduralSleeves(seed, n) {
const r = mulberry32(seed >>> 0);
const out = [];
for (let i = 0; i < n; i++) {
const g = GENRE[(r() * GENRE.length) | 0];
- out.push({
- i, kind: 'record',
- a: ARTISTS[(r() * ARTISTS.length) | 0],
- t: TITLES[(r() * TITLES.length) | 0],
- y: 1971 + ((r() * 28) | 0),
- s: g[0], color: g[1],
- cond: CONDS[(r() * CONDS.length) | 0],
- price: 2 + ((r() * 28) | 0),
- rare: r() < 0.12,
- });
+ const a = ARTISTS[(r() * ARTISTS.length) | 0];
+ const t = TITLES[(r() * TITLES.length) | 0];
+ const y = 1971 + ((r() * 28) | 0);
+ const cond = CONDS[(r() * CONDS.length) | 0];
+ const band = PARODY_BAND(r());
+ const [lo, hi] = BANDS.record[band];
+ const priceRoll = r(), gemRoll = r(), gemPriceRoll = r(); // always drawn — fixed stream shape
+ const gem = gemRoll < 0.10 && (band === 'collector' || band === 'grail');
+ // gem asking sits STRICTLY under the keeper's own bandLow offer (min(lo−1, floor(lo/2)) — sell.js),
+ // so a spotted gem always nets margin: collector asks $2–9 vs offer $12 · grail $5–20 vs $30.
+ const price = gem
+ ? (band === 'grail' ? 5 + ((gemPriceRoll * 16) | 0) : 2 + ((gemPriceRoll * 8) | 0))
+ : lo + ((priceRoll * ((hi == null ? GRAIL_ASK_HI : hi) - lo + 1)) | 0);
+ out.push({ i, kind: 'record', a, t, y, s: g[0], color: g[1], cond, price, band, rare: band === 'grail' });
}
return out;
}
@@ -250,6 +263,11 @@ export function createDig(THREE, renderer) {
const line = [o.y, o.s].filter(Boolean).join(' '); // "1974 SOUL" | "SOUL" | "" — never " · "
if (line) stamps.push(`${esc(line)}`);
if (o.rare) stamps.push(`★ ${o.real ? 'GRAIL' : 'RARE'}`);
+ // [R32 THE BIBLE] the guide stamp — a RANGE off the 90s guide book, never a verdict. Present-fields-
+ // only law holds: an unknown band leaves no stamp (guideText returns ''). The asking price sits on
+ // the sticker; whether it's under the guide is for the PLAYER to notice — the card never says gem.
+ const guide = guideText('record', o.band);
+ if (guide) stamps.push(`${esc(guide)}`);
panel.innerHTML = `
✕
`
+ `
$${esc(o.price)}
`
+ `
${esc(o.a)}
`
@@ -342,13 +360,16 @@ export function createDig(THREE, renderer) {
// Both forms hide the sold record, so "the gone item never renders" cannot tell them apart — the
// collateral is what a gate must assert on. F: update item FIELDS in place (live price) freely; do NOT
// add, remove, or reorder `pack.items` — that identity/order IS the seeded pick's stability.
- function pickRealOffers(pack, seed, n) {
+ function pickRealOffers(pack, seed, n, pulledGone) {
const rr = mulberry32(seed >>> 0), out = [];
const gone = pack.gone instanceof Set ? pack.gone
: (Array.isArray(pack.gone) ? new Set(pack.gone) : null);
for (let i = 0; i < n; i++) {
const it = pack.items[(rr() * pack.items.length) | 0];
if (gone && gone.has(it.id)) continue; // sold in the real shop → this slot is simply empty
+ // [R32 scarcity] the PLAYER bought it (this day, this bin) → same pick-then-omit seam, same
+ // zero-collateral guarantee: the pull leaves, the survivors keep their slots.
+ if (pulledGone && pulledGone.has('sku:' + it.id)) continue;
// Real fields straight off the atlas index (§7.2c): condition/sleeve_cond are OPTIONAL — present on
// POS-sourced items, absent on mint ones that had no genuine grade. `y` (year) is never in the index,
// so it stays empty and the card simply omits it. No invented values: an unstated grade stays unstated.
@@ -358,22 +379,29 @@ export function createDig(THREE, renderer) {
out.push({ real: true, pack, item: it, a: it.artist, t: it.title, price: it.price,
s: band === 'GRAIL' ? '' : band, y: '',
cond: it.condition || '', sleeveCond: it.sleeve_cond || '',
+ band: it.price_band || null, // [R32] canonical band for the guide stamp + the sell basis
rare: it.price_band === 'grail' });
}
return out;
}
- // open({ seed, count, shopName, shop, stockAdapter, onClose, onBuy, getCash, emitters })
+ // open({ seed, count, shopName, shop, stockAdapter, onClose, onBuy, getCash, emitters, gone })
// `emitters` [R28, OPTIONAL] — pass `room.audio.emitters` and the till rings from the counter at the
// right distance. Omit it and the till still rings, just un-positioned. Never required.
+ // `gone` [R32, OPTIONAL] — a Set of THIS bin's already-pulled slots ('sku:' for pack items, the
+ // stringified offer index for parody), from the save's pull records. Omission happens AFTER the full
+ // seeded draw (both paths), so the survivors keep their slots — the R27 zero-collateral law. Absent
+ // (?game=0 / classic / fresh day) ⇒ the bin riffles exactly as it always did.
function open(opts = {}) {
active = true;
onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null;
emitters = opts.emitters || null;
const count = opts.count || 16;
+ const gone = opts.gone instanceof Set && opts.gone.size ? opts.gone : null;
// riffle items: real pack (?stock=real) via the stockAdapter seam, else seeded procedural sleeves.
const src = opts.stockAdapter && opts.stockAdapter(opts.shop, 'sleeve');
- offers = (src && src.real) ? pickRealOffers(src.pack, opts.seed ?? 1, count) : proceduralSleeves(opts.seed ?? 1, count);
+ offers = (src && src.real) ? pickRealOffers(src.pack, opts.seed ?? 1, count, gone)
+ : proceduralSleeves(opts.seed ?? 1, count).filter((o) => !(gone && gone.has(String(o.i))));
curLbl.textContent = (opts.shopName ? opts.shopName + ' — ' : '');
[hint, curLbl, outBtn].forEach(e => e.style.display = 'block');
buildStack(offers);
@@ -411,7 +439,16 @@ export function createDig(THREE, renderer) {
[css, hint, curLbl, panel, outBtn, toast].forEach(e => e.remove());
}
- return { scene, camera, open, close, update, dispose, get active() { return active; } };
+ // [R32 — the honest-gate surface, R24 precedent] What is in THIS open bin, BY IDENTITY: original
+ // offer index, asking, band, sku (pack items). Read-only snapshots; null when closed. F's bible/
+ // scarcity gate asserts on these instead of trusting "the crate looked right".
+ function currentOffers() {
+ if (!active) return null;
+ return recs.map((r) => ({ i: r.offer.i ?? null, price: r.offer.price, band: r.offer.band || null,
+ sku: r.offer.item ? String(r.offer.item.id) : null }));
+ }
+
+ return { scene, camera, open, close, update, dispose, currentOffers, get active() { return active; } };
}
// A stable per-bin seed from the shop + a bin index, so a given bin riffles the same order every time.
diff --git a/web/js/interiors/sell.js b/web/js/interiors/sell.js
index 6369f48..66234ca 100644
--- a/web/js/interiors/sell.js
+++ b/web/js/interiors/sell.js
@@ -13,15 +13,20 @@
// (?classic=1 / ?game=0) → the consumer never constructs/opens this, and open() itself returns false
// on an empty sellable list — zero errors, zero DOM.
+import { sellBasis, guideText } from './bible.js'; // [R32] THE BIBLE — the §9.1 beta basis + the card's guide line
+
export const SELL_MULT = 0.5; // the keeper pays half of what he'll sticker it at (§9.1 — he eats too)
export const SELL_DIST = 2.0; // metres from room.counter.pose (bench centre, room-local)
-// The offer (§9.1). Alpha basis = pricePaid (the buy-side price the player actually faced — no guide
-// bands until beta; beta swaps the basis to bandLow, the clamp survives untouched). The min(p−1, …)
-// clamp is the law: no retune of SELL_MULT can ever push an offer to break even. p=1 → $0 (the SELL
-// button disables — the keeper won't buy what he can't sticker).
-export function sellOffer(pricePaid) {
- const p = Math.floor(+pricePaid || 0);
+// The offer (§9.1). [R32 — the pre-ratified beta swap, executed] Basis = bandLow when the entry carries
+// a band the BIBLE knows (sellBasis, bible.js), else the alpha basis (pricePaid) — so alpha-era finds
+// sell exactly as they always did. THE CLAMP SHAPE IS UNTOUCHED: the offer sits strictly below the
+// BASIS for every basis ≥ 1, which with bandLow as basis is the no-pump law's beta form — "every sell
+// price < the buy-side guide band, structurally" (charter law #2). A gem (bought UNDER band) nets its
+// finder margin against the band, never against the keeper; scarcity (the save's pulls) makes that
+// margin a finite find, not a loop. p=1 → $0 (the SELL button disables).
+export function sellOffer(basis) {
+ const p = Math.floor(+basis || 0);
if (p <= 0) return 0;
return Math.min(p - 1, Math.max(1, Math.floor(p * SELL_MULT)));
}
@@ -92,15 +97,16 @@ export function createSell() {
function render() {
const it = items[idx];
if (!it) { close(); return; }
- const offer = sellOffer(it.pricePaid != null ? it.pricePaid : it.price);
+ const offer = sellOffer(sellBasis(it)); // [R32] bandLow basis when banded, pricePaid else
const head = labelOf(it) || titleOf(it);
const ttl = labelOf(it) ? titleOf(it) : '';
const paid = it.pricePaid != null ? it.pricePaid : it.price;
+ const guide = guideText(it.type, it.band); // [R32] '' when unbanded → no stray separator (§7.2c)
panel.innerHTML = `