Compare commits
4 Commits
046668e5af
...
9aae167d64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aae167d64 | ||
|
|
bb30767b62 | ||
|
|
75e5e06288 | ||
|
|
f9517f14ab |
@ -4,6 +4,23 @@
|
||||
|
||||
---
|
||||
|
||||
## 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) →
|
||||
the pull/sell cards carry the **guide stamp**; parody bins roll seeded bands + 10% collector/
|
||||
grail **gems** (asking under the keeper's bandLow offer — margin by construction); §9.1 sell
|
||||
basis swapped to bandLow exactly as pre-ratified (alpha entries keep the pricePaid basis).
|
||||
**Scarcity:** optional `pulls` on procity-save/1 — a bought dig slot stays gone all day
|
||||
(pick-then-omit, zero collateral), pruned on sleep; real-sourced pulls never expire. §31.6
|
||||
queue: cluster-pose spawn (main-street doors, classic keeps the frozen pose) · THE CRATE +
|
||||
game bar live in interiors (C anywhere, yields to the cards) · DAWN dome swaps to high-cirrus
|
||||
(weather override wins). **Gates:** new STRICT `smoke_bible` (band law over every bin ·
|
||||
ledger closes to the cent on the GEM's bin, Σask $333 → Σoffer $135, gems +$21 · emptied bin
|
||||
0 across reload · `?game=0` control re-opens 16/16) + sell_routing amended to the exact
|
||||
formula (the fence moved from the receipt to the band). Full record: LANE_F_NOTES §32.
|
||||
|
||||
---
|
||||
|
||||
## Round 31 (v7.0-alpha CLOSE) — THE DIG FLIP + the tag
|
||||
|
||||
**Fable's wave-0 playtest found the one alpha-blocker: the dig was default-OFF on a plain boot.**
|
||||
|
||||
@ -23,7 +23,8 @@ Click **START** to lock the mouse. Then:
|
||||
| **W A S D** + mouse | walk / look (first person) |
|
||||
| walk into a shop door | enter the interior (open shops only — check the hours tooltip) |
|
||||
| **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** (street) · **SLEEP** (top bar) | sell a find to the keeper (always under what you paid) · open THE CRATE — your collection · 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 $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) |
|
||||
| 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) |
|
||||
|
||||
@ -1754,3 +1754,62 @@ Fable's preview holds :8130, untouched):
|
||||
5. Automation-only, unverified in person: **dig Esc-close under synthetic events**.
|
||||
6. *(carried from §30.6)* no per-day `gone` tracking on parody/mint dig stock — scarcity-per-day
|
||||
needs the save to carry pull records; rule it with the guide bands.
|
||||
|
||||
## §32 — R32: v7.0-beta wave 1 — THE BIBLE + scarcity + the §31.6 queue (Fable, solo round, JING5)
|
||||
|
||||
**The round in one line:** the guide bands land (derived, not baked), gems exist (parody bins only,
|
||||
by construction), a bought slot stays gone for the day (pulls ride the save), and §31.6 items 1–3
|
||||
are fixed. All gates green; the no-pump law survives the §9.1 basis swap structurally.
|
||||
|
||||
### §32.1 What shipped, by seam
|
||||
|
||||
- **`js/interiors/bible.js`** (C) — the band table mirroring E's pipeline thresholds
|
||||
(record 8/25/60 · book 5/15/40 · toy 6/20/50), `bandRange`/`guideText`/`sellBasis`. Derivation
|
||||
ruling: NO pack re-emit (charter risk #3 never opens); if E's thresholds move, bible.js moves in
|
||||
the same commit.
|
||||
- **dig.js** (C) — parody sleeves roll seeded bands (25/45/22/8%) + a 10% gem on collector/grail
|
||||
(asking strictly under the keeper's own bandLow offer: collector $2–9 vs $12 · grail $5–20 vs
|
||||
$30); fixed 9-draw stream shape per item; `rare` now ≡ grail (matches real packs). Real offers
|
||||
carry `band` off `price_band`. `open({gone})` omits pulled slots AFTER the draw (the R27
|
||||
pick-then-omit seam, zero collateral) — real via `'sku:<id>'`, parody via original offer index
|
||||
(`o.i` survives filtering, identity preserved). The pull card + sell card gain the **guide
|
||||
stamp** (`guide $8–24` / `$60+`) — present-fields-only, and the card NEVER says "gem": spotting
|
||||
asking-under-guide is the player's skill.
|
||||
- **sell.js** (C) — §9.1 basis swap executed exactly as pre-ratified: `sellOffer(sellBasis(entry))`
|
||||
= `min(bandLow−1, max(1, floor(bandLow·0.5)))` when banded, pricePaid basis for alpha-era
|
||||
entries (they sell exactly as before — no migration needed).
|
||||
- **save.js** (F) — optional `pulls: string[]` on procity-save/1 (validated loudly when present;
|
||||
absent on alpha saves — old savers drop it silently, never reject). `recordPull`/`pullsFor`
|
||||
seams; pruned on sleep/import (day-tagged keys from other days are unreachable by construction;
|
||||
untagged = real-sourced, kept — sold means gone). Cap 600 FIFO. Collection entries gain
|
||||
optional `band` (recordFind stamps dig + shelf buys).
|
||||
- **interior_mode.js** (F) — pull keys `${shopId}|${binKey}${pullTag}#${slot}`; pullTag =
|
||||
`@d<game.day>` for rotating crates (INCLUDING day 1 — the seed stays untagged per the day-1
|
||||
convention, the ledger doesn't), `''` for real-sourced. The SEED input is untouched (A's
|
||||
rotation boundary holds). `digOffers` honest surface (index/ask/band/sku) for the gate.
|
||||
- **hud.js + index.html** (B/F) — §31.6-2: `#pc-game` rides document.body (position:fixed, z 30),
|
||||
ticks in every mode (`hud.tickGame()` from the interior/map branches), yields to the dig/sell
|
||||
cards and the map; C toggles the crate anywhere. §31.6-1: cluster-pose spawn — main-street
|
||||
shop doors (block kind, both generators), stand at the door nearest the cluster heart, face the
|
||||
longer run of shopfronts; pure plan math, zero draws; `?classic=1` keeps the frozen pose.
|
||||
§31.6-3: DAWN swaps the dome to `high-cirrus` (lighting `dawnSky` opt, null under classic);
|
||||
a weather `setSky` is now an explicit override that always wins.
|
||||
|
||||
### §32.2 THE GATE (`smoke_bible`, STRICT) + the R30 amendment
|
||||
|
||||
Scans EVERY bin of the record shop off `digOffers`: band law on all offers (in-band unless a
|
||||
collector/grail gem), then runs the buy-EVERYTHING ledger on **the gem's bin** (leg is
|
||||
non-vacuous by construction — 80 offers/5 bins, bin1 holds 2 gems on the pinned seed, grail
|
||||
asking $19 vs keeper $30). Ledger closes exactly: Σask $333 → Σoffer $135 (gems alone +$21,
|
||||
every non-gem strictly negative). Scarcity: the emptied bin re-opens at 0, STAYS 0 across a full
|
||||
reload (16 pulls in the save); negative control: `?game=0` re-opens it at 16 — the detector
|
||||
discriminates. 0 console errors. **R30 amendment (sell_routing):** `credited < paid` was the
|
||||
alpha inequality; the gate now asserts the exact formula (`credited == min(basis−1, basis//2)`,
|
||||
basis = bandLow-or-pricePaid, `credited < basis`) — the fence moved from the receipt to the
|
||||
band, which is what charter law #2 always said.
|
||||
|
||||
### §32.3 Environment note (JING5)
|
||||
|
||||
`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.
|
||||
|
||||
61
docs/LANES/ROUND32_INSTRUCTIONS.md
Normal file
61
docs/LANES/ROUND32_INSTRUCTIONS.md
Normal file
@ -0,0 +1,61 @@
|
||||
# PROCITY — Round 32 instructions (from Fable, integrator)
|
||||
|
||||
Date: 2026-07-20 · **R31 closed** — v7.0-alpha tagged and pushed; the loop plays. Round 32 opens
|
||||
**v7.0-beta — THE THRILL, wave 1**: the guide bands land ([V7_THE_GAME.md](../V7_THE_GAME.md)
|
||||
stage 2), scarcity rides the save, and the alpha playtest's friction queue (LANE_F_NOTES §31.6)
|
||||
gets its cosmetic items. **Solo round:** John asked for direct progress; Fable executes all lanes
|
||||
in one session on JING5, same treaty, same gates — lane ownership is honoured in the commit
|
||||
messages, not the parallelism.
|
||||
|
||||
## The design rulings this round encodes (from the ratified charter + §31.6 queue)
|
||||
|
||||
1. **THE BIBLE derives, it is not baked.** E's band thresholds (build_stock_pack.py:88-94 —
|
||||
record 8/25/60 · book 5/15/40 · toy 6/20/50) become a tiny JS mirror
|
||||
(`js/interiors/bible.js`). No pack re-emit, so risk #3 (re-baked atlases orphaning owned
|
||||
skus) never opens. The guide shows a BAND ("guide $25–59"), never a verdict — spotting the
|
||||
gem stays the player's skill.
|
||||
2. **Gems live in the parody bins.** Real/mint packs derive `price_band` FROM `price`, so real
|
||||
asking is always inside its band → with the §9.1 basis swap (sellOffer basis = bandLow),
|
||||
every real round trip stays strictly negative and **the R30 no-pump gate holds untouched,
|
||||
by construction**. Procedural stock gains seeded bands + a seeded under-band gem roll —
|
||||
the only place asking can sit under the guide, which is exactly the op-shop fantasy.
|
||||
3. **Scarcity is what makes the gem loop safe** (§31.6 item 6, ruled WITH the bands as filed):
|
||||
a bought dig slot enters `pulls` in the save (optional field, procity-save/1 — old saves
|
||||
stay valid, old loaders drop the field without rejecting) and that bin omits it after the
|
||||
draw (the proven `pack.gone` pick-then-omit seam — zero collateral). A gem is profit ONCE;
|
||||
tomorrow's rotation re-keys the bin. Growth is bounded by finds, not arbitrage (charter
|
||||
law #2's real meaning).
|
||||
4. **§31.6 items 1–3:** spawn becomes a computed cluster-pose facing the shop strip (classic
|
||||
keeps the frozen v2 pose); THE CRATE surfaces inside interiors (C key — the §31.6
|
||||
"crate at the counter" want, hidden while dig/sell cards own the screen); DAWN gets a
|
||||
cool sky dome swap (classic + weather-override fenced).
|
||||
|
||||
## Gate additions (F)
|
||||
|
||||
`smoke_bible` in flags_check.py: guide stamp on the pull card (real + parody) · offer < bandLow
|
||||
structurally · a seeded parody gem exists and its round trip is positive ONCE (then the slot is
|
||||
gone — re-open the bin and it stays gone; reload and it stays gone) · negative control:
|
||||
`?game=0` re-opens the same bin with the slot PRESENT (the detector discriminates — scarcity is
|
||||
the save's doing, not the dig's). Classic purity untouched (no new module constructs under
|
||||
`?classic=1` / `?dig=0`).
|
||||
|
||||
## ROUND CLOSED (Fable) — ALL GREEN
|
||||
|
||||
`qa.sh --strict --matrix`: **7 passed · 0 failed · 0 warn · 0 skipped**, MATRIX 10 towns green.
|
||||
`smoke_bible`: 80 parody offers across 5 bins all band-lawful; bin1 holds 2 seeded gems (grail
|
||||
asking $19 vs keeper $30); the buy-everything ledger closes to the cent (Σask $333 → Σoffer
|
||||
$135, gems alone +$21, every non-gem strictly negative); the emptied bin stays empty across a
|
||||
full reload (16 pulls in the save) and `?game=0` re-opens it 16/16 (the control discriminates);
|
||||
0 console errors. sell_routing now asserts the exact §9.1 formula and passed on a BANDED item
|
||||
(standard, basis $8 → +$4). Money shots: `docs/shots/v7_beta/guide_card.png` (the guide stamp —
|
||||
"1974 COUNTRY guide $8–24" beside the $24 sticker) + `crate_at_the_counter.png` (THE CRATE open
|
||||
inside a shop, game bar riding bottom-right). Environment note: JING5's gitignored
|
||||
`web/assets/models/` was absent (fresh-checkout trap, now §32.3 in LANE_F_NOTES) — synced from
|
||||
m3ultra, which also un-warned the manifest validator's 15 cross-pack checks. Full record:
|
||||
LANE_F_NOTES §32. Shipped: bible.js + guide stamps + parody bands/gems (C) · pulls on the save
|
||||
+ gone-wiring + digOffers surface (F) · crate-in-interiors + cluster-pose spawn + dawn sky (B)
|
||||
· smoke_bible + the routing amendment (F) · README/docs.
|
||||
|
||||
**The beta's remaining charter** (next rounds): the WANTLIST + seeded rumors (D's keepers
|
||||
finally gossip) · travel costs a day (23 towns become the map) · §31.6-5 (dig Esc under real
|
||||
hands) · then v7.0 = GodBay offline auctions (G) + onboarding + the tour.
|
||||
BIN
docs/shots/v7_beta/crate_at_the_counter.png
Normal file
BIN
docs/shots/v7_beta/crate_at_the_counter.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 789 KiB |
BIN
docs/shots/v7_beta/guide_card.png
Normal file
BIN
docs/shots/v7_beta/guide_card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 154 KiB |
@ -1633,16 +1633,17 @@ def _game_state(pg):
|
||||
exNorm: JSON.stringify(ex), day: g.day, cash: g.cash, n: g.collection.length }; }""")
|
||||
|
||||
|
||||
def _at_bin(pg):
|
||||
"""Stand at the first bin of the open room, aimed at it (smoke_buy's proven setup)."""
|
||||
return pg.evaluate("""() => {
|
||||
def _at_bin(pg, idx=0):
|
||||
"""Stand at the idx-th bin of the open room, aimed at it (smoke_buy's proven setup)."""
|
||||
return pg.evaluate("""(idx) => {
|
||||
const P = window.PROCITY;
|
||||
const bins = []; P.interiorMode.current.group.traverse(o => { if (o.userData && o.userData.kind === 'bin') bins.push(o); });
|
||||
if (!bins.length) return { err: 'no bins' };
|
||||
const bp = new P.THREE.Vector3(); bins[0].getWorldPosition(bp);
|
||||
if (idx >= bins.length) return { err: 'bin index out of range', bins: bins.length };
|
||||
const bp = new P.THREE.Vector3(); bins[idx].getWorldPosition(bp);
|
||||
P.camera.position.set(bp.x, 1.6, bp.z + 1.4); P.camera.lookAt(bp.x, bp.y + 0.4, bp.z); P.camera.updateMatrixWorld();
|
||||
return { ok: true };
|
||||
}""")
|
||||
return { ok: true, bins: bins.length };
|
||||
}""", idx)
|
||||
|
||||
|
||||
def _dig_buy(pg):
|
||||
@ -1757,7 +1758,7 @@ def smoke_sell_routing(p):
|
||||
FAIL(f"routing: dig buy wallet/collection delta wrong — {rb}"); return
|
||||
ent = pg.evaluate("""() => { const g = window.PROCITY.game;
|
||||
const e = g.collection[g.collection.length - 1];
|
||||
return { type: e.type, pricePaid: e.pricePaid, hasId: !!(e.sku || e.slotId) }; }""")
|
||||
return { type: e.type, pricePaid: e.pricePaid, hasId: !!(e.sku || e.slotId), band: e.band || null }; }""")
|
||||
if ent['type'] == 'record' and ent['hasId'] and ent['pricePaid'] == paid:
|
||||
OK(f"routing: entry shape honest — type=record, sku|slotId set, pricePaid == debit (${paid})")
|
||||
else:
|
||||
@ -1778,10 +1779,21 @@ def smoke_sell_routing(p):
|
||||
hudCash: cashEl ? cashEl.textContent : null,
|
||||
hudCrate: crateBtn ? crateBtn.textContent : null }; }""")
|
||||
credited = after['cash'] - cash_before
|
||||
if rs.get('sold') == 1 and after['n'] == n_before - 1 and 0 < credited < paid:
|
||||
OK(f"routing: SELL — +${credited} credited (< ${paid} paid, no-pump by construction), collection {n_before}→{after['n']}")
|
||||
# [R32 amendment] the no-pump law's beta form: the offer is EXACTLY min(basis−1, max(1, basis//2))
|
||||
# where basis = bandLow when the entry carries a band (the §9.1 swap), pricePaid otherwise. The
|
||||
# old `credited < paid` held for every alpha sale but a banded GEM legitimately credits above
|
||||
# what was paid — the fence moved from the receipt to the guide band, so the gate measures the
|
||||
# formula itself, not the alpha inequality.
|
||||
R32_LOW = {'bargain': 2, 'standard': 8, 'collector': 25, 'grail': 60}
|
||||
basis = R32_LOW.get(ent.get('band'), paid)
|
||||
want = min(basis - 1, max(1, basis // 2)) if basis >= 1 else 0
|
||||
if rs.get('sold') == 1 and after['n'] == n_before - 1 and credited == want and credited < basis:
|
||||
OK(f"routing: SELL — +${credited} credited == min(basis−1, basis/2) with basis ${basis} "
|
||||
f"({'band ' + ent['band'] if ent.get('band') else 'pricePaid'}; < basis, no-pump by construction), "
|
||||
f"collection {n_before}→{after['n']}")
|
||||
else:
|
||||
FAIL(f"routing: sale delta wrong — sold={rs.get('sold')}, credited {credited} vs paid {paid}, n {n_before}→{after['n']}")
|
||||
FAIL(f"routing: sale delta wrong — sold={rs.get('sold')}, credited {credited} want {want} "
|
||||
f"(basis {basis}), n {n_before}→{after['n']}")
|
||||
if after['hudCash'] is not None:
|
||||
if after['hudCash'] == str(after['cash']) and after['hudCrate'] == f"CRATE {after['n']}":
|
||||
OK(f"routing: B's HUD follows — $" + after['hudCash'] + f" · {after['hudCrate']}")
|
||||
@ -1983,6 +1995,147 @@ def smoke_nopump(p):
|
||||
b.close()
|
||||
|
||||
|
||||
def smoke_bible(p):
|
||||
"""R32 gate — THE BIBLE + per-day scarcity (v7.0-beta wave 1). Legs: (a) the pull card carries the
|
||||
guide stamp on a NAKED boot (lesson #4 — no explicit flags; parody bin); (b) structural: every parody
|
||||
offer prices INSIDE its band unless it's a seeded gem (asking < bandLow, collector/grail only) —
|
||||
read off F's digOffers honest surface, checked against the BIBLE mirror of E's thresholds;
|
||||
(c) buy the WHOLE bin, sell it all: cash moves exactly Σoffer−Σask with offer = min(low−1,
|
||||
max(1, low//2)) per item — gems positive, everything else negative, the ledger closes to the cent;
|
||||
(d) scarcity: the emptied bin re-opens EMPTY, stays empty across a reload (pulls ride the save),
|
||||
and (e) negative control: ?game=0 re-opens the same bin FULL — the detector discriminates
|
||||
(scarcity is the save's doing, not the dig's). (f) real stock carries the stamp too."""
|
||||
head('GATE R32: THE BIBLE — guide bands · gems · per-day scarcity (+ ?game=0 control)')
|
||||
BIBLE = {'bargain': (2, 7), 'standard': (8, 24), 'collector': (25, 59), 'grail': (60, None)}
|
||||
exp_offer = lambda band: min(BIBLE[band][0] - 1, max(1, BIBLE[band][0] // 2))
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, '') # NAKED — dig/game default-on carry the feature
|
||||
r0 = _enter_record_shop(pg)
|
||||
if r0.get('err'): FAIL(f"bible: {r0['err']}"); return
|
||||
# bankroll the buy-everything leg through the REAL import path (5000 covers any 16-sleeve bin)
|
||||
rich = pg.evaluate("""() => { const g = window.PROCITY.game;
|
||||
const ex = JSON.parse(g.export()); ex.cash = 5000; ex.savedAt = Date.now();
|
||||
return { ok: g.import(JSON.stringify(ex)), cash: g.cash }; }""")
|
||||
if not (rich['ok'] and rich['cash'] == 5000):
|
||||
FAIL(f"bible: bankroll import failed — {rich}"); return
|
||||
pg.evaluate("() => window.DBG.setSegment(2)") # import's onDay woke us at DAWN — back to midday
|
||||
# ── (a)+(b): scan EVERY bin in the room off the honest surface — band law on all of them, and
|
||||
# find a bin holding a GEM (seeded: whichever bin it is today, it's that bin forever). The
|
||||
# buy-everything ledger leg then runs on THE GEM'S BIN, so the finder-margin arithmetic is
|
||||
# exercised for real, not vacuously (a 0-gem bin would prove only the losing half).
|
||||
n_bins = (_at_bin(pg) or {}).get('bins', 1)
|
||||
bad, all_offers, gem_bin, gems = [], {}, None, []
|
||||
for bi in range(n_bins):
|
||||
_at_bin(pg, bi)
|
||||
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||
pg.wait_for_timeout(350)
|
||||
offs = pg.evaluate("() => window.PROCITY.interiorMode.digOffers")
|
||||
pg.evaluate("() => { const o = document.querySelector('.pcdg-out'); if (o) o.click(); }")
|
||||
pg.wait_for_timeout(200)
|
||||
if not offs: bad.append(f"bin{bi} unreadable"); continue
|
||||
all_offers[bi] = offs
|
||||
bin_gems = []
|
||||
for o in offs:
|
||||
band = o.get('band')
|
||||
if band not in BIBLE: bad.append(f"bin{bi}#{o['i']} band={band!r}"); continue
|
||||
lo, hi = BIBLE[band]
|
||||
if o['price'] < lo:
|
||||
if band in ('collector', 'grail'): bin_gems.append(o)
|
||||
else: bad.append(f"bin{bi}#{o['i']} {band} ${o['price']} under-band but not gem-eligible")
|
||||
elif hi is not None and o['price'] > hi:
|
||||
bad.append(f"bin{bi}#{o['i']} {band} ${o['price']} over band")
|
||||
if bin_gems and gem_bin is None: gem_bin, gems = bi, bin_gems
|
||||
if bad: FAIL(f"bible: band-law violations — {bad[:3]}")
|
||||
else: OK(f"bible: {sum(len(v) for v in all_offers.values())} parody offers across {n_bins} bin(s) "
|
||||
f"all inside their bands (or seeded gems)")
|
||||
if gem_bin is None:
|
||||
FAIL(f"bible: NO gem in any of {n_bins} bins — the thrill leg is unreachable on this seed"); return
|
||||
for g in gems:
|
||||
if not g['price'] < exp_offer(g['band']):
|
||||
FAIL(f"bible: gem #{g['i']} asks ${g['price']} ≥ keeper offer ${exp_offer(g['band'])} — no finder margin")
|
||||
OK(f"bible: bin{gem_bin} holds {len(gems)} gem(s) — e.g. {gems[0]['band']} asking ${gems[0]['price']} "
|
||||
f"vs keeper ${exp_offer(gems[0]['band'])} (margin by construction)")
|
||||
offers = all_offers[gem_bin]
|
||||
_at_bin(pg, gem_bin)
|
||||
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||
pg.wait_for_timeout(350)
|
||||
# pull the front sleeve → the guide stamp on the card
|
||||
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)
|
||||
stamp = pg.evaluate("""() => { const p = document.querySelector('.pcdg-panel');
|
||||
return p && p.style.display === 'block' ? p.textContent : null; }""")
|
||||
if stamp and 'guide $' in stamp: OK('bible: the pull card carries the guide stamp (naked boot, parody)')
|
||||
else: FAIL(f"bible: no guide stamp on the pull card — {stamp[:80] if stamp else 'no panel'}")
|
||||
pg.evaluate("""() => { const p = document.querySelector('.pcdg-panel');
|
||||
const x = p && p.querySelector('.x'); if (x) x.click(); }""")
|
||||
# ── (c): buy the WHOLE bin through the real card, then sell it all at the counter ──
|
||||
cash0 = pg.evaluate("() => window.PROCITY.wallet.cash()")
|
||||
bought = 0
|
||||
for _ in range(len(offers)):
|
||||
rb = _dig_buy(pg)
|
||||
if rb.get('err') or rb.get('disabled'): break
|
||||
bought += 1
|
||||
if bought < len(offers): _at_bin(pg, gem_bin) # walk-out re-closed the dig; re-aim the gem's bin
|
||||
if bought != len(offers):
|
||||
FAIL(f"bible: bought {bought}/{len(offers)} — the buy-everything leg died early"); return
|
||||
ask_total = sum(o['price'] for o in offers)
|
||||
offer_total = sum(exp_offer(o['band']) for o in offers)
|
||||
rs = _counter_sell(pg, max_sales=bought + 2)
|
||||
sell_card = pg.evaluate("() => { const p = document.querySelector('.pcsl-panel'); return p ? p.textContent : ''; }")
|
||||
if rs.get('err') or rs.get('sold', 0) != bought:
|
||||
FAIL(f"bible: sold {rs.get('sold')}/{bought} — {rs}"); return
|
||||
cash1 = pg.evaluate("() => window.PROCITY.wallet.cash()")
|
||||
gem_profit = sum(exp_offer(o['band']) - o['price'] for o in gems)
|
||||
if cash1 - cash0 == offer_total - ask_total:
|
||||
OK(f"bible: the ledger closes — Σask ${ask_total} → Σoffer ${offer_total} "
|
||||
f"(net {cash1 - cash0:+d}; gems alone {gem_profit:+d}, every non-gem strictly negative)")
|
||||
else:
|
||||
FAIL(f"bible: ledger mismatch — cash moved {cash1 - cash0:+d}, expected {offer_total - ask_total:+d} "
|
||||
f"(basis law broken somewhere)")
|
||||
# ── (d): scarcity — the emptied bin is EMPTY on re-open, and STAYS empty across a reload ──
|
||||
_at_bin(pg, gem_bin)
|
||||
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||
pg.wait_for_timeout(300)
|
||||
n_re = pg.evaluate("() => (window.PROCITY.interiorMode.digOffers || []).length")
|
||||
pg.evaluate("() => { const o = document.querySelector('.pcdg-out'); if (o) o.click(); }")
|
||||
pulls_n = pg.evaluate("() => (JSON.parse(window.PROCITY.game.export()).pulls || []).length")
|
||||
if n_re == 0 and pulls_n >= bought:
|
||||
OK(f"bible/scarcity: re-opened bin is cleaned out (0 of {bought}) · save carries {pulls_n} pull(s)")
|
||||
else:
|
||||
FAIL(f"bible/scarcity: re-open shows {n_re} sleeves (want 0) · pulls in save {pulls_n}")
|
||||
boot(pg, '') # full reload, same origin — the save must carry it
|
||||
r1 = _enter_record_shop(pg)
|
||||
if r1.get('err'): FAIL(f"bible/scarcity reload: {r1['err']}")
|
||||
else:
|
||||
_at_bin(pg, gem_bin)
|
||||
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||
pg.wait_for_timeout(300)
|
||||
n_reload = pg.evaluate("() => (window.PROCITY.interiorMode.digOffers || []).length")
|
||||
if n_reload == 0: OK('bible/scarcity: still cleaned out after a full reload — pulls rode the save')
|
||||
else: FAIL(f"bible/scarcity: reload restocked the bin ({n_reload} sleeves) — pulls not persisted")
|
||||
# ── (e): negative control — ?game=0 must re-open the SAME bin FULL (rotation gate: game=0 ≡ day1) ──
|
||||
boot(pg, 'game=0')
|
||||
r2 = _enter_record_shop(pg)
|
||||
if r2.get('err'): FAIL(f"bible/control: {r2['err']}")
|
||||
else:
|
||||
_at_bin(pg, gem_bin)
|
||||
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||
pg.wait_for_timeout(300)
|
||||
n_ctl = pg.evaluate("() => (window.PROCITY.interiorMode.digOffers || []).length")
|
||||
if n_ctl == len(offers): OK(f"bible/control: ?game=0 re-opens the bin FULL ({n_ctl}) — "
|
||||
"scarcity is the save's doing, the detector discriminates")
|
||||
else: FAIL(f"bible/control: ?game=0 bin shows {n_ctl}, want {len(offers)}")
|
||||
if 'guide $' in (sell_card or ''): OK('bible: the sell card carries the guide line too')
|
||||
else: WARN('bible: no guide line read off the sell card (cosmetic leg)')
|
||||
if errs: FAIL(f"bible: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||||
else: OK('bible: 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
|
||||
@ -2136,6 +2289,7 @@ def main():
|
||||
smoke_nopump(p) # R30 gate b — 5 real round trips monotonic loss + C's negative 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_bible(p) # R32 — THE BIBLE (bands · gems · ledger) + per-day scarcity + ?game=0 control
|
||||
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:
|
||||
|
||||
@ -168,7 +168,8 @@ const skins = createSkinLibrary();
|
||||
const ground = buildGround(plan, skins);
|
||||
scene.add(ground.group);
|
||||
|
||||
const lighting = createLighting(scene, plan, skins, { radius: RADIUS, shadows: SHADOWS });
|
||||
const lighting = createLighting(scene, plan, skins, { radius: RADIUS, shadows: SHADOWS,
|
||||
dawnSky: CLASSIC ? null : 'high-cirrus' }); // [R32 §31.6-3] dawn reads dawn; classic keeps the frozen dome
|
||||
lighting.attachRenderer(renderer);
|
||||
|
||||
const ctx = { skins, citySeed: plan.citySeed, plan, night: lighting.isNight(), radius: RADIUS };
|
||||
@ -354,8 +355,37 @@ const venuePresentation = gigState ? createVenuePresentation(plan, skins, scene)
|
||||
const venueQueues = gigState ? new Map() : null; // venueShopId → VenueQueue
|
||||
const queueCountOf = (id) => { const q = venueQueues && venueQueues.get(id); return q ? q.count() : 0; };
|
||||
|
||||
// spawn on the footpath near the west end of the strip, looking east down main street
|
||||
player.teleport(-96, 9, -Math.PI / 2);
|
||||
// [R32 §31.6-1 — THE CLUSTER-POSE SPAWN] The frozen pose (-96, 9, looking east) was tuned for the
|
||||
// synthetic fixture; on real OSM towns it faced dirt and building-backs (the R31 playtest's #1).
|
||||
// Non-classic boots now spawn at one END of the shop cluster's dominant axis, ON the footpath door-line
|
||||
// (a point every shop already computes for patronage — known walkable), looking down the strip toward
|
||||
// the cluster's heart. Deterministic (pure plan math, no draws — no stream moves), and ?classic=1
|
||||
// keeps the frozen pose forever (the covenant is a POSE here, not just a plan).
|
||||
let SPAWN = { x: -96, z: 9, yaw: -Math.PI / 2 };
|
||||
if (!CLASSIC && plan.shops && plan.shops.length >= 2) {
|
||||
// main-street shop doors only (block kind — both generators stamp it); every-door fallback
|
||||
const msBlocks = new Set((plan.blocks || []).filter((b) => b.kind === 'mainstreet').map((b) => b.id));
|
||||
const doorOf = (s) => {
|
||||
const l = plan.lots.find((x) => x.id === s.lot); if (!l) return null;
|
||||
const ry = l.ry || 0;
|
||||
return { x: l.x - Math.sin(ry) * (l.d / 2 + 2.6), z: l.z - Math.cos(ry) * (l.d / 2 + 2.6), block: l.block };
|
||||
};
|
||||
let doors = plan.shops.map(doorOf).filter(Boolean);
|
||||
const ms = doors.filter((d) => msBlocks.has(d.block));
|
||||
if (ms.length >= 2) doors = ms;
|
||||
if (doors.length >= 2) {
|
||||
let cx = 0, cz = 0; for (const d of doors) { cx += d.x; cz += d.z; } cx /= doors.length; cz /= doors.length;
|
||||
let vx = 0, vz = 0; for (const d of doors) { vx += (d.x - cx) ** 2; vz += (d.z - cz) ** 2; }
|
||||
const ax = vx >= vz ? 'x' : 'z'; // the strip's dominant axis
|
||||
let stand = doors[0], bd = Infinity; // stand at the door nearest the cluster's heart
|
||||
for (const d of doors) { const dd = (d.x - cx) ** 2 + (d.z - cz) ** 2; if (dd < bd) { bd = dd; stand = d; } }
|
||||
let lo = Infinity, hi = -Infinity;
|
||||
for (const d of doors) { lo = Math.min(lo, d[ax]); hi = Math.max(hi, d[ax]); }
|
||||
const dir = (hi - stand[ax] >= stand[ax] - lo) ? 1 : -1; // face the longer run of shopfronts
|
||||
SPAWN = { x: stand.x, z: stand.z, yaw: ax === 'x' ? Math.atan2(-dir, 0) : Math.atan2(0, -dir) };
|
||||
}
|
||||
}
|
||||
player.teleport(SPAWN.x, SPAWN.z, SPAWN.yaw);
|
||||
chunks.warmup(player.position);
|
||||
lighting.update(0.0001, player.position);
|
||||
|
||||
@ -508,8 +538,10 @@ function frame() {
|
||||
renderer.info.reset(); // zero the counters, then let the composer's passes accumulate this frame
|
||||
composer.render();
|
||||
} else if (MODE === 'interior') {
|
||||
hud.tickGame(); // [R32 §31.6-2] the crate/day/cash surface lives in shops too (yields to the cards)
|
||||
if (interiorMode.update(dt, player.isLocked ? keys : EMPTY)) leaveShop(); // [Lane F] walk to the door → back to street
|
||||
} else if (MODE === 'map') {
|
||||
hud.tickGame(); // [R32] map mode: the same check hides the surface (the map owns the screen)
|
||||
camera.getWorldDirection(_fwd);
|
||||
minimap.draw(player.position, { x: _fwd.x, z: _fwd.z });
|
||||
}
|
||||
|
||||
58
web/js/interiors/bible.js
Normal file
58
web/js/interiors/bible.js
Normal file
@ -0,0 +1,58 @@
|
||||
// PROCITY Lane C — bible.js [v7.0-beta wave 1, R32 — THE GUIDE + THE GEM (charter system #3)]
|
||||
// The in-game price guide: thriftgod's BIBLE concept as a VALUE BAND per item. The band is the
|
||||
// 90s guide book, not an oracle — it names a range ("guide $25–59"), never a verdict. A **gem**
|
||||
// is an item ASKING under its band's low edge; spotting one is the player's skill, so nothing in
|
||||
// any UI ever says "gem".
|
||||
//
|
||||
// THE DERIVATION RULING (R32): band NUMBERS mirror Lane E's pipeline thresholds
|
||||
// (pipeline/build_stock_pack.py price_band(): record 8/25/60 · book 5/15/40 · toy 6/20/50 —
|
||||
// duplicated verbatim in godverse_stock.py, validated by validate_pack.py). We derive ranges in
|
||||
// JS from the band LABEL the packs already carry instead of re-baking band data into atlases —
|
||||
// so charter risk #3 (a re-emitted pack orphaning owned skus) never opens. If E's thresholds
|
||||
// move, this table moves with them in the same commit (the two files name each other).
|
||||
//
|
||||
// THE STRUCTURAL CONSEQUENCE (why the no-pump law survives the beta, LANE_C_PUB §9.1):
|
||||
// real/mint pack items derive `price_band` FROM `price`, so real asking always sits INSIDE its
|
||||
// band ⇒ bandLow ≤ asking ⇒ sellOffer(bandLow) ≤ bandLow − 1 < asking: every real round trip
|
||||
// stays strictly negative with the basis swapped. Only PARODY stock (proceduralSleeves) rolls a
|
||||
// seeded under-band gem — and per-day scarcity (the save's `pulls`) makes each gem profit ONCE.
|
||||
// Growth is bounded by finds, not arbitrage loops — charter law #2, made structural.
|
||||
//
|
||||
// Zero DOM, zero draws, zero fetch, zero storage: a pure table + pure functions. ?classic=1 /
|
||||
// ?dig=0 never import a caller, and importing this module alone has no effect at all.
|
||||
|
||||
// kind → band → [low, high]; `high: null` = open-ended (the card renders "$60+").
|
||||
// Lows/highs are E's threshold edges: a band [t0, t1) renders as $t0–(t1−1).
|
||||
export const BANDS = {
|
||||
record: { bargain: [2, 7], standard: [8, 24], collector: [25, 59], grail: [60, null] },
|
||||
book: { bargain: [1, 4], standard: [5, 14], collector: [15, 39], grail: [40, null] },
|
||||
toy: { bargain: [2, 5], standard: [6, 19], collector: [20, 49], grail: [50, null] },
|
||||
};
|
||||
export const BAND_ORDER = ['bargain', 'standard', 'collector', 'grail'];
|
||||
|
||||
// bandRange('record','collector') → [25, 59] | null on unknown kind/band (fail-closed: no range,
|
||||
// no guide line, no basis swap — never an invented number).
|
||||
export function bandRange(kind, band) {
|
||||
const k = BANDS[kind];
|
||||
const r = k && k[String(band || '').toLowerCase()];
|
||||
return r ? [r[0], r[1]] : null;
|
||||
}
|
||||
|
||||
// The guide line for a card stamp — "guide $25–59" / "guide $60+" / '' when unknowable.
|
||||
export function guideText(kind, band) {
|
||||
const r = bandRange(kind, band);
|
||||
if (!r) return '';
|
||||
return r[1] == null ? `guide $${r[0]}+` : `guide $${r[0]}–${r[1]}`;
|
||||
}
|
||||
|
||||
// The sell basis for a collection entry (LANE_C_PUB §9.1, the pre-ratified beta swap): bandLow
|
||||
// when the entry carries a band the BIBLE knows, else the alpha basis (pricePaid) — so every
|
||||
// alpha-era find (no band field) sells exactly as it did before this round. The sellOffer()
|
||||
// CLAMP SHAPE is sell.js's and survives untouched; this function only chooses the number fed in.
|
||||
export function sellBasis(entry) {
|
||||
if (entry && entry.band) {
|
||||
const r = bandRange(entry.type, entry.band);
|
||||
if (r) return r[0];
|
||||
}
|
||||
return entry ? (entry.pricePaid != null ? entry.pricePaid : entry.price) : 0;
|
||||
}
|
||||
@ -10,6 +10,7 @@
|
||||
// runs unless the shell/test-page opens it, so `?dig=0` is byte-identical to v1.
|
||||
|
||||
import { mulberry32, xmur3 } from '../core/prng.js';
|
||||
import { BANDS, guideText } from './bible.js'; // [R32] THE BIBLE — band ranges + the card's guide stamp
|
||||
|
||||
const MAXFLIP = 1.95, BACK = -0.12;
|
||||
const SLEEVE = [0.31, 0.31, 0.0060]; // w, h, thickness (packed a touch chunkier than vinyl)
|
||||
@ -23,21 +24,33 @@ const TITLES = ['Sunburnt', 'Back o’ Bourke', 'Servo at Midnight', 'Long Weeke
|
||||
const CONDS = ['VG+', 'VG', 'G+', 'NM', 'EX'];
|
||||
|
||||
// Deterministic sleeve list for a bin — the "existing procedural stock" as riffle-able offers.
|
||||
// [R32 — THE BIBLE + THE GEM] Every parody sleeve now rolls a seeded BAND (the guide's range) and an
|
||||
// ASKING price with seeded variance: non-gems price INSIDE their band; a 10% gem roll (collector/grail
|
||||
// only — an under-band bargain is not a thrill) prices UNDER the band's low edge, which is the entire
|
||||
// op-shop fantasy made mechanical. Draw COUNT is fixed per item (9 rolls, outcome-independent) so the
|
||||
// stream stays deterministic and re-orderable never. `rare` (the ★) now means grail, matching the real
|
||||
// packs — the card never says "gem"; spotting the asking-under-guide gap is the player's skill.
|
||||
const PARODY_BAND = (roll) => (roll < 0.25 ? 'bargain' : roll < 0.70 ? 'standard' : roll < 0.92 ? 'collector' : 'grail');
|
||||
const GRAIL_ASK_HI = 120; // parody grail asking ceiling (band renders "$60+")
|
||||
function proceduralSleeves(seed, n) {
|
||||
const r = mulberry32(seed >>> 0);
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const g = GENRE[(r() * GENRE.length) | 0];
|
||||
out.push({
|
||||
i, kind: 'record',
|
||||
a: ARTISTS[(r() * ARTISTS.length) | 0],
|
||||
t: TITLES[(r() * TITLES.length) | 0],
|
||||
y: 1971 + ((r() * 28) | 0),
|
||||
s: g[0], color: g[1],
|
||||
cond: CONDS[(r() * CONDS.length) | 0],
|
||||
price: 2 + ((r() * 28) | 0),
|
||||
rare: r() < 0.12,
|
||||
});
|
||||
const a = ARTISTS[(r() * ARTISTS.length) | 0];
|
||||
const t = TITLES[(r() * TITLES.length) | 0];
|
||||
const y = 1971 + ((r() * 28) | 0);
|
||||
const cond = CONDS[(r() * CONDS.length) | 0];
|
||||
const band = PARODY_BAND(r());
|
||||
const [lo, hi] = BANDS.record[band];
|
||||
const priceRoll = r(), gemRoll = r(), gemPriceRoll = r(); // always drawn — fixed stream shape
|
||||
const gem = gemRoll < 0.10 && (band === 'collector' || band === 'grail');
|
||||
// gem asking sits STRICTLY under the keeper's own bandLow offer (min(lo−1, floor(lo/2)) — sell.js),
|
||||
// so a spotted gem always nets margin: collector asks $2–9 vs offer $12 · grail $5–20 vs $30.
|
||||
const price = gem
|
||||
? (band === 'grail' ? 5 + ((gemPriceRoll * 16) | 0) : 2 + ((gemPriceRoll * 8) | 0))
|
||||
: lo + ((priceRoll * ((hi == null ? GRAIL_ASK_HI : hi) - lo + 1)) | 0);
|
||||
out.push({ i, kind: 'record', a, t, y, s: g[0], color: g[1], cond, price, band, rare: band === 'grail' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@ -250,6 +263,11 @@ export function createDig(THREE, renderer) {
|
||||
const line = [o.y, o.s].filter(Boolean).join(' '); // "1974 SOUL" | "SOUL" | "" — never " · "
|
||||
if (line) stamps.push(`<span class="tag">${esc(line)}</span>`);
|
||||
if (o.rare) stamps.push(`<span class="star">★ ${o.real ? 'GRAIL' : 'RARE'}</span>`);
|
||||
// [R32 THE BIBLE] the guide stamp — a RANGE off the 90s guide book, never a verdict. Present-fields-
|
||||
// only law holds: an unknown band leaves no stamp (guideText returns ''). The asking price sits on
|
||||
// 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>`);
|
||||
panel.innerHTML = `<div class="x">✕</div>`
|
||||
+ `<div class="sticker">$${esc(o.price)}</div>`
|
||||
+ `<h3>${esc(o.a)}</h3>`
|
||||
@ -342,13 +360,16 @@ export function createDig(THREE, renderer) {
|
||||
// Both forms hide the sold record, so "the gone item never renders" cannot tell them apart — the
|
||||
// collateral is what a gate must assert on. F: update item FIELDS in place (live price) freely; do NOT
|
||||
// add, remove, or reorder `pack.items` — that identity/order IS the seeded pick's stability.
|
||||
function pickRealOffers(pack, seed, n) {
|
||||
function pickRealOffers(pack, seed, n, pulledGone) {
|
||||
const rr = mulberry32(seed >>> 0), out = [];
|
||||
const gone = pack.gone instanceof Set ? pack.gone
|
||||
: (Array.isArray(pack.gone) ? new Set(pack.gone) : null);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const it = pack.items[(rr() * pack.items.length) | 0];
|
||||
if (gone && gone.has(it.id)) continue; // sold in the real shop → this slot is simply empty
|
||||
// [R32 scarcity] the PLAYER bought it (this day, this bin) → same pick-then-omit seam, same
|
||||
// zero-collateral guarantee: the pull leaves, the survivors keep their slots.
|
||||
if (pulledGone && pulledGone.has('sku:' + it.id)) continue;
|
||||
// Real fields straight off the atlas index (§7.2c): condition/sleeve_cond are OPTIONAL — present on
|
||||
// POS-sourced items, absent on mint ones that had no genuine grade. `y` (year) is never in the index,
|
||||
// so it stays empty and the card simply omits it. No invented values: an unstated grade stays unstated.
|
||||
@ -358,22 +379,29 @@ export function createDig(THREE, renderer) {
|
||||
out.push({ real: true, pack, item: it, a: it.artist, t: it.title, price: it.price,
|
||||
s: band === 'GRAIL' ? '' : band, y: '',
|
||||
cond: it.condition || '', sleeveCond: it.sleeve_cond || '',
|
||||
band: it.price_band || null, // [R32] canonical band for the guide stamp + the sell basis
|
||||
rare: it.price_band === 'grail' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// open({ seed, count, shopName, shop, stockAdapter, onClose, onBuy, getCash, emitters })
|
||||
// open({ seed, count, shopName, shop, stockAdapter, onClose, onBuy, getCash, emitters, gone })
|
||||
// `emitters` [R28, OPTIONAL] — pass `room.audio.emitters` and the till rings from the counter at the
|
||||
// right distance. Omit it and the till still rings, just un-positioned. Never required.
|
||||
// `gone` [R32, OPTIONAL] — a Set of THIS bin's already-pulled slots ('sku:<id>' for pack items, the
|
||||
// stringified offer index for parody), from the save's pull records. Omission happens AFTER the full
|
||||
// seeded draw (both paths), so the survivors keep their slots — the R27 zero-collateral law. Absent
|
||||
// (?game=0 / classic / fresh day) ⇒ the bin riffles exactly as it always did.
|
||||
function open(opts = {}) {
|
||||
active = true;
|
||||
onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null;
|
||||
emitters = opts.emitters || null;
|
||||
const count = opts.count || 16;
|
||||
const gone = opts.gone instanceof Set && opts.gone.size ? opts.gone : null;
|
||||
// riffle items: real pack (?stock=real) via the stockAdapter seam, else seeded procedural sleeves.
|
||||
const src = opts.stockAdapter && opts.stockAdapter(opts.shop, 'sleeve');
|
||||
offers = (src && src.real) ? pickRealOffers(src.pack, opts.seed ?? 1, count) : proceduralSleeves(opts.seed ?? 1, count);
|
||||
offers = (src && src.real) ? pickRealOffers(src.pack, opts.seed ?? 1, count, gone)
|
||||
: proceduralSleeves(opts.seed ?? 1, count).filter((o) => !(gone && gone.has(String(o.i))));
|
||||
curLbl.textContent = (opts.shopName ? opts.shopName + ' — ' : '');
|
||||
[hint, curLbl, outBtn].forEach(e => e.style.display = 'block');
|
||||
buildStack(offers);
|
||||
@ -411,7 +439,16 @@ export function createDig(THREE, renderer) {
|
||||
[css, hint, curLbl, panel, outBtn, toast].forEach(e => e.remove());
|
||||
}
|
||||
|
||||
return { scene, camera, open, close, update, dispose, get active() { return active; } };
|
||||
// [R32 — the honest-gate surface, R24 precedent] What is in THIS open bin, BY IDENTITY: original
|
||||
// offer index, asking, band, sku (pack items). Read-only snapshots; null when closed. F's bible/
|
||||
// scarcity gate asserts on these instead of trusting "the crate looked right".
|
||||
function currentOffers() {
|
||||
if (!active) return null;
|
||||
return recs.map((r) => ({ i: r.offer.i ?? null, price: r.offer.price, band: r.offer.band || null,
|
||||
sku: r.offer.item ? String(r.offer.item.id) : null }));
|
||||
}
|
||||
|
||||
return { scene, camera, open, close, update, dispose, currentOffers, get active() { return active; } };
|
||||
}
|
||||
|
||||
// A stable per-bin seed from the shop + a bin index, so a given bin riffles the same order every time.
|
||||
|
||||
@ -13,15 +13,20 @@
|
||||
// (?classic=1 / ?game=0) → the consumer never constructs/opens this, and open() itself returns false
|
||||
// on an empty sellable list — zero errors, zero DOM.
|
||||
|
||||
import { sellBasis, guideText } from './bible.js'; // [R32] THE BIBLE — the §9.1 beta basis + the card's guide line
|
||||
|
||||
export const SELL_MULT = 0.5; // the keeper pays half of what he'll sticker it at (§9.1 — he eats too)
|
||||
export const SELL_DIST = 2.0; // metres from room.counter.pose (bench centre, room-local)
|
||||
|
||||
// The offer (§9.1). Alpha basis = pricePaid (the buy-side price the player actually faced — no guide
|
||||
// bands until beta; beta swaps the basis to bandLow, the clamp survives untouched). The min(p−1, …)
|
||||
// clamp is the law: no retune of SELL_MULT can ever push an offer to break even. p=1 → $0 (the SELL
|
||||
// button disables — the keeper won't buy what he can't sticker).
|
||||
export function sellOffer(pricePaid) {
|
||||
const p = Math.floor(+pricePaid || 0);
|
||||
// The offer (§9.1). [R32 — the pre-ratified beta swap, executed] Basis = bandLow when the entry carries
|
||||
// a band the BIBLE knows (sellBasis, bible.js), else the alpha basis (pricePaid) — so alpha-era finds
|
||||
// sell exactly as they always did. THE CLAMP SHAPE IS UNTOUCHED: the offer sits strictly below the
|
||||
// BASIS for every basis ≥ 1, which with bandLow as basis is the no-pump law's beta form — "every sell
|
||||
// price < the buy-side guide band, structurally" (charter law #2). A gem (bought UNDER band) nets its
|
||||
// finder margin against the band, never against the keeper; scarcity (the save's pulls) makes that
|
||||
// margin a finite find, not a loop. p=1 → $0 (the SELL button disables).
|
||||
export function sellOffer(basis) {
|
||||
const p = Math.floor(+basis || 0);
|
||||
if (p <= 0) return 0;
|
||||
return Math.min(p - 1, Math.max(1, Math.floor(p * SELL_MULT)));
|
||||
}
|
||||
@ -92,15 +97,16 @@ export function createSell() {
|
||||
function render() {
|
||||
const it = items[idx];
|
||||
if (!it) { close(); return; }
|
||||
const offer = sellOffer(it.pricePaid != null ? it.pricePaid : it.price);
|
||||
const offer = sellOffer(sellBasis(it)); // [R32] bandLow basis when banded, pricePaid else
|
||||
const head = labelOf(it) || titleOf(it);
|
||||
const ttl = labelOf(it) ? titleOf(it) : '';
|
||||
const paid = it.pricePaid != null ? it.pricePaid : it.price;
|
||||
const guide = guideText(it.type, it.band); // [R32] '' when unbanded → no stray separator (§7.2c)
|
||||
panel.innerHTML = `<div class="x">✕</div>`
|
||||
+ `<div class="sticker">$${esc(offer)}</div>`
|
||||
+ `<h3>${esc(head)}</h3>`
|
||||
+ (ttl ? `<div class="ttl">“${esc(ttl)}”</div>` : '')
|
||||
+ `<div class="meta">${paid != null ? 'you paid $' + esc(paid) : ''}${it.dayFound != null ? ' · day ' + esc(it.dayFound) : ''}</div>`
|
||||
+ `<div class="meta">${paid != null ? 'you paid $' + esc(paid) : ''}${guide ? ' · ' + esc(guide) : ''}${it.dayFound != null ? ' · day ' + esc(it.dayFound) : ''}</div>`
|
||||
+ (items.length > 1 ? `<div class="nav"><span class="pv">‹</span>${idx + 1} of ${items.length}<span class="nx">›</span></div>` : '')
|
||||
+ `<button ${offer <= 0 ? 'disabled' : ''}>${offer <= 0 ? 'NOT WORTH BUYING' : 'SELL — $' + esc(offer)}</button>`;
|
||||
panel.style.display = 'block';
|
||||
|
||||
@ -27,7 +27,7 @@ const CSS = `
|
||||
#pc-townpick:hover{opacity:1}
|
||||
#pc-townpick select{background:rgba(20,16,10,.7);color:#f4efe6;border:1px solid rgba(255,215,94,.35);border-radius:8px;padding:5px 9px;font:12px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;max-width:230px;cursor:pointer}
|
||||
#pc-townpick select:focus{outline:1px solid rgba(255,215,94,.6)}
|
||||
#pc-game{position:absolute;right:10px;bottom:12px;display:flex;flex-direction:column;align-items:flex-end;gap:8px}
|
||||
#pc-game{position:fixed;right:10px;bottom:12px;z-index:30;display:flex;flex-direction:column;align-items:flex-end;gap:8px}
|
||||
#pc-gamebar{background:rgba(20,16,10,.72);padding:6px 10px;border-radius:10px;display:flex;gap:10px;align-items:center;pointer-events:auto;text-shadow:0 1px 2px #000}
|
||||
#pc-gamebar b{color:#ffd75e;font-weight:600}
|
||||
#pc-gamebar button{background:rgba(20,16,10,.7);color:#f4efe6;border:1px solid rgba(255,215,94,.35);border-radius:8px;padding:4px 10px;font:12px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;cursor:pointer}
|
||||
@ -166,7 +166,11 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
||||
bar.innerHTML = `<span>day <b id="pc-day">—</b> · $<b id="pc-cash">—</b></span>
|
||||
<button id="pc-crate-btn" title="The collection (C toggles; Esc frees the pointer first)">CRATE 0</button>
|
||||
<button id="pc-sleep-btn" title="Sleep — save, wake tomorrow">☾ SLEEP</button>`;
|
||||
wrap.append(panel, bar); root.appendChild(wrap);
|
||||
// [R32 §31.6-2 — the crate at the counter] The game surface rides document.body, NOT the street-HUD
|
||||
// root: the street root hides in interior mode (by design), but your day/cash/crate are YOURS in a
|
||||
// shop too — the playtest's "why can't I check the crate at the counter". It yields the screen to
|
||||
// the dig/sell cards and the map (visibility below); ?classic=1 / ?game=0 never build any of this.
|
||||
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;
|
||||
@ -218,18 +222,25 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
||||
shownCount = col.length; btnCrate.textContent = `CRATE ${shownCount}`;
|
||||
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;
|
||||
const yield_ = (P && P.mode === 'map') || !!(im && (im.digActive || im.sellActive));
|
||||
const want = yield_ ? 'none' : 'flex';
|
||||
if (wrap.style.display !== want) wrap.style.display = want;
|
||||
}
|
||||
refresh();
|
||||
onGameKey = (e) => { // C toggles the crate on the street (not inside a shop — E/dig territory)
|
||||
onGameKey = (e) => { // [R32 §31.6-2] C toggles the crate on the street AND in a shop (not over a card)
|
||||
if (e.code !== 'KeyC') return;
|
||||
const P = window.PROCITY;
|
||||
if (P && P.mode === 'interior') return;
|
||||
const P = window.PROCITY, im = P && P.interiorMode;
|
||||
if (P && P.mode === 'map') return;
|
||||
if (im && (im.digActive || im.sellActive)) return; // the cards own the keys while open
|
||||
setOpen(!open);
|
||||
};
|
||||
addEventListener('keydown', onGameKey);
|
||||
const help = root.querySelector('#pc-help');
|
||||
if (help) help.textContent += ' · C crate';
|
||||
return { refresh };
|
||||
return { refresh, wrap }; // wrap rides document.body → dispose() must remove it itself
|
||||
}
|
||||
|
||||
const ray = new THREE.Raycaster();
|
||||
@ -318,9 +329,14 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
||||
function dispose() {
|
||||
renderer.domElement.removeEventListener('click', onClick);
|
||||
if (onGameKey) removeEventListener('keydown', onGameKey);
|
||||
if (gameUI && gameUI.wrap) gameUI.wrap.remove(); // [R32] the game surface lives on document.body now
|
||||
root.remove();
|
||||
}
|
||||
|
||||
return { update, tickToast, showToast, setVisible, dispose, getHovered: () => hovered,
|
||||
// [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(); }
|
||||
|
||||
return { update, tickToast, showToast, setVisible, tickGame, dispose, getHovered: () => hovered,
|
||||
getFps: () => fpsShown };
|
||||
}
|
||||
|
||||
@ -117,6 +117,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
let currentAdapter = null; // [F2 §stock] ?stock=real stockAdapter for this shop (null → parody fallback)
|
||||
let currentStockDay = null; // [R30 §30.3] the day salt for THIS room's stock streams (null = no rotation:
|
||||
// no game, day 1, or a REAL-sourced crate — real stock never rotates)
|
||||
let currentRealSourced = false; // [R32] this room's crate is sourcing:'real' — its pulls never expire
|
||||
let doorReturn = null; // { x, y, z, ry } on the street to restore on exit
|
||||
let exitArmed = false; // disarmed at spawn (spawn sits by the door); arms once player steps inside
|
||||
let dig = null; // [F2] Lane C crate-riffle instance, created lazily on first use (only if ?dig=1)
|
||||
@ -166,26 +167,40 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
// [R30 §30.3] the riffle rotates with the day: salt the bin key when this room's stock rotates
|
||||
// (currentStockDay is null for no-game / day-1 / REAL-sourced crates — those riffle exactly as pre-v7).
|
||||
const dayTag = currentStockDay != null ? '@d' + currentStockDay : '';
|
||||
// [R32 scarcity] the PULL key's day tag is broader than the SEED's: a rotating bin is pulled-from
|
||||
// per (bin, day) even on day 1 (where the seed stays untagged by the day-1 convention), while a
|
||||
// REAL-sourced crate stays untagged forever — sold means gone, so its pulls never expire. The seed
|
||||
// input above is UNTOUCHED (A's rotation-boundary law); this tag exists only in the save's ledger.
|
||||
const pullTag = (game && !currentRealSourced) ? '@d' + game.day : '';
|
||||
const pullPrefix = `${(currentShop && currentShop.id) ?? '?'}|${key}${pullTag}#`;
|
||||
dig.open({
|
||||
seed: binSeed((currentShop && currentShop.seed) || 1, key + dayTag), count: 16,
|
||||
shopName: (current && current.recipe && current.recipe.label) || (currentShop && currentShop.name),
|
||||
shop: currentShop,
|
||||
stockAdapter: currentAdapter, // [F2 §stock=real] real covers in the riffle (null → parody)
|
||||
gone: game ? game.pullsFor(pullPrefix) : undefined, // [R32] this bin's bought slots stay gone today
|
||||
// [F R8 — Lane C buy loop v0] bind the session wallet so pulling a sleeve deducts cash + banks it.
|
||||
// dig removes the pulled sleeve from the bin; onBuy returns false when broke (no state change).
|
||||
// [R30 — THE COLLECTION] a successful buy also records the find into game.collection (§30.1 entry
|
||||
// shape): a dig pull is a 'record' wherever it happens (an opshop bin is still a record crate);
|
||||
// sku = the pack item id when real/mint, else slotId = the bin's day-salted key + offer index.
|
||||
// [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,
|
||||
onBuy: wallet ? (o) => {
|
||||
const ok = wallet.buy(o);
|
||||
if (ok && game) game.recordFind(currentShop, {
|
||||
type: 'record',
|
||||
sku: o && o.item && o.item.id != null ? String(o.item.id) : undefined,
|
||||
slotId: o && o.item && o.item.id != null ? undefined : `${key}${dayTag}#${(o && o.i) ?? 0}`,
|
||||
title: o && (o.t || o.title), artist: o && (o.a || o.artist),
|
||||
price: (o && o.price) || 0,
|
||||
});
|
||||
if (ok && game) {
|
||||
const sku = o && o.item && o.item.id != null ? String(o.item.id) : null;
|
||||
game.recordFind(currentShop, {
|
||||
type: 'record',
|
||||
sku: sku ?? undefined,
|
||||
slotId: sku != null ? undefined : `${key}${dayTag}#${(o && o.i) ?? 0}`,
|
||||
title: o && (o.t || o.title), artist: o && (o.a || o.artist),
|
||||
price: (o && o.price) || 0,
|
||||
band: (o && o.band) || undefined,
|
||||
});
|
||||
game.recordPull(pullPrefix + (sku != null ? 'sku:' + sku : String((o && o.i) ?? 0)));
|
||||
}
|
||||
return ok;
|
||||
} : undefined,
|
||||
onClose: () => window.dispatchEvent(new CustomEvent('procity:digClose')),
|
||||
@ -224,6 +239,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
slotId: item.id != null ? undefined : `shelf#${(best.vStart | 0)}`,
|
||||
title: item.title, artist: item.artist,
|
||||
price: item.price || 20,
|
||||
band: item.price_band || undefined, // [R32] the shelf find carries its band too (§9.1 basis)
|
||||
});
|
||||
collapseBuyItem(mesh, best); // zero-area the bought quad in place
|
||||
flashToast(`SOLD — ${item.title || 'item'} · $${item.price || 20} · $${wallet.cash()} left`);
|
||||
@ -346,6 +362,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
// when: no game layer · day 1 (the day-1 convention) · a REAL-sourced crate (the godverse manifest
|
||||
// says sourcing:'real' — real stock never rotates, it's real). Mint crates and parody stock rotate.
|
||||
const realSourced = !!(shop.godverseShopId && sourcingOf && sourcingOf(shop.godverseShopId) === 'real');
|
||||
currentRealSourced = realSourced; // [R32] pull-ledger tagging reads this at dig-open time
|
||||
currentStockDay = (game && game.day > 1 && !realSourced) ? game.day : null;
|
||||
current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter,
|
||||
stockDay: currentStockDay,
|
||||
@ -471,6 +488,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
currentShop = null;
|
||||
currentAdapter = null;
|
||||
currentStockDay = null;
|
||||
currentRealSourced = false;
|
||||
banner.style.display = 'none';
|
||||
toast.style.display = 'none'; _toastT = 0;
|
||||
if (doorReturn) {
|
||||
@ -491,6 +509,9 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
get crewInfo() { return crewInfo; }, // [R12] { band, crowd } actually spawned — F's smokes assert on this
|
||||
get digActive() { return !!(dig && dig.active); }, // [F2] shell reads this to keep pointer-lock/Esc sane while riffling
|
||||
get sellActive() { return !!(sellCard && sellCard.active); }, // [R30 §9.4] same contract for the sell card
|
||||
// [R32 — honest-gate surface] the open bin's offers by identity (index/ask/band/sku); null when no
|
||||
// dig is open. The bible/scarcity gate asserts on THESE, not on how the crate looked.
|
||||
get digOffers() { return dig && dig.active ? dig.currentOffers() : null; },
|
||||
|
||||
// [Lane F R24 — the honest-gate surface, per the vacuous-gate law] What stock is in THIS room, BY
|
||||
// IDENTITY. R23's #7a could go green on "real covers render" while the room held the generic v2 pack
|
||||
|
||||
@ -38,17 +38,29 @@ function gradientEquirect(THREE) {
|
||||
return tex;
|
||||
}
|
||||
|
||||
export function createLighting(scene, plan, skins, { radius = 3, shadows = true } = {}) {
|
||||
export function createLighting(scene, plan, skins, { radius = 3, shadows = true, dawnSky = null } = {}) {
|
||||
const group = new THREE.Group();
|
||||
group.name = 'lighting';
|
||||
scene.add(group);
|
||||
|
||||
// sky dome — seeded photo skin, darkened at night via material colour
|
||||
// [R32 §31.6-3] `dawnSky` (option, shell passes null under ?classic=1): the default dome is a
|
||||
// golden-SUNSET photo, so DAWN read as dusk-orange (the R31 playtest's cosmetic #3). During the DAWN
|
||||
// segment only, the dome swaps to a cool morning skin, then back. A weather setSky() override
|
||||
// (rain/overcast) OWNS the dome outright — the dawn swap never fights it.
|
||||
const skyName = plan.sky || 'golden-arvo';
|
||||
const skyMat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.BackSide, depthWrite: false, fog: false });
|
||||
let externalSky = null; // set by setSky() (weather) — wins over base + dawn forever after
|
||||
let shownSky = skyName;
|
||||
skins.skyTex(skyName, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
|
||||
const dome = new THREE.Mesh(new THREE.SphereGeometry(650, 24, 14), skyMat);
|
||||
group.add(dome);
|
||||
function applySkyFor(segIdx) {
|
||||
const want = externalSky || (segIdx === 0 && dawnSky ? dawnSky : skyName);
|
||||
if (want === shownSky) return;
|
||||
shownSky = want;
|
||||
skins.skyTex(want, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
|
||||
}
|
||||
|
||||
const hemi = new THREE.HemisphereLight(0xd8e8ff, 0xa89878, 1.0);
|
||||
group.add(hemi);
|
||||
@ -76,6 +88,7 @@ export function createLighting(scene, plan, skins, { radius = 3, shadows = true
|
||||
|
||||
function apply(i) {
|
||||
const [, sunI, sunC, hSky, hGnd, hI, skyTint, fogC, exposure, isNight] = SEG[i];
|
||||
applySkyFor(i); // [R32] dawn dome swap (no-op under classic / weather override / other segments)
|
||||
sun.intensity = sunI; sun.color.set(sunC);
|
||||
hemi.intensity = hI; hemi.color.set(hSky); hemi.groundColor.set(hGnd);
|
||||
_skyTint.set(skyTint); skyMat.color.copy(_skyTint);
|
||||
@ -95,7 +108,11 @@ export function createLighting(scene, plan, skins, { radius = 3, shadows = true
|
||||
|
||||
// Swap the sky-dome skin at runtime (weather.js uses this for a matching rainy/overcast sky).
|
||||
// The per-segment day/night colour tint (skyMat.color in apply) still rides on top of the new map.
|
||||
function setSky(name) { skins.skyTex(name, (t) => { skyMat.map = t; skyMat.needsUpdate = true; }); }
|
||||
// [R32] a caller-set sky is an OVERRIDE: it wins over the base skin and the dawn swap from then on.
|
||||
function setSky(name) {
|
||||
externalSky = name; shownSky = name;
|
||||
skins.skyTex(name, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
|
||||
}
|
||||
|
||||
const _sunOff = new THREE.Vector3();
|
||||
function update(dt, playerPos) {
|
||||
|
||||
@ -42,6 +42,14 @@ export function validateSave(obj) {
|
||||
if (!Array.isArray(obj.collection)) return { ok: false, why: 'collection not an array' };
|
||||
for (let i = 0; i < obj.collection.length; i++)
|
||||
if (!validEntry(obj.collection[i])) return { ok: false, why: `collection[${i}] malformed` };
|
||||
// [R32 scarcity] `pulls` is OPTIONAL (absent on every alpha save — those stay valid forever); when
|
||||
// present it must be an array of strings (exact-and-loud like everything else). An old loader that
|
||||
// predates this field simply ignores and drops it on its next save — degraded, never rejected.
|
||||
if (obj.pulls != null) {
|
||||
if (!Array.isArray(obj.pulls)) return { ok: false, why: 'pulls not an array' };
|
||||
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` };
|
||||
}
|
||||
return { ok: true, state: obj };
|
||||
}
|
||||
|
||||
@ -58,12 +66,22 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
|
||||
let day = 1;
|
||||
let cash = Math.max(0, Math.floor(+startCash || 0));
|
||||
let collection = [];
|
||||
// [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.
|
||||
let pulls = [];
|
||||
const PULLS_CAP = 600; // FIFO backstop — a save stays tiny (delta law)
|
||||
const PULL_DAY_RE = /@d(\d+)#/;
|
||||
|
||||
// ── storage (fail-soft: a blocked/full localStorage warns and plays on in memory) ────────────
|
||||
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
|
||||
return p;
|
||||
}
|
||||
function save() {
|
||||
if (!store) return false;
|
||||
const payload = { schema: SAVE_SCHEMA, day, cash, town: townKey, collection, savedAt: Date.now() };
|
||||
try { store.setItem(SAVE_KEY, JSON.stringify(payload)); return true; }
|
||||
try { store.setItem(SAVE_KEY, JSON.stringify(payload())); return true; }
|
||||
catch (e) { console.warn('[procity save] write failed (playing on in memory):', e && e.message || e); return false; }
|
||||
}
|
||||
|
||||
@ -71,6 +89,15 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
|
||||
day = state.day;
|
||||
cash = Math.floor(state.cash);
|
||||
collection = state.collection.slice();
|
||||
pulls = Array.isArray(state.pulls) ? state.pulls.slice() : [];
|
||||
prunePulls();
|
||||
}
|
||||
|
||||
// Day-tagged pulls from another day can never match a bin key again (the consumer embeds the
|
||||
// CURRENT day in rotating bins' keys) — dead weight, dropped. Untagged pulls are real-sourced
|
||||
// (never rotate) and persist: sold means gone, the tier-2 spirit at tier 1.
|
||||
function prunePulls() {
|
||||
pulls = pulls.filter((k) => { const m = PULL_DAY_RE.exec(k); return !m || +m[1] === day; });
|
||||
}
|
||||
|
||||
function load() {
|
||||
@ -137,13 +164,14 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
|
||||
// save (noted vs the brief's literal order in §30.1): disk always carries the morning you woke into.
|
||||
sleep() {
|
||||
day += 1;
|
||||
prunePulls(); // [R32] yesterday's day-tagged pulls are unreachable — drop them
|
||||
if (onDay) { try { onDay(day); } catch (e) { console.warn('[procity save] onDay hook threw:', e); } }
|
||||
save();
|
||||
return day;
|
||||
},
|
||||
|
||||
export() {
|
||||
return JSON.stringify({ schema: SAVE_SCHEMA, day, cash, town: townKey, collection, savedAt: Date.now() });
|
||||
return JSON.stringify(payload());
|
||||
},
|
||||
|
||||
import(json) { // loud reject leaves CURRENT state untouched
|
||||
@ -175,6 +203,7 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
|
||||
else e.slotId = String((info && info.slotId) || 'slot#0');
|
||||
if (info && info.title) e.title = String(info.title);
|
||||
if (info && info.artist) e.artist = String(info.artist);
|
||||
if (info && info.band) e.band = String(info.band); // [R32] §9.1 sell basis — optional, alpha entries lack it
|
||||
collection.push(e);
|
||||
notify();
|
||||
return e;
|
||||
@ -188,6 +217,24 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
|
||||
notify();
|
||||
return true;
|
||||
},
|
||||
|
||||
// ── the pull seam (R32 scarcity — interior_mode records a bought dig slot; SELLING an item does
|
||||
// NOT restock the bin, which is why this list is independent of the collection) ──────────────
|
||||
recordPull(key) {
|
||||
const k = String(key || '');
|
||||
if (!k || pulls.includes(k)) return false;
|
||||
pulls.push(k);
|
||||
if (pulls.length > PULLS_CAP) pulls.splice(0, pulls.length - PULLS_CAP);
|
||||
return true;
|
||||
},
|
||||
|
||||
// The consumer's read: every recorded slot for one bin, as the Set dig.open({gone}) takes —
|
||||
// suffixes only ('sku:<id>' | '<offer index>'), prefix = `${shopId}|${binKey}${tag}#`.
|
||||
pullsFor(prefix) {
|
||||
const out = new Set();
|
||||
for (const k of pulls) if (k.startsWith(prefix)) out.add(k.slice(prefix.length));
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
||||
// boot: adopt an existing save (fresh start on absence/rejection), then arm the unload save-point.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user