diff --git a/web/js/world/interior_mode.js b/web/js/world/interior_mode.js index 6d987a2..d2a5049 100644 --- a/web/js/world/interior_mode.js +++ b/web/js/world/interior_mode.js @@ -19,6 +19,7 @@ import { buildInterior, getStockPack, makeStockAdapter } from '../interiors/inte import { KeeperManager } from '../citizens/keepers.js'; // Lane D — shopkeeper behind the counter import { GigCrew } from '../citizens/band.js'; // Lane D — band trio + audience (R12, ?gigs=1) import { createDig, binSeed } from '../interiors/dig.js'; // Lane C — crate-riffle (v2, gated on ?dig=1) +import { mulberry32, xmur3 } from '../core/prng.js'; // [R33] the rumor roll (seeded, runtime-only) import { collapseBuyItem } from '../interiors/stockpack.js'; // Lane C — R9 buy-anywhere (book spines / toy boxes) import { createSell, nearCounter } from '../interiors/sell.js'; // Lane C — R30 sell counter (F owns this hook, §9.4) @@ -37,6 +38,11 @@ const EXIT_DIST = 1.0; // …then coming back within this distance leaves to th // lives in CITY_SPEC §v3. See LANE_F_NOTES §12. const WALLA_KEY = 'crowd-walla'; // [R13] manifest.ambience — crowd murmur layered under the gig bed while 'on' +// [R33 — THE RUMOR] where a keeper's mutter can point: the repo's real cached towns (display names). +// Flavor + a travel nudge, not a promise — the 90s had no stock lookups, and the town-wide packs mean +// every town CAN surface any hunted record eventually. Full persona lines are D's when the clip work lands. +const RUMOR_TOWNS = ['Katoomba', 'Newtown', 'Fremantle', 'Bendigo', 'Castlemaine']; + // [Lane F R24 §6a] The per-shop stock base for a GODVERSE shop — A's `godverseShopId` → C's per-shop // pack location (LANE_C_PUB §7.3/§7.5). Exported and used by BOTH the shell's boot preload and the room's // lookup, deliberately: C's cache identity is the LITERAL (type, base) pair, so if the two sides built @@ -187,6 +193,11 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null // [R32] the find carries its BAND (the sell counter's §9.1 basis) and the pull enters the save's // ledger — the slot is gone for the rest of the day even if the item is later sold on. getCash: wallet ? () => wallet.cash() : undefined, + // [R33] the wantlist seams — game-only (undefined ⇒ dig builds zero WANT DOM, classic-pure) + onWant: game ? (o) => game.recordWant({ artist: o && (o.a || o.artist), title: o && (o.t || o.title), + sku: o && o.item && o.item.id != null ? String(o.item.id) : undefined }) : undefined, + isWanted: game ? (o) => !!game.findWant({ artist: o && (o.a || o.artist), title: o && (o.t || o.title), + sku: o && o.item && o.item.id != null ? String(o.item.id) : '' }) : undefined, onBuy: wallet ? (o) => { const ok = wallet.buy(o); if (ok && game) { @@ -200,6 +211,9 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null band: (o && o.band) || undefined, }); game.recordPull(pullPrefix + (sku != null ? 'sku:' + sku : String((o && o.i) ?? 0))); + // [R33] the hunt ends when you're holding it: buying a wanted item clears its want + const w = game.findWant({ artist: o && (o.a || o.artist), title: o && (o.t || o.title), sku: sku || '' }); + if (w) { game.removeWant(w); flashToast('✓ off the wantlist'); } } return ok; } : undefined, @@ -419,10 +433,25 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null // [R30 §9.4] the sell hint shows only when the player actually HOLDS something this shop trades in // (type = type, C's fail-closed matcher) — a hint over nothing sellable would advertise a dead E. const canSell = !!(game && game.collection && game.collection.some((it) => it && it.type === shop.type)); + // [R33 — THE RUMOR, charter #6, the keeper finally gossips] Seeded per (citySeed, shop, day): a + // ~45% mutter naming a hunted want + a real town — the reason to ride somewhere tomorrow. Runtime + // stream, freshly keyed: no plan/golden moves (A's boundary). No game / empty wantlist ⇒ silence, + // and the banner line simply doesn't exist (classic-pure by construction). + let rumor = ''; + if (game && game.wants && game.wants.length) { + const rr = mulberry32(xmur3(`rumor:${plan.citySeed}:${shop.id}:${game.day}`)() >>> 0); + if (rr() < 0.45) { + const w = game.wants[(rr() * game.wants.length) | 0]; + const towns = RUMOR_TOWNS.filter((t) => !(plan.name || '').toLowerCase().includes(t.toLowerCase())); + const town = towns[(rr() * towns.length) | 0] || RUMOR_TOWNS[0]; + rumor = ` · 🗣 “${String(w.artist || w.title || w.sku).toUpperCase()}? saw one in ${town} just last week”`; + } + } banner.textContent = `🚪 ${name || shop.name || 'shop'} — walk out the door or press Esc to leave` + (gigOn ? ` · 🎸 ${String(tonight.bandName).toUpperCase()} — LIVE` : '') + (hasBins ? ' · E: riffle a record bin' : '') - + (canSell ? ' · E at the counter: sell' : ''); + + (canSell ? ' · E at the counter: sell' : '') + + rumor; banner.style.display = 'block'; return current; } diff --git a/web/js/world/save.js b/web/js/world/save.js index a1d8919..6a90e34 100644 --- a/web/js/world/save.js +++ b/web/js/world/save.js @@ -50,6 +50,17 @@ export function validateSave(obj) { for (let i = 0; i < obj.pulls.length; i++) if (typeof obj.pulls[i] !== 'string' || !obj.pulls[i]) return { ok: false, why: `pulls[${i}] malformed` }; } + // [R33 wantlist] `wants` is OPTIONAL, same law: array of {artist?|title?|sku?} string fields, at + // least one present per entry — a want with nothing to hunt is malformed, not empty-but-fine. + if (obj.wants != null) { + if (!Array.isArray(obj.wants)) return { ok: false, why: 'wants not an array' }; + for (let i = 0; i < obj.wants.length; i++) { + const w = obj.wants[i]; + if (!w || typeof w !== 'object' || Array.isArray(w)) return { ok: false, why: `wants[${i}] malformed` }; + const f = ['artist', 'title', 'sku'].filter((k) => w[k] != null); + if (!f.length || f.some((k) => typeof w[k] !== 'string' || !w[k])) return { ok: false, why: `wants[${i}] malformed` }; + } + } return { ok: true, state: obj }; } @@ -66,6 +77,11 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul let day = 1; let cash = Math.max(0, Math.floor(+startCash || 0)); let collection = []; + // [R33] the wantlist — what you're hunting ({artist?|title?|sku?}); capped, deduped, save-carried. + let wants = []; + const WANTS_CAP = 40; + // [R33 travel] true when THIS boot adopted a save from a DIFFERENT town — the ride cost a day. + let traveled = false; // [R32 scarcity] pull records — one string per bought dig slot, keyed by the CONSUMER // (interior_mode: `${shopId}|${binKey}@d#`; real-sourced crates omit the day tag — real // stock never rotates, so a real pull is gone for good). The save only stores and prunes them. @@ -77,6 +93,7 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul function payload() { const p = { schema: SAVE_SCHEMA, day, cash, town: townKey, collection, savedAt: Date.now() }; if (pulls.length) p.pulls = pulls; // absent when empty → alpha-era byte shape preserved + if (wants.length) p.wants = wants; // [R33] same convention return p; } function save() { @@ -90,6 +107,13 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul cash = Math.floor(state.cash); collection = state.collection.slice(); pulls = Array.isArray(state.pulls) ? state.pulls.slice() : []; + wants = Array.isArray(state.wants) ? state.wants.slice() : []; + // [R33 — TRAVEL COSTS A DAY, charter system #6] The adopted save came from ANOTHER town (townKey + // embeds plansrc/town/seed, so a new seed is a new town too): the ride ate a day. Same-town + // adoption (reload, export→import round trip) costs nothing — the save-determinism gate's + // byte-equality survives untouched. Day moves BEFORE prunePulls so yesterday's-town day-tagged + // pulls drop here, and the shell reads game.day/traveled at boot (onDay stays un-fired, §30.1). + if (state.town !== townKey) { day += 1; traveled = true; } prunePulls(); } @@ -235,6 +259,40 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul for (const k of pulls) if (k.startsWith(prefix)) out.add(k.slice(prefix.length)); return out; }, + + // ── the wantlist seams (R33, charter system #6) ──────────────────────────────────────────── + get wants() { return wants; }, // LIVE array — READ-ONLY by contract (like collection) + get traveled() { return traveled; }, // this boot's save came from another town (+1 day) + + // w: {artist?|title?|sku?} strings. Dedupe is case-insensitive over the fields present. + recordWant(w) { + const e = {}; + for (const k of ['artist', 'title', 'sku']) if (w && w[k]) e[k] = String(w[k]); + if (!Object.keys(e).length) return false; + if (game.findWant(e)) return false; // (named binding, not `this` — survives detached calls) + wants.push(e); + if (wants.length > WANTS_CAP) wants.splice(0, wants.length - WANTS_CAP); + notify(); + return true; + }, + + // The matcher both UIs use: a want covers an item when every field the WANT names matches the + // item's (case-insensitive) — sku exact, artist/title by value. Buying a wanted item clears it. + findWant(o) { + const low = (s) => String(s || '').toLowerCase(); + return wants.find((w) => + (!w.sku || w.sku === String(o.sku || '')) && + (!w.artist || low(w.artist) === low(o.artist)) && + (!w.title || low(w.title) === low(o.title)) ) || null; + }, + + removeWant(w) { // identity removal, findWant supplies the identity + const i = wants.indexOf(w); + if (i < 0) return false; + wants.splice(i, 1); + notify(); + return true; + }, }; // boot: adopt an existing save (fresh start on absence/rejection), then arm the unload save-point.