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
**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) |
| 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 $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) |
| walk out the door / **Esc** | leave the shop (Esc also frees the mouse anywhere) |
| **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**.
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 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 }; }""")
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(basis1, 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(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:
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(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):
"""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:

View File

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

View File

@ -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(p1, …)
// 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';

View File

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

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 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

View File

@ -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) {

View File

@ -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.