Lane C R32 (v7.0-beta wave 1): THE BIBLE — bands derived, gems seeded, the cards get the guide stamp

bible.js: band ranges MIRRORED from E's pipeline thresholds (record 8/25/60 · book 5/15/40 ·
toy 6/20/50) — derived in JS off the price_band labels the packs already carry, NO pack re-emit
(charter risk #3 never opens). guideText renders a RANGE ('guide $8–24' / '$60+'), never a
verdict — the card never says gem; spotting asking-under-guide is the player's skill.
dig.js: parody sleeves roll seeded bands (25/45/22/8) + a 10% gem on collector/grail only,
asking strictly under the keeper's own bandLow offer (collector $2–9 vs $12 · grail $5–20 vs
$30) — margin by construction; fixed 9-draw stream per item; rare ≡ grail (matches real packs).
Real offers carry band off price_band — and real asking always sits INSIDE its band (the band
derives FROM the price), so with the §9.1 basis swap every real round trip stays strictly
negative: the no-pump gate holds untouched, structurally. open({gone}) omits pulled slots AFTER
the draw (R27 pick-then-omit, zero collateral; o.i survives filtering — identity preserved).
currentOffers() honest surface for F's gate. sell.js: the §9.1 basis swap EXECUTED exactly as
pre-ratified — sellOffer(sellBasis(entry)) = min(bandLow−1, max(1, floor(bandLow·0.5))) when
banded; alpha-era entries (no band) keep the pricePaid basis and sell exactly as before. The
sell card carries the guide line too.
This commit is contained in:
type-two 2026-07-20 18:26:15 +10:00
parent 046668e5af
commit f9517f14ab
3 changed files with 123 additions and 22 deletions

58
web/js/interiors/bible.js Normal file
View File

@ -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 $2559"), 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(t11).
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 $2559" / "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;
}

View File

@ -10,6 +10,7 @@
// runs unless the shell/test-page opens it, so `?dig=0` is byte-identical to v1. // runs unless the shell/test-page opens it, so `?dig=0` is byte-identical to v1.
import { mulberry32, xmur3 } from '../core/prng.js'; 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 MAXFLIP = 1.95, BACK = -0.12;
const SLEEVE = [0.31, 0.31, 0.0060]; // w, h, thickness (packed a touch chunkier than vinyl) 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']; const CONDS = ['VG+', 'VG', 'G+', 'NM', 'EX'];
// Deterministic sleeve list for a bin — the "existing procedural stock" as riffle-able offers. // 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) { function proceduralSleeves(seed, n) {
const r = mulberry32(seed >>> 0); const r = mulberry32(seed >>> 0);
const out = []; const out = [];
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const g = GENRE[(r() * GENRE.length) | 0]; const g = GENRE[(r() * GENRE.length) | 0];
out.push({ const a = ARTISTS[(r() * ARTISTS.length) | 0];
i, kind: 'record', const t = TITLES[(r() * TITLES.length) | 0];
a: ARTISTS[(r() * ARTISTS.length) | 0], const y = 1971 + ((r() * 28) | 0);
t: TITLES[(r() * TITLES.length) | 0], const cond = CONDS[(r() * CONDS.length) | 0];
y: 1971 + ((r() * 28) | 0), const band = PARODY_BAND(r());
s: g[0], color: g[1], const [lo, hi] = BANDS.record[band];
cond: CONDS[(r() * CONDS.length) | 0], const priceRoll = r(), gemRoll = r(), gemPriceRoll = r(); // always drawn — fixed stream shape
price: 2 + ((r() * 28) | 0), const gem = gemRoll < 0.10 && (band === 'collector' || band === 'grail');
rare: r() < 0.12, // gem asking sits STRICTLY under the keeper's own bandLow offer (min(lo1, floor(lo/2)) — sell.js),
}); // so a spotted gem always nets margin: collector asks $29 vs offer $12 · grail $520 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; return out;
} }
@ -250,6 +263,11 @@ export function createDig(THREE, renderer) {
const line = [o.y, o.s].filter(Boolean).join(' '); // "1974 SOUL" | "SOUL" | "" — never " · " const line = [o.y, o.s].filter(Boolean).join(' '); // "1974 SOUL" | "SOUL" | "" — never " · "
if (line) stamps.push(`<span class="tag">${esc(line)}</span>`); if (line) stamps.push(`<span class="tag">${esc(line)}</span>`);
if (o.rare) stamps.push(`<span class="star">★ ${o.real ? 'GRAIL' : 'RARE'}</span>`); if (o.rare) stamps.push(`<span class="star">★ ${o.real ? 'GRAIL' : 'RARE'}</span>`);
// [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(`<span class="tag">${esc(guide)}</span>`);
panel.innerHTML = `<div class="x">✕</div>` panel.innerHTML = `<div class="x">✕</div>`
+ `<div class="sticker">$${esc(o.price)}</div>` + `<div class="sticker">$${esc(o.price)}</div>`
+ `<h3>${esc(o.a)}</h3>` + `<h3>${esc(o.a)}</h3>`
@ -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 // 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 // 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. // 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 rr = mulberry32(seed >>> 0), out = [];
const gone = pack.gone instanceof Set ? pack.gone const gone = pack.gone instanceof Set ? pack.gone
: (Array.isArray(pack.gone) ? new Set(pack.gone) : null); : (Array.isArray(pack.gone) ? new Set(pack.gone) : null);
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const it = pack.items[(rr() * pack.items.length) | 0]; 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 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 // 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, // 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. // 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, out.push({ real: true, pack, item: it, a: it.artist, t: it.title, price: it.price,
s: band === 'GRAIL' ? '' : band, y: '', s: band === 'GRAIL' ? '' : band, y: '',
cond: it.condition || '', sleeveCond: it.sleeve_cond || '', 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' }); rare: it.price_band === 'grail' });
} }
return out; 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 // `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. // 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:<id>' 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 = {}) { function open(opts = {}) {
active = true; active = true;
onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null; onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null;
emitters = opts.emitters || null; emitters = opts.emitters || null;
const count = opts.count || 16; 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. // riffle items: real pack (?stock=real) via the stockAdapter seam, else seeded procedural sleeves.
const src = opts.stockAdapter && opts.stockAdapter(opts.shop, 'sleeve'); 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 + ' — ' : ''); curLbl.textContent = (opts.shopName ? opts.shopName + ' — ' : '');
[hint, curLbl, outBtn].forEach(e => e.style.display = 'block'); [hint, curLbl, outBtn].forEach(e => e.style.display = 'block');
buildStack(offers); buildStack(offers);
@ -411,7 +439,16 @@ export function createDig(THREE, renderer) {
[css, hint, curLbl, panel, outBtn, toast].forEach(e => e.remove()); [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. // A stable per-bin seed from the shop + a bin index, so a given bin riffles the same order every time.

View File

@ -13,15 +13,20 @@
// (?classic=1 / ?game=0) → the consumer never constructs/opens this, and open() itself returns false // (?classic=1 / ?game=0) → the consumer never constructs/opens this, and open() itself returns false
// on an empty sellable list — zero errors, zero DOM. // 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_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) 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 // The offer (§9.1). [R32 — the pre-ratified beta swap, executed] Basis = bandLow when the entry carries
// bands until beta; beta swaps the basis to bandLow, the clamp survives untouched). The min(p1, …) // a band the BIBLE knows (sellBasis, bible.js), else the alpha basis (pricePaid) — so alpha-era finds
// clamp is the law: no retune of SELL_MULT can ever push an offer to break even. p=1 → $0 (the SELL // sell exactly as they always did. THE CLAMP SHAPE IS UNTOUCHED: the offer sits strictly below the
// button disables — the keeper won't buy what he can't sticker). // BASIS for every basis ≥ 1, which with bandLow as basis is the no-pump law's beta form — "every sell
export function sellOffer(pricePaid) { // price < the buy-side guide band, structurally" (charter law #2). A gem (bought UNDER band) nets its
const p = Math.floor(+pricePaid || 0); // 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; if (p <= 0) return 0;
return Math.min(p - 1, Math.max(1, Math.floor(p * SELL_MULT))); return Math.min(p - 1, Math.max(1, Math.floor(p * SELL_MULT)));
} }
@ -92,15 +97,16 @@ export function createSell() {
function render() { function render() {
const it = items[idx]; const it = items[idx];
if (!it) { close(); return; } 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 head = labelOf(it) || titleOf(it);
const ttl = labelOf(it) ? titleOf(it) : ''; const ttl = labelOf(it) ? titleOf(it) : '';
const paid = it.pricePaid != null ? it.pricePaid : it.price; 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 = `<div class="x">✕</div>` panel.innerHTML = `<div class="x">✕</div>`
+ `<div class="sticker">$${esc(offer)}</div>` + `<div class="sticker">$${esc(offer)}</div>`
+ `<h3>${esc(head)}</h3>` + `<h3>${esc(head)}</h3>`
+ (ttl ? `<div class="ttl">“${esc(ttl)}”</div>` : '') + (ttl ? `<div class="ttl">“${esc(ttl)}”</div>` : '')
+ `<div class="meta">${paid != null ? 'you paid $' + esc(paid) : ''}${it.dayFound != null ? ' · day ' + esc(it.dayFound) : ''}</div>` + `<div class="meta">${paid != null ? 'you paid $' + esc(paid) : ''}${guide ? ' · ' + esc(guide) : ''}${it.dayFound != null ? ' · day ' + esc(it.dayFound) : ''}</div>`
+ (items.length > 1 ? `<div class="nav"><span class="pv"></span>${idx + 1} of ${items.length}<span class="nx"></span></div>` : '') + (items.length > 1 ? `<div class="nav"><span class="pv"></span>${idx + 1} of ${items.length}<span class="nx"></span></div>` : '')
+ `<button ${offer <= 0 ? 'disabled' : ''}>${offer <= 0 ? 'NOT WORTH BUYING' : 'SELL — $' + esc(offer)}</button>`; + `<button ${offer <= 0 ? 'disabled' : ''}>${offer <= 0 ? 'NOT WORTH BUYING' : 'SELL — $' + esc(offer)}</button>`;
panel.style.display = 'block'; panel.style.display = 'block';