// 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 = `