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

${esc(head)}

` + (ttl ? `
“${esc(ttl)}”
` : '') + `
${paid != null ? 'you paid $' + esc(paid) : ''}${guide ? ' · ' + esc(guide) : ''}${it.dayFound != null ? ' · day ' + esc(it.dayFound) : ''}
` + (items.length > 1 ? `` : '') + ``; 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; } }; }