PROCITY/web/js/interiors/sell.js

161 lines
9.5 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.
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(p1, …)
// 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);
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(it.pricePaid != null ? it.pricePaid : it.price);
const head = labelOf(it) || titleOf(it);
const ttl = labelOf(it) ? titleOf(it) : '';
const paid = it.pricePaid != null ? it.pricePaid : it.price;
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) : ''}${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; } };
}