Compare commits
29 Commits
v7.0-alpha
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae2137fd9c | ||
|
|
c842547e61 | ||
|
|
4709cfece6 | ||
|
|
2fd4c1cf79 | ||
|
|
622a5e0d82 | ||
|
|
d9d8cbfd3e | ||
|
|
b8e1082182 | ||
|
|
83bce93f82 | ||
|
|
24183b1eb3 | ||
|
|
285ce69553 | ||
|
|
3ef751e1e3 | ||
|
|
25cef5983e | ||
|
|
55b984e0b0 | ||
|
|
c9e233a7e5 | ||
|
|
e528c92fed | ||
|
|
b048f51640 | ||
|
|
776b10b599 | ||
|
|
7b26e45fa0 | ||
|
|
37773bc81d | ||
|
|
ce71908bb5 | ||
|
|
9aae167d64 | ||
|
|
bb30767b62 | ||
|
|
75e5e06288 | ||
|
|
f9517f14ab | ||
|
|
046668e5af | ||
|
|
b3394027be | ||
|
|
3c5d0a1e95 | ||
|
|
67913f8b66 | ||
|
|
bb5f36f1e0 |
105
F-progress.md
@ -4,6 +4,111 @@
|
||||
|
||||
---
|
||||
|
||||
## Round 36 (THE v7.0 CLOSE, wave 2) — the gate, the freeze, the tag
|
||||
|
||||
**Zero alpha-blockers stood** (Fable's wave-0 naked-boot playtest walked the whole loop by hand),
|
||||
so wave 2 was pure close: E's three flags measured shut, the full gate over the final tree, docs
|
||||
frozen, **tag `v7.0` (annotated, LOCAL — Fable pushes)**.
|
||||
|
||||
**E's 2 a.m.-asset flags, closed by measurement:** (a) the 5 trellis DJ bodies' bind skeletons
|
||||
measure **0.984–0.989 units** vs the legacy roster's 173.46 (cm) — the figure-scale gate grew a
|
||||
DJ-body leg that spawns all five BY NAME through the real browse path: statures **1.64–1.89 m**,
|
||||
all in [1.4,2.0], feet planted, rig-kind verified (the old sample's `(i*7)%19` could never reach
|
||||
indices 17–21). (b) New STRICT `smoke_djdance`: the 4 Rokoko clips bind **64/65 tracks, sole
|
||||
unbound `mixamorigReference`** (gate on binds + NaN, never node counts — no template pin); a
|
||||
gig-night dancer's live action IS a dance clip (identity by duration, mixer advancing,
|
||||
`position.y` flat = the clip moves the body); `?classic` fetches **zero dance clips** (positive
|
||||
control: default fetched 4); `?noassets` procedural sway ALIVE (placeholder dancers bounce
|
||||
0.07 m — the inverse pair proves the branch). (c) Rokoko zone = E's w1 provenance, nothing
|
||||
runtime.
|
||||
|
||||
**THE HOLD → RESOLVED (the five-holds law, end-to-end in one round):** wave 2 found `?classic=1`
|
||||
fetching the 5 DJ body GLBs (unconditional `PED_NAMES.normal` members, rigs.js, `b339402` —
|
||||
classic's crowd pool widened 17→22 ungated). F WARNED it in the suite and held; Fable ruled
|
||||
"classic keeps its people"; D fixed it in rigs.js alone (`2fd4c1c`: base-17 restored, the five
|
||||
append behind `djs = dance` riding the shell's existing `!CLASSIC` signal; classic's 129-citizen
|
||||
census byte-identical to a `b339402^` shadow tree). F's wave-2.5 re-gate on the true final tree:
|
||||
the warn flipped to "✓ zero DJ-body fetches" — **flags harness 0 fails / 0 warns**, default boot
|
||||
keeps all 22 bodies and the organic gig-crowd proof. Held, ruled, fixed by the owning lane,
|
||||
re-proven — nothing improvised.
|
||||
|
||||
**The gate (final tree, :8946 port-isolated): `qa.sh --strict --matrix` 7 passed · 0 failed ·
|
||||
0 warn · 0 skipped; flags harness 0 fails / 1 warn wave 2 (the held finding, deliberate) →
|
||||
**0 fails / 0 warns on the wave-2.5 re-gate** (true final tree, run 3 on the healed box — the
|
||||
first two re-gate runs died to a box-level fork famine, ~10,600 leaked m3panel processes, fixed
|
||||
at source; starvation artifacts, not gates); selfcheck
|
||||
156,352/156,352; figure-scale 29 figs / 6 shops; MATRIX GREEN 10 towns × 7 gates.** The R32–35
|
||||
smokes all re-green on the final tree: bible (ledger Σask $333 → Σoffer $135, scarcity across
|
||||
reload, ?game=0 control) · hunt (rumor + auto-clear + travel day 1→2→3, same-town free) ·
|
||||
godbay (one-hammer byte-identical, SOLD net $19 bounded, the paper on the wake toast) ·
|
||||
first-five (HILLS HOIST $191 splash, beats on the real path, seeded across browsers). Classic
|
||||
covenant fp `0x5f76e76`, gig golden `0xec7a2d39`, world fp `0xb1c7843e` unmoved.
|
||||
|
||||
**The freeze:** README v7.0 section (the loop + the morning-paper manual row) · V7 charter risks
|
||||
retired-or-carried v4-style (economy RETIRED-structural, bot verifier carried to the live tier ·
|
||||
schema RETIRED · re-emit identity CARRIED, never fired · scope RETIRED · v6 collision RETIRED) ·
|
||||
LANE_F_NOTES §36. Full record: LANE_F_NOTES §36.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
**Fable's wave-0 playtest found the one alpha-blocker: the dig was default-OFF on a plain boot.**
|
||||
|
||||
34
README.md
@ -23,7 +23,11 @@ Click **START** to lock the mouse. Then:
|
||||
| **W A S D** + mouse | walk / look (first person) |
|
||||
| walk into a shop door | enter the interior (open shops only — check the hours tooltip) |
|
||||
| **E** (inside a shop) | open the crate at a record bin — then it's cursor-driven: click sleeves to riffle, click BUY |
|
||||
| **E** (at the counter) · **C** (street) · **SLEEP** (top bar) | sell a find to the keeper (always under what you paid) · open THE CRATE — your collection · sleep = save, the day advances, stock rotates |
|
||||
| **E** (at the counter) · **C** (anywhere) · **SLEEP** (bottom bar) | sell a find to the keeper (he pays under the **guide band**, never over) · open THE CRATE — your collection, in shops too now · sleep = save, the day advances, stock rotates |
|
||||
| read the **guide** stamp on every price card | the 90s price-guide band ("guide $8–24"). An item **asking under its band is a gem** — buy it, sell it to a keeper, pocket the margin. Bought slots stay gone for the day (the bin remembers) |
|
||||
| **☆ 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 |
|
||||
| wake up (sleep or arrive) | **the morning paper** rides the wake/arrival toast: "⚖ SOLD 'Milk Bar Dreams' — hammer $8, $7 after fees" (or "PASSED IN — it comes home"). The news is a toast, never saved — the ledger is your cash and crate |
|
||||
| click a book spine / toy box | pull-and-buy card (real price stickers in godverse shops) |
|
||||
| walk out the door / **Esc** | leave the shop (Esc also frees the mouse anywhere) |
|
||||
| **M** | 2D town map (street only) |
|
||||
@ -159,3 +163,31 @@ economy design: [`docs/G3_ECONOMY_DESIGN.md`](docs/G3_ECONOMY_DESIGN.md).
|
||||
the default boot, plus a `?classic=1` before/after. **QA:** `tools/qa.sh --strict` (classic regression +
|
||||
default-boot gate + smokes + no-giants) `--soak` (the full-week soak); the seated drummer passes the
|
||||
no-giants gate at the seated stature band `[0.9,1.5] m`.
|
||||
|
||||
## v7.0 — THE GAME: you're a crate-digger in 90s Australia
|
||||
|
||||
v4 made the towns real, v5 made the shops real — **v7 makes it a game.** Boot the plain URL on a
|
||||
fresh browser and you arrive HUNTING: the splash names a seeded first want and your pocket money
|
||||
("You're hunting HILLS HOIST — $191 in your pocket"), three toast-beats walk you through the first
|
||||
dig, and from there the loop is live:
|
||||
|
||||
**dig** (E at a record bin — the crate riffle) → **read the guide** (every price card carries a 90s
|
||||
price-guide band; an item asking *under* its band is a gem, and spotting one is the whole skill) →
|
||||
**☆ WANT** what you're hunting (keepers gossip seeded rumors — "saw one in Castlemaine") →
|
||||
**travel** (switching towns costs a day; the 23 towns are a strategy map) → sell to the **keeper**
|
||||
(instant cash, always under guide — the no-pump law) or **⚖ LIST on GodBay** (a seeded offline
|
||||
auction that hammers overnight; one hammer per item, ever — no retry farm) → **SLEEP** (= save; the
|
||||
day advances, parody/mint stock rotates per (shop, day), bought slots come back) → wake to **the
|
||||
morning paper** ("⚖ SOLD — hammer $22, $19 after fees"). Repeat, richer and one gem wiser.
|
||||
|
||||
The machinery under it: a versioned localStorage save carrying THE DELTA ONLY (cash · collection ·
|
||||
day · town · wants · pulls · listings — the world always regenerates from seed, so a corrupt save
|
||||
can cost you your stuff, never the town), JSON export/import for moving machines, and an economy
|
||||
fenced by gates: sell < guide-band structurally, auction EV ≈ 0.8×mid (random flips bleed; only
|
||||
finds profit), save/load byte-determinism, rotation seeded by (citySeed, shop, day).
|
||||
|
||||
**`?classic=1` still boots the pure v2 town — zero save, zero game DOM, zero Storage calls,
|
||||
instrumented every run** — and `?game=0` opts out of the layer on any boot. Charter:
|
||||
[`docs/V7_THE_GAME.md`](docs/V7_THE_GAME.md) (ratified 2026-07-17, risk verdicts appended at the
|
||||
close) · economy: [`docs/G3_ECONOMY_DESIGN.md`](docs/G3_ECONOMY_DESIGN.md) · the tour (day one to
|
||||
first gem, 6 frames, no staging): [`docs/shots/v7_tour/`](docs/shots/v7_tour/).
|
||||
|
||||
@ -6,6 +6,48 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
|
||||
|
||||
---
|
||||
|
||||
## ROUND 36 (wave 2.5) — CLASSIC KEEPS ITS PEOPLE: the DJ bodies gated out of `?classic`
|
||||
|
||||
**The ruling (Fable, on F's §36.4 held warn):** `b339402` (July 20) appended the 5 trellis'd DJ
|
||||
bodies (`woman_dj_01/02/03`, `dj_techno_01`, `dj_phrtt_01`) to `PED_NAMES.normal` UNCONDITIONALLY —
|
||||
so `?classic=1` fetched them and its seeded crowd pool widened 17→22, six epochs after the covenant
|
||||
froze. Ruled option (b): the five gate OUT of classic's pool, mirroring the sit/look/dance `!CLASSIC`
|
||||
clip gates. Classic is the v2 town INCLUDING ITS PEOPLE; the DJs walk every other boot.
|
||||
|
||||
**Mechanism (D's call — one file, `rigs.js`, per the wave-2.5 pathspec law):**
|
||||
- `PED_NAMES.normal` is restored to the covenanted **base 17** (exact pre-July-20 list, order
|
||||
preserved). The five move to a new `PED_NAMES.djs` extension list.
|
||||
- `loadPedFleet` gains **`djs = dance`** — a new opt whose DEFAULT tracks the existing `dance` flag.
|
||||
The shell already passes `dance: !CLASSIC` (index.html:194), so the classic-ness signal arrives
|
||||
with ZERO shell edits: classic ⇒ `dance:false` ⇒ `djs:false` ⇒ no DJ fetch, 17-pool; default ⇒
|
||||
`djs:true` ⇒ all 22. Separable for callers wanting clips without bodies (or vice versa).
|
||||
- The roster is built `djs ? [...normal, ...djs] : normal` — the five APPEND, so no base slot is
|
||||
renumbered in either mode: the default-boot 22-list is byte-identical to the pre-ruling list, and
|
||||
classic's 17-list is byte-identical to pre-b339402. `pickRig` maps `r01 * 997 % pool.length`, so
|
||||
pool LENGTH is crowd identity — 17 restores the exact pre-July-20 mapping, not merely the width.
|
||||
- Known side effect (accepted, noted): `citizens_test.html` calls `loadPedFleet` with no opts ⇒
|
||||
`dance:false` ⇒ its dev-harness roster shows 17+2, not 22+2. It is not a covenanted surface; a
|
||||
future D round can pass `{djs:true}` there if the harness wants the five back.
|
||||
|
||||
**Measured (fresh contexts each, port-isolated no-store servers :8971 current / :8972 pre-b339402
|
||||
shadow tree, seed 20261990):**
|
||||
|
||||
| check | `?classic=1` (gated) | default boot (gated) |
|
||||
|---|---|---|
|
||||
| DJ-GLB fetches | **0 of 5** (21 ped-GLB fetches total = 17+2 bodies + walk+idle) | **5 of 5** |
|
||||
| pool | **17 normal** / 2 comical / 19 all | **22 normal** / 24 all |
|
||||
| crowd census | 129 active, 129 named, **19 distinct — all base-17 ∪ comical, zero DJ** | 153 named street peds incl. **37 DJ-bodied** (techno 13 / w01 8 / phrtt 7 / w03 6 / w02 3) |
|
||||
| gig crowd (F's organic proof) | n/a (gig layer absent) | **preserved** — pinned seed's venue crew (12) includes `dj_phrtt_01` + `woman_dj_02` in the crowd |
|
||||
| console errors | **0** | **0** |
|
||||
|
||||
**The pre-July-20 identity check (the R29 lesson: measure, don't assume):** booted `?classic=1` on a
|
||||
shadow tree whose `rigs.js` is `b339402^` (everything else current), same seed, same server law.
|
||||
Roster arrays identical; the full 129-citizen census — every `(id, pedRoll, pedIndex, pedName)`
|
||||
tuple — is **byte-identical** to the gated tree's classic boot. Classic's crowd is not just 17-wide;
|
||||
it is the SAME crowd, ped for ped, as before July 20. (This held by construction — the five were
|
||||
appended, so gating them off restores both list content and length — but it is now held by
|
||||
measurement too.)
|
||||
|
||||
## ROUND 29 — v6 SPIKE 1: `look.glb` bound and wired — peds glance (wave 0)
|
||||
|
||||
*PROCITY-D, 2026-07-17. The spike finally reaches the game: E's `look.glb` (mesh-free, 65 joints, 6.5 s
|
||||
|
||||
@ -1,5 +1,69 @@
|
||||
# LANE E — cross-lane notes
|
||||
|
||||
## Round 36 wave 1 — the 2 a.m. commits, regularized ⟨v7.0⟩
|
||||
|
||||
**Scope: sweep `046668e` + `b339402` (July 20, landed outside the round system) into the pipeline —
|
||||
provenance, licence table, structural checks, depot.** Everything below is measured off the committed
|
||||
bytes, not the commit messages.
|
||||
|
||||
**1. THE COUNT: the brief said 7 assets; there are NINE.** `046668e` = 4 dance clips.
|
||||
**`b339402` = FIVE trellis DJ bodies, not 3** (`dj_phrtt_01 · dj_techno_01 · woman_dj_01/02/03` — the
|
||||
commit's own diffstat says five, and all five are in `PED_NAMES`). All 9 swept; the table rows say 9.
|
||||
|
||||
**2. THE ZONE CORRECTION — the dance clips are NOT Mixamo. They are ROKOKO.** The brief guessed the
|
||||
Adobe Mixamo walk/idle/sit/look family; 90sDJsim's own clip baker (`tools/bake_clips.py`, the
|
||||
`fbx_to_clip.py` port) names the true source: **Rokoko's free 263-asset motion-capture library**,
|
||||
`Rokoko Studio (Mocap)/Dancing/{GentleSwayingDancing,NPC_DancingParty,DancingMedium,DancingwithDrink}_mixamo.fbx`
|
||||
— the pack is on this box (`~/Documents/Rokoko_Free_Mocap_FBX_263`), the `_mixamo` suffix is **Rokoko's
|
||||
own Mixamo-skeleton export** (which is why they bind like Mixamo clips without being Mixamo clips).
|
||||
Licence verified at the source (rokoko.com, the pack's own download page): *"use them in any animation,
|
||||
VFX, game, 3D art or other 3D project you want, **including for commercial use**"* — royalty-free, no
|
||||
attribution demanded, motion only and mesh-free as shipped. **🟢 web-ok, but a NEW zone** — the licence
|
||||
table gains a Rokoko row rather than quietly widening the Mixamo one. A licence spine that files a
|
||||
Rokoko clip under Adobe's licence is the R25 spelling-fork one layer up: right answer, wrong authority.
|
||||
|
||||
**3. Clip structure — the sit.glb checklist, run; the shape DEVIATES from sit/look and I'm recording
|
||||
it, not normalizing it.** All 4: **mesh-free (0 meshes)**, 1 animation, **198 tracks**, dangling skin
|
||||
**with inverseBindMatrices present**, **0 NaN / 0 Inf** across every sampler accessor (156k–268k floats
|
||||
scanned per clip), durations 30.58 / 30.50 / 44.04 / 52.42 s (party/medium/drink/sway). But the Rokoko
|
||||
export shape is **68 nodes, 52-joint skin** — not the Mixamo clips' 66 nodes / 195 tracks / 65-joint
|
||||
skin. The bind census against a shipped ped skeleton (`man_casual_01`, 65 canonical bones): **65/66
|
||||
distinct canonicalised track targets bind; the sole unbound is `mixamorigReference`** (Rokoko's root
|
||||
node — `_canon` can't map it, three.js drops the orphan track silently; matches 90sDJsim's in-game
|
||||
64/65-tracks measurement). Not a defect: both repos verified the dancers animating in-scene.
|
||||
|
||||
**4. Bodies — GLB law + ped law, PASS; one number F's gate must see.** Tris **4000 / 4000 / 3999 /
|
||||
3999 / 3998** (≤5k ped law, the commit's "4k" claim is honest) · 1 material / 1 image / **tex 1024²
|
||||
webp** (`EXT_texture_webp` — the whole existing roster carries the same extension, so no seam) · **no
|
||||
Draco** · 65-joint mixamorig skin, IBM clean (0 NaN/1040). **⚠ → F (figure-scale gate, your wave 2):
|
||||
the new bodies' bind-pose skeletons measure ~0.95–0.99 units tall; the pre-existing roster measures
|
||||
~173–180** (the cm-scale convention). In-game this is absorbed by design — `rigs.js:163` scales every
|
||||
rig to target height (`inner.scale.setScalar(height/span)`), and both repos eyeballed them animating —
|
||||
but they joined the roster ungated and the R36 brief already routes them through your figure-scale
|
||||
gate: this is the number it should expect. FLAG, not a fix; the files are untouched.
|
||||
|
||||
**5. Provenance chain, proved not narrated.** All 9 procity GLBs are **sha1-identical to the 90sDJsim
|
||||
originals** (`f8f0208` clips / `1906294` bodies, both July 20). Bodies' upstream: John's own DJ
|
||||
characters (`grldj5/grldj2/djgrl3/techno_01/phrtt2`) — flux_local sheet → **TRELLIS.2 on-device mesh**
|
||||
(the instrument-pipeline zone, CC0-equiv) → **Adobe Mixamo auto-rig** (full 65-bone) → MESHGOD
|
||||
`scripts/fbx_to_ped.py` FBX→skinned-GLB + 4k decimate. Honest caveat: the source FBXs live in the
|
||||
rigroom library (`~/meshgod-backups`, on **ultra** — not this box), so the mesh-generation step is
|
||||
recorded from 90sDJsim's commit + both repos' `trellis'd DJ bodies` roster comments + MESHGOD's
|
||||
rigroom autorig flow, while everything downstream of the FBX is verified from bytes here.
|
||||
|
||||
**6. Depot — published per standing practice, verified by fetching the bytes back (R28 discipline).**
|
||||
Staged to `_normalized/` (the sit/look pattern), pushed via the passwordless tailnet ingress under
|
||||
**John's R22 standing authorization for Lane E assets**: `_published.json` **44 → 53**, drift check
|
||||
`✓ all 53 recorded assets live`. Then all 9 fetched back from the **public** depot
|
||||
(`https://digalot.fyi/3god/a/<name>.glb`) — **HTTP 200, byte counts exact, sha1 identical to local,
|
||||
9/9.** Gates re-run after the sweep: `validate_manifest` **OK — 0 errors, 0 warnings** (ped assets load
|
||||
by convention, not the manifest — unchanged, same as walk/idle/sit/look) · `publish.py --verify` green.
|
||||
|
||||
**→ F (wave 2), the full flag list from this sweep:** (a) the figure-scale number in §4; (b) the
|
||||
clips' 68-node/52-joint Rokoko shape in §3 — if your gate pins the sit.glb structural template,
|
||||
these four are legitimately different; gate on binds (65/66) and NaN (0), not node count; (c) nothing
|
||||
else — no defective file found, nothing held back.
|
||||
|
||||
## Round 28 — Spike 1: half of it shipped, half of it doesn't exist ⟨v5.1⟩
|
||||
|
||||
**1. `look.glb` — DONE, published, verified.** `Looking_Around.fbx` (675 KB, from the cached tailnet stash,
|
||||
@ -809,6 +873,8 @@ loader wiring (`spawnRig`/fleet). Bench-sit loiter (Fable's stretch goal) reuses
|
||||
| Ped clip **⟨v3.1⟩** | `sit.glb` (E R16; + pre-existing `walk`/`idle` from D/90sDJsim) | Adobe **Mixamo** `Sitting_Idle.fbx` | `fbx_to_clip.py` (Blender, mesh-strip) | 🟢 Mixamo royalty-free (motion only, mesh stripped) |
|
||||
| Ped clip **⟨v5.1⟩** | `look.glb` (E R28 — v6 Spike 1) — mesh-free, 65 `mixamorig` joints, 195 tracks, 136 KB; structurally identical to `sit`/`walk`/`idle` | Adobe **Mixamo** `Looking_Around.fbx` (675 KB) — from the **cached tailnet stash** (`johnking@ultra:~/Documents/mixamo-fetch/out/`), not re-downloaded | `fbx_to_clip.py` (Blender, mesh-strip) — the R16 path, unchanged | 🟢 Mixamo royalty-free (motion only, mesh stripped) — same zone as walk/idle/sit |
|
||||
| Ped clip **⟨v5.1⟩ NOT BUILT** | `lean.glb` — **the source does not exist** (see R28 §1) | — | — | — |
|
||||
| Ped dance clips **⟨v7.0⟩** | `dance_party` · `dance_medium` · `dance_drink` · `dance_sway` (4 — joined `046668e` outside the round system, swept R36 w1) — mesh-free, 68 nodes / 198 tracks / 52-joint dangling skin + IBM each, 0 NaN, 30.5–52.4 s | **Rokoko free motion-capture library** (263-asset pack, `~/Documents/Rokoko_Free_Mocap_FBX_263`, `Rokoko Studio (Mocap)/Dancing/*_mixamo.fbx` — Rokoko's own Mixamo-skeleton export). **NOT Mixamo** — R36 measured zone correction | 90sDJsim `tools/bake_clips.py` (the `fbx_to_clip.py` port, ACTIVE_ACTIONS fix) → sha1-identical copy into procity (`f8f0208` → `046668e`) | 🟢 **Rokoko royalty-free incl. commercial** ("use in any … 3D project … including for commercial use" — rokoko.com pack page); motion only, mesh-free |
|
||||
| Ped bodies **⟨v7.0⟩** | `dj_phrtt_01` · `dj_techno_01` · `woman_dj_01/02/03` (**5, not the brief's 3** — joined `b339402` outside the round system, swept R36 w1) — 4000/4000/3999/3999/3998 tris, 1024² webp (`EXT_texture_webp`, roster-standard), no Draco, 65-joint mixamorig skin, IBM clean | John's own DJ characters (`grldj5/grldj2/djgrl3/techno_01/phrtt2`): `flux_local` sheet → **TRELLIS.2 on-device mesh** → Adobe **Mixamo auto-rig** (full 65-bone). Source FBXs in the rigroom library on ultra | MESHGOD `scripts/fbx_to_ped.py` (FBX→skinned GLB, 4k collapse-decimate, mixamorig + skin preserved) → sha1-identical copy (`1906294` → `b339402`) | 🟢 **CC0-equiv on-device mesh (the instrument-pipeline zone) + Mixamo auto-rig output (royalty-free)** · ⚠ figure-scale gate pending (R36 w2, F) |
|
||||
| Town pack **⟨v5.0-FINAL⟩** | **21 real AU towns, all 8 states/territories** — **1196 shops** (299 heroes) + real street geometry (`roads[]`) | **OpenStreetMap** via Overpass API — bounded per-town bbox; raw shops+roads snapshots committed in `_raw/` | `build_towns.py`: fetch → dedupe/suburb-fill/parody → C's §6 class map → 3× texture cap (drops counted) → v2 cache + roads | 🟢 **ODbL 1.0** (© OSM contributors; `web/assets/towns/SOURCES.md` + `license`/`attribution` on every cache) |
|
||||
| Godverse towns *(Lane G's, listed for completeness)* **⟨v5.0-FINAL⟩** | `newtown_godverse` (72 shops) · **`redhill_godverse` (37)** — the town whose record shop is THE shop. Both rostered in E's `index.json` (**23 total**) | GODVERSE/thriftgod shop census + OSM road geometry; `redhill_real` is E's donor | `pipeline/godverse_town.py` (**Lane G owns this provenance**) | 🟢 ODbL 1.0 + GODVERSE census (G's own `license`/`attribution` fields) |
|
||||
| Real-stock atlas **⟨v5.0-FINAL⟩** | **1 crate, 120 items**, 2×2048² webp — Monster Robot Party (`3962749`) | **the real POS** (`recordgod`) — crate 550 "DEEP HOUSE"; covers are **the shop's own product shots**, cached by dealgod | `pipeline/godverse_stock.py` (**Lane G owns**) — `snapshot`(DB-side, sha256-pinned) → `pack`(pure) | 🟡 **in-house-green — FLAG BEFORE ANY PUBLIC/COMMERCIAL RELEASE** · `sourcing:"real"` · ids `sku_<POS id>` |
|
||||
|
||||
@ -1754,3 +1754,279 @@ Fable's preview holds :8130, untouched):
|
||||
5. Automation-only, unverified in person: **dig Esc-close under synthetic events**.
|
||||
6. *(carried from §30.6)* no per-day `gone` tracking on parody/mint dig stock — scarcity-per-day
|
||||
needs the save to carry pull records; rule it with the guide bands.
|
||||
|
||||
## §32 — R32: v7.0-beta wave 1 — THE BIBLE + scarcity + the §31.6 queue (Fable, solo round, JING5)
|
||||
|
||||
**The round in one line:** the guide bands land (derived, not baked), gems exist (parody bins only,
|
||||
by construction), a bought slot stays gone for the day (pulls ride the save), and §31.6 items 1–3
|
||||
are fixed. All gates green; the no-pump law survives the §9.1 basis swap structurally.
|
||||
|
||||
### §32.1 What shipped, by seam
|
||||
|
||||
- **`js/interiors/bible.js`** (C) — the band table mirroring E's pipeline thresholds
|
||||
(record 8/25/60 · book 5/15/40 · toy 6/20/50), `bandRange`/`guideText`/`sellBasis`. Derivation
|
||||
ruling: NO pack re-emit (charter risk #3 never opens); if E's thresholds move, bible.js moves in
|
||||
the same commit.
|
||||
- **dig.js** (C) — parody sleeves roll seeded bands (25/45/22/8%) + a 10% gem on collector/grail
|
||||
(asking strictly under the keeper's own bandLow offer: collector $2–9 vs $12 · grail $5–20 vs
|
||||
$30); fixed 9-draw stream shape per item; `rare` now ≡ grail (matches real packs). Real offers
|
||||
carry `band` off `price_band`. `open({gone})` omits pulled slots AFTER the draw (the R27
|
||||
pick-then-omit seam, zero collateral) — real via `'sku:<id>'`, parody via original offer index
|
||||
(`o.i` survives filtering, identity preserved). The pull card + sell card gain the **guide
|
||||
stamp** (`guide $8–24` / `$60+`) — present-fields-only, and the card NEVER says "gem": spotting
|
||||
asking-under-guide is the player's skill.
|
||||
- **sell.js** (C) — §9.1 basis swap executed exactly as pre-ratified: `sellOffer(sellBasis(entry))`
|
||||
= `min(bandLow−1, max(1, floor(bandLow·0.5)))` when banded, pricePaid basis for alpha-era
|
||||
entries (they sell exactly as before — no migration needed).
|
||||
- **save.js** (F) — optional `pulls: string[]` on procity-save/1 (validated loudly when present;
|
||||
absent on alpha saves — old savers drop it silently, never reject). `recordPull`/`pullsFor`
|
||||
seams; pruned on sleep/import (day-tagged keys from other days are unreachable by construction;
|
||||
untagged = real-sourced, kept — sold means gone). Cap 600 FIFO. Collection entries gain
|
||||
optional `band` (recordFind stamps dig + shelf buys).
|
||||
- **interior_mode.js** (F) — pull keys `${shopId}|${binKey}${pullTag}#${slot}`; pullTag =
|
||||
`@d<game.day>` for rotating crates (INCLUDING day 1 — the seed stays untagged per the day-1
|
||||
convention, the ledger doesn't), `''` for real-sourced. The SEED input is untouched (A's
|
||||
rotation boundary holds). `digOffers` honest surface (index/ask/band/sku) for the gate.
|
||||
- **hud.js + index.html** (B/F) — §31.6-2: `#pc-game` rides document.body (position:fixed, z 30),
|
||||
ticks in every mode (`hud.tickGame()` from the interior/map branches), yields to the dig/sell
|
||||
cards and the map; C toggles the crate anywhere. §31.6-1: cluster-pose spawn — main-street
|
||||
shop doors (block kind, both generators), stand at the door nearest the cluster heart, face the
|
||||
longer run of shopfronts; pure plan math, zero draws; `?classic=1` keeps the frozen pose.
|
||||
§31.6-3: DAWN swaps the dome to `high-cirrus` (lighting `dawnSky` opt, null under classic);
|
||||
a weather `setSky` is now an explicit override that always wins.
|
||||
|
||||
### §32.2 THE GATE (`smoke_bible`, STRICT) + the R30 amendment
|
||||
|
||||
Scans EVERY bin of the record shop off `digOffers`: band law on all offers (in-band unless a
|
||||
collector/grail gem), then runs the buy-EVERYTHING ledger on **the gem's bin** (leg is
|
||||
non-vacuous by construction — 80 offers/5 bins, bin1 holds 2 gems on the pinned seed, grail
|
||||
asking $19 vs keeper $30). Ledger closes exactly: Σask $333 → Σoffer $135 (gems alone +$21,
|
||||
every non-gem strictly negative). Scarcity: the emptied bin re-opens at 0, STAYS 0 across a full
|
||||
reload (16 pulls in the save); negative control: `?game=0` re-opens it at 16 — the detector
|
||||
discriminates. 0 console errors. **R30 amendment (sell_routing):** `credited < paid` was the
|
||||
alpha inequality; the gate now asserts the exact formula (`credited == min(basis−1, basis//2)`,
|
||||
basis = bandLow-or-pricePaid, `credited < basis`) — the fence moved from the receipt to the
|
||||
band, which is what charter law #2 always said.
|
||||
|
||||
### §32.3 Environment note (JING5)
|
||||
|
||||
`web/assets/models/` is GITIGNORED build output — a fresh checkout runs stock=real/book-toy/
|
||||
no-pump gates RED with 404s (the R31-green tree lives on m3ultra). Fixed here by rsync from
|
||||
m3ultra. If a third machine joins the rotation, sync that dir (49 M) or re-run E's pipeline.
|
||||
|
||||
## §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.
|
||||
|
||||
## §36 — R36: THE v7.0 CLOSE (wave 2 — the gate, the freeze, the tag)
|
||||
|
||||
### §36.1 The two playtests (the tag's precondition, both on record)
|
||||
|
||||
- **Fable, wave 0 (fresh naked boot, in person): ZERO alpha-blockers.** The whole loop by hand:
|
||||
splash hook → strip spawn → onboarding beats → BIBLE guide on the cards → ☆ WANT (the
|
||||
splash-seeded HILLS HOIST, add + auto-clear-on-find) → ⚖ LIST → sleep → the morning paper
|
||||
("SOLD Milk Bar Dreams — hammer $8, $7 after fees") → travel Boolarra→Katoomba costing a day,
|
||||
the save riding along. The v7.x queue (filed, NOT fixed, per the five-holds law):
|
||||
⚖ discoverability · LIST confirm-click · the paper as a bigger artifact · keeper-rumor
|
||||
untested-by-hand. Fable's verdict line, kept for the tag: *"zero alpha-blockers; the game
|
||||
charged me $12 tuition for ignoring its own advice."*
|
||||
- **R31's alpha playtest** stands as the precedent that found the dig default-off blocker; this
|
||||
one found none — the explicit-flag blindfold lesson (harness #4) held: the wave-0 boot was
|
||||
NAKED, and the R31/R35 flag-absent gates had kept the default boot the game.
|
||||
|
||||
### §36.2 E's wave-1 flags, closed by measurement (the 2 a.m. assets)
|
||||
|
||||
E's R36 w1 sweep (`83bce93`) regularized `046668e`+`b339402` (9 assets, not the brief's 7:
|
||||
4 Rokoko dance clips + **5** trellis DJ bodies) and flagged three things at F's gate. All three
|
||||
measured on the final tree:
|
||||
|
||||
1. **Flag (a) — the bind-scale seam.** The DJ bodies' bind skeletons measure **0.9835–0.9893
|
||||
units** vs the legacy roster's ~173.46 (cm convention) — E's "~0.95–0.99" confirmed at
|
||||
runtime. `rigs.js:163` span-normalise absorbs it at spawn: the figure-scale gate grew a
|
||||
**DJ-body leg** (interior_scale_check.py) that spawns all five BY NAME through the real
|
||||
browse-spawn path and asserts stature — **woman_dj_01 0.986u→1.64 m · woman_dj_02
|
||||
0.987u→1.71 m · woman_dj_03 0.984u→1.81 m · dj_techno_01 0.986u→1.87 m · dj_phrtt_01
|
||||
0.989u→1.89 m**, all in [1.4,2.0], feet planted, rig-kind verified (not placeholder — the
|
||||
subject provably exercised; the old browse sample's `(i*7)%19` could never reach fleet
|
||||
indices 17–21). Bonus contact: the pinned seed's own gig crowd contains dj_phrtt_01 and
|
||||
woman_dj_02 organically.
|
||||
2. **Flag (b) — gate on binds + NaN, never node counts.** New STRICT `smoke_djdance`: runtime
|
||||
bind census post `_canon`/`_rotOnly` — every clip binds **64/65 tracks, sole unbound =
|
||||
`mixamorigReference`** (Rokoko's root; matches E's node-side 65/66 census and 90sDJsim's
|
||||
in-game measurement). No structural-template pin anywhere in F's suite — the 68-node/52-joint
|
||||
Rokoko shape passes because nothing wrongly asserts the sit/look 66/65 template.
|
||||
3. **Flag (c) — ROKOKO zone** — E's provenance/licence row verified in w1; nothing for the
|
||||
runtime gate beyond (b).
|
||||
|
||||
### §36.3 `smoke_djdance` — what the 2 a.m. commit claimed, now proven (STRICT)
|
||||
|
||||
The commit verified "page boots" only. The gate now proves: **4 dance clips load and
|
||||
canonicalise** on the default boot · a gig-night **dancer's live action IS a dance clip**
|
||||
(identity by duration — 30.5–52.4 s is unambiguous vs idle; mixer provably advancing;
|
||||
`fig.position.y` FLAT, so the clip moves the body, not the fallback) · **`?classic=1` fetches
|
||||
ZERO dance clips** (positive control: the default boot fetched all 4 — the detector
|
||||
discriminates) · **`?noassets=1`**: zero GLB fetches, dancers exist as placeholders, and the
|
||||
**procedural sway fallback is ALIVE** — bounce amplitude 0.07 m vs the clip-driven 0.00 (the
|
||||
inverse pair proves which branch moved the body). 0 console errors on every boot.
|
||||
|
||||
### §36.4 THE HOLD — held, ruled, RESOLVED (the five-holds law working end-to-end)
|
||||
|
||||
**As held (wave 2):** `?classic=1` fetched the 5 trellis DJ body GLBs — unconditional
|
||||
`PED_NAMES.normal` roster members (rigs.js, Lane D's file, landed in `b339402`) — which also
|
||||
widened classic's seeded crowd pool 17→22 normal on July 20. The plan fingerprint is plan-side
|
||||
so the covenant gates stayed green, and draws were in tolerance — but the classic FETCH SURFACE
|
||||
and crowd identity moved without a ruling, six epochs after the covenant froze. F WARNED it in
|
||||
`smoke_djdance` (the machine record) and did not improvise in D's file during the close.
|
||||
|
||||
**RESOLVED (wave 2.5, Fable's ruling, D's fix — `2fd4c1c`, LANE_D_NOTES §36): CLASSIC KEEPS
|
||||
ITS PEOPLE.** `PED_NAMES.normal` restored to the covenanted base 17 (exact pre-July-20 list);
|
||||
the five moved to `PED_NAMES.djs`, admitted by a new `loadPedFleet` opt **`djs = dance`** — the
|
||||
default tracks the existing dance flag, which the shell already passes as `!CLASSIC`, so the
|
||||
gate arrived with ZERO shell edits and the five append AFTER the base 17 (no slot renumbers in
|
||||
either mode). D's measurement: classic **0/5 DJ fetches, 17-pool, 129-citizen census
|
||||
byte-identical to a `b339402^` shadow tree** (the SAME crowd, ped for ped, not merely the same
|
||||
width); default keeps all 22 (37 DJ-bodied street peds) and F's organic gig-crowd proof
|
||||
(dj_phrtt_01 + woman_dj_02 in the pinned seed's venue crew) is preserved. F's re-gate on the
|
||||
true final tree: the `smoke_djdance` warn line flipped to **"✓ ?classic=1: zero DJ-body
|
||||
fetches"** — flags harness **0 fails / 0 warns**, and the DJ-body figure-scale leg still green
|
||||
(the five spawn on the default boot exactly as before). The hold cost one extra wave and
|
||||
nothing else — held rather than improvised, ruled by the party that owns the covenant, fixed by
|
||||
the lane that owns the file, re-proven by the gate that held it.
|
||||
|
||||
### §36.5 The gate — full `qa.sh --strict --matrix` on the final tree (:8946, port-isolated)
|
||||
|
||||
**SUMMARY: 7 passed · 0 failed · 0 warn · 0 skipped — QA GREEN.** The per-gate record:
|
||||
|
||||
- **scaffold + consistency + citygen selfcheck**: ALL GREEN **156,352/156,352** checks.
|
||||
- **manifest validator**: OK — 0 errors, 0 warnings (48 facades · 85 skin groups · 33 GLBs ·
|
||||
28 audio; atlas-QA 15 per-shop atlases valid, 15 licence lines SHOWN; depot live).
|
||||
- **flags harness: 0 fails / 1 warn** (the warn was §36.4's held DJ-roster finding, by design —
|
||||
**0 fails / 0 warns on the wave-2.5 re-gate** after D's fix, see the addendum below).
|
||||
- classic covenant: fp `0x5f76e76` holds · dig INERT at the bin · gig layer wholly absent ·
|
||||
162 draws / 16,642 tris (baseline 164) · 0 errors.
|
||||
- default boot: all four flags ON · 14 gigs / 14 posters / venue geo / tram · gig golden
|
||||
`0xec7a2d39` · 131 draws / 23,390 tris @ venue block · **dig REACHABLE naked** (R31's fence).
|
||||
- R30 gates re-green: sell routing ($12 buy → +$4 == min(basis−1, basis/2), HUD follows) ·
|
||||
save determinism (byte-equal import, 255 B state / 316 B export, world fp `0xb1c7843e`
|
||||
unmoved, weekNight 2 == (3−1)%7) · no-pump (5/5 strictly negative, Σ −$59, both controls
|
||||
discriminate) · classic purity (ZERO Storage calls classic AND game=0; positive control
|
||||
sees the save + all 7 game DOM ids) · rotation (day fps `0x50edd91b → 0x2121f187 →
|
||||
0x2f4a9b93`, re-entry + cross-boot byte-identical, ?game=0 == day 1, 88,320 bytes hashed).
|
||||
- **R32 bible**: 80 offers / 5 bins in-band-or-gem · bin1 holds 2 gems (grail asking $19 vs
|
||||
keeper $30) · ledger closes Σask $333 → Σoffer $135 (gems +$21) · emptied bin 0/16 across
|
||||
reload · ?game=0 re-opens 16/16.
|
||||
- **R33 hunt**: ☆→★ through the real card (save carries 2 wants) · keeper muttered the rumor
|
||||
("THE VERANDAHS? saw one in Castlemaine") · buy cleared ITS want · travel synthetic→melbourne
|
||||
day 1→2 with $179 + 1 find riding · same-town free · return fare day 3 · ?game=0 zero DOM.
|
||||
- **R34 godbay**: consign through the crate (collection 2→1) · ONE HAMMER byte-identical
|
||||
across a double sleep · SOLD net $19 ≤ 2×$24×0.88 · the paper rode the wake toast
|
||||
("⚖ SOLD Cane Toad Blues — hammer $22, $19 after fees") · travel hammered on arrival ·
|
||||
?game=0 no auction house.
|
||||
- **R35 first-five**: fresh game hunts HILLS HOIST with $191 on the splash · beats ②③ + the
|
||||
send-off on the real path · returning save gets no tutorial · second fresh browser hunts the
|
||||
SAME want (seeded) · ?game=0 control.
|
||||
- **R36 smoke_djdance**: all green per §36.3 (binds 64/65 · dancers clip-driven · classic zero
|
||||
dance fetch · noassets sway 0.067 m).
|
||||
- **figure-scale gate**: **29 figs / 6 shops** all human-sized incl. the new DJ leg (§36.2's
|
||||
five, 0.984–0.989 u → 1.64–1.89 m).
|
||||
- **MATRIX GREEN — 10 towns × 7 gates** (boot · determinism · budget · district · roads · tram ·
|
||||
noassets): 2 synthetic + 3 fixtures + 5 real caches, budgets 66–161 draws, every town
|
||||
byte-equal on re-boot, 0 fetches under ?noassets across all ten.
|
||||
|
||||
**Wave-2.5 addendum — the re-gate on the TRUE final tree** (after D's `2fd4c1c` roster fix +
|
||||
Fable's frontier commit `622a5e0`; run 3, healed box, port-isolated):
|
||||
**SUMMARY 7 passed · 0 failed · 0 warn · 0 skipped — QA GREEN · MATRIX GREEN 10 towns ×
|
||||
7 gates · flags harness 0 fails / 0 WARNS** (the §36.4 warn flipped to "✓ ?classic=1: zero
|
||||
DJ-body fetches" — D's gate holds under the full suite, not just standalone). Everything else
|
||||
unmoved from wave 2: selfcheck 156,352/156,352 · classic fp `0x5f76e76`, 162 draws/16,642 tris ·
|
||||
default gig golden `0xec7a2d39`, 131 draws/23,390 tris · Rokoko binds 64/65 · noassets sway
|
||||
0.071–0.075 m · figure-scale 29 figs/6 shops with the DJ leg's exact wave-2 statures
|
||||
(0.984–0.989 u → 1.64–1.89 m) · rotation fps `0x50edd91b/0x2121f187/0x2f4a9b93` byte-identical
|
||||
per day and cross-boot. *Honest record: re-gate runs 1 and 2 died mid-suite to a BOX-LEVEL fork
|
||||
famine (m3panel had leaked ~10,600 orphaned `pmset|tail` processes over 2.5 days — fixed at
|
||||
source, reaper installed); both partial REDs were starvation artifacts, not gates — every gate
|
||||
that completed in those runs matched run 3's verdicts line for line.*
|
||||
|
||||
### §36.6 The freeze + the tag
|
||||
|
||||
- **README**: v7.0 section (the loop dig→guide→want→travel→list→paper, the delta-law machinery,
|
||||
the classic covenant) + manual row for the morning paper (☆ WANT and ⚖ LIST rows landed with
|
||||
their rounds).
|
||||
- **V7_THE_GAME.md**: the five charter risks get their retired-or-carried verdicts, v4-style
|
||||
(written by F under the single-lane-close ruling): economy RETIRED-structural (bot verifier
|
||||
carried to the live tier) · schema drift RETIRED · re-emit identity CARRIED (never fired;
|
||||
owned-item stability check still unbuilt — next re-emit round owns it) · scope gravity
|
||||
RETIRED · v6 collision RETIRED (the 2 a.m. commits were the one contact, now swept + gated).
|
||||
- **Tag `v7.0`** — annotated, **LOCAL ONLY** (the R35 close law: the tag waited for hands, and
|
||||
the hands played twice). Fable reviews, pushes main + tag, writes the epoch retro.
|
||||
|
||||
@ -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
|
||||
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
|
||||
|
||||
- **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).
|
||||
65
docs/LANES/ROUND36_INSTRUCTIONS.md
Normal file
@ -0,0 +1,65 @@
|
||||
# PROCITY — Round 36 instructions (from Fable, integrator)
|
||||
|
||||
Date: 2026-07-23 · **R32–R35 closed green in other sessions** (THE BIBLE · scarcity · THE HUNT
|
||||
· travel · GODBAY · the first five minutes · the tour). R35's close law stands: the tag waits
|
||||
for hands. Round 36 is **the v7.0 CLOSE**: Fable plays the whole loop, the out-of-round assets
|
||||
get regularized, the full gate runs over the final tree, docs freeze, **tag `v7.0`**.
|
||||
|
||||
## WAVE 0 — THE PLAYTEST (Fable, in person, FRESH SAVE)
|
||||
|
||||
The R31 precedent, now over every v7 system: fresh save → the first-five-minutes beats → spot
|
||||
a gem via the BIBLE's guide stamps → ☆ WANT an artist → the keeper's rumor → TRAVEL (and pay
|
||||
the day) → ⚖ LIST a find on GodBay → sleep → the morning paper → the hammer. Deliverable: the
|
||||
friction list, split alpha-blocking vs v7.x queue. R31's lesson stands armed: the playtest
|
||||
boots NAKED (no flags beyond dbg) — the explicit-flag blindfold is the enemy.
|
||||
|
||||
## WAVE 1 — regularize the 2 a.m. commits (E)
|
||||
|
||||
`046668e` + `b339402` landed outside the round system (July 20, disciplined but unswept):
|
||||
**4 dance-clip GLBs + 3 trellis DJ bodies** in `web/models/peds/`. E: provenance table entries
|
||||
+ licence lines for all 7 (dance clips = the 90sDJsim Mixamo-family bank — verify and record
|
||||
the zone; DJ bodies = on-device trellis, CC0-equiv, the instrument discipline); depot publish
|
||||
per standing practice; the validator sees them. The commits verified "page boots" only — the
|
||||
full-suite proof is wave 2's.
|
||||
|
||||
## WAVE 2 — the gate, the freeze, the tag (F)
|
||||
|
||||
1. Any wave-0 alpha-blockers first (micro-fix agents per owner if not F's).
|
||||
2. Full `qa.sh --strict --matrix` over the final tree — explicitly including: the 3 DJ bodies
|
||||
through the figure-scale gate (they joined the roster ungated), the dance clips through
|
||||
classic zero-fetch + `?noassets` fallback (the commit claims it; the gate proves it), and
|
||||
the R32–35 smokes (bible, scarcity, hunt, godbay) re-green on the final tree.
|
||||
3. Docs freeze: README v7 section (the loop, the manual rows for WANT/LIST/paper), notes §36,
|
||||
the charters' retired-or-carried (A's v4-style list for the V7 charter risk section —
|
||||
F may write it this once, single-lane round).
|
||||
4. **Tag `v7.0`** (annotated: the loop's numbers + both playtest verdicts), push main + tag.
|
||||
|
||||
## Then: Fable writes the v7 epoch retro, and the menu reopens
|
||||
|
||||
(v6 footage-spikes · the beta/v7.x queue incl. John's own playtest findings whenever he walks
|
||||
it · the frontier shelf: terrain, vernacular kit, civic layer, THE BIBLE polish.)
|
||||
|
||||
## WAVE 2.5 (Fable ruling on F's held warn): CLASSIC KEEPS ITS PEOPLE
|
||||
|
||||
F's `smoke_djdance` caught the July-20 commit adding the 5 DJ bodies to `PED_NAMES`
|
||||
UNCONDITIONALLY — they fetch under `?classic=1` and widened classic's seeded crowd pool 17→22.
|
||||
Every covenant gate stayed green (the covenant pins the plan; the roster was never pinned), but
|
||||
the classic fetch surface and crowd identity moved without a ruling. **RULED: option (b) — the
|
||||
five gate OUT of classic's pool**, mirroring the clips' `!CLASSIC` pattern. The covenant's
|
||||
meaning stays crisp: `?classic=1` is the v2 town INCLUDING ITS PEOPLE; the DJs walk every other
|
||||
boot. D lands the line; F re-verifies (classic fetch surface + pool 17 + smoke_djdance green,
|
||||
strict re-run) and RE-LAYS the tag on the true final commit. The warn was the system working —
|
||||
a covenanted surface cannot move quietly even when every pinned number stays green.
|
||||
|
||||
## ROUND CLOSED (Fable) — **v7.0 TAGGED on `4709cfe`, all green, everything measured twice**
|
||||
|
||||
Run 3 on the healed box: qa 7/0/0/0 · MATRIX 10 towns · flags harness 0 fails / **0 warns** (the
|
||||
§36.4 hold flipped to "✓ classic: zero DJ-body fetches" under the full suite) · classic
|
||||
`0x5f76e76` · the five DJs human-sized by name · Rokoko binds 64/65 · rotation byte-identical
|
||||
per day and across boots. The honest record stands in §36.5: re-gate runs 1–2 died to a
|
||||
BOX-level fork famine (m3panel's 2.5-day `pmset|tail` leak — fixed at source mid-round, the
|
||||
`party.monster.reaper` janitor installed as standing fleet infra) — starvation artifacts, not
|
||||
gates. The hold's full arc: held by machine-recorded WARN → ruled (classic keeps its people) →
|
||||
fixed by the owning lane alone → re-proven by the gate that held it. Nothing improvised.
|
||||
|
||||
## Waves: [Fable plays] → [E] → [F fix+gate+tag] → [D classic-gate + F re-lay] → [retro]
|
||||
104
docs/RETRO_V7.md
Normal file
@ -0,0 +1,104 @@
|
||||
# PROCITY v7 — THE GAME: the epoch retro
|
||||
|
||||
*Fable (m3, integrator), 2026-07-26, written after the `v7.0` tag (`4709cfe`). Epoch span:
|
||||
R30 → R36. Tags: `v7.0-alpha` → `v7.0`. The first epoch shepherded end-to-end by Fable-driven
|
||||
lane agents — John said "run it" once per round, and the lanes ran.*
|
||||
|
||||
## What shipped, in one breath
|
||||
|
||||
**The town became a game.** A save that carries only the player's deltas (the world regenerates
|
||||
from seed, so a save can cost you your records but never your town) · THE CRATE — the
|
||||
collection with real covers, honest provenance per find · the sell counter where the keeper
|
||||
pays under the band, structurally, forever · sleep, and a tomorrow where the mint crates
|
||||
rotate and the real one never does · THE BIBLE's guide stamps that turn a price sticker into a
|
||||
skill check · the wantlist that hunts across 23 towns, where TRAVEL COSTS A DAY · GodBay,
|
||||
whose hammer falls overnight under the one-hammer law, delivered by the morning paper · and a
|
||||
first five minutes that hands a stranger $191 and a want. Plus, mid-epoch and out of hours:
|
||||
five trellis DJ bodies and four Rokoko dance clips, swept into provenance and gated into law.
|
||||
|
||||
## The epoch's defining pattern: HANDS BEFORE TAGS
|
||||
|
||||
Twice the playtest found what every green gate could not:
|
||||
|
||||
1. **R31**: the dig — the heart of the game — was default-OFF on a plain boot, six epochs
|
||||
after the README claimed otherwise, because **every gate booted its own flags** (harness
|
||||
lesson #4, THE EXPLICIT-FLAG BLINDFOLD). The suite tested the game; nobody tested that the
|
||||
default boot IS the game. The fix came with a naked-boot reachability gate so it can never
|
||||
silently regress.
|
||||
2. **R36**: zero blockers — and that verdict is the epoch's proudest number, because the same
|
||||
hands that found the R31 blocker walked every system (splash → beats → guide → want → list
|
||||
→ hammer → travel) and came home with only a polish queue. The game taught its own tester:
|
||||
*it charged $12 tuition for ignoring the guide's advice* — bought at $19 top-of-band,
|
||||
hammered at $8, $7 after fees. An economy that punishes bad digging is an economy working.
|
||||
|
||||
## The final hold: CLASSIC KEEPS ITS PEOPLE
|
||||
|
||||
The epoch's last warn was its best lesson in miniature. Two 2 a.m. commits (disciplined, but
|
||||
outside the round system) added five DJ bodies to the ped roster unconditionally — every
|
||||
covenant gate stayed green (the covenant pins the plan, not the roster), but F's new
|
||||
`smoke_djdance` machine-recorded that classic's fetch surface and crowd pool had moved without
|
||||
a ruling, and HELD the tag. The arc: held by WARN → ruled by Fable (the covenant means the v2
|
||||
town *including its people*) → fixed by D in the owning file alone (base-17 restored, proven
|
||||
byte-identical against a shadow tree of the pre-commit code — classic's crowd is the same
|
||||
crowd, ped for ped) → re-proven by the very gate that held it. **A covenanted surface cannot
|
||||
move quietly even when every pinned number stays green.**
|
||||
|
||||
## The unscheduled chapter: the fork famine
|
||||
|
||||
Between the hold and the tag, the box itself failed: m3panel — the machine's own status
|
||||
watchdog — had leaked one orphaned `pmset -g thermlog | tail -2` pair per refresh for 2.5
|
||||
days: ~10,600 processes, fork starvation, two QA runs killed mid-suite. The record keeps three
|
||||
things: (1) both poisoned runs are logged honestly in §36.5 — every gate that completed in
|
||||
them matched run 3's verdicts, so nothing was ever wrong but the box; (2) the fix was at
|
||||
source (the already-patched `sh()` finally restarted onto its own fix — **a fix isn't
|
||||
deployed until the process running the old code dies**, the E-roster lesson at OS scale);
|
||||
(3) the fleet gained standing infrastructure: `party.monster.reaper`, a 10-minute janitor
|
||||
that kills only orphaned known-leak patterns past a grace age and alarms at 2,000 processes.
|
||||
The watchman flooded the town; now the watchman has a watchman.
|
||||
|
||||
## What worked — keep doing
|
||||
|
||||
1. **Fable-driven lanes.** Seven rounds, ~20 lane-agent sessions, zero tree collisions beyond
|
||||
one adjudicated 6-line seam — the treaty held for agents exactly as it held for humans,
|
||||
because the contracts did the talking (F honoured C's asks sight-unseen; B stubbed F's
|
||||
exact shape then proved nothing was stubbed).
|
||||
2. **Ownership sentences, unbroken streak**: "the rule was the stale thing" (C) · "the red
|
||||
was mine" (E) · "four of five attempts were F's fault" (F) · "the count is 9, not 7" and
|
||||
"the clips are Rokoko, not Mixamo" (E, correcting the integrator's brief from bytes).
|
||||
3. **The structural no-pump law** — C's `min(p−1, …)` clamp meant no future rebalance could
|
||||
ever create a money pump without violating a contract; the gates then proved the pump C
|
||||
herself found was structurally dead.
|
||||
4. **Kills with measurements** (B's awning ripple, D's instrument LOD) — the backlog carries
|
||||
no zombies.
|
||||
5. **The morning paper** — the come-back-tomorrow hook works. The epoch's best moment of play
|
||||
is reading the hammer's news at dawn.
|
||||
|
||||
## What cost us — fixed or fenced
|
||||
|
||||
1. **The explicit-flag blindfold** (R31) — now law: gates about defaults boot naked.
|
||||
2. **Out-of-round commits** — the 2 a.m. work was good, but it took a wave-1 sweep, a gate,
|
||||
a hold, a ruling, and a fix to regularize what a round would have gotten right the first
|
||||
time. The door stays open for 2 a.m. inspiration; the sweep is now the standing tax.
|
||||
3. **Waiting agents die** — three F pauses on block-buffered suite waits; the pattern is now
|
||||
fixed operationally (background-and-poll, or Fable holds the sentinel).
|
||||
4. **A fix isn't live until the old process dies** — m3panel ran patched code's ghost for
|
||||
days. Applies to LaunchAgents, servers, and long-lived sessions alike.
|
||||
|
||||
## Carried to v7.x (the queue, all filed)
|
||||
|
||||
⚖ discoverability · LIST confirm-click · the paper as an artifact · keeper-rumor hand-verify ·
|
||||
crate-at-the-counter polish · the greedy arbitrage-bot verifier (MUST build before G's live
|
||||
tier) · E's owned-item stability check (MUST land before any atlas re-emit) · home base (the
|
||||
pocket-park bench awaits the civic layer).
|
||||
|
||||
## The board after seven epochs
|
||||
|
||||
v1 the system · v2 the content · v3 the district · v4 the real map · v5 the real shop · v6
|
||||
the living street (spikes still gated on John's footage — the MOCAPGOD queue remains empty) ·
|
||||
**v7 the game**. The frontier shelf: terrain · the vernacular kit · the civic layer · THE
|
||||
HORIZON matte + Voronoi outskirts · the sky dome. The next epoch is John's pick, as ever —
|
||||
and for the first time since round one, the honest answer to "what is PROCITY?" is: **a game
|
||||
you can hand to a stranger.**
|
||||
|
||||
*— Fable. Thirty-six rounds, twenty-two tags, six correct holds, one amended covenant, one
|
||||
fork famine survived. Sunburnt is still in the crate.*
|
||||
@ -59,6 +59,84 @@ makes digging MEAN something is unbuilt:
|
||||
- **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.
|
||||
|
||||
## 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). *(2026-07-23: Sebastian Lague's
|
||||
graphics-library video §6 is effectively the implementation guide — fBm + cheap
|
||||
Rayleigh/Mie approximation in one full-screen pass; the winmap shader proves the house
|
||||
does this class of trick. Skip full volumetric raymarch until measured.)*
|
||||
- **2b. THE HORIZON MATTE (filed 2026-07-23, from the same video) — the poor-man's
|
||||
terrain.** Towns end at a flat empty horizon. A seeded fBm-raymarched HORIZON BAND — a
|
||||
matte painting that computes itself, ~1 draw, zero geometry — puts the Blue Mountains
|
||||
behind Katoomba and scrub ranges behind Bendigo, seeded per town (real towns could bias
|
||||
the profile from their actual region). Buys most of the terrain epoch's FEEL years early;
|
||||
classic-gated like everything; retires automatically if real terrain ever lands.
|
||||
- **2c. VORONOI OUTSKIRTS (same filing).** Grid-constrained Voronoi paddocks with treeline
|
||||
borders as the town-edge ring, so the approach to town reads as countryside instead of a
|
||||
hard stop. A's partitioner class (plan-side, seeded, golden-aware) + B's tree instancing.
|
||||
Pairs with 2b; both are candidates for a single "THE HORIZON" polish round.
|
||||
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.
|
||||
|
||||
6. **THE CIVIC LAYER (filed 2026-07-23, John's question — "do we have fountains/statues/
|
||||
gardens?" Answer: no, and the town feels it).** Trees exist (wind-swaying billboard gums,
|
||||
R17) and commerce exists; civic PRIDE doesn't — no parks, gardens, fountains, statues,
|
||||
memorials, rotundas. The real-data angle makes this a first-class citizen of the real-map
|
||||
DNA: **OSM maps all of it** (`amenity=fountain` · `historic=memorial` · `leisure=park/
|
||||
garden`), and the **war memorial** — the defining civic marker of exactly the AU towns we
|
||||
build — is mapped in nearly every one. Real towns get their actual memorial/park/rotunda
|
||||
at actual positions via the existing ODbL fetch (one more class, standing auth); synthetic
|
||||
towns get a seeded civic kit (memorial at the main intersection, rotunda + gums in a
|
||||
pocket park, drinking fountain by the shops); statue/rotunda meshes via the local gen
|
||||
stack (hunyuan3d_mlx, CC0-equiv, the instrument-pipeline discipline). Cheap-to-mid (B
|
||||
furniture patterns + E assets + one A data class), incremental like the kit, and pairs
|
||||
with it: terrain = the ground's shape, the kit = the buildings' shape, the civic layer =
|
||||
the town's heart. A pocket park with a memorial is also the natural HOME BASE candidate
|
||||
for v7.x (sleep on a park bench, you battler).
|
||||
|
||||
## 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
|
||||
|
||||
@ -104,3 +104,38 @@ v7.0-alpha can start **now** — it's unblocked (v6 waits on footage; the two do
|
||||
Recommended: R30 = v7.0-alpha wave 1 (F save core + B collection UI + C sell counter contracts
|
||||
first). John ratifies this charter (or amends the fantasy — it's your shop, your era, your
|
||||
game) and says go.
|
||||
|
||||
## Risk verdicts at the close (R36, v7.0 — written by F per the single-lane-close ruling)
|
||||
|
||||
1. **Economy balance — RETIRED (structurally fenced).** The no-pump gate is STRICT since R30
|
||||
(5 real UI round trips all strictly negative + C's broke-player negative control); R32 moved
|
||||
the fence from the receipt to the band (`credited == min(bandLow−1, ⌊bandLow/2⌋)`, the exact
|
||||
formula, not an inequality); R34's GodBay holds EV ≈ 0.8×mid under the ONE-HAMMER law (item-
|
||||
identity-seeded — a passed-in item re-lists to the same result forever, so there is no retry
|
||||
farm), bounded above at 2×high×0.88. Both hand playtests confirmed the shape — Fable's wave-0
|
||||
line: the game charged $12 tuition for ignoring its own advice. *Honest residue:* the named
|
||||
adversarial verifier (a greedy N-day arbitrage bot) was never built as an agent — the
|
||||
structural gates + two hand playtests stood in for it. It goes live again with G's live tier
|
||||
(v7.x), where structure alone won't be enough.
|
||||
2. **Save schema drift — RETIRED.** `procity-save/1` versioned from day one; corrupted/foreign
|
||||
blobs loud-reject + stash raw + boot fresh (gated since R30). The schema widened three times
|
||||
in-epoch (`pulls` R32 · `wants` R33 · `listings` R34), every field optional,
|
||||
validated-exact-and-loud when present, absent-compatible — zero migrations, zero silent
|
||||
rejections, alpha saves still load at the close.
|
||||
3. **Item identity across re-emits — CARRIED (narrowed, never fired).** The sku IS the identity
|
||||
(the R25/R26 fences); real-sourced pulls/listings key by `sku:<id>`, parody by seeded slot
|
||||
identity. No atlas was re-emitted during v7, so the risk never had a chance to fire — and
|
||||
E's validator still lacks the chartered owned-item stability check. Carried to whichever
|
||||
round next re-bakes an atlas: that round must add the check BEFORE the re-emit.
|
||||
4. **Scope gravity — RETIRED (the stage gates held).** Alpha shipped one town playing complete
|
||||
(R30–31), beta opened the 23 (R32–33), v7.0 closed with the auction + onboarding (R34–35).
|
||||
Every mid-epoch want was FILED, not built: the §31.6 beta queue, then the v7.x queue
|
||||
(⚖ discoverability · LIST confirm · the paper as artifact · keeper-rumor by hand · walkable
|
||||
home room). No housing, no romance, no phone.
|
||||
5. **v6 collision — RETIRED (none, by design).** The epochs interleaved without a merge
|
||||
conflict or a contract collision. The one contact was the July-20 2 a.m. commits
|
||||
(`046668e`/`b339402`: 4 Rokoko dance clips + 5 trellis DJ bodies, v6-flavoured assets landing
|
||||
mid-v7 outside the round system) — swept into provenance/licence/depot by E (R36 w1) and
|
||||
gated by F (R36 w2: figure-scale spawn proof, runtime bind census, classic zero-fetch for the
|
||||
clips). One hold from that sweep rides the R36 record: the DJ bodies joined `?classic`'s
|
||||
roster ungated — Fable to bless or bounce.
|
||||
|
||||
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.
|
||||
@ -481,3 +481,43 @@ helpers `organ()` + `grungechord()`). Audio pack **26 → 28 assets**. Old `pubr
|
||||
ingress (this box is allowlisted): the 5 base names now serve **14k bytes** + the 5 `_hi` originals are
|
||||
live. Verified on the public `digalot.fyi` depot (amp base **5.9 MB → 1.31 MB**, `_hi` 5.9 MB live).
|
||||
`_published.json` **33 → 38**. `validate_manifest --depot` GREEN; Gate-3 provenance flag **cleared**.
|
||||
|
||||
## Round 36 wave 1 — the 2 a.m. ped assets, swept (provenance + depot regularization)
|
||||
|
||||
`046668e` (4 dance clips) + `b339402` (**5** trellis DJ bodies — the R36 brief said 3; the diffstat
|
||||
says 5) landed July 20 outside the round system. R36 wave 1 swept all **9** into the pipeline; the
|
||||
files themselves are untouched (byte-identical to both commits and to their 90sDJsim originals).
|
||||
|
||||
**Dance clips — zone corrected by measurement: ROKOKO, not Mixamo.** Source = Rokoko's free 263-asset
|
||||
mocap library (`~/Documents/Rokoko_Free_Mocap_FBX_263`, `Rokoko Studio (Mocap)/Dancing/*_mixamo.fbx`,
|
||||
Rokoko's own Mixamo-skeleton export), baked mesh-free by 90sDJsim `tools/bake_clips.py` (the
|
||||
`fbx_to_clip.py` port). Licence per the pack's rokoko.com page: royalty-free including commercial use.
|
||||
Structure (all 4): 0 meshes · 68 nodes · 198 tracks · 52-joint dangling skin with IBM · 0 NaN/Inf ·
|
||||
binds 65/66 canonical targets on a shipped ped skeleton (only `mixamorigReference` unbound — dropped
|
||||
silently by three.js; matches 90sDJsim's in-game 64/65). Durations 30.58/30.50/44.04/52.42 s.
|
||||
|
||||
| clip | bytes | sha1 |
|
||||
|---|---|---|
|
||||
| dance_party.glb | 688 220 | 38587f7b0cf4aaf199f55677a139db087acdd80d |
|
||||
| dance_medium.glb | 1 028 164 | 4a30fb62163b6e5137c8fb513dfc70d5f6f322b4 |
|
||||
| dance_drink.glb | 962 464 | d818ddb0868e19c87a435b26c8b6ffab14832be4 |
|
||||
| dance_sway.glb | 1 132 560 | 8af9112538e5219fc6a7b6cb36bf1e87e5ac24ff |
|
||||
|
||||
**DJ bodies — on-device TRELLIS.2 mesh (CC0-equiv, the instrument zone) + Adobe Mixamo auto-rig,
|
||||
converted/decimated by MESHGOD `scripts/fbx_to_ped.py`.** GLB law: no Draco, 1024² webp
|
||||
(`EXT_texture_webp` — roster-standard), 1 mtl/1 img, 65-joint mixamorig skin, IBM 0 NaN. Ped law ≤5k:
|
||||
PASS. ⚠ bind-pose skeleton spans ~0.95–0.99 units vs the existing roster's ~173–180 (cm convention) —
|
||||
absorbed at spawn (`rigs.js:163` height/span normalize), flagged to F's R36-w2 figure-scale gate.
|
||||
|
||||
| body | tris | bytes | sha1 |
|
||||
|---|---|---|---|
|
||||
| dj_phrtt_01.glb | 4 000 | 712 792 | 21cf0db5a291f88f7fd9b9cfdb0eac8413c7cf2e |
|
||||
| dj_techno_01.glb | 4 000 | 702 760 | 4cb6bf44a752f921e14d1355f9944b7859c92386 |
|
||||
| woman_dj_01.glb | 3 999 | 731 440 | 7d364dfc7e7659c0b15bead82a5d8bbd285e5286 |
|
||||
| woman_dj_02.glb | 3 999 | 715 116 | 2413ededff1a7684999d6fd6fd58417b77f25c10 |
|
||||
| woman_dj_03.glb | 3 998 | 718 128 | ec39692fa8019645bd200aab9eb6d1078535189c |
|
||||
|
||||
**Depot:** staged to `_normalized/` (the sit/look practice) and published via the passwordless tailnet
|
||||
ingress under John's R22 standing authorization — `_published.json` **44 → 53**, drift check green, and
|
||||
all 9 fetched back from the public depot (`digalot.fyi/3god/a/`) sha1-identical to local. Gates after
|
||||
the sweep: `validate_manifest` 0 errors / 0 warnings · `publish.py --verify` ✓ 53/53 live.
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
[
|
||||
"dance_drink.glb",
|
||||
"dance_medium.glb",
|
||||
"dance_party.glb",
|
||||
"dance_sway.glb",
|
||||
"dj_phrtt_01.glb",
|
||||
"dj_techno_01.glb",
|
||||
"look.glb",
|
||||
"procity_fit_arcade_cabinet_01.glb",
|
||||
"procity_fit_bass_guitar_01.glb",
|
||||
@ -42,5 +48,8 @@
|
||||
"procity_street_park_bench_01.glb",
|
||||
"procity_street_streetlight_01.glb",
|
||||
"sit.glb",
|
||||
"vintage-cash-register.glb"
|
||||
"vintage-cash-register.glb",
|
||||
"woman_dj_01.glb",
|
||||
"woman_dj_02.glb",
|
||||
"woman_dj_03.glb"
|
||||
]
|
||||
@ -1496,6 +1496,176 @@ def smoke_glance(p):
|
||||
b.close()
|
||||
|
||||
|
||||
def smoke_djdance(p):
|
||||
"""R36 wave 2 — the 2 a.m. dance clips (046668e) through the gate, per E's wave-1 flags.
|
||||
|
||||
Four Rokoko free-mocap clips (dance_party/medium/drink/sway — Rokoko's own Mixamo-skeleton
|
||||
export, NOT Mixamo; new licence zone, LANE_E_NOTES R36) joined `loadPedFleet({dance:!CLASSIC})`
|
||||
outside the round system with only "page boots" verified. This smoke proves the commit's claims:
|
||||
|
||||
· the clips BIND (E flag (b)): gate on binds + NaN, never node counts — the Rokoko shape is
|
||||
legitimately 68-node/52-joint, not the sit/look 66/65 template. Runtime census: post
|
||||
_canon/_rotOnly each clip must bind all tracks except Rokoko's root `mixamorigReference`
|
||||
(64/65 — matches E's node-side census and 90sDJsim's in-game measurement).
|
||||
· the clips REACH THE GAME: a gig-night dancer's live action IS a dance clip (identity by
|
||||
duration — the four durations 30.5–52.4 s are unambiguous vs the ~s idle), the mixer is
|
||||
provably advancing, and fig.position.y stays FLAT (the procedural fallback is not running).
|
||||
· `?classic=1` fetches ZERO dance clips (the zero-fetch-delta covenant; positive control =
|
||||
the default boot fetched all 4, so the detector discriminates).
|
||||
· `?noassets=1` falls back to PROCEDURAL sway: zero GLB fetches, dancers exist as
|
||||
placeholders, and fig.position.y measurably bounces (>2 cm) — the exact inverse of the
|
||||
clip-driven amplitude, so the pair proves which branch moved the body.
|
||||
|
||||
[R36 HOLD — not this gate's to fix] the 5 trellis DJ BODIES (b339402) fetch under ?classic
|
||||
too: they are unconditional PED_NAMES.normal roster members (rigs.js:22, Lane D's file), which
|
||||
also widened classic's seeded crowd pool 17→22 on July 20. Suite stayed green (plan fp is
|
||||
plan-side; draws in tolerance) but the classic fetch surface + crowd identity moved without a
|
||||
ruling. WARNED here so the record is in the machine, held for Fable per the five-holds law."""
|
||||
head('SMOKE: dance clips (R36 — Rokoko binds · dancer driven · classic zero-fetch · noassets sway)')
|
||||
if not os.path.exists(os.path.join(str(ROOT), 'web', 'models', 'peds', 'dance_party.glb')):
|
||||
print('· SKIP: dance_party.glb not on disk — subject absent, nothing asserted (Lane E)'); return
|
||||
DANCE_FILES = ['dance_party.glb', 'dance_medium.glb', 'dance_drink.glb', 'dance_sway.glb']
|
||||
DJ_FILES = ['woman_dj_01.glb', 'woman_dj_02.glb', 'woman_dj_03.glb', 'dj_techno_01.glb', 'dj_phrtt_01.glb']
|
||||
|
||||
# 1+2) default boot: binds + the driven dancer (with fetch capture as the positive control)
|
||||
b, pg, errs = new_page(p)
|
||||
reqs = []
|
||||
pg.on('request', lambda r: reqs.append(r.url))
|
||||
try:
|
||||
boot(pg, '')
|
||||
r = pg.evaluate("""async () => {
|
||||
const P = window.PROCITY, D = window.DBG, fleet = P.fleet;
|
||||
const clips = fleet.danceClips || [];
|
||||
// runtime bind census vs a real rig's node set (post _canon/_rotOnly, exactly as bound)
|
||||
const nodes = new Set(); fleet.all[0].scene.traverse(o => { if (o.name) nodes.add(o.name); });
|
||||
const census = clips.map(c => {
|
||||
const un = [...new Set(c.tracks.filter(t => !nodes.has(t.name.split('.')[0]))
|
||||
.map(t => t.name.split('.')[0]))];
|
||||
return { dur: +c.duration.toFixed(2), total: c.tracks.length,
|
||||
bind: c.tracks.length - c.tracks.filter(t => !nodes.has(t.name.split('.')[0])).length,
|
||||
unbound: un };
|
||||
});
|
||||
// gig night: the dancer's live action
|
||||
D.setSegment(5);
|
||||
D.enterShop(P.gigs.venueShopIds[0]);
|
||||
await new Promise(res => setTimeout(res, 600));
|
||||
const crew = P.interiorMode.crew;
|
||||
const dancers = crew ? crew.members.filter(m => m.part === 'crowd' && m.dance) : [];
|
||||
const durs = new Set(clips.map(c => +c.duration.toFixed(2)));
|
||||
const before = dancers.map(m => {
|
||||
const acts = m.actor.mixer ? m.actor.mixer._actions : [];
|
||||
return { kind: m.kind, clipDur: acts.length ? +acts[0]._clip.duration.toFixed(2) : null,
|
||||
t: acts.length ? acts[0].time : null, y: m.actor.fig.position.y };
|
||||
});
|
||||
let minY = dancers.map(m => m.actor.fig.position.y), maxY = minY.slice();
|
||||
for (let k = 0; k < 14; k++) {
|
||||
await new Promise(res => setTimeout(res, 50));
|
||||
dancers.forEach((m, i) => { const y = m.actor.fig.position.y;
|
||||
minY[i] = Math.min(minY[i], y); maxY[i] = Math.max(maxY[i], y); });
|
||||
}
|
||||
const after = dancers.map(m => {
|
||||
const acts = m.actor.mixer ? m.actor.mixer._actions : [];
|
||||
return acts.length ? acts[0].time : null;
|
||||
});
|
||||
D.exitShop();
|
||||
return { nClips: clips.length, census,
|
||||
dancers: before.map((d, i) => ({ ...d, tAfter: after[i],
|
||||
isDance: d.clipDur != null && durs.has(d.clipDur),
|
||||
amp: +(maxY[i] - minY[i]).toFixed(4) })) };
|
||||
}""")
|
||||
if r['nClips'] == 4: OK('default boot: 4 dance clips loaded and canonicalised (party/medium/drink/sway)')
|
||||
else: FAIL(f"default boot: expected 4 dance clips, fleet has {r['nClips']}")
|
||||
badc = [c for c in r['census'] if c['bind'] != c['total'] - 1 or c['unbound'] != ['mixamorigReference']]
|
||||
if not badc:
|
||||
OK(f"Rokoko binds: every clip binds {r['census'][0]['bind']}/{r['census'][0]['total']} tracks, "
|
||||
f"sole unbound = mixamorigReference (the Rokoko root — E's census, reproduced at runtime)")
|
||||
else:
|
||||
FAIL(f"dance clip bind census off: {badc[:2]}")
|
||||
dn = r['dancers']
|
||||
if not dn:
|
||||
FAIL('gig night: no dancers in the crowd — the subject is absent')
|
||||
else:
|
||||
not_dance = [d for d in dn if not d['isDance'] or d['kind'] != 'rig']
|
||||
stalled = [d for d in dn if d['tAfter'] is not None and d['t'] is not None and d['tAfter'] <= d['t']]
|
||||
bouncing = [d for d in dn if d['amp'] > 0.02]
|
||||
if not_dance: FAIL(f"gig night: dancer NOT driven by a dance clip: {not_dance[:2]}")
|
||||
elif stalled: FAIL(f"gig night: dancer mixer not advancing (page stalled?) — amplitude read is vacuous: {stalled[:2]}")
|
||||
elif bouncing: FAIL(f"gig night: dancer position.y bouncing {bouncing[0]['amp']}m — the PROCEDURAL fallback is running despite loaded clips")
|
||||
else:
|
||||
OK(f"gig night: {len(dn)} dancer(s) driven by real dance clips (durations {sorted(set(d['clipDur'] for d in dn))}s, "
|
||||
f"mixers advancing, position.y flat — the clip moves the body, not the fallback)")
|
||||
dance_fetches = [u for u in reqs if any(f in u for f in DANCE_FILES)]
|
||||
if len(dance_fetches) == 4: OK('positive control: the default boot fetched all 4 dance clips (the detector can see the fetch)')
|
||||
else: WARN(f"positive control: expected 4 dance-clip fetches on the default boot, saw {len(dance_fetches)}")
|
||||
if errs: FAIL(f"dance/default: {len(errs)} console error(s); first: {errs[0][:120]}")
|
||||
else: print(' · 0 console errors (default boot)')
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
# 3) ?classic=1 — zero dance-clip fetches; the DJ-body roster fetch is WARNED (held, see docstring)
|
||||
b, pg, errs = new_page(p)
|
||||
reqs = []
|
||||
pg.on('request', lambda r: reqs.append(r.url))
|
||||
try:
|
||||
boot(pg, 'classic=1')
|
||||
pg.wait_for_timeout(1500)
|
||||
dance_fetches = [u.split('/')[-1] for u in reqs if any(f in u for f in DANCE_FILES)]
|
||||
dj_fetches = sorted(u.split('/')[-1] for u in reqs if any(f in u for f in DJ_FILES))
|
||||
if not dance_fetches: OK('?classic=1: ZERO dance-clip fetches — the zero-fetch-delta covenant holds for the clips')
|
||||
else: FAIL(f"?classic=1 FETCHED dance clips: {dance_fetches}")
|
||||
if dj_fetches:
|
||||
WARN(f"?classic=1 fetches the 5 trellis DJ BODIES ({len(dj_fetches)}) — they are unconditional roster "
|
||||
f"members (rigs.js PED_NAMES, b339402), which also widened classic's crowd pool 17→22. "
|
||||
f"HELD for Fable: bless the roster change or direct D to gate them out of ?classic")
|
||||
else:
|
||||
OK('?classic=1: zero DJ-body fetches')
|
||||
if errs: FAIL(f"dance/classic: {len(errs)} console error(s); first: {errs[0][:120]}")
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
# 4) ?noassets=1 — the procedural sway fallback, alive and measured
|
||||
b, pg, errs = new_page(p)
|
||||
reqs = []
|
||||
pg.on('request', lambda r: reqs.append(r.url))
|
||||
try:
|
||||
boot(pg, 'noassets=1')
|
||||
r = pg.evaluate("""async () => {
|
||||
const P = window.PROCITY, D = window.DBG;
|
||||
D.setSegment(5);
|
||||
D.enterShop(P.gigs.venueShopIds[0]);
|
||||
await new Promise(res => setTimeout(res, 600));
|
||||
const crew = P.interiorMode.crew;
|
||||
const dancers = crew ? crew.members.filter(m => m.part === 'crowd' && m.dance) : [];
|
||||
let minY = dancers.map(m => m.actor.fig.position.y), maxY = minY.slice();
|
||||
for (let k = 0; k < 14; k++) {
|
||||
await new Promise(res => setTimeout(res, 50));
|
||||
dancers.forEach((m, i) => { const y = m.actor.fig.position.y;
|
||||
minY[i] = Math.min(minY[i], y); maxY[i] = Math.max(maxY[i], y); });
|
||||
}
|
||||
D.exitShop();
|
||||
return { fleet: !!P.fleet, dancers: dancers.map((m, i) => ({ kind: m.kind,
|
||||
amp: +(maxY[i] - minY[i]).toFixed(4) })) };
|
||||
}""")
|
||||
glb_fetches = [u for u in reqs if '.glb' in u]
|
||||
if not glb_fetches: OK('?noassets=1: zero GLB fetches (dance clips included)')
|
||||
else: FAIL(f"?noassets=1: {len(glb_fetches)} GLB fetch(es) leaked; first {glb_fetches[0]}")
|
||||
dn = r['dancers']
|
||||
swaying = [d for d in dn if d['amp'] > 0.02]
|
||||
if not dn:
|
||||
FAIL('?noassets=1: no dancers — fallback subject absent')
|
||||
elif r['fleet']:
|
||||
FAIL('?noassets=1: fleet exists — the boot is not asset-free')
|
||||
elif len(swaying) == len(dn):
|
||||
OK(f"?noassets=1: procedural sway ALIVE — {len(dn)} placeholder dancer(s) bouncing "
|
||||
f"(amp {min(d['amp'] for d in dn)}–{max(d['amp'] for d in dn)}m vs the clip-driven 0.00 — the inverse pair proves the branch)")
|
||||
else:
|
||||
FAIL(f"?noassets=1: dancer(s) NOT swaying procedurally: {dn}")
|
||||
if errs: FAIL(f"dance/noassets: {len(errs)} console error(s); first: {errs[0][:120]}")
|
||||
else: print(' · 0 console errors (?noassets)')
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
|
||||
def smoke_continuity(p):
|
||||
"""R15 identity continuity (ledger #3): the ped who queued/surged into a venue turns up in its crowd.
|
||||
Exercises F's real relay end-to-end — VenueQueue.admitOne() → onAdmit → citizens.recordVenueEntry →
|
||||
@ -1633,16 +1803,17 @@ def _game_state(pg):
|
||||
exNorm: JSON.stringify(ex), day: g.day, cash: g.cash, n: g.collection.length }; }""")
|
||||
|
||||
|
||||
def _at_bin(pg):
|
||||
"""Stand at the first bin of the open room, aimed at it (smoke_buy's proven setup)."""
|
||||
return pg.evaluate("""() => {
|
||||
def _at_bin(pg, idx=0):
|
||||
"""Stand at the idx-th bin of the open room, aimed at it (smoke_buy's proven setup)."""
|
||||
return pg.evaluate("""(idx) => {
|
||||
const P = window.PROCITY;
|
||||
const bins = []; P.interiorMode.current.group.traverse(o => { if (o.userData && o.userData.kind === 'bin') bins.push(o); });
|
||||
if (!bins.length) return { err: 'no bins' };
|
||||
const bp = new P.THREE.Vector3(); bins[0].getWorldPosition(bp);
|
||||
if (idx >= bins.length) return { err: 'bin index out of range', bins: bins.length };
|
||||
const bp = new P.THREE.Vector3(); bins[idx].getWorldPosition(bp);
|
||||
P.camera.position.set(bp.x, 1.6, bp.z + 1.4); P.camera.lookAt(bp.x, bp.y + 0.4, bp.z); P.camera.updateMatrixWorld();
|
||||
return { ok: true };
|
||||
}""")
|
||||
return { ok: true, bins: bins.length };
|
||||
}""", idx)
|
||||
|
||||
|
||||
def _dig_buy(pg):
|
||||
@ -1757,7 +1928,7 @@ def smoke_sell_routing(p):
|
||||
FAIL(f"routing: dig buy wallet/collection delta wrong — {rb}"); return
|
||||
ent = pg.evaluate("""() => { const g = window.PROCITY.game;
|
||||
const e = g.collection[g.collection.length - 1];
|
||||
return { type: e.type, pricePaid: e.pricePaid, hasId: !!(e.sku || e.slotId) }; }""")
|
||||
return { type: e.type, pricePaid: e.pricePaid, hasId: !!(e.sku || e.slotId), band: e.band || null }; }""")
|
||||
if ent['type'] == 'record' and ent['hasId'] and ent['pricePaid'] == paid:
|
||||
OK(f"routing: entry shape honest — type=record, sku|slotId set, pricePaid == debit (${paid})")
|
||||
else:
|
||||
@ -1778,10 +1949,21 @@ def smoke_sell_routing(p):
|
||||
hudCash: cashEl ? cashEl.textContent : null,
|
||||
hudCrate: crateBtn ? crateBtn.textContent : null }; }""")
|
||||
credited = after['cash'] - cash_before
|
||||
if rs.get('sold') == 1 and after['n'] == n_before - 1 and 0 < credited < paid:
|
||||
OK(f"routing: SELL — +${credited} credited (< ${paid} paid, no-pump by construction), collection {n_before}→{after['n']}")
|
||||
# [R32 amendment] the no-pump law's beta form: the offer is EXACTLY min(basis−1, max(1, basis//2))
|
||||
# where basis = bandLow when the entry carries a band (the §9.1 swap), pricePaid otherwise. The
|
||||
# old `credited < paid` held for every alpha sale but a banded GEM legitimately credits above
|
||||
# what was paid — the fence moved from the receipt to the guide band, so the gate measures the
|
||||
# formula itself, not the alpha inequality.
|
||||
R32_LOW = {'bargain': 2, 'standard': 8, 'collector': 25, 'grail': 60}
|
||||
basis = R32_LOW.get(ent.get('band'), paid)
|
||||
want = min(basis - 1, max(1, basis // 2)) if basis >= 1 else 0
|
||||
if rs.get('sold') == 1 and after['n'] == n_before - 1 and credited == want and credited < basis:
|
||||
OK(f"routing: SELL — +${credited} credited == min(basis−1, basis/2) with basis ${basis} "
|
||||
f"({'band ' + ent['band'] if ent.get('band') else 'pricePaid'}; < basis, no-pump by construction), "
|
||||
f"collection {n_before}→{after['n']}")
|
||||
else:
|
||||
FAIL(f"routing: sale delta wrong — sold={rs.get('sold')}, credited {credited} vs paid {paid}, n {n_before}→{after['n']}")
|
||||
FAIL(f"routing: sale delta wrong — sold={rs.get('sold')}, credited {credited} want {want} "
|
||||
f"(basis {basis}), n {n_before}→{after['n']}")
|
||||
if after['hudCash'] is not None:
|
||||
if after['hudCash'] == str(after['cash']) and after['hudCrate'] == f"CRATE {after['n']}":
|
||||
OK(f"routing: B's HUD follows — $" + after['hudCash'] + f" · {after['hudCrate']}")
|
||||
@ -1983,6 +2165,458 @@ def smoke_nopump(p):
|
||||
b.close()
|
||||
|
||||
|
||||
def smoke_bible(p):
|
||||
"""R32 gate — THE BIBLE + per-day scarcity (v7.0-beta wave 1). Legs: (a) the pull card carries the
|
||||
guide stamp on a NAKED boot (lesson #4 — no explicit flags; parody bin); (b) structural: every parody
|
||||
offer prices INSIDE its band unless it's a seeded gem (asking < bandLow, collector/grail only) —
|
||||
read off F's digOffers honest surface, checked against the BIBLE mirror of E's thresholds;
|
||||
(c) buy the WHOLE bin, sell it all: cash moves exactly Σoffer−Σask with offer = min(low−1,
|
||||
max(1, low//2)) per item — gems positive, everything else negative, the ledger closes to the cent;
|
||||
(d) scarcity: the emptied bin re-opens EMPTY, stays empty across a reload (pulls ride the save),
|
||||
and (e) negative control: ?game=0 re-opens the same bin FULL — the detector discriminates
|
||||
(scarcity is the save's doing, not the dig's). (f) real stock carries the stamp too."""
|
||||
head('GATE R32: THE BIBLE — guide bands · gems · per-day scarcity (+ ?game=0 control)')
|
||||
BIBLE = {'bargain': (2, 7), 'standard': (8, 24), 'collector': (25, 59), 'grail': (60, None)}
|
||||
exp_offer = lambda band: min(BIBLE[band][0] - 1, max(1, BIBLE[band][0] // 2))
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, '') # NAKED — dig/game default-on carry the feature
|
||||
r0 = _enter_record_shop(pg)
|
||||
if r0.get('err'): FAIL(f"bible: {r0['err']}"); return
|
||||
# bankroll the buy-everything leg through the REAL import path (5000 covers any 16-sleeve bin)
|
||||
rich = pg.evaluate("""() => { const g = window.PROCITY.game;
|
||||
const ex = JSON.parse(g.export()); ex.cash = 5000; ex.savedAt = Date.now();
|
||||
return { ok: g.import(JSON.stringify(ex)), cash: g.cash }; }""")
|
||||
if not (rich['ok'] and rich['cash'] == 5000):
|
||||
FAIL(f"bible: bankroll import failed — {rich}"); return
|
||||
pg.evaluate("() => window.DBG.setSegment(2)") # import's onDay woke us at DAWN — back to midday
|
||||
# ── (a)+(b): scan EVERY bin in the room off the honest surface — band law on all of them, and
|
||||
# find a bin holding a GEM (seeded: whichever bin it is today, it's that bin forever). The
|
||||
# buy-everything ledger leg then runs on THE GEM'S BIN, so the finder-margin arithmetic is
|
||||
# exercised for real, not vacuously (a 0-gem bin would prove only the losing half).
|
||||
n_bins = (_at_bin(pg) or {}).get('bins', 1)
|
||||
bad, all_offers, gem_bin, gems = [], {}, None, []
|
||||
for bi in range(n_bins):
|
||||
_at_bin(pg, bi)
|
||||
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||
pg.wait_for_timeout(350)
|
||||
offs = pg.evaluate("() => window.PROCITY.interiorMode.digOffers")
|
||||
pg.evaluate("() => { const o = document.querySelector('.pcdg-out'); if (o) o.click(); }")
|
||||
pg.wait_for_timeout(200)
|
||||
if not offs: bad.append(f"bin{bi} unreadable"); continue
|
||||
all_offers[bi] = offs
|
||||
bin_gems = []
|
||||
for o in offs:
|
||||
band = o.get('band')
|
||||
if band not in BIBLE: bad.append(f"bin{bi}#{o['i']} band={band!r}"); continue
|
||||
lo, hi = BIBLE[band]
|
||||
if o['price'] < lo:
|
||||
if band in ('collector', 'grail'): bin_gems.append(o)
|
||||
else: bad.append(f"bin{bi}#{o['i']} {band} ${o['price']} under-band but not gem-eligible")
|
||||
elif hi is not None and o['price'] > hi:
|
||||
bad.append(f"bin{bi}#{o['i']} {band} ${o['price']} over band")
|
||||
if bin_gems and gem_bin is None: gem_bin, gems = bi, bin_gems
|
||||
if bad: FAIL(f"bible: band-law violations — {bad[:3]}")
|
||||
else: OK(f"bible: {sum(len(v) for v in all_offers.values())} parody offers across {n_bins} bin(s) "
|
||||
f"all inside their bands (or seeded gems)")
|
||||
if gem_bin is None:
|
||||
FAIL(f"bible: NO gem in any of {n_bins} bins — the thrill leg is unreachable on this seed"); return
|
||||
for g in gems:
|
||||
if not g['price'] < exp_offer(g['band']):
|
||||
FAIL(f"bible: gem #{g['i']} asks ${g['price']} ≥ keeper offer ${exp_offer(g['band'])} — no finder margin")
|
||||
OK(f"bible: bin{gem_bin} holds {len(gems)} gem(s) — e.g. {gems[0]['band']} asking ${gems[0]['price']} "
|
||||
f"vs keeper ${exp_offer(gems[0]['band'])} (margin by construction)")
|
||||
offers = all_offers[gem_bin]
|
||||
_at_bin(pg, gem_bin)
|
||||
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||
pg.wait_for_timeout(350)
|
||||
# pull the front sleeve → the guide stamp on the card
|
||||
pg.evaluate("""() => { const c = window.PROCITY.renderer.domElement;
|
||||
c.dispatchEvent(new PointerEvent('pointerdown', { clientX: 640, clientY: 360, bubbles: true }));
|
||||
c.dispatchEvent(new PointerEvent('pointerup', { clientX: 640, clientY: 360, bubbles: true })); }""")
|
||||
pg.wait_for_timeout(300)
|
||||
stamp = pg.evaluate("""() => { const p = document.querySelector('.pcdg-panel');
|
||||
return p && p.style.display === 'block' ? p.textContent : null; }""")
|
||||
if stamp and 'guide $' in stamp: OK('bible: the pull card carries the guide stamp (naked boot, parody)')
|
||||
else: FAIL(f"bible: no guide stamp on the pull card — {stamp[:80] if stamp else 'no panel'}")
|
||||
pg.evaluate("""() => { const p = document.querySelector('.pcdg-panel');
|
||||
const x = p && p.querySelector('.x'); if (x) x.click(); }""")
|
||||
# ── (c): buy the WHOLE bin through the real card, then sell it all at the counter ──
|
||||
cash0 = pg.evaluate("() => window.PROCITY.wallet.cash()")
|
||||
bought = 0
|
||||
for _ in range(len(offers)):
|
||||
rb = _dig_buy(pg)
|
||||
if rb.get('err') or rb.get('disabled'): break
|
||||
bought += 1
|
||||
if bought < len(offers): _at_bin(pg, gem_bin) # walk-out re-closed the dig; re-aim the gem's bin
|
||||
if bought != len(offers):
|
||||
FAIL(f"bible: bought {bought}/{len(offers)} — the buy-everything leg died early"); return
|
||||
ask_total = sum(o['price'] for o in offers)
|
||||
offer_total = sum(exp_offer(o['band']) for o in offers)
|
||||
rs = _counter_sell(pg, max_sales=bought + 2)
|
||||
sell_card = pg.evaluate("() => { const p = document.querySelector('.pcsl-panel'); return p ? p.textContent : ''; }")
|
||||
if rs.get('err') or rs.get('sold', 0) != bought:
|
||||
FAIL(f"bible: sold {rs.get('sold')}/{bought} — {rs}"); return
|
||||
cash1 = pg.evaluate("() => window.PROCITY.wallet.cash()")
|
||||
gem_profit = sum(exp_offer(o['band']) - o['price'] for o in gems)
|
||||
if cash1 - cash0 == offer_total - ask_total:
|
||||
OK(f"bible: the ledger closes — Σask ${ask_total} → Σoffer ${offer_total} "
|
||||
f"(net {cash1 - cash0:+d}; gems alone {gem_profit:+d}, every non-gem strictly negative)")
|
||||
else:
|
||||
FAIL(f"bible: ledger mismatch — cash moved {cash1 - cash0:+d}, expected {offer_total - ask_total:+d} "
|
||||
f"(basis law broken somewhere)")
|
||||
# ── (d): scarcity — the emptied bin is EMPTY on re-open, and STAYS empty across a reload ──
|
||||
_at_bin(pg, gem_bin)
|
||||
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||
pg.wait_for_timeout(300)
|
||||
n_re = pg.evaluate("() => (window.PROCITY.interiorMode.digOffers || []).length")
|
||||
pg.evaluate("() => { const o = document.querySelector('.pcdg-out'); if (o) o.click(); }")
|
||||
pulls_n = pg.evaluate("() => (JSON.parse(window.PROCITY.game.export()).pulls || []).length")
|
||||
if n_re == 0 and pulls_n >= bought:
|
||||
OK(f"bible/scarcity: re-opened bin is cleaned out (0 of {bought}) · save carries {pulls_n} pull(s)")
|
||||
else:
|
||||
FAIL(f"bible/scarcity: re-open shows {n_re} sleeves (want 0) · pulls in save {pulls_n}")
|
||||
boot(pg, '') # full reload, same origin — the save must carry it
|
||||
r1 = _enter_record_shop(pg)
|
||||
if r1.get('err'): FAIL(f"bible/scarcity reload: {r1['err']}")
|
||||
else:
|
||||
_at_bin(pg, gem_bin)
|
||||
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||
pg.wait_for_timeout(300)
|
||||
n_reload = pg.evaluate("() => (window.PROCITY.interiorMode.digOffers || []).length")
|
||||
if n_reload == 0: OK('bible/scarcity: still cleaned out after a full reload — pulls rode the save')
|
||||
else: FAIL(f"bible/scarcity: reload restocked the bin ({n_reload} sleeves) — pulls not persisted")
|
||||
# ── (e): negative control — ?game=0 must re-open the SAME bin FULL (rotation gate: game=0 ≡ day1) ──
|
||||
boot(pg, 'game=0')
|
||||
r2 = _enter_record_shop(pg)
|
||||
if r2.get('err'): FAIL(f"bible/control: {r2['err']}")
|
||||
else:
|
||||
_at_bin(pg, gem_bin)
|
||||
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||||
pg.wait_for_timeout(300)
|
||||
n_ctl = pg.evaluate("() => (window.PROCITY.interiorMode.digOffers || []).length")
|
||||
if n_ctl == len(offers): OK(f"bible/control: ?game=0 re-opens the bin FULL ({n_ctl}) — "
|
||||
"scarcity is the save's doing, the detector discriminates")
|
||||
else: FAIL(f"bible/control: ?game=0 bin shows {n_ctl}, want {len(offers)}")
|
||||
if 'guide $' in (sell_card or ''): OK('bible: the sell card carries the guide line too')
|
||||
else: WARN('bible: no guide line read off the sell card (cosmetic leg)')
|
||||
if errs: FAIL(f"bible: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||||
else: OK('bible: 0 console errors')
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
|
||||
def smoke_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):
|
||||
"""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
|
||||
@ -2131,11 +2765,16 @@ def main():
|
||||
smoke_gigs(p) # R12/13 — the district gig layer (schedule · band · crowd · budget · cover · noassets)
|
||||
smoke_continuity(p) # R15 — identity continuity (the ped you followed is in the crowd · settled tris)
|
||||
smoke_glance(p) # R29 — Spike 1: v6's first clip in the game (classic zero-fetch)
|
||||
smoke_djdance(p) # R36 — the 2 a.m. dance clips: Rokoko binds · dancer driven · classic zero-fetch · noassets sway
|
||||
smoke_sell_routing(p) # R30 — the E-key sell routing (§9.4, the held join, live loop)
|
||||
smoke_save_determinism(p) # R30 gate a — export → fresh boot → import byte-equal, world untouched
|
||||
smoke_nopump(p) # R30 gate b — 5 real round trips monotonic loss + C's negative control
|
||||
smoke_classic_purity(p) # R30 gate c — instrumented Storage zero + zero game DOM + positive control
|
||||
smoke_rotation(p) # R30 gate d — vertex-data rotation determinism (the wave-3 binding)
|
||||
smoke_bible(p) # R32 — THE BIBLE (bands · gems · ledger) + per-day scarcity + ?game=0 control
|
||||
smoke_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_real_crate(p) # R24 — THE REAL CRATE (per-shop atlas identity · soft fall · no-op off the path)
|
||||
finally:
|
||||
|
||||
@ -37,6 +37,13 @@ LO, HI = 1.4, 2.0 # human crown band (Round-10 gate)
|
||||
SEAT_LO, SEAT_HI = 0.9, 1.5 # [R16 ledger #3] seated figs (the drummer): a seated human's feet→crown span, still < ceiling, feet planted
|
||||
FOOT_EPS = 0.05 # feet must plant at floor y=0
|
||||
SAMPLE_TYPES = ['opshop', 'toy', 'video', 'record', 'book', 'milkbar', 'clothes', 'hardware']
|
||||
# [R36 w2 — E's flag (a)] the five trellis DJ bodies (b339402, swept R36 w1). Their bind skeletons
|
||||
# measure ~0.95–0.99 UNITS tall vs the legacy roster's ~173–180 (the cm convention) — absorbed by
|
||||
# design at rigs.js:163 (span-normalise at spawn), but they joined PED_NAMES ungated and the browse
|
||||
# sample above only ever draws pedIndex (i*7)%19, which can never reach fleet indices 17–21. This leg
|
||||
# spawns each of the five BY NAME and asserts the spawned stature lands human — the number E flagged,
|
||||
# proven at the spawn path, not inferred from the file.
|
||||
DJ_BODIES = ['woman_dj_01', 'woman_dj_02', 'woman_dj_03', 'dj_techno_01', 'dj_phrtt_01']
|
||||
|
||||
fails = []
|
||||
def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}")
|
||||
@ -137,6 +144,57 @@ def main():
|
||||
OK(f"{sid['name']} ({t}, H={H}m): {len(figs)} figs statures=[{stats}] all human-sized")
|
||||
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
|
||||
pg.wait_for_timeout(150)
|
||||
|
||||
# ── [R36 w2] the five DJ bodies, spawned by name (E's flag (a)) ──────────
|
||||
dj = pg.evaluate(r"""(DJ) => {
|
||||
const P = window.PROCITY, D = window.DBG, THREE = P.THREE, v = new THREE.Vector3();
|
||||
const s = (P.plan.shops || []).find(x => x.type === 'record' && P.isOpen(x));
|
||||
if (!s) return { err: 'no open record shop' };
|
||||
D.enterShop(s.id);
|
||||
const im = P.interiorMode, cur = im.current;
|
||||
if (!cur) return { err: 'interior did not build' };
|
||||
const bps = cur.browsePoints || [];
|
||||
const H = +cur.dims.H, fleet = P.fleet, figs = [];
|
||||
DJ.forEach((name, i) => {
|
||||
const idx = fleet.all.findIndex(r => r.pedName === name);
|
||||
if (idx < 0) { figs.push({ name, missing: true }); return; }
|
||||
// native bind span — the number E flagged (~0.95–0.99 units vs legacy ~173–180)
|
||||
const sc = fleet.all[idx].scene; sc.updateWorldMatrix(true, true);
|
||||
let bh = 0, bm = Infinity;
|
||||
sc.traverse(o => { if (o.isBone) { o.getWorldPosition(v);
|
||||
if (/head/i.test(o.name)) bh = Math.max(bh, v.y); bm = Math.min(bm, v.y); } });
|
||||
const pt = bps[i % Math.max(1, bps.length)] || { x: 0, z: 0, ry: 0 };
|
||||
const k = im.keepers.spawn(cur.group, { x: pt.x, z: pt.z, ry: pt.ry, shopId: s.id,
|
||||
browse: true, seedKey: 'qa-dj#' + i, pedIndex: idx });
|
||||
const a = k.actor; a.fig.updateWorldMatrix(true, true);
|
||||
let crown = 0, foot = Infinity;
|
||||
(a.inner || a.fig).traverse(o => { if (o.isBone) { o.getWorldPosition(v);
|
||||
if (/head/i.test(o.name)) crown = Math.max(crown, v.y); foot = Math.min(foot, v.y); } });
|
||||
figs.push({ name, idx, kind: k.kind, usedPed: fleet.all[idx].pedName,
|
||||
nativeSpan: +(bh - bm).toFixed(3), stature: +(crown - foot).toFixed(3),
|
||||
foot: +foot.toFixed(3), crown: +crown.toFixed(3) });
|
||||
im.keepers.remove(k);
|
||||
});
|
||||
D.exitShop();
|
||||
return { shop: s.name, H, figs };
|
||||
}""", DJ_BODIES)
|
||||
if dj.get('err'):
|
||||
FAIL(f"DJ-body leg (R36): {dj['err']} — the five trellis bodies went unmeasured")
|
||||
else:
|
||||
bad = []
|
||||
for f in dj['figs']:
|
||||
if f.get('missing'): bad.append(f"{f['name']} NOT IN FLEET"); continue
|
||||
total_figs += 1
|
||||
if f['kind'] != 'rig' or f['usedPed'] != f['name']:
|
||||
bad.append(f"{f['name']} spawned as {f['kind']}/{f.get('usedPed')} — subject not exercised")
|
||||
elif not (LO <= f['stature'] <= HI): bad.append(f"{f['name']} stature {f['stature']}m out of [{LO},{HI}] (bind span {f['nativeSpan']}u)")
|
||||
elif not (f['crown'] < dj['H']): bad.append(f"{f['name']} crown {f['crown']}m >= ceiling {dj['H']}m")
|
||||
elif abs(f['foot']) >= FOOT_EPS: bad.append(f"{f['name']} feet at {f['foot']}m (not planted)")
|
||||
if bad:
|
||||
FAIL(f"DJ bodies (R36, {dj['shop']}): " + "; ".join(bad))
|
||||
else:
|
||||
det = ", ".join(f"{f['name']} {f['nativeSpan']}u→{f['stature']:.2f}m" for f in dj['figs'])
|
||||
OK(f"DJ bodies (R36, {dj['shop']}): all 5 spawned as rigs, human-sized — rigs.js span-normalise absorbs the ~1-unit bind [{det}]")
|
||||
b.close()
|
||||
finally:
|
||||
if srv: srv.terminate()
|
||||
|
||||
@ -32,6 +32,7 @@
|
||||
<h1>PROCITY</h1>
|
||||
<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>
|
||||
<div class="sub" id="pc-hunt" style="color:#ffd75e;display:none"></div>
|
||||
<button id="pc-go">walk into town</button>
|
||||
<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>
|
||||
@ -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 { 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 { 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) => {
|
||||
console.error('[procity] init failed:', err);
|
||||
@ -168,7 +171,8 @@ const skins = createSkinLibrary();
|
||||
const ground = buildGround(plan, skins);
|
||||
scene.add(ground.group);
|
||||
|
||||
const lighting = createLighting(scene, plan, skins, { radius: RADIUS, shadows: SHADOWS });
|
||||
const lighting = createLighting(scene, plan, skins, { radius: RADIUS, shadows: SHADOWS,
|
||||
dawnSky: CLASSIC ? null : 'high-cirrus' }); // [R32 §31.6-3] dawn reads dawn; classic keeps the frozen dome
|
||||
lighting.attachRenderer(renderer);
|
||||
|
||||
const ctx = { skins, citySeed: plan.citySeed, plan, night: lighting.isNight(), radius: RADIUS };
|
||||
@ -187,7 +191,7 @@ const minimap = createMinimap(plan);
|
||||
// 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.
|
||||
// • 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
|
||||
// [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
|
||||
@ -263,7 +267,9 @@ const game = GAME_ON ? createGame({
|
||||
onDay: (day) => {
|
||||
if (gigState) gigState.setWeekNight(weekNightOf(day)); // (day − 1) % 7 — see the seam note below
|
||||
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;
|
||||
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.
|
||||
const weekNightOf = (day) => (Math.max(1, day) - 1) % 7;
|
||||
if (game && gigState) gigState.setWeekNight(weekNightOf(game.day));
|
||||
// [R33 — TRAVEL COSTS A DAY] this boot adopted a save from another town: the ride ate a day (save.js
|
||||
// adopt() did the increment + pull-pruning; weekNight above already read the incremented day). Tell John.
|
||||
// [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()
|
||||
// time (door click), long after citizens is initialized — so the forward reference is safe.
|
||||
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 queueCountOf = (id) => { const q = venueQueues && venueQueues.get(id); return q ? q.count() : 0; };
|
||||
|
||||
// spawn on the footpath near the west end of the strip, looking east down main street
|
||||
player.teleport(-96, 9, -Math.PI / 2);
|
||||
// [R32 §31.6-1 — THE CLUSTER-POSE SPAWN] The frozen pose (-96, 9, looking east) was tuned for the
|
||||
// synthetic fixture; on real OSM towns it faced dirt and building-backs (the R31 playtest's #1).
|
||||
// Non-classic boots now spawn at one END of the shop cluster's dominant axis, ON the footpath door-line
|
||||
// (a point every shop already computes for patronage — known walkable), looking down the strip toward
|
||||
// the cluster's heart. Deterministic (pure plan math, no draws — no stream moves), and ?classic=1
|
||||
// keeps the frozen pose forever (the covenant is a POSE here, not just a plan).
|
||||
let SPAWN = { x: -96, z: 9, yaw: -Math.PI / 2 };
|
||||
if (!CLASSIC && plan.shops && plan.shops.length >= 2) {
|
||||
// main-street shop doors only (block kind — both generators stamp it); every-door fallback
|
||||
const msBlocks = new Set((plan.blocks || []).filter((b) => b.kind === 'mainstreet').map((b) => b.id));
|
||||
const doorOf = (s) => {
|
||||
const l = plan.lots.find((x) => x.id === s.lot); if (!l) return null;
|
||||
const ry = l.ry || 0;
|
||||
return { x: l.x - Math.sin(ry) * (l.d / 2 + 2.6), z: l.z - Math.cos(ry) * (l.d / 2 + 2.6), block: l.block };
|
||||
};
|
||||
let doors = plan.shops.map(doorOf).filter(Boolean);
|
||||
const ms = doors.filter((d) => msBlocks.has(d.block));
|
||||
if (ms.length >= 2) doors = ms;
|
||||
if (doors.length >= 2) {
|
||||
let cx = 0, cz = 0; for (const d of doors) { cx += d.x; cz += d.z; } cx /= doors.length; cz /= doors.length;
|
||||
let vx = 0, vz = 0; for (const d of doors) { vx += (d.x - cx) ** 2; vz += (d.z - cz) ** 2; }
|
||||
const ax = vx >= vz ? 'x' : 'z'; // the strip's dominant axis
|
||||
let stand = doors[0], bd = Infinity; // stand at the door nearest the cluster's heart
|
||||
for (const d of doors) { const dd = (d.x - cx) ** 2 + (d.z - cz) ** 2; if (dd < bd) { bd = dd; stand = d; } }
|
||||
let lo = Infinity, hi = -Infinity;
|
||||
for (const d of doors) { lo = Math.min(lo, d[ax]); hi = Math.max(hi, d[ax]); }
|
||||
const dir = (hi - stand[ax] >= stand[ax] - lo) ? 1 : -1; // face the longer run of shopfronts
|
||||
SPAWN = { x: stand.x, z: stand.z, yaw: ax === 'x' ? Math.atan2(-dir, 0) : Math.atan2(0, -dir) };
|
||||
}
|
||||
}
|
||||
player.teleport(SPAWN.x, SPAWN.z, SPAWN.yaw);
|
||||
chunks.warmup(player.position);
|
||||
lighting.update(0.0001, player.position);
|
||||
|
||||
@ -508,8 +586,10 @@ function frame() {
|
||||
renderer.info.reset(); // zero the counters, then let the composer's passes accumulate this frame
|
||||
composer.render();
|
||||
} else if (MODE === 'interior') {
|
||||
hud.tickGame(); // [R32 §31.6-2] the crate/day/cash surface lives in shops too (yields to the cards)
|
||||
if (interiorMode.update(dt, player.isLocked ? keys : EMPTY)) leaveShop(); // [Lane F] walk to the door → back to street
|
||||
} else if (MODE === 'map') {
|
||||
hud.tickGame(); // [R32] map mode: the same check hides the surface (the map owns the screen)
|
||||
camera.getWorldDirection(_fwd);
|
||||
minimap.draw(player.position, { x: _fwd.x, z: _fwd.z });
|
||||
}
|
||||
|
||||
@ -138,7 +138,7 @@ export class GigCrew {
|
||||
// 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
|
||||
// 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 h = height != null ? height : 1.6 + r() * 0.3;
|
||||
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 rig = pk && this.fleet.all[pk.index];
|
||||
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 (!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) => {
|
||||
const who = roster[i] || null;
|
||||
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 }
|
||||
: { x: w.x, y: 0, z: w.z, ry: w.ry });
|
||||
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, dance: !!w.dance });
|
||||
if (who) fromRoster++;
|
||||
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: [] });
|
||||
@ -284,10 +287,14 @@ export class GigCrew {
|
||||
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;
|
||||
}
|
||||
} else if (m.dance) { // dancer: bounce + sway + a little step
|
||||
} else if (m.dance) { // dancer
|
||||
if (this.fleet && this.fleet.danceClips && this.fleet.danceClips.length) {
|
||||
f.rotation.y = b.ry + Math.sin(t * 0.6 + ph) * 0.10; // real dance clip drives the body; just a slow facing drift
|
||||
} 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
|
||||
f.rotation.z = Math.sin(t * 0.9 + ph) * 0.022;
|
||||
f.rotation.y = b.ry + Math.sin(t * 0.5 + ph) * 0.03;
|
||||
|
||||
@ -14,11 +14,17 @@ import { clone as skeletonClone } from 'three/addons/utils/SkeletonUtils.js';
|
||||
import { loadGLB } from '../core/loaders.js';
|
||||
|
||||
// The 19 rigged peds (17 normal + 2 comical) copied byte-identical from 90sDJsim into web/models/peds/.
|
||||
// [R36 wave 2.5 — Fable ruling on F's §36.4 hold] `normal` is the CLASSIC-COVENANTED base 17 (the exact
|
||||
// pre-July-20 list, order preserved — pickRig maps r01 through pool.length, so the length IS the identity).
|
||||
// The 5 trellis'd DJ bodies (b339402, fbx_to_ped, 4k tris) live in `djs` and join the roster only when
|
||||
// loadPedFleet's `djs` opt is on — they are APPENDED, so the default-boot pool is byte-identical to the
|
||||
// pre-ruling 22-list and `?classic=1` is byte-identical to pre-July-20 (fetch surface AND crowd identity).
|
||||
export const PED_NAMES = {
|
||||
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_dj_streetwear_01', 'woman_casual_01', 'woman_casual_02', 'woman_business_01',
|
||||
'woman_athleisure_01', 'woman_smart_01', 'woman_chef_01'],
|
||||
djs: ['woman_dj_01', 'woman_dj_02', 'woman_dj_03', 'dj_techno_01', 'dj_phrtt_01'], // gated: dance-scene bodies, NOT in classic's pool
|
||||
comical: ['comical_luchador_01', 'comical_boy_01'],
|
||||
};
|
||||
|
||||
@ -55,20 +61,28 @@ function loadRig(ref) {
|
||||
// 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
|
||||
// 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 } = {}) {
|
||||
// `opts.djs` (R36 w2.5): admit the 5 trellis'd DJ bodies into the normal roster. DEFAULTS TO `dance` —
|
||||
// the shell already passes `dance: !CLASSIC`, so the classic-ness signal arrives without a shell edit and
|
||||
// the bodies ride the same `!CLASSIC` gate as the sit/look/dance clips: `?classic=1` fetches none of the
|
||||
// five and keeps the covenanted 17-pool; every other boot gets all 22. Separable for callers that want
|
||||
// dance clips without the bodies (or vice versa).
|
||||
export function loadPedFleet(base = 'models/peds/', { sit = false, look = false, dance = false, djs = dance } = {}) {
|
||||
const fleet = {
|
||||
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,
|
||||
};
|
||||
// DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array
|
||||
// order (and thus every pickRig index + identity signature) depends on GLB load-completion timing,
|
||||
// which varies per run/machine and silently breaks "same seed → same crowd". Compact after all
|
||||
// settle (filter(Boolean) preserves the fixed relative order, so a missing file is deterministic too).
|
||||
const nSlots = new Array(PED_NAMES.normal.length).fill(null);
|
||||
// R36 w2.5: the DJ bodies extend the roster AFTER the base 17, so gating them off shortens the pool
|
||||
// without renumbering a single base slot — classic's pickRig mapping is the pre-July-20 one exactly.
|
||||
const normalNames = djs ? [...PED_NAMES.normal, ...PED_NAMES.djs] : PED_NAMES.normal;
|
||||
const nSlots = new Array(normalNames.length).fill(null);
|
||||
const cSlots = new Array(PED_NAMES.comical.length).fill(null);
|
||||
const jobs = [];
|
||||
PED_NAMES.normal.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => {
|
||||
normalNames.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => {
|
||||
if (r) { r.pedName = n; nSlots[i] = canonRig(r); } })));
|
||||
PED_NAMES.comical.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => {
|
||||
if (r) { r.pedName = n; cSlots[i] = canonRig(r); } })));
|
||||
@ -89,10 +103,19 @@ export function loadPedFleet(base = 'models/peds/', { sit = false, look = false
|
||||
if (look) jobs.push(loadRig(`${base}look.glb`).then(r => {
|
||||
const c = r && r.anims && r.anims[0];
|
||||
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.normal = nSlots.filter(Boolean);
|
||||
fleet.comical = cSlots.filter(Boolean);
|
||||
fleet.all = [...fleet.normal, ...fleet.comical];
|
||||
fleet.danceClips = danceSlots.filter(Boolean);
|
||||
fleet.ready = fleet.all.length > 0 && !!fleet.walkClip;
|
||||
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.
|
||||
|
||||
import { mulberry32, xmur3 } from '../core/prng.js';
|
||||
import { BANDS, guideText } from './bible.js'; // [R32] THE BIBLE — band ranges + the card's guide stamp
|
||||
|
||||
const MAXFLIP = 1.95, BACK = -0.12;
|
||||
const SLEEVE = [0.31, 0.31, 0.0060]; // w, h, thickness (packed a touch chunkier than vinyl)
|
||||
// 90s-Australian crate genres → sleeve card colour.
|
||||
const GENRE = [['ROCK', '#7a2a3a'], ['SOUL', '#b0894a'], ['JAZZ', '#2a4a6a'], ['DISCO', '#8a3a7a'],
|
||||
['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).
|
||||
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',
|
||||
'Southerly Buster', 'Milk Bar Dreams', 'Cane Toad Blues', 'Ute Muster', 'Arvo'];
|
||||
const CONDS = ['VG+', 'VG', 'G+', 'NM', 'EX'];
|
||||
|
||||
// Deterministic sleeve list for a bin — the "existing procedural stock" as riffle-able offers.
|
||||
// [R32 — THE BIBLE + THE GEM] Every parody sleeve now rolls a seeded BAND (the guide's range) and an
|
||||
// ASKING price with seeded variance: non-gems price INSIDE their band; a 10% gem roll (collector/grail
|
||||
// only — an under-band bargain is not a thrill) prices UNDER the band's low edge, which is the entire
|
||||
// op-shop fantasy made mechanical. Draw COUNT is fixed per item (9 rolls, outcome-independent) so the
|
||||
// stream stays deterministic and re-orderable never. `rare` (the ★) now means grail, matching the real
|
||||
// packs — the card never says "gem"; spotting the asking-under-guide gap is the player's skill.
|
||||
const PARODY_BAND = (roll) => (roll < 0.25 ? 'bargain' : roll < 0.70 ? 'standard' : roll < 0.92 ? 'collector' : 'grail');
|
||||
const GRAIL_ASK_HI = 120; // parody grail asking ceiling (band renders "$60+")
|
||||
function proceduralSleeves(seed, n) {
|
||||
const r = mulberry32(seed >>> 0);
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const g = GENRE[(r() * GENRE.length) | 0];
|
||||
out.push({
|
||||
i, kind: 'record',
|
||||
a: ARTISTS[(r() * ARTISTS.length) | 0],
|
||||
t: TITLES[(r() * TITLES.length) | 0],
|
||||
y: 1971 + ((r() * 28) | 0),
|
||||
s: g[0], color: g[1],
|
||||
cond: CONDS[(r() * CONDS.length) | 0],
|
||||
price: 2 + ((r() * 28) | 0),
|
||||
rare: r() < 0.12,
|
||||
});
|
||||
const a = ARTISTS[(r() * ARTISTS.length) | 0];
|
||||
const t = TITLES[(r() * TITLES.length) | 0];
|
||||
const y = 1971 + ((r() * 28) | 0);
|
||||
const cond = CONDS[(r() * CONDS.length) | 0];
|
||||
const band = PARODY_BAND(r());
|
||||
const [lo, hi] = BANDS.record[band];
|
||||
const priceRoll = r(), gemRoll = r(), gemPriceRoll = r(); // always drawn — fixed stream shape
|
||||
const gem = gemRoll < 0.10 && (band === 'collector' || band === 'grail');
|
||||
// gem asking sits STRICTLY under the keeper's own bandLow offer (min(lo−1, floor(lo/2)) — sell.js),
|
||||
// so a spotted gem always nets margin: collector asks $2–9 vs offer $12 · grail $5–20 vs $30.
|
||||
const price = gem
|
||||
? (band === 'grail' ? 5 + ((gemPriceRoll * 16) | 0) : 2 + ((gemPriceRoll * 8) | 0))
|
||||
: lo + ((priceRoll * ((hi == null ? GRAIL_ASK_HI : hi) - lo + 1)) | 0);
|
||||
out.push({ i, kind: 'record', a, t, y, s: g[0], color: g[1], cond, price, band, rare: band === 'grail' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@ -103,6 +117,7 @@ export function createDig(THREE, renderer) {
|
||||
|
||||
let recs = [], cursor = 0, vel = 0, active = false, pulled = null, lastFloor = -1;
|
||||
let crate = null, offers = [], onCloseCb = null, onBuyCb = null, getCashCb = null;
|
||||
let onWantCb = null, isWantedCb = null; // [R33] the wantlist seams — absent (?game=0/classic) ⇒ no WANT DOM at all
|
||||
let emitters = null; // [R28] room.audio.emitters, per open — where the till sounds from. Optional.
|
||||
// shared across opens (persistent): sleeves are all the same box; sides + crate timber never vary
|
||||
const sleeveGeo = trackGeo(new THREE.BoxGeometry(SLEEVE[0], SLEEVE[1], SLEEVE[2]));
|
||||
@ -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:hover:not(:disabled){background:#241f18;color:#f4e11a;border-color:#241f18}
|
||||
.pcdg-panel button:disabled{color:#9a917c;border-color:#c3b99f;cursor:default}
|
||||
.pcdg-panel .want{margin-top:7px;text-align:center;font:700 11px "Courier New",monospace;letter-spacing:.08em;color:#8a7f66;cursor:pointer;user-select:none}
|
||||
.pcdg-panel .want:hover{color:#241f18}
|
||||
.pcdg-panel .want.on{color:#b3121f;cursor:default}
|
||||
.pcdg-panel .x{position:absolute;top:4px;left:8px;cursor:pointer;color:#a89e86;font-size:13px}
|
||||
.pcdg-out{position:fixed;left:16px;top:16px;background:#1d1d2b;border:1px solid #666;color:#d8d8e0;padding:8px 14px;font:13px "Courier New",monospace;cursor:pointer;display:none;z-index:60}
|
||||
.pcdg-out:hover{border-color:#3dff8b;color:#3dff8b}
|
||||
@ -250,14 +268,30 @@ export function createDig(THREE, renderer) {
|
||||
const line = [o.y, o.s].filter(Boolean).join(' '); // "1974 SOUL" | "SOUL" | "" — never " · "
|
||||
if (line) stamps.push(`<span class="tag">${esc(line)}</span>`);
|
||||
if (o.rare) stamps.push(`<span class="star">★ ${o.real ? 'GRAIL' : 'RARE'}</span>`);
|
||||
// [R32 THE BIBLE] the guide stamp — a RANGE off the 90s guide book, never a verdict. Present-fields-
|
||||
// only law holds: an unknown band leaves no stamp (guideText returns ''). The asking price sits on
|
||||
// the sticker; whether it's under the guide is for the PLAYER to notice — the card never says gem.
|
||||
const guide = guideText('record', o.band);
|
||||
if (guide) stamps.push(`<span class="tag">${esc(guide)}</span>`);
|
||||
// [R33] the WANT line — only when the game layer wired the seams (construction-gated DOM: a
|
||||
// ?game=0 / classic card carries no wantlist trace). Already-wanted reads as a stamp, not a button.
|
||||
const wanted = isWantedCb ? !!isWantedCb(o) : false;
|
||||
const wantLine = onWantCb
|
||||
? `<div class="want${wanted ? ' on' : ''}">${wanted ? '★ ON THE WANTLIST' : '☆ WANT IT — hunt one cheaper'}</div>`
|
||||
: '';
|
||||
panel.innerHTML = `<div class="x">✕</div>`
|
||||
+ `<div class="sticker">$${esc(o.price)}</div>`
|
||||
+ `<h3>${esc(o.a)}</h3>`
|
||||
+ (o.t ? `<div class="ttl">“${esc(o.t)}”</div>` : '')
|
||||
+ (stamps.length ? `<div class="stamps">${stamps.join('')}</div>` : '')
|
||||
+ `<button ${o.price > cash ? 'disabled' : ''}>${o.price > cash ? 'NOT ENOUGH CASH' : 'BUY IT'}</button>`;
|
||||
+ `<button ${o.price > cash ? 'disabled' : ''}>${o.price > cash ? 'NOT ENOUGH CASH' : 'BUY IT'}</button>`
|
||||
+ wantLine;
|
||||
panel.style.display = 'block';
|
||||
panel.querySelector('.x').onclick = unpull;
|
||||
const wantEl = panel.querySelector('.want');
|
||||
if (wantEl && !wanted) wantEl.onclick = () => {
|
||||
if (onWantCb(o) !== false) { wantEl.classList.add('on'); wantEl.textContent = '★ ON THE WANTLIST'; wantEl.onclick = null; }
|
||||
};
|
||||
const b = panel.querySelector('button');
|
||||
if (b && !b.disabled) b.onclick = () => {
|
||||
// STUB economy (round 6+): onBuy may veto; default is a toast + remove-from-bin.
|
||||
@ -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
|
||||
// collateral is what a gate must assert on. F: update item FIELDS in place (live price) freely; do NOT
|
||||
// add, remove, or reorder `pack.items` — that identity/order IS the seeded pick's stability.
|
||||
function pickRealOffers(pack, seed, n) {
|
||||
function pickRealOffers(pack, seed, n, pulledGone) {
|
||||
const rr = mulberry32(seed >>> 0), out = [];
|
||||
const gone = pack.gone instanceof Set ? pack.gone
|
||||
: (Array.isArray(pack.gone) ? new Set(pack.gone) : null);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const it = pack.items[(rr() * pack.items.length) | 0];
|
||||
if (gone && gone.has(it.id)) continue; // sold in the real shop → this slot is simply empty
|
||||
// [R32 scarcity] the PLAYER bought it (this day, this bin) → same pick-then-omit seam, same
|
||||
// zero-collateral guarantee: the pull leaves, the survivors keep their slots.
|
||||
if (pulledGone && pulledGone.has('sku:' + it.id)) continue;
|
||||
// Real fields straight off the atlas index (§7.2c): condition/sleeve_cond are OPTIONAL — present on
|
||||
// POS-sourced items, absent on mint ones that had no genuine grade. `y` (year) is never in the index,
|
||||
// so it stays empty and the card simply omits it. No invented values: an unstated grade stays unstated.
|
||||
@ -358,22 +395,30 @@ export function createDig(THREE, renderer) {
|
||||
out.push({ real: true, pack, item: it, a: it.artist, t: it.title, price: it.price,
|
||||
s: band === 'GRAIL' ? '' : band, y: '',
|
||||
cond: it.condition || '', sleeveCond: it.sleeve_cond || '',
|
||||
band: it.price_band || null, // [R32] canonical band for the guide stamp + the sell basis
|
||||
rare: it.price_band === 'grail' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// open({ seed, count, shopName, shop, stockAdapter, onClose, onBuy, getCash, emitters })
|
||||
// open({ seed, count, shopName, shop, stockAdapter, onClose, onBuy, getCash, emitters, gone })
|
||||
// `emitters` [R28, OPTIONAL] — pass `room.audio.emitters` and the till rings from the counter at the
|
||||
// right distance. Omit it and the till still rings, just un-positioned. Never required.
|
||||
// `gone` [R32, OPTIONAL] — a Set of THIS bin's already-pulled slots ('sku:<id>' for pack items, the
|
||||
// stringified offer index for parody), from the save's pull records. Omission happens AFTER the full
|
||||
// seeded draw (both paths), so the survivors keep their slots — the R27 zero-collateral law. Absent
|
||||
// (?game=0 / classic / fresh day) ⇒ the bin riffles exactly as it always did.
|
||||
function open(opts = {}) {
|
||||
active = true;
|
||||
onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null;
|
||||
onWantCb = opts.onWant || null; isWantedCb = opts.isWanted || null; // [R33] wantlist seams
|
||||
emitters = opts.emitters || null;
|
||||
const count = opts.count || 16;
|
||||
const gone = opts.gone instanceof Set && opts.gone.size ? opts.gone : null;
|
||||
// riffle items: real pack (?stock=real) via the stockAdapter seam, else seeded procedural sleeves.
|
||||
const src = opts.stockAdapter && opts.stockAdapter(opts.shop, 'sleeve');
|
||||
offers = (src && src.real) ? pickRealOffers(src.pack, opts.seed ?? 1, count) : proceduralSleeves(opts.seed ?? 1, count);
|
||||
offers = (src && src.real) ? pickRealOffers(src.pack, opts.seed ?? 1, count, gone)
|
||||
: proceduralSleeves(opts.seed ?? 1, count).filter((o) => !(gone && gone.has(String(o.i))));
|
||||
curLbl.textContent = (opts.shopName ? opts.shopName + ' — ' : '');
|
||||
[hint, curLbl, outBtn].forEach(e => e.style.display = 'block');
|
||||
buildStack(offers);
|
||||
@ -411,7 +456,16 @@ export function createDig(THREE, renderer) {
|
||||
[css, hint, curLbl, panel, outBtn, toast].forEach(e => e.remove());
|
||||
}
|
||||
|
||||
return { scene, camera, open, close, update, dispose, get active() { return active; } };
|
||||
// [R32 — the honest-gate surface, R24 precedent] What is in THIS open bin, BY IDENTITY: original
|
||||
// offer index, asking, band, sku (pack items). Read-only snapshots; null when closed. F's bible/
|
||||
// scarcity gate asserts on these instead of trusting "the crate looked right".
|
||||
function currentOffers() {
|
||||
if (!active) return null;
|
||||
return recs.map((r) => ({ i: r.offer.i ?? null, price: r.offer.price, band: r.offer.band || null,
|
||||
sku: r.offer.item ? String(r.offer.item.id) : null }));
|
||||
}
|
||||
|
||||
return { scene, camera, open, close, update, dispose, currentOffers, get active() { return active; } };
|
||||
}
|
||||
|
||||
// A stable per-bin seed from the shop + a bin index, so a given bin riffles the same order every time.
|
||||
|
||||
@ -13,15 +13,20 @@
|
||||
// (?classic=1 / ?game=0) → the consumer never constructs/opens this, and open() itself returns false
|
||||
// on an empty sellable list — zero errors, zero DOM.
|
||||
|
||||
import { sellBasis, guideText } from './bible.js'; // [R32] THE BIBLE — the §9.1 beta basis + the card's guide line
|
||||
|
||||
export const SELL_MULT = 0.5; // the keeper pays half of what he'll sticker it at (§9.1 — he eats too)
|
||||
export const SELL_DIST = 2.0; // metres from room.counter.pose (bench centre, room-local)
|
||||
|
||||
// The offer (§9.1). Alpha basis = pricePaid (the buy-side price the player actually faced — no guide
|
||||
// bands until beta; beta swaps the basis to bandLow, the clamp survives untouched). The min(p−1, …)
|
||||
// clamp is the law: no retune of SELL_MULT can ever push an offer to break even. p=1 → $0 (the SELL
|
||||
// button disables — the keeper won't buy what he can't sticker).
|
||||
export function sellOffer(pricePaid) {
|
||||
const p = Math.floor(+pricePaid || 0);
|
||||
// The offer (§9.1). [R32 — the pre-ratified beta swap, executed] Basis = bandLow when the entry carries
|
||||
// a band the BIBLE knows (sellBasis, bible.js), else the alpha basis (pricePaid) — so alpha-era finds
|
||||
// sell exactly as they always did. THE CLAMP SHAPE IS UNTOUCHED: the offer sits strictly below the
|
||||
// BASIS for every basis ≥ 1, which with bandLow as basis is the no-pump law's beta form — "every sell
|
||||
// price < the buy-side guide band, structurally" (charter law #2). A gem (bought UNDER band) nets its
|
||||
// finder margin against the band, never against the keeper; scarcity (the save's pulls) makes that
|
||||
// margin a finite find, not a loop. p=1 → $0 (the SELL button disables).
|
||||
export function sellOffer(basis) {
|
||||
const p = Math.floor(+basis || 0);
|
||||
if (p <= 0) return 0;
|
||||
return Math.min(p - 1, Math.max(1, Math.floor(p * SELL_MULT)));
|
||||
}
|
||||
@ -92,15 +97,16 @@ export function createSell() {
|
||||
function render() {
|
||||
const it = items[idx];
|
||||
if (!it) { close(); return; }
|
||||
const offer = sellOffer(it.pricePaid != null ? it.pricePaid : it.price);
|
||||
const offer = sellOffer(sellBasis(it)); // [R32] bandLow basis when banded, pricePaid else
|
||||
const head = labelOf(it) || titleOf(it);
|
||||
const ttl = labelOf(it) ? titleOf(it) : '';
|
||||
const paid = it.pricePaid != null ? it.pricePaid : it.price;
|
||||
const guide = guideText(it.type, it.band); // [R32] '' when unbanded → no stray separator (§7.2c)
|
||||
panel.innerHTML = `<div class="x">✕</div>`
|
||||
+ `<div class="sticker">$${esc(offer)}</div>`
|
||||
+ `<h3>${esc(head)}</h3>`
|
||||
+ (ttl ? `<div class="ttl">“${esc(ttl)}”</div>` : '')
|
||||
+ `<div class="meta">${paid != null ? 'you paid $' + esc(paid) : ''}${it.dayFound != null ? ' · day ' + esc(it.dayFound) : ''}</div>`
|
||||
+ `<div class="meta">${paid != null ? 'you paid $' + esc(paid) : ''}${guide ? ' · ' + esc(guide) : ''}${it.dayFound != null ? ' · day ' + esc(it.dayFound) : ''}</div>`
|
||||
+ (items.length > 1 ? `<div class="nav"><span class="pv">‹</span>${idx + 1} of ${items.length}<span class="nx">›</span></div>` : '')
|
||||
+ `<button ${offer <= 0 ? 'disabled' : ''}>${offer <= 0 ? 'NOT WORTH BUYING' : 'SELL — $' + esc(offer)}</button>`;
|
||||
panel.style.display = 'block';
|
||||
|
||||
@ -21,13 +21,13 @@ const CSS = `
|
||||
#pc-dbg b{color:#ffd75e;font-weight:600}
|
||||
#pc-dbg .warn{color:#ff9a6a}
|
||||
#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-townpick{position:absolute;left:10px;top:10px;pointer-events:auto;opacity:.8;transition:opacity .12s}
|
||||
#pc-townpick:hover{opacity:1}
|
||||
#pc-townpick select{background:rgba(20,16,10,.7);color:#f4efe6;border:1px solid rgba(255,215,94,.35);border-radius:8px;padding:5px 9px;font:12px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;max-width:230px;cursor:pointer}
|
||||
#pc-townpick select:focus{outline:1px solid rgba(255,215,94,.6)}
|
||||
#pc-game{position:absolute;right:10px;bottom:12px;display:flex;flex-direction:column;align-items:flex-end;gap:8px}
|
||||
#pc-game{position:fixed;right:10px;bottom:12px;z-index:30;display:flex;flex-direction:column;align-items:flex-end;gap:8px}
|
||||
#pc-gamebar{background:rgba(20,16,10,.72);padding:6px 10px;border-radius:10px;display:flex;gap:10px;align-items:center;pointer-events:auto;text-shadow:0 1px 2px #000}
|
||||
#pc-gamebar b{color:#ffd75e;font-weight:600}
|
||||
#pc-gamebar button{background:rgba(20,16,10,.7);color:#f4efe6;border:1px solid rgba(255,215,94,.35);border-radius:8px;padding:4px 10px;font:12px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;cursor:pointer}
|
||||
@ -67,6 +67,9 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
||||
const $ = (id) => root.querySelector('#' + id);
|
||||
const elTip = $('pc-tip'), elClock = $('pc-clock'), elFps = $('pc-fps'), elDraws = $('pc-draws'),
|
||||
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-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>
|
||||
<button id="pc-crate-btn" title="The collection (C toggles; Esc frees the pointer first)">CRATE 0</button>
|
||||
<button id="pc-sleep-btn" title="Sleep — save, wake tomorrow">☾ SLEEP</button>`;
|
||||
wrap.append(panel, bar); root.appendChild(wrap);
|
||||
// [R32 §31.6-2 — the crate at the counter] The game surface rides document.body, NOT the street-HUD
|
||||
// root: the street root hides in interior mode (by design), but your day/cash/crate are YOURS in a
|
||||
// shop too — the playtest's "why can't I check the crate at the counter". It yields the screen to
|
||||
// the dig/sell cards and the map (visibility below); ?classic=1 / ?game=0 never build any of this.
|
||||
wrap.append(panel, bar); document.body.appendChild(wrap);
|
||||
const elDay = bar.querySelector('#pc-day'), elCash = bar.querySelector('#pc-cash'),
|
||||
btnCrate = bar.querySelector('#pc-crate-btn'), btnSleep = bar.querySelector('#pc-sleep-btn');
|
||||
let shownDay = null, shownCash = null, shownCount = null, open = false;
|
||||
let shownDay = null, shownCash = null, shownCount = null, shownWants = null, open = false;
|
||||
|
||||
function rebuildList() {
|
||||
const col = game.collection || [];
|
||||
@ -180,7 +187,7 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
||||
panel.appendChild(h);
|
||||
if (!col.length) {
|
||||
const d = document.createElement('div'); d.className = 'pc-crate-empty';
|
||||
d.textContent = 'Empty — go dig.'; panel.appendChild(d); return;
|
||||
d.textContent = 'Empty — go dig.'; panel.appendChild(d);
|
||||
}
|
||||
for (let i = col.length - 1; i >= 0; i--) { // newest find first
|
||||
const e = col[i];
|
||||
@ -196,7 +203,57 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
||||
const meta = document.createElement('div'); meta.className = 'pc-crate-meta';
|
||||
meta.textContent = `paid $${e.pricePaid} · ${foundAt(e, game)} · day ${e.dayFound}`;
|
||||
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) {
|
||||
@ -218,18 +275,27 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
||||
shownCount = col.length; btnCrate.textContent = `CRATE ${shownCount}`;
|
||||
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();
|
||||
onGameKey = (e) => { // C toggles the crate on the street (not inside a shop — E/dig territory)
|
||||
onGameKey = (e) => { // [R32 §31.6-2] C toggles the crate on the street AND in a shop (not over a card)
|
||||
if (e.code !== 'KeyC') return;
|
||||
const P = window.PROCITY;
|
||||
if (P && P.mode === 'interior') return;
|
||||
const P = window.PROCITY, im = P && P.interiorMode;
|
||||
if (P && P.mode === 'map') return;
|
||||
if (im && (im.digActive || im.sellActive)) return; // the cards own the keys while open
|
||||
setOpen(!open);
|
||||
};
|
||||
addEventListener('keydown', onGameKey);
|
||||
const help = root.querySelector('#pc-help');
|
||||
if (help) help.textContent += ' · C crate';
|
||||
return { refresh };
|
||||
return { refresh, wrap }; // wrap rides document.body → dispose() must remove it itself
|
||||
}
|
||||
|
||||
const ray = new THREE.Raycaster();
|
||||
@ -270,14 +336,11 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
||||
frame++;
|
||||
if (frame % 6 === 0) raycastDoor();
|
||||
|
||||
// v7 game surface — lazy construction (the shell builds the game after the HUD; null under
|
||||
// ?classic=1 / ?game=0 ⇒ this never fires), then cheap getter polls offset from the raycast frames.
|
||||
if (!gameUI) {
|
||||
if (frame % 30 === 0) {
|
||||
const g = window.PROCITY && window.PROCITY.game;
|
||||
if (g) gameUI = buildGameUI(g);
|
||||
}
|
||||
} else if (frame % 6 === 3) gameUI.refresh();
|
||||
// v7 game surface — lazy construction + refresh both live in tickGame() (R33 fix: the build used
|
||||
// to happen ONLY here in street frames, so a door entered fast after boot had no crate/bar inside
|
||||
// at all — the interior branch could refresh a surface that never got built). Throttled here; the
|
||||
// interior/map branches call tickGame() per frame (cheap: change-guarded property reads).
|
||||
if (frame % 6 === 3) tickGame();
|
||||
|
||||
// tooltip — shows CLOSED (with opening hour) when the shop is shut at the current hour (§3.5)
|
||||
if (hovered) {
|
||||
@ -318,9 +381,23 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
||||
function dispose() {
|
||||
renderer.domElement.removeEventListener('click', onClick);
|
||||
if (onGameKey) removeEventListener('keydown', onGameKey);
|
||||
if (gameUI && gameUI.wrap) gameUI.wrap.remove(); // [R32] the game surface lives on document.body now
|
||||
elToast.remove(); // [R35] so does the toast
|
||||
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 };
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ import { buildInterior, getStockPack, makeStockAdapter } from '../interiors/inte
|
||||
import { KeeperManager } from '../citizens/keepers.js'; // Lane D — shopkeeper behind the counter
|
||||
import { GigCrew } from '../citizens/band.js'; // Lane D — band trio + audience (R12, ?gigs=1)
|
||||
import { createDig, binSeed } from '../interiors/dig.js'; // Lane C — crate-riffle (v2, gated on ?dig=1)
|
||||
import { mulberry32, xmur3 } from '../core/prng.js'; // [R33] the rumor roll (seeded, runtime-only)
|
||||
import { collapseBuyItem } from '../interiors/stockpack.js'; // Lane C — R9 buy-anywhere (book spines / toy boxes)
|
||||
import { createSell, nearCounter } from '../interiors/sell.js'; // Lane C — R30 sell counter (F owns this hook, §9.4)
|
||||
|
||||
@ -37,6 +38,11 @@ const EXIT_DIST = 1.0; // …then coming back within this distance leaves to th
|
||||
// lives in CITY_SPEC §v3. See LANE_F_NOTES §12.
|
||||
const WALLA_KEY = 'crowd-walla'; // [R13] manifest.ambience — crowd murmur layered under the gig bed while 'on'
|
||||
|
||||
// [R33 — THE RUMOR] where a keeper's mutter can point: the repo's real cached towns (display names).
|
||||
// Flavor + a travel nudge, not a promise — the 90s had no stock lookups, and the town-wide packs mean
|
||||
// every town CAN surface any hunted record eventually. Full persona lines are D's when the clip work lands.
|
||||
const RUMOR_TOWNS = ['Katoomba', 'Newtown', 'Fremantle', 'Bendigo', 'Castlemaine'];
|
||||
|
||||
// [Lane F R24 §6a] The per-shop stock base for a GODVERSE shop — A's `godverseShopId` → C's per-shop
|
||||
// pack location (LANE_C_PUB §7.3/§7.5). Exported and used by BOTH the shell's boot preload and the room's
|
||||
// lookup, deliberately: C's cache identity is the LITERAL (type, base) pair, so if the two sides built
|
||||
@ -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 currentStockDay = null; // [R30 §30.3] the day salt for THIS room's stock streams (null = no rotation:
|
||||
// no game, day 1, or a REAL-sourced crate — real stock never rotates)
|
||||
let currentRealSourced = false; // [R32] this room's crate is sourcing:'real' — its pulls never expire
|
||||
let doorReturn = null; // { x, y, z, ry } on the street to restore on exit
|
||||
let exitArmed = false; // disarmed at spawn (spawn sits by the door); arms once player steps inside
|
||||
let dig = null; // [F2] Lane C crate-riffle instance, created lazily on first use (only if ?dig=1)
|
||||
@ -166,26 +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
|
||||
// (currentStockDay is null for no-game / day-1 / REAL-sourced crates — those riffle exactly as pre-v7).
|
||||
const dayTag = currentStockDay != null ? '@d' + currentStockDay : '';
|
||||
// [R32 scarcity] the PULL key's day tag is broader than the SEED's: a rotating bin is pulled-from
|
||||
// per (bin, day) even on day 1 (where the seed stays untagged by the day-1 convention), while a
|
||||
// REAL-sourced crate stays untagged forever — sold means gone, so its pulls never expire. The seed
|
||||
// input above is UNTOUCHED (A's rotation-boundary law); this tag exists only in the save's ledger.
|
||||
const pullTag = (game && !currentRealSourced) ? '@d' + game.day : '';
|
||||
const pullPrefix = `${(currentShop && currentShop.id) ?? '?'}|${key}${pullTag}#`;
|
||||
dig.open({
|
||||
seed: binSeed((currentShop && currentShop.seed) || 1, key + dayTag), count: 16,
|
||||
shopName: (current && current.recipe && current.recipe.label) || (currentShop && currentShop.name),
|
||||
shop: currentShop,
|
||||
stockAdapter: currentAdapter, // [F2 §stock=real] real covers in the riffle (null → parody)
|
||||
gone: game ? game.pullsFor(pullPrefix) : undefined, // [R32] this bin's bought slots stay gone today
|
||||
// [F R8 — Lane C buy loop v0] bind the session wallet so pulling a sleeve deducts cash + banks it.
|
||||
// dig removes the pulled sleeve from the bin; onBuy returns false when broke (no state change).
|
||||
// [R30 — THE COLLECTION] a successful buy also records the find into game.collection (§30.1 entry
|
||||
// shape): a dig pull is a 'record' wherever it happens (an opshop bin is still a record crate);
|
||||
// sku = the pack item id when real/mint, else slotId = the bin's day-salted key + offer index.
|
||||
// [R32] the find carries its BAND (the sell counter's §9.1 basis) and the pull enters the save's
|
||||
// ledger — the slot is gone for the rest of the day even if the item is later sold on.
|
||||
getCash: wallet ? () => wallet.cash() : undefined,
|
||||
// [R33] the wantlist seams — game-only (undefined ⇒ dig builds zero WANT DOM, classic-pure)
|
||||
onWant: game ? (o) => game.recordWant({ artist: o && (o.a || o.artist), title: o && (o.t || o.title),
|
||||
sku: o && o.item && o.item.id != null ? String(o.item.id) : undefined }) : undefined,
|
||||
isWanted: game ? (o) => !!game.findWant({ artist: o && (o.a || o.artist), title: o && (o.t || o.title),
|
||||
sku: o && o.item && o.item.id != null ? String(o.item.id) : '' }) : undefined,
|
||||
onBuy: wallet ? (o) => {
|
||||
const ok = wallet.buy(o);
|
||||
if (ok && game) game.recordFind(currentShop, {
|
||||
if (ok && game) {
|
||||
const sku = o && o.item && o.item.id != null ? String(o.item.id) : null;
|
||||
game.recordFind(currentShop, {
|
||||
type: 'record',
|
||||
sku: o && o.item && o.item.id != null ? String(o.item.id) : undefined,
|
||||
slotId: o && o.item && o.item.id != null ? undefined : `${key}${dayTag}#${(o && o.i) ?? 0}`,
|
||||
sku: sku ?? undefined,
|
||||
slotId: sku != null ? undefined : `${key}${dayTag}#${(o && o.i) ?? 0}`,
|
||||
title: o && (o.t || o.title), artist: o && (o.a || o.artist),
|
||||
price: (o && o.price) || 0,
|
||||
band: (o && o.band) || undefined,
|
||||
});
|
||||
game.recordPull(pullPrefix + (sku != null ? 'sku:' + sku : String((o && o.i) ?? 0)));
|
||||
// [R33] the hunt ends when you're holding it: buying a wanted item clears its want
|
||||
const w = game.findWant({ artist: o && (o.a || o.artist), title: o && (o.t || o.title), sku: sku || '' });
|
||||
if (w) { game.removeWant(w); flashToast('✓ off the wantlist'); }
|
||||
}
|
||||
return ok;
|
||||
} : undefined,
|
||||
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)}`,
|
||||
title: item.title, artist: item.artist,
|
||||
price: item.price || 20,
|
||||
band: item.price_band || undefined, // [R32] the shelf find carries its band too (§9.1 basis)
|
||||
});
|
||||
collapseBuyItem(mesh, best); // zero-area the bought quad in place
|
||||
flashToast(`SOLD — ${item.title || 'item'} · $${item.price || 20} · $${wallet.cash()} left`);
|
||||
@ -346,6 +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
|
||||
// says sourcing:'real' — real stock never rotates, it's real). Mint crates and parody stock rotate.
|
||||
const realSourced = !!(shop.godverseShopId && sourcingOf && sourcingOf(shop.godverseShopId) === 'real');
|
||||
currentRealSourced = realSourced; // [R32] pull-ledger tagging reads this at dig-open time
|
||||
currentStockDay = (game && game.day > 1 && !realSourced) ? game.day : null;
|
||||
current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter,
|
||||
stockDay: currentStockDay,
|
||||
@ -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
|
||||
// (type = type, C's fail-closed matcher) — a hint over nothing sellable would advertise a dead E.
|
||||
const canSell = !!(game && game.collection && game.collection.some((it) => it && it.type === shop.type));
|
||||
// [R33 — THE RUMOR, charter #6, the keeper finally gossips] Seeded per (citySeed, shop, day): a
|
||||
// ~45% mutter naming a hunted want + a real town — the reason to ride somewhere tomorrow. Runtime
|
||||
// stream, freshly keyed: no plan/golden moves (A's boundary). No game / empty wantlist ⇒ silence,
|
||||
// and the banner line simply doesn't exist (classic-pure by construction).
|
||||
let rumor = '';
|
||||
if (game && game.wants && game.wants.length) {
|
||||
const rr = mulberry32(xmur3(`rumor:${plan.citySeed}:${shop.id}:${game.day}`)() >>> 0);
|
||||
if (rr() < 0.45) {
|
||||
const w = game.wants[(rr() * game.wants.length) | 0];
|
||||
const towns = RUMOR_TOWNS.filter((t) => !(plan.name || '').toLowerCase().includes(t.toLowerCase()));
|
||||
const town = towns[(rr() * towns.length) | 0] || RUMOR_TOWNS[0];
|
||||
rumor = ` · 🗣 “${String(w.artist || w.title || w.sku).toUpperCase()}? saw one in ${town} just last week”`;
|
||||
}
|
||||
}
|
||||
banner.textContent = `🚪 ${name || shop.name || 'shop'} — walk out the door or press Esc to leave`
|
||||
+ (gigOn ? ` · 🎸 ${String(tonight.bandName).toUpperCase()} — LIVE` : '')
|
||||
+ (hasBins ? ' · E: riffle a record bin' : '')
|
||||
+ (canSell ? ' · E at the counter: sell' : '');
|
||||
+ (canSell ? ' · E at the counter: sell' : '')
|
||||
+ rumor;
|
||||
banner.style.display = 'block';
|
||||
return current;
|
||||
}
|
||||
@ -471,6 +517,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
currentShop = null;
|
||||
currentAdapter = null;
|
||||
currentStockDay = null;
|
||||
currentRealSourced = false;
|
||||
banner.style.display = 'none';
|
||||
toast.style.display = 'none'; _toastT = 0;
|
||||
if (doorReturn) {
|
||||
@ -491,6 +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 digActive() { return !!(dig && dig.active); }, // [F2] shell reads this to keep pointer-lock/Esc sane while riffling
|
||||
get sellActive() { return !!(sellCard && sellCard.active); }, // [R30 §9.4] same contract for the sell card
|
||||
// [R32 — honest-gate surface] the open bin's offers by identity (index/ask/band/sku); null when no
|
||||
// dig is open. The bible/scarcity gate asserts on THESE, not on how the crate looked.
|
||||
get digOffers() { return dig && dig.active ? dig.currentOffers() : null; },
|
||||
|
||||
// [Lane F R24 — the honest-gate surface, per the vacuous-gate law] What stock is in THIS room, BY
|
||||
// IDENTITY. R23's #7a could go green on "real covers render" while the room held the generic v2 pack
|
||||
|
||||
@ -38,17 +38,29 @@ function gradientEquirect(THREE) {
|
||||
return tex;
|
||||
}
|
||||
|
||||
export function createLighting(scene, plan, skins, { radius = 3, shadows = true } = {}) {
|
||||
export function createLighting(scene, plan, skins, { radius = 3, shadows = true, dawnSky = null } = {}) {
|
||||
const group = new THREE.Group();
|
||||
group.name = 'lighting';
|
||||
scene.add(group);
|
||||
|
||||
// sky dome — seeded photo skin, darkened at night via material colour
|
||||
// [R32 §31.6-3] `dawnSky` (option, shell passes null under ?classic=1): the default dome is a
|
||||
// golden-SUNSET photo, so DAWN read as dusk-orange (the R31 playtest's cosmetic #3). During the DAWN
|
||||
// segment only, the dome swaps to a cool morning skin, then back. A weather setSky() override
|
||||
// (rain/overcast) OWNS the dome outright — the dawn swap never fights it.
|
||||
const skyName = plan.sky || 'golden-arvo';
|
||||
const skyMat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.BackSide, depthWrite: false, fog: false });
|
||||
let externalSky = null; // set by setSky() (weather) — wins over base + dawn forever after
|
||||
let shownSky = skyName;
|
||||
skins.skyTex(skyName, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
|
||||
const dome = new THREE.Mesh(new THREE.SphereGeometry(650, 24, 14), skyMat);
|
||||
group.add(dome);
|
||||
function applySkyFor(segIdx) {
|
||||
const want = externalSky || (segIdx === 0 && dawnSky ? dawnSky : skyName);
|
||||
if (want === shownSky) return;
|
||||
shownSky = want;
|
||||
skins.skyTex(want, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
|
||||
}
|
||||
|
||||
const hemi = new THREE.HemisphereLight(0xd8e8ff, 0xa89878, 1.0);
|
||||
group.add(hemi);
|
||||
@ -76,6 +88,7 @@ export function createLighting(scene, plan, skins, { radius = 3, shadows = true
|
||||
|
||||
function apply(i) {
|
||||
const [, sunI, sunC, hSky, hGnd, hI, skyTint, fogC, exposure, isNight] = SEG[i];
|
||||
applySkyFor(i); // [R32] dawn dome swap (no-op under classic / weather override / other segments)
|
||||
sun.intensity = sunI; sun.color.set(sunC);
|
||||
hemi.intensity = hI; hemi.color.set(hSky); hemi.groundColor.set(hGnd);
|
||||
_skyTint.set(skyTint); skyMat.color.copy(_skyTint);
|
||||
@ -95,7 +108,11 @@ export function createLighting(scene, plan, skins, { radius = 3, shadows = true
|
||||
|
||||
// Swap the sky-dome skin at runtime (weather.js uses this for a matching rainy/overcast sky).
|
||||
// The per-segment day/night colour tint (skyMat.color in apply) still rides on top of the new map.
|
||||
function setSky(name) { skins.skyTex(name, (t) => { skyMat.map = t; skyMat.needsUpdate = true; }); }
|
||||
// [R32] a caller-set sky is an OVERRIDE: it wins over the base skin and the dawn swap from then on.
|
||||
function setSky(name) {
|
||||
externalSky = name; shownSky = name;
|
||||
skins.skyTex(name, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
|
||||
}
|
||||
|
||||
const _sunOff = new THREE.Vector3();
|
||||
function update(dt, playerPos) {
|
||||
|
||||
@ -18,6 +18,33 @@
|
||||
export const SAVE_SCHEMA = 'procity-save/1';
|
||||
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) ─────────────────────────────
|
||||
// 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
|
||||
@ -42,6 +69,34 @@ export function validateSave(obj) {
|
||||
if (!Array.isArray(obj.collection)) return { ok: false, why: 'collection not an array' };
|
||||
for (let i = 0; i < obj.collection.length; i++)
|
||||
if (!validEntry(obj.collection[i])) return { ok: false, why: `collection[${i}] malformed` };
|
||||
// [R32 scarcity] `pulls` is OPTIONAL (absent on every alpha save — those stay valid forever); when
|
||||
// present it must be an array of strings (exact-and-loud like everything else). An old loader that
|
||||
// predates this field simply ignores and drops it on its next save — degraded, never rejected.
|
||||
if (obj.pulls != null) {
|
||||
if (!Array.isArray(obj.pulls)) return { ok: false, why: 'pulls not an array' };
|
||||
for (let i = 0; i < obj.pulls.length; i++)
|
||||
if (typeof obj.pulls[i] !== 'string' || !obj.pulls[i]) return { ok: false, why: `pulls[${i}] malformed` };
|
||||
}
|
||||
// [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 };
|
||||
}
|
||||
|
||||
@ -58,12 +113,53 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
|
||||
let day = 1;
|
||||
let cash = Math.max(0, Math.floor(+startCash || 0));
|
||||
let collection = [];
|
||||
// [R33] the wantlist — what you're hunting ({artist?|title?|sku?}); capped, deduped, save-carried.
|
||||
let wants = [];
|
||||
const WANTS_CAP = 40;
|
||||
// [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) ────────────
|
||||
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() {
|
||||
if (!store) return false;
|
||||
const payload = { schema: SAVE_SCHEMA, day, cash, town: townKey, collection, savedAt: Date.now() };
|
||||
try { store.setItem(SAVE_KEY, JSON.stringify(payload)); return true; }
|
||||
try { store.setItem(SAVE_KEY, JSON.stringify(payload())); return true; }
|
||||
catch (e) { console.warn('[procity save] write failed (playing on in memory):', e && e.message || e); return false; }
|
||||
}
|
||||
|
||||
@ -71,6 +167,24 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
|
||||
day = state.day;
|
||||
cash = Math.floor(state.cash);
|
||||
collection = state.collection.slice();
|
||||
pulls = Array.isArray(state.pulls) ? state.pulls.slice() : [];
|
||||
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() {
|
||||
@ -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.
|
||||
sleep() {
|
||||
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); } }
|
||||
save();
|
||||
return day;
|
||||
},
|
||||
|
||||
export() {
|
||||
return JSON.stringify({ schema: SAVE_SCHEMA, day, cash, town: townKey, collection, savedAt: Date.now() });
|
||||
return JSON.stringify(payload());
|
||||
},
|
||||
|
||||
import(json) { // loud reject leaves CURRENT state untouched
|
||||
@ -175,6 +291,7 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
|
||||
else e.slotId = String((info && info.slotId) || 'slot#0');
|
||||
if (info && info.title) e.title = String(info.title);
|
||||
if (info && info.artist) e.artist = String(info.artist);
|
||||
if (info && info.band) e.band = String(info.band); // [R32] §9.1 sell basis — optional, alpha entries lack it
|
||||
collection.push(e);
|
||||
notify();
|
||||
return e;
|
||||
@ -188,6 +305,79 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
|
||||
notify();
|
||||
return true;
|
||||
},
|
||||
|
||||
// ── the pull seam (R32 scarcity — interior_mode records a bought dig slot; SELLING an item does
|
||||
// NOT restock the bin, which is why this list is independent of the collection) ──────────────
|
||||
recordPull(key) {
|
||||
const k = String(key || '');
|
||||
if (!k || pulls.includes(k)) return false;
|
||||
pulls.push(k);
|
||||
if (pulls.length > PULLS_CAP) pulls.splice(0, pulls.length - PULLS_CAP);
|
||||
return true;
|
||||
},
|
||||
|
||||
// The consumer's read: every recorded slot for one bin, as the Set dig.open({gone}) takes —
|
||||
// suffixes only ('sku:<id>' | '<offer index>'), prefix = `${shopId}|${binKey}${tag}#`.
|
||||
pullsFor(prefix) {
|
||||
const out = new Set();
|
||||
for (const k of pulls) if (k.startsWith(prefix)) out.add(k.slice(prefix.length));
|
||||
return out;
|
||||
},
|
||||
|
||||
// ── 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.
|
||||
|
||||