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 = `
` + `
$${esc(offer)}
` + `

${esc(head)}

` + (ttl ? `
“${esc(ttl)}”
` : '') - + `
${paid != null ? 'you paid $' + esc(paid) : ''}${it.dayFound != null ? ' · day ' + esc(it.dayFound) : ''}
` + + `
${paid != null ? 'you paid $' + esc(paid) : ''}${guide ? ' · ' + esc(guide) : ''}${it.dayFound != null ? ' · day ' + esc(it.dayFound) : ''}
` + (items.length > 1 ? `` : '') + ``; panel.style.display = 'block';