Lane F R33 (v7.0-beta wave 2): wants on the save · the keeper's rumor · TRAVEL COSTS A DAY

save.js: optional wants:[{artist?|title?|sku?}] on procity-save/1 (validated exact-and-loud,
≥1 string field per entry, cap 40, absent-when-empty — the same back-compat law as pulls).
recordWant (case-insensitive dedupe) / findWant (a want covers an item when every field the
WANT names matches) / removeWant (identity). TRAVEL in adopt(): the adopted save's town !==
this boot's townKey (town OR seed — a new seed is a new town) ⇒ day += 1, traveled = true,
prunePulls — load AND import both pay the fare; same-town adoption is FREE, so reloads and
export→import round trips keep the save-determinism gate's byte equality untouched. The 23
towns are a map you spend days crossing now, not a dropdown.
interior_mode.js: onWant/isWanted seams into dig.open; buying a matching item clears its want
('✓ off the wantlist'). THE RUMOR (charter #6 — the keeper finally gossips): seeded
xmur3('rumor:citySeed:shopId:day') → ~45% of shop entries mutter a hunted want + a real
cached town in the banner. Runtime-only stream, freshly keyed — A's rotation boundary holds;
no game / empty wantlist ⇒ the line never exists. Full persona lines stay D's for the v6
clip epoch; the banner is the keeper's voice until then.
This commit is contained in:
type-two 2026-07-20 19:01:40 +10:00
parent ce71908bb5
commit 37773bc81d
2 changed files with 88 additions and 1 deletions

View File

@ -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;
}

View File

@ -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<day>#<slot>`; 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.