Compare commits
19 Commits
v7.0-alpha
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ef751e1e3 | ||
|
|
25cef5983e | ||
|
|
55b984e0b0 | ||
|
|
c9e233a7e5 | ||
|
|
e528c92fed | ||
|
|
b048f51640 | ||
|
|
776b10b599 | ||
|
|
7b26e45fa0 | ||
|
|
37773bc81d | ||
|
|
ce71908bb5 | ||
|
|
9aae167d64 | ||
|
|
bb30767b62 | ||
|
|
75e5e06288 | ||
|
|
f9517f14ab | ||
|
|
046668e5af | ||
|
|
b3394027be | ||
|
|
3c5d0a1e95 | ||
|
|
67913f8b66 | ||
|
|
bb5f36f1e0 |
@ -4,6 +4,64 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Round 35 (v7.0 close, wave 2) — THE FIRST FIVE MINUTES (Fable, solo, JING5)
|
||||||
|
|
||||||
|
A fresh game arrives HUNTING: one seeded artist want (`rng(citySeed,'firstwant',0)` × the
|
||||||
|
parody roster) named on the splash with the pocket money ("You're hunting HILLS HOIST — $191
|
||||||
|
in your pocket"), pointing the rumors and the tour at the same target. Three toast-beats walk
|
||||||
|
the hustle off existing events (walk-in → digOpen → first find → first sale-or-listing, then
|
||||||
|
the listener removes itself). Returning saves get no tutorial; ?game=0/classic never reach it.
|
||||||
|
#pc-toast body-mounted so beats and morning papers surface inside shops too. STRICT
|
||||||
|
`smoke_first_five` green (roster want + splash · real-path beats · returning/?game=0 controls
|
||||||
|
· cross-browser seed determinism). Full record: LANE_F_NOTES §35.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Round 34 (v7.0 close, wave 1) — GODBAY: the offline auction (Fable, solo, JING5)
|
||||||
|
|
||||||
|
⚖ LIST from the crate (mail-in consignment) → `listings` on procity-save/1 → resolves the day
|
||||||
|
after, wherever the day moves (sleep, travel, imports — adopt() and sleep() share the
|
||||||
|
resolver). THE ONE-HAMMER LAW: identity-seeded (never day-seeded) — a passed-in item re-lists
|
||||||
|
to the same result forever, no retry farm. 15% pass · uniform-across-band hammer (grail 2×low)
|
||||||
|
· 12% war ×1.5–2 · 12% fees, net floor $1; EV ≈ 0.8×mid so random flips bleed and only finds
|
||||||
|
profit; keeper = floor, auction = bounded ceiling. The morning paper rides the wake/arrival
|
||||||
|
toast ("⚖ SOLD 'Cane Toad Blues' — hammer $22, $19 after fees"). Band table imported from C's
|
||||||
|
bible.js (one authority), PRNG from core. STRICT `smoke_godbay` green (real-DOM consign ·
|
||||||
|
byte-identical double-sleep · bounded honest resolution · travel hammers · game=0 control).
|
||||||
|
Full record: LANE_F_NOTES §34.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Round 33 (v7.0-beta wave 2) — THE HUNT: wantlist · rumor · travel (Fable, solo, JING5)
|
||||||
|
|
||||||
|
☆ WANT on the pull card → `wants` on procity-save/1 (optional, capped, deduped); buying a
|
||||||
|
matching item clears the hunt; THE WANTLIST section in the crate panel. The keeper gossips:
|
||||||
|
seeded 45% banner mutter naming a hunted want + a real cached town. TRAVEL COSTS A DAY:
|
||||||
|
adopting a save from another townKey (town OR seed) charges +1 day in save.js adopt() — load
|
||||||
|
and import both pay, same-town stays free (byte-equality gates untouched); arrival toast names
|
||||||
|
the fare. New STRICT `smoke_wantlist_travel` (real-card want · rumor scan · auto-clear ·
|
||||||
|
+1-day with intact cash/collection · same-town control · return fare · ?game=0 zero-DOM).
|
||||||
|
Full record: LANE_F_NOTES §33.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Round 32 (v7.0-beta wave 1) — THE BIBLE + scarcity + the §31.6 queue (Fable, solo, JING5)
|
||||||
|
|
||||||
|
Guide bands derived in `js/interiors/bible.js` (E's thresholds mirrored, no pack re-emit) →
|
||||||
|
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.**
|
||||||
|
|||||||
@ -23,7 +23,10 @@ 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 $8–24"). An item **asking under its band is a gem** — buy it, sell it to a keeper, pocket the margin. Bought slots stay gone for the day (the bin remembers) |
|
||||||
|
| **☆ WANT IT** on a pull card | start hunting it — keepers gossip ("saw one in Castlemaine"), and buying one clears the hunt. **Switching towns costs a day** — the 23 towns are a map now, not a dropdown |
|
||||||
|
| **⚖ LIST** in the crate | consign a find to **GodBay** — the auction hammers overnight (sleep or ride somewhere). One hammer per item, ever: keeper cash now, or the gavel tomorrow |
|
||||||
| 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) |
|
||||||
|
|||||||
@ -1754,3 +1754,137 @@ 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 1–3
|
||||||
|
are fixed. All gates green; the no-pump law survives the §9.1 basis swap structurally.
|
||||||
|
|
||||||
|
### §32.1 What shipped, by seam
|
||||||
|
|
||||||
|
- **`js/interiors/bible.js`** (C) — the band table mirroring E's pipeline thresholds
|
||||||
|
(record 8/25/60 · book 5/15/40 · toy 6/20/50), `bandRange`/`guideText`/`sellBasis`. Derivation
|
||||||
|
ruling: NO pack re-emit (charter risk #3 never opens); if E's thresholds move, bible.js moves in
|
||||||
|
the same commit.
|
||||||
|
- **dig.js** (C) — parody sleeves roll seeded bands (25/45/22/8%) + a 10% gem on collector/grail
|
||||||
|
(asking strictly under the keeper's own bandLow offer: collector $2–9 vs $12 · grail $5–20 vs
|
||||||
|
$30); fixed 9-draw stream shape per item; `rare` now ≡ grail (matches real packs). Real offers
|
||||||
|
carry `band` off `price_band`. `open({gone})` omits pulled slots AFTER the draw (the R27
|
||||||
|
pick-then-omit seam, zero collateral) — real via `'sku:<id>'`, parody via original offer index
|
||||||
|
(`o.i` survives filtering, identity preserved). The pull card + sell card gain the **guide
|
||||||
|
stamp** (`guide $8–24` / `$60+`) — present-fields-only, and the card NEVER says "gem": spotting
|
||||||
|
asking-under-guide is the player's skill.
|
||||||
|
- **sell.js** (C) — §9.1 basis swap executed exactly as pre-ratified: `sellOffer(sellBasis(entry))`
|
||||||
|
= `min(bandLow−1, max(1, floor(bandLow·0.5)))` when banded, pricePaid basis for alpha-era
|
||||||
|
entries (they sell exactly as before — no migration needed).
|
||||||
|
- **save.js** (F) — optional `pulls: string[]` on procity-save/1 (validated loudly when present;
|
||||||
|
absent on alpha saves — old savers drop it silently, never reject). `recordPull`/`pullsFor`
|
||||||
|
seams; pruned on sleep/import (day-tagged keys from other days are unreachable by construction;
|
||||||
|
untagged = real-sourced, kept — sold means gone). Cap 600 FIFO. Collection entries gain
|
||||||
|
optional `band` (recordFind stamps dig + shelf buys).
|
||||||
|
- **interior_mode.js** (F) — pull keys `${shopId}|${binKey}${pullTag}#${slot}`; pullTag =
|
||||||
|
`@d<game.day>` for rotating crates (INCLUDING day 1 — the seed stays untagged per the day-1
|
||||||
|
convention, the ledger doesn't), `''` for real-sourced. The SEED input is untouched (A's
|
||||||
|
rotation boundary holds). `digOffers` honest surface (index/ask/band/sku) for the gate.
|
||||||
|
- **hud.js + index.html** (B/F) — §31.6-2: `#pc-game` rides document.body (position:fixed, z 30),
|
||||||
|
ticks in every mode (`hud.tickGame()` from the interior/map branches), yields to the dig/sell
|
||||||
|
cards and the map; C toggles the crate anywhere. §31.6-1: cluster-pose spawn — main-street
|
||||||
|
shop doors (block kind, both generators), stand at the door nearest the cluster heart, face the
|
||||||
|
longer run of shopfronts; pure plan math, zero draws; `?classic=1` keeps the frozen pose.
|
||||||
|
§31.6-3: DAWN swaps the dome to `high-cirrus` (lighting `dawnSky` opt, null under classic);
|
||||||
|
a weather `setSky` is now an explicit override that always wins.
|
||||||
|
|
||||||
|
### §32.2 THE GATE (`smoke_bible`, STRICT) + the R30 amendment
|
||||||
|
|
||||||
|
Scans EVERY bin of the record shop off `digOffers`: band law on all offers (in-band unless a
|
||||||
|
collector/grail gem), then runs the buy-EVERYTHING ledger on **the gem's bin** (leg is
|
||||||
|
non-vacuous by construction — 80 offers/5 bins, bin1 holds 2 gems on the pinned seed, grail
|
||||||
|
asking $19 vs keeper $30). Ledger closes exactly: Σask $333 → Σoffer $135 (gems alone +$21,
|
||||||
|
every non-gem strictly negative). Scarcity: the emptied bin re-opens at 0, STAYS 0 across a full
|
||||||
|
reload (16 pulls in the save); negative control: `?game=0` re-opens it at 16 — the detector
|
||||||
|
discriminates. 0 console errors. **R30 amendment (sell_routing):** `credited < paid` was the
|
||||||
|
alpha inequality; the gate now asserts the exact formula (`credited == min(basis−1, basis//2)`,
|
||||||
|
basis = bandLow-or-pricePaid, `credited < basis`) — the fence moved from the receipt to the
|
||||||
|
band, which is what charter law #2 always said.
|
||||||
|
|
||||||
|
### §32.3 Environment note (JING5)
|
||||||
|
|
||||||
|
`web/assets/models/` is GITIGNORED build output — a fresh checkout runs stock=real/book-toy/
|
||||||
|
no-pump gates RED with 404s (the R31-green tree lives on m3ultra). Fixed here by rsync from
|
||||||
|
m3ultra. If a third machine joins the rotation, sync that dir (49 M) or re-run E's pipeline.
|
||||||
|
|
||||||
|
## §33 — R33: v7.0-beta wave 2 — THE HUNT (wantlist · rumor · travel) (Fable, solo, JING5)
|
||||||
|
|
||||||
|
- **save.js:** optional `wants: [{artist?|title?|sku?}]` on procity-save/1 (validated
|
||||||
|
exact-and-loud when present; ≥1 string field per entry; cap 40; absent-when-empty keeps the
|
||||||
|
alpha byte shape). `recordWant` (case-insensitive dedupe) / `findWant` (a want covers an item
|
||||||
|
when every field the WANT names matches — sku exact, artist/title by value) / `removeWant`
|
||||||
|
(identity). **TRAVEL in `adopt()`:** adopted save's `town !== townKey` ⇒ `day += 1`,
|
||||||
|
`traveled = true`, prunePulls — load AND import both pay the fare; same-town adoption free
|
||||||
|
(save-determinism byte-equality untouched). `game.traveled` getter → the shell's arrival toast.
|
||||||
|
- **dig.js:** the pull card's ☆ WANT line — construction-gated on `opts.onWant` (game absent ⇒
|
||||||
|
zero want DOM, the classic-pure pattern). ☆ click → ★ ON THE WANTLIST (a stamp, not a button,
|
||||||
|
once wanted).
|
||||||
|
- **interior_mode.js:** onWant/isWanted seams into dig.open; buy of a matching item removes the
|
||||||
|
want (+ '✓ off the wantlist' toast). **THE RUMOR** in the enter() banner: seeded
|
||||||
|
`xmur3('rumor:<citySeed>:<shopId>:<day>')` → 45% roll → hunted want + a town from
|
||||||
|
RUMOR_TOWNS (the repo's real cached towns, current town excluded). Runtime-only stream —
|
||||||
|
A's boundary holds; no game / empty wants ⇒ the line never exists.
|
||||||
|
- **hud.js:** THE WANTLIST section in the crate panel (non-empty only): artist/title rows, ✕
|
||||||
|
stops hunting; refresh watches wants length so an open panel follows adds/clears.
|
||||||
|
- **Gate:** `smoke_wantlist_travel` STRICT (see ROUND33_INSTRUCTIONS for the green run) — real
|
||||||
|
card path, seeded rumor scan, auto-clear, +1-day travel with same-town negative control and
|
||||||
|
the return fare, `?game=0` zero-DOM control, 0 console errors.
|
||||||
|
- **Matrix note:** town_matrix shares one browser across towns, so travel drifts the day as it
|
||||||
|
walks the matrix — safe because determinism double-boots the SAME town (free by the
|
||||||
|
same-town rule) and no matrix gate reads absolute day. Reasoned before landing, not after.
|
||||||
|
- **The shot found what the gate didn't (harness lesson #5 candidate):** R32's crate-in-shops
|
||||||
|
surface BUILT only in street update() frames — a door entered fast after boot had no game
|
||||||
|
surface inside at all, and the interior tickGame() happily refreshed a UI that never existed.
|
||||||
|
Green gates missed it because every gate's boot idles on the street long enough to build.
|
||||||
|
Fixed: tickGame() now builds AND refreshes (any mode); found by taking the money shot, which
|
||||||
|
entered the shop faster than any gate does. Shots are a detector, not decoration.
|
||||||
|
|
||||||
|
## §34 — R34: GODBAY, the offline auction (v7.0 close opens) (Fable, solo, JING5)
|
||||||
|
|
||||||
|
- **save.js:** `godbayHammer(entry, townKey)` — THE ONE-HAMMER LAW: seeded by ITEM IDENTITY
|
||||||
|
(`godbay:<townKey>:<sku|slotId>:<dayFound>:<pricePaid>`), never by resolution day — a
|
||||||
|
passed-in item re-lists to the same result forever, no retry farm. Fixed 4-draw stream. Rolls:
|
||||||
|
15% passed in (comes home free) · uniform hammer across the band (grail top = 2×low; unbanded
|
||||||
|
alpha finds auction around pricePaid — honest, never invented) · 12% bidding war ×1.5–2.0 ·
|
||||||
|
12% commission, net floor $1. EV ≈ 0.8×mid — under in-band asking EV, so random flipping
|
||||||
|
bleeds and only FINDS profit; the keeper's half-of-low stays the instant floor, the auction
|
||||||
|
is the gambler's ceiling (bounded 2×high×0.88). PRNG imported from core/prng.js; the band
|
||||||
|
table imported from C's bible.js (one authority — no mirror after all).
|
||||||
|
- `listings` optional on procity-save/1 (entry shape + listDay, same back-compat law).
|
||||||
|
`listFind` (identity, entry leaves the crate — can't also sell it) · `resolveAuctions()` runs
|
||||||
|
in adopt() AND sleep(): sleep, travel, and day-moving imports all hammer what they pass.
|
||||||
|
`takeAuctionNews()` drains the transient morning paper (NOT saved — the ledger is cash/
|
||||||
|
collection; the paper is a toast).
|
||||||
|
- **hud.js:** ⚖ LIST on crate find rows (mail-in consignment) + AT AUCTION section; rebuild
|
||||||
|
watches listings. **index.html:** the wake toast and the arrival toast fold the paper in.
|
||||||
|
- **Gate `smoke_godbay` (STRICT):** consign through the real crate DOM → save carries it ·
|
||||||
|
ONE-HAMMER determinism (a frozen pre-sleep export slept twice lands byte-identical) · bounded
|
||||||
|
honest resolution (SOLD net $19 on a standard record, ≤ 2×24×0.88) · the wake toast carried
|
||||||
|
"⚖ SOLD Cane Toad Blues — hammer $22, $19 after fees" · the ride to melbourne hammered a
|
||||||
|
listing on arrival · ?game=0 has no crate to list from · 0 console errors.
|
||||||
|
- G's later LIVE tier must match these laws (the ladder rule: the offline tier is the contract).
|
||||||
|
|
||||||
|
## §35 — R35: THE FIRST FIVE MINUTES (Fable, solo, JING5)
|
||||||
|
|
||||||
|
Fresh-game detection = day 1 ∧ empty collection ∧ empty listings (no flag in the save — the
|
||||||
|
delta law stays clean; a returning empty-crate player re-seeing beats is onboarding working,
|
||||||
|
not a bug). Seeded first want: `rng(citySeed,'firstwant',0)` × dig.js ARTISTS (exported R35);
|
||||||
|
guarded on empty wants so the persisted want isn't re-added. Splash gains #pc-hunt (fresh+game
|
||||||
|
only). Beats: ① on the go-click (900ms), ② on first `procity:digOpen`, ③ on first find via
|
||||||
|
wallet.onChange, send-off on first sale-or-listing (listener self-removes). #pc-toast body-
|
||||||
|
mounted (z 31) — visible in interiors/map; hud.dispose removes it. Gate `smoke_first_five`
|
||||||
|
STRICT (see ROUND35_INSTRUCTIONS). All v7.0 SYSTEMS are now built — tour + freeze + tag remain,
|
||||||
|
tag gated on John's playtest.
|
||||||
|
- **Cross-round amendment the suite caught (the reason full runs exist):** R35's seeded first
|
||||||
|
want changed the FRESH-BOOT CONTRACT (wants starts at 1, not 0) and the R33 gate's three
|
||||||
|
fresh-boot assumptions went red on the full run — green solo, red together. Amended to a
|
||||||
|
baseline snapshot (base = wants at boot; card adds base+1; the rumor may name ANY live want;
|
||||||
|
buying clears ITS want back to base). A feature that moves a boot invariant must sweep every
|
||||||
|
gate that assumed it — grep the harness for the invariant before calling a round closed.
|
||||||
|
|||||||
@ -29,6 +29,21 @@ Money shot: the crate panel with real finds (the collection IS the alpha's face)
|
|||||||
verdict in the message), push main + tag. The README gains one line in the manual: sell at
|
verdict in the message), push main + tag. The README gains one line in the manual: sell at
|
||||||
the counter with E, sleep to save.
|
the counter with E, sleep to save.
|
||||||
|
|
||||||
|
## ROUND CLOSED (Fable) — **v7.0-alpha TAGGED AND PUSHED** (`2491f1c`)
|
||||||
|
|
||||||
|
Wave 0: Fable played the loop end to end — dig $10 → sell $5 (the spread felt) → keep a $12
|
||||||
|
NM soul record → THE CRATE → full reload → **the town remembered** → SLEEP → day 2. Verdict:
|
||||||
|
**the loop plays.** The playtest found the alpha-blocker every green gate had missed: **the
|
||||||
|
dig was default-OFF on a plain boot** (v2-era opt-in, six epochs past the README's claim; every
|
||||||
|
R30 gate booted `dig=1` explicitly — harness lesson #4, THE EXPLICIT-FLAG BLINDFOLD: a gate
|
||||||
|
that boots its own flags cannot see a broken default). Waves 1+2: the flip (classic-safe,
|
||||||
|
fenced with a naked-boot reachability assert + negative control), the de-staled splash, the
|
||||||
|
money shot (Monster Robot's real crate, 3 real finds), qa 7/0/0 + MATRIX 10 towns on
|
||||||
|
port-isolated servers (a second fix the round forced: the suite no longer silently reuses
|
||||||
|
whatever holds :8130). **The beta queue is filed in LANE_F_NOTES §31.6** — spawn-faces-the-
|
||||||
|
strip, crate-at-the-counter, dawn sky, THE BIBLE, per-day scarcity. The beta charters on
|
||||||
|
John's own playtest whenever he walks the strip.
|
||||||
|
|
||||||
## Standing
|
## Standing
|
||||||
|
|
||||||
- **John — the alpha's real acceptance is you:** play it (locally now, or grant the VPS ssh
|
- **John — the alpha's real acceptance is you:** play it (locally now, or grant the VPS ssh
|
||||||
|
|||||||
61
docs/LANES/ROUND32_INSTRUCTIONS.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# PROCITY — Round 32 instructions (from Fable, integrator)
|
||||||
|
|
||||||
|
Date: 2026-07-20 · **R31 closed** — v7.0-alpha tagged and pushed; the loop plays. Round 32 opens
|
||||||
|
**v7.0-beta — THE THRILL, wave 1**: the guide bands land ([V7_THE_GAME.md](../V7_THE_GAME.md)
|
||||||
|
stage 2), scarcity rides the save, and the alpha playtest's friction queue (LANE_F_NOTES §31.6)
|
||||||
|
gets its cosmetic items. **Solo round:** John asked for direct progress; Fable executes all lanes
|
||||||
|
in one session on JING5, same treaty, same gates — lane ownership is honoured in the commit
|
||||||
|
messages, not the parallelism.
|
||||||
|
|
||||||
|
## The design rulings this round encodes (from the ratified charter + §31.6 queue)
|
||||||
|
|
||||||
|
1. **THE BIBLE derives, it is not baked.** E's band thresholds (build_stock_pack.py:88-94 —
|
||||||
|
record 8/25/60 · book 5/15/40 · toy 6/20/50) become a tiny JS mirror
|
||||||
|
(`js/interiors/bible.js`). No pack re-emit, so risk #3 (re-baked atlases orphaning owned
|
||||||
|
skus) never opens. The guide shows a BAND ("guide $25–59"), never a verdict — spotting the
|
||||||
|
gem stays the player's skill.
|
||||||
|
2. **Gems live in the parody bins.** Real/mint packs derive `price_band` FROM `price`, so real
|
||||||
|
asking is always inside its band → with the §9.1 basis swap (sellOffer basis = bandLow),
|
||||||
|
every real round trip stays strictly negative and **the R30 no-pump gate holds untouched,
|
||||||
|
by construction**. Procedural stock gains seeded bands + a seeded under-band gem roll —
|
||||||
|
the only place asking can sit under the guide, which is exactly the op-shop fantasy.
|
||||||
|
3. **Scarcity is what makes the gem loop safe** (§31.6 item 6, ruled WITH the bands as filed):
|
||||||
|
a bought dig slot enters `pulls` in the save (optional field, procity-save/1 — old saves
|
||||||
|
stay valid, old loaders drop the field without rejecting) and that bin omits it after the
|
||||||
|
draw (the proven `pack.gone` pick-then-omit seam — zero collateral). A gem is profit ONCE;
|
||||||
|
tomorrow's rotation re-keys the bin. Growth is bounded by finds, not arbitrage (charter
|
||||||
|
law #2's real meaning).
|
||||||
|
4. **§31.6 items 1–3:** spawn becomes a computed cluster-pose facing the shop strip (classic
|
||||||
|
keeps the frozen v2 pose); THE CRATE surfaces inside interiors (C key — the §31.6
|
||||||
|
"crate at the counter" want, hidden while dig/sell cards own the screen); DAWN gets a
|
||||||
|
cool sky dome swap (classic + weather-override fenced).
|
||||||
|
|
||||||
|
## Gate additions (F)
|
||||||
|
|
||||||
|
`smoke_bible` in flags_check.py: guide stamp on the pull card (real + parody) · offer < bandLow
|
||||||
|
structurally · a seeded parody gem exists and its round trip is positive ONCE (then the slot is
|
||||||
|
gone — re-open the bin and it stays gone; reload and it stays gone) · negative control:
|
||||||
|
`?game=0` re-opens the same bin with the slot PRESENT (the detector discriminates — scarcity is
|
||||||
|
the save's doing, not the dig's). Classic purity untouched (no new module constructs under
|
||||||
|
`?classic=1` / `?dig=0`).
|
||||||
|
|
||||||
|
## ROUND CLOSED (Fable) — ALL GREEN
|
||||||
|
|
||||||
|
`qa.sh --strict --matrix`: **7 passed · 0 failed · 0 warn · 0 skipped**, MATRIX 10 towns green.
|
||||||
|
`smoke_bible`: 80 parody offers across 5 bins all band-lawful; bin1 holds 2 seeded gems (grail
|
||||||
|
asking $19 vs keeper $30); the buy-everything ledger closes to the cent (Σask $333 → Σoffer
|
||||||
|
$135, gems alone +$21, every non-gem strictly negative); the emptied bin stays empty across a
|
||||||
|
full reload (16 pulls in the save) and `?game=0` re-opens it 16/16 (the control discriminates);
|
||||||
|
0 console errors. sell_routing now asserts the exact §9.1 formula and passed on a BANDED item
|
||||||
|
(standard, basis $8 → +$4). Money shots: `docs/shots/v7_beta/guide_card.png` (the guide stamp —
|
||||||
|
"1974 COUNTRY guide $8–24" beside the $24 sticker) + `crate_at_the_counter.png` (THE CRATE open
|
||||||
|
inside a shop, game bar riding bottom-right). Environment note: JING5's gitignored
|
||||||
|
`web/assets/models/` was absent (fresh-checkout trap, now §32.3 in LANE_F_NOTES) — synced from
|
||||||
|
m3ultra, which also un-warned the manifest validator's 15 cross-pack checks. Full record:
|
||||||
|
LANE_F_NOTES §32. Shipped: bible.js + guide stamps + parody bands/gems (C) · pulls on the save
|
||||||
|
+ gone-wiring + digOffers surface (F) · crate-in-interiors + cluster-pose spawn + dawn sky (B)
|
||||||
|
· smoke_bible + the routing amendment (F) · README/docs.
|
||||||
|
|
||||||
|
**The beta's remaining charter** (next rounds): the WANTLIST + seeded rumors (D's keepers
|
||||||
|
finally gossip) · travel costs a day (23 towns become the map) · §31.6-5 (dig Esc under real
|
||||||
|
hands) · then v7.0 = GodBay offline auctions (G) + onboarding + the tour.
|
||||||
52
docs/LANES/ROUND33_INSTRUCTIONS.md
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# PROCITY — Round 33 instructions (from Fable, integrator)
|
||||||
|
|
||||||
|
Date: 2026-07-20 · **R32 closed all green** (7/0/0, matrix 10 towns — THE BIBLE + scarcity live).
|
||||||
|
Round 33 is **v7.0-beta wave 2 — THE HUNT**: the wantlist, the rumor, and travel that costs a
|
||||||
|
day ([V7_THE_GAME.md](../V7_THE_GAME.md) system #6, the last unbuilt beta system). Solo round
|
||||||
|
again (John's "keep going"), same treaty, lane ownership honoured in the commits.
|
||||||
|
|
||||||
|
## The design rulings this round encodes
|
||||||
|
|
||||||
|
1. **A want is something you SAW.** The wantlist grows from the pull card (☆ WANT IT — you
|
||||||
|
pulled it, it's dear, you'll hunt one cheaper) — not from an abstract search box the 90s
|
||||||
|
didn't have. Buying a matching item clears its want automatically (the hunt ends when
|
||||||
|
you're holding it). Wants ride the save (`wants: [{artist?|title?|sku?}]`, optional field,
|
||||||
|
same procity-save/1 back-compat law as `pulls`).
|
||||||
|
2. **The keeper finally gossips** (charter: "D's citizens finally gossip for a reason").
|
||||||
|
Seeded per (citySeed, shop, day): ~45% of shop entries mutter a hunted want + a real cached
|
||||||
|
town in the banner — flavor and a travel nudge, honest about being neither promise nor
|
||||||
|
stock lookup (town-wide packs mean any town CAN surface the record eventually). Runtime
|
||||||
|
stream, freshly keyed — no plan/golden moves. Full persona lines remain D's when the v6
|
||||||
|
clip work lands; the banner is the keeper's voice until then.
|
||||||
|
3. **Travel = the townKey changing hands.** Booting a save in a different town (different
|
||||||
|
plansrc/town/**seed** — a new seed is a new town) costs exactly one day, applied in
|
||||||
|
save.js `adopt()` so load and import both pay it, and pulls prune on the way. Same-town
|
||||||
|
reloads and export→import round trips stay free — the save-determinism gate's byte
|
||||||
|
equality survives untouched. The town selector IS the bus; the arrival toast names the
|
||||||
|
fare. The 23 towns are now a map you spend days moving across, not a dropdown.
|
||||||
|
|
||||||
|
## Gate (F)
|
||||||
|
|
||||||
|
`smoke_wantlist_travel` (STRICT): ☆→★ through the real card → the save carries the want ·
|
||||||
|
a keeper names the hunted artist within the open shops (seeded scan) · buying the wanted
|
||||||
|
sleeve auto-clears it · synthetic→melbourne costs exactly one day with cash/collection intact
|
||||||
|
· same-town reboot is FREE (the control) · the ride home costs a day again · `?game=0` builds
|
||||||
|
zero want DOM and mutters nothing.
|
||||||
|
|
||||||
|
## ROUND CLOSED (Fable) — ALL GREEN
|
||||||
|
|
||||||
|
`qa.sh --strict --matrix`: **7 passed · 0 failed · 0 warn · 0 skipped**, MATRIX 10 towns green
|
||||||
|
(the shared-context day drift travel introduces across matrix towns was reasoned through
|
||||||
|
before landing: determinism double-boots the SAME town, so no gate sees it). The new gate's
|
||||||
|
run: want carried {THE VERANDAHS — "Servo at Midnight"} · rumor after 2 shops ("🗣 THE
|
||||||
|
VERANDAHS? saw one in Castlemaine just last week") · buy cleared the want · travel day 1→2
|
||||||
|
with $179 and the find riding along · same-town free · return trip day 3 · game=0 clean ·
|
||||||
|
0 console errors. Full record: LANE_F_NOTES §33.
|
||||||
|
|
||||||
|
Also caught and fixed here: the R32 crate-in-shops surface only BUILT in street frames — a
|
||||||
|
door entered fast after boot had no game surface inside (the money-shot script entered faster
|
||||||
|
than any gate does and exposed it; tickGame() now builds-or-refreshes in any mode). Money
|
||||||
|
shots: `docs/shots/v7_beta/rumor_and_wantlist.png` + `want_card.png`.
|
||||||
|
|
||||||
|
**What remains of v7:** §31.6-5 (dig Esc in person — John's hands) · then the v7.0 close:
|
||||||
|
GodBay offline auctions (G's lane), THE FIRST FIVE MINUTES (onboarding), the tour, the tag.
|
||||||
57
docs/LANES/ROUND34_INSTRUCTIONS.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# PROCITY — Round 34 instructions (from Fable, integrator)
|
||||||
|
|
||||||
|
Date: 2026-07-20 · **R33 closed all green** — every v7.0-beta system is built. Round 34 opens
|
||||||
|
**the v7.0 close: GODBAY, the offline auction** ([V7_THE_GAME.md](../V7_THE_GAME.md) system #4:
|
||||||
|
"list an item, a SEEDED offline auction resolves NEXT DAY — the come-back-tomorrow hook").
|
||||||
|
Offline tier FIRST per the proven ladder (G3's live rails are v7.x; this round is the seeded
|
||||||
|
brain in the game layer, G's later live tier must match these laws). Solo round (John's "keep
|
||||||
|
rolling").
|
||||||
|
|
||||||
|
## The laws (GODBAY v0 — named now, gated below)
|
||||||
|
|
||||||
|
1. **Mail-in consignment.** You list from THE CRATE (⚖ LIST on a find) — the 90s answer
|
||||||
|
(classifieds + the post). The entry leaves the collection into `listings` (you cannot also
|
||||||
|
sell it at a counter), `listDay` stamped. Resolution happens when the day moves past
|
||||||
|
`listDay` — sleep, travel, or a day-moving import all count (adopt() and sleep() both run
|
||||||
|
the resolver).
|
||||||
|
2. **THE ONE-HAMMER LAW (no retry farming).** The hammer is seeded by ITEM IDENTITY —
|
||||||
|
`godbay:<townKey>:<sku|slotId>:<dayFound>:<pricePaid>` — NOT by the resolution day. A
|
||||||
|
passed-in item re-lists to the SAME result, forever. There is no re-roll anywhere in the
|
||||||
|
economy: the keeper's offer is a formula, the auction is one hammer per item, per life.
|
||||||
|
3. **The house always eats.** Hammer rolls uniform across the band (grail's open top = 2×low);
|
||||||
|
~12% chance of a bidding war (×1.5–2.0); ~15% passed in (returns to the crate, free);
|
||||||
|
12% commission, net floor $1. Expected value ≈ 0.8× band midpoint — UNDER the asking-price
|
||||||
|
EV of in-band stock, so random flipping bleeds and only FINDS profit (charter law #2's
|
||||||
|
spirit at the auction house: growth bounded by finds, the keeper's instant half-of-low
|
||||||
|
remains the coward's floor and the auction the gambler's ceiling — bounded at 2×high×0.88).
|
||||||
|
4. **Delta law / classic purity unchanged.** `listings` is an optional procity-save/1 field
|
||||||
|
(same back-compat law as pulls/wants); no game layer ⇒ no listings, no DOM, no news.
|
||||||
|
5. **The morning report.** Resolution queues transient news ("⚖ SOLD 'Sunburnt' — hammer $38,
|
||||||
|
$33 after fees"); the shell folds it into the wake/arrival toast. News is NOT saved — it's
|
||||||
|
the morning paper, not the ledger (cash/collection already carry the truth).
|
||||||
|
|
||||||
|
## Gate (F): `smoke_godbay` (STRICT)
|
||||||
|
|
||||||
|
List through the real crate DOM (C → ⚖ LIST) → entry moves collection→listings, the save
|
||||||
|
carries it · sleep → resolved: listings empty, cash+net OR item returned, news queued, net
|
||||||
|
within [1, 2.2×bandHigh] when sold · DETERMINISM: a pre-sleep export imported twice sleeps to
|
||||||
|
byte-identical cash/collection both times (the seeded hammer, measured not trusted) · travel
|
||||||
|
resolves too (list → ride to melbourne → resolved on arrival) · `?game=0` builds zero ⚖ DOM ·
|
||||||
|
0 console errors.
|
||||||
|
|
||||||
|
## ROUND CLOSED (Fable) — ALL GREEN
|
||||||
|
|
||||||
|
`qa.sh --strict --matrix` on the final tree: **7 passed · 0 failed · 0 warn · 0 skipped**,
|
||||||
|
MATRIX 10 towns green. `smoke_godbay`: consigned through the real crate DOM (collection 2→1,
|
||||||
|
listDay stamped) · ONE-HAMMER determinism (a frozen pre-sleep export slept twice → byte-
|
||||||
|
identical) · honest bounded resolution (SOLD net $19 on a standard record, ≤ 2×$24×0.88) ·
|
||||||
|
the wake toast carried "⚖ SOLD 'Cane Toad Blues' — hammer $22, $19 after fees" · the ride to
|
||||||
|
melbourne hammered a listing on arrival · `?game=0` has no crate to list from · 0 console
|
||||||
|
errors. Design note vs the brief: the band table is IMPORTED from C's bible.js and the PRNG
|
||||||
|
from core/prng.js (one authority each — the drafted mirror died in review). Money shots:
|
||||||
|
`docs/shots/v7_beta/godbay_morning_paper.png` (dawn day 2, the paper in the toast, the crate's
|
||||||
|
⚖ row) + `godbay_at_auction.png`. Full record: LANE_F_NOTES §34.
|
||||||
|
|
||||||
|
**What remains of v7.0:** THE FIRST FIVE MINUTES (onboarding — arrive with a want, three
|
||||||
|
toast-beats) · the tour ("day one to first gem") · docs freeze · the tag. Then John's playtest
|
||||||
|
charters what's next.
|
||||||
36
docs/LANES/ROUND35_INSTRUCTIONS.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# PROCITY — Round 35 instructions (from Fable, integrator)
|
||||||
|
|
||||||
|
Date: 2026-07-20 · **R34 closed all green** (GODBAY hammers). Round 35 is **THE FIRST FIVE
|
||||||
|
MINUTES** ([V7_THE_GAME.md](../V7_THE_GAME.md) system #7 — "smallest system, biggest
|
||||||
|
stranger-facing payoff"), the last unbuilt v7.0 system before the tour/freeze/tag.
|
||||||
|
|
||||||
|
## What ships
|
||||||
|
|
||||||
|
1. **You arrive with a want.** A FRESH game (day 1, empty crate, empty auction house) seeds ONE
|
||||||
|
artist want off `rng(citySeed,'firstwant',0)` × C's parody roster (ARTISTS now exported) —
|
||||||
|
so the splash, the keeper rumors, and the tour all point at the same hunt on a given seed.
|
||||||
|
The splash names it: "You're hunting HILLS HOIST — $191 in your pocket." A returning save
|
||||||
|
gets its town back, not a tutorial; ?game=0 / classic never reach the block.
|
||||||
|
2. **Three toast-beats walk the hustle** (fresh games only): ① find a record shop / E at a
|
||||||
|
crate (on walk-in) → ② riffle · mind the guide · BUY (first digOpen) → ③ sell at a counter
|
||||||
|
or ⚖ LIST and sleep (first find banked) → the send-off ("that's the whole hustle") on the
|
||||||
|
first sale-or-listing. Driven off existing events + wallet.onChange — zero new systems.
|
||||||
|
3. **Toasts surface in every mode** — #pc-toast moved to document.body (the #pc-game body-mount
|
||||||
|
rule): beats fire INSIDE shops, and a SLEEP taken at a counter now shows its morning paper.
|
||||||
|
|
||||||
|
## Gate (F): `smoke_first_five` (STRICT)
|
||||||
|
|
||||||
|
Fresh game: 1 seeded want from the roster + the splash names hunt AND cash · beats ②/③/send-off
|
||||||
|
through the REAL paths (E at crate / dig buy / crate ⚖) · a returning save gets NO tutorial
|
||||||
|
(splash line hidden, no ② on dig) · a second fresh browser hunts the SAME artist (seeded, not
|
||||||
|
random) · ?game=0 shows nothing · 0 console errors.
|
||||||
|
|
||||||
|
## ROUND CLOSED (Fable) — ALL GREEN
|
||||||
|
|
||||||
|
`qa.sh --strict --matrix`: 7 passed · 0 failed · 0 warn · 0 skipped, MATRIX 10 towns green
|
||||||
|
(final tree). Gate run: hunts HILLS HOIST with $191, beats walked through the real dig/buy/list
|
||||||
|
paths, returning-save and ?game=0 controls clean, second fresh browser hunts the same artist,
|
||||||
|
0 console errors. Money shot: `docs/shots/v7_beta/first_five_splash.png`.
|
||||||
|
|
||||||
|
**v7.0 remaining:** the tour ("day one to first gem") · docs freeze · the tag — and the tag
|
||||||
|
waits for John's playtest (the alpha precedent: hands before tags).
|
||||||
@ -59,6 +59,55 @@ makes digging MEAN something is unbuilt:
|
|||||||
- **The 90s-ness:** it's in the fabric (video shops, milk bars) but no system USES the era —
|
- **The 90s-ness:** it's in the fabric (video shops, milk bars) but no system USES the era —
|
||||||
no phone-free navigation puzzles, no street-press gig guide, no mixtapes.
|
no phone-free navigation puzzles, no street-press gig guide, no mixtapes.
|
||||||
|
|
||||||
|
## THE THIRD DIMENSION + TEXTURE LIFE (filed 2026-07-18 — John's Perlin question, answered from the code)
|
||||||
|
|
||||||
|
**The ruling that frames this section:** world *structure* stays on the semantic hash-stream
|
||||||
|
system (`prng.js` — `seedFor(citySeed, kind, id)`, independently-keyed streams per entity).
|
||||||
|
That architecture is why 31 rounds of features never moved a golden: a draw added to one
|
||||||
|
stream shifts no other. Perlin answers a different question — a *continuous* value over
|
||||||
|
space/time — and it enters the toolbox only where the question really is continuous. When it
|
||||||
|
does, it seeds from the same citySeed keying: deterministic, gate-able, no exceptions.
|
||||||
|
|
||||||
|
Candidates, ranked by honest cost:
|
||||||
|
|
||||||
|
1. **TERRAIN / ELEVATION — the headline, epoch-scale, chartered as a v8+ candidate.**
|
||||||
|
Everything is flat, which is *wrong for the real map*: Katoomba is a mountain town rendered
|
||||||
|
as a billiard table. Per the real-map DNA, the primary source is REAL elevation (SRTM /
|
||||||
|
contour data for each town bbox, the same discipline as the roads fetch) — noise is only
|
||||||
|
the infill between samples, never the shape of a real hill. **The cost is named now so
|
||||||
|
nobody charters it lightly:** terrain touches everything — roads follow grades, buildings
|
||||||
|
need foundations and steps, footpaths slope, the chunk streamer and the walkers and every
|
||||||
|
budget meet a Y axis, and the classic covenant needs a flat-forever fence. This is an
|
||||||
|
epoch with an alpha/beta/close arc of its own, not a round.
|
||||||
|
2. **Sky / cloud domes — cheap, near-term (v6/v7 polish).** Skies are baked textures today; a
|
||||||
|
seeded FBM dome gives every day its own weather-shaped sky, composes with the existing
|
||||||
|
weather states, and is one lane's item (B, lighting).
|
||||||
|
3. **Wear & grime — cheap, high-charm (E/C canvas painters).** Seeded value-noise for sleeve
|
||||||
|
ring-wear, facade sun-fade, footpath staining — per-item, deterministic, and exactly the
|
||||||
|
90s patina the parody sleeves deserve. Pairs with the beta's price-guide work (a worn
|
||||||
|
sleeve *reading* worn next to its condition grade).
|
||||||
|
4. **Crowd ebb (LIVING STREET track, D).** A slow 1D noise curve modulating street density
|
||||||
|
across the day — mornings trickle, arvo swells — richer than uniform draws, one stream,
|
||||||
|
identity-safe.
|
||||||
|
|
||||||
|
5. **THE VERNACULAR KIT (filed 2026-07-18, John's idea, source corrected) — buildings that
|
||||||
|
feel real.** The town's massing is same-y boxes; the fix is **15–20 parametric Aussie
|
||||||
|
vernacular archetypes** (Queenslander, Federation cottage, terrace row, weatherboard
|
||||||
|
shopfront + verandah, deco corner pub, brick milk bar, fibro shack, tin warehouse…) with
|
||||||
|
seeded knobs — width, storeys, verandah depth, roof pitch, parapet, tint, and the wear
|
||||||
|
noise from item #3 above. **Source ruling: NOT Google Maps 3D** (licence-fatal — the one
|
||||||
|
thing the provenance spine must never touch — and photogrammetry mush at house scale
|
||||||
|
anyway). Instead: (a) **OSM/Overture building footprints + heights** for the real towns —
|
||||||
|
ODbL, the licence and pipeline we already ship, one more fetch class under the standing
|
||||||
|
authorization; (b) the kit designed from Street View *reference* (looking is not
|
||||||
|
extracting) and built parametrically on B's existing instanced-attribute machinery, which
|
||||||
|
already varies tint/hours per instance. Real footprints place real shapes on real streets;
|
||||||
|
the kit + knobs cover everything unfootprinted. Pairs with terrain as the two halves of a
|
||||||
|
future "the streetscape gets real" epoch — but unlike terrain, the kit is INCREMENTAL
|
||||||
|
(archetype by archetype, budget-measured, classic fenced), so it could also drip in as
|
||||||
|
B/E polish rounds. Goldens untouched either way (plan hashes don't see meshes); budgets
|
||||||
|
re-measured per archetype.
|
||||||
|
|
||||||
## The honest sequencing opinion (Fable's, not a ruling)
|
## The honest sequencing opinion (Fable's, not a ruling)
|
||||||
|
|
||||||
The private-test deploy is the design instrument: John walks the game cold and writes down
|
The private-test deploy is the design instrument: John walks the game cold and writes down
|
||||||
|
|||||||
BIN
docs/shots/v7_beta/crate_at_the_counter.png
Normal file
|
After Width: | Height: | Size: 789 KiB |
BIN
docs/shots/v7_beta/first_five_splash.png
Normal file
|
After Width: | Height: | Size: 378 KiB |
BIN
docs/shots/v7_beta/godbay_at_auction.png
Normal file
|
After Width: | Height: | Size: 982 KiB |
BIN
docs/shots/v7_beta/godbay_morning_paper.png
Normal file
|
After Width: | Height: | Size: 705 KiB |
BIN
docs/shots/v7_beta/guide_card.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
docs/shots/v7_beta/rumor_and_wantlist.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/shots/v7_beta/want_card.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
docs/shots/v7_tour/01_you_arrive_hunting.png
Normal file
|
After Width: | Height: | Size: 378 KiB |
BIN
docs/shots/v7_tour/02_the_strip.png
Normal file
|
After Width: | Height: | Size: 863 KiB |
BIN
docs/shots/v7_tour/03_every_door_opens.png
Normal file
|
After Width: | Height: | Size: 741 KiB |
BIN
docs/shots/v7_tour/04_the_gem.png
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
docs/shots/v7_tour/05_the_crate.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/shots/v7_tour/06_the_morning_paper.png
Normal file
|
After Width: | Height: | Size: 715 KiB |
15
docs/shots/v7_tour/README.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# THE TOUR — day one to first gem (v7.0's money-shot sequence, R36)
|
||||||
|
|
||||||
|
Seed 20261990 (Boolarra Heads), one real playthrough driven end to end, no staging:
|
||||||
|
|
||||||
|
1. **01_you_arrive_hunting** — the splash names the mission: hunting HILLS HOIST, $191 in your pocket.
|
||||||
|
2. **02_the_strip** — the cluster-pose spawn: midday on the main street, shopfronts down the block.
|
||||||
|
3. **03_every_door_opens** — inside Curio & Wax; the keeper's rumor overhead.
|
||||||
|
4. **04_the_gem** — THE VERANDAHS "Tinny", asking **$19** against **★ RARE guide $60+**. The
|
||||||
|
skill of the whole game in one card. (Beat ③ visible below — the onboarding walking its talk.)
|
||||||
|
5. **05_the_crate** — the find banked, ⚖ a click away.
|
||||||
|
6. **06_the_morning_paper** — dawn, day 2: **"⚖ SOLD 'Tinny' — hammer $89, $78 after fees."**
|
||||||
|
Bought for $19, hammered for $89. That's the loop.
|
||||||
|
|
||||||
|
Regenerate: the scratch driver lives in the R36 session; it reuses tools/flags_check.py helpers
|
||||||
|
against any port via PROCITY_QA_PORT.
|
||||||
@ -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(basis−1, 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(basis−1, basis/2) with basis ${basis} "
|
||||||
|
f"({'band ' + ent['band'] if ent.get('band') else 'pricePaid'}; < basis, no-pump by construction), "
|
||||||
|
f"collection {n_before}→{after['n']}")
|
||||||
else:
|
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,458 @@ 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(low−1,
|
||||||
|
max(1, low//2)) per item — gems positive, everything else negative, the ledger closes to the cent;
|
||||||
|
(d) scarcity: the emptied bin re-opens EMPTY, stays empty across a reload (pulls ride the save),
|
||||||
|
and (e) negative control: ?game=0 re-opens the same bin FULL — the detector discriminates
|
||||||
|
(scarcity is the save's doing, not the dig's). (f) real stock carries the stamp too."""
|
||||||
|
head('GATE R32: THE BIBLE — guide bands · gems · per-day scarcity (+ ?game=0 control)')
|
||||||
|
BIBLE = {'bargain': (2, 7), 'standard': (8, 24), 'collector': (25, 59), 'grail': (60, None)}
|
||||||
|
exp_offer = lambda band: min(BIBLE[band][0] - 1, max(1, BIBLE[band][0] // 2))
|
||||||
|
b, pg, errs = new_page(p)
|
||||||
|
try:
|
||||||
|
boot(pg, '') # NAKED — dig/game default-on carry the feature
|
||||||
|
r0 = _enter_record_shop(pg)
|
||||||
|
if r0.get('err'): FAIL(f"bible: {r0['err']}"); return
|
||||||
|
# bankroll the buy-everything leg through the REAL import path (5000 covers any 16-sleeve bin)
|
||||||
|
rich = pg.evaluate("""() => { const g = window.PROCITY.game;
|
||||||
|
const ex = JSON.parse(g.export()); ex.cash = 5000; ex.savedAt = Date.now();
|
||||||
|
return { ok: g.import(JSON.stringify(ex)), cash: g.cash }; }""")
|
||||||
|
if not (rich['ok'] and rich['cash'] == 5000):
|
||||||
|
FAIL(f"bible: bankroll import failed — {rich}"); return
|
||||||
|
pg.evaluate("() => window.DBG.setSegment(2)") # import's onDay woke us at DAWN — back to midday
|
||||||
|
# ── (a)+(b): scan EVERY bin in the room off the honest surface — band law on all of them, and
|
||||||
|
# find a bin holding a GEM (seeded: whichever bin it is today, it's that bin forever). The
|
||||||
|
# buy-everything ledger leg then runs on THE GEM'S BIN, so the finder-margin arithmetic is
|
||||||
|
# exercised for real, not vacuously (a 0-gem bin would prove only the losing half).
|
||||||
|
n_bins = (_at_bin(pg) or {}).get('bins', 1)
|
||||||
|
bad, all_offers, gem_bin, gems = [], {}, None, []
|
||||||
|
for bi in range(n_bins):
|
||||||
|
_at_bin(pg, bi)
|
||||||
|
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||||
|
pg.wait_for_timeout(350)
|
||||||
|
offs = pg.evaluate("() => window.PROCITY.interiorMode.digOffers")
|
||||||
|
pg.evaluate("() => { const o = document.querySelector('.pcdg-out'); if (o) o.click(); }")
|
||||||
|
pg.wait_for_timeout(200)
|
||||||
|
if not offs: bad.append(f"bin{bi} unreadable"); continue
|
||||||
|
all_offers[bi] = offs
|
||||||
|
bin_gems = []
|
||||||
|
for o in offs:
|
||||||
|
band = o.get('band')
|
||||||
|
if band not in BIBLE: bad.append(f"bin{bi}#{o['i']} band={band!r}"); continue
|
||||||
|
lo, hi = BIBLE[band]
|
||||||
|
if o['price'] < lo:
|
||||||
|
if band in ('collector', 'grail'): bin_gems.append(o)
|
||||||
|
else: bad.append(f"bin{bi}#{o['i']} {band} ${o['price']} under-band but not gem-eligible")
|
||||||
|
elif hi is not None and o['price'] > hi:
|
||||||
|
bad.append(f"bin{bi}#{o['i']} {band} ${o['price']} over band")
|
||||||
|
if bin_gems and gem_bin is None: gem_bin, gems = bi, bin_gems
|
||||||
|
if bad: FAIL(f"bible: band-law violations — {bad[:3]}")
|
||||||
|
else: OK(f"bible: {sum(len(v) for v in all_offers.values())} parody offers across {n_bins} bin(s) "
|
||||||
|
f"all inside their bands (or seeded gems)")
|
||||||
|
if gem_bin is None:
|
||||||
|
FAIL(f"bible: NO gem in any of {n_bins} bins — the thrill leg is unreachable on this seed"); return
|
||||||
|
for g in gems:
|
||||||
|
if not g['price'] < exp_offer(g['band']):
|
||||||
|
FAIL(f"bible: gem #{g['i']} asks ${g['price']} ≥ keeper offer ${exp_offer(g['band'])} — no finder margin")
|
||||||
|
OK(f"bible: bin{gem_bin} holds {len(gems)} gem(s) — e.g. {gems[0]['band']} asking ${gems[0]['price']} "
|
||||||
|
f"vs keeper ${exp_offer(gems[0]['band'])} (margin by construction)")
|
||||||
|
offers = all_offers[gem_bin]
|
||||||
|
_at_bin(pg, gem_bin)
|
||||||
|
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||||
|
pg.wait_for_timeout(350)
|
||||||
|
# pull the front sleeve → the guide stamp on the card
|
||||||
|
pg.evaluate("""() => { const c = window.PROCITY.renderer.domElement;
|
||||||
|
c.dispatchEvent(new PointerEvent('pointerdown', { clientX: 640, clientY: 360, bubbles: true }));
|
||||||
|
c.dispatchEvent(new PointerEvent('pointerup', { clientX: 640, clientY: 360, bubbles: true })); }""")
|
||||||
|
pg.wait_for_timeout(300)
|
||||||
|
stamp = pg.evaluate("""() => { const p = document.querySelector('.pcdg-panel');
|
||||||
|
return p && p.style.display === 'block' ? p.textContent : null; }""")
|
||||||
|
if stamp and 'guide $' in stamp: OK('bible: the pull card carries the guide stamp (naked boot, parody)')
|
||||||
|
else: FAIL(f"bible: no guide stamp on the pull card — {stamp[:80] if stamp else 'no panel'}")
|
||||||
|
pg.evaluate("""() => { const p = document.querySelector('.pcdg-panel');
|
||||||
|
const x = p && p.querySelector('.x'); if (x) x.click(); }""")
|
||||||
|
# ── (c): buy the WHOLE bin through the real card, then sell it all at the counter ──
|
||||||
|
cash0 = pg.evaluate("() => window.PROCITY.wallet.cash()")
|
||||||
|
bought = 0
|
||||||
|
for _ in range(len(offers)):
|
||||||
|
rb = _dig_buy(pg)
|
||||||
|
if rb.get('err') or rb.get('disabled'): break
|
||||||
|
bought += 1
|
||||||
|
if bought < len(offers): _at_bin(pg, gem_bin) # walk-out re-closed the dig; re-aim the gem's bin
|
||||||
|
if bought != len(offers):
|
||||||
|
FAIL(f"bible: bought {bought}/{len(offers)} — the buy-everything leg died early"); return
|
||||||
|
ask_total = sum(o['price'] for o in offers)
|
||||||
|
offer_total = sum(exp_offer(o['band']) for o in offers)
|
||||||
|
rs = _counter_sell(pg, max_sales=bought + 2)
|
||||||
|
sell_card = pg.evaluate("() => { const p = document.querySelector('.pcsl-panel'); return p ? p.textContent : ''; }")
|
||||||
|
if rs.get('err') or rs.get('sold', 0) != bought:
|
||||||
|
FAIL(f"bible: sold {rs.get('sold')}/{bought} — {rs}"); return
|
||||||
|
cash1 = pg.evaluate("() => window.PROCITY.wallet.cash()")
|
||||||
|
gem_profit = sum(exp_offer(o['band']) - o['price'] for o in gems)
|
||||||
|
if cash1 - cash0 == offer_total - ask_total:
|
||||||
|
OK(f"bible: the ledger closes — Σask ${ask_total} → Σoffer ${offer_total} "
|
||||||
|
f"(net {cash1 - cash0:+d}; gems alone {gem_profit:+d}, every non-gem strictly negative)")
|
||||||
|
else:
|
||||||
|
FAIL(f"bible: ledger mismatch — cash moved {cash1 - cash0:+d}, expected {offer_total - ask_total:+d} "
|
||||||
|
f"(basis law broken somewhere)")
|
||||||
|
# ── (d): scarcity — the emptied bin is EMPTY on re-open, and STAYS empty across a reload ──
|
||||||
|
_at_bin(pg, gem_bin)
|
||||||
|
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||||
|
pg.wait_for_timeout(300)
|
||||||
|
n_re = pg.evaluate("() => (window.PROCITY.interiorMode.digOffers || []).length")
|
||||||
|
pg.evaluate("() => { const o = document.querySelector('.pcdg-out'); if (o) o.click(); }")
|
||||||
|
pulls_n = pg.evaluate("() => (JSON.parse(window.PROCITY.game.export()).pulls || []).length")
|
||||||
|
if n_re == 0 and pulls_n >= bought:
|
||||||
|
OK(f"bible/scarcity: re-opened bin is cleaned out (0 of {bought}) · save carries {pulls_n} pull(s)")
|
||||||
|
else:
|
||||||
|
FAIL(f"bible/scarcity: re-open shows {n_re} sleeves (want 0) · pulls in save {pulls_n}")
|
||||||
|
boot(pg, '') # full reload, same origin — the save must carry it
|
||||||
|
r1 = _enter_record_shop(pg)
|
||||||
|
if r1.get('err'): FAIL(f"bible/scarcity reload: {r1['err']}")
|
||||||
|
else:
|
||||||
|
_at_bin(pg, gem_bin)
|
||||||
|
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||||
|
pg.wait_for_timeout(300)
|
||||||
|
n_reload = pg.evaluate("() => (window.PROCITY.interiorMode.digOffers || []).length")
|
||||||
|
if n_reload == 0: OK('bible/scarcity: still cleaned out after a full reload — pulls rode the save')
|
||||||
|
else: FAIL(f"bible/scarcity: reload restocked the bin ({n_reload} sleeves) — pulls not persisted")
|
||||||
|
# ── (e): negative control — ?game=0 must re-open the SAME bin FULL (rotation gate: game=0 ≡ day1) ──
|
||||||
|
boot(pg, 'game=0')
|
||||||
|
r2 = _enter_record_shop(pg)
|
||||||
|
if r2.get('err'): FAIL(f"bible/control: {r2['err']}")
|
||||||
|
else:
|
||||||
|
_at_bin(pg, gem_bin)
|
||||||
|
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||||
|
pg.wait_for_timeout(300)
|
||||||
|
n_ctl = pg.evaluate("() => (window.PROCITY.interiorMode.digOffers || []).length")
|
||||||
|
if n_ctl == len(offers): OK(f"bible/control: ?game=0 re-opens the bin FULL ({n_ctl}) — "
|
||||||
|
"scarcity is the save's doing, the detector discriminates")
|
||||||
|
else: FAIL(f"bible/control: ?game=0 bin shows {n_ctl}, want {len(offers)}")
|
||||||
|
if 'guide $' in (sell_card or ''): OK('bible: the sell card carries the guide line too')
|
||||||
|
else: WARN('bible: no guide line read off the sell card (cosmetic leg)')
|
||||||
|
if errs: FAIL(f"bible: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||||||
|
else: OK('bible: 0 console errors')
|
||||||
|
finally:
|
||||||
|
b.close()
|
||||||
|
|
||||||
|
|
||||||
|
def smoke_wantlist_travel(p):
|
||||||
|
"""R33 gate — THE WANTLIST + THE RUMOR + TRAVEL COSTS A DAY (v7.0-beta wave 2). Legs:
|
||||||
|
(a) ☆ WANT on the pull card (real path) → the save carries the want; (b) a keeper rumor
|
||||||
|
naming the hunted artist surfaces in some shop's banner (seeded per shop×day — scan);
|
||||||
|
(c) buying the wanted item auto-clears the want; (d) booting ANOTHER town on the same save
|
||||||
|
costs exactly one day (cash/collection intact), (e) re-booting the SAME town costs nothing
|
||||||
|
(the control discriminates), (f) the return trip costs a day again; (g) ?game=0 builds ZERO
|
||||||
|
want DOM and mutters no rumor (construction-gating holds)."""
|
||||||
|
head('GATE R33: wantlist · rumor · travel-costs-a-day (+ same-town and ?game=0 controls)')
|
||||||
|
b, pg, errs = new_page(p)
|
||||||
|
try:
|
||||||
|
boot(pg, '')
|
||||||
|
r0 = _enter_record_shop(pg)
|
||||||
|
if r0.get('err'): FAIL(f"wantlist: {r0['err']}"); return
|
||||||
|
# (a) pull the front sleeve, click ☆ WANT through the real card
|
||||||
|
_at_bin(pg)
|
||||||
|
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||||
|
pg.wait_for_timeout(400)
|
||||||
|
pg.evaluate("""() => { const c = window.PROCITY.renderer.domElement;
|
||||||
|
c.dispatchEvent(new PointerEvent('pointerdown', { clientX: 640, clientY: 360, bubbles: true }));
|
||||||
|
c.dispatchEvent(new PointerEvent('pointerup', { clientX: 640, clientY: 360, bubbles: true })); }""")
|
||||||
|
pg.wait_for_timeout(300)
|
||||||
|
# [R35 amendment] a FRESH game already hunts ONE seeded want (the first-five system) — this
|
||||||
|
# gate's fresh-boot baseline is that want, and the card adds a SECOND on top of it.
|
||||||
|
base = pg.evaluate("() => window.PROCITY.game.wants.length")
|
||||||
|
rw = pg.evaluate("""() => {
|
||||||
|
const w = document.querySelector('.pcdg-panel .want');
|
||||||
|
if (!w) return { err: 'no WANT line on the card' };
|
||||||
|
const before = w.textContent; w.click();
|
||||||
|
const g = window.PROCITY.game, ex = JSON.parse(g.export());
|
||||||
|
return { before, after: w.textContent, wants: ex.wants || [], live: g.wants.length }; }""")
|
||||||
|
if rw.get('err'): FAIL(f"wantlist: {rw['err']}"); return
|
||||||
|
if 'WANT IT' in rw['before'] and 'ON THE WANTLIST' in rw['after'] and len(rw['wants']) == base + 1 and rw['live'] == base + 1:
|
||||||
|
OK(f"wantlist: ☆→★ through the real card — the save carries {len(rw['wants'])} want(s) "
|
||||||
|
f"(the seeded hunt + {rw['wants'][-1]})")
|
||||||
|
else:
|
||||||
|
FAIL(f"wantlist: want did not take — base {base}, {rw}")
|
||||||
|
# the rumor may pick ANY current want (seeded index over the live list)
|
||||||
|
artists = [str(w.get('artist') or w.get('title') or '').upper() for w in rw['wants']]
|
||||||
|
artist = artists[-1]
|
||||||
|
pg.evaluate("""() => { const p = document.querySelector('.pcdg-panel');
|
||||||
|
const x = p && p.querySelector('.x'); if (x) x.click();
|
||||||
|
const o = document.querySelector('.pcdg-out'); if (o) o.click(); }""")
|
||||||
|
pg.wait_for_timeout(200)
|
||||||
|
pg.evaluate("() => window.DBG.exitShop()")
|
||||||
|
pg.wait_for_timeout(300)
|
||||||
|
# (b) the rumor: seeded ~45% per (shop, day) — walk the town's open shops until a keeper mutters
|
||||||
|
rr = pg.evaluate("""() => {
|
||||||
|
const P = window.PROCITY, D = window.DBG;
|
||||||
|
const open = (P.plan.shops || []).filter(s => P.isOpen(s)).slice(0, 10);
|
||||||
|
const seen = [];
|
||||||
|
for (const s of open) {
|
||||||
|
D.enterShop(s.id);
|
||||||
|
const t = [...document.querySelectorAll('div')].map(e => e.textContent)
|
||||||
|
.find(t => t && t.includes('🚪') && t.includes('🗣'));
|
||||||
|
D.exitShop();
|
||||||
|
if (t) return { rumor: t.slice(t.indexOf('🗣')), tried: seen.length + 1 };
|
||||||
|
seen.push(s.id);
|
||||||
|
}
|
||||||
|
return { rumor: null, tried: seen.length }; }""")
|
||||||
|
if rr.get('rumor') and any(a and a in rr['rumor'] for a in artists):
|
||||||
|
OK(f"rumor: a keeper muttered after {rr['tried']} shop(s) — {rr['rumor'][:80]}")
|
||||||
|
else:
|
||||||
|
FAIL(f"rumor: no keeper named any of {artists} in {rr.get('tried')} open shops — {rr}")
|
||||||
|
# (c) buy the wanted sleeve → the want clears itself
|
||||||
|
r1 = _enter_record_shop(pg)
|
||||||
|
if r1.get('err'): FAIL(f"wantlist/buy: {r1['err']}"); return
|
||||||
|
_at_bin(pg)
|
||||||
|
rb = _dig_buy(pg)
|
||||||
|
rc = pg.evaluate("() => ({ wants: window.PROCITY.game.wants.length, n: window.PROCITY.game.collection.length })")
|
||||||
|
if not rb.get('err') and not rb.get('disabled') and rc['wants'] == base and rc['n'] >= 1:
|
||||||
|
OK('wantlist: bought the hunted sleeve — ITS want cleared itself (the seeded hunt stays), '
|
||||||
|
'the find is in the crate')
|
||||||
|
else:
|
||||||
|
FAIL(f"wantlist/buy: {rb} · after {rc} (want base {base})")
|
||||||
|
base = pg.evaluate("() => ({ day: window.PROCITY.game.day, cash: window.PROCITY.game.cash, n: window.PROCITY.game.collection.length })")
|
||||||
|
# (d) travel: another town on the SAME save — the ride costs exactly one day
|
||||||
|
boot(pg, 'plansrc=osm&town=melbourne')
|
||||||
|
t1 = pg.evaluate("() => ({ day: window.PROCITY.game.day, cash: window.PROCITY.game.cash, n: window.PROCITY.game.collection.length, trav: window.PROCITY.game.traveled })")
|
||||||
|
if t1['day'] == base['day'] + 1 and t1['trav'] and t1['cash'] == base['cash'] and t1['n'] == base['n']:
|
||||||
|
OK(f"travel: synthetic → melbourne cost exactly one day (day {base['day']}→{t1['day']}), "
|
||||||
|
f"cash ${t1['cash']} and {t1['n']} find(s) rode along")
|
||||||
|
else:
|
||||||
|
FAIL(f"travel: {base} → {t1} (want day+1, traveled, cash/collection intact)")
|
||||||
|
# (e) same-town control: re-boot melbourne — no day moves, traveled stays false
|
||||||
|
boot(pg, 'plansrc=osm&town=melbourne')
|
||||||
|
t2 = pg.evaluate("() => ({ day: window.PROCITY.game.day, trav: window.PROCITY.game.traveled })")
|
||||||
|
if t2['day'] == t1['day'] and not t2['trav']:
|
||||||
|
OK(f"travel/control: re-booting the SAME town is free (day {t2['day']}, traveled false)")
|
||||||
|
else:
|
||||||
|
FAIL(f"travel/control: same-town reboot moved the day — {t2}")
|
||||||
|
# (f) the return trip costs a day again
|
||||||
|
boot(pg, '')
|
||||||
|
t3 = pg.evaluate("() => ({ day: window.PROCITY.game.day, trav: window.PROCITY.game.traveled })")
|
||||||
|
if t3['day'] == t1['day'] + 1 and t3['trav']:
|
||||||
|
OK(f"travel: the ride home cost a day too (day {t3['day']})")
|
||||||
|
else:
|
||||||
|
FAIL(f"travel: return trip wrong — {t3}")
|
||||||
|
# (g) ?game=0: zero want DOM on the card, zero rumor in any banner
|
||||||
|
boot(pg, 'game=0')
|
||||||
|
r2 = _enter_record_shop(pg)
|
||||||
|
if r2.get('err'): FAIL(f"wantlist/g0: {r2['err']}")
|
||||||
|
else:
|
||||||
|
_at_bin(pg)
|
||||||
|
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||||
|
pg.wait_for_timeout(350)
|
||||||
|
pg.evaluate("""() => { const c = window.PROCITY.renderer.domElement;
|
||||||
|
c.dispatchEvent(new PointerEvent('pointerdown', { clientX: 640, clientY: 360, bubbles: true }));
|
||||||
|
c.dispatchEvent(new PointerEvent('pointerup', { clientX: 640, clientY: 360, bubbles: true })); }""")
|
||||||
|
pg.wait_for_timeout(300)
|
||||||
|
g0 = pg.evaluate("""() => ({
|
||||||
|
want: !!document.querySelector('.pcdg-panel .want'),
|
||||||
|
panel: !!document.querySelector('.pcdg-panel'),
|
||||||
|
rumor: [...document.querySelectorAll('div')].some(e => e.textContent && e.textContent.includes('🗣')) })""")
|
||||||
|
if g0['panel'] and not g0['want'] and not g0['rumor']:
|
||||||
|
OK('wantlist/control: ?game=0 card has ZERO want DOM and no keeper mutters (construction-gated)')
|
||||||
|
else:
|
||||||
|
FAIL(f"wantlist/control: game=0 leaked want/rumor DOM — {g0}")
|
||||||
|
if errs: FAIL(f"wantlist: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||||||
|
else: OK('wantlist: 0 console errors')
|
||||||
|
finally:
|
||||||
|
b.close()
|
||||||
|
|
||||||
|
|
||||||
|
def smoke_godbay(p):
|
||||||
|
"""R34 gate — GODBAY, the offline seeded auction. Legs: (a) ⚖ LIST through the real crate DOM →
|
||||||
|
the entry leaves the collection into save-carried listings; (b) sleep → resolved: listings empty,
|
||||||
|
cash+net (bounded [1, 2×high×0.88 + slack]) XOR the item came home, the wake toast carries the
|
||||||
|
morning paper; (c) THE ONE-HAMMER DETERMINISM: a pre-sleep export imported and slept TWICE lands
|
||||||
|
byte-identical cash/collection both times; (d) travel resolves too (list → ride → hammered on
|
||||||
|
arrival); (e) ?game=0 builds no crate at all (nothing to list from)."""
|
||||||
|
head('GATE R34: GODBAY — the offline auction (one hammer · bounded · morning paper · controls)')
|
||||||
|
BANDHI = {'record': {'bargain': 7, 'standard': 24, 'collector': 59, 'grail': 120},
|
||||||
|
'book': {'bargain': 4, 'standard': 14, 'collector': 39, 'grail': 80},
|
||||||
|
'toy': {'bargain': 5, 'standard': 19, 'collector': 49, 'grail': 100}}
|
||||||
|
b, pg, errs = new_page(p)
|
||||||
|
try:
|
||||||
|
boot(pg, '')
|
||||||
|
r0 = _enter_record_shop(pg)
|
||||||
|
if r0.get('err'): FAIL(f"godbay: {r0['err']}"); return
|
||||||
|
rich = pg.evaluate("""() => { const g = window.PROCITY.game;
|
||||||
|
const ex = JSON.parse(g.export()); ex.cash = 5000; ex.savedAt = Date.now();
|
||||||
|
return g.import(JSON.stringify(ex)); }""")
|
||||||
|
if not rich: FAIL('godbay: bankroll import failed'); return
|
||||||
|
pg.evaluate("() => window.DBG.setSegment(2)")
|
||||||
|
for i in range(2): # two finds: one to list now, one for the travel leg
|
||||||
|
_at_bin(pg)
|
||||||
|
rb = _dig_buy(pg)
|
||||||
|
if rb.get('err') or rb.get('disabled'): FAIL(f"godbay: buy {i + 1} failed — {rb}"); return
|
||||||
|
# (a) ⚖ LIST through the real crate DOM
|
||||||
|
rl = pg.evaluate("""() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyC' }));
|
||||||
|
const g = window.PROCITY.game, n0 = g.collection.length;
|
||||||
|
const lb = document.querySelector('#pc-crate .pc-crate-row div[title*="GodBay"]');
|
||||||
|
if (!lb) return { err: 'no ⚖ LIST control in the crate' };
|
||||||
|
lb.click();
|
||||||
|
const ex = JSON.parse(g.export());
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyC' }));
|
||||||
|
return { n0, n1: g.collection.length, listings: (ex.listings || []).length,
|
||||||
|
entry: (ex.listings || [])[0] || null }; }""")
|
||||||
|
if rl.get('err'): FAIL(f"godbay: {rl['err']}"); return
|
||||||
|
if rl['n1'] == rl['n0'] - 1 and rl['listings'] == 1 and rl['entry'] and rl['entry'].get('listDay'):
|
||||||
|
OK(f"godbay: consigned through the crate — collection {rl['n0']}→{rl['n1']}, "
|
||||||
|
f"the save carries the listing (listDay {rl['entry']['listDay']})")
|
||||||
|
else:
|
||||||
|
FAIL(f"godbay: listing did not take — {rl}"); return
|
||||||
|
# (c) THE ONE-HAMMER DETERMINISM: freeze pre-sleep, sleep twice from the same frozen state
|
||||||
|
pre = pg.evaluate("() => window.PROCITY.game.export()")
|
||||||
|
outcomes = []
|
||||||
|
for _ in range(2):
|
||||||
|
pg.evaluate("(ex) => window.PROCITY.game.import(ex)", pre)
|
||||||
|
pg.evaluate("() => { document.getElementById('pc-sleep-btn').click(); }")
|
||||||
|
pg.wait_for_timeout(250)
|
||||||
|
toast = pg.evaluate("() => (document.getElementById('pc-toast') || {}).textContent || ''")
|
||||||
|
st = pg.evaluate("""() => { const g = window.PROCITY.game;
|
||||||
|
const ex = JSON.parse(g.export()); delete ex.savedAt;
|
||||||
|
return { cash: g.cash, n: g.collection.length, nl: g.listings.length, ex: JSON.stringify(ex) }; }""")
|
||||||
|
st['toast'] = toast
|
||||||
|
outcomes.append(st)
|
||||||
|
a, b2 = outcomes
|
||||||
|
if a['ex'] == b2['ex'] and a['cash'] == b2['cash']:
|
||||||
|
OK('godbay: ONE HAMMER — the same frozen state slept twice lands byte-identical both times')
|
||||||
|
else:
|
||||||
|
FAIL(f"godbay: hammer not deterministic — {a['cash']} vs {b2['cash']}")
|
||||||
|
# (b) resolution honesty + bounds + the morning paper
|
||||||
|
pre_cash = json.loads(pre)['cash']; pre_n = len(json.loads(pre)['collection'])
|
||||||
|
ent = rl['entry']; hi = BANDHI.get(ent.get('type'), BANDHI['record']).get(ent.get('band'), 120)
|
||||||
|
sold = a['cash'] > pre_cash; came_home = a['n'] == pre_n + 1
|
||||||
|
net = a['cash'] - pre_cash
|
||||||
|
if a['nl'] == 0 and (sold != came_home) and (not sold or 1 <= net <= int(2.0 * hi * 0.88) + 2):
|
||||||
|
OK(f"godbay: resolved — {'SOLD net $' + str(net) + f' (≤ 2×${hi}×0.88 bound)' if sold else 'passed in, came home'}"
|
||||||
|
f" · listings drained")
|
||||||
|
else:
|
||||||
|
FAIL(f"godbay: resolution dishonest — nl={a['nl']} sold={sold} home={came_home} net={net} hi={hi}")
|
||||||
|
if '⚖' in a['toast']:
|
||||||
|
OK(f"godbay: the wake toast carried the morning paper — {a['toast'][:90]}")
|
||||||
|
else:
|
||||||
|
FAIL(f"godbay: no ⚖ in the wake toast — {a['toast'][:90]!r}")
|
||||||
|
# (d) travel resolves too: list the second find, ride to melbourne
|
||||||
|
r2 = pg.evaluate("""() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyC' }));
|
||||||
|
const g = window.PROCITY.game;
|
||||||
|
const lb = document.querySelector('#pc-crate .pc-crate-row div[title*="GodBay"]');
|
||||||
|
if (!lb) return { err: 'no ⚖ for the travel leg (collection empty?)' };
|
||||||
|
lb.click();
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyC' }));
|
||||||
|
return { cash: g.cash, nl: g.listings.length, n: g.collection.length }; }""")
|
||||||
|
if r2.get('err'): FAIL(f"godbay/travel: {r2['err']}")
|
||||||
|
else:
|
||||||
|
boot(pg, 'plansrc=osm&town=melbourne')
|
||||||
|
t = pg.evaluate("""() => { const g = window.PROCITY.game;
|
||||||
|
return { nl: g.listings.length, cash: g.cash, n: g.collection.length, trav: g.traveled }; }""")
|
||||||
|
if t['trav'] and t['nl'] == 0 and (t['cash'] > r2['cash'] or t['n'] == r2['n'] + 1):
|
||||||
|
OK(f"godbay/travel: the ride to melbourne hammered the listing (cash ${r2['cash']}→${t['cash']}, "
|
||||||
|
f"collection {r2['n']}→{t['n']})")
|
||||||
|
else:
|
||||||
|
FAIL(f"godbay/travel: {r2} → {t}")
|
||||||
|
# (e) ?game=0 — no crate, nothing to list from
|
||||||
|
boot(pg, 'game=0')
|
||||||
|
g0 = pg.evaluate("() => ({ crate: !!document.getElementById('pc-crate'), game: !!window.PROCITY.game })")
|
||||||
|
if not g0['crate'] and not g0['game']: OK('godbay/control: ?game=0 — no game, no crate, no auction house')
|
||||||
|
else: FAIL(f"godbay/control: game=0 leaked — {g0}")
|
||||||
|
if errs: FAIL(f"godbay: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||||||
|
else: OK('godbay: 0 console errors')
|
||||||
|
finally:
|
||||||
|
b.close()
|
||||||
|
|
||||||
|
|
||||||
|
def smoke_first_five(p):
|
||||||
|
"""R35 gate — THE FIRST FIVE MINUTES (charter #7). Legs: (a) a FRESH game arrives with a seeded
|
||||||
|
first want (parody roster) and the splash names the hunt + the pocket money; (b) the want is
|
||||||
|
the SAME artist in a second fresh browser (seeded, not random); (c) the beats walk the hustle:
|
||||||
|
E at a crate → '②', a buy → '③', a ⚖ LIST → the send-off; (d) a RETURNING save gets no
|
||||||
|
tutorial (splash line hidden, no '②' on dig); (e) ?game=0 shows no hunt line at all."""
|
||||||
|
head('GATE R35: THE FIRST FIVE MINUTES — seeded want · splash · beats (+ returning/?game=0 controls)')
|
||||||
|
ROSTER = {'THE VERANDAHS', 'DEZ & THE UTES', 'MERINO', 'HILLS HOIST', 'THE ESKIES', 'GOON BAG',
|
||||||
|
'STOBIE POLES', 'THE FIBROS', 'WARATAH', 'THONGS', 'THE CLOTHESLINE', 'GALAH'}
|
||||||
|
b, pg, errs = new_page(p)
|
||||||
|
try:
|
||||||
|
boot(pg, '')
|
||||||
|
fa = pg.evaluate("""() => { const g = window.PROCITY.game, h = document.getElementById('pc-hunt');
|
||||||
|
return { wants: g.wants.map(w => w.artist), cash: g.cash,
|
||||||
|
hunt: h ? { shown: h.style.display !== 'none', text: h.textContent } : null }; }""")
|
||||||
|
if (len(fa['wants']) == 1 and fa['wants'][0] in ROSTER and fa['hunt'] and fa['hunt']['shown']
|
||||||
|
and fa['wants'][0] in fa['hunt']['text'] and f"${fa['cash']}" in fa['hunt']['text']):
|
||||||
|
OK(f"first-five: fresh game hunts {fa['wants'][0]} with ${fa['cash']} — the splash says so")
|
||||||
|
else:
|
||||||
|
FAIL(f"first-five: seeded want/splash wrong — {fa}")
|
||||||
|
# (c) the beats, through the real paths
|
||||||
|
r0 = _enter_record_shop(pg)
|
||||||
|
if r0.get('err'): FAIL(f"first-five: {r0['err']}"); return
|
||||||
|
pg.evaluate("""() => { const g = window.PROCITY.game;
|
||||||
|
const ex = JSON.parse(g.export()); ex.cash = 5000; ex.savedAt = Date.now(); g.import(JSON.stringify(ex)); }""")
|
||||||
|
pg.evaluate("() => window.DBG.setSegment(2)")
|
||||||
|
_at_bin(pg)
|
||||||
|
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||||
|
pg.wait_for_timeout(350)
|
||||||
|
t2 = pg.evaluate("() => (document.getElementById('pc-toast') || {}).textContent || ''")
|
||||||
|
pg.evaluate("() => { const o = document.querySelector('.pcdg-out'); if (o) o.click(); }")
|
||||||
|
pg.wait_for_timeout(200)
|
||||||
|
_at_bin(pg)
|
||||||
|
rb = _dig_buy(pg)
|
||||||
|
t3 = pg.evaluate("() => (document.getElementById('pc-toast') || {}).textContent || ''")
|
||||||
|
rl = pg.evaluate("""() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyC' }));
|
||||||
|
const lb = document.querySelector('#pc-crate .pc-crate-row div[title*="GodBay"]');
|
||||||
|
if (lb) lb.click();
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyC' }));
|
||||||
|
return (document.getElementById('pc-toast') || {}).textContent || ''; }""")
|
||||||
|
if '②' in t2 and '③' in t3 and 'whole hustle' in rl:
|
||||||
|
OK('first-five: the beats walked the hustle — ② at the crate, ③ on the buy, the send-off on the ⚖')
|
||||||
|
else:
|
||||||
|
FAIL(f"first-five: beats wrong — ②:{('②' in t2)} ③:{('③' in t3)} send-off:{('whole hustle' in rl)}")
|
||||||
|
# (d) a returning save gets no tutorial
|
||||||
|
boot(pg, '')
|
||||||
|
rd = pg.evaluate("""() => { const h = document.getElementById('pc-hunt');
|
||||||
|
return { shown: h && h.style.display !== 'none', n: window.PROCITY.game.collection.length,
|
||||||
|
nl: window.PROCITY.game.listings.length }; }""")
|
||||||
|
r1 = _enter_record_shop(pg)
|
||||||
|
if r1.get('err'): FAIL(f"first-five/return: {r1['err']}")
|
||||||
|
else:
|
||||||
|
_at_bin(pg)
|
||||||
|
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||||
|
pg.wait_for_timeout(350)
|
||||||
|
tr = pg.evaluate("""() => { const t = document.getElementById('pc-toast');
|
||||||
|
return { text: t ? t.textContent : '', vis: t ? t.style.opacity : '0' }; }""")
|
||||||
|
if not rd['shown'] and not ('②' in tr['text'] and tr['vis'] == '1'):
|
||||||
|
OK(f"first-five/return: a save with {rd['n']} find(s)/{rd['nl']} listing(s) gets no tutorial")
|
||||||
|
else:
|
||||||
|
FAIL(f"first-five/return: tutorial leaked to a returning save — {rd} {tr}")
|
||||||
|
# (b) determinism: a second FRESH browser hunts the same artist
|
||||||
|
b2, pg2, _ = new_page(p)
|
||||||
|
try:
|
||||||
|
boot(pg2, '')
|
||||||
|
w2 = pg2.evaluate("() => window.PROCITY.game.wants.map(w => w.artist)")
|
||||||
|
if w2 == fa['wants']: OK(f"first-five: a second fresh browser hunts the same {w2[0]} — seeded, not random")
|
||||||
|
else: FAIL(f"first-five: fresh want differs across browsers — {fa['wants']} vs {w2}")
|
||||||
|
finally:
|
||||||
|
b2.close()
|
||||||
|
# (e) ?game=0 — no hunt line
|
||||||
|
boot(pg, 'game=0')
|
||||||
|
g0 = pg.evaluate("""() => { const h = document.getElementById('pc-hunt');
|
||||||
|
return h ? h.style.display !== 'none' : false; }""")
|
||||||
|
if not g0: OK('first-five/control: ?game=0 — no hunt, no tutorial')
|
||||||
|
else: FAIL('first-five/control: game=0 shows the hunt line')
|
||||||
|
if errs: FAIL(f"first-five: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||||||
|
else: OK('first-five: 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 +2600,10 @@ 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_wantlist_travel(p) # R33 — wantlist · keeper rumor · travel-costs-a-day (+ controls)
|
||||||
|
smoke_godbay(p) # R34 — GODBAY offline auction (one hammer · bounded · morning paper)
|
||||||
|
smoke_first_five(p) # R35 — the first five minutes (seeded want · splash · beats + controls)
|
||||||
smoke_tier2(p) # R27 — the kill-the-server gate (charter law #1)
|
smoke_tier2(p) # R27 — the kill-the-server gate (charter law #1)
|
||||||
smoke_real_crate(p) # R24 — THE REAL CRATE (per-shop atlas identity · soft fall · no-op off the path)
|
smoke_real_crate(p) # R24 — THE REAL CRATE (per-shop atlas identity · soft fall · no-op off the path)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@ -32,6 +32,7 @@
|
|||||||
<h1>PROCITY</h1>
|
<h1>PROCITY</h1>
|
||||||
<div class="sub">A procedurally generated, walkable 90s-Australian shopping town.
|
<div class="sub">A procedurally generated, walkable 90s-Australian shopping town.
|
||||||
Every door opens. Dig the crates, sell the flips, sleep — the town remembers.</div>
|
Every door opens. Dig the crates, sell the flips, sleep — the town remembers.</div>
|
||||||
|
<div class="sub" id="pc-hunt" style="color:#ffd75e;display:none"></div>
|
||||||
<button id="pc-go">walk into town</button>
|
<button id="pc-go">walk into town</button>
|
||||||
<div class="seed" id="pc-startseed"></div>
|
<div class="seed" id="pc-startseed"></div>
|
||||||
<div class="sub" style="font-size:12px;opacity:.7">WASD move · shift run · mouse look · click a door · E riffle a bin / sell at the counter · C your crate · SLEEP to save · [ ] time · M map</div>
|
<div class="sub" style="font-size:12px;opacity:.7">WASD move · shift run · mouse look · click a door · E riffle a bin / sell at the counter · C your crate · SLEEP to save · [ ] time · M map</div>
|
||||||
@ -63,6 +64,8 @@ import { loadPedFleet } from './js/citizens/rigs.js'; // [Lane F §3
|
|||||||
import { VenueQueue } from './js/citizens/queue.js'; // [Lane F R13] Lane D outdoor gig queue (?gigs=1)
|
import { VenueQueue } from './js/citizens/queue.js'; // [Lane F R13] Lane D outdoor gig queue (?gigs=1)
|
||||||
import { preloadManifest, preloadStockPack } from './js/interiors/interiors.js'; // [Lane F] Lane E manifest + Lane C/E stock pack
|
import { preloadManifest, preloadStockPack } from './js/interiors/interiors.js'; // [Lane F] Lane E manifest + Lane C/E stock pack
|
||||||
import { createGame } from './js/world/save.js'; // [Lane F R30] v7 THE SAVE CORE (contract: LANE_F_NOTES §30)
|
import { createGame } from './js/world/save.js'; // [Lane F R30] v7 THE SAVE CORE (contract: LANE_F_NOTES §30)
|
||||||
|
import { ARTISTS } from './js/interiors/dig.js'; // [R35] the parody roster — a fresh game's seeded first want
|
||||||
|
import { rng, pick } from './js/core/prng.js'; // [R35] seeded first-want draw (runtime stream, fresh key)
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error('[procity] init failed:', err);
|
console.error('[procity] init failed:', err);
|
||||||
@ -168,7 +171,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 };
|
||||||
@ -187,7 +191,7 @@ const minimap = createMinimap(plan);
|
|||||||
// unless) a GLB lands, so the town is fully playable the instant it boots.
|
// unless) a GLB lands, so the town is fully playable the instant it boots.
|
||||||
// • rig fleet: models/peds/*.glb → shared GLB actors; CitizenSim + keepers upgrade from placeholders.
|
// • rig fleet: models/peds/*.glb → shared GLB actors; CitizenSim + keepers upgrade from placeholders.
|
||||||
// • manifest: Lane E's assets/manifest.json → interior fitting GLBs (loaded from the 3GOD depot).
|
// • manifest: Lane E's assets/manifest.json → interior fitting GLBs (loaded from the 3GOD depot).
|
||||||
const fleet = NOASSETS ? null : loadPedFleet('models/peds/', { sit: !CLASSIC, look: !CLASSIC }); // [R16] sit.glb (the seated drummer) + [R29] look.glb (the street glance) on the default boot; ?classic keeps the zero-fetch-delta covenant
|
const fleet = NOASSETS ? null : loadPedFleet('models/peds/', { sit: !CLASSIC, look: !CLASSIC, dance: !CLASSIC }); // [R16] sit.glb (the seated drummer) + [R29] look.glb (the street glance) on the default boot; ?classic keeps the zero-fetch-delta covenant
|
||||||
if (!NOASSETS) preloadManifest(); // prime the manifest so the first interior can upgrade its fittings
|
if (!NOASSETS) preloadManifest(); // prime the manifest so the first interior can upgrade its fittings
|
||||||
// [Lane F integration] interior bridge — owns the 'interior' branch (Lane C buildInterior + Lane D keeper)
|
// [Lane F integration] interior bridge — owns the 'interior' branch (Lane C buildInterior + Lane D keeper)
|
||||||
// [Lane F R31 — THE DIG FLIP] The crate-riffle is DEFAULT-ON: `?dig=0` opts out, `?classic=1` forces it off
|
// [Lane F R31 — THE DIG FLIP] The crate-riffle is DEFAULT-ON: `?dig=0` opts out, `?classic=1` forces it off
|
||||||
@ -263,7 +267,9 @@ const game = GAME_ON ? createGame({
|
|||||||
onDay: (day) => {
|
onDay: (day) => {
|
||||||
if (gigState) gigState.setWeekNight(weekNightOf(day)); // (day − 1) % 7 — see the seam note below
|
if (gigState) gigState.setWeekNight(weekNightOf(day)); // (day − 1) % 7 — see the seam note below
|
||||||
lighting.setSegment(0);
|
lighting.setSegment(0);
|
||||||
hud.showToast(`🌅 day ${day}`);
|
// [R34] the morning paper: GODBAY resolved before this hook fired — fold the results in
|
||||||
|
const news = game ? game.takeAuctionNews() : [];
|
||||||
|
hud.showToast(`🌅 day ${day}` + (news.length ? ' · ' + news.join(' · ') : ''), news.length ? 6 : 2.6);
|
||||||
},
|
},
|
||||||
}) : null;
|
}) : null;
|
||||||
const wallet = game ? game.wallet : baseWallet;
|
const wallet = game ? game.wallet : baseWallet;
|
||||||
@ -280,6 +286,49 @@ const gigState = (GIGS_ON && plan.gigs && plan.gigs.length) ? createGigState({ p
|
|||||||
// day-1 stock streams (§30.3). Skipped entirely when either layer is off.
|
// day-1 stock streams (§30.3). Skipped entirely when either layer is off.
|
||||||
const weekNightOf = (day) => (Math.max(1, day) - 1) % 7;
|
const weekNightOf = (day) => (Math.max(1, day) - 1) % 7;
|
||||||
if (game && gigState) gigState.setWeekNight(weekNightOf(game.day));
|
if (game && gigState) gigState.setWeekNight(weekNightOf(game.day));
|
||||||
|
// [R33 — TRAVEL COSTS A DAY] this boot adopted a save from another town: the ride ate a day (save.js
|
||||||
|
// adopt() did the increment + pull-pruning; weekNight above already read the incremented day). Tell John.
|
||||||
|
// [R34] the ride also resolved any GODBAY listings the day moved past — the arrival toast carries the news.
|
||||||
|
if (game) {
|
||||||
|
const bootNews = game.takeAuctionNews();
|
||||||
|
if (game.traveled) hud.showToast(`🚌 ${plan.name || 'a new town'} — the ride cost a day. Day ${game.day}`
|
||||||
|
+ (bootNews.length ? ' · ' + bootNews.join(' · ') : ''), bootNews.length ? 7 : 4);
|
||||||
|
else if (bootNews.length) hud.showToast(bootNews.join(' · '), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── [R35 — THE FIRST FIVE MINUTES (charter #7)] ────────────────────────────────────────────────
|
||||||
|
// A stranger arrives with cash and A WANT, and three toast-beats walk the whole hustle: dig →
|
||||||
|
// buy → sell-or-list. FRESH games only (day 1, empty crate, empty auction house) — a returning
|
||||||
|
// save gets its town back, not a tutorial. ?game=0 / ?classic=1 never reach this block. The
|
||||||
|
// first want is SEEDED (citySeed → the parody roster), so the splash line, the keeper rumors,
|
||||||
|
// and the tour all point at the same hunt on a given seed — deterministic, gate-checkable.
|
||||||
|
if (game && game.day === 1 && !game.collection.length && !game.listings.length) {
|
||||||
|
if (!game.wants.length) game.recordWant({ artist: pick(rng(plan.citySeed, 'firstwant', 0), ARTISTS) });
|
||||||
|
const hunt = document.getElementById('pc-hunt');
|
||||||
|
if (hunt && game.wants.length) {
|
||||||
|
hunt.textContent = `You're hunting ${game.wants[0].artist} — $${game.cash} in your pocket.`;
|
||||||
|
hunt.style.display = 'block';
|
||||||
|
}
|
||||||
|
let stage = 1;
|
||||||
|
const go = document.getElementById('pc-go');
|
||||||
|
if (go) go.addEventListener('click', () =>
|
||||||
|
setTimeout(() => { if (stage === 1) hud.showToast('① find a record shop — walk in, press E at a crate', 7); }, 900),
|
||||||
|
{ once: true });
|
||||||
|
window.addEventListener('procity:digOpen', () => {
|
||||||
|
if (stage === 1) { stage = 2; hud.showToast('② riffle with the wheel · pull a sleeve · mind the guide — BUY', 7); }
|
||||||
|
});
|
||||||
|
let n3 = 0;
|
||||||
|
const offBeats = game.wallet.onChange(() => {
|
||||||
|
if (stage <= 2 && game.collection.length) {
|
||||||
|
stage = 3; n3 = game.collection.length;
|
||||||
|
hud.showToast('③ yours now. Sell it at a counter (E) — or ⚖ LIST it in the crate and SLEEP on it', 8);
|
||||||
|
} else if (stage === 3 && (game.listings.length || game.collection.length < n3)) {
|
||||||
|
stage = 4;
|
||||||
|
hud.showToast('That’s the whole hustle. ☆ want things · keepers gossip · 🚌 towns cost a day. Good digging.', 9);
|
||||||
|
offBeats();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
// [Lane F R9] occupancyOf closure defers to `citizens` (declared just below); only invoked at enter()
|
// [Lane F R9] occupancyOf closure defers to `citizens` (declared just below); only invoked at enter()
|
||||||
// time (door click), long after citizens is initialized — so the forward reference is safe.
|
// time (door click), long after citizens is initialized — so the forward reference is safe.
|
||||||
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS,
|
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS,
|
||||||
@ -354,8 +403,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 +586,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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -138,7 +138,7 @@ export class GigCrew {
|
|||||||
// one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot.
|
// one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot.
|
||||||
// `seated` (R16): the drummer — use E's sit.glb pose (fleet.sitClip) + spawnRig's foot-replant + tag the
|
// `seated` (R16): the drummer — use E's sit.glb pose (fleet.sitClip) + spawnRig's foot-replant + tag the
|
||||||
// fig for F's stature gate; falls back to the standing idle (caller sinks it via SEAT_DROP) if no sitClip.
|
// fig for F's stature gate; falls back to the standing idle (caller sinks it via SEAT_DROP) if no sitClip.
|
||||||
_make(target, key, { x, y, z, ry, height, pedIndex = null, seated = false }) {
|
_make(target, key, { x, y, z, ry, height, pedIndex = null, seated = false, dance = false }) {
|
||||||
const r = rng(this.citySeed, 'gig', key);
|
const r = rng(this.citySeed, 'gig', key);
|
||||||
const h = height != null ? height : 1.6 + r() * 0.3;
|
const h = height != null ? height : 1.6 + r() * 0.3;
|
||||||
let actor, kind, usedIndex = null;
|
let actor, kind, usedIndex = null;
|
||||||
@ -146,7 +146,10 @@ export class GigCrew {
|
|||||||
const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r());
|
const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r());
|
||||||
const rig = pk && this.fleet.all[pk.index];
|
const rig = pk && this.fleet.all[pk.index];
|
||||||
const useSit = seated && !!this.fleet.sitClip; // real seated pose iff E's sit clip is loaded
|
const useSit = seated && !!this.fleet.sitClip; // real seated pose iff E's sit clip is loaded
|
||||||
const sp = rig && spawnRig(rig, { ry, height: h, clip: useSit ? this.fleet.sitClip : this.fleet.idleClip, phase: r(), seated: useSit });
|
// dancers play a real dance clip (separate 'gigdance' stream keeps the pick off the existing draws)
|
||||||
|
const dcs = this.fleet.danceClips || [];
|
||||||
|
const danceClip = (dance && dcs.length) ? dcs[(rng(this.citySeed, 'gigdance', key)() * dcs.length) | 0] : null;
|
||||||
|
const sp = rig && spawnRig(rig, { ry, height: h, clip: useSit ? this.fleet.sitClip : (danceClip || this.fleet.idleClip), phase: r(), seated: useSit });
|
||||||
if (sp) { actor = sp; kind = 'rig'; usedIndex = pk.index; if (useSit) sp.fig.userData.procitySeated = true; }
|
if (sp) { actor = sp; kind = 'rig'; usedIndex = pk.index; if (useSit) sp.fig.userData.procitySeated = true; }
|
||||||
}
|
}
|
||||||
if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'gig-body', key), { height: h }); actor.fig.rotation.y = ry; kind = 'placeholder'; }
|
if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'gig-body', key), { height: h }); actor.fig.rotation.y = ry; kind = 'placeholder'; }
|
||||||
@ -200,8 +203,8 @@ export class GigCrew {
|
|||||||
pts.forEach((w, i) => {
|
pts.forEach((w, i) => {
|
||||||
const who = roster[i] || null;
|
const who = roster[i] || null;
|
||||||
const { actor, kind, pedIndex } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`,
|
const { actor, kind, pedIndex } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`,
|
||||||
who ? { x: w.x, y: 0, z: w.z, ry: w.ry, pedIndex: who.pedIndex, height: who.height }
|
who ? { x: w.x, y: 0, z: w.z, ry: w.ry, pedIndex: who.pedIndex, height: who.height, dance: !!w.dance }
|
||||||
: { x: w.x, y: 0, z: w.z, ry: w.ry });
|
: { x: w.x, y: 0, z: w.z, ry: w.ry, dance: !!w.dance });
|
||||||
if (who) fromRoster++;
|
if (who) fromRoster++;
|
||||||
this.members.push({ actor, kind, pedIndex, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false, fromRoster: !!who,
|
this.members.push({ actor, kind, pedIndex, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false, fromRoster: !!who,
|
||||||
base: { x: w.x, y: 0, z: w.z, ry: w.ry }, phase: rng(this.citySeed, 'gigp', `c${gid}:${w.slotIndex}`)() * Math.PI * 2, extra: [] });
|
base: { x: w.x, y: 0, z: w.z, ry: w.ry }, phase: rng(this.citySeed, 'gigp', `c${gid}:${w.slotIndex}`)() * Math.PI * 2, extra: [] });
|
||||||
@ -284,10 +287,14 @@ export class GigCrew {
|
|||||||
f.rotation.z = Math.sin(t * 3.1 + ph) * 0.055;
|
f.rotation.z = Math.sin(t * 3.1 + ph) * 0.055;
|
||||||
f.position.y = b.y + Math.abs(Math.sin(t * 3.1 + ph)) * 0.02;
|
f.position.y = b.y + Math.abs(Math.sin(t * 3.1 + ph)) * 0.02;
|
||||||
}
|
}
|
||||||
} else if (m.dance) { // dancer: bounce + sway + a little step
|
} else if (m.dance) { // dancer
|
||||||
f.position.y = b.y + Math.abs(Math.sin(t * 5.2 + ph)) * 0.075;
|
if (this.fleet && this.fleet.danceClips && this.fleet.danceClips.length) {
|
||||||
f.rotation.z = Math.sin(t * 2.6 + ph) * 0.07;
|
f.rotation.y = b.ry + Math.sin(t * 0.6 + ph) * 0.10; // real dance clip drives the body; just a slow facing drift
|
||||||
f.rotation.y = b.ry + Math.sin(t * 1.7 + ph) * 0.13;
|
} else { // fallback (classic/noassets): procedural bounce + sway
|
||||||
|
f.position.y = b.y + Math.abs(Math.sin(t * 5.2 + ph)) * 0.075;
|
||||||
|
f.rotation.z = Math.sin(t * 2.6 + ph) * 0.07;
|
||||||
|
f.rotation.y = b.ry + Math.sin(t * 1.7 + ph) * 0.13;
|
||||||
|
}
|
||||||
} else { // stander: slow weight shift, feet planted
|
} else { // stander: slow weight shift, feet planted
|
||||||
f.rotation.z = Math.sin(t * 0.9 + ph) * 0.022;
|
f.rotation.z = Math.sin(t * 0.9 + ph) * 0.022;
|
||||||
f.rotation.y = b.ry + Math.sin(t * 0.5 + ph) * 0.03;
|
f.rotation.y = b.ry + Math.sin(t * 0.5 + ph) * 0.03;
|
||||||
|
|||||||
@ -18,7 +18,8 @@ export const PED_NAMES = {
|
|||||||
normal: ['man_worker_hivis_01', 'man_suit_01', 'man_casual_01', 'man_casual_02', 'man_casual_03',
|
normal: ['man_worker_hivis_01', 'man_suit_01', 'man_casual_01', 'man_casual_02', 'man_casual_03',
|
||||||
'man_grunge_01', 'man_elder_01', 'man_athletic_01', 'man_bouncer_01', 'man_soldier_ww2_01',
|
'man_grunge_01', 'man_elder_01', 'man_athletic_01', 'man_bouncer_01', 'man_soldier_ww2_01',
|
||||||
'man_dj_streetwear_01', 'woman_casual_01', 'woman_casual_02', 'woman_business_01',
|
'man_dj_streetwear_01', 'woman_casual_01', 'woman_casual_02', 'woman_business_01',
|
||||||
'woman_athleisure_01', 'woman_smart_01', 'woman_chef_01'],
|
'woman_athleisure_01', 'woman_smart_01', 'woman_chef_01',
|
||||||
|
'woman_dj_01', 'woman_dj_02', 'woman_dj_03', 'dj_techno_01', 'dj_phrtt_01'], // trellis'd DJ bodies (fbx_to_ped, 4k tris)
|
||||||
comical: ['comical_luchador_01', 'comical_boy_01'],
|
comical: ['comical_luchador_01', 'comical_boy_01'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -55,10 +56,10 @@ function loadRig(ref) {
|
|||||||
// sit.glb fetch would break the ?classic=1 zero-fetch-delta covenant — F passes sit:true on the gig/default
|
// sit.glb fetch would break the ?classic=1 zero-fetch-delta covenant — F passes sit:true on the gig/default
|
||||||
// boot, sit:false (default) under ?classic. Absent/off → fleet.sitClip stays null and the drummer falls
|
// boot, sit:false (default) under ?classic. Absent/off → fleet.sitClip stays null and the drummer falls
|
||||||
// back to the standing-sunk hack, so this never breaks a boot. Does NOT touch fleet.ready / determinism.
|
// back to the standing-sunk hack, so this never breaks a boot. Does NOT touch fleet.ready / determinism.
|
||||||
export function loadPedFleet(base = 'models/peds/', { sit = false, look = false } = {}) {
|
export function loadPedFleet(base = 'models/peds/', { sit = false, look = false, dance = false } = {}) {
|
||||||
const fleet = {
|
const fleet = {
|
||||||
normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas
|
normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas
|
||||||
walkClip: null, idleClip: null, sitClip: null, lookClip: null,
|
walkClip: null, idleClip: null, sitClip: null, lookClip: null, danceClips: [],
|
||||||
ready: false, whenReady: null,
|
ready: false, whenReady: null,
|
||||||
};
|
};
|
||||||
// DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array
|
// DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array
|
||||||
@ -89,10 +90,19 @@ export function loadPedFleet(base = 'models/peds/', { sit = false, look = false
|
|||||||
if (look) jobs.push(loadRig(`${base}look.glb`).then(r => {
|
if (look) jobs.push(loadRig(`${base}look.glb`).then(r => {
|
||||||
const c = r && r.anims && r.anims[0];
|
const c = r && r.anims && r.anims[0];
|
||||||
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.lookClip = _rotOnly(c); } }));
|
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.lookClip = _rotOnly(c); } }));
|
||||||
|
// dance clips (mesh-free mixamorig): the club dancers play a REAL move instead of procedural sway.
|
||||||
|
// GATED like sit/look — ?classic fetches nothing so the zero-fetch-delta covenant holds. The per-dancer
|
||||||
|
// pick is deterministic (a separate seeded stream in _make), so 'same seed -> same crowd' is preserved.
|
||||||
|
const DANCE_CLIPS = ['dance_party', 'dance_medium', 'dance_drink', 'dance_sway'];
|
||||||
|
const danceSlots = new Array(DANCE_CLIPS.length).fill(null);
|
||||||
|
if (dance) DANCE_CLIPS.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => {
|
||||||
|
const c = r && r.anims && r.anims[0];
|
||||||
|
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); danceSlots[i] = _rotOnly(c); } })));
|
||||||
fleet.whenReady = Promise.all(jobs).then(() => {
|
fleet.whenReady = Promise.all(jobs).then(() => {
|
||||||
fleet.normal = nSlots.filter(Boolean);
|
fleet.normal = nSlots.filter(Boolean);
|
||||||
fleet.comical = cSlots.filter(Boolean);
|
fleet.comical = cSlots.filter(Boolean);
|
||||||
fleet.all = [...fleet.normal, ...fleet.comical];
|
fleet.all = [...fleet.normal, ...fleet.comical];
|
||||||
|
fleet.danceClips = danceSlots.filter(Boolean);
|
||||||
fleet.ready = fleet.all.length > 0 && !!fleet.walkClip;
|
fleet.ready = fleet.all.length > 0 && !!fleet.walkClip;
|
||||||
return fleet;
|
return fleet;
|
||||||
});
|
});
|
||||||
|
|||||||
58
web/js/interiors/bible.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
// PROCITY Lane C — bible.js [v7.0-beta wave 1, R32 — THE GUIDE + THE GEM (charter system #3)]
|
||||||
|
// The in-game price guide: thriftgod's BIBLE concept as a VALUE BAND per item. The band is the
|
||||||
|
// 90s guide book, not an oracle — it names a range ("guide $25–59"), never a verdict. A **gem**
|
||||||
|
// is an item ASKING under its band's low edge; spotting one is the player's skill, so nothing in
|
||||||
|
// any UI ever says "gem".
|
||||||
|
//
|
||||||
|
// THE DERIVATION RULING (R32): band NUMBERS mirror Lane E's pipeline thresholds
|
||||||
|
// (pipeline/build_stock_pack.py price_band(): record 8/25/60 · book 5/15/40 · toy 6/20/50 —
|
||||||
|
// duplicated verbatim in godverse_stock.py, validated by validate_pack.py). We derive ranges in
|
||||||
|
// JS from the band LABEL the packs already carry instead of re-baking band data into atlases —
|
||||||
|
// so charter risk #3 (a re-emitted pack orphaning owned skus) never opens. If E's thresholds
|
||||||
|
// move, this table moves with them in the same commit (the two files name each other).
|
||||||
|
//
|
||||||
|
// THE STRUCTURAL CONSEQUENCE (why the no-pump law survives the beta, LANE_C_PUB §9.1):
|
||||||
|
// real/mint pack items derive `price_band` FROM `price`, so real asking always sits INSIDE its
|
||||||
|
// band ⇒ bandLow ≤ asking ⇒ sellOffer(bandLow) ≤ bandLow − 1 < asking: every real round trip
|
||||||
|
// stays strictly negative with the basis swapped. Only PARODY stock (proceduralSleeves) rolls a
|
||||||
|
// seeded under-band gem — and per-day scarcity (the save's `pulls`) makes each gem profit ONCE.
|
||||||
|
// Growth is bounded by finds, not arbitrage loops — charter law #2, made structural.
|
||||||
|
//
|
||||||
|
// Zero DOM, zero draws, zero fetch, zero storage: a pure table + pure functions. ?classic=1 /
|
||||||
|
// ?dig=0 never import a caller, and importing this module alone has no effect at all.
|
||||||
|
|
||||||
|
// kind → band → [low, high]; `high: null` = open-ended (the card renders "$60+").
|
||||||
|
// Lows/highs are E's threshold edges: a band [t0, t1) renders as $t0–(t1−1).
|
||||||
|
export const BANDS = {
|
||||||
|
record: { bargain: [2, 7], standard: [8, 24], collector: [25, 59], grail: [60, null] },
|
||||||
|
book: { bargain: [1, 4], standard: [5, 14], collector: [15, 39], grail: [40, null] },
|
||||||
|
toy: { bargain: [2, 5], standard: [6, 19], collector: [20, 49], grail: [50, null] },
|
||||||
|
};
|
||||||
|
export const BAND_ORDER = ['bargain', 'standard', 'collector', 'grail'];
|
||||||
|
|
||||||
|
// bandRange('record','collector') → [25, 59] | null on unknown kind/band (fail-closed: no range,
|
||||||
|
// no guide line, no basis swap — never an invented number).
|
||||||
|
export function bandRange(kind, band) {
|
||||||
|
const k = BANDS[kind];
|
||||||
|
const r = k && k[String(band || '').toLowerCase()];
|
||||||
|
return r ? [r[0], r[1]] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The guide line for a card stamp — "guide $25–59" / "guide $60+" / '' when unknowable.
|
||||||
|
export function guideText(kind, band) {
|
||||||
|
const r = bandRange(kind, band);
|
||||||
|
if (!r) return '';
|
||||||
|
return r[1] == null ? `guide $${r[0]}+` : `guide $${r[0]}–${r[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sell basis for a collection entry (LANE_C_PUB §9.1, the pre-ratified beta swap): bandLow
|
||||||
|
// when the entry carries a band the BIBLE knows, else the alpha basis (pricePaid) — so every
|
||||||
|
// alpha-era find (no band field) sells exactly as it did before this round. The sellOffer()
|
||||||
|
// CLAMP SHAPE is sell.js's and survives untouched; this function only chooses the number fed in.
|
||||||
|
export function sellBasis(entry) {
|
||||||
|
if (entry && entry.band) {
|
||||||
|
const r = bandRange(entry.type, entry.band);
|
||||||
|
if (r) return r[0];
|
||||||
|
}
|
||||||
|
return entry ? (entry.pricePaid != null ? entry.pricePaid : entry.price) : 0;
|
||||||
|
}
|
||||||
@ -10,34 +10,48 @@
|
|||||||
// 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)
|
||||||
// 90s-Australian crate genres → sleeve card colour.
|
// 90s-Australian crate genres → sleeve card colour.
|
||||||
const GENRE = [['ROCK', '#7a2a3a'], ['SOUL', '#b0894a'], ['JAZZ', '#2a4a6a'], ['DISCO', '#8a3a7a'],
|
const GENRE = [['ROCK', '#7a2a3a'], ['SOUL', '#b0894a'], ['JAZZ', '#2a4a6a'], ['DISCO', '#8a3a7a'],
|
||||||
['COUNTRY', '#6a5a2a'], ['PUNK', '#3a3a3a'], ['REGGAE', '#2a6a4a'], ['POP', '#b05a8a']];
|
['COUNTRY', '#6a5a2a'], ['PUNK', '#3a3a3a'], ['REGGAE', '#2a6a4a'], ['POP', '#b05a8a']];
|
||||||
const ARTISTS = ['THE VERANDAHS', 'DEZ & THE UTES', 'MERINO', 'HILLS HOIST', 'THE ESKIES', 'GOON BAG',
|
// [R35] exported: the shell seeds a fresh game's FIRST WANT from this roster (THE FIRST FIVE MINUTES).
|
||||||
'STOBIE POLES', 'THE FIBROS', 'WARATAH', 'THONGS', 'THE CLOTHESLINE', 'GALAH'];
|
export const ARTISTS = ['THE VERANDAHS', 'DEZ & THE UTES', 'MERINO', 'HILLS HOIST', 'THE ESKIES', 'GOON BAG',
|
||||||
|
'STOBIE POLES', 'THE FIBROS', 'WARATAH', 'THONGS', 'THE CLOTHESLINE', 'GALAH'];
|
||||||
const TITLES = ['Sunburnt', 'Back o’ Bourke', 'Servo at Midnight', 'Long Weekend', 'Tinny',
|
const TITLES = ['Sunburnt', 'Back o’ Bourke', 'Servo at Midnight', 'Long Weekend', 'Tinny',
|
||||||
'Southerly Buster', 'Milk Bar Dreams', 'Cane Toad Blues', 'Ute Muster', 'Arvo'];
|
'Southerly Buster', 'Milk Bar Dreams', 'Cane Toad Blues', 'Ute Muster', 'Arvo'];
|
||||||
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(lo−1, floor(lo/2)) — sell.js),
|
||||||
});
|
// so a spotted gem always nets margin: collector asks $2–9 vs offer $12 · grail $5–20 vs $30.
|
||||||
|
const price = gem
|
||||||
|
? (band === 'grail' ? 5 + ((gemPriceRoll * 16) | 0) : 2 + ((gemPriceRoll * 8) | 0))
|
||||||
|
: lo + ((priceRoll * ((hi == null ? GRAIL_ASK_HI : hi) - lo + 1)) | 0);
|
||||||
|
out.push({ i, kind: 'record', a, t, y, s: g[0], color: g[1], cond, price, band, rare: band === 'grail' });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@ -103,6 +117,7 @@ export function createDig(THREE, renderer) {
|
|||||||
|
|
||||||
let recs = [], cursor = 0, vel = 0, active = false, pulled = null, lastFloor = -1;
|
let recs = [], cursor = 0, vel = 0, active = false, pulled = null, lastFloor = -1;
|
||||||
let crate = null, offers = [], onCloseCb = null, onBuyCb = null, getCashCb = null;
|
let crate = null, offers = [], onCloseCb = null, onBuyCb = null, getCashCb = null;
|
||||||
|
let onWantCb = null, isWantedCb = null; // [R33] the wantlist seams — absent (?game=0/classic) ⇒ no WANT DOM at all
|
||||||
let emitters = null; // [R28] room.audio.emitters, per open — where the till sounds from. Optional.
|
let emitters = null; // [R28] room.audio.emitters, per open — where the till sounds from. Optional.
|
||||||
// shared across opens (persistent): sleeves are all the same box; sides + crate timber never vary
|
// shared across opens (persistent): sleeves are all the same box; sides + crate timber never vary
|
||||||
const sleeveGeo = trackGeo(new THREE.BoxGeometry(SLEEVE[0], SLEEVE[1], SLEEVE[2]));
|
const sleeveGeo = trackGeo(new THREE.BoxGeometry(SLEEVE[0], SLEEVE[1], SLEEVE[2]));
|
||||||
@ -128,6 +143,9 @@ export function createDig(THREE, renderer) {
|
|||||||
.pcdg-panel button{margin-top:13px;width:100%;padding:9px;border:1px solid #8a7f66;background:#e7dcc2;color:#241f18;font:700 13px "Courier New",monospace;letter-spacing:.06em;cursor:pointer}
|
.pcdg-panel button{margin-top:13px;width:100%;padding:9px;border:1px solid #8a7f66;background:#e7dcc2;color:#241f18;font:700 13px "Courier New",monospace;letter-spacing:.06em;cursor:pointer}
|
||||||
.pcdg-panel button:hover:not(:disabled){background:#241f18;color:#f4e11a;border-color:#241f18}
|
.pcdg-panel button:hover:not(:disabled){background:#241f18;color:#f4e11a;border-color:#241f18}
|
||||||
.pcdg-panel button:disabled{color:#9a917c;border-color:#c3b99f;cursor:default}
|
.pcdg-panel button:disabled{color:#9a917c;border-color:#c3b99f;cursor:default}
|
||||||
|
.pcdg-panel .want{margin-top:7px;text-align:center;font:700 11px "Courier New",monospace;letter-spacing:.08em;color:#8a7f66;cursor:pointer;user-select:none}
|
||||||
|
.pcdg-panel .want:hover{color:#241f18}
|
||||||
|
.pcdg-panel .want.on{color:#b3121f;cursor:default}
|
||||||
.pcdg-panel .x{position:absolute;top:4px;left:8px;cursor:pointer;color:#a89e86;font-size:13px}
|
.pcdg-panel .x{position:absolute;top:4px;left:8px;cursor:pointer;color:#a89e86;font-size:13px}
|
||||||
.pcdg-out{position:fixed;left:16px;top:16px;background:#1d1d2b;border:1px solid #666;color:#d8d8e0;padding:8px 14px;font:13px "Courier New",monospace;cursor:pointer;display:none;z-index:60}
|
.pcdg-out{position:fixed;left:16px;top:16px;background:#1d1d2b;border:1px solid #666;color:#d8d8e0;padding:8px 14px;font:13px "Courier New",monospace;cursor:pointer;display:none;z-index:60}
|
||||||
.pcdg-out:hover{border-color:#3dff8b;color:#3dff8b}
|
.pcdg-out:hover{border-color:#3dff8b;color:#3dff8b}
|
||||||
@ -250,14 +268,30 @@ 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>`);
|
||||||
|
// [R33] the WANT line — only when the game layer wired the seams (construction-gated DOM: a
|
||||||
|
// ?game=0 / classic card carries no wantlist trace). Already-wanted reads as a stamp, not a button.
|
||||||
|
const wanted = isWantedCb ? !!isWantedCb(o) : false;
|
||||||
|
const wantLine = onWantCb
|
||||||
|
? `<div class="want${wanted ? ' on' : ''}">${wanted ? '★ ON THE WANTLIST' : '☆ WANT IT — hunt one cheaper'}</div>`
|
||||||
|
: '';
|
||||||
panel.innerHTML = `<div class="x">✕</div>`
|
panel.innerHTML = `<div class="x">✕</div>`
|
||||||
+ `<div class="sticker">$${esc(o.price)}</div>`
|
+ `<div class="sticker">$${esc(o.price)}</div>`
|
||||||
+ `<h3>${esc(o.a)}</h3>`
|
+ `<h3>${esc(o.a)}</h3>`
|
||||||
+ (o.t ? `<div class="ttl">“${esc(o.t)}”</div>` : '')
|
+ (o.t ? `<div class="ttl">“${esc(o.t)}”</div>` : '')
|
||||||
+ (stamps.length ? `<div class="stamps">${stamps.join('')}</div>` : '')
|
+ (stamps.length ? `<div class="stamps">${stamps.join('')}</div>` : '')
|
||||||
+ `<button ${o.price > cash ? 'disabled' : ''}>${o.price > cash ? 'NOT ENOUGH CASH' : 'BUY IT'}</button>`;
|
+ `<button ${o.price > cash ? 'disabled' : ''}>${o.price > cash ? 'NOT ENOUGH CASH' : 'BUY IT'}</button>`
|
||||||
|
+ wantLine;
|
||||||
panel.style.display = 'block';
|
panel.style.display = 'block';
|
||||||
panel.querySelector('.x').onclick = unpull;
|
panel.querySelector('.x').onclick = unpull;
|
||||||
|
const wantEl = panel.querySelector('.want');
|
||||||
|
if (wantEl && !wanted) wantEl.onclick = () => {
|
||||||
|
if (onWantCb(o) !== false) { wantEl.classList.add('on'); wantEl.textContent = '★ ON THE WANTLIST'; wantEl.onclick = null; }
|
||||||
|
};
|
||||||
const b = panel.querySelector('button');
|
const b = panel.querySelector('button');
|
||||||
if (b && !b.disabled) b.onclick = () => {
|
if (b && !b.disabled) b.onclick = () => {
|
||||||
// STUB economy (round 6+): onBuy may veto; default is a toast + remove-from-bin.
|
// STUB economy (round 6+): onBuy may veto; default is a toast + remove-from-bin.
|
||||||
@ -342,13 +376,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 +395,30 @@ 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;
|
||||||
|
onWantCb = opts.onWant || null; isWantedCb = opts.isWanted || null; // [R33] wantlist seams
|
||||||
emitters = opts.emitters || null;
|
emitters = opts.emitters || null;
|
||||||
const count = opts.count || 16;
|
const count = opts.count || 16;
|
||||||
|
const gone = opts.gone instanceof Set && opts.gone.size ? opts.gone : null;
|
||||||
// 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 +456,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.
|
||||||
|
|||||||
@ -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(p−1, …)
|
// 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';
|
||||||
|
|||||||
@ -21,13 +21,13 @@ const CSS = `
|
|||||||
#pc-dbg b{color:#ffd75e;font-weight:600}
|
#pc-dbg b{color:#ffd75e;font-weight:600}
|
||||||
#pc-dbg .warn{color:#ff9a6a}
|
#pc-dbg .warn{color:#ff9a6a}
|
||||||
#pc-clock{font-size:15px;margin-bottom:3px}
|
#pc-clock{font-size:15px;margin-bottom:3px}
|
||||||
#pc-toast{position:absolute;left:50%;bottom:64px;transform:translateX(-50%);background:rgba(20,16,10,.9);padding:10px 18px;border-radius:10px;font-size:15px;opacity:0;transition:opacity .25s;text-shadow:0 1px 2px #000}
|
#pc-toast{position:fixed;left:50%;bottom:64px;transform:translateX(-50%);background:rgba(20,16,10,.9);padding:10px 18px;border-radius:10px;font-size:15px;opacity:0;transition:opacity .25s;text-shadow:0 1px 2px #000;z-index:31;max-width:80vw;font:15px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;color:#f4efe6;pointer-events:none}
|
||||||
#pc-help{position:absolute;left:12px;bottom:12px;background:rgba(20,16,10,.5);padding:7px 11px;border-radius:8px;opacity:.85}
|
#pc-help{position:absolute;left:12px;bottom:12px;background:rgba(20,16,10,.5);padding:7px 11px;border-radius:8px;opacity:.85}
|
||||||
#pc-townpick{position:absolute;left:10px;top:10px;pointer-events:auto;opacity:.8;transition:opacity .12s}
|
#pc-townpick{position:absolute;left:10px;top:10px;pointer-events:auto;opacity:.8;transition:opacity .12s}
|
||||||
#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}
|
||||||
@ -67,6 +67,9 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
|||||||
const $ = (id) => root.querySelector('#' + id);
|
const $ = (id) => root.querySelector('#' + id);
|
||||||
const elTip = $('pc-tip'), elClock = $('pc-clock'), elFps = $('pc-fps'), elDraws = $('pc-draws'),
|
const elTip = $('pc-tip'), elClock = $('pc-clock'), elFps = $('pc-fps'), elDraws = $('pc-draws'),
|
||||||
elTris = $('pc-tris'), elChunks = $('pc-chunks'), elToast = $('pc-toast');
|
elTris = $('pc-tris'), elChunks = $('pc-chunks'), elToast = $('pc-toast');
|
||||||
|
// [R35] toasts surface in EVERY mode (beats fire inside shops; a SLEEP taken at a counter must
|
||||||
|
// still show its morning paper) — same body-mount rule as #pc-game, same dispose duty.
|
||||||
|
document.body.appendChild(elToast);
|
||||||
$('pc-town').textContent = plan.name || '—';
|
$('pc-town').textContent = plan.name || '—';
|
||||||
$('pc-seed').textContent = plan.citySeed;
|
$('pc-seed').textContent = plan.citySeed;
|
||||||
|
|
||||||
@ -166,10 +169,14 @@ 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, shownWants = null, open = false;
|
||||||
|
|
||||||
function rebuildList() {
|
function rebuildList() {
|
||||||
const col = game.collection || [];
|
const col = game.collection || [];
|
||||||
@ -180,7 +187,7 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
|||||||
panel.appendChild(h);
|
panel.appendChild(h);
|
||||||
if (!col.length) {
|
if (!col.length) {
|
||||||
const d = document.createElement('div'); d.className = 'pc-crate-empty';
|
const d = document.createElement('div'); d.className = 'pc-crate-empty';
|
||||||
d.textContent = 'Empty — go dig.'; panel.appendChild(d); return;
|
d.textContent = 'Empty — go dig.'; panel.appendChild(d);
|
||||||
}
|
}
|
||||||
for (let i = col.length - 1; i >= 0; i--) { // newest find first
|
for (let i = col.length - 1; i >= 0; i--) { // newest find first
|
||||||
const e = col[i];
|
const e = col[i];
|
||||||
@ -196,7 +203,57 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
|||||||
const meta = document.createElement('div'); meta.className = 'pc-crate-meta';
|
const meta = document.createElement('div'); meta.className = 'pc-crate-meta';
|
||||||
meta.textContent = `paid $${e.pricePaid} · ${foundAt(e, game)} · day ${e.dayFound}`;
|
meta.textContent = `paid $${e.pricePaid} · ${foundAt(e, game)} · day ${e.dayFound}`;
|
||||||
main.append(t, a, meta);
|
main.append(t, a, meta);
|
||||||
row.append(th, main); panel.appendChild(row);
|
row.append(th, main);
|
||||||
|
// [R34 GODBAY] ⚖ LIST — mail-in consignment; the entry leaves the crate, hammers tomorrow.
|
||||||
|
if (typeof game.listFind === 'function') {
|
||||||
|
const lb = document.createElement('div');
|
||||||
|
lb.textContent = '⚖'; lb.title = 'LIST at GodBay — the auction resolves tomorrow';
|
||||||
|
lb.style.cssText = 'cursor:pointer;opacity:.65;padding:0 4px;flex:none;font-size:15px';
|
||||||
|
lb.onclick = () => { if (game.listFind(e)) rebuildList(); };
|
||||||
|
row.appendChild(lb);
|
||||||
|
}
|
||||||
|
panel.appendChild(row);
|
||||||
|
}
|
||||||
|
// [R34] AT AUCTION — consigned finds, hammering overnight. Non-empty only (no header over nothing).
|
||||||
|
const listings = game.listings || [];
|
||||||
|
if (listings.length) {
|
||||||
|
const lh = document.createElement('h3');
|
||||||
|
lh.style.marginTop = '10px';
|
||||||
|
lh.textContent = `AT AUCTION — ${listings.length} consigned · hammers tomorrow`;
|
||||||
|
panel.appendChild(lh);
|
||||||
|
for (const l of listings) {
|
||||||
|
const row = document.createElement('div'); row.className = 'pc-crate-row';
|
||||||
|
const main = document.createElement('div'); main.className = 'pc-crate-main';
|
||||||
|
const t = document.createElement('div'); t.className = 'pc-crate-title';
|
||||||
|
t.textContent = l.title || l.sku || l.slotId || 'item';
|
||||||
|
const meta = document.createElement('div'); meta.className = 'pc-crate-meta';
|
||||||
|
meta.textContent = `paid $${l.pricePaid} · listed day ${l.listDay}`;
|
||||||
|
main.append(t, meta);
|
||||||
|
row.append(main); panel.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [R33] THE WANTLIST — what you're hunting (☆ on a pull card adds; ✕ here removes; buying one
|
||||||
|
// clears it automatically). Section exists only when non-empty — no stray header over nothing.
|
||||||
|
const wants = game.wants || [];
|
||||||
|
if (wants.length) {
|
||||||
|
const wh = document.createElement('h3');
|
||||||
|
wh.style.marginTop = '10px';
|
||||||
|
wh.textContent = `THE WANTLIST — ${wants.length} hunted`;
|
||||||
|
panel.appendChild(wh);
|
||||||
|
for (const w of wants) {
|
||||||
|
const row = document.createElement('div'); row.className = 'pc-crate-row';
|
||||||
|
const main = document.createElement('div'); main.className = 'pc-crate-main';
|
||||||
|
const t = document.createElement('div'); t.className = 'pc-crate-title';
|
||||||
|
t.textContent = w.artist || w.title || w.sku;
|
||||||
|
const a = document.createElement('div'); a.className = 'pc-crate-artist';
|
||||||
|
a.textContent = w.artist && w.title ? `“${w.title}”` : '';
|
||||||
|
main.append(t, a);
|
||||||
|
const x = document.createElement('div');
|
||||||
|
x.textContent = '✕'; x.title = 'stop hunting this';
|
||||||
|
x.style.cssText = 'cursor:pointer;opacity:.6;padding:0 4px;flex:none';
|
||||||
|
x.onclick = () => { game.removeWant(w); rebuildList(); };
|
||||||
|
row.append(main, x); panel.appendChild(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function setOpen(v) {
|
function setOpen(v) {
|
||||||
@ -218,18 +275,27 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
|||||||
shownCount = col.length; btnCrate.textContent = `CRATE ${shownCount}`;
|
shownCount = col.length; btnCrate.textContent = `CRATE ${shownCount}`;
|
||||||
if (open) rebuildList();
|
if (open) rebuildList();
|
||||||
}
|
}
|
||||||
|
const nw = (game.wants || []).length + (game.listings || []).length * 100; // [R33/R34] wants OR
|
||||||
|
if (nw !== shownWants) { shownWants = nw; if (open) rebuildList(); } // listings change → rebuild
|
||||||
|
// [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();
|
||||||
@ -270,14 +336,11 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
|||||||
frame++;
|
frame++;
|
||||||
if (frame % 6 === 0) raycastDoor();
|
if (frame % 6 === 0) raycastDoor();
|
||||||
|
|
||||||
// v7 game surface — lazy construction (the shell builds the game after the HUD; null under
|
// v7 game surface — lazy construction + refresh both live in tickGame() (R33 fix: the build used
|
||||||
// ?classic=1 / ?game=0 ⇒ this never fires), then cheap getter polls offset from the raycast frames.
|
// to happen ONLY here in street frames, so a door entered fast after boot had no crate/bar inside
|
||||||
if (!gameUI) {
|
// at all — the interior branch could refresh a surface that never got built). Throttled here; the
|
||||||
if (frame % 30 === 0) {
|
// interior/map branches call tickGame() per frame (cheap: change-guarded property reads).
|
||||||
const g = window.PROCITY && window.PROCITY.game;
|
if (frame % 6 === 3) tickGame();
|
||||||
if (g) gameUI = buildGameUI(g);
|
|
||||||
}
|
|
||||||
} else if (frame % 6 === 3) gameUI.refresh();
|
|
||||||
|
|
||||||
// tooltip — shows CLOSED (with opening hour) when the shop is shut at the current hour (§3.5)
|
// tooltip — shows CLOSED (with opening hour) when the shop is shut at the current hour (§3.5)
|
||||||
if (hovered) {
|
if (hovered) {
|
||||||
@ -318,9 +381,23 @@ 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
|
||||||
|
elToast.remove(); // [R35] so does the toast
|
||||||
root.remove();
|
root.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { update, tickToast, showToast, setVisible, dispose, getHovered: () => hovered,
|
// [R32/R33] the game surface ticks in EVERY mode (the street path reaches it via update(); interior/
|
||||||
|
// map call this directly) — and BUILDS here too, so the surface exists no matter which mode first
|
||||||
|
// sees a truthy game. day/cash/crate stay live while you shop; the card-yield check runs here.
|
||||||
|
function tickGame() {
|
||||||
|
if (!gameUI) {
|
||||||
|
const g = window.PROCITY && window.PROCITY.game;
|
||||||
|
if (g) gameUI = buildGameUI(g);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gameUI.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { update, tickToast, showToast, setVisible, tickGame, dispose, getHovered: () => hovered,
|
||||||
getFps: () => fpsShown };
|
getFps: () => fpsShown };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { buildInterior, getStockPack, makeStockAdapter } from '../interiors/inte
|
|||||||
import { KeeperManager } from '../citizens/keepers.js'; // Lane D — shopkeeper behind the counter
|
import { KeeperManager } from '../citizens/keepers.js'; // Lane D — shopkeeper behind the counter
|
||||||
import { GigCrew } from '../citizens/band.js'; // Lane D — band trio + audience (R12, ?gigs=1)
|
import { GigCrew } from '../citizens/band.js'; // Lane D — band trio + audience (R12, ?gigs=1)
|
||||||
import { createDig, binSeed } from '../interiors/dig.js'; // Lane C — crate-riffle (v2, gated on ?dig=1)
|
import { createDig, binSeed } from '../interiors/dig.js'; // Lane C — crate-riffle (v2, gated on ?dig=1)
|
||||||
|
import { mulberry32, xmur3 } from '../core/prng.js'; // [R33] the rumor roll (seeded, runtime-only)
|
||||||
import { collapseBuyItem } from '../interiors/stockpack.js'; // Lane C — R9 buy-anywhere (book spines / toy boxes)
|
import { collapseBuyItem } from '../interiors/stockpack.js'; // Lane C — R9 buy-anywhere (book spines / toy boxes)
|
||||||
import { createSell, nearCounter } from '../interiors/sell.js'; // Lane C — R30 sell counter (F owns this hook, §9.4)
|
import { createSell, nearCounter } from '../interiors/sell.js'; // Lane C — R30 sell counter (F owns this hook, §9.4)
|
||||||
|
|
||||||
@ -37,6 +38,11 @@ const EXIT_DIST = 1.0; // …then coming back within this distance leaves to th
|
|||||||
// lives in CITY_SPEC §v3. See LANE_F_NOTES §12.
|
// lives in CITY_SPEC §v3. See LANE_F_NOTES §12.
|
||||||
const WALLA_KEY = 'crowd-walla'; // [R13] manifest.ambience — crowd murmur layered under the gig bed while 'on'
|
const WALLA_KEY = 'crowd-walla'; // [R13] manifest.ambience — crowd murmur layered under the gig bed while 'on'
|
||||||
|
|
||||||
|
// [R33 — THE RUMOR] where a keeper's mutter can point: the repo's real cached towns (display names).
|
||||||
|
// Flavor + a travel nudge, not a promise — the 90s had no stock lookups, and the town-wide packs mean
|
||||||
|
// every town CAN surface any hunted record eventually. Full persona lines are D's when the clip work lands.
|
||||||
|
const RUMOR_TOWNS = ['Katoomba', 'Newtown', 'Fremantle', 'Bendigo', 'Castlemaine'];
|
||||||
|
|
||||||
// [Lane F R24 §6a] The per-shop stock base for a GODVERSE shop — A's `godverseShopId` → C's per-shop
|
// [Lane F R24 §6a] The per-shop stock base for a GODVERSE shop — A's `godverseShopId` → C's per-shop
|
||||||
// pack location (LANE_C_PUB §7.3/§7.5). Exported and used by BOTH the shell's boot preload and the room's
|
// pack location (LANE_C_PUB §7.3/§7.5). Exported and used by BOTH the shell's boot preload and the room's
|
||||||
// lookup, deliberately: C's cache identity is the LITERAL (type, base) pair, so if the two sides built
|
// lookup, deliberately: C's cache identity is the LITERAL (type, base) pair, so if the two sides built
|
||||||
@ -117,6 +123,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 +173,48 @@ 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,
|
||||||
|
// [R33] the wantlist seams — game-only (undefined ⇒ dig builds zero WANT DOM, classic-pure)
|
||||||
|
onWant: game ? (o) => game.recordWant({ artist: o && (o.a || o.artist), title: o && (o.t || o.title),
|
||||||
|
sku: o && o.item && o.item.id != null ? String(o.item.id) : undefined }) : undefined,
|
||||||
|
isWanted: game ? (o) => !!game.findWant({ artist: o && (o.a || o.artist), title: o && (o.t || o.title),
|
||||||
|
sku: o && o.item && o.item.id != null ? String(o.item.id) : '' }) : undefined,
|
||||||
onBuy: wallet ? (o) => {
|
onBuy: wallet ? (o) => {
|
||||||
const ok = wallet.buy(o);
|
const ok = wallet.buy(o);
|
||||||
if (ok && game) 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)));
|
||||||
|
// [R33] the hunt ends when you're holding it: buying a wanted item clears its want
|
||||||
|
const w = game.findWant({ artist: o && (o.a || o.artist), title: o && (o.t || o.title), sku: sku || '' });
|
||||||
|
if (w) { game.removeWant(w); flashToast('✓ off the wantlist'); }
|
||||||
|
}
|
||||||
return ok;
|
return ok;
|
||||||
} : undefined,
|
} : undefined,
|
||||||
onClose: () => window.dispatchEvent(new CustomEvent('procity:digClose')),
|
onClose: () => window.dispatchEvent(new CustomEvent('procity:digClose')),
|
||||||
@ -224,6 +253,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 +376,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,
|
||||||
@ -402,10 +433,25 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
|||||||
// [R30 §9.4] the sell hint shows only when the player actually HOLDS something this shop trades in
|
// [R30 §9.4] the sell hint shows only when the player actually HOLDS something this shop trades in
|
||||||
// (type = type, C's fail-closed matcher) — a hint over nothing sellable would advertise a dead E.
|
// (type = type, C's fail-closed matcher) — a hint over nothing sellable would advertise a dead E.
|
||||||
const canSell = !!(game && game.collection && game.collection.some((it) => it && it.type === shop.type));
|
const canSell = !!(game && game.collection && game.collection.some((it) => it && it.type === shop.type));
|
||||||
|
// [R33 — THE RUMOR, charter #6, the keeper finally gossips] Seeded per (citySeed, shop, day): a
|
||||||
|
// ~45% mutter naming a hunted want + a real town — the reason to ride somewhere tomorrow. Runtime
|
||||||
|
// stream, freshly keyed: no plan/golden moves (A's boundary). No game / empty wantlist ⇒ silence,
|
||||||
|
// and the banner line simply doesn't exist (classic-pure by construction).
|
||||||
|
let rumor = '';
|
||||||
|
if (game && game.wants && game.wants.length) {
|
||||||
|
const rr = mulberry32(xmur3(`rumor:${plan.citySeed}:${shop.id}:${game.day}`)() >>> 0);
|
||||||
|
if (rr() < 0.45) {
|
||||||
|
const w = game.wants[(rr() * game.wants.length) | 0];
|
||||||
|
const towns = RUMOR_TOWNS.filter((t) => !(plan.name || '').toLowerCase().includes(t.toLowerCase()));
|
||||||
|
const town = towns[(rr() * towns.length) | 0] || RUMOR_TOWNS[0];
|
||||||
|
rumor = ` · 🗣 “${String(w.artist || w.title || w.sku).toUpperCase()}? saw one in ${town} just last week”`;
|
||||||
|
}
|
||||||
|
}
|
||||||
banner.textContent = `🚪 ${name || shop.name || 'shop'} — walk out the door or press Esc to leave`
|
banner.textContent = `🚪 ${name || shop.name || 'shop'} — walk out the door or press Esc to leave`
|
||||||
+ (gigOn ? ` · 🎸 ${String(tonight.bandName).toUpperCase()} — LIVE` : '')
|
+ (gigOn ? ` · 🎸 ${String(tonight.bandName).toUpperCase()} — LIVE` : '')
|
||||||
+ (hasBins ? ' · E: riffle a record bin' : '')
|
+ (hasBins ? ' · E: riffle a record bin' : '')
|
||||||
+ (canSell ? ' · E at the counter: sell' : '');
|
+ (canSell ? ' · E at the counter: sell' : '')
|
||||||
|
+ rumor;
|
||||||
banner.style.display = 'block';
|
banner.style.display = 'block';
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
@ -471,6 +517,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 +538,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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -18,6 +18,33 @@
|
|||||||
export const SAVE_SCHEMA = 'procity-save/1';
|
export const SAVE_SCHEMA = 'procity-save/1';
|
||||||
export const SAVE_KEY = 'procity-save';
|
export const SAVE_KEY = 'procity-save';
|
||||||
|
|
||||||
|
// ── GODBAY v0 — the offline seeded auction (R34, charter system #4; laws in ROUND34_INSTRUCTIONS) ──
|
||||||
|
import { xmur3, mulberry32 } from '../core/prng.js';
|
||||||
|
import { BANDS as GODBAY_BANDS } from '../interiors/bible.js'; // C's pure band table — one authority, no mirror
|
||||||
|
|
||||||
|
const GODBAY_FEE = 0.12; // the house always eats
|
||||||
|
const GODBAY_PASS = 0.15; // passed in — no bids, item comes home
|
||||||
|
const GODBAY_WAR = 0.12; // a bidding war: hammer ×1.5–2.0
|
||||||
|
|
||||||
|
// THE ONE-HAMMER LAW: seeded by ITEM IDENTITY, never by the resolution day — a passed-in item
|
||||||
|
// re-lists to the same result forever, so there is no retry farm anywhere in the economy.
|
||||||
|
// → { sold, hammer, net } | { sold:false }. Fixed draw count (4), outcome-independent.
|
||||||
|
export function godbayHammer(entry, townKey) {
|
||||||
|
const id = `godbay:${townKey}:${entry.sku || entry.slotId}:${entry.dayFound}:${entry.pricePaid}`;
|
||||||
|
const r = mulberry32(xmur3(id)());
|
||||||
|
const passRoll = r(), posRoll = r(), warRoll = r(), warSizeRoll = r();
|
||||||
|
const bands = GODBAY_BANDS[entry.type] || GODBAY_BANDS.record;
|
||||||
|
const range = bands[entry.band] || null;
|
||||||
|
// an unbanded entry (alpha-era find) auctions around what was paid — honest, never invented
|
||||||
|
const lo = range ? range[0] : Math.max(1, entry.pricePaid);
|
||||||
|
const hi = range ? (range[1] == null ? lo * 2 : range[1]) : Math.max(1, entry.pricePaid);
|
||||||
|
if (passRoll < GODBAY_PASS) return { sold: false };
|
||||||
|
let hammer = lo + Math.floor(posRoll * (hi - lo + 1));
|
||||||
|
if (warRoll < GODBAY_WAR) hammer = Math.floor(hammer * (1.5 + warSizeRoll * 0.5));
|
||||||
|
const net = Math.max(1, Math.floor(hammer * (1 - GODBAY_FEE)));
|
||||||
|
return { sold: true, hammer, net };
|
||||||
|
}
|
||||||
|
|
||||||
// ── validation — exact and loud (the delta law's falsifiable half) ─────────────────────────────
|
// ── validation — exact and loud (the delta law's falsifiable half) ─────────────────────────────
|
||||||
// A corrupted/foreign blob is REJECTED WHOLE: schema string must match exactly, every field must
|
// A corrupted/foreign blob is REJECTED WHOLE: schema string must match exactly, every field must
|
||||||
// check. Rejecting loudly + starting fresh is the design — the save can cost you your stuff, never
|
// check. Rejecting loudly + starting fresh is the design — the save can cost you your stuff, never
|
||||||
@ -42,6 +69,34 @@ 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` };
|
||||||
|
}
|
||||||
|
// [R34 GODBAY] `listings` is OPTIONAL, same law: collection-shaped entries + an int listDay ≥ 1.
|
||||||
|
if (obj.listings != null) {
|
||||||
|
if (!Array.isArray(obj.listings)) return { ok: false, why: 'listings not an array' };
|
||||||
|
for (let i = 0; i < obj.listings.length; i++) {
|
||||||
|
const l = obj.listings[i];
|
||||||
|
if (!validEntry(l) || !Number.isInteger(l.listDay) || l.listDay < 1)
|
||||||
|
return { ok: false, why: `listings[${i}] malformed` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [R33 wantlist] `wants` is OPTIONAL, same law: array of {artist?|title?|sku?} string fields, at
|
||||||
|
// least one present per entry — a want with nothing to hunt is malformed, not empty-but-fine.
|
||||||
|
if (obj.wants != null) {
|
||||||
|
if (!Array.isArray(obj.wants)) return { ok: false, why: 'wants not an array' };
|
||||||
|
for (let i = 0; i < obj.wants.length; i++) {
|
||||||
|
const w = obj.wants[i];
|
||||||
|
if (!w || typeof w !== 'object' || Array.isArray(w)) return { ok: false, why: `wants[${i}] malformed` };
|
||||||
|
const f = ['artist', 'title', 'sku'].filter((k) => w[k] != null);
|
||||||
|
if (!f.length || f.some((k) => typeof w[k] !== 'string' || !w[k])) return { ok: false, why: `wants[${i}] malformed` };
|
||||||
|
}
|
||||||
|
}
|
||||||
return { ok: true, state: obj };
|
return { ok: true, state: obj };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,12 +113,53 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
|
|||||||
let day = 1;
|
let day = 1;
|
||||||
let cash = Math.max(0, Math.floor(+startCash || 0));
|
let cash = Math.max(0, Math.floor(+startCash || 0));
|
||||||
let collection = [];
|
let collection = [];
|
||||||
|
// [R33] the wantlist — what you're hunting ({artist?|title?|sku?}); capped, deduped, save-carried.
|
||||||
|
let wants = [];
|
||||||
|
const WANTS_CAP = 40;
|
||||||
|
// [R34 GODBAY] consigned finds ({...entry, listDay}) + this boot's transient morning report.
|
||||||
|
let listings = [];
|
||||||
|
let auctionNews = []; // NOT saved — the morning paper, not the ledger
|
||||||
|
// [R33 travel] true when THIS boot adopted a save from a DIFFERENT town — the ride cost a day.
|
||||||
|
let traveled = false;
|
||||||
|
// [R32 scarcity] pull records — one string per bought dig slot, keyed by the CONSUMER
|
||||||
|
// (interior_mode: `${shopId}|${binKey}@d<day>#<slot>`; real-sourced crates omit the day tag — real
|
||||||
|
// stock never rotates, so a real pull is gone for good). The save only stores and prunes them.
|
||||||
|
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
|
||||||
|
if (wants.length) p.wants = wants; // [R33] same convention
|
||||||
|
if (listings.length) p.listings = listings; // [R34] same convention
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [R34 GODBAY] resolve every listing the day has moved past — sleep, travel, and day-moving imports
|
||||||
|
// all funnel here. One hammer per item (identity-seeded), so WHEN it resolves never changes WHAT.
|
||||||
|
function resolveAuctions() {
|
||||||
|
if (!listings.length) return;
|
||||||
|
const still = [];
|
||||||
|
for (const l of listings) {
|
||||||
|
if (l.listDay >= day) { still.push(l); continue; } // resolves the day AFTER listing
|
||||||
|
const title = l.title || l.sku || l.slotId || 'the item';
|
||||||
|
const res = godbayHammer(l, l.townKey || townKey);
|
||||||
|
if (res.sold) {
|
||||||
|
cash += res.net;
|
||||||
|
auctionNews.push(`⚖ SOLD “${title}” — hammer $${res.hammer}, $${res.net} after fees`);
|
||||||
|
} else {
|
||||||
|
const { listDay, ...entry } = l; // passed in — comes home unchanged
|
||||||
|
collection.push(entry);
|
||||||
|
auctionNews.push(`⚖ passed in — “${title}” came home`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listings = still;
|
||||||
|
}
|
||||||
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 +167,24 @@ 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() : [];
|
||||||
|
wants = Array.isArray(state.wants) ? state.wants.slice() : [];
|
||||||
|
// [R33 — TRAVEL COSTS A DAY, charter system #6] The adopted save came from ANOTHER town (townKey
|
||||||
|
// embeds plansrc/town/seed, so a new seed is a new town too): the ride ate a day. Same-town
|
||||||
|
// adoption (reload, export→import round trip) costs nothing — the save-determinism gate's
|
||||||
|
// byte-equality survives untouched. Day moves BEFORE prunePulls so yesterday's-town day-tagged
|
||||||
|
// pulls drop here, and the shell reads game.day/traveled at boot (onDay stays un-fired, §30.1).
|
||||||
|
if (state.town !== townKey) { day += 1; traveled = true; }
|
||||||
|
listings = Array.isArray(state.listings) ? state.listings.slice() : [];
|
||||||
|
resolveAuctions(); // [R34] a day-moving adopt (travel/import) resolves what it passed
|
||||||
|
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 +251,15 @@ 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;
|
||||||
|
resolveAuctions(); // [R34] GODBAY hammers overnight — before onDay so the toast carries it
|
||||||
|
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 +291,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 +305,79 @@ 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;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── the wantlist seams (R33, charter system #6) ────────────────────────────────────────────
|
||||||
|
get wants() { return wants; }, // LIVE array — READ-ONLY by contract (like collection)
|
||||||
|
get traveled() { return traveled; }, // this boot's save came from another town (+1 day)
|
||||||
|
|
||||||
|
// w: {artist?|title?|sku?} strings. Dedupe is case-insensitive over the fields present.
|
||||||
|
recordWant(w) {
|
||||||
|
const e = {};
|
||||||
|
for (const k of ['artist', 'title', 'sku']) if (w && w[k]) e[k] = String(w[k]);
|
||||||
|
if (!Object.keys(e).length) return false;
|
||||||
|
if (game.findWant(e)) return false; // (named binding, not `this` — survives detached calls)
|
||||||
|
wants.push(e);
|
||||||
|
if (wants.length > WANTS_CAP) wants.splice(0, wants.length - WANTS_CAP);
|
||||||
|
notify();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// The matcher both UIs use: a want covers an item when every field the WANT names matches the
|
||||||
|
// item's (case-insensitive) — sku exact, artist/title by value. Buying a wanted item clears it.
|
||||||
|
findWant(o) {
|
||||||
|
const low = (s) => String(s || '').toLowerCase();
|
||||||
|
return wants.find((w) =>
|
||||||
|
(!w.sku || w.sku === String(o.sku || '')) &&
|
||||||
|
(!w.artist || low(w.artist) === low(o.artist)) &&
|
||||||
|
(!w.title || low(w.title) === low(o.title)) ) || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeWant(w) { // identity removal, findWant supplies the identity
|
||||||
|
const i = wants.indexOf(w);
|
||||||
|
if (i < 0) return false;
|
||||||
|
wants.splice(i, 1);
|
||||||
|
notify();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── GODBAY seams (R34) ─────────────────────────────────────────────────────────────────────
|
||||||
|
get listings() { return listings; }, // LIVE, READ-ONLY by contract
|
||||||
|
|
||||||
|
// Consign a collection entry (identity, like removeFind): it leaves the crate for the auction
|
||||||
|
// house and resolves the NEXT day. false ⇒ not in the collection, nothing moves.
|
||||||
|
listFind(entry) {
|
||||||
|
const i = collection.indexOf(entry);
|
||||||
|
if (i < 0) return false;
|
||||||
|
collection.splice(i, 1);
|
||||||
|
listings.push({ ...entry, listDay: day });
|
||||||
|
notify();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Drain the morning paper (transient — resolution wrote it, the shell toasts it once).
|
||||||
|
takeAuctionNews() {
|
||||||
|
const n = auctionNews;
|
||||||
|
auctionNews = [];
|
||||||
|
return n;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||