Compare commits

...

4 Commits

Author SHA1 Message Date
type-two
9aae167d64 Fable (integrator): ROUND 32 CLOSED — v7.0-beta wave 1 all green; smoke_bible + the routing amendment
qa.sh --strict --matrix on the final tree: 7 passed · 0 failed · 0 warn · 0 skipped, MATRIX 10
towns green. New STRICT gate smoke_bible: band law over EVERY bin off the digOffers surface
(80 offers/5 bins), the buy-everything ledger runs on THE GEM'S BIN so the finder-margin leg is
non-vacuous (bin1: 2 gems, grail asking $19 vs keeper $30; ledger closes to the cent — Σask
$333 → Σoffer $135, gems alone +$21, every non-gem strictly negative), scarcity measured
(emptied bin 0/16 on re-open AND across a full reload; 16 pulls in the save) with the ?game=0
negative control re-opening 16/16 (the detector discriminates). sell_routing amended from the
alpha inequality (credited < paid) to the exact formula (credited == min(basis−1, basis//2),
basis = bandLow-or-pricePaid) — the fence moved from the receipt to the band, which is what
charter law #2 always said; it passed on a banded standard item (+$4 on basis $8). _at_bin
grows an idx param (back-compatible). Money shots in docs/shots/v7_beta/ (the guide card + the
crate at the counter). LANE_F_NOTES §32 (incl. the JING5 fresh-checkout trap: gitignored
web/assets/models synced from m3ultra — also un-warned the validator's 15 cross-pack checks).
README manual: the guide/gem/scarcity rows. Remaining beta charter queued in the round doc:
wantlist + rumors · travel costs a day · Esc-in-person · then GodBay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:27:19 +10:00
type-two
bb30767b62 Lane B R32 (§31.6 queue 1–3): the crate lives in shops · cluster-pose spawn · dawn reads dawn
hud.js: #pc-game rides document.body (position:fixed, z 30) instead of the mode-hidden street
root — day/cash/CRATE/SLEEP are YOURS inside a shop too (the playtest's 'crate at the counter').
C toggles the crate ANYWHERE; the surface yields to the dig/sell cards and the map (they own
the screen). tickGame() exported so the interior/map frame branches keep it live (buys update
the count while you shop); dispose removes the body-mounted wrap.
index.html: the frozen spawn (-96,9,east) was tuned for the synthetic fixture — on real towns
it faced dirt (§31.6-1). Non-classic boots now spawn ON the main-street footpath door-line
(block kind 'mainstreet', both generators; every-door fallback) at the door nearest the shop
cluster's heart, facing the LONGER run of shopfronts down the strip's dominant axis. Pure plan
math, zero draws — no stream moves; ?classic=1 keeps the frozen pose forever.
lighting.js: dawnSky opt (shell passes high-cirrus, null under classic) — the DAWN segment
swaps the dome off the golden-SUNSET default that read dusk-orange (§31.6-3), and swaps back.
setSky() is now an explicit override: weather's rain/overcast dome always wins over base+dawn.
2026-07-20 18:26:56 +10:00
type-two
75e5e06288 Lane F R32 (v7.0-beta wave 1): per-day scarcity — pulls ride procity-save/1, the bin remembers
save.js: optional pulls: string[] on the SAME schema (validated exact-and-loud when present;
absent on every alpha save — old savers drop it silently, never reject; payload omits it when
empty so the alpha byte shape survives). recordPull/pullsFor seams; pruned on sleep/import —
day-tagged keys from other days are unreachable by construction (the consumer embeds the
current day), untagged keys are real-sourced and KEPT (sold means gone, tier-2 spirit at
tier 1). Cap 600 FIFO (delta law: saves stay tiny). Collection entries gain optional band
(recordFind stamps dig + shelf buys) — C's §9.1 sell basis reads it.
interior_mode.js: 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 — a day-1 pull must not survive into day 2's identical-looking untagged key space),
'' for real-sourced (their pulls never expire). The seed input to binSeed is UNTOUCHED — A's
rotation boundary holds. Selling an item does NOT restock the bin (pulls are independent of the
collection — the leak the §30.6 filing predicted). digOffers getter = the honest-gate surface
(index/ask/band/sku) so the bible gate asserts identity, not looks.
2026-07-20 18:26:36 +10:00
type-two
f9517f14ab Lane C R32 (v7.0-beta wave 1): THE BIBLE — bands derived, gems seeded, the cards get the guide stamp
bible.js: band ranges MIRRORED from E's pipeline thresholds (record 8/25/60 · book 5/15/40 ·
toy 6/20/50) — derived in JS off the price_band labels the packs already carry, NO pack re-emit
(charter risk #3 never opens). guideText renders a RANGE ('guide $8–24' / '$60+'), never a
verdict — the card never says gem; spotting asking-under-guide is the player's skill.
dig.js: parody sleeves roll seeded bands (25/45/22/8) + a 10% gem on collector/grail only,
asking strictly under the keeper's own bandLow offer (collector $2–9 vs $12 · grail $5–20 vs
$30) — margin by construction; fixed 9-draw stream per item; rare ≡ grail (matches real packs).
Real offers carry band off price_band — and real asking always sits INSIDE its band (the band
derives FROM the price), so with the §9.1 basis swap every real round trip stays strictly
negative: the no-pump gate holds untouched, structurally. open({gone}) omits pulled slots AFTER
the draw (R27 pick-then-omit, zero collateral; o.i survives filtering — identity preserved).
currentOffers() honest surface for F's gate. sell.js: the §9.1 basis swap EXECUTED exactly as
pre-ratified — sellOffer(sellBasis(entry)) = min(bandLow−1, max(1, floor(bandLow·0.5))) when
banded; alpha-era entries (no band) keep the pricePaid basis and sell exactly as before. The
sell card carries the guide line too.
2026-07-20 18:26:15 +10:00
15 changed files with 581 additions and 55 deletions

View File

@ -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 ## 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.** **Fable's wave-0 playtest found the one alpha-blocker: the dig was default-OFF on a plain boot.**

View File

@ -23,7 +23,8 @@ Click **START** to lock the mouse. Then:
| **W A S D** + mouse | walk / look (first person) | | **W A S D** + mouse | walk / look (first person) |
| walk into a shop door | enter the interior (open shops only — check the hours tooltip) | | 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** (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 $824"). An item **asking under its band is a gem** — buy it, sell it to a keeper, pocket the margin. Bought slots stay gone for the day (the bin remembers) |
| click a book spine / toy box | pull-and-buy card (real price stickers in godverse shops) | | click a book spine / toy box | pull-and-buy card (real price stickers in godverse shops) |
| walk out the door / **Esc** | leave the shop (Esc also frees the mouse anywhere) | | walk out the door / **Esc** | leave the shop (Esc also frees the mouse anywhere) |
| **M** | 2D town map (street only) | | **M** | 2D town map (street only) |

View File

@ -1754,3 +1754,62 @@ Fable's preview holds :8130, untouched):
5. Automation-only, unverified in person: **dig Esc-close under synthetic events**. 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 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. 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 13
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 $29 vs $12 · grail $520 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 $824` / `$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(bandLow1, 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(basis1, 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.

View 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 $2559"), 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 13:** 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 $824" 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 789 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

View File

@ -1633,16 +1633,17 @@ def _game_state(pg):
exNorm: JSON.stringify(ex), day: g.day, cash: g.cash, n: g.collection.length }; }""") exNorm: JSON.stringify(ex), day: g.day, cash: g.cash, n: g.collection.length }; }""")
def _at_bin(pg): def _at_bin(pg, idx=0):
"""Stand at the first bin of the open room, aimed at it (smoke_buy's proven setup).""" """Stand at the idx-th bin of the open room, aimed at it (smoke_buy's proven setup)."""
return pg.evaluate("""() => { return pg.evaluate("""(idx) => {
const P = window.PROCITY; const P = window.PROCITY;
const bins = []; P.interiorMode.current.group.traverse(o => { if (o.userData && o.userData.kind === 'bin') bins.push(o); }); 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' }; 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(); 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): def _dig_buy(pg):
@ -1757,7 +1758,7 @@ def smoke_sell_routing(p):
FAIL(f"routing: dig buy wallet/collection delta wrong — {rb}"); return FAIL(f"routing: dig buy wallet/collection delta wrong — {rb}"); return
ent = pg.evaluate("""() => { const g = window.PROCITY.game; ent = pg.evaluate("""() => { const g = window.PROCITY.game;
const e = g.collection[g.collection.length - 1]; 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: 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})") OK(f"routing: entry shape honest — type=record, sku|slotId set, pricePaid == debit (${paid})")
else: else:
@ -1778,10 +1779,21 @@ def smoke_sell_routing(p):
hudCash: cashEl ? cashEl.textContent : null, hudCash: cashEl ? cashEl.textContent : null,
hudCrate: crateBtn ? crateBtn.textContent : null }; }""") hudCrate: crateBtn ? crateBtn.textContent : null }; }""")
credited = after['cash'] - cash_before credited = after['cash'] - cash_before
if rs.get('sold') == 1 and after['n'] == n_before - 1 and 0 < credited < paid: # [R32 amendment] the no-pump law's beta form: the offer is EXACTLY min(basis1, max(1, basis//2))
OK(f"routing: SELL — +${credited} credited (< ${paid} paid, no-pump by construction), collection {n_before}{after['n']}") # 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(basis1, 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: 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'] is not None:
if after['hudCash'] == str(after['cash']) and after['hudCrate'] == f"CRATE {after['n']}": 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']}") OK(f"routing: B's HUD follows — $" + after['hudCash'] + f" · {after['hudCrate']}")
@ -1983,6 +1995,147 @@ def smoke_nopump(p):
b.close() 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(low1,
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): def smoke_classic_purity(p):
"""R30 gate (c) — ?classic=1 and ?game=0: ZERO Storage-prototype calls (instrumented before any """R30 gate (c) — ?classic=1 and ?game=0: ZERO Storage-prototype calls (instrumented before any
page script), zero game DOM (B's ids), game null. Default boot is the POSITIVE control (the page script), zero game DOM (B's ids), game null. Default boot is the POSITIVE control (the
@ -2136,6 +2289,7 @@ def main():
smoke_nopump(p) # R30 gate b — 5 real round trips monotonic loss + C's negative control 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_classic_purity(p) # R30 gate c — instrumented Storage zero + zero game DOM + positive control
smoke_rotation(p) # R30 gate d — vertex-data rotation determinism (the wave-3 binding) smoke_rotation(p) # R30 gate d — vertex-data rotation determinism (the wave-3 binding)
smoke_bible(p) # R32 — THE BIBLE (bands · gems · ledger) + per-day scarcity + ?game=0 control
smoke_tier2(p) # R27 — the kill-the-server gate (charter law #1) smoke_tier2(p) # R27 — the kill-the-server gate (charter law #1)
smoke_real_crate(p) # R24 — THE REAL CRATE (per-shop atlas identity · soft fall · no-op off the path) smoke_real_crate(p) # R24 — THE REAL CRATE (per-shop atlas identity · soft fall · no-op off the path)
finally: finally:

View File

@ -168,7 +168,8 @@ const skins = createSkinLibrary();
const ground = buildGround(plan, skins); const ground = buildGround(plan, skins);
scene.add(ground.group); 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); lighting.attachRenderer(renderer);
const ctx = { skins, citySeed: plan.citySeed, plan, night: lighting.isNight(), radius: RADIUS }; 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 venueQueues = gigState ? new Map() : null; // venueShopId → VenueQueue
const queueCountOf = (id) => { const q = venueQueues && venueQueues.get(id); return q ? q.count() : 0; }; 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 // [R32 §31.6-1 — THE CLUSTER-POSE SPAWN] The frozen pose (-96, 9, looking east) was tuned for the
player.teleport(-96, 9, -Math.PI / 2); // 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); chunks.warmup(player.position);
lighting.update(0.0001, 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 renderer.info.reset(); // zero the counters, then let the composer's passes accumulate this frame
composer.render(); composer.render();
} else if (MODE === 'interior') { } 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 if (interiorMode.update(dt, player.isLocked ? keys : EMPTY)) leaveShop(); // [Lane F] walk to the door → back to street
} else if (MODE === 'map') { } else if (MODE === 'map') {
hud.tickGame(); // [R32] map mode: the same check hides the surface (the map owns the screen)
camera.getWorldDirection(_fwd); camera.getWorldDirection(_fwd);
minimap.draw(player.position, { x: _fwd.x, z: _fwd.z }); minimap.draw(player.position, { x: _fwd.x, z: _fwd.z });
} }

58
web/js/interiors/bible.js Normal file
View 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 $2559"), 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(t11).
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 $2559" / "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;
}

View File

@ -10,6 +10,7 @@
// runs unless the shell/test-page opens it, so `?dig=0` is byte-identical to v1. // runs unless the shell/test-page opens it, so `?dig=0` is byte-identical to v1.
import { mulberry32, xmur3 } from '../core/prng.js'; 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 MAXFLIP = 1.95, BACK = -0.12;
const SLEEVE = [0.31, 0.31, 0.0060]; // w, h, thickness (packed a touch chunkier than vinyl) 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']; const CONDS = ['VG+', 'VG', 'G+', 'NM', 'EX'];
// Deterministic sleeve list for a bin — the "existing procedural stock" as riffle-able offers. // 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) { function proceduralSleeves(seed, n) {
const r = mulberry32(seed >>> 0); const r = mulberry32(seed >>> 0);
const out = []; const out = [];
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const g = GENRE[(r() * GENRE.length) | 0]; const g = GENRE[(r() * GENRE.length) | 0];
out.push({ const a = ARTISTS[(r() * ARTISTS.length) | 0];
i, kind: 'record', const t = TITLES[(r() * TITLES.length) | 0];
a: ARTISTS[(r() * ARTISTS.length) | 0], const y = 1971 + ((r() * 28) | 0);
t: TITLES[(r() * TITLES.length) | 0], const cond = CONDS[(r() * CONDS.length) | 0];
y: 1971 + ((r() * 28) | 0), const band = PARODY_BAND(r());
s: g[0], color: g[1], const [lo, hi] = BANDS.record[band];
cond: CONDS[(r() * CONDS.length) | 0], const priceRoll = r(), gemRoll = r(), gemPriceRoll = r(); // always drawn — fixed stream shape
price: 2 + ((r() * 28) | 0), const gem = gemRoll < 0.10 && (band === 'collector' || band === 'grail');
rare: r() < 0.12, // gem asking sits STRICTLY under the keeper's own bandLow offer (min(lo1, floor(lo/2)) — sell.js),
}); // so a spotted gem always nets margin: collector asks $29 vs offer $12 · grail $520 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; return out;
} }
@ -250,6 +263,11 @@ export function createDig(THREE, renderer) {
const line = [o.y, o.s].filter(Boolean).join(' '); // "1974 SOUL" | "SOUL" | "" — never " · " const line = [o.y, o.s].filter(Boolean).join(' '); // "1974 SOUL" | "SOUL" | "" — never " · "
if (line) stamps.push(`<span class="tag">${esc(line)}</span>`); if (line) stamps.push(`<span class="tag">${esc(line)}</span>`);
if (o.rare) stamps.push(`<span class="star">★ ${o.real ? 'GRAIL' : 'RARE'}</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>` panel.innerHTML = `<div class="x">✕</div>`
+ `<div class="sticker">$${esc(o.price)}</div>` + `<div class="sticker">$${esc(o.price)}</div>`
+ `<h3>${esc(o.a)}</h3>` + `<h3>${esc(o.a)}</h3>`
@ -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 // 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 // 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. // 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 rr = mulberry32(seed >>> 0), out = [];
const gone = pack.gone instanceof Set ? pack.gone const gone = pack.gone instanceof Set ? pack.gone
: (Array.isArray(pack.gone) ? new Set(pack.gone) : null); : (Array.isArray(pack.gone) ? new Set(pack.gone) : null);
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const it = pack.items[(rr() * pack.items.length) | 0]; 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 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 // 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, // 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. // 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, out.push({ real: true, pack, item: it, a: it.artist, t: it.title, price: it.price,
s: band === 'GRAIL' ? '' : band, y: '', s: band === 'GRAIL' ? '' : band, y: '',
cond: it.condition || '', sleeveCond: it.sleeve_cond || '', 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' }); rare: it.price_band === 'grail' });
} }
return out; 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 // `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. // 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 = {}) { function open(opts = {}) {
active = true; active = true;
onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null; onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null;
emitters = opts.emitters || null; emitters = opts.emitters || null;
const count = opts.count || 16; const count = opts.count || 16;
const gone = opts.gone instanceof Set && opts.gone.size ? opts.gone : null;
// riffle items: real pack (?stock=real) via the stockAdapter seam, else seeded procedural sleeves. // riffle items: real pack (?stock=real) via the stockAdapter seam, else seeded procedural sleeves.
const src = opts.stockAdapter && opts.stockAdapter(opts.shop, 'sleeve'); 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 + ' — ' : ''); curLbl.textContent = (opts.shopName ? opts.shopName + ' — ' : '');
[hint, curLbl, outBtn].forEach(e => e.style.display = 'block'); [hint, curLbl, outBtn].forEach(e => e.style.display = 'block');
buildStack(offers); buildStack(offers);
@ -411,7 +439,16 @@ export function createDig(THREE, renderer) {
[css, hint, curLbl, panel, outBtn, toast].forEach(e => e.remove()); [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. // A stable per-bin seed from the shop + a bin index, so a given bin riffles the same order every time.

View File

@ -13,15 +13,20 @@
// (?classic=1 / ?game=0) → the consumer never constructs/opens this, and open() itself returns false // (?classic=1 / ?game=0) → the consumer never constructs/opens this, and open() itself returns false
// on an empty sellable list — zero errors, zero DOM. // 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_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) 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 // The offer (§9.1). [R32 — the pre-ratified beta swap, executed] Basis = bandLow when the entry carries
// bands until beta; beta swaps the basis to bandLow, the clamp survives untouched). The min(p1, …) // a band the BIBLE knows (sellBasis, bible.js), else the alpha basis (pricePaid) — so alpha-era finds
// clamp is the law: no retune of SELL_MULT can ever push an offer to break even. p=1 → $0 (the SELL // sell exactly as they always did. THE CLAMP SHAPE IS UNTOUCHED: the offer sits strictly below the
// button disables — the keeper won't buy what he can't sticker). // BASIS for every basis ≥ 1, which with bandLow as basis is the no-pump law's beta form — "every sell
export function sellOffer(pricePaid) { // price < the buy-side guide band, structurally" (charter law #2). A gem (bought UNDER band) nets its
const p = Math.floor(+pricePaid || 0); // 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; if (p <= 0) return 0;
return Math.min(p - 1, Math.max(1, Math.floor(p * SELL_MULT))); return Math.min(p - 1, Math.max(1, Math.floor(p * SELL_MULT)));
} }
@ -92,15 +97,16 @@ export function createSell() {
function render() { function render() {
const it = items[idx]; const it = items[idx];
if (!it) { close(); return; } 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 head = labelOf(it) || titleOf(it);
const ttl = labelOf(it) ? titleOf(it) : ''; const ttl = labelOf(it) ? titleOf(it) : '';
const paid = it.pricePaid != null ? it.pricePaid : it.price; 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>` panel.innerHTML = `<div class="x">✕</div>`
+ `<div class="sticker">$${esc(offer)}</div>` + `<div class="sticker">$${esc(offer)}</div>`
+ `<h3>${esc(head)}</h3>` + `<h3>${esc(head)}</h3>`
+ (ttl ? `<div class="ttl">“${esc(ttl)}”</div>` : '') + (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>` : '') + (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>`; + `<button ${offer <= 0 ? 'disabled' : ''}>${offer <= 0 ? 'NOT WORTH BUYING' : 'SELL — $' + esc(offer)}</button>`;
panel.style.display = 'block'; panel.style.display = 'block';

View File

@ -27,7 +27,7 @@ const CSS = `
#pc-townpick:hover{opacity:1} #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{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-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{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 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} #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> 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-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>`; <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'), const elDay = bar.querySelector('#pc-day'), elCash = bar.querySelector('#pc-cash'),
btnCrate = bar.querySelector('#pc-crate-btn'), btnSleep = bar.querySelector('#pc-sleep-btn'); btnCrate = bar.querySelector('#pc-crate-btn'), btnSleep = bar.querySelector('#pc-sleep-btn');
let shownDay = null, shownCash = null, shownCount = null, open = false; let shownDay = null, shownCash = null, shownCount = null, open = false;
@ -218,18 +222,25 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
shownCount = col.length; btnCrate.textContent = `CRATE ${shownCount}`; shownCount = col.length; btnCrate.textContent = `CRATE ${shownCount}`;
if (open) rebuildList(); if (open) rebuildList();
} }
// [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(); 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; if (e.code !== 'KeyC') return;
const P = window.PROCITY; const P = window.PROCITY, im = P && P.interiorMode;
if (P && P.mode === 'interior') return; if (P && P.mode === 'map') return;
if (im && (im.digActive || im.sellActive)) return; // the cards own the keys while open
setOpen(!open); setOpen(!open);
}; };
addEventListener('keydown', onGameKey); addEventListener('keydown', onGameKey);
const help = root.querySelector('#pc-help'); const help = root.querySelector('#pc-help');
if (help) help.textContent += ' · C crate'; 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(); const ray = new THREE.Raycaster();
@ -318,9 +329,14 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
function dispose() { function dispose() {
renderer.domElement.removeEventListener('click', onClick); renderer.domElement.removeEventListener('click', onClick);
if (onGameKey) removeEventListener('keydown', onGameKey); if (onGameKey) removeEventListener('keydown', onGameKey);
if (gameUI && gameUI.wrap) gameUI.wrap.remove(); // [R32] the game surface lives on document.body now
root.remove(); 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 }; getFps: () => fpsShown };
} }

View File

@ -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 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: 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) // 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 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 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) 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 // [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). // (currentStockDay is null for no-game / day-1 / REAL-sourced crates — those riffle exactly as pre-v7).
const dayTag = currentStockDay != null ? '@d' + currentStockDay : ''; 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({ dig.open({
seed: binSeed((currentShop && currentShop.seed) || 1, key + dayTag), count: 16, seed: binSeed((currentShop && currentShop.seed) || 1, key + dayTag), count: 16,
shopName: (current && current.recipe && current.recipe.label) || (currentShop && currentShop.name), shopName: (current && current.recipe && current.recipe.label) || (currentShop && currentShop.name),
shop: currentShop, shop: currentShop,
stockAdapter: currentAdapter, // [F2 §stock=real] real covers in the riffle (null → parody) 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. // [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). // 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 // [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); // 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. // 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, getCash: wallet ? () => wallet.cash() : undefined,
onBuy: wallet ? (o) => { onBuy: wallet ? (o) => {
const ok = wallet.buy(o); const ok = wallet.buy(o);
if (ok && game) game.recordFind(currentShop, { if (ok && game) {
type: 'record', const sku = o && o.item && o.item.id != null ? String(o.item.id) : null;
sku: o && o.item && o.item.id != null ? String(o.item.id) : undefined, game.recordFind(currentShop, {
slotId: o && o.item && o.item.id != null ? undefined : `${key}${dayTag}#${(o && o.i) ?? 0}`, type: 'record',
title: o && (o.t || o.title), artist: o && (o.a || o.artist), sku: sku ?? undefined,
price: (o && o.price) || 0, 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; return ok;
} : undefined, } : undefined,
onClose: () => window.dispatchEvent(new CustomEvent('procity:digClose')), 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)}`, slotId: item.id != null ? undefined : `shelf#${(best.vStart | 0)}`,
title: item.title, artist: item.artist, title: item.title, artist: item.artist,
price: item.price || 20, 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 collapseBuyItem(mesh, best); // zero-area the bought quad in place
flashToast(`SOLD — ${item.title || 'item'} · $${item.price || 20} · $${wallet.cash()} left`); 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 // 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. // 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'); 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; currentStockDay = (game && game.day > 1 && !realSourced) ? game.day : null;
current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter, current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter,
stockDay: currentStockDay, stockDay: currentStockDay,
@ -471,6 +488,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
currentShop = null; currentShop = null;
currentAdapter = null; currentAdapter = null;
currentStockDay = null; currentStockDay = null;
currentRealSourced = false;
banner.style.display = 'none'; banner.style.display = 'none';
toast.style.display = 'none'; _toastT = 0; toast.style.display = 'none'; _toastT = 0;
if (doorReturn) { 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 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 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 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 // [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 // IDENTITY. R23's #7a could go green on "real covers render" while the room held the generic v2 pack

View File

@ -38,17 +38,29 @@ function gradientEquirect(THREE) {
return tex; 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(); const group = new THREE.Group();
group.name = 'lighting'; group.name = 'lighting';
scene.add(group); scene.add(group);
// sky dome — seeded photo skin, darkened at night via material colour // 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 skyName = plan.sky || 'golden-arvo';
const skyMat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.BackSide, depthWrite: false, fog: false }); 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; }); skins.skyTex(skyName, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
const dome = new THREE.Mesh(new THREE.SphereGeometry(650, 24, 14), skyMat); const dome = new THREE.Mesh(new THREE.SphereGeometry(650, 24, 14), skyMat);
group.add(dome); 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); const hemi = new THREE.HemisphereLight(0xd8e8ff, 0xa89878, 1.0);
group.add(hemi); group.add(hemi);
@ -76,6 +88,7 @@ export function createLighting(scene, plan, skins, { radius = 3, shadows = true
function apply(i) { function apply(i) {
const [, sunI, sunC, hSky, hGnd, hI, skyTint, fogC, exposure, isNight] = SEG[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); sun.intensity = sunI; sun.color.set(sunC);
hemi.intensity = hI; hemi.color.set(hSky); hemi.groundColor.set(hGnd); hemi.intensity = hI; hemi.color.set(hSky); hemi.groundColor.set(hGnd);
_skyTint.set(skyTint); skyMat.color.copy(_skyTint); _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). // 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. // 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(); const _sunOff = new THREE.Vector3();
function update(dt, playerPos) { function update(dt, playerPos) {

View File

@ -42,6 +42,14 @@ export function validateSave(obj) {
if (!Array.isArray(obj.collection)) return { ok: false, why: 'collection not an array' }; if (!Array.isArray(obj.collection)) return { ok: false, why: 'collection not an array' };
for (let i = 0; i < obj.collection.length; i++) for (let i = 0; i < obj.collection.length; i++)
if (!validEntry(obj.collection[i])) return { ok: false, why: `collection[${i}] malformed` }; 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 }; return { ok: true, state: obj };
} }
@ -58,12 +66,22 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
let day = 1; let day = 1;
let cash = Math.max(0, Math.floor(+startCash || 0)); let cash = Math.max(0, Math.floor(+startCash || 0));
let collection = []; let collection = [];
// [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) ──────────── // ── 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() { function save() {
if (!store) return false; 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; } 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; day = state.day;
cash = Math.floor(state.cash); cash = Math.floor(state.cash);
collection = state.collection.slice(); collection = state.collection.slice();
pulls = Array.isArray(state.pulls) ? state.pulls.slice() : [];
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() { 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. // save (noted vs the brief's literal order in §30.1): disk always carries the morning you woke into.
sleep() { sleep() {
day += 1; 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); } } if (onDay) { try { onDay(day); } catch (e) { console.warn('[procity save] onDay hook threw:', e); } }
save(); save();
return day; return day;
}, },
export() { 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 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'); else e.slotId = String((info && info.slotId) || 'slot#0');
if (info && info.title) e.title = String(info.title); if (info && info.title) e.title = String(info.title);
if (info && info.artist) e.artist = String(info.artist); 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); collection.push(e);
notify(); notify();
return e; return e;
@ -188,6 +217,24 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
notify(); notify();
return true; 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. // boot: adopt an existing save (fresh start on absence/rejection), then arm the unload save-point.