Compare commits
4 Commits
9aae167d64
...
776b10b599
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
776b10b599 | ||
|
|
7b26e45fa0 | ||
|
|
37773bc81d | ||
|
|
ce71908bb5 |
@ -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)
|
||||
|
||||
Guide bands derived in `js/interiors/bible.js` (E's thresholds mirrored, no pack re-emit) →
|
||||
|
||||
@ -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** (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 $8–24"). 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) |
|
||||
| walk out the door / **Esc** | leave the shop (Esc also frees the mouse anywhere) |
|
||||
| **M** | 2D town map (street only) |
|
||||
|
||||
@ -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/
|
||||
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.
|
||||
|
||||
## §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.
|
||||
|
||||
52
docs/LANES/ROUND33_INSTRUCTIONS.md
Normal file
52
docs/LANES/ROUND33_INSTRUCTIONS.md
Normal 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.
|
||||
BIN
docs/shots/v7_beta/rumor_and_wantlist.png
Normal file
BIN
docs/shots/v7_beta/rumor_and_wantlist.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/shots/v7_beta/want_card.png
Normal file
BIN
docs/shots/v7_beta/want_card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
@ -2136,6 +2136,123 @@ def smoke_bible(p):
|
||||
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):
|
||||
"""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
|
||||
@ -2290,6 +2407,7 @@ def main():
|
||||
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_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_real_crate(p) # R24 — THE REAL CRATE (per-shop atlas identity · soft fall · no-op off the path)
|
||||
finally:
|
||||
|
||||
@ -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.
|
||||
const weekNightOf = (day) => (Math.max(1, day) - 1) % 7;
|
||||
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()
|
||||
// time (door click), long after citizens is initialized — so the forward reference is safe.
|
||||
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS,
|
||||
|
||||
@ -116,6 +116,7 @@ export function createDig(THREE, renderer) {
|
||||
|
||||
let recs = [], cursor = 0, vel = 0, active = false, pulled = null, lastFloor = -1;
|
||||
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.
|
||||
// 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]));
|
||||
@ -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:hover:not(:disabled){background:#241f18;color:#f4e11a;border-color:#241f18}
|
||||
.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-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}
|
||||
@ -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.
|
||||
const guide = guideText('record', o.band);
|
||||
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>`
|
||||
+ `<div class="sticker">$${esc(o.price)}</div>`
|
||||
+ `<h3>${esc(o.a)}</h3>`
|
||||
+ (o.t ? `<div class="ttl">“${esc(o.t)}”</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.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');
|
||||
if (b && !b.disabled) b.onclick = () => {
|
||||
// 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 = {}) {
|
||||
active = true;
|
||||
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;
|
||||
const count = opts.count || 16;
|
||||
const gone = opts.gone instanceof Set && opts.gone.size ? opts.gone : null;
|
||||
|
||||
@ -173,7 +173,7 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
||||
wrap.append(panel, bar); document.body.appendChild(wrap);
|
||||
const elDay = bar.querySelector('#pc-day'), elCash = bar.querySelector('#pc-cash'),
|
||||
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() {
|
||||
const col = game.collection || [];
|
||||
@ -184,7 +184,7 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
||||
panel.appendChild(h);
|
||||
if (!col.length) {
|
||||
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
|
||||
const e = col[i];
|
||||
@ -202,6 +202,29 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
||||
main.append(t, a, meta);
|
||||
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) {
|
||||
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}`;
|
||||
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
|
||||
// everywhere else — street AND interior. Cheap: a style write only when the answer changes.
|
||||
const P = window.PROCITY, im = P && P.interiorMode;
|
||||
@ -281,14 +306,11 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
||||
frame++;
|
||||
if (frame % 6 === 0) raycastDoor();
|
||||
|
||||
// v7 game surface — lazy construction (the shell builds the game after the HUD; null under
|
||||
// ?classic=1 / ?game=0 ⇒ this never fires), then cheap getter polls offset from the raycast frames.
|
||||
if (!gameUI) {
|
||||
if (frame % 30 === 0) {
|
||||
const g = window.PROCITY && window.PROCITY.game;
|
||||
if (g) gameUI = buildGameUI(g);
|
||||
}
|
||||
} else if (frame % 6 === 3) gameUI.refresh();
|
||||
// v7 game surface — lazy construction + refresh both live in tickGame() (R33 fix: the build used
|
||||
// to happen ONLY here in street frames, so a door entered fast after boot had no crate/bar inside
|
||||
// at all — the interior branch could refresh a surface that never got built). Throttled here; the
|
||||
// interior/map branches call tickGame() per frame (cheap: change-guarded property reads).
|
||||
if (frame % 6 === 3) tickGame();
|
||||
|
||||
// tooltip — shows CLOSED (with opening hour) when the shop is shut at the current hour (§3.5)
|
||||
if (hovered) {
|
||||
@ -333,9 +355,17 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
||||
root.remove();
|
||||
}
|
||||
|
||||
// [R32] the game surface ticks in EVERY mode (the street path reaches it via update(); interior/map
|
||||
// call this directly) — day/cash/crate stay live while you shop, and the card-yield check runs there.
|
||||
function tickGame() { if (gameUI) gameUI.refresh(); }
|
||||
// [R32/R33] the game surface ticks in EVERY mode (the street path reaches it via update(); interior/
|
||||
// map call this directly) — and BUILDS here too, so the surface exists no matter which mode first
|
||||
// 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,
|
||||
getFps: () => fpsShown };
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user