161 lines
9.5 KiB
JavaScript
161 lines
9.5 KiB
JavaScript
// 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(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);
|
||
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(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; } };
|
||
}
|