PROCITY/web/js/interiors/sell.js
type-two f9517f14ab 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.
2026-07-20 18:26:15 +10:00

167 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PROCITY Lane C — the sell counter (v7.0-alpha, R30 ledger #2). Walk to the keeper's counter holding
// items the shop trades in → a sell card (the mirror of the dig's price sticker): item, OFFER, SELL.
// Contract: LANE_C_PUB §9 — this file is the loader-exact authority (the §7 rule applies here too).
//
// THE NO-PUMP LAW IS STRUCTURAL, NOT TUNED (charter law #2): sellOffer() is strictly below what the
// player paid for every pricePaid ≥ 1, so buy-then-immediately-sell loses ≥ $1 per round trip by
// construction — F's monotonic-loss gate holds against the formula's shape, not a balance pass.
//
// Consumer pattern mirrors dig.js: C ships the interaction, F owns the in-game input hook. C consumes
// the game collection READ-ONLY — the single side-effect is onSell(item, offer); the CONSUMER credits
// the wallet and removes the item via the game API (never this file, never a splice of game state).
// DOM-only: zero draws, zero GPU resources; dispose() removes every node + listener. No game layer
// (?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). [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)));
}
// Alpha matcher (§9.2): any shop of the item's type buys it — strict equality, and FAIL-CLOSED on an
// untyped item (vacuous-gate law: a matcher that passes an untyped item would "match" everywhere).
// Per-keeper taste / haggling / pawn-buys-everything: beta — do not widen here.
export function sellableIn(shopType, item) {
return !!(item && item.type && shopType && item.type === shopType);
}
// Proximity test (§9.4) — pos is the interior camera position (room-local, the same frame the
// counter pose is published in).
export function nearCounter(room, pos, dist = SELL_DIST) {
const c = room && room.counter && room.counter.pose;
if (!c || !pos) return false;
return Math.hypot(pos.x - c.x, pos.z - c.z) <= dist;
}
// [R28 pattern] one-shot distance gain from the counter emitter — same math as dig.js's till.
function emitterGain(em) {
if (!em) return 1;
const cam = window.PROCITY && window.PROCITY.camera && window.PROCITY.camera.position;
if (!cam) return 1;
const d = Math.hypot(cam.x - em.x, cam.z - em.z);
const prox = Math.max(0, Math.min(1, (em.r - d) / em.r));
return em.floor + (1 - em.floor) * prox;
}
function ringTill(em) {
const eng = window.PROCITY && window.PROCITY.audio;
if (!eng || !eng.playSfx) return; // no engine → silent-and-happy (?mute/?noassets inside)
eng.playSfx('till', { gain: emitterGain(em) });
}
export function createSell() {
const el = (tag, cls) => { const e = document.createElement(tag); if (cls) e.className = cls; return e; };
// The buy card's manila sticker, mirrored to the LEFT (the sell side of the counter). Same
// typewriter/stamp treatment as .pcdg-panel so the two cards read as one shop's stationery.
const css = el('style'); css.textContent = `
.pcsl-panel{position:fixed;left:6%;top:50%;transform:translateY(-50%) rotate(1.1deg);width:262px;background:#f4ecd8;border:1px solid #cbbf9e;box-shadow:0 10px 26px rgba(0,0,0,.55);padding:15px 16px 14px;color:#241f18;font:14px "Courier New",monospace;display:none;z-index:61}
.pcsl-panel .sticker{float:right;width:54px;height:54px;border-radius:50%;background:#bfe3c0;border:1px solid #5a9a5e;color:#1c5e2a;font:700 18px Arial;line-height:54px;text-align:center;transform:rotate(-5deg);margin:-2px -4px 4px 10px;box-shadow:0 1px 3px rgba(0,0,0,.25)}
.pcsl-panel h3{margin:.1em 0 3px;color:#241f18;font-size:15px;font-weight:700;text-transform:uppercase;letter-spacing:.02em}
.pcsl-panel .ttl{color:#4c4436;font-size:13px;font-style:italic;margin:0 0 9px;line-height:1.25}
.pcsl-panel .meta{clear:both;color:#6f6553;font-size:11px;letter-spacing:.05em;min-height:1px}
.pcsl-panel .nav{display:flex;justify-content:space-between;align-items:center;margin-top:9px;color:#6f6553;font-size:12px}
.pcsl-panel .nav span{cursor:pointer;padding:0 8px;user-select:none}
.pcsl-panel .nav span:hover{color:#241f18}
.pcsl-panel button{margin-top:11px;width:100%;padding:9px;border:1px solid #5a7a5e;background:#dcead0;color:#1c5e2a;font:700 13px "Courier New",monospace;letter-spacing:.06em;cursor:pointer}
.pcsl-panel button:hover:not(:disabled){background:#1c5e2a;color:#dcead0;border-color:#1c5e2a}
.pcsl-panel button:disabled{color:#9a917c;border-color:#c3b99f;background:#e7dcc2;cursor:default}
.pcsl-panel .x{position:absolute;top:4px;left:8px;cursor:pointer;color:#a89e86;font-size:13px}
.pcsl-toast{position:fixed;left:50%;top:40%;transform:translate(-50%,-50%);background:#0e160e;border:1px solid #3dff8b;color:#3dff8b;padding:14px 22px;font:700 15px "Courier New",monospace;z-index:62;display:none}`;
document.head.appendChild(css);
const panel = el('div', 'pcsl-panel');
const toast = el('div', 'pcsl-toast');
document.body.append(panel, toast);
let active = false, items = [], idx = 0;
let onSellCb = null, onCloseCb = null, emitters = null, shopName = '';
let toastT = null;
const showToast = (msg) => { toast.textContent = msg; toast.style.display = 'block'; clearTimeout(toastT); toastT = setTimeout(() => (toast.style.display = 'none'), 1400); };
const esc = (s) => String(s).replace(/[&<>"]/g, (m) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[m]));
const labelOf = (it) => it.artist || it.a || ''; // headline line (artist), may be empty
const titleOf = (it) => it.title || it.t || it.sku || it.slotId || 'item'; // §9.2 fallback: honest, ugly
function render() {
const it = items[idx];
if (!it) { close(); return; }
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 = `<div class="x">✕</div>`
+ `<div class="sticker">$${esc(offer)}</div>`
+ `<h3>${esc(head)}</h3>`
+ (ttl ? `<div class="ttl">“${esc(ttl)}”</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>` : '')
+ `<button ${offer <= 0 ? 'disabled' : ''}>${offer <= 0 ? 'NOT WORTH BUYING' : 'SELL — $' + esc(offer)}</button>`;
panel.style.display = 'block';
panel.querySelector('.x').onclick = () => close();
const pv = panel.querySelector('.pv'), nx = panel.querySelector('.nx');
if (pv) pv.onclick = () => { idx = (idx + items.length - 1) % items.length; render(); };
if (nx) nx.onclick = () => { idx = (idx + 1) % items.length; render(); };
const b = panel.querySelector('button');
if (b && !b.disabled) b.onclick = () => {
// The single side-effect (§9.3): the consumer credits the wallet AND removes the item via the
// game API. false = keeper veto → card unchanged, nothing moves. C never touches game state.
const ok = onSellCb ? onSellCb(it, offer) : false;
if (ok === false) return;
ringTill(emitters && emitters.counter);
showToast(`SOLD — ${esc(head)} · +$${offer}`);
items.splice(idx, 1); // the LOCAL sellable list only, never the collection
if (!items.length) { close(); return; }
idx = Math.min(idx, items.length - 1);
render();
};
}
const onKey = (e) => { if (!active) return; if (e.key === 'Escape') { e.stopPropagation(); close(); } };
// open({ shopType, shopName, items, getCash, onSell, onClose, emitters }) → bool (§9.6).
// `items` = the game collection (read-only; a filtered COPY is carded). Nothing sellable → false,
// no card, no DOM — the fail-soft half of the classic-pure law lives here too.
function open(opts = {}) {
if (active) return true;
const src = Array.isArray(opts.items) ? opts.items : [];
const sellable = src.filter((it) => sellableIn(opts.shopType, it));
if (!sellable.length) return false;
items = sellable; idx = 0; active = true;
onSellCb = opts.onSell || null; onCloseCb = opts.onClose || null;
emitters = opts.emitters || null; shopName = opts.shopName || '';
document.addEventListener('keydown', onKey, true);
render();
return true;
}
function close() {
if (!active) return;
active = false; items = []; idx = 0;
panel.style.display = 'none';
document.removeEventListener('keydown', onKey, true);
const cb = onCloseCb; onCloseCb = null; onSellCb = null; emitters = null;
if (cb) cb();
}
function dispose() {
close();
clearTimeout(toastT);
[css, panel, toast].forEach((e) => e.remove());
}
return { open, close, dispose, get active() { return active; } };
}