diff --git a/C-progress.md b/C-progress.md index 0316d88..ceaa2e3 100644 --- a/C-progress.md +++ b/C-progress.md @@ -3,11 +3,71 @@ *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.* -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. diff --git a/web/interior_test.html b/web/interior_test.html index ee30576..85c3bde 100644 --- a/web/interior_test.html +++ b/web/interior_test.html @@ -73,6 +73,7 @@ import { buildInterior, SHOP_TYPES, ARCHETYPE_KEYS, preloadStockPack, getStockPa import { createDig, binSeed } from './js/interiors/dig.js'; import { createWallet } from './js/interiors/wallet.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. const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real'; @@ -522,6 +523,54 @@ async function shelfBuySoak() { 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 += ' · E at the counter: sell'; + // ── loop ─────────────────────────────────────────────────────────────────────── let last = performance.now(); function frame() { @@ -534,7 +583,8 @@ function frame() { // 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, 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(); frame(); diff --git a/web/js/interiors/sell.js b/web/js/interiors/sell.js new file mode 100644 index 0000000..6369f48 --- /dev/null +++ b/web/js/interiors/sell.js @@ -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 = `
` + + `
$${esc(offer)}
` + + `

${esc(head)}

` + + (ttl ? `
“${esc(ttl)}”
` : '') + + `
${paid != null ? 'you paid $' + esc(paid) : ''}${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; } }; +} diff --git a/web/js/interiors/wallet.js b/web/js/interiors/wallet.js index 8ea9cc7..51ca806 100644 --- a/web/js/interiors/wallet.js +++ b/web/js/interiors/wallet.js @@ -26,6 +26,19 @@ export function createWallet(seed = 1) { notify(); 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(), count: () => inv.length, onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },