Compare commits

...

4 Commits

Author SHA1 Message Date
type-two
776b10b599 Fable (integrator): ROUND 33 CLOSED — THE HUNT lands, all green; the beta's last system is in
qa.sh --strict --matrix on the final tree: 7 passed · 0 failed · 0 warn · 0 skipped, MATRIX 10
towns green. New STRICT smoke_wantlist_travel: ☆→★ through the real card (the save carried
{THE VERANDAHS — 'Servo at Midnight'}) · a keeper muttered the hunted artist within 2 shops
('saw one in Castlemaine just last week') · buying the wanted sleeve cleared the hunt · the
synthetic→melbourne ride cost EXACTLY one day with $179 and the find riding along · the
same-town reboot was free (the control discriminates) · the ride home charged again (day 3) ·
?game=0 built zero want DOM and muttered nothing · 0 console errors. Matrix note: town_matrix
shares one browser across towns so travel drifts the day as it walks — safe because
determinism double-boots the SAME town (free by the same-town rule); reasoned before landing.
Money shots: docs/shots/v7_beta/rumor_and_wantlist.png (the keeper's mutter over the toy
shop's 70s wallpaper + THE WANTLIST open in the crate) + want_card.png. README manual row.
v7.0-beta systems are now ALL BUILT — what remains of v7: Esc-in-person (John's hands), then
the v7.0 close (GodBay offline auctions, THE FIRST FIVE MINUTES, the tour, the tag).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:01:40 +10:00
type-two
7b26e45fa0 Lane B R33: THE WANTLIST in the crate panel · the arrival toast · the build-race fix the shot found
hud.js: THE WANTLIST section in the crate (non-empty only — no header over nothing): hunted
rows, ✕ stops hunting, refresh watches wants length so an open panel follows adds/clears.
THE FIX: R32's game surface BUILT only in street update() frames — a door entered fast after
boot had no crate/bar inside at all, and the interior tickGame() refreshed a UI that never
existed. Every gate idles on the street long enough to build, so green gates missed it; the
R33 money-shot script entered faster and exposed it. tickGame() now builds-or-refreshes in
ANY mode (shots are a detector, not decoration — filed in LANE_F_NOTES §33).
index.html: game.traveled at boot → '🚌 <town> — the ride cost a day. Day N' toast (save.js
already charged the fare and re-keyed the week before the shell read game.day).
2026-07-20 19:01:40 +10:00
type-two
37773bc81d 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.
2026-07-20 19:01:40 +10:00
type-two
ce71908bb5 Lane C R33 (v7.0-beta wave 2): ☆ WANT IT on the pull card
The wantlist grows from the card, not a search box the 90s didn't have: you pulled it, it's
dear, you'll hunt one cheaper. ☆ WANT IT → ★ ON THE WANTLIST (a stamp once wanted, not a
button). Construction-gated on opts.onWant/isWanted — ?game=0 / classic cards carry ZERO
wantlist DOM, the classic-pure pattern.
2026-07-20 19:01:40 +10:00
12 changed files with 367 additions and 15 deletions

View File

@ -4,6 +4,19 @@
--- ---
## Round 33 (v7.0-beta wave 2) — THE HUNT: wantlist · rumor · travel (Fable, solo, JING5)
☆ WANT on the pull card → `wants` on procity-save/1 (optional, capped, deduped); buying a
matching item clears the hunt; THE WANTLIST section in the crate panel. The keeper gossips:
seeded 45% banner mutter naming a hunted want + a real cached town. TRAVEL COSTS A DAY:
adopting a save from another townKey (town OR seed) charges +1 day in save.js adopt() — load
and import both pay, same-town stays free (byte-equality gates untouched); arrival toast names
the fare. New STRICT `smoke_wantlist_travel` (real-card want · rumor scan · auto-clear ·
+1-day with intact cash/collection · same-town control · return fare · ?game=0 zero-DOM).
Full record: LANE_F_NOTES §33.
---
## Round 32 (v7.0-beta wave 1) — THE BIBLE + scarcity + the §31.6 queue (Fable, solo, JING5) ## Round 32 (v7.0-beta wave 1) — THE BIBLE + scarcity + the §31.6 queue (Fable, solo, JING5)
Guide bands derived in `js/interiors/bible.js` (E's thresholds mirrored, no pack re-emit) → Guide bands derived in `js/interiors/bible.js` (E's thresholds mirrored, no pack re-emit) →

View File

@ -25,6 +25,7 @@ Click **START** to lock the mouse. Then:
| **E** (inside a shop) | open the crate at a record bin — then it's cursor-driven: click sleeves to riffle, click BUY | | **E** (inside a shop) | open the crate at a record bin — then it's cursor-driven: click sleeves to riffle, click BUY |
| **E** (at the counter) · **C** (anywhere) · **SLEEP** (bottom bar) | sell a find to the keeper (he pays under the **guide band**, never over) · open THE CRATE — your collection, in shops too now · sleep = save, the day advances, stock rotates | | **E** (at the counter) · **C** (anywhere) · **SLEEP** (bottom bar) | sell a find to the keeper (he pays under the **guide band**, never over) · open THE CRATE — your collection, in shops too now · sleep = save, the day advances, stock rotates |
| read the **guide** stamp on every price card | the 90s price-guide band ("guide $824"). An item **asking under its band is a gem** — buy it, sell it to a keeper, pocket the margin. Bought slots stay gone for the day (the bin remembers) | | read the **guide** stamp on every price card | the 90s price-guide band ("guide $824"). An item **asking under its band is a gem** — buy it, sell it to a keeper, pocket the margin. Bought slots stay gone for the day (the bin remembers) |
| **☆ WANT IT** on a pull card | start hunting it — keepers gossip ("saw one in Castlemaine"), and buying one clears the hunt. **Switching towns costs a day** — the 23 towns are a map now, not a dropdown |
| click a book spine / toy box | pull-and-buy card (real price stickers in godverse shops) | | click a book spine / toy box | pull-and-buy card (real price stickers in godverse shops) |
| walk out the door / **Esc** | leave the shop (Esc also frees the mouse anywhere) | | walk out the door / **Esc** | leave the shop (Esc also frees the mouse anywhere) |
| **M** | 2D town map (street only) | | **M** | 2D town map (street only) |

View File

@ -1813,3 +1813,35 @@ band, which is what charter law #2 always said.
`web/assets/models/` is GITIGNORED build output — a fresh checkout runs stock=real/book-toy/ `web/assets/models/` is GITIGNORED build output — a fresh checkout runs stock=real/book-toy/
no-pump gates RED with 404s (the R31-green tree lives on m3ultra). Fixed here by rsync from no-pump gates RED with 404s (the R31-green tree lives on m3ultra). Fixed here by rsync from
m3ultra. If a third machine joins the rotation, sync that dir (49 M) or re-run E's pipeline. m3ultra. If a third machine joins the rotation, sync that dir (49 M) or re-run E's pipeline.
## §33 — R33: v7.0-beta wave 2 — THE HUNT (wantlist · rumor · travel) (Fable, solo, JING5)
- **save.js:** optional `wants: [{artist?|title?|sku?}]` on procity-save/1 (validated
exact-and-loud when present; ≥1 string field per entry; cap 40; absent-when-empty keeps the
alpha byte shape). `recordWant` (case-insensitive dedupe) / `findWant` (a want covers an item
when every field the WANT names matches — sku exact, artist/title by value) / `removeWant`
(identity). **TRAVEL in `adopt()`:** adopted save's `town !== townKey``day += 1`,
`traveled = true`, prunePulls — load AND import both pay the fare; same-town adoption free
(save-determinism byte-equality untouched). `game.traveled` getter → the shell's arrival toast.
- **dig.js:** the pull card's ☆ WANT line — construction-gated on `opts.onWant` (game absent ⇒
zero want DOM, the classic-pure pattern). ☆ click → ★ ON THE WANTLIST (a stamp, not a button,
once wanted).
- **interior_mode.js:** onWant/isWanted seams into dig.open; buy of a matching item removes the
want (+ '✓ off the wantlist' toast). **THE RUMOR** in the enter() banner: seeded
`xmur3('rumor:<citySeed>:<shopId>:<day>')` → 45% roll → hunted want + a town from
RUMOR_TOWNS (the repo's real cached towns, current town excluded). Runtime-only stream —
A's boundary holds; no game / empty wants ⇒ the line never exists.
- **hud.js:** THE WANTLIST section in the crate panel (non-empty only): artist/title rows, ✕
stops hunting; refresh watches wants length so an open panel follows adds/clears.
- **Gate:** `smoke_wantlist_travel` STRICT (see ROUND33_INSTRUCTIONS for the green run) — real
card path, seeded rumor scan, auto-clear, +1-day travel with same-town negative control and
the return fare, `?game=0` zero-DOM control, 0 console errors.
- **Matrix note:** town_matrix shares one browser across towns, so travel drifts the day as it
walks the matrix — safe because determinism double-boots the SAME town (free by the
same-town rule) and no matrix gate reads absolute day. Reasoned before landing, not after.
- **The shot found what the gate didn't (harness lesson #5 candidate):** R32's crate-in-shops
surface BUILT only in street update() frames — a door entered fast after boot had no game
surface inside at all, and the interior tickGame() happily refreshed a UI that never existed.
Green gates missed it because every gate's boot idles on the street long enough to build.
Fixed: tickGame() now builds AND refreshes (any mode); found by taking the money shot, which
entered the shop faster than any gate does. Shots are a detector, not decoration.

View File

@ -0,0 +1,52 @@
# PROCITY — Round 33 instructions (from Fable, integrator)
Date: 2026-07-20 · **R32 closed all green** (7/0/0, matrix 10 towns — THE BIBLE + scarcity live).
Round 33 is **v7.0-beta wave 2 — THE HUNT**: the wantlist, the rumor, and travel that costs a
day ([V7_THE_GAME.md](../V7_THE_GAME.md) system #6, the last unbuilt beta system). Solo round
again (John's "keep going"), same treaty, lane ownership honoured in the commits.
## The design rulings this round encodes
1. **A want is something you SAW.** The wantlist grows from the pull card (☆ WANT IT — you
pulled it, it's dear, you'll hunt one cheaper) — not from an abstract search box the 90s
didn't have. Buying a matching item clears its want automatically (the hunt ends when
you're holding it). Wants ride the save (`wants: [{artist?|title?|sku?}]`, optional field,
same procity-save/1 back-compat law as `pulls`).
2. **The keeper finally gossips** (charter: "D's citizens finally gossip for a reason").
Seeded per (citySeed, shop, day): ~45% of shop entries mutter a hunted want + a real cached
town in the banner — flavor and a travel nudge, honest about being neither promise nor
stock lookup (town-wide packs mean any town CAN surface the record eventually). Runtime
stream, freshly keyed — no plan/golden moves. Full persona lines remain D's when the v6
clip work lands; the banner is the keeper's voice until then.
3. **Travel = the townKey changing hands.** Booting a save in a different town (different
plansrc/town/**seed** — a new seed is a new town) costs exactly one day, applied in
save.js `adopt()` so load and import both pay it, and pulls prune on the way. Same-town
reloads and export→import round trips stay free — the save-determinism gate's byte
equality survives untouched. The town selector IS the bus; the arrival toast names the
fare. The 23 towns are now a map you spend days moving across, not a dropdown.
## Gate (F)
`smoke_wantlist_travel` (STRICT): ☆→★ through the real card → the save carries the want ·
a keeper names the hunted artist within the open shops (seeded scan) · buying the wanted
sleeve auto-clears it · synthetic→melbourne costs exactly one day with cash/collection intact
· same-town reboot is FREE (the control) · the ride home costs a day again · `?game=0` builds
zero want DOM and mutters nothing.
## ROUND CLOSED (Fable) — ALL GREEN
`qa.sh --strict --matrix`: **7 passed · 0 failed · 0 warn · 0 skipped**, MATRIX 10 towns green
(the shared-context day drift travel introduces across matrix towns was reasoned through
before landing: determinism double-boots the SAME town, so no gate sees it). The new gate's
run: want carried {THE VERANDAHS — "Servo at Midnight"} · rumor after 2 shops ("🗣 THE
VERANDAHS? saw one in Castlemaine just last week") · buy cleared the want · travel day 1→2
with $179 and the find riding along · same-town free · return trip day 3 · game=0 clean ·
0 console errors. Full record: LANE_F_NOTES §33.
Also caught and fixed here: the R32 crate-in-shops surface only BUILT in street frames — a
door entered fast after boot had no game surface inside (the money-shot script entered faster
than any gate does and exposed it; tickGame() now builds-or-refreshes in any mode). Money
shots: `docs/shots/v7_beta/rumor_and_wantlist.png` + `want_card.png`.
**What remains of v7:** §31.6-5 (dig Esc in person — John's hands) · then the v7.0 close:
GodBay offline auctions (G's lane), THE FIRST FIVE MINUTES (onboarding), the tour, the tag.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

View File

@ -2136,6 +2136,123 @@ def smoke_bible(p):
b.close() b.close()
def smoke_wantlist_travel(p):
"""R33 gate — THE WANTLIST + THE RUMOR + TRAVEL COSTS A DAY (v7.0-beta wave 2). Legs:
(a) WANT on the pull card (real path) the save carries the want; (b) a keeper rumor
naming the hunted artist surfaces in some shop's banner (seeded per shop×day — scan);
(c) buying the wanted item auto-clears the want; (d) booting ANOTHER town on the same save
costs exactly one day (cash/collection intact), (e) re-booting the SAME town costs nothing
(the control discriminates), (f) the return trip costs a day again; (g) ?game=0 builds ZERO
want DOM and mutters no rumor (construction-gating holds)."""
head('GATE R33: wantlist · rumor · travel-costs-a-day (+ same-town and ?game=0 controls)')
b, pg, errs = new_page(p)
try:
boot(pg, '')
r0 = _enter_record_shop(pg)
if r0.get('err'): FAIL(f"wantlist: {r0['err']}"); return
# (a) pull the front sleeve, click ☆ WANT through the real card
_at_bin(pg)
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
pg.wait_for_timeout(400)
pg.evaluate("""() => { const c = window.PROCITY.renderer.domElement;
c.dispatchEvent(new PointerEvent('pointerdown', { clientX: 640, clientY: 360, bubbles: true }));
c.dispatchEvent(new PointerEvent('pointerup', { clientX: 640, clientY: 360, bubbles: true })); }""")
pg.wait_for_timeout(300)
rw = pg.evaluate("""() => {
const w = document.querySelector('.pcdg-panel .want');
if (!w) return { err: 'no WANT line on the card' };
const before = w.textContent; w.click();
const g = window.PROCITY.game, ex = JSON.parse(g.export());
return { before, after: w.textContent, wants: ex.wants || [], live: g.wants.length }; }""")
if rw.get('err'): FAIL(f"wantlist: {rw['err']}"); return
if 'WANT IT' in rw['before'] and 'ON THE WANTLIST' in rw['after'] and len(rw['wants']) == 1 and rw['live'] == 1:
OK(f"wantlist: ☆→★ through the real card — the save carries {rw['wants'][0]}")
else:
FAIL(f"wantlist: want did not take — {rw}")
artist = (rw['wants'][0].get('artist') or rw['wants'][0].get('title') or '').upper()
pg.evaluate("""() => { const p = document.querySelector('.pcdg-panel');
const x = p && p.querySelector('.x'); if (x) x.click();
const o = document.querySelector('.pcdg-out'); if (o) o.click(); }""")
pg.wait_for_timeout(200)
pg.evaluate("() => window.DBG.exitShop()")
pg.wait_for_timeout(300)
# (b) the rumor: seeded ~45% per (shop, day) — walk the town's open shops until a keeper mutters
rr = pg.evaluate("""() => {
const P = window.PROCITY, D = window.DBG;
const open = (P.plan.shops || []).filter(s => P.isOpen(s)).slice(0, 10);
const seen = [];
for (const s of open) {
D.enterShop(s.id);
const t = [...document.querySelectorAll('div')].map(e => e.textContent)
.find(t => t && t.includes('🚪') && t.includes('🗣'));
D.exitShop();
if (t) return { rumor: t.slice(t.indexOf('🗣')), tried: seen.length + 1 };
seen.push(s.id);
}
return { rumor: null, tried: seen.length }; }""")
if rr.get('rumor') and artist and artist in rr['rumor']:
OK(f"rumor: a keeper muttered after {rr['tried']} shop(s) — {rr['rumor'][:80]}")
else:
FAIL(f"rumor: no keeper named {artist!r} in {rr.get('tried')} open shops — {rr}")
# (c) buy the wanted sleeve → the want clears itself
r1 = _enter_record_shop(pg)
if r1.get('err'): FAIL(f"wantlist/buy: {r1['err']}"); return
_at_bin(pg)
rb = _dig_buy(pg)
rc = pg.evaluate("() => ({ wants: window.PROCITY.game.wants.length, n: window.PROCITY.game.collection.length })")
if not rb.get('err') and not rb.get('disabled') and rc['wants'] == 0 and rc['n'] >= 1:
OK('wantlist: bought the hunted sleeve — the want cleared itself, the find is in the crate')
else:
FAIL(f"wantlist/buy: {rb} · after {rc}")
base = pg.evaluate("() => ({ day: window.PROCITY.game.day, cash: window.PROCITY.game.cash, n: window.PROCITY.game.collection.length })")
# (d) travel: another town on the SAME save — the ride costs exactly one day
boot(pg, 'plansrc=osm&town=melbourne')
t1 = pg.evaluate("() => ({ day: window.PROCITY.game.day, cash: window.PROCITY.game.cash, n: window.PROCITY.game.collection.length, trav: window.PROCITY.game.traveled })")
if t1['day'] == base['day'] + 1 and t1['trav'] and t1['cash'] == base['cash'] and t1['n'] == base['n']:
OK(f"travel: synthetic → melbourne cost exactly one day (day {base['day']}{t1['day']}), "
f"cash ${t1['cash']} and {t1['n']} find(s) rode along")
else:
FAIL(f"travel: {base}{t1} (want day+1, traveled, cash/collection intact)")
# (e) same-town control: re-boot melbourne — no day moves, traveled stays false
boot(pg, 'plansrc=osm&town=melbourne')
t2 = pg.evaluate("() => ({ day: window.PROCITY.game.day, trav: window.PROCITY.game.traveled })")
if t2['day'] == t1['day'] and not t2['trav']:
OK(f"travel/control: re-booting the SAME town is free (day {t2['day']}, traveled false)")
else:
FAIL(f"travel/control: same-town reboot moved the day — {t2}")
# (f) the return trip costs a day again
boot(pg, '')
t3 = pg.evaluate("() => ({ day: window.PROCITY.game.day, trav: window.PROCITY.game.traveled })")
if t3['day'] == t1['day'] + 1 and t3['trav']:
OK(f"travel: the ride home cost a day too (day {t3['day']})")
else:
FAIL(f"travel: return trip wrong — {t3}")
# (g) ?game=0: zero want DOM on the card, zero rumor in any banner
boot(pg, 'game=0')
r2 = _enter_record_shop(pg)
if r2.get('err'): FAIL(f"wantlist/g0: {r2['err']}")
else:
_at_bin(pg)
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
pg.wait_for_timeout(350)
pg.evaluate("""() => { const c = window.PROCITY.renderer.domElement;
c.dispatchEvent(new PointerEvent('pointerdown', { clientX: 640, clientY: 360, bubbles: true }));
c.dispatchEvent(new PointerEvent('pointerup', { clientX: 640, clientY: 360, bubbles: true })); }""")
pg.wait_for_timeout(300)
g0 = pg.evaluate("""() => ({
want: !!document.querySelector('.pcdg-panel .want'),
panel: !!document.querySelector('.pcdg-panel'),
rumor: [...document.querySelectorAll('div')].some(e => e.textContent && e.textContent.includes('🗣')) })""")
if g0['panel'] and not g0['want'] and not g0['rumor']:
OK('wantlist/control: ?game=0 card has ZERO want DOM and no keeper mutters (construction-gated)')
else:
FAIL(f"wantlist/control: game=0 leaked want/rumor DOM — {g0}")
if errs: FAIL(f"wantlist: {len(errs)} console error(s); first: {errs[0][:140]}")
else: OK('wantlist: 0 console errors')
finally:
b.close()
def smoke_classic_purity(p): def smoke_classic_purity(p):
"""R30 gate (c) — ?classic=1 and ?game=0: ZERO Storage-prototype calls (instrumented before any """R30 gate (c) — ?classic=1 and ?game=0: ZERO Storage-prototype calls (instrumented before any
page script), zero game DOM (B's ids), game null. Default boot is the POSITIVE control (the page script), zero game DOM (B's ids), game null. Default boot is the POSITIVE control (the
@ -2290,6 +2407,7 @@ def main():
smoke_classic_purity(p) # R30 gate c — instrumented Storage zero + zero game DOM + positive control smoke_classic_purity(p) # R30 gate c — instrumented Storage zero + zero game DOM + positive control
smoke_rotation(p) # R30 gate d — vertex-data rotation determinism (the wave-3 binding) smoke_rotation(p) # R30 gate d — vertex-data rotation determinism (the wave-3 binding)
smoke_bible(p) # R32 — THE BIBLE (bands · gems · ledger) + per-day scarcity + ?game=0 control smoke_bible(p) # R32 — THE BIBLE (bands · gems · ledger) + per-day scarcity + ?game=0 control
smoke_wantlist_travel(p) # R33 — wantlist · keeper rumor · travel-costs-a-day (+ controls)
smoke_tier2(p) # R27 — the kill-the-server gate (charter law #1) smoke_tier2(p) # R27 — the kill-the-server gate (charter law #1)
smoke_real_crate(p) # R24 — THE REAL CRATE (per-shop atlas identity · soft fall · no-op off the path) smoke_real_crate(p) # R24 — THE REAL CRATE (per-shop atlas identity · soft fall · no-op off the path)
finally: finally:

View File

@ -281,6 +281,9 @@ const gigState = (GIGS_ON && plan.gigs && plan.gigs.length) ? createGigState({ p
// day-1 stock streams (§30.3). Skipped entirely when either layer is off. // day-1 stock streams (§30.3). Skipped entirely when either layer is off.
const weekNightOf = (day) => (Math.max(1, day) - 1) % 7; const weekNightOf = (day) => (Math.max(1, day) - 1) % 7;
if (game && gigState) gigState.setWeekNight(weekNightOf(game.day)); if (game && gigState) gigState.setWeekNight(weekNightOf(game.day));
// [R33 — TRAVEL COSTS A DAY] this boot adopted a save from another town: the ride ate a day (save.js
// adopt() did the increment + pull-pruning; weekNight above already read the incremented day). Tell John.
if (game && game.traveled) hud.showToast(`🚌 ${plan.name || 'a new town'} — the ride cost a day. Day ${game.day}`, 4);
// [Lane F R9] occupancyOf closure defers to `citizens` (declared just below); only invoked at enter() // [Lane F R9] occupancyOf closure defers to `citizens` (declared just below); only invoked at enter()
// time (door click), long after citizens is initialized — so the forward reference is safe. // time (door click), long after citizens is initialized — so the forward reference is safe.
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS, const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS,

View File

@ -116,6 +116,7 @@ export function createDig(THREE, renderer) {
let recs = [], cursor = 0, vel = 0, active = false, pulled = null, lastFloor = -1; let recs = [], cursor = 0, vel = 0, active = false, pulled = null, lastFloor = -1;
let crate = null, offers = [], onCloseCb = null, onBuyCb = null, getCashCb = null; let crate = null, offers = [], onCloseCb = null, onBuyCb = null, getCashCb = null;
let onWantCb = null, isWantedCb = null; // [R33] the wantlist seams — absent (?game=0/classic) ⇒ no WANT DOM at all
let emitters = null; // [R28] room.audio.emitters, per open — where the till sounds from. Optional. let emitters = null; // [R28] room.audio.emitters, per open — where the till sounds from. Optional.
// shared across opens (persistent): sleeves are all the same box; sides + crate timber never vary // shared across opens (persistent): sleeves are all the same box; sides + crate timber never vary
const sleeveGeo = trackGeo(new THREE.BoxGeometry(SLEEVE[0], SLEEVE[1], SLEEVE[2])); const sleeveGeo = trackGeo(new THREE.BoxGeometry(SLEEVE[0], SLEEVE[1], SLEEVE[2]));
@ -141,6 +142,9 @@ export function createDig(THREE, renderer) {
.pcdg-panel button{margin-top:13px;width:100%;padding:9px;border:1px solid #8a7f66;background:#e7dcc2;color:#241f18;font:700 13px "Courier New",monospace;letter-spacing:.06em;cursor:pointer} .pcdg-panel button{margin-top:13px;width:100%;padding:9px;border:1px solid #8a7f66;background:#e7dcc2;color:#241f18;font:700 13px "Courier New",monospace;letter-spacing:.06em;cursor:pointer}
.pcdg-panel button:hover:not(:disabled){background:#241f18;color:#f4e11a;border-color:#241f18} .pcdg-panel button:hover:not(:disabled){background:#241f18;color:#f4e11a;border-color:#241f18}
.pcdg-panel button:disabled{color:#9a917c;border-color:#c3b99f;cursor:default} .pcdg-panel button:disabled{color:#9a917c;border-color:#c3b99f;cursor:default}
.pcdg-panel .want{margin-top:7px;text-align:center;font:700 11px "Courier New",monospace;letter-spacing:.08em;color:#8a7f66;cursor:pointer;user-select:none}
.pcdg-panel .want:hover{color:#241f18}
.pcdg-panel .want.on{color:#b3121f;cursor:default}
.pcdg-panel .x{position:absolute;top:4px;left:8px;cursor:pointer;color:#a89e86;font-size:13px} .pcdg-panel .x{position:absolute;top:4px;left:8px;cursor:pointer;color:#a89e86;font-size:13px}
.pcdg-out{position:fixed;left:16px;top:16px;background:#1d1d2b;border:1px solid #666;color:#d8d8e0;padding:8px 14px;font:13px "Courier New",monospace;cursor:pointer;display:none;z-index:60} .pcdg-out{position:fixed;left:16px;top:16px;background:#1d1d2b;border:1px solid #666;color:#d8d8e0;padding:8px 14px;font:13px "Courier New",monospace;cursor:pointer;display:none;z-index:60}
.pcdg-out:hover{border-color:#3dff8b;color:#3dff8b} .pcdg-out:hover{border-color:#3dff8b;color:#3dff8b}
@ -268,14 +272,25 @@ export function createDig(THREE, renderer) {
// the sticker; whether it's under the guide is for the PLAYER to notice — the card never says gem. // the sticker; whether it's under the guide is for the PLAYER to notice — the card never says gem.
const guide = guideText('record', o.band); const guide = guideText('record', o.band);
if (guide) stamps.push(`<span class="tag">${esc(guide)}</span>`); if (guide) stamps.push(`<span class="tag">${esc(guide)}</span>`);
// [R33] the WANT line — only when the game layer wired the seams (construction-gated DOM: a
// ?game=0 / classic card carries no wantlist trace). Already-wanted reads as a stamp, not a button.
const wanted = isWantedCb ? !!isWantedCb(o) : false;
const wantLine = onWantCb
? `<div class="want${wanted ? ' on' : ''}">${wanted ? '★ ON THE WANTLIST' : '☆ WANT IT — hunt one cheaper'}</div>`
: '';
panel.innerHTML = `<div class="x">✕</div>` panel.innerHTML = `<div class="x">✕</div>`
+ `<div class="sticker">$${esc(o.price)}</div>` + `<div class="sticker">$${esc(o.price)}</div>`
+ `<h3>${esc(o.a)}</h3>` + `<h3>${esc(o.a)}</h3>`
+ (o.t ? `<div class="ttl">“${esc(o.t)}”</div>` : '') + (o.t ? `<div class="ttl">“${esc(o.t)}”</div>` : '')
+ (stamps.length ? `<div class="stamps">${stamps.join('')}</div>` : '') + (stamps.length ? `<div class="stamps">${stamps.join('')}</div>` : '')
+ `<button ${o.price > cash ? 'disabled' : ''}>${o.price > cash ? 'NOT ENOUGH CASH' : 'BUY IT'}</button>`; + `<button ${o.price > cash ? 'disabled' : ''}>${o.price > cash ? 'NOT ENOUGH CASH' : 'BUY IT'}</button>`
+ wantLine;
panel.style.display = 'block'; panel.style.display = 'block';
panel.querySelector('.x').onclick = unpull; panel.querySelector('.x').onclick = unpull;
const wantEl = panel.querySelector('.want');
if (wantEl && !wanted) wantEl.onclick = () => {
if (onWantCb(o) !== false) { wantEl.classList.add('on'); wantEl.textContent = '★ ON THE WANTLIST'; wantEl.onclick = null; }
};
const b = panel.querySelector('button'); const b = panel.querySelector('button');
if (b && !b.disabled) b.onclick = () => { if (b && !b.disabled) b.onclick = () => {
// STUB economy (round 6+): onBuy may veto; default is a toast + remove-from-bin. // STUB economy (round 6+): onBuy may veto; default is a toast + remove-from-bin.
@ -395,6 +410,7 @@ export function createDig(THREE, renderer) {
function open(opts = {}) { function open(opts = {}) {
active = true; active = true;
onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null; onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null;
onWantCb = opts.onWant || null; isWantedCb = opts.isWanted || null; // [R33] wantlist seams
emitters = opts.emitters || null; emitters = opts.emitters || null;
const count = opts.count || 16; const count = opts.count || 16;
const gone = opts.gone instanceof Set && opts.gone.size ? opts.gone : null; const gone = opts.gone instanceof Set && opts.gone.size ? opts.gone : null;

View File

@ -173,7 +173,7 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
wrap.append(panel, bar); document.body.appendChild(wrap); wrap.append(panel, bar); document.body.appendChild(wrap);
const elDay = bar.querySelector('#pc-day'), elCash = bar.querySelector('#pc-cash'), const elDay = bar.querySelector('#pc-day'), elCash = bar.querySelector('#pc-cash'),
btnCrate = bar.querySelector('#pc-crate-btn'), btnSleep = bar.querySelector('#pc-sleep-btn'); btnCrate = bar.querySelector('#pc-crate-btn'), btnSleep = bar.querySelector('#pc-sleep-btn');
let shownDay = null, shownCash = null, shownCount = null, open = false; let shownDay = null, shownCash = null, shownCount = null, shownWants = null, open = false;
function rebuildList() { function rebuildList() {
const col = game.collection || []; const col = game.collection || [];
@ -184,7 +184,7 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
panel.appendChild(h); panel.appendChild(h);
if (!col.length) { if (!col.length) {
const d = document.createElement('div'); d.className = 'pc-crate-empty'; const d = document.createElement('div'); d.className = 'pc-crate-empty';
d.textContent = 'Empty — go dig.'; panel.appendChild(d); return; d.textContent = 'Empty — go dig.'; panel.appendChild(d);
} }
for (let i = col.length - 1; i >= 0; i--) { // newest find first for (let i = col.length - 1; i >= 0; i--) { // newest find first
const e = col[i]; const e = col[i];
@ -202,6 +202,29 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
main.append(t, a, meta); main.append(t, a, meta);
row.append(th, main); panel.appendChild(row); row.append(th, main); panel.appendChild(row);
} }
// [R33] THE WANTLIST — what you're hunting (☆ on a pull card adds; ✕ here removes; buying one
// clears it automatically). Section exists only when non-empty — no stray header over nothing.
const wants = game.wants || [];
if (wants.length) {
const wh = document.createElement('h3');
wh.style.marginTop = '10px';
wh.textContent = `THE WANTLIST — ${wants.length} hunted`;
panel.appendChild(wh);
for (const w of wants) {
const row = document.createElement('div'); row.className = 'pc-crate-row';
const main = document.createElement('div'); main.className = 'pc-crate-main';
const t = document.createElement('div'); t.className = 'pc-crate-title';
t.textContent = w.artist || w.title || w.sku;
const a = document.createElement('div'); a.className = 'pc-crate-artist';
a.textContent = w.artist && w.title ? `${w.title}` : '';
main.append(t, a);
const x = document.createElement('div');
x.textContent = '✕'; x.title = 'stop hunting this';
x.style.cssText = 'cursor:pointer;opacity:.6;padding:0 4px;flex:none';
x.onclick = () => { game.removeWant(w); rebuildList(); };
row.append(main, x); panel.appendChild(row);
}
}
} }
function setOpen(v) { function setOpen(v) {
open = v; panel.style.display = v ? 'block' : 'none'; open = v; panel.style.display = v ? 'block' : 'none';
@ -222,6 +245,8 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
shownCount = col.length; btnCrate.textContent = `CRATE ${shownCount}`; shownCount = col.length; btnCrate.textContent = `CRATE ${shownCount}`;
if (open) rebuildList(); if (open) rebuildList();
} }
const nw = (game.wants || []).length; // [R33] want add/clear refreshes an open panel too
if (nw !== shownWants) { shownWants = nw; if (open) rebuildList(); }
// [R32] the surface yields to the map and to the dig/sell cards (they own the screen), and shows // [R32] the surface yields to the map and to the dig/sell cards (they own the screen), and shows
// everywhere else — street AND interior. Cheap: a style write only when the answer changes. // everywhere else — street AND interior. Cheap: a style write only when the answer changes.
const P = window.PROCITY, im = P && P.interiorMode; const P = window.PROCITY, im = P && P.interiorMode;
@ -281,14 +306,11 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
frame++; frame++;
if (frame % 6 === 0) raycastDoor(); if (frame % 6 === 0) raycastDoor();
// v7 game surface — lazy construction (the shell builds the game after the HUD; null under // v7 game surface — lazy construction + refresh both live in tickGame() (R33 fix: the build used
// ?classic=1 / ?game=0 ⇒ this never fires), then cheap getter polls offset from the raycast frames. // to happen ONLY here in street frames, so a door entered fast after boot had no crate/bar inside
if (!gameUI) { // at all — the interior branch could refresh a surface that never got built). Throttled here; the
if (frame % 30 === 0) { // interior/map branches call tickGame() per frame (cheap: change-guarded property reads).
const g = window.PROCITY && window.PROCITY.game; if (frame % 6 === 3) tickGame();
if (g) gameUI = buildGameUI(g);
}
} else if (frame % 6 === 3) gameUI.refresh();
// tooltip — shows CLOSED (with opening hour) when the shop is shut at the current hour (§3.5) // tooltip — shows CLOSED (with opening hour) when the shop is shut at the current hour (§3.5)
if (hovered) { if (hovered) {
@ -333,9 +355,17 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
root.remove(); root.remove();
} }
// [R32] the game surface ticks in EVERY mode (the street path reaches it via update(); interior/map // [R32/R33] the game surface ticks in EVERY mode (the street path reaches it via update(); interior/
// call this directly) — day/cash/crate stay live while you shop, and the card-yield check runs there. // map call this directly) — and BUILDS here too, so the surface exists no matter which mode first
function tickGame() { if (gameUI) gameUI.refresh(); } // sees a truthy game. day/cash/crate stay live while you shop; the card-yield check runs here.
function tickGame() {
if (!gameUI) {
const g = window.PROCITY && window.PROCITY.game;
if (g) gameUI = buildGameUI(g);
return;
}
gameUI.refresh();
}
return { update, tickToast, showToast, setVisible, tickGame, dispose, getHovered: () => hovered, return { update, tickToast, showToast, setVisible, tickGame, dispose, getHovered: () => hovered,
getFps: () => fpsShown }; getFps: () => fpsShown };

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 { 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 { 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 { 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 { 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) 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. // 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' 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 // [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 // 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 // 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 // [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. // ledger — the slot is gone for the rest of the day even if the item is later sold on.
getCash: wallet ? () => wallet.cash() : undefined, 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) => { onBuy: wallet ? (o) => {
const ok = wallet.buy(o); const ok = wallet.buy(o);
if (ok && game) { if (ok && game) {
@ -200,6 +211,9 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
band: (o && o.band) || undefined, band: (o && o.band) || undefined,
}); });
game.recordPull(pullPrefix + (sku != null ? 'sku:' + sku : String((o && o.i) ?? 0))); 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; return ok;
} : undefined, } : 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 // [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. // (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)); 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` banner.textContent = `🚪 ${name || shop.name || 'shop'} — walk out the door or press Esc to leave`
+ (gigOn ? ` · 🎸 ${String(tonight.bandName).toUpperCase()} — LIVE` : '') + (gigOn ? ` · 🎸 ${String(tonight.bandName).toUpperCase()} — LIVE` : '')
+ (hasBins ? ' · E: riffle a record bin' : '') + (hasBins ? ' · E: riffle a record bin' : '')
+ (canSell ? ' · E at the counter: sell' : ''); + (canSell ? ' · E at the counter: sell' : '')
+ rumor;
banner.style.display = 'block'; banner.style.display = 'block';
return current; return current;
} }

View File

@ -50,6 +50,17 @@ export function validateSave(obj) {
for (let i = 0; i < obj.pulls.length; i++) 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` }; 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 }; return { ok: true, state: obj };
} }
@ -66,6 +77,11 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
let day = 1; let day = 1;
let cash = Math.max(0, Math.floor(+startCash || 0)); let cash = Math.max(0, Math.floor(+startCash || 0));
let collection = []; 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 // [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 // (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. // 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() { function payload() {
const p = { schema: SAVE_SCHEMA, day, cash, town: townKey, collection, savedAt: Date.now() }; 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 (pulls.length) p.pulls = pulls; // absent when empty → alpha-era byte shape preserved
if (wants.length) p.wants = wants; // [R33] same convention
return p; return p;
} }
function save() { function save() {
@ -90,6 +107,13 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
cash = Math.floor(state.cash); cash = Math.floor(state.cash);
collection = state.collection.slice(); collection = state.collection.slice();
pulls = Array.isArray(state.pulls) ? state.pulls.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(); 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)); for (const k of pulls) if (k.startsWith(prefix)) out.add(k.slice(prefix.length));
return out; 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. // boot: adopt an existing save (fresh start on absence/rejection), then arm the unload save-point.