Lane C R30 (v7.0-alpha ledger #2, implementation): sell.js — the sell counter lands. E at the bench → the mirrored price sticker; offer strictly below pricePaid by construction (8/8 scripted round trips lose money); type=type fail-closed (untyped item sells nowhere); wallet.sell = the credit mirror; game layer absent → no card, zero errors. Verified on :8144 no-store, stubbed game shape (F's core not landed); soak green avg 3.4ms · leak 0/0 · draws card-open ≡ closed
This commit is contained in:
parent
53fcd3ca1b
commit
369a2be23e
@ -3,11 +3,71 @@
|
|||||||
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
|
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
|
||||||
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
|
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
|
||||||
|
|
||||||
Last updated: 2026-07-17 (round 28) · owner: PROCITY-C · reviewer: Fable
|
Last updated: 2026-07-18 (round 30) · owner: PROCITY-C · reviewer: Fable
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Update 2026-07-17 (round 28, THE SPIKE AND THE SWEEP) — `audioEmitter`: the till rings, and the panner lies
|
## Update 2026-07-18 (round 30, v7.0-alpha THE SAVE AND THE CRATE) — the sell counter: cash comes back, always less
|
||||||
|
|
||||||
|
R30 ledger #2 — contract first (LANE_C_PUB §9, its own commit), implementation second. F's save core
|
||||||
|
(ledger #1) had **not landed** when this was built: no `save.js`, no `window.PROCITY.game` anywhere in
|
||||||
|
the tree — so everything below is built to the R30-published shape and verified against a **stub** of
|
||||||
|
exactly that shape (noted loudly below).
|
||||||
|
|
||||||
|
### The contract (§9) — the one number and why
|
||||||
|
**`sellOffer(p) = min(p − 1, max(1, floor(p × 0.5)))`, never < 0.**
|
||||||
|
- **0.5** is the secondhand-dealer margin — the keeper pays half of what he'll sticker it at.
|
||||||
|
- **`min(p − 1, …)` is the no-pump law made STRUCTURAL:** the offer is strictly below `pricePaid` for
|
||||||
|
every `p ≥ 1`, so buy-then-immediately-sell loses ≥ $1 per round trip **by construction** — no
|
||||||
|
balance retune can break F's monotonic-loss gate without violating the contract. At `p = 1` the
|
||||||
|
offer is $0 and SELL disables (the keeper won't buy what he can't sticker).
|
||||||
|
- **Alpha basis = `pricePaid`** (every alpha buy is at asking price, so it IS the buy-side price the
|
||||||
|
player faced; there are no guide bands yet). **Beta swaps the basis to `bandLow`, not the shape.**
|
||||||
|
- Matching is **type = type, strict, fail-closed**: an untyped item sells NOWHERE (a matcher passing
|
||||||
|
untyped items would "match" everywhere — vacuous-gate law applied to a matcher).
|
||||||
|
|
||||||
|
### What shipped (C's files only)
|
||||||
|
- **`web/js/interiors/sell.js`** — `createSell()` + pure `sellOffer` / `sellableIn` / `nearCounter`.
|
||||||
|
DOM-only (zero draws, zero GPU), the dig's manila price-sticker mirrored to the left with a green
|
||||||
|
offer sticker; one item at a time, `‹ ›` cycling, ✕/Esc; till rings from the counter emitter (R28
|
||||||
|
one-shot pattern). Same consumer pattern as `dig.js`: the single side-effect is `onSell(item,
|
||||||
|
offer)` — **C never mutates `game.collection`** (read-only law, §9.3).
|
||||||
|
- **`web/js/interiors/wallet.js`** — `wallet.sell(item, offer)`: the credit mirror of `buy()`.
|
||||||
|
This file never prices; the offer comes in from `sellOffer`.
|
||||||
|
- **`web/interior_test.html`** — E-at-counter routing (bin → dig · counter → sell · shelf → buy) gated
|
||||||
|
on `window.PROCITY.game` presence (the in-game classic-pure law, exactly); `?sellstub=1` stubs the
|
||||||
|
published game shape for verification until F's core lands.
|
||||||
|
|
||||||
|
### Verified (fresh context, no-store server on :8144 — NOT :8130, zero console errors throughout)
|
||||||
|
- **The sale:** record shop seed 1990, at the bench (`nearCounter` true at 0.9 m, false across the
|
||||||
|
room) → card `THE FIBROS — "Servo at Midnight" · you paid $24 · offer $12` → SELL → **cash 163 →
|
||||||
|
175 (+$12 < $24 paid)**, collection 4 → 3, sold item gone, toast + till fired.
|
||||||
|
- **Type fence:** stub carried 2 records + 1 book + 1 untyped item. Record shop cards **exactly the 2
|
||||||
|
records**; book shop cards **exactly the book** ($7 on $15); the untyped item cards **nowhere**.
|
||||||
|
- **The disabled edge:** the $1 record offers $0 → button reads NOT WORTH BUYING, disabled.
|
||||||
|
- **No-pump round-trip (C's mini-gate, precursor to F's #5):** buy-then-sell through the real wallet +
|
||||||
|
card path, prices {2,3,5,9,24,60,61,137} → **8/8 strictly negative** (−1,−2,−3,−5,−12,−30,−31,−69).
|
||||||
|
- **The gate discriminated first** (vacuous-gate law, proven live): my first loop pushed a 999-priced
|
||||||
|
item into the collection **without checking `wallet.buy` succeeded** — broke wallet, no debit, then
|
||||||
|
a +$499 "sale". A pump — from an item never paid for. **Finding for F below.**
|
||||||
|
- **Fail-soft:** boot with no stub (= no game layer) → E at the counter does nothing, `open()` false,
|
||||||
|
zero DOM, zero errors. `dispose()` leaves 0 panels/0 toasts in the DOM.
|
||||||
|
- **Budgets:** draws with the card open ≡ closed (16 ≡ 16 — DOM is zero-draw); `buildInterior` and all
|
||||||
|
room code untouched this round; 50-room soak re-run green in the same session: **avg 3.4 ms · worst
|
||||||
|
8.2 ms · leak geo 0 / tex 0 · determinism identical · worst draws 73 ≤ 350**.
|
||||||
|
|
||||||
|
### Findings / asks (→ F, for the save core + gates)
|
||||||
|
1. **The collection item needs `type`** (registry stock type, stamped at buy time). The R30 shape
|
||||||
|
(`{townKey, godverseShopId|shopId, sku|slotId, pricePaid, dayFound}`) doesn't carry it, and without
|
||||||
|
it **nothing is sellable anywhere** (my matcher is fail-closed on purpose). `title`/`artist` are
|
||||||
|
strongly asked too (B's collection UI needs them; my card falls back to the sku label — honest, ugly).
|
||||||
|
2. **`pricePaid` must only ever be written by a SUCCESSFUL debit.** The no-pump law's structure
|
||||||
|
assumes pricePaid was actually paid — my broken first loop showed a collection entry that skipped
|
||||||
|
the wallet debit converts straight into a money pump. F's no-pump gate should include this negative
|
||||||
|
control: a collection entry minted without a debit must be impossible (or the gate must fail).
|
||||||
|
3. **The removal handoff:** in-game, the sold item must leave the collection **via the game API** —
|
||||||
|
`onSell` is where F calls it. My harness splices its own stub (its array, its right); C's modules
|
||||||
|
never touch game state. When F publishes the API name, §9.3 references it verbatim.
|
||||||
|
|
||||||
R28 §Lane C (ledger #3) — my own idea, parked since R7, brief states acceptance only. Wave 1, alongside E+B.
|
R28 §Lane C (ledger #3) — my own idea, parked since R7, brief states acceptance only. Wave 1, alongside E+B.
|
||||||
|
|
||||||
|
|||||||
@ -73,6 +73,7 @@ import { buildInterior, SHOP_TYPES, ARCHETYPE_KEYS, preloadStockPack, getStockPa
|
|||||||
import { createDig, binSeed } from './js/interiors/dig.js';
|
import { createDig, binSeed } from './js/interiors/dig.js';
|
||||||
import { createWallet } from './js/interiors/wallet.js';
|
import { createWallet } from './js/interiors/wallet.js';
|
||||||
import { collapseBuyItem } from './js/interiors/stockpack.js';
|
import { collapseBuyItem } from './js/interiors/stockpack.js';
|
||||||
|
import { createSell, sellOffer, sellableIn, nearCounter, SELL_DIST } from './js/interiors/sell.js';
|
||||||
|
|
||||||
// ?stock=real — feed Lane E's GODVERSE record-sleeve pack through the stockAdapter seam.
|
// ?stock=real — feed Lane E's GODVERSE record-sleeve pack through the stockAdapter seam.
|
||||||
const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real';
|
const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real';
|
||||||
@ -522,6 +523,54 @@ async function shelfBuySoak() {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── sell counter (R30, v7.0-alpha ledger #2) — walk to the counter holding items, press E → sell card ──
|
||||||
|
// GATE = the game layer's presence (window.PROCITY.game), exactly the in-game law: ?classic=1/?game=0
|
||||||
|
// build no game object, so no sell card, zero errors. F's save core (R30 ledger #1) hadn't landed when
|
||||||
|
// this was wired, so ?sellstub=1 STUBS the published contract shape ({ day, cash, collection[] } — the
|
||||||
|
// slice C reads) purely for this harness. When F's window.PROCITY.game is real, the stub retires; the
|
||||||
|
// sell path below is what F wires verbatim (LANE_C_PUB §9.3/§9.4).
|
||||||
|
const SELL_STUB = new URLSearchParams(location.search).has('sellstub');
|
||||||
|
if (SELL_STUB) {
|
||||||
|
window.PROCITY = window.PROCITY || {};
|
||||||
|
// STUB — R30 ledger #1's published shape, + the §9.2 asks (type, title, artist) the card needs.
|
||||||
|
window.PROCITY.game = window.PROCITY.game || { day: 1, cash: 0, collection: [
|
||||||
|
{ townKey: 'test', shopId: 'stub1', slotId: 'stub_0001', type: 'record', title: 'Servo at Midnight', artist: 'THE FIBROS', pricePaid: 24, dayFound: 1 },
|
||||||
|
{ townKey: 'test', shopId: 'stub2', slotId: 'stub_0002', type: 'record', title: 'Arvo', artist: 'GALAH', pricePaid: 1, dayFound: 1 }, // $1 → offer $0 → SELL disabled (§9.1)
|
||||||
|
{ townKey: 'test', shopId: 'stub3', slotId: 'stub_0003', type: 'book', title: 'The Long Paddock', artist: '', pricePaid: 15, dayFound: 1 },
|
||||||
|
{ townKey: 'test', shopId: 'stub4', slotId: 'sku_untyped', pricePaid: 30, dayFound: 1 }, // NO type → never sellable (§9.2 fail-closed)
|
||||||
|
] };
|
||||||
|
}
|
||||||
|
const sell = createSell();
|
||||||
|
function trySell() {
|
||||||
|
const game = window.PROCITY && window.PROCITY.game; // absent ⇒ no game layer ⇒ no card
|
||||||
|
if (!game || !Array.isArray(game.collection) || !current) return false;
|
||||||
|
if (!nearCounter(current, camera.position)) return false; // §9.4: within SELL_DIST of the bench
|
||||||
|
const opened = sell.open({
|
||||||
|
shopType: current.dims.type, shopName: current.recipe.label,
|
||||||
|
items: game.collection, // READ-ONLY — sell.js cards a filtered copy
|
||||||
|
getCash: wallet.cash,
|
||||||
|
emitters: current.audio && current.audio.emitters,
|
||||||
|
// The consumer's two halves (§9.3): credit the wallet + remove from the collection. In-game the
|
||||||
|
// removal is F's, via the game API; HERE the collection is this harness's own stub, so the harness
|
||||||
|
// splices its own array. C's modules never touch it.
|
||||||
|
onSell: (item, offer) => {
|
||||||
|
wallet.sell(item, offer);
|
||||||
|
const i = game.collection.indexOf(item); if (i >= 0) game.collection.splice(i, 1);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (opened && controls.isLocked) controls.unlock();
|
||||||
|
return opened;
|
||||||
|
}
|
||||||
|
addEventListener('keydown', e => {
|
||||||
|
if (e.code !== 'KeyE' || e.target.matches('input,select')) return;
|
||||||
|
if ((dig && dig.active) || sell.active) return;
|
||||||
|
if (DIG_ON && binUnderAim()) return; // bins → dig (that listener handles it)
|
||||||
|
if (shelfPanel.style.display === 'block') return; // shelf card already up
|
||||||
|
trySell(); // counter → sell card
|
||||||
|
});
|
||||||
|
if (SELL_STUB) document.getElementById('hint').innerHTML += ' · <b>E</b> at the counter: sell';
|
||||||
|
|
||||||
// ── loop ───────────────────────────────────────────────────────────────────────
|
// ── loop ───────────────────────────────────────────────────────────────────────
|
||||||
let last = performance.now();
|
let last = performance.now();
|
||||||
function frame() {
|
function frame() {
|
||||||
@ -534,7 +583,8 @@ function frame() {
|
|||||||
// expose for headless verification (screenshot harness, workflow checks)
|
// expose for headless verification (screenshot harness, workflow checks)
|
||||||
window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, drawSweep, DRAW_LAW, rebuild, get current() { return current; }, scene, camera, renderer,
|
window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, drawSweep, DRAW_LAW, rebuild, get current() { return current; }, scene, camera, renderer,
|
||||||
DIG_ON, STOCK_REAL, digSoak, openDigOn, binUnderAim, get dig() { return dig; }, wallet, preloadStockPack, getStockPack, makeStockAdapter,
|
DIG_ON, STOCK_REAL, digSoak, openDigOn, binUnderAim, get dig() { return dig; }, wallet, preloadStockPack, getStockPack, makeStockAdapter,
|
||||||
shelfUnderAim, showShelfCard, buyShelfOffer, shelfBuySoak };
|
shelfUnderAim, showShelfCard, buyShelfOffer, shelfBuySoak,
|
||||||
|
SELL_STUB, sell, trySell, sellOffer, sellableIn, nearCounter, SELL_DIST };
|
||||||
|
|
||||||
rebuild();
|
rebuild();
|
||||||
frame();
|
frame();
|
||||||
|
|||||||
160
web/js/interiors/sell.js
Normal file
160
web/js/interiors/sell.js
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
// 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; } };
|
||||||
|
}
|
||||||
@ -26,6 +26,19 @@ export function createWallet(seed = 1) {
|
|||||||
notify();
|
notify();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
// [R30 §9.3] sell(item, offer) — the credit side of the counter, the exact mirror of buy(): adds the
|
||||||
|
// offer (computed by sell.js sellOffer — this file never prices) and drops the matching v0-inventory
|
||||||
|
// entry if one exists (by identity first, then title+artist). The GAME collection is NOT touched
|
||||||
|
// here — that removal is the consumer's, via the game API (LANE_C_PUB §9.3).
|
||||||
|
sell(o, offer) {
|
||||||
|
const amt = Math.max(0, Math.floor(+offer || 0));
|
||||||
|
cash += amt;
|
||||||
|
let i = inv.indexOf(o);
|
||||||
|
if (i < 0) i = inv.findIndex((x) => x.title === (o.t || o.title) && x.artist === (o.a || o.artist || ''));
|
||||||
|
if (i >= 0) inv.splice(i, 1);
|
||||||
|
notify();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
inventory: () => inv.slice(),
|
inventory: () => inv.slice(),
|
||||||
count: () => inv.length,
|
count: () => inv.length,
|
||||||
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
|
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user