Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ef751e1e3 | ||
|
|
25cef5983e | ||
|
|
55b984e0b0 | ||
|
|
c9e233a7e5 | ||
|
|
e528c92fed | ||
|
|
b048f51640 | ||
|
|
776b10b599 | ||
|
|
7b26e45fa0 | ||
|
|
37773bc81d | ||
|
|
ce71908bb5 | ||
|
|
9aae167d64 | ||
|
|
bb30767b62 | ||
|
|
75e5e06288 | ||
|
|
f9517f14ab | ||
|
|
046668e5af | ||
|
|
b3394027be | ||
|
|
3c5d0a1e95 | ||
|
|
67913f8b66 | ||
|
|
bb5f36f1e0 | ||
|
|
2491f1c8ec | ||
|
|
50385c0f08 | ||
|
|
d96cbde004 | ||
|
|
7a7da29132 | ||
|
|
2c9cb94b8e | ||
|
|
5e115688ed | ||
|
|
cb03f07fb2 | ||
|
|
f419c1a5e2 | ||
|
|
7b7227af73 | ||
|
|
207bcffb54 | ||
|
|
369a2be23e | ||
|
|
53fcd3ca1b | ||
|
|
c8503a5a2e | ||
|
|
83ef8c9234 | ||
|
|
e2345b544d | ||
|
|
bebaa66196 | ||
|
|
fe00c11d2c | ||
|
|
a80ad55f47 | ||
|
|
10025d4082 | ||
|
|
09dff6b788 |
@ -4,6 +4,25 @@
|
||||
plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's
|
||||
`generatePlan` (which landed mid-session). Everything below was verified live in a browser.
|
||||
|
||||
## Round 30 (v7.0-alpha ledger #4) — THE COLLECTION UI + SLEEP surface · qa GREEN 6/6
|
||||
|
||||
The crate panel + game bar in `hud.js` (my DOM-overlay patterns), on F's §30.1 `PROCITY.game`
|
||||
contract — and verified against F's REAL committed core (`207bcff` landed mid-round; my test
|
||||
snapshot diffed byte-identical to it — **nothing stubbed**, unlike C's wave-1 run).
|
||||
- **Game bar** (bottom-right): `day N · $cash` · `CRATE n` (count in the standing HUD; opens the
|
||||
panel, `C` toggles) · `☾ SLEEP` → `game.sleep()`, enabled any time (alpha).
|
||||
- **Crate panel**: cover thumb (pack-atlas CSS crop off the entry's sku — per-shop godverse base or
|
||||
town-wide; parody = type glyph by design), title/artist, `paid $p · where · day d`, newest first,
|
||||
header totals. 50-row rebuild 0.9 ms; 0 draws (DOM-only).
|
||||
- **Construction-gated on the game object itself** (lazy, R19-selector style): `?classic=1`/`?game=0`
|
||||
⇒ zero game DOM, zero listeners, help line byte-identical, 0 console errors (measured, clean tabs).
|
||||
`?noassets` ⇒ 0 stock fetches, placeholders stand.
|
||||
- **SLEEP proven end-to-end**: day 1→2, weekNight 0→1, wake DAWN seg 0, `procity-save/1` on disk,
|
||||
next boot loads it and the UI reads it. **venue.js §30.4 filing fixed as filed** — compat
|
||||
`venueShopId` is now a live weekNight-keyed getter (116 → 490 across a sleep, measured).
|
||||
- Full measurement table + honest notes: LANE_B_NOTES §30. Verified on a port-isolated no-store
|
||||
:8151 snapshot server (not :8130/:8144 — Lane F was mid-flight in the shared tree).
|
||||
|
||||
## Round 9 (Fable's ROUND9 → Lane B) — v2 tour bookmarks + tram (`?tram=1`) · qa GREEN
|
||||
|
||||
Ship-v2.0 round. Two deliverables, both v2-law clean, `qa.sh --strict` GREEN, flags-off byte-identical.
|
||||
|
||||
@ -3,11 +3,71 @@
|
||||
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
|
||||
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
|
||||
|
||||
Last updated: 2026-07-17 (round 28) · owner: PROCITY-C · reviewer: Fable
|
||||
Last updated: 2026-07-18 (round 30) · owner: PROCITY-C · reviewer: Fable
|
||||
|
||||
---
|
||||
|
||||
## Update 2026-07-17 (round 28, THE SPIKE AND THE SWEEP) — `audioEmitter`: the till rings, and the panner lies
|
||||
## Update 2026-07-18 (round 30, v7.0-alpha THE SAVE AND THE CRATE) — the sell counter: cash comes back, always less
|
||||
|
||||
R30 ledger #2 — contract first (LANE_C_PUB §9, its own commit), implementation second. F's save core
|
||||
(ledger #1) had **not landed** when this was built: no `save.js`, no `window.PROCITY.game` anywhere in
|
||||
the tree — so everything below is built to the R30-published shape and verified against a **stub** of
|
||||
exactly that shape (noted loudly below).
|
||||
|
||||
### The contract (§9) — the one number and why
|
||||
**`sellOffer(p) = min(p − 1, max(1, floor(p × 0.5)))`, never < 0.**
|
||||
- **0.5** is the secondhand-dealer margin — the keeper pays half of what he'll sticker it at.
|
||||
- **`min(p − 1, …)` is the no-pump law made STRUCTURAL:** the offer is strictly below `pricePaid` for
|
||||
every `p ≥ 1`, so buy-then-immediately-sell loses ≥ $1 per round trip **by construction** — no
|
||||
balance retune can break F's monotonic-loss gate without violating the contract. At `p = 1` the
|
||||
offer is $0 and SELL disables (the keeper won't buy what he can't sticker).
|
||||
- **Alpha basis = `pricePaid`** (every alpha buy is at asking price, so it IS the buy-side price the
|
||||
player faced; there are no guide bands yet). **Beta swaps the basis to `bandLow`, not the shape.**
|
||||
- Matching is **type = type, strict, fail-closed**: an untyped item sells NOWHERE (a matcher passing
|
||||
untyped items would "match" everywhere — vacuous-gate law applied to a matcher).
|
||||
|
||||
### What shipped (C's files only)
|
||||
- **`web/js/interiors/sell.js`** — `createSell()` + pure `sellOffer` / `sellableIn` / `nearCounter`.
|
||||
DOM-only (zero draws, zero GPU), the dig's manila price-sticker mirrored to the left with a green
|
||||
offer sticker; one item at a time, `‹ ›` cycling, ✕/Esc; till rings from the counter emitter (R28
|
||||
one-shot pattern). Same consumer pattern as `dig.js`: the single side-effect is `onSell(item,
|
||||
offer)` — **C never mutates `game.collection`** (read-only law, §9.3).
|
||||
- **`web/js/interiors/wallet.js`** — `wallet.sell(item, offer)`: the credit mirror of `buy()`.
|
||||
This file never prices; the offer comes in from `sellOffer`.
|
||||
- **`web/interior_test.html`** — E-at-counter routing (bin → dig · counter → sell · shelf → buy) gated
|
||||
on `window.PROCITY.game` presence (the in-game classic-pure law, exactly); `?sellstub=1` stubs the
|
||||
published game shape for verification until F's core lands.
|
||||
|
||||
### Verified (fresh context, no-store server on :8144 — NOT :8130, zero console errors throughout)
|
||||
- **The sale:** record shop seed 1990, at the bench (`nearCounter` true at 0.9 m, false across the
|
||||
room) → card `THE FIBROS — "Servo at Midnight" · you paid $24 · offer $12` → SELL → **cash 163 →
|
||||
175 (+$12 < $24 paid)**, collection 4 → 3, sold item gone, toast + till fired.
|
||||
- **Type fence:** stub carried 2 records + 1 book + 1 untyped item. Record shop cards **exactly the 2
|
||||
records**; book shop cards **exactly the book** ($7 on $15); the untyped item cards **nowhere**.
|
||||
- **The disabled edge:** the $1 record offers $0 → button reads NOT WORTH BUYING, disabled.
|
||||
- **No-pump round-trip (C's mini-gate, precursor to F's #5):** buy-then-sell through the real wallet +
|
||||
card path, prices {2,3,5,9,24,60,61,137} → **8/8 strictly negative** (−1,−2,−3,−5,−12,−30,−31,−69).
|
||||
- **The gate discriminated first** (vacuous-gate law, proven live): my first loop pushed a 999-priced
|
||||
item into the collection **without checking `wallet.buy` succeeded** — broke wallet, no debit, then
|
||||
a +$499 "sale". A pump — from an item never paid for. **Finding for F below.**
|
||||
- **Fail-soft:** boot with no stub (= no game layer) → E at the counter does nothing, `open()` false,
|
||||
zero DOM, zero errors. `dispose()` leaves 0 panels/0 toasts in the DOM.
|
||||
- **Budgets:** draws with the card open ≡ closed (16 ≡ 16 — DOM is zero-draw); `buildInterior` and all
|
||||
room code untouched this round; 50-room soak re-run green in the same session: **avg 3.4 ms · worst
|
||||
8.2 ms · leak geo 0 / tex 0 · determinism identical · worst draws 73 ≤ 350**.
|
||||
|
||||
### Findings / asks (→ F, for the save core + gates)
|
||||
1. **The collection item needs `type`** (registry stock type, stamped at buy time). The R30 shape
|
||||
(`{townKey, godverseShopId|shopId, sku|slotId, pricePaid, dayFound}`) doesn't carry it, and without
|
||||
it **nothing is sellable anywhere** (my matcher is fail-closed on purpose). `title`/`artist` are
|
||||
strongly asked too (B's collection UI needs them; my card falls back to the sku label — honest, ugly).
|
||||
2. **`pricePaid` must only ever be written by a SUCCESSFUL debit.** The no-pump law's structure
|
||||
assumes pricePaid was actually paid — my broken first loop showed a collection entry that skipped
|
||||
the wallet debit converts straight into a money pump. F's no-pump gate should include this negative
|
||||
control: a collection entry minted without a debit must be impossible (or the gate must fail).
|
||||
3. **The removal handoff:** in-game, the sold item must leave the collection **via the game API** —
|
||||
`onSell` is where F calls it. My harness splices its own stub (its array, its right); C's modules
|
||||
never touch game state. When F publishes the API name, §9.3 references it verbatim.
|
||||
|
||||
R28 §Lane C (ledger #3) — my own idea, parked since R7, brief states acceptance only. Wave 1, alongside E+B.
|
||||
|
||||
|
||||
153
F-progress.md
@ -4,6 +4,159 @@
|
||||
|
||||
---
|
||||
|
||||
## 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.**
|
||||
`index.html` still carried the v2-era opt-in (`params.has('dig')`) six epochs after the README
|
||||
claimed "dig on" — and every R30 game-loop gate booted `dig=1` explicitly, so the suite was green
|
||||
while E at a bin did nothing on the boot every player gets. Fixed with the one-line flip
|
||||
(`DIG_ON = flagOn('dig')` — ?dig=0 opt-out, ?classic=1 off, `dig` on the flags surface) and fenced
|
||||
with two new STRICT legs: **default_boot_gate asserts dig REACHABILITY on a NAKED boot** (E at an
|
||||
aimed bin → digActive true → closed clean) and **classic_regression asserts dig INERT at the bin**
|
||||
(zero `pcdg-*` DOM, zero Storage calls — measured, not assumed). *Harness lesson #4: the
|
||||
explicit-flag blindfold — a gate that force-feeds its subject's flag can never notice the default
|
||||
is broken; every claimed-default-on flag needs at least one flag-ABSENT gate.* Splash de-staled
|
||||
("Every door opens." — the "(soon)" is over; controls line = E riffle/sell · C crate · SLEEP to
|
||||
save) + the README manual row. `PROCITY_QA_PORT` override so the suite never silently reuses
|
||||
whatever holds :8130 (this round: Fable's own preview server).
|
||||
|
||||
**The re-gate (final tree, :8931): `qa.sh --strict --matrix` 7 passed · 0 failed · 0 warn ·
|
||||
0 skipped; flags_check 0/0 incl. both new legs; MATRIX 10 towns green.** Money shot:
|
||||
`docs/shots/v7_alpha/crate_panel.png` — Monster Robot Party's frontage, THE CRATE with 3 real
|
||||
finds (real cover thumbs, honest paid/where/day), game bar day 1 · $116 · CRATE 3 · SLEEP, driven
|
||||
through the real loop with the dig param absent. **Tag `v7.0-alpha` — annotated, LOCAL ONLY**
|
||||
(Fable pushes). THE BETA QUEUE (Fable's design wants, filed NOT fixed) is in LANE_F_NOTES §31.6.
|
||||
Commits: `50385c0` (flip + gates + splash) · shot + notes commit (tag target). Full record: §31.
|
||||
|
||||
---
|
||||
|
||||
## Round 30 (v7.0-alpha — THE SAVE AND THE CRATE) — F: the contract first, then the save core + tomorrow
|
||||
|
||||
**The contract shipped before a line of implementation** (`53fcd3c`): `window.PROCITY.game =
|
||||
{ day, cash, collection[], townKey, save(), load(), sleep(), export(), import(json), wallet,
|
||||
recordFind(), removeFind() }` + the `procity-save/1` schema + the rotation law, published in
|
||||
LANE_F_NOTES §30 — and it worked exactly as the order intended: **B picked up §30.4 within the
|
||||
session** (venue.js "tonight" already reads `gigs.weekNight`; hud.js collection/SLEEP surface in
|
||||
flight), and C's already-published §9 asks (`type` stamped at buy, `title`/`artist`, removal via the
|
||||
game API, `wallet.sell` mirrored) are honoured in the entry shape.
|
||||
|
||||
**Then the layer** (`207bcff`): `web/js/world/save.js` (F-owned, new) — versioned localStorage save,
|
||||
THE DELTA LAW BY SHAPE (payload keys are exactly `[cash, collection, day, savedAt, schema, town]` —
|
||||
no world field can even be expressed); corrupted/foreign → **loud reject, raw stashed, fresh start,
|
||||
town boots**; save on sleep + beforeunload; export/import for moving machines. The wallet is a
|
||||
game-backed facade of C's exact v0 interface, so every proven seam (dig onBuy, shelf buy, cover,
|
||||
C's onSell) is unchanged — cash just became authoritative from the save. Buy seams record finds;
|
||||
`?classic=1`/`?game=0` never construct the layer — **ZERO Storage calls, instrumented, not trusted**.
|
||||
|
||||
**SLEEP=TOMORROW:** `sleep()` → day+1 → gig `weekNight` re-keys the EXISTING week schedule → wake at
|
||||
DAWN → save. Stock rotation is a runtime day salt on the EXISTING `stk-*` streams (2 marked seam
|
||||
lines in C's interiors/layout) + the dig's `binSeed` — **plan generation never sees `day`** (A's #6
|
||||
boundary). **Day 1 IS the pre-v7 town** (measured: `?game=0` stock fp == fresh-game day-1 fp), and
|
||||
**REAL-sourced crates never rotate** (Monster Robot fp identical across sleeps; the mint crate rotates).
|
||||
|
||||
**Two amendments filed loudly, with measurement, not slipped in:** gig night = **(day − 1) % 7**, not
|
||||
the brief's literal `day % 7` (day starts at 1 — the literal form flips a fresh boot's gigs off night 0,
|
||||
breaking the R13 cover/band gates before the player ever sleeps); and sleep saves AFTER incrementing
|
||||
(disk always carries the morning you woke into). Fable ratifies or overrules at review.
|
||||
|
||||
**Numbers:** 7 verify legs green in fresh no-store contexts — $12 dig buy == pricePaid recorded · sell
|
||||
halves credit/remove correctly · parody fp `3396372961 → 1818111125` across a sleep, byte-identical
|
||||
same-day AND across reboots · corrupt blob = 1 loud reject + stash + clean boot · `qa.sh --strict`
|
||||
**6 passed · 0 failed · 0 warn** with the game layer default-ON. Two F-harness bugs caught and fixed
|
||||
in the run (the post-sleep CLOSED door; a transform-based fingerprint that was vacuous over batched
|
||||
parody stock — hash the VERTEX data). Full record: `LANE_F_NOTES` §30.
|
||||
|
||||
**Held for the later F session (per the brief):** ledger #5 gates + the sell-card E-key routing.
|
||||
|
||||
### Wave 3 — THE GATES SESSION: the routing landed, five STRICT gates, the loop closes end-to-end
|
||||
|
||||
**The held join is in.** E inside a shop now routes: **aimed bin → dig · near counter with sellables →
|
||||
C's sell card · forgiving nearest-bin → dig · shelf-buy** (`interior_mode.js`; 3 marked shell seams in
|
||||
`index.html` — `procity:sellOpen` unlock + `sellActive` on the Esc/relock guards, the dig's exact
|
||||
contract). `onSell` does F's two §9.3 halves in the safe order: `removeFind` first (veto ⇒ nothing
|
||||
moves), credit second. **The loop, measured through the real DOM:** dig pull **$9 debited** → walk to
|
||||
the counter → E → card → SELL **+$4** (< $9, the clamp) → collection 1→0 → street HUD **$186 ·
|
||||
CRATE 0**. One amendment filed with its measurement: the old nearest-bin fallback ignored aim and would
|
||||
have swallowed E at any counter near a bin — aimed-raycast now outranks the sell trigger, the
|
||||
forgiving pick runs after it; both branches proven live (Moe Vinyl counter: card opens; Kelpie Sound
|
||||
bin, wrong aim: dig opens).
|
||||
|
||||
**The gates (ledger #5), all landed as STRICT suite smokes in `flags_check.py` — no more session-local
|
||||
instruments:** (a) **save/load determinism** — scripted buy 2/sell 1/sleep 2× via B's real button →
|
||||
export → fresh browser → import **byte-equal** (state 243 B, normalized export 269 B), plan fp
|
||||
`0xb1c7843e` **unchanged** across the import, weekNight re-keyed 2; (b) **no-pump v0** — 5 real
|
||||
UI round trips **all strictly negative** (Σ −$30), plus **C's negative control**: broke player,
|
||||
`wallet.buy` invoked-and-false on the real shelf path ⇒ **recordFind unreachable, zero entry** (dig BUY
|
||||
disables outright), with a positive arm proving the control discriminates; (c) **classic purity** —
|
||||
Storage-prototype instrument pre-page-script: `?classic=1`/`?game=0` **0 calls, 0 game DOM ids**,
|
||||
positive control sees the default boot's `procity-save` read + all 7 ids, plan fp game-on == game-off;
|
||||
(d) **rotation determinism over VERTEX DATA** (the wave-3 binding) — day 1/2/3 fps all distinct,
|
||||
re-entry byte-identical each day, **cross-boot byte-identical off the persisted save**, `?game=0` ==
|
||||
day 1 (88,320 hashed bytes / 20 stock meshes printed — the subject provably present). Harness bug #3
|
||||
caught en route: the HUD is hidden in interiors by design; the gate read it on the street.
|
||||
**NO TAG** — per the brief, the alpha tags when it plays (Fable's call, likely R31). Full record:
|
||||
`LANE_F_NOTES` §30.6.
|
||||
|
||||
---
|
||||
|
||||
## Round 26 (v5.0-beta — EVERY CRATE DIFFERENT) — F: the manifest consumed, the tolerance retired
|
||||
|
||||
**Every crate is different, and F measured it rather than taking G's word.** Not by entering one lucky
|
||||
|
||||
27
README.md
@ -14,9 +14,36 @@ cd web && python3 -m http.server 8130
|
||||
# http://localhost:8130/map.html (2D plan debugger — Lane A)
|
||||
```
|
||||
|
||||
## The manual — how to play (private test)
|
||||
|
||||
Click **START** to lock the mouse. Then:
|
||||
|
||||
| input | does |
|
||||
|---|---|
|
||||
| **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** (anywhere) · **SLEEP** (bottom bar) | sell a find to the keeper (he pays under the **guide band**, never over) · open THE CRATE — your collection, in shops too now · sleep = save, the day advances, stock rotates |
|
||||
| read the **guide** stamp on every price card | the 90s price-guide band ("guide $8–24"). An item **asking under its band is a gem** — buy it, sell it to a keeper, pocket the margin. Bought slots stay gone for the day (the bin remembers) |
|
||||
| **☆ WANT IT** on a pull card | start hunting it — keepers gossip ("saw one in Castlemaine"), and buying one clears the hunt. **Switching towns costs a day** — the 23 towns are a map now, not a dropdown |
|
||||
| **⚖ LIST** in the crate | consign a find to **GodBay** — the auction hammers overnight (sleep or ride somewhere). One hammer per item, ever: keeper cash now, or the gavel tomorrow |
|
||||
| click a book spine / toy box | pull-and-buy card (real price stickers in godverse shops) |
|
||||
| walk out the door / **Esc** | leave the shop (Esc also frees the mouse anywhere) |
|
||||
| **M** | 2D town map (street only) |
|
||||
| **[** / **]** | step time of day back / forward · **T** pauses the clock |
|
||||
| **P** | capture screenshots (street) |
|
||||
| HUD dropdown (top-left) | switch towns — synthetic, fixtures, 23 real AU towns, godverse |
|
||||
|
||||
Worth trying: `?classic=1` (the original v2 town, byte-identical forever) · `?noassets=1`
|
||||
(zero-network boot) · `?stock=real` in a godverse town (Monster Robot Party's actual crate) ·
|
||||
`?seed=<n>` (any number = a different town). Night is when the gigs happen — step time with `]`.
|
||||
|
||||
## Read first
|
||||
|
||||
- **`docs/CITY_SPEC.md`** — the treaty: schema, units, seeds, budgets, file ownership.
|
||||
- **`docs/V7_THE_GAME.md`** — the CURRENT epoch (ratified): persistence, the collection, the
|
||||
gem thrill. `docs/V6_LIVING_STREET.md` runs in parallel (mocap NPCs, footpaths, cars).
|
||||
- **`docs/THE_FRONTIER.md`** — the honest map of what is not figured out yet.
|
||||
- **`docs/RESEARCH.md`** — what the parent repos already solved, with exact paths to port from.
|
||||
|
||||
## The lanes (parallel Opus 4.8 sessions)
|
||||
|
||||
@ -1,9 +1,20 @@
|
||||
# LANE A — CITYGEN · progress (PROCITY-A)
|
||||
|
||||
*Status: **all deliverables landed; self-check ALL GREEN (156,352/156,352); rounds 2–27 (incl. wave 5) closed**.
|
||||
R27 w5 THE FACADE FIX: every town — synthetic included — had its shopfronts facing away from the street
|
||||
since **v1**. Fixed in all four ry sites; **51 goldens re-pinned**; the classic covenant amended on John's
|
||||
ruling (`0x3fa36874 → 0x5f76e76`); a cross-convention gate added so it can never recur.*
|
||||
*Status: **all deliverables landed; self-check ALL GREEN (156,352/156,352); rounds 2–30 closed**.
|
||||
R30 (v7.0-alpha ledger #6): the rotation boundary VERIFIED — F's `day` salt rides runtime streams only,
|
||||
plan generation structurally cannot receive it; all goldens frozen, zero re-pins.*
|
||||
|
||||
## Round 30 (2026-07-18) — the rotation boundary verify (ledger #6, small round as briefed)
|
||||
|
||||
One job: prove F's day-salted stock rotation (207bcff) never reaches plan generation. **It holds.**
|
||||
Three measured legs (full detail in LANE_A_NOTES §30): (1) grep-zero for day identifiers over the proven
|
||||
`citygen/**` glob (8 files / 2,546 lines, census printed; pattern proven non-vacuous against
|
||||
`interior_mode.js`); (2) structural absence — `generatePlanFor` consumes no day-shaped opt and neither
|
||||
shell call site passes one; (3) day-inertness byte-measured — plan hash identical with `{day, stockDay}`
|
||||
hostilely injected as 1 and 37 vs absent, 3 seeds × synthetic+osm, 6/6. The salt's actual reach: `stk-*`
|
||||
stock-pick stream labels + dig `binSeed` + `setWeekNight` night-selection over the plan-time 7-night week
|
||||
(day picks a night, never generates one). Selfcheck ALL GREEN 156,352/156,352, fingerprint `0x5f76e76`
|
||||
frozen, every town pin green. No files of mine changed except these two notes.
|
||||
|
||||
## Round 27 wave 5 (2026-07-17) — THE FACADE FIX (the epoch's biggest catch)
|
||||
|
||||
|
||||
@ -2,6 +2,34 @@
|
||||
|
||||
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
|
||||
|
||||
## Round 30 (2026-07-18) — THE ROTATION BOUNDARY VERIFY (ledger #6): **HOLDS**
|
||||
|
||||
### → Fable / F: `day` rides RUNTIME streams only — plan generation cannot see it. Measured, not read.
|
||||
|
||||
**Verdict: the seeded-rotation boundary HOLDS.** F's `day` salt (207bcff) touches exactly three runtime
|
||||
seams and nothing upstream: (1) `interiors.js:178` `ctx.stockSalt` from `opts.stockDay`, consumed ONLY in
|
||||
`layout.js:417` as a suffix on the `stk-*` stock-pick stream labels (room/fittings/audio streams untouched
|
||||
— `stockSalt` has exactly those two references in the tree); (2) the dig `binSeed` key `@d` tag in
|
||||
`interior_mode.js` (same null-gating: no game / day 1 / real-sourced ⇒ absent); (3) `gig_state.setWeekNight`,
|
||||
which only **selects** a night from `plan.gigs` — the whole 7-night week is generated at plan time
|
||||
(`gigs.js:227` `for night < NIGHTS`) from citySeed streams; day picks, never feeds.
|
||||
|
||||
**How measured (three falsifiable legs):**
|
||||
1. **Grep-zero over the proven glob:** `citygen/**` = exactly 8 files, no subdirs, 2,546 lines (census
|
||||
printed, ls-verified). `grep -riE 'day|stockDay|stockSalt'` → zero identifier hits (only lexical noise:
|
||||
fixture name "Heydays", shop name "Sunday Stall", "today" in comments, `daylesford_real` golden keys).
|
||||
Non-vacuous: the same pattern hits `stockDay` 4× in `world/interior_mode.js`.
|
||||
2. **Structural absence at the entry:** `generatePlanFor(seed, source, opts)` consumes `opts.{town, gigs,
|
||||
customBands, cache, report}` only (grepped every `opts.` in citygen); both shell call sites
|
||||
(`index.html:127`, `map.html:315`) pass `{town, gigs, customBands}` — no day exists to pass.
|
||||
3. **Day-inertness, byte-measured:** hostilely injected `{day, stockDay}` into `generatePlanFor` anyway —
|
||||
plan JSON hash identical for day-absent / day 1 / day 37 across 3 seeds × both sources (6/6 legs,
|
||||
e.g. synthetic 20261990 `0xec7a2d39` all three ways). Even a rogue caller can't leak day into a plan.
|
||||
|
||||
**Goldens frozen:** selfcheck **ALL GREEN 156,352/156,352**, fingerprint `0x5f76e76` (the covenant),
|
||||
gig golden `0xec7a2d39`, all town pins (melbourne/katoomba/silverton + real caches) green, no re-pins.
|
||||
Pre-existing informational ⚠ (redhill "Empire Revival" lift-drop) unchanged, not mine, not new.
|
||||
|
||||
## Round 27 wave 5 (2026-07-17) — THE FACADE FIX: it was every town, not just the real ones
|
||||
|
||||
### → F: **your files hold the old covenant hashes — they are now wrong, and only you can fix them**
|
||||
|
||||
@ -1,5 +1,64 @@
|
||||
# LANE B — NOTES (measured)
|
||||
|
||||
## Round 30 (v7.0-alpha ledger #4) — THE COLLECTION UI + the SLEEP surface: **shipped, verified against F's REAL core (nothing stubbed)**
|
||||
|
||||
**The crate is on screen: cover thumb · title/artist · price paid · where + day found; count, day and
|
||||
cash in the standing HUD; SLEEP saves and wakes tomorrow. All of it built on `PROCITY.game` exactly as
|
||||
§30.1 publishes it — and by verify time none of it was a stub: F committed the save core (`207bcff`)
|
||||
mid-round, and every game-layer file my test server carried is byte-identical to that HEAD (diffed,
|
||||
not assumed). Lane C stubbed because F hadn't landed; B landed after F did — the wave order worked.**
|
||||
|
||||
### What ships (`web/js/world/hud.js` — my file, my DOM-overlay patterns; +8 lines in `venue.js`, F's §30.4 filing)
|
||||
- **The game bar** (bottom-right): `day N · $cash` readout + `CRATE n` (the standing-HUD count, opens
|
||||
the panel; `C` toggles it on the street) + `☾ SLEEP` → `game.sleep()` — enabled any time (alpha;
|
||||
home-base gating is beta). The shell's `onDay` hook owns the wake toast; the bar just re-reads the
|
||||
contract getters.
|
||||
- **The crate panel**: newest find first; each row = cover thumb, title/artist (C's §30.1 fallback
|
||||
chain title → sku → slotId), `paid $p · <where> · day d`. Header totals finds + $ spent. *Where* =
|
||||
the shop's name off the plan when the entry's `townKey` is this boot's, else the town part of its
|
||||
townKey (`redhill godverse`) — honest, never guessed.
|
||||
- **Cover thumbs = the pack atlas, cropped in CSS.** The entry's `sku` resolves against its pack index
|
||||
(per-shop `assets/stock_godverse/<godverseShopId>/` when set, else the town-wide `assets/models/`),
|
||||
and the row's 44 px thumb is `background-image` + `background-size: 100/du% × 100/dv%` +
|
||||
`background-position: u0/(1−du)` off the item's `uv` rect (origin top-left = CSS's origin, no flip —
|
||||
the WebGL V-flip in stockpack.js is exactly the transform NOT to apply here). Zero canvas, zero
|
||||
texture decode beyond the browser's own; B keeps its own tiny index-only fetch cache (`base|type`,
|
||||
read-only GETs — can't fight stockpack's texture cache). Parody finds (slotId, no sku) wear a type
|
||||
glyph by design (§30.4); so does any missing pack / unknown sku / remote (`depot:`/http) atlas.
|
||||
- **Construction-gated like the R19 selector, but on the game itself:** the HUD builds NOTHING at
|
||||
`createHUD` — a lazy check (every 30th frame) builds the surface on first sight of a truthy
|
||||
`window.PROCITY.game` (the shell constructs the game after the HUD). `?classic=1` / `?game=0` ⇒
|
||||
`game` is null forever ⇒ **none of this DOM, the KeyC listener, or the help-line suffix ever
|
||||
exists** — the gate is the game object, not a re-derivation of the flags.
|
||||
- **`venue.js` — F's §30.4 filing, fixed as filed:** the compat `venueShopId` ("tonight's venue") was
|
||||
frozen at night-0's gig; now a live getter keyed to `PROCITY.gigs.weekNight` (0 pre-game/day-1 ⇒
|
||||
exact pre-v7 value). No consumer of the field exists in-tree today (grepped), so the getter is pure
|
||||
forward-correctness for whoever reads "tonight" next.
|
||||
|
||||
### Measured (fresh contexts, port-isolated no-store server :8151 — NOT :8130/:8144 — serving a code
|
||||
### snapshot + assets symlink, so F's mid-flight edits couldn't shift under the test; snapshot later
|
||||
### diffed clean against F's landed commits)
|
||||
| check | result |
|
||||
|---|---|
|
||||
| `?classic=1` / `?game=0` (clean tab) | `game: null` · game DOM **0/5 ids** · help line byte-identical · **0 console errors** |
|
||||
| default boot | bar lazily built, `day 1 · $191` (wallet-backed start), CRATE 0 |
|
||||
| 3 finds via the REAL `game.recordFind` (godverse sku · town-wide sku · parody slotId) | CRATE 3 · head `3 finds · $92 spent` · thumbs from **both** pack bases (`stock_godverse/158/`, `models/`) — real cover art on screen (screenshotted), parody = ♪ · exactly 4 fetches (2 index + 2 atlas) |
|
||||
| foreign-town entry via the REAL `game.import()` | row reads `paid $25 · redhill godverse · day 1` (townKey fallback) · missing sku ⇒ placeholder, 0 errors |
|
||||
| sell seam (`game.removeFind`) | 4 → 3: bar + open panel both follow within one poll (≤6 frames) |
|
||||
| **SLEEP** (button) | day 1→2 · weekNight 0→1 · wake DAWN 06:30 seg 0 · `procity-save/1` on disk with day 2 + 3 entries · shell toast `🌅 day 2` · cash untouched · **`venueShopId` re-keyed 116 → 490** (the §30.4 fix, proven behaviourally) |
|
||||
| next boot (same profile) | the saved game LOADS and the bar/panel read it — persistence proven from the UI side too |
|
||||
| `?noassets` + a real-sku find | **0** stock/atlas fetches (asset law) · placeholder stands · 0 errors |
|
||||
| cost | 50-row full rebuild **0.9 ms** · first 3-row open 2.1 ms · DOM-only = 0 draws (C proved the class; unchanged) |
|
||||
|
||||
**Honest notes:** (1) the four `THREE.PointerLockControls` console errors seen mid-drive are the
|
||||
automation browser refusing the Pointer Lock API on my splash click — reproduced click-free at zero,
|
||||
absent on every clean-tab boot; environment, not code. (2) F's splash ("walk into town") was new in
|
||||
the tree mid-round and sat over my first screenshot — noted, F's surface, untouched. (3) Beta polish,
|
||||
not done: foreign-town rows could carry real display names via E's towns index (the selector already
|
||||
fetches it) — one map at build time, when the money-shot pass comes.
|
||||
|
||||
---
|
||||
|
||||
## Round 28 (v5.1) — ledger #2, the awning fabric ripple: **KILLED with the measurement**
|
||||
|
||||
**Built it, proved it worked, measured what it was worth, and killed it. A 10×-over-driven billow —
|
||||
|
||||
@ -29,6 +29,9 @@
|
||||
> **v5.0-beta amendment (2026-07-17, R26 wave 4):** **§7.2a's id form is now SOURCING-SCOPED** —
|
||||
> `sku_<POS id>` for real, `mint_<listing id>` for mint. **Prefixes are namespace fences**, not decoration:
|
||||
> my R25 line was written before mint existed and read as one space where there are two. Doc-only.
|
||||
> **v7.0-alpha amendment (2026-07-18, R30 ledger #2):** **§9 added — the sell-counter contract** (the
|
||||
> no-pump law made structural: `offer(p) = min(p−1, max(1, floor(p·0.5)))` — strictly below the buy side
|
||||
> for every p ≥ 1, by construction). §0–§8 unchanged.
|
||||
|
||||
*The district ships **three** venue archetypes behind `?gigs=1` — `pub`, `band_room`, `rsl`. Lane A
|
||||
converts a chosen shop to a venue kind in place (`shop.type = kind`); C keys the interior recipe off
|
||||
@ -462,3 +465,85 @@ never persisted.
|
||||
a *different* seeded pick than the dig's offers — a fan, not the crate's contents. A sold record's cover may
|
||||
linger in the fan until the room rebuilds. If we ever want the fan to honour `gone`, **that** is when I'd
|
||||
publish a post-build hook; it is not needed for the epoch's claim, because the crate you *riffle* is true.
|
||||
|
||||
---
|
||||
|
||||
## 9. The sell counter (v7.0-alpha, R30 ledger #2) → Lanes F/B
|
||||
|
||||
**The mirror of the buy card.** Walk to the keeper's counter holding items the shop trades in → a sell
|
||||
card (the same manila price-sticker treatment as the dig's pull panel): item, OFFER, SELL. Module:
|
||||
`web/js/interiors/sell.js` — DOM-only (zero draws, zero GPU resources), same consumer pattern as
|
||||
`dig.js` (C ships the interaction; F owns the in-game input/mode hook).
|
||||
|
||||
### 9.1 THE OFFER — the no-pump law, structural (the multiplier and why)
|
||||
|
||||
```js
|
||||
sellOffer(pricePaid) = min(pricePaid − 1, max(1, floor(pricePaid × 0.5))) // and never < 0
|
||||
```
|
||||
|
||||
- **The multiplier is 0.5** — the classic secondhand-dealer margin (a keeper pays about half of what
|
||||
he'll sticker it at; he has to eat too). It keeps selling *meaningful* (half your money back beats a
|
||||
full crate of duds) without being an income source.
|
||||
- **The `min(pricePaid − 1, …)` clamp is the law made structural, not tuned:** the offer is **strictly
|
||||
below what you paid for every `pricePaid ≥ 1`** (at `p = 1` the offer is $0 and the SELL button
|
||||
disables — the keeper won't buy what he can't sticker; at `p = 2` the naive `max(1, floor(p/2)) = 1`
|
||||
would meet the clamp anyway — the clamp exists so no future retune of the multiplier can ever push an
|
||||
offer to break even). **Buy-then-immediately-sell therefore loses ≥ $1 per round trip, always** —
|
||||
F's no-pump gate (`R30 ledger #5`, monotonic cash loss) holds **by construction**, and a change to
|
||||
this formula that breaks that property is a contract violation, not a balance pass.
|
||||
- **Alpha basis = `pricePaid`.** The alpha carries no guide-band data (the BIBLE bands are beta), and
|
||||
in the alpha every buy happens at the shop's asking price — so `pricePaid` IS the buy-side price the
|
||||
player faced. **Beta swaps only the basis, not the shape:** when E's guide bands land, the offer
|
||||
becomes `min(bandLow − 1, max(1, floor(bandLow × 0.5)))` — below the band's *low* edge by
|
||||
construction, so a gem bought under band still nets its finder margin only against the band, never
|
||||
against the keeper. The formula's clamp survives the swap untouched.
|
||||
|
||||
### 9.2 What the keeper buys (alpha: type = type, strict)
|
||||
|
||||
- `sellableIn(shopType, item)` ⇔ `item.type === shopType`. A record shop buys records, a book shop
|
||||
books — **any** shop of the type (per-keeper taste, haggling, pawn-buys-everything: all beta, not
|
||||
built).
|
||||
- **An item with no `type` is NOT sellable — fail-closed** (vacuous-gate law: a matcher that passes an
|
||||
untyped item would "match" everywhere). **→ Lane F ASK:** stamp `type` (the registry stock type:
|
||||
`record`/`book`/`toy`/…) on every collection item at buy time — the R30 published shape
|
||||
(`{townKey, godverseShopId|shopId, sku|slotId, pricePaid, dayFound}`) doesn't carry it, and without
|
||||
it nothing is sellable anywhere. Carrying `title`/`artist` too is strongly asked (B's collection UI
|
||||
needs them; my card falls back to the sku/slotId label when absent — ugly but honest).
|
||||
|
||||
### 9.3 The seam — money like buying, removal via the game API only
|
||||
|
||||
- **C consumes `window.PROCITY.game.collection` READ-ONLY.** The card never splices, reorders, or
|
||||
mutates it (same species of law as §8.3's `items[]`).
|
||||
- **`onSell(item, offer)`** is the single side-effect callback — the exact mirror of the dig's
|
||||
`onBuy(offer)` (the proven wallet seam). The **consumer** (F) does both halves: credit the wallet,
|
||||
remove the item from the collection **through the game API**. `onSell` returning `false` = keeper
|
||||
veto: card unchanged, nothing moves.
|
||||
- `wallet.sell(item, offer)` is provided (C-owned `wallet.js`, mirrors `buy()`): credits `offer`,
|
||||
drops the matching v0-inventory entry, notifies. F binds it exactly like it binds `wallet.buy`.
|
||||
- Till ring: the sale rings `'till'` from the counter emitter (the R28 one-shot pattern, same
|
||||
distance-gain math as the dig's) — silent-and-happy with no engine / `?mute` / `?noassets`.
|
||||
|
||||
### 9.4 The trigger + the card
|
||||
|
||||
- **Where:** within **`SELL_DIST = 2.0 m`** of `room.counter.pose` (bench centre, room-local — the
|
||||
frame the interior camera walks in). Helper exported: `nearCounter(room, pos, dist?)`.
|
||||
- **Suggested E routing for F** (matches my harness): aimed bin → dig (unchanged) · else near counter
|
||||
with ≥ 1 sellable → sell card · else shelf-buy. Pointer unlocks while the card is up (dig precedent).
|
||||
- **The card:** one item at a time — artist/title (or the sku/slotId fallback), the round OFFER
|
||||
sticker, SELL, `‹ ›` cycling when more than one sellable, ✕/Esc closes. `open()` returns `false`
|
||||
(no card, no DOM) when nothing is sellable.
|
||||
|
||||
### 9.5 Fail-soft (the classic-pure law, C's half)
|
||||
|
||||
No game layer (`?classic=1` / `?game=0` ⇒ `window.PROCITY.game` absent) → the consumer never opens
|
||||
the sell UI, **and** `open()` itself returns `false` on a missing/empty item list — zero errors, zero
|
||||
DOM, zero localStorage. `createSell()` allocates DOM only; `dispose()` removes every node + listener.
|
||||
|
||||
### 9.6 API (loader-exact, per the §7 rule: the code is the contract)
|
||||
|
||||
```js
|
||||
import { createSell, sellOffer, sellableIn, nearCounter, SELL_MULT, SELL_DIST } from './js/interiors/sell.js';
|
||||
const sell = createSell();
|
||||
sell.open({ shopType, shopName, items, getCash, onSell, onClose, emitters }) // → bool (false = nothing sellable)
|
||||
sell.close(); sell.dispose(); sell.active // mirrors dig.js
|
||||
```
|
||||
|
||||
@ -6,6 +6,70 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
'Look') is bound through the fleet gate and street peds now glance around during window-shop stops. `lean`
|
||||
is not here and never was — E measured the stash (R28). Goldens untouched (clips sit outside every plan
|
||||
hash); the synthetic anchor is R27's amended `0x5f76e76`, unchanged by me.*
|
||||
|
||||
### What shipped
|
||||
- **`rigs.js` — `loadPedFleet(base, { sit, look })`** → `fleet.lookClip`, the exact twin of the R16 sit gate.
|
||||
The shell passes `look: !CLASSIC`, so **`?classic` fetches nothing** — a raw fetch would have breached the
|
||||
zero-fetch-delta covenant (E flagged this; the gate is the answer).
|
||||
- **`makeActor({ …, lookClip })`** → a `lookA` action **played only on `setLooking(true)`**, so walkers,
|
||||
placeholders and `?classic` never advance it. `setLooking` **crossfades** (0.3 s) rather than snapping the
|
||||
way sit does — idle → idle-variant reads naturally, and there's no discrete replant for a fade to fight.
|
||||
- **`sim.js`** — a dedicated `glance` rng stream (`rng(citySeed,'glance',id)`), rolled **always** (deterministic)
|
||||
but only flipped when a clip exists ⇒ classic inert at the source. **Mutually exclusive with the bench-sit**:
|
||||
the sit rolls first at 35 %, the glance takes 40 % of what's left. `RigPool` threads `lookClip`; release
|
||||
snaps the glance off so no rig is pooled mid-look.
|
||||
|
||||
### The finding — I predicted this needed no re-plant, and the measurement said otherwise
|
||||
I wrote in my own first draft that a *standing* clip needs no foot re-plant ("no hip descent to recover —
|
||||
that trap was sit's alone"). **Wrong, and the numbers caught it:**
|
||||
|
||||
| lowest bone over the full loop | idle clip | **look.glb, no plant** |
|
||||
|---|---|---|
|
||||
| min → max | −0.04 → 0.00 (span **4 cm**) | −0.009 → **+0.205** (span **21 cm**) |
|
||||
|
||||
The ped **hovers up to 20.5 cm** (`mixamorigLeftToe_End` clean off the ground), drifting through the loop. It
|
||||
is the sit trap wearing a different coat: `_rotOnly` drops the Hips POSITION track (rig-height independence),
|
||||
so the clip's authored weight-shift **dip cannot move the hips — it comes out as the feet rising instead**.
|
||||
Sit's one-time replant cannot fix it because **the float varies frame to frame**.
|
||||
|
||||
**Fix: `plantFeet()` — the per-frame twin of sit's replant.** Reset to the bind plant → measure the posed
|
||||
feet → drop `inner` by float / scale. **Absolute (reset→measure→correct), never incremental, so it cannot
|
||||
drift.** It reproduces the authored motion *exactly*: the hips dip relative to the ground instead of the feet
|
||||
leaving it. Scale-aware like sit's. Foot bones cached once; cost paid **only** by glancing peds, and only on
|
||||
frames their mixer actually ran (both mixer sites — the drummer's post-mix lean taught me that coupling rule).
|
||||
|
||||
### Proofs
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| foot float over the loop | **0.205 m** | **0.000 m** (pinned) |
|
||||
| head yaw sweep (the glance itself) | — | **span 1.735** (−0.85 → +0.89) |
|
||||
| `?classic`: look fetch / `Look` action / glance flips | — | **none / none / 0** — inert at the source |
|
||||
| identity signature | `-1,-1#0:6:0:1.777:1.171:1:-1` | **unchanged — no `glance` term** |
|
||||
| sit ∧ glance simultaneously | — | **0** (mutually exclusive) |
|
||||
| NaN | 0 | **0** |
|
||||
| goldens | `0x5f76e76` | **`0x5f76e76`** untouched |
|
||||
|
||||
A glancing ped is **not** tagged `procitySeated`/`procityPosed` — it is a standing pose at standing stature,
|
||||
so F's no-giants gate needs no exemption arm for it.
|
||||
|
||||
### Handshakes
|
||||
- **D → F:** **the pose gate has its subject** — un-SKIP it. A fresh `?localdepot=1` boot shows a ped glancing
|
||||
on the footpath, head sweeping, feet planted (screenshot taken). Classic/`?noassets` untouched.
|
||||
- **Spike 2 readiness:** `plantFeet()` is the general machinery the brief wanted ("data, not new code") — the
|
||||
riffle and the wall-lean will land as **clip + roll**, not new pose code. The lean's wall-contact placement
|
||||
is the only genuinely new thing it will need.
|
||||
- **Stale line spotted (A's file, A rests):** `selfcheck.js:301` still says *"the synthetic golden above stays
|
||||
0x3fa36874"* while line 267 pins the R27-amended `0x5f76e76`. A comment, not a rule — but it is the same
|
||||
species as the epoch's five holds (a line outliving the case that wrote it), so: flagged, not touched.
|
||||
|
||||
---
|
||||
|
||||
## ROUND 28 — v5.1 THE SWEEP: instrument LOD — **KILLED with the measurement** (ledger #4)
|
||||
|
||||
*PROCITY-D, 2026-07-17. The oldest tri item on the books (v3.1-era). The brief attached the wind-sway rule:
|
||||
|
||||
@ -1413,3 +1413,478 @@ precious. **What ships is the sweep, and the tag says exactly that:**
|
||||
**Filed for R29 (D, one opt):** `rigs.js` gains `look` the way it gates `sit`; F passes it from the shell.
|
||||
Then the pose gate has a subject and F will run it. **v6 has not begun in-game — the tag must not say it
|
||||
has.**
|
||||
|
||||
---
|
||||
|
||||
## 29. Round 29 wave 0 — **the pose gate gains its subject. v6's first clip is in the game.**
|
||||
|
||||
R28's gate had **no subject** and skipped with the reason printed. D bound the clip in wave 0, so the
|
||||
gate binds too — and it is now permanent, not a hand-proof:
|
||||
|
||||
| assertion | result |
|
||||
|---|---|
|
||||
| default boot: `look.glb` **bound** | **6.5 s, 64 tracks** |
|
||||
| **the glance reaches the game** | **peak 46 of 396 peds glancing** · 0 console errors |
|
||||
| `?classic=1`: the clip | **never fetched** — the zero-fetch-delta covenant holds |
|
||||
| `?classic=1`: the behaviour | **inert at the source** — 0 of 269 peds, **and the rng stream still draws** |
|
||||
|
||||
**That last row is the whole design, and it's D's cleverness worth naming: the determinism lives in the
|
||||
DRAW, not the outcome.** D takes from a dedicated `glanceRng` stream *always* — so the seeded sequence is
|
||||
identical whether or not a clip exists — and only flips the flag when `fleet.lookClip` is real. Under
|
||||
`?classic` the roll still happens, the flag never sets, nothing is fetched, and the covenant is
|
||||
byte-identical. Add a behaviour without touching a hash.
|
||||
|
||||
**And the gate asserts the clip's ABSENCE under classic, not just "0 glancing."** "0 glancing" alone
|
||||
passes on a fleet that failed to load entirely — the vacuous form of this exact check. Present on the
|
||||
default boot + absent under classic is the pair that can't both be satisfied by a broken fleet.
|
||||
|
||||
### ⚠ F's gate crashed the suite before it ran — worth recording
|
||||
The first cut checked for its subject with `pg.goto('.../look.glb')`. Navigating to a `.glb` starts a
|
||||
**download**, Playwright aborts the navigation, and the exception took `flags_check` down with it —
|
||||
before a single assertion ran. **A gate that dies looking for its subject is worse than one that skips:
|
||||
it doesn't report "no subject", it reports nothing at all, and takes the suite with it.** The
|
||||
subject-presence check now reads the disk. *The vacuous-gate law has a sibling: a gate must survive its
|
||||
own subject being absent.*
|
||||
|
||||
**No tag** — wave 0 rides Spike 2's close, which is gated on John's footage. Nothing else is owed on m3.
|
||||
|
||||
---
|
||||
|
||||
## 30. Round 30 — v7.0-alpha THE SAVE CORE (ledger #1) + SLEEP=SAVE=TOMORROW (ledger #3)
|
||||
|
||||
### §30.1 THE CONTRACT — `window.PROCITY.game` (PUBLISHED FIRST, per the brief; B builds on this, C reads it at the counter)
|
||||
|
||||
`window.PROCITY.game` is **null** under `?classic=1` and under `?game=0`; on every other boot it is:
|
||||
|
||||
```js
|
||||
window.PROCITY.game = {
|
||||
day, // getter, int ≥ 1. DAY 1 IS THE PRE-v7 TOWN: stock streams carry no day salt on day 1,
|
||||
// so a fresh game's shops are byte-identical to a ?game=0 boot. Rotation begins at the
|
||||
// first sleep. (Falsifiable: day-1 stock == no-game stock; day-2 stock != day-1 stock.)
|
||||
cash, // getter, int dollars — always equals PROCITY.wallet.cash()
|
||||
collection, // getter → the LIVE array. READ-ONLY for every consumer (C's §9.3 law); the only
|
||||
// mutations are game.recordFind (buy seam) and game.removeFind (sell seam).
|
||||
townKey, // string for THIS boot: `${plansrc}/${town || 'default'}@${seed}`
|
||||
// e.g. 'synthetic/default@20261990' · 'osm/redhill_godverse@20261990'
|
||||
save(), // → bool. Serializes schema procity-save/1 → localStorage['procity-save'].
|
||||
load(), // → bool (ran once at construction). Corrupted/foreign blob → LOUD reject
|
||||
// (console.error), raw stashed at localStorage['procity-save.rejected'], fresh start.
|
||||
// THE TOWN NEVER BREAKS: the save holds only player deltas (the delta law), so a bad
|
||||
// save can cost you your stuff, never the world.
|
||||
sleep(), // → the new day (int). day+1 → gig weekNight re-keys to (day − 1) % 7 (see the §30.3
|
||||
// amendment) → wake at DAWN (lighting segment 0) → save(). Stock streams re-seed (§30.3).
|
||||
export(), // → JSON string — the exact procity-save/1 payload (John moves machines).
|
||||
import(json), // → bool. Validated exactly like load(). true ⇒ state replaced + saved + day-derived
|
||||
// state applied (weekNight etc). false ⇒ loud reject, CURRENT STATE UNTOUCHED.
|
||||
wallet, // the wallet-compatible facade — PROCITY.wallet IS this object when the game is on.
|
||||
// Full Lane C v0 interface: cash() start() canBuy(p) buy(o) sell(o, offer)
|
||||
// inventory() count() onChange(fn). buy/sell/inventory keep C's exact semantics
|
||||
// (sell credits the offer + drops the matching v0-inventory entry, per §9.3);
|
||||
// cash is game-backed so a loaded save's cash is authoritative.
|
||||
recordFind(shop, info), // F's buy-seam internal (interior_mode calls it) — appends a collection
|
||||
// entry stamped with townKey + day. Exposed so the gates can drive it.
|
||||
removeFind(entry), // → bool. Identity (===) removal — THE only way the collection shrinks.
|
||||
// C's sell counter: onSell ⇒ F calls wallet.sell(item, offer) +
|
||||
// game.removeFind(entry). Returns false (nothing moves) if entry ∉ collection.
|
||||
};
|
||||
```
|
||||
|
||||
**Collection entry shape** (C's §9.2 asks honoured: `type` stamped at buy time, `title`/`artist` carried):
|
||||
|
||||
```js
|
||||
{ townKey, // where in the world — the boot key above
|
||||
shopId, // plan shop id (always present)
|
||||
godverseShopId, // the real POS id, ONLY when the shop has one (the two id spaces stay separate — R24 law)
|
||||
type, // the ITEM's stock type: 'record' (any dig, incl. an opshop bin) · 'book' · 'toy' (shelf
|
||||
// buys). Sellability is type = type, fail-closed (C §9.2) — this field is load-bearing.
|
||||
sku, // pack item id (`sku_*` / `mint_*` / `rec_*` — the sku IS the identity, R25/26 fences)…
|
||||
slotId, // …OR, for parody items with no pack id: `<binKey>#<offerIndex>` (dig). Exactly one
|
||||
// of sku|slotId is set.
|
||||
title, artist, // display (B's collection card; C's sell card falls back to sku/slotId when absent)
|
||||
pricePaid, // int dollars actually debited
|
||||
dayFound } // game.day at purchase
|
||||
```
|
||||
|
||||
**Save schema `procity-save/1`** (localStorage key `procity-save`):
|
||||
|
||||
```js
|
||||
{ schema: 'procity-save/1', day, cash, town: townKey, collection: [entries], savedAt }
|
||||
```
|
||||
|
||||
THE DELTA LAW, enforced by shape: no plan, no shop, no stock, no clock, no world field of any kind ever
|
||||
enters this object — the world regenerates from seed. Validation on load/import is exact-schema-string +
|
||||
field checks (day int ≥ 1 · cash finite ≥ 0 · collection entries carry townKey/pricePaid/dayFound and
|
||||
one of sku|slotId); any miss rejects the WHOLE save loudly. Versioning: a future `procity-save/2`
|
||||
migrates-or-rejects, never silently coerces.
|
||||
|
||||
**Save points:** `sleep()` and `beforeunload`. (Note, measured against the brief's "save, day+1" order:
|
||||
sleep increments FIRST and saves the post-sleep state, so disk always carries the morning you woke into —
|
||||
the literal order would leave disk one day stale between sleep and unload. Same falsifiable outcome, one
|
||||
fewer window.)
|
||||
|
||||
### §30.2 The laws, wired from birth
|
||||
|
||||
- **`?classic=1`** → `createGame` is NEVER CALLED: no game object, no wallet facade, no listener, ZERO
|
||||
localStorage touches (save.js has no module-scope storage access — the classic-purity gate in ledger #5
|
||||
measures this rather than trusting it). `?game=0` → same absence, C's plain `createWallet` runs the
|
||||
session exactly as pre-v7. Default boot → game on (the v3.1 flip precedent).
|
||||
- **Game money is game money:** the save carries dollars that exist nowhere else; import validates shape,
|
||||
not provenance — there is no bridge to anything real, in either direction.
|
||||
|
||||
### §30.3 SLEEP = TOMORROW — the rotation law (ledger #3, runtime only)
|
||||
|
||||
- **The day salt.** The parody/mint stock pick streams re-seed per (shop, day) by salting the EXISTING
|
||||
runtime streams — plan generation NEVER sees `day` (A verifies the boundary, ledger #6):
|
||||
- interior stock: `buildInterior(shop, THREE, { …, stockDay: day })` → `ctx.stockSalt = ':d'+day` →
|
||||
layout.js appends it to the `stk-*` sub-stream salts (one marked seam line each in interiors.js +
|
||||
layout.js — `[Lane F R30 seam]`). Fittings/layout/audio streams untouched: the ROOM is the same
|
||||
room every day; what rotates is which items the streams pick into it.
|
||||
- the dig: `binSeed(shop.seed, binKey + '@d' + day)` (interior_mode, F-owned).
|
||||
- `day === 1` or no game layer ⇒ NO salt (byte-identical to pre-v7 — the day-1 convention above).
|
||||
- **REAL-sourced stock never rotates.** A shop whose per-shop pack the godverse manifest declares
|
||||
`sourcing:'real'` (Monster Robot Party) gets NO day salt, ever — its crate is a real crate. `mint`
|
||||
crates and parody stock rotate. (The town-wide v2 packs are real *images* used as seeded set-dressing,
|
||||
not a real shop's inventory — they rotate with the parody streams. Ruled here so nobody re-derives it.)
|
||||
- **Gig night = (day − 1) % 7 — AMENDED from the brief's literal `day % 7`, with the measurement.**
|
||||
`day` starts at 1, so the literal form keys a FRESH game's first night to night 1 — flipping the
|
||||
default boot's gigs off night 0 before the player ever sleeps, breaking the R13 cover/band gates and
|
||||
B's night-0-keyed frontage, and contradicting the day-1-is-the-pre-v7-town convention this same
|
||||
contract establishes for stock. (day − 1) % 7 keeps the brief's intent exactly — every sleep walks
|
||||
the existing seeded week — and day 1 IS night 0. `gig_state.js` (F-owned) gains `setWeekNight(n)` +
|
||||
`get weekNight`; every venue's latch re-keys to its night-n gig (plan.gigs is already the whole week;
|
||||
the three hard-won latch laws untouched). Cover stamps reset with the re-key (new night ⇒ cover due),
|
||||
correct by John's R12 ruling. Fable ratifies or overrules at review — filed loudly, not slipped in.
|
||||
- **Wake at dawn:** `sleep()` → `lighting.setSegment(0)` (fires `procity:segment`, so every latch +
|
||||
facade observes the morning — the listens-not-polls law does the work for free).
|
||||
|
||||
### §30.4 Cross-lane notes on this contract
|
||||
|
||||
- **→ B (collection UI + SLEEP surface, ledger #4):** everything you need is `PROCITY.game`
|
||||
(`day`/`cash`/`collection`/`sleep()`) + `wallet.onChange` for the cash chip. Cover thumbs: resolve via
|
||||
the entry's `sku` against the shop's pack where present; parody entries have no thumb (title/artist
|
||||
card is correct-and-readable, per the brief).
|
||||
- **→ B (found while wiring weekNight):** `venue.js:38` keys "tonight" to **night 0** — correct pre-game,
|
||||
but after the first sleep the marquee/frontage would advertise night-0's band while the latch plays
|
||||
night-(day%7)'s. `PROCITY.gigs.weekNight` (and `game.day`) are published; one line in venue.js when
|
||||
you touch the HUD. Filed, not silently fixed (your file).
|
||||
- **→ C (sell counter, §9.3):** your asks are honoured — `type` stamped at buy time, `title`/`artist`
|
||||
carried, removal via `game.removeFind(entry)`, credit via the facade's `sell(item, offer)` (mirrors
|
||||
your wallet.js exactly). The E-key routing hook (aimed bin → dig · near counter + sellable → sell card
|
||||
· else shelf-buy) is F's and lands with the sell wiring pass, not this one.
|
||||
- **→ A (ledger #6):** the day salt rides `buildInterior` opts / `binSeed` only — grep-provable that
|
||||
`generatePlan*`/plan_osm never receive `day`.
|
||||
|
||||
### §30.5 RESULTS — implemented, measured, committed (`53fcd3c` contract · `207bcff` implementation)
|
||||
|
||||
All measured in FRESH browser contexts on a port-isolated no-store server (:8791 — the module-cache lesson),
|
||||
seed 20261990, plus `redhill_godverse?stock=real` for the crate legs. Seven legs, all green:
|
||||
|
||||
| leg | measured |
|
||||
|---|---|
|
||||
| the contract | default boot: `game` live — day 1 · cash **$191 == wallet.cash()** · collection 0 · townKey `synthetic/default@20261990` · weekNight 0 · full API incl. `wallet.sell` · 0 errors |
|
||||
| classic purity | `?classic=1` AND `?game=0`: `game === null`, **ZERO Storage-prototype calls** (instrumented before any page script, not trusted) · wallet $191 (C's v0) · 0 errors |
|
||||
| buy → collection | dig pull: **$12 debited**, entry `{type:'record', slotId:'20_-317#0', pricePaid:12, dayFound:1, title:'Sunburnt'}` — debit == pricePaid, shape == §30.1 |
|
||||
| sell seam halves | `wallet.sell` credited $3 (179→182) · `removeFind` true then **false on re-remove** · collection 0 |
|
||||
| sleep | `sleep()` → day 2 · **woke at DAWN (seg 0)** · weekNight 1 · localStorage payload keys exactly `[cash, collection, day, savedAt, schema, town]` — the delta law by shape, no world field |
|
||||
| rotation | parody shop vertex-data fp: day 1 `3396372961` → day 2 `1818111125` (**rotates**) · re-entry same day **byte-identical** · after reload **still identical** (cross-boot determinism) · `?game=0` fp **== day-1 fp** (the day-1 convention, measured) |
|
||||
| persistence | reload: day 2 · cash · collection 1 · weekNight 1 all restored (beforeunload save) · `export()` valid · `import()` of garbage + foreign schema **rejected loudly, state untouched**; real payload adopted + weekNight re-keyed |
|
||||
| corrupt save | pre-seeded bad blob → **1 loud reject**, raw stashed at `procity-save.rejected`, fresh start ($191, day 1), **town boots, 0 other errors** |
|
||||
| the crates | **REAL** (Monster Robot Party g:3962749): base `stock_godverse/3962749/`, fp **identical** day 1 → day 2 — real never rotates · **MINT** (Presents of Mind g:767): fp **rotates** — mint does |
|
||||
|
||||
`qa.sh --strict` **6 passed · 0 failed · 0 warn** over the landed layer — classic regression, default-boot
|
||||
gate, buy-v0, cover (paid + free), the R26 crate gate, R27 live gate, glance, no-giants: all green with the
|
||||
game layer default-ON.
|
||||
|
||||
**Two of F's own harness bugs caught by the run (recorded per house habit):**
|
||||
1. **The post-sleep CLOSED door.** First fp read after `sleep()` failed to enter the shop — because the
|
||||
town woke at DAWN and the shop was **correctly closed** (the hours law doing its job). The harness now
|
||||
sets midday before entering. A verify that forgets the world has rules will read the rules as bugs.
|
||||
2. **The fingerprint was vacuous for parody rooms.** It hashed mesh transforms + first UVs — but
|
||||
`batchRoom` merges parody stock into merged geometry at IDENTITY transform, so it hashed constants and
|
||||
reported "no rotation" over stock that rotates. (Mint showed through only because atlas UV rects land
|
||||
in the first 8 floats.) Fixed: hash the **vertex data** (position + uv arrays), where the day-salted
|
||||
picks/jitters actually live. The vacuous-measurement species, caught in F's own tool, same round it
|
||||
re-read the law to everyone else.
|
||||
|
||||
**Live cross-lane note (seen mid-session, not F's commit):** B picked up §30.4 within the hour —
|
||||
`venue.js` "tonight" is already a live getter off `PROCITY.gigs.weekNight`, and `hud.js` has the
|
||||
collection/SLEEP surface in flight. The contract-first order did exactly what it exists to do.
|
||||
|
||||
**Held / next session (per the brief):** ledger #5 gates (save/load determinism scripted session ·
|
||||
no-pump v0 · classic-purity localStorage gate · rotation determinism) — a LATER F session. The sell-card
|
||||
E-key routing (dig → sell → shelf priority, §9.4) rides the sell wiring pass alongside it.
|
||||
|
||||
### §30.6 THE GATES SESSION (wave 3) — the routing landed, five gates wired STRICT, all green
|
||||
|
||||
All measured on the house harness (:8130, fresh Playwright contexts per gate — no module cache to lie).
|
||||
Committed as five new STRICT smokes in `tools/flags_check.py` (`smoke_sell_routing` ·
|
||||
`smoke_save_determinism` · `smoke_nopump` · `smoke_classic_purity` · `smoke_rotation`), so every future
|
||||
`qa.sh` run re-proves them; the session-local instruments from §30.5 are now suite property.
|
||||
|
||||
**THE E-KEY SELL ROUTING (§9.4, the held join) — landed + verified live through the real UI:**
|
||||
`interior_mode.js` grew `trySell()` (C's `createSell`/`nearCounter`, lazily built, only when the game is
|
||||
on) and the E chain is now: **AIMED bin → dig · else near counter with ≥1 sellable → sell card · else
|
||||
forgiving nearest-bin → dig · else shelf-buy.** F does both §9.3 halves in `onSell`, in this order:
|
||||
`game.removeFind(item)` FIRST (false ⇒ keeper veto — no credit can exist for an item not in the
|
||||
collection), then `wallet.sell(item, offer)`. Shell seams (3 lines, `index.html`): `procity:sellOpen` →
|
||||
`player.unlock()`, and `sellActive` guards the Esc-leaves-shop + click-relock paths (the dig's exact
|
||||
contract). The full loop, measured through the real DOM: **dig pull $9 debited → entry
|
||||
{type:'record', sku|slotId, pricePaid:9} → E at the counter → C's card → SELL +$4 (< $9, the clamp) →
|
||||
collection 1→0 → back on the street B's HUD reads $186 · CRATE 0.** Negative legs: empty collection ⇒
|
||||
E opens NOTHING (open() false, zero DOM); `?game=0` ⇒ no game, no card, zero errors.
|
||||
|
||||
**One routing amendment, filed with the measurement (F's own file, F's own routing):** `binUnderAim`'s
|
||||
forgiving fallback picks the nearest bin within 2.5 m of the camera and **ignores aim** — at a counter
|
||||
that sits near a bin it would swallow every E and the sell card could never open there. The chain now
|
||||
asks aimed-raycast FIRST (the §9.4 contract's literal "aimed bin"), lets the sell trigger decline, and
|
||||
only then runs the forgiving pick. Both sides proven live: at Moe Vinyl's counter-adjacent bin the card
|
||||
now opens (`sell:true, dig:false` — the old chain opened the dig); by a bin away from the counter with
|
||||
a sellable held and aim wrong, the dig still opens (`dig:true, sell:false` — the fallback survives).
|
||||
|
||||
**THE GATES (ledger #5) — each with its subject named and touched:**
|
||||
|
||||
| gate | verdict + numbers |
|
||||
|---|---|
|
||||
| (a) save/load determinism | scripted session buy 2 · sell 1 · sleep 2× (via **B's real SLEEP button**) → day 3 · $177 · 1 item → export → FRESH browser → import: state **byte-equal** (243 B) + savedAt-normalized export **byte-equal** (269 B) · plan fp **unchanged** `0xb1c7843e` across export→import (the delta law's falsifiable form) · weekNight re-keyed to 2 == (3−1)%7 · 0 errors both contexts |
|
||||
| (b) no-pump v0 | 5 real round trips (dig UI pull+BUY → counter card SELL-all): **5/5 strictly negative, deltas [−6,−6,−6,−6,−6], Σ −$30** — identical deltas are the deterministic re-seeded crate, not a stuck instrument (the controls prove discrimination) |
|
||||
| (b′) C's negative control | broke player ($0 via import): dig BUY **disabled**, clicked anyway — zero debit, zero entry · shelf path: `wallet.buy` **INVOKED once (counted, not mocked), returned false, recordFind unreachable** — no entry, cash still $0 · positive arm: same aim with cash → debit $14 == entry.pricePaid, 1 entry — **C's pump is dead, and the control can tell** |
|
||||
| (c) classic purity | Storage **prototype** instrumented before any page script: `?classic=1` AND `?game=0` → **0 calls**, game null, **0 of B's 7 game DOM ids** · POSITIVE control: default boot → instrument saw the `procity-save` getItem + all 7 ids present (a zero nobody proved the instrument could see would be vacuous) · plan fp game-on **==** game-off `0xb1c7843e` (the layer moves zero plan bytes; classic's own fp differs by design — its golden is classic_regression's) |
|
||||
| (d) rotation determinism | **VERTEX-DATA fp (position+uv arrays), per the wave-3 binding** — transforms measured vacuous in §30.5: day 1 `0x50edd91b` (**88,320 bytes / 20 stock meshes** — subject present, printed) · re-entry byte-identical · day 2 `0x2121f187` ≠ day 1, re-entry identical · day 3 `0x2f4a9b93` ≠ both · **cross-boot: day-3 fp byte-identical after a full reboot** off the persisted save · `?game=0` fp **== day-1 fp** (the pre-v7 town IS day 1, measured again from the suite) |
|
||||
| (e) the suite | all five wired **STRICT** into flags_check (FAIL-level, house pattern) · `qa.sh --strict --matrix` full run: see §30.6 numbers below |
|
||||
|
||||
**Harness bug #3 of the round (recorded per house habit):** the routing gate first read B's HUD
|
||||
*inside* the shop and called it stale — but the HUD is **hidden and unrefreshed in interior mode by
|
||||
design** (`hud.setVisible(m === 'street')`; `gameUI.refresh()` rides the street loop). The gate now
|
||||
walks back onto the street and reads it where a player could — $186 · CRATE 0, correct. Same species
|
||||
as §30.5's closed-door: a verify that forgets the world's rules reads the rules as bugs.
|
||||
|
||||
**Design fact, filed not fixed (alpha-acceptable, for the beta ledger):** parody/mint dig stock has no
|
||||
`gone` tracking — the same $11 sleeve is back in the bin on re-entry (the crate is a seeded stream, not
|
||||
an inventory). Buying it again is possible **and loses money every time** (the −$6/trip measurement is
|
||||
exactly this); scarcity-per-day would need the save to carry per-day pull records. The no-pump clamp is
|
||||
what makes the infinite crate safe. Rule it in beta with the guide bands, not here.
|
||||
|
||||
---
|
||||
|
||||
## 31. Round 31 — the v7.0-alpha CLOSE: THE DIG FLIP (wave 1) + THE TAG (wave 2)
|
||||
|
||||
### §31.1 The alpha-blocker (Fable's wave-0 playtest) and the flip
|
||||
|
||||
**The dig was default-OFF on a plain boot.** `web/index.html` still carried the v2-era opt-in
|
||||
(`const DIG_ON = params.has('dig') && params.get('dig') !== '0'`) six epochs after the README's
|
||||
flags table began claiming "dig on" — and every R30 game-loop gate booted `dig=1` EXPLICITLY, so
|
||||
the whole suite was green while the boot every player gets had no crate-riffle at all: E at a bin
|
||||
did nothing. The gates tested the game; nobody tested that the default boot IS the game.
|
||||
|
||||
**The flip (one line + the law):** `DIG_ON = flagOn('dig')` — default-ON, `?dig=0` opts out,
|
||||
`?classic=1` master-off: the exact pattern the other flipped flags ride. `dig` joins the
|
||||
`PROCITY.flags` intent surface (the gates read intent there). Measured both ways in fresh
|
||||
contexts on a port-isolated server (:8951):
|
||||
|
||||
| boot | measured |
|
||||
|---|---|
|
||||
| NAKED (no params) | E at a bin → riffle **OPENED** + closed clean · `flags.dig` true · 0 errors |
|
||||
| `?dig=0` | opt-out honoured — riffle does NOT open, **ZERO** dig DOM |
|
||||
| `?classic=1` | dig INERT at the bin (E falls through `trySell` (null game) + shelf no-op) · ZERO `pcdg-*` DOM · **ZERO Storage-prototype calls** (instrumented) · 0 errors — the covenant untouched |
|
||||
| `?noassets=1` naked | dig opens on **parody canvas** · 0 errors (existing law holds) |
|
||||
|
||||
*(Observation filed, pre-existing, NOT touched: `assets/gen/*.jpg` skin textures fetch under
|
||||
`?noassets` — 22 fetches, fail-soft, present since v1; the dig added zero fetches to that boot.)*
|
||||
|
||||
### §31.2 The gate that makes the regression impossible (vacuous-gate law, turned on F's own suite)
|
||||
|
||||
Two new STRICT legs in `tools/flags_check.py`:
|
||||
|
||||
- **default_boot_gate** grows **dig REACHABILITY on the NAKED boot**: enter a record shop, E at an
|
||||
aimed bin, `digActive` must go true (and close clean via WALK OUT); plus a `PROCITY.flags.dig`
|
||||
intent assert. Un-flip the default again and this line goes red. Negative control: the identical
|
||||
measurement on a `?dig=0` boot reads `opened:false` (pre-flight, :8951) — the detector
|
||||
discriminates.
|
||||
- **classic_regression** grows the **dig-INERT leg**: the old `digActive` assert was vacuously
|
||||
false on the street; it is now measured AT THE BIN — E opens nothing, zero dig DOM, filed as
|
||||
"measured, not assumed".
|
||||
|
||||
**Harness lesson #4 of the epoch (house habit): the explicit-flag blindfold.** A gate that
|
||||
force-feeds its subject's flag (`dig=1` in every boot query, belt-and-braces) can never notice the
|
||||
default is broken. Where a flag is CLAIMED default-on, at least one gate must exercise it with the
|
||||
flag ABSENT. Now true for dig; gigs/weather/winmap/tram always had it (the default-boot gate boots
|
||||
naked by construction).
|
||||
|
||||
### §31.3 Port isolation (small, suite-wide)
|
||||
|
||||
`PROCITY_QA_PORT` env override added to `flags_check.py`, `qa/interior_scale_check.py`,
|
||||
`qa/town_matrix.py`. `ensure_server()` silently REUSES anything parked on :8130 — this round that
|
||||
was Fable's own preview server, and a shared server is somebody else's cache semantics + somebody
|
||||
else's lifecycle. The R31 re-gate rode `:8931`; default behaviour unchanged.
|
||||
|
||||
### §31.4 The splash (same file, same commit)
|
||||
|
||||
"Every door opens **(soon)**" — stale by six epochs — is gone: the tagline now sells the loop
|
||||
("Every door opens. Dig the crates, sell the flips, sleep — the town remembers.") and the controls
|
||||
line carries the game beats (E riffle a bin / sell at the counter · C your crate · SLEEP to save).
|
||||
README manual gains the matching row (sell at the counter · C crate · SLEEP = save/advance/rotate).
|
||||
|
||||
### §31.5 The re-gate, the shot, the tag — RESULTS
|
||||
|
||||
All on the FINAL tree (commit `50385c0`), suite port-isolated on `:8931` (`PROCITY_QA_PORT` —
|
||||
Fable's preview holds :8130, untouched):
|
||||
|
||||
- **`qa.sh --strict --matrix`: 7 passed · 0 failed · 0 warn · 0 skipped.** flags_check **0 fails /
|
||||
0 warns** — both new R31 legs green in the run (`dig REACHABLE on the naked boot — E at a bin
|
||||
opened the riffle (and closed clean)` · `?classic dig INERT at the bin — E opened nothing, zero
|
||||
dig DOM`), all five R30 STRICT gates re-proven over the flipped tree, classic covenant fingerprint
|
||||
green, no-giants green. **MATRIX GREEN — 10 towns (synthetic + fixtures + real caches) × 7 gates.**
|
||||
- **The money shot: `docs/shots/v7_alpha/crate_panel.png`** — Monster Robot Party's real frontage
|
||||
(redhill_godverse, `stock=real`), THE CRATE open with **3 real finds** (The Return $40 ·
|
||||
First Filtration Of The Duplex Brains $15 · Blue Blackness $20 — real cover thumbs off the
|
||||
per-shop atlas, every row honest: `paid $N · Monster Robot Party · day 1`), game bar
|
||||
**day 1 · $116 · CRATE 3 · SLEEP**. Driven through the REAL loop with the dig param ABSENT (the
|
||||
flip carries the shot): enter → E at three different bins → riffle → pull → BUY ×3 → street →
|
||||
C. 0 console errors. Script: session scratchpad (`crate_shot.py`) — the PNG is the deliverable.
|
||||
- **The tag: `v7.0-alpha`**, annotated, **LOCAL ONLY** on the notes commit — Fable reviews and
|
||||
pushes main + tag together (treaty).
|
||||
|
||||
### §31.6 THE BETA QUEUE (Fable's playtest, filed NOT fixed — John's ruling queue)
|
||||
|
||||
1. **Spawn faces dirt/building-backs** instead of the strip — wants a cluster-pose for spawns.
|
||||
2. **THE CRATE is unreachable inside shops** — C is street-only by design (hud.js); a player wants
|
||||
it at the counter.
|
||||
3. **Dawn sky reads dusk-orange** (cosmetic).
|
||||
4. The pull card's **value hint is THE BIBLE** (chartered — the beta's guide bands).
|
||||
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.
|
||||
|
||||
82
docs/LANES/ROUND29_INSTRUCTIONS.md
Normal file
@ -0,0 +1,82 @@
|
||||
# PROCITY — Round 29 lane instructions (from Fable, integrator)
|
||||
|
||||
Date: 2026-07-17 · **v5.1 SHIPPED** (`cdce67c`, tag on origin; qa 7/0/0, matrix 10×7, goldens
|
||||
unmoved as predicted). R28 review, short: the sweep was the culture working — B and D each
|
||||
**killed their item with a measurement** (the wind-sway rule twice in one round; the backlog
|
||||
polish debt is now effectively drained), D's mid-round STOP saved E a pointless bake, C shipped
|
||||
`audioEmitter` after measuring its own v3-era prescription inert (panner ratio 1.0000 —
|
||||
bit-identical door vs stage), E shipped `look.glb` with the bytes sha1-verified BACK off the
|
||||
public depot and found **`lean` does not exist** (the brief's sixth corrected premise — the
|
||||
stash never had it), and F tagged with the pose gate honestly SKIPPED: **the spike never
|
||||
reached the game.** Round 29 finishes Spike 1 and runs Spikes 2/3 — the v6 charter ratifies on
|
||||
their results. Most of this round is GATED ON JOHN (below); wave 0 runs today.
|
||||
|
||||
## WAVE 0 — finish Spike 1 (runnable NOW, no dependencies)
|
||||
|
||||
**D:** bind and wire `look.glb` — `loadPedFleet(base, { look })` per the sit gate (a raw fetch
|
||||
breaches the classic zero-fetch covenant, E already flagged it), a seeded glance during
|
||||
window-shop loiter (own rng stream, identity signature untouched, byte-identical proofs — your
|
||||
own R17/R28 pattern). **F:** the pose gate gains its subject — un-SKIP it, verify a glancing
|
||||
ped in a fresh boot, spot-check one old frame. No tag for this alone; it rides Spike 2's close.
|
||||
|
||||
## THE GATE ON THIS ROUND — John's two unblockers (everything below waits)
|
||||
|
||||
1. **Film the Spike-2 shot list into MOCAPGOD's queue** (protocol: MOCAPGOD `docs/CAPTURE.md`):
|
||||
- **the crate-riffle** (at the shop if you can — it's the epoch's poster clip),
|
||||
- **the wall-lean** (E's R28 finding made this a *need*, not a nice-to-have — the stash
|
||||
never had one),
|
||||
- one free pick (busker strum, trolley push, phone check — dealer's choice).
|
||||
2. **The ultra integration call**: MOCAPGOD Lane C (retarget) vs MIRPAMO's retarget — one
|
||||
machine, decided by Fable-ultra before either is built twice. Nudge that session.
|
||||
|
||||
## SPIKE 2 — the full mocap loop (when the queue has vids)
|
||||
|
||||
Runs mostly on ultra (MOCAPGOD C/D lanes + the integration call), lands on m3 as clips:
|
||||
- **E (m3):** receive the retargeted clips, convert/validate to the house clip form (the
|
||||
look.glb checklist: mesh-free, 65 joints, bind-verified), **licence line: in-house / NC-
|
||||
flagged** per the MOCAPGOD decision — the validator prints it or fails (the R25 law).
|
||||
- **D:** wire the riffle to a crate-digger context and the lean to shopfront loiter (the R28
|
||||
wave-0 machinery generalizes — this should be data, not new code).
|
||||
- **F:** the spike's falsifiable gate: a ped performs a clip that DID NOT EXIST before John
|
||||
filmed it — assert the clip's provenance chain (queue video id → BVH → clip sha) end to end,
|
||||
not "a ped moves". Then tag **`v6.0-spike`** (a checkpoint tag, not an epoch tag) with the
|
||||
spike report.
|
||||
|
||||
## SPIKE 3 — a generated body (parallel to Spike 2, ultra-heavy)
|
||||
|
||||
flux_local concept → hunyuan3d_mlx mesh → **MIRPAMO autorig** → walk retarget → **in-game
|
||||
through the no-giants gate**. Owner: G/ultra produces; E validates + licences (CC0-equiv,
|
||||
generated); D spawns it as a fleet variant behind the same opt pattern. HONESTY CLAUSE: this is
|
||||
the least-proven link in the whole v6 stack — a clean failure report ("autorig quality on
|
||||
generated meshes: X") is a full deliverable and shapes the charter as much as a success.
|
||||
|
||||
## After the spikes — the ratification
|
||||
|
||||
Fable amends `V6_LIVING_STREET.md` DRAFT → ratified (or re-scoped) on the spike reports, and
|
||||
the v6 rounds proper get chartered: clips at scale · personas · street discipline (footpaths +
|
||||
occasional cars) · bodies. **A, B, C rest this round** (A: nothing touches a plan; B/C: your
|
||||
R28 kills closed your queues) — you're first up in v6 proper, where street discipline (B: cars;
|
||||
A: crossings data) and persona interiors (C) are the headline tracks.
|
||||
|
||||
## Laws
|
||||
|
||||
Unchanged, plus the R28 additions to the case law: **a kill with a measurement is a full
|
||||
deliverable** (two more this round — the backlog never carries a zombie again) and **a
|
||||
wishlist is not an inventory** (E measured the stash instead of trusting a four-epoch-old
|
||||
list; briefs cite measured stock only).
|
||||
|
||||
## THE EXACT RUN ORDER
|
||||
|
||||
0. **Now: D + F (wave 0, Spike 1 closes).**
|
||||
1. **John: film the vids + nudge ultra (the gate).**
|
||||
2. **When clips flow: E → D → F (Spike 2), G/ultra → E → D (Spike 3, parallel).**
|
||||
3. **F: the spike gate + `v6.0-spike` checkpoint tag.**
|
||||
4. **Fable: charter ratification → v6 rounds proper.**
|
||||
|
||||
## Standing
|
||||
|
||||
- **John — the two unblockers above are the whole critical path.** Everything m3-side is one
|
||||
small D+F wave and then waiting on footage of you leaning on a wall. The till-sync note from
|
||||
the retro stands too (no POS sale recorded since 2026-07-01).
|
||||
- **Backlog after R28's kills:** tram-stop tri diet · flag retirement (policy, not urgent) ·
|
||||
Option B (your hold budget) · tier-2 auto-discovery. Thinner than it has ever been.
|
||||
120
docs/LANES/ROUND30_INSTRUCTIONS.md
Normal file
@ -0,0 +1,120 @@
|
||||
# PROCITY — Round 30 lane instructions (from Fable, integrator)
|
||||
|
||||
Date: 2026-07-17 · **V7 THE GAME is RATIFIED** ([docs/V7_THE_GAME.md](../V7_THE_GAME.md) —
|
||||
read it first, it is the epoch's truth). Round 30 opens **v7.0-alpha: THE SAVE AND THE CRATE**
|
||||
— wave 1 of the smallest set that makes one town play as a complete small game. *(Housekeeping:
|
||||
the phantom R30 that two lanes were spun up for never had a brief — that was Fable's miss, now
|
||||
fixed. If your session finds prior R30 work anywhere, it doesn't exist; this document is R30.)*
|
||||
|
||||
## The ledger (v7.0-alpha wave 1)
|
||||
|
||||
1. **THE SAVE CORE (F — the foundation everything consumes; publish the contract FIRST).**
|
||||
- `web/js/world/save.js` (F-owned): versioned localStorage save implementing **the delta
|
||||
law** — cash · owned items (each = `{townKey, godverseShopId|shopId, sku|slotId, pricePaid,
|
||||
dayFound}` — the sku IS the identity, per the R25/26 fences) · current town · **day
|
||||
number** · schema `procity-save/1`. Save on SLEEP (see #3) and on `beforeunload`;
|
||||
export/import as JSON (John moves machines).
|
||||
- **The published contract** (`window.PROCITY.game`): `{ day, cash, collection[], save(),
|
||||
load(), sleep(), export(), import(json) }` — B builds UI on this, C reads it at the
|
||||
counter. Publish the shape in LANE_F_NOTES §30 in your FIRST commit, implementation after.
|
||||
- **Laws wired from birth:** `?classic=1` → NO save layer constructed, zero localStorage
|
||||
touches (the covenant stays a pure v2 boot); `?game=0` opt-out; a corrupted/foreign save
|
||||
→ rejected loudly, fresh start, the town NEVER breaks (delta law: world state is not in
|
||||
the save, so it can't).
|
||||
2. **THE SELL COUNTER CONTRACT (C — publish first, implement second).** Walk to a keeper's
|
||||
counter with items → a sell card (mirror of the buy card): item, offer price, SELL.
|
||||
**Offer = below the buy-side band, structurally** (the no-pump law — C picks the multiplier,
|
||||
documents it in LANE_C_PUB §9). v7.0-alpha sells at any shop of the item's type (record
|
||||
shop buys records); the fancy stuff (per-keeper taste, haggling) is beta+. C consumes
|
||||
`PROCITY.game.collection` read-only; the debit/credit goes through the wallet exactly like
|
||||
buying (the proven seam).
|
||||
3. **SLEEP = SAVE = TOMORROW (F + B).** v7.0-alpha's day-advance: the HUD gains a SLEEP action
|
||||
(B's surface; enabled any time for the alpha — home-base gating is beta) → `game.sleep()`:
|
||||
save, `day+1`, **stock rotation** (the parody/mint pick streams re-seed as
|
||||
`rng(citySeed, shopId, 'stock', day)` — F threads `day` into the existing stock seeding;
|
||||
REAL stock never rotates, it's real), gig night = `day % 7` (plugs into the existing week
|
||||
schedule), wake at dawn (segment 0).
|
||||
4. **THE COLLECTION UI (B — after F's contract lands).** A collection panel in hud.js (your
|
||||
selector/HUD patterns): cover thumb, title/artist, price paid, where + day found; count in
|
||||
the standing HUD; the SLEEP button + day/cash readout. Money-shot-grade is beta; readable
|
||||
and correct is alpha.
|
||||
5. **THE GATES (F, last).**
|
||||
- **Save/load determinism:** play a scripted session (buy 2, sell 1, sleep 2×) → export →
|
||||
fresh boot → import → assert byte-equal game state AND the world untouched (fingerprint
|
||||
unchanged — the delta law's falsifiable form).
|
||||
- **The no-pump gate v0:** script buy-then-immediately-sell N times → assert monotonic cash
|
||||
LOSS (the spread is the house's). The full adversarial playtest-bot is beta.
|
||||
- **Classic purity:** `?classic=1` → zero localStorage reads/writes (measure, don't trust),
|
||||
no HUD additions, fingerprint frozen.
|
||||
- **Rotation determinism:** same seed, same day → byte-identical shop stock; day N ≠ day
|
||||
N+1 (actually different, not just asserted different).
|
||||
- `qa.sh --strict --matrix` green throughout. **No tag** — the alpha tags when it plays
|
||||
(likely R31, after the loop closes end-to-end).
|
||||
6. **A — the rotation boundary verify (small).** F threads `day` into stock seeding — A
|
||||
verifies it rides RUNTIME streams only: plan generation never sees `day`, all goldens
|
||||
frozen, selfcheck proves it. One note, one line. **D, E, G — rest** (E's guide-band bake
|
||||
and G's GodBay open in beta; D's rumors likewise. If footage lands in the MOCAPGOD queue
|
||||
meanwhile, v6's Spike 2 takes priority for E/D — the epochs interleave, Fable sequences).
|
||||
|
||||
## Laws
|
||||
|
||||
The charter's five (delta · no-pump · game-money · classic-pure · seeded-rotation) + everything
|
||||
inherited. No goldens move this round (rotation is runtime; A verifies the boundary). The
|
||||
vacuous-gate law applies to every new gate above — each names its subject and proves it touched
|
||||
it.
|
||||
|
||||
## THE EXACT RUN ORDER
|
||||
|
||||
1. **Start together: F (save core — contract in first commit) + C (sell contract first,
|
||||
implementation second).**
|
||||
2. **When F's contract is published: B (collection UI + SLEEP surface).** F continues
|
||||
(sleep/rotation threading) in parallel — disjoint files.
|
||||
3. **When F + B + C are in: F gates (#5), A verifies the boundary (#6).**
|
||||
4. Fable reviews; R31 charters the rest of the alpha (or its close, if it plays).
|
||||
|
||||
Waves: **[F, C] → [B, F-cont] → [F-gate, A]**. D/E/G rest unless footage arrives.
|
||||
|
||||
## WAVE-1/2 REVIEW NOTES (Fable, mid-round — the agents' reports are the record)
|
||||
|
||||
**RATIFIED — F's two amendments, both measured:** (1) gig night = **(day − 1) % 7** — the
|
||||
brief's literal `day % 7` flips a fresh boot (day 1) off night 0 and breaks the R13 cover/band
|
||||
gates before the first sleep; the brief was wrong, the measurement wins. (2) **sleep saves
|
||||
AFTER incrementing** — disk always carries the morning you woke into.
|
||||
|
||||
**ADJUDICATED — the cross-lane 6 lines (F edited C's `interiors.js`/`layout.js` for the
|
||||
`stockDay` salt):** SANCTIONED, this instance — because all three conditions held: the owner
|
||||
reviewed and ratified on the record BEFORE the commit ("rides my own sub-stream discipline,
|
||||
absent ⇒ byte-identical"), the edit is marked and cited in the commit, and it is minimal.
|
||||
**Without all three, a cross-lane edit gets reverted on sight.** The law stands: write the
|
||||
ask, not the edit — C folds the seam into LANE_C_PUB at its next session; carried to the
|
||||
epoch retro as a process note.
|
||||
|
||||
**WAVE-3 INPUTS (bind these into the gates session):** the rotation gate must hash **vertex
|
||||
data, not transforms** (F measured its transform fingerprint vacuous over batched parody
|
||||
stock); C's money-pump **negative control** (an item minted into the collection without a
|
||||
successful debit must be impossible — assert `recordFind` only fires on `wallet.buy` truthy);
|
||||
the **E-key sell routing** (§9.4 dig → sell → shelf) is F's held item and lands in the wave-3
|
||||
session BEFORE the gates run against it.
|
||||
|
||||
## ROUND CLOSED (Fable, at the wave-3 review) — **v7.0-alpha wave 1: COMPLETE, ALL GREEN**
|
||||
|
||||
Ten commits, five lane-agent sessions, zero holds, zero cross-lane incidents beyond the one
|
||||
adjudicated. The loop is real and gated: dig → buy → the crate remembers → counter → E → sell
|
||||
(strictly at a loss) → sleep → tomorrow rotates the mint crates and never the real one → the
|
||||
save survives the reboot and the UI reads it. `qa.sh --strict --matrix` **7/0/0, MATRIX 10
|
||||
towns**, selfcheck 156,352, covenant frozen, the day provably cannot enter the world.
|
||||
|
||||
**Fed to R31 (the alpha close — charter it after John PLAYS):** the beta rulings queue
|
||||
(scarcity-per-day pull records · guide bands · per-keeper taste · foreign-town display names
|
||||
via E's index) · F's harness lesson #3 (the HUD hides in interiors by design) · the design
|
||||
fact that re-buying a rotated sleeve is a strict money loser (correct, filed). **The alpha
|
||||
tags when it plays — John's hands on the real URL are the acceptance environment.**
|
||||
|
||||
## Standing
|
||||
|
||||
- **John — nothing blocks this round.** The deploy to digalot.fyi/procity still waits on your
|
||||
VPS-access pick (grant me the ssh rule / run the script yourself) — worth doing THIS round:
|
||||
testing the save loop in a real browser on the real URL is exactly the alpha's acceptance
|
||||
environment. And the MOCAPGOD queue still wants your footage whenever.
|
||||
- **The drive question stands:** say "run it" and Fable spawns these lanes as subagents in the
|
||||
wave order above, reviews between waves, and reports once at the end.
|
||||
53
docs/LANES/ROUND31_INSTRUCTIONS.md
Normal file
@ -0,0 +1,53 @@
|
||||
# PROCITY — Round 31 instructions (from Fable, integrator)
|
||||
|
||||
Date: 2026-07-18 · **R30 closed all green** (7/0/0, matrix 10 towns, the loop live and gated).
|
||||
Round 31 is **the v7.0-alpha CLOSE**: play it, fix the friction, tag it. Charter stage check
|
||||
([V7_THE_GAME.md](../V7_THE_GAME.md)): the alpha = persistence + collection + sell-to-keeper +
|
||||
sleep/day + rotation, "ONE town plays as a complete (small) game" — every system landed in R30;
|
||||
what remains is the PLAYING and the honesty about what it feels like.
|
||||
|
||||
## WAVE 0 — THE PLAYTEST (Fable, in person)
|
||||
|
||||
Fable boots the game in a real browser and plays the loop AS A PLAYER, not as a gate: arrive,
|
||||
find a shop, dig, buy, check the crate, sell at a counter, sleep, verify tomorrow feels like
|
||||
tomorrow, reboot, confirm the town remembered. Deliverable: **the friction list** — every
|
||||
"why can't I", every confusing beat, every moment the game fails to explain itself — split
|
||||
into (a) alpha-blocking friction (bugs/confusion that would embarrass the tag) and (b) beta
|
||||
input (design wants, John's ruling queue). Honest numbers where they apply. *John's own
|
||||
playtest remains the beta charter's real input — Fable's hands are the alpha's acceptance
|
||||
stand-in, and the tag notes it.*
|
||||
|
||||
## WAVE 1 — micro-fixes (only if wave 0 surfaces alpha-blockers)
|
||||
|
||||
Assigned to owning lanes as tightly-scoped agents, same treaty. Nothing speculative — only
|
||||
what the playtest actually hit.
|
||||
|
||||
## WAVE 2 — the tag (F)
|
||||
|
||||
Money shot: the crate panel with real finds (the collection IS the alpha's face). Fresh
|
||||
`qa.sh --strict --matrix` on the final tree. Tag **`v7.0-alpha`** (annotated, the playtest
|
||||
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
|
||||
and Fable ships digalot.fyi/procity same hour). Your friction list charters the beta. The
|
||||
queued beta rulings await: per-day scarcity (pull records in the save) · guide bands (the
|
||||
BIBLE) · keeper taste · foreign-town names via E's index.
|
||||
- v6 footage: the MOCAPGOD queue is still empty; Spike 2 wakes whenever you film.
|
||||
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).
|
||||
116
docs/THE_FRONTIER.md
Normal file
@ -0,0 +1,116 @@
|
||||
# PROCITY — THE FRONTIER (what is genuinely not figured out)
|
||||
|
||||
*Fable (m3), 2026-07-17. John's challenge, verbatim: "there is heaps more to figure out — all
|
||||
the systems are set you say?" He's right. This doc is the honest map: what 29 rounds built is
|
||||
the WORLD and the PIPELine; the GAME — the reason a stranger would play for an hour — is
|
||||
mostly unfigured. Nothing here is chartered; it's the space the next epochs get picked from.
|
||||
Sibling docs: V2_IDEAS (the old parking lot), V6_LIVING_STREET (chartered, in flight).*
|
||||
|
||||
## What is actually solid (don't re-litigate)
|
||||
|
||||
Procedural + real towns · enterable interiors · citizens with identity · gigs · weather/tram ·
|
||||
the dig with real/mint/parody stock · the tier ladder + offline law · the two-studio pipeline ·
|
||||
the QA culture. The platform is genuinely done-ish. Everything below stands ON it.
|
||||
|
||||
## THE BIG ONE — it isn't a game yet
|
||||
|
||||
You walk, you dig, you buy. Then you close the tab and it never happened. The whole loop that
|
||||
makes digging MEAN something is unbuilt:
|
||||
|
||||
1. **Persistence.** No saves. The wallet resets, the collection vanishes, "gone this session"
|
||||
is the only memory. (localStorage fits the seeded-everything law trivially — the world
|
||||
regenerates from seed; only the DELTA needs saving: cash, owned items, day. This is days of
|
||||
work, not an epoch, and it unlocks everything below.)
|
||||
2. **The collection.** You buy records and they go... nowhere. No crate at home, no album/
|
||||
shelf UI, no "look what I found in Katoomba." The collection IS the trophy case of the
|
||||
whole game and it does not exist.
|
||||
3. **The thrill loop.** thriftgod's `mint()` gem bands exist as DATA (price bands, gems,
|
||||
CBD-distance curation) and drive NOTHING. No underpriced gem in a dusty crate, no "is this
|
||||
worth more than they're asking", no sell/flip loop, no wantlist hunting across towns. The
|
||||
entire reason op-shopping is exciting in real life is un-simulated.
|
||||
4. **Why today matters.** No day structure — no opening hours pressure (hours exist! nothing
|
||||
makes you race them), no end-of-day, no "the gig starts at 9, the shops shut at 5, choose."
|
||||
The clock is scenery.
|
||||
5. **The player.** No hands, no home, no inventory UI beyond a number, no character. Who are
|
||||
you in this town?
|
||||
6. **Onboarding.** A stranger at digalot.fyi/procity gets pointer-lock and silence. The first
|
||||
five minutes are unfigured entirely.
|
||||
|
||||
## Chartered but unbuilt (in flight or waiting)
|
||||
|
||||
- **v6 LIVING STREET** — spikes gated on footage; then clips-at-scale, personas, footpaths +
|
||||
occasional cars, generated bodies.
|
||||
- **Option B** — real reservation holds (John's hold budget).
|
||||
- **Tier-2 auto-discovery** — needs a reachability contract.
|
||||
- **The editor/override layer** (V2_IDEAS, still the best long-game idea: hand-author any shop
|
||||
interior over the procedural base).
|
||||
- **Multiplayer presence** (see who's digging the same town).
|
||||
|
||||
## Unowned design questions (nobody has even sketched these)
|
||||
|
||||
- **What is a session?** 20 minutes? A day-cycle? A town per sitting?
|
||||
- **The economy loop:** buy low → ??? → profit. Sell where? To whom? GodBay (thriftgod's
|
||||
auction sim) is sitting right there as the obvious answer and no design connects them.
|
||||
- **Cross-town play:** 23 towns exist; nothing makes you travel. Regional wantlists? A gem
|
||||
rumor system? The tram/train BETWEEN towns?
|
||||
- **The gig layer as gameplay:** you can attend a gig; you can't book one, play one, or care
|
||||
about one. 90sDJsim overlap sits unexplored (DJ the venue?).
|
||||
- **Music identity:** procedural beds exist; a game this specific deserves a sound.
|
||||
- **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).
|
||||
3. **Wear & grime — cheap, high-charm (E/C canvas painters).** Seeded value-noise for sleeve
|
||||
ring-wear, facade sun-fade, footpath staining — per-item, deterministic, and exactly the
|
||||
90s patina the parody sleeves deserve. Pairs with the beta's price-guide work (a worn
|
||||
sleeve *reading* worn next to its condition grade).
|
||||
4. **Crowd ebb (LIVING STREET track, D).** A slow 1D noise curve modulating street density
|
||||
across the day — mornings trickle, arvo swells — richer than uniform draws, one stream,
|
||||
identity-safe.
|
||||
|
||||
5. **THE VERNACULAR KIT (filed 2026-07-18, John's idea, source corrected) — buildings that
|
||||
feel real.** The town's massing is same-y boxes; the fix is **15–20 parametric Aussie
|
||||
vernacular archetypes** (Queenslander, Federation cottage, terrace row, weatherboard
|
||||
shopfront + verandah, deco corner pub, brick milk bar, fibro shack, tin warehouse…) with
|
||||
seeded knobs — width, storeys, verandah depth, roof pitch, parapet, tint, and the wear
|
||||
noise from item #3 above. **Source ruling: NOT Google Maps 3D** (licence-fatal — the one
|
||||
thing the provenance spine must never touch — and photogrammetry mush at house scale
|
||||
anyway). Instead: (a) **OSM/Overture building footprints + heights** for the real towns —
|
||||
ODbL, the licence and pipeline we already ship, one more fetch class under the standing
|
||||
authorization; (b) the kit designed from Street View *reference* (looking is not
|
||||
extracting) and built parametrically on B's existing instanced-attribute machinery, which
|
||||
already varies tint/hours per instance. Real footprints place real shapes on real streets;
|
||||
the kit + knobs cover everything unfootprinted. Pairs with terrain as the two halves of a
|
||||
future "the streetscape gets real" epoch — but unlike terrain, the kit is INCREMENTAL
|
||||
(archetype by archetype, budget-measured, classic fenced), so it could also drip in as
|
||||
B/E polish rounds. Goldens untouched either way (plan hashes don't see meshes); budgets
|
||||
re-measured per archetype.
|
||||
|
||||
## The honest sequencing opinion (Fable's, not a ruling)
|
||||
|
||||
The private-test deploy is the design instrument: John walks the game cold and writes down
|
||||
every "why can't I..." — that list IS the v7 charter input. My candidate for v7 once v6's
|
||||
spikes land: **THE GAME (persistence + collection + the thrill loop + a day that matters)** —
|
||||
it's the smallest set that turns the platform into something a stranger plays twice.
|
||||
@ -33,10 +33,12 @@ for Fable-ultra before either builds it twice.
|
||||
|
||||
The pipeline test John asked for, as three end-to-end spikes. No epoch work until these run.
|
||||
|
||||
1. **SPIKE 1 — the free stash (motion, zero generation).** Wire 2 of the 34 cached Mixamo
|
||||
clips (`lean`, `look-around`) into PROCITY loiter via the exact sit.glb pattern (E converts,
|
||||
D wires, classic-gated fetch). *Proves:* the clip bank expands cheaply; D's four-epoch-old
|
||||
wishlist starts clearing. Risk: ~zero.
|
||||
1. **SPIKE 1 — the free stash (motion, zero generation).** ~~Wire 2 of the 34 cached clips
|
||||
(`lean`, `look-around`)~~ **AMENDED R28: `lean` does not exist in the stash** — E measured
|
||||
the 34 instead of trusting the wishlist (the brief's premise was half wrong). `look.glb` is
|
||||
shipped + depot-verified (R28); D's wiring closes in R29 wave 0. The lean moves to Spike
|
||||
2's shot list — John films a wall-lean. *Proves:* the clip bank expands cheaply, and
|
||||
wishlists are not inventories. Risk: ~zero (confirmed).
|
||||
2. **SPIKE 2 — the full mocap loop (the one that matters).** John films ONE move on a phone —
|
||||
**the crate-riffle, at Monster Robot** (the exact clip D's notes flagged "bespoke, won't be
|
||||
on Mixamo" two epochs ago) → MOCAPGOD ingest → BVH → retarget (MOCAPGOD-C or MIRPAMO —
|
||||
|
||||
106
docs/V7_THE_GAME.md
Normal file
@ -0,0 +1,106 @@
|
||||
# PROCITY v7 — THE GAME (epoch charter — **RATIFIED by John, 2026-07-17**)
|
||||
|
||||
*(Ratified as drafted; the fantasy stands. R30 opens the epoch — v7.0-alpha wave 1.)*
|
||||
|
||||
*Fable (m3), 2026-07-17. From THE_FRONTIER's headline gap: the world is built, the game isn't.
|
||||
v7 is the smallest set of systems that makes a stranger play twice. Design principle: every
|
||||
system below rides machinery that ALREADY EXISTS — nothing here invents a new world layer.*
|
||||
|
||||
## The fantasy, one line
|
||||
|
||||
**You're a crate-digger in 90s Australia: build the collection, learn the prices, work the
|
||||
towns — and be home in time for the gig.**
|
||||
|
||||
## The loop
|
||||
|
||||
Wake (save point) → plan the day (shops shut per their real hours; the gig starts at night —
|
||||
the clock finally matters) → dig shops, spot the gems (priced below the guide), spend scarce
|
||||
cash → sell the flips (keeper today; the auction tomorrow) → sleep = save, the day advances,
|
||||
stock rotates → the collection grows → the wantlist points at another town → travel costs a
|
||||
day. Repeat, richer and one gem wiser.
|
||||
|
||||
## The systems (each with its existing foundation)
|
||||
|
||||
1. **PERSISTENCE — the foundation stone.** Save = THE DELTA ONLY: cash, owned items, town, day
|
||||
number, wantlist, stats. The world is never saved — it regenerates from seed (the
|
||||
seeded-everything law finally pays its biggest dividend: saves are tiny and can't corrupt
|
||||
the world). localStorage, schema-versioned, JSON export/import for moving machines.
|
||||
*Foundation: item identity is already stable — parody items are seeded, real/mint carry
|
||||
skus (the R25/R26 sku fences were built for exactly this, before we knew it).*
|
||||
2. **THE COLLECTION.** Your crate, as a UI: cover, title, artist, price paid, where and what
|
||||
day you found it. The trophy case of the whole game. (A walkable home-room shelf is v7.x —
|
||||
UI first.) *Foundation: the atlas indexes already carry every field the card needs.*
|
||||
3. **THE GUIDE + THE GEM (the thrill loop).** An in-game price guide — thriftgod's BIBLE
|
||||
concept — gives a VALUE BAND per item (imperfect, like the real 90s guide books). Shops
|
||||
price with seeded variance around it → a **gem** is an item priced under its band. Skill =
|
||||
spotting them. *Foundation: mint() gem/price bands exist as data and drive nothing; real
|
||||
items have real dealgod prices; the dig card already shows asking price.*
|
||||
4. **SELLING — where cash comes back.** v7.0-alpha: sell to a keeper at the counter (below
|
||||
guide, always — the no-money-pump law). v7.0: **GodBay** — list an item, a SEEDED offline
|
||||
auction resolves NEXT DAY (the come-back-tomorrow hook; thriftgod's auction brain, G's
|
||||
lane, offline tier first per the proven tier-ladder pattern — a live tier is v7.x and
|
||||
rides the G3 rails).
|
||||
5. **THE DAY.** Sleep advances the day; **stock rotates per (shop, day)** — seeded, so day 3
|
||||
in Bendigo is the same for every player on that seed (parody/mint rotate; the real shop's
|
||||
real stock stays real); gigs already run a 7-night week — `day % 7` plugs straight in;
|
||||
hours already exist — now closing time costs you something. *Foundation: the segment spine,
|
||||
`isOpen`, and `plan.gigs` are all sitting there waiting.*
|
||||
6. **THE WANTLIST + TRAVEL.** Mark items/artists you're hunting; seeded rumors surface hints
|
||||
("saw one in Castlemaine" — a keeper line, a gig-crowd mutter: D's citizens finally gossip
|
||||
for a reason); **travel costs a day** → 23 towns become a strategy map instead of a
|
||||
dropdown. *Foundation: the selector, the pack, the personas.*
|
||||
7. **THE FIRST FIVE MINUTES.** You arrive with $50 and a want. Three toast-beats: dig, buy,
|
||||
sell. A real splash. *(Smallest system, biggest stranger-facing payoff.)*
|
||||
|
||||
## The stages
|
||||
|
||||
- **v7.0-alpha — THE SAVE AND THE CRATE:** persistence + collection UI + sell-to-keeper +
|
||||
sleep/day cycle + stock rotation. ONE town plays as a complete (small) game.
|
||||
- **v7.0-beta — THE THRILL:** the guide bands + gem variance + wantlist + rumors + travel
|
||||
costs a day. The 23 towns become the map.
|
||||
- **v7.0 — THE AUCTION:** GodBay offline tier, onboarding, the tour ("day one to first gem"
|
||||
as the money shot), docs freeze, tag.
|
||||
|
||||
## The laws (new ones this epoch earns)
|
||||
|
||||
1. **The delta law:** a save NEVER contains world state — only player deltas. A corrupted
|
||||
save can cost you your stuff, never the town.
|
||||
2. **The no-pump law:** every sell price < the buy-side guide band, structurally. The economy
|
||||
gets a GATE (an agent plays N seeded days and asserts net-worth growth is bounded by finds,
|
||||
not arbitrage loops).
|
||||
3. **Game money is game money:** the write-back law extends — no bridge to real money, ever,
|
||||
in either direction.
|
||||
4. **Classic stays pure:** `?classic=1` boots the v2 town with NO game layer (no save, no
|
||||
HUD additions) — the covenant is untouched. The game layer defaults ON for normal boots
|
||||
(the v3.1 flip precedent) with `?game=0` as the opt-out.
|
||||
5. **Rotation is seeded:** stock rotation draws from (citySeed, shopId, day) — deterministic,
|
||||
gate-checkable, same for everyone on a seed.
|
||||
|
||||
## Lane map (first cut)
|
||||
|
||||
**F** the save core + gates (delta law, no-pump gate, save/load determinism) · **B** the UI
|
||||
surfaces (collection, wantlist, day clock, splash — hud.js territory) · **C** the counter
|
||||
sell interaction + dig-card guide hints (+ the home room, v7.x) · **A** the day/rotation
|
||||
seeding contracts (new sub-streams, goldens untouched — rotation rides runtime, not plan
|
||||
gen... A verifies that boundary) · **E** guide-band data baked into packs/atlases (mint
|
||||
bands, real prices) · **D** rumor lines through keepers/citizens · **G** GodBay (the auction
|
||||
brain, offline seeded tier) + real guide prices from dealgod.
|
||||
|
||||
## Risks (named now, v4-style)
|
||||
|
||||
1. **Economy balance** — pumps, dumps, degenerate flips. The no-pump gate is the fence; a
|
||||
playtest-bot (an agent that greedily arbitrages for N days) is the adversarial verifier.
|
||||
2. **Save schema drift** — versioned from day one, migration-or-reject, never silent.
|
||||
3. **Item identity across re-emits** — a re-baked atlas must not orphan owned items (the sku
|
||||
IS the identity; E's validator grows an owned-item stability check).
|
||||
4. **Scope gravity** — the game wants everything (housing! romance! a phone!). The stage
|
||||
gates are the fence: alpha ships ONE town playing well before beta touches 23.
|
||||
5. **v6 collision — none by design:** v7-alpha is systems/UI (F/B/C-heavy); v6's spikes are
|
||||
clips/ultra (E/D/G-heavy). They interleave; Fable sequences the rounds.
|
||||
|
||||
## Sequencing
|
||||
|
||||
v7.0-alpha can start **now** — it's unblocked (v6 waits on footage; the two don't collide).
|
||||
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.
|
||||
BIN
docs/shots/v7_alpha/crate_panel.png
Normal file
|
After Width: | Height: | Size: 894 KiB |
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.
|
||||
42
tools/deploy_digalot_procity.sh
Executable file
@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# PROCITY → digalot.fyi/procity — PRIVATE TEST deploy (Fable, R29-era).
|
||||
# John's ruling: private test only, no payments, NO SEO/indexing. The licence flag
|
||||
# (docs/V5_REAL_SHOP.md law #3) stays ARMED for any real public release.
|
||||
#
|
||||
# Usage: tools/deploy_digalot_procity.sh # rsync + install nginx block + verify
|
||||
# tools/deploy_digalot_procity.sh --dry # show what would ship
|
||||
#
|
||||
# Ship-check status (run 2026-07-17): static files only — no endpoints, no auth
|
||||
# surface, no money paths. Secrets sweep clean (no .env/.sql; grep hits were stock
|
||||
# titles). _raw/ Overpass snapshots excluded (pipeline provenance, not runtime).
|
||||
set -euo pipefail
|
||||
|
||||
VPS="root@100.94.195.115"
|
||||
DEST="/opt/procity/web/"
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DRY=""; [ "${1:-}" = "--dry" ] && DRY="--dry-run"
|
||||
|
||||
echo "== 1/4 rsync web/ -> $VPS:$DEST (excluding pipeline provenance + junk)"
|
||||
rsync -az --delete $DRY \
|
||||
--exclude '_raw/' --exclude '.DS_Store' --exclude '*.map' \
|
||||
"$HERE/web/" "$VPS:$DEST"
|
||||
[ -n "$DRY" ] && { echo "(dry run — stopping)"; exit 0; }
|
||||
|
||||
echo "== 2/4 install nginx location (noindex, no autoindex, no listing)"
|
||||
ssh "$VPS" 'CONF=$(docker exec dealgod-nginx sh -c "grep -rl digalot /etc/nginx/conf.d/" | head -1); \
|
||||
echo "digalot server block: $CONF"; \
|
||||
docker exec dealgod-nginx sh -c "grep -q location\\ /procity/ $CONF" 2>/dev/null && { echo "location exists — skipping insert"; exit 0; }; \
|
||||
docker exec dealgod-nginx sh -c "sed -i \"/server_name.*digalot/a\\ location /procity/ { alias /srv/procity/web/; autoindex off; add_header X-Robots-Tag \\\"noindex, nofollow, noarchive\\\" always; }\" $CONF"'
|
||||
# NOTE: the alias path above must be the path AS THE CONTAINER SEES IT. If /opt/procity
|
||||
# is not already bind-mounted into dealgod-nginx, add a mount (or symlink under an
|
||||
# existing mounted root) BEFORE reload — the installer checks and tells you.
|
||||
|
||||
echo "== 3/4 robots: ensure digalot.fyi/robots.txt disallows /procity/"
|
||||
ssh "$VPS" 'R=/opt/procity/robots-note.txt; echo "add to digalot robots.txt: Disallow: /procity/" > $R; \
|
||||
docker exec dealgod-nginx sh -c "nginx -t" && docker exec dealgod-nginx nginx -s reload'
|
||||
|
||||
echo "== 4/4 verify with own eyes (cache-busted)"
|
||||
curl -sf -o /dev/null -w "index: %{http_code}\n" "https://digalot.fyi/procity/?cb=$(date +%s)"
|
||||
curl -sf -o /dev/null -w "module: %{http_code} %{content_type}\n" "https://digalot.fyi/procity/js/citygen/index.js?cb=$(date +%s)"
|
||||
curl -sI "https://digalot.fyi/procity/" | grep -i x-robots-tag || echo "WARN: noindex header missing"
|
||||
echo "DONE — boot https://digalot.fyi/procity/ in a browser; classic check: ?classic=1"
|
||||
1128
tools/flags_check.py
@ -24,10 +24,10 @@ Run: tools/.venv/bin/python tools/qa/interior_scale_check.py [--seed N]
|
||||
Setup: the same Playwright venv as tools/soak.py / tools/flags_check.py.
|
||||
Needs Lane B's window.DBG hook (?dbg=1) + window.PROCITY.interiorMode (Lane C/F).
|
||||
"""
|
||||
import sys, time, socket, subprocess, pathlib
|
||||
import sys, os, time, socket, subprocess, pathlib
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
|
||||
PORT = 8130
|
||||
PORT = int(os.environ.get('PROCITY_QA_PORT', '8130')) # [R31] port-isolated suite runs (see flags_check.py)
|
||||
HOST = f'http://127.0.0.1:{PORT}'
|
||||
SEED = 20261990
|
||||
if '--seed' in sys.argv:
|
||||
|
||||
@ -16,10 +16,10 @@ Gates per town (all on the DEFAULT boot — gigs/weather/winmap/tram on, post-R1
|
||||
Run: tools/.venv/bin/python tools/qa/town_matrix.py
|
||||
Needs the Playwright venv + Lane B's ?dbg=1 hook + Lane F's real-cache shell wiring (R17).
|
||||
"""
|
||||
import sys, time, socket, subprocess, pathlib
|
||||
import sys, os, time, socket, subprocess, pathlib
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
|
||||
PORT = 8130
|
||||
PORT = int(os.environ.get('PROCITY_QA_PORT', '8130')) # [R31] port-isolated suite runs (see flags_check.py)
|
||||
HOST = f'http://127.0.0.1:{PORT}'
|
||||
STREET_DRAWS_MAX, STREET_TRIS_MAX = 300, 200_000
|
||||
# [R21 ledger #6c] B's R20 tram ruling: on a REAL-ROADS town the tram only runs if its best main chain
|
||||
|
||||
148
web/index.html
@ -31,10 +31,11 @@
|
||||
<div id="pc-start">
|
||||
<h1>PROCITY</h1>
|
||||
<div class="sub">A procedurally generated, walkable 90s-Australian shopping town.
|
||||
Every door opens (soon). Walk the strip, watch the day turn.</div>
|
||||
Every door opens. Dig the crates, sell the flips, sleep — the town remembers.</div>
|
||||
<div class="sub" id="pc-hunt" style="color:#ffd75e;display:none"></div>
|
||||
<button id="pc-go">walk into town</button>
|
||||
<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 · [ ] time of day · M map · P screenshot</div>
|
||||
<div class="sub" style="font-size:12px;opacity:.7">WASD move · shift run · mouse look · click a door · E riffle a bin / sell at the counter · C your crate · SLEEP to save · [ ] time · M map</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
@ -62,6 +63,9 @@ import { createVenuePresentation } from './js/world/venue.js'; // [Lane F R1
|
||||
import { loadPedFleet } from './js/citizens/rigs.js'; // [Lane F §3.4] Lane D rig fleet (GLB peds/keepers)
|
||||
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);
|
||||
@ -167,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 };
|
||||
@ -186,11 +191,17 @@ 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 }); // [R16] sit.glb on the default boot (the seated drummer); ?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 §F2] ?dig=1 enables Lane C's crate-riffle inside record interiors (v2). Off ⇒ byte-identical to v1.
|
||||
const DIG_ON = params.has('dig') && params.get('dig') !== '0';
|
||||
// [Lane F R31 — THE DIG FLIP] The crate-riffle is DEFAULT-ON: `?dig=0` opts out, `?classic=1` forces it off
|
||||
// — the exact flagOn pattern the other flipped flags ride. The v2-era opt-in (`params.has('dig')`) survived
|
||||
// here six epochs after the README's flags table claimed "dig on", and every R30 game-loop gate booted
|
||||
// `dig=1` explicitly — so the whole suite was green while the plain boot (the boot every player gets) had
|
||||
// no dig at all: E at a bin did nothing (Fable's R31 wave-0 playtest, the alpha-blocker). The default-boot
|
||||
// gate now asserts dig REACHABILITY on a naked boot, so un-flipping this line goes red in qa, not in a
|
||||
// player's hands. ?noassets composes unchanged: the dig falls to parody canvas (existing law).
|
||||
const DIG_ON = flagOn('dig');
|
||||
// [Lane F §F2 stock=real] real GODVERSE sleeves in the crates (v2). Preload the record pack at boot so
|
||||
// the first interior/dig can batch real covers; fail-soft (missing pack → parody canvas). Off under ?noassets.
|
||||
const STOCK_REAL = params.get('stock') === 'real' && !NOASSETS;
|
||||
@ -240,22 +251,97 @@ if (STOCK_REAL) {
|
||||
}
|
||||
// [Lane F R8 — Lane C buy loop v0] ONE session wallet (seeded start cash) bound to the dig BUY across shops.
|
||||
// Runtime-only: never writes back into the plan/room build, so goldens + draw-counts are unaffected.
|
||||
const wallet = createWallet(seed);
|
||||
// [Lane F R30 — v7 THE SAVE CORE] The game layer, default-ON (the v3.1 flip precedent): ?game=0 opts out,
|
||||
// ?classic=1 forces its total absence (createGame never called ⇒ ZERO localStorage touches — the covenant
|
||||
// stays a pure v2 boot). When the game is on, PROCITY.wallet IS game.wallet — Lane C's exact v0 interface
|
||||
// (buy/sell/canBuy/…) backed by game-owned cash, so a loaded save's cash is authoritative and every
|
||||
// existing consumer (dig onBuy, shelf buy, the cover charge, C's onSell) rides the proven seam unchanged.
|
||||
// A fresh game opens with Lane C's seeded start cash, so day 1 is byte-identical money to a ?game=0 boot.
|
||||
const GAME_ON = flagOn('game');
|
||||
const TOWNKEY = `${PLANSRC}/${TOWN || 'default'}@${seed}`;
|
||||
const baseWallet = createWallet(seed);
|
||||
const game = GAME_ON ? createGame({
|
||||
townKey: TOWNKEY, startCash: baseWallet.start(),
|
||||
// fired after sleep()/import() moves the day: re-key the gig week (day % 7 into the existing schedule),
|
||||
// wake at dawn (segment 0 — fires procity:segment, so every latch observes the morning), tell John.
|
||||
onDay: (day) => {
|
||||
if (gigState) gigState.setWeekNight(weekNightOf(day)); // (day − 1) % 7 — see the seam note below
|
||||
lighting.setSegment(0);
|
||||
// [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;
|
||||
// [Lane F R12] the gig state machine (F-owned, js/world/gig_state.js): quiet → doors (DUSK) → on (NIGHT)
|
||||
// → done, off Lane B's clock and Lane A's plan.gigs[0] (alpha's "tonight"). Constructed ONLY under ?gigs=1
|
||||
// with a real schedule, so window.PROCITY.gigs stays undefined flags-off — which is precisely what Lane B's
|
||||
// audio engine tests to decide there is no gig layer at all (byte-identical, no spill).
|
||||
const gigState = (GIGS_ON && plan.gigs && plan.gigs.length) ? createGigState({ plan, lighting }) : null;
|
||||
// [Lane F R30 — SLEEP=TOMORROW] key tonight's gigs off the game day: weekNight = (day − 1) % 7, so a
|
||||
// loaded save wakes mid-week and every sleep walks the EXISTING seeded week schedule. Measured against
|
||||
// the brief's literal `day % 7`: day starts at 1, so the literal form would boot a FRESH game on night 1
|
||||
// — flipping the default boot's gigs off night 0, breaking the R13 cover/band gates and B's night-0-keyed
|
||||
// frontage before the player ever sleeps. Day 1 IS night 0 (the pre-v7 tonight), same convention as the
|
||||
// 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,
|
||||
dig: DIG_ON, stockReal: STOCK_REAL, wallet, occupancyOf: (id) => citizens.occupancyOf(id),
|
||||
rosterOf: (id) => citizens.tonightRoster(id), gigState, // [R15] identity continuity: the crowd IS tonight's roster
|
||||
liveBase: LIVE_BASE, sourcingOf: (id) => (STOCK_SOURCING ? STOCK_SOURCING(id) : null) }); // [R27] tier 2
|
||||
liveBase: LIVE_BASE, sourcingOf: (id) => (STOCK_SOURCING ? STOCK_SOURCING(id) : null), // [R27] tier 2
|
||||
game }); // [R30] the collection (buy seam records finds) + the day salt (stock rotation, §30.3)
|
||||
// [Lane F §F2] the riffle is cursor-driven (DOM buttons + click-a-sleeve), so release pointer-lock while it's
|
||||
// open and re-lock (on click) after. digActive gates the unlock→leaveShop guard below so opening a bin
|
||||
// doesn't read as "walked out of the shop".
|
||||
window.addEventListener('procity:digOpen', () => player.unlock());
|
||||
// [Lane F R30 §9.4] the sell card is cursor-driven too (SELL button, ‹ › cycling) — same unlock contract.
|
||||
window.addEventListener('procity:sellOpen', () => player.unlock());
|
||||
// [Lane F §F1 — ROUND 7 ROSTER FLIP · prime-law amendment] The chunk-streamed roster is now the
|
||||
// DEFAULT: the town is permanently busier. `?roster=v1` restores the old fixed roster (escape hatch);
|
||||
// `?roster=stream` is still accepted (now a no-op — stream is default). Gated on Lane D's GO: E1's
|
||||
@ -317,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);
|
||||
|
||||
@ -390,9 +505,9 @@ addEventListener('blur', () => keys.clear());
|
||||
const startPanel = document.getElementById('pc-start');
|
||||
document.getElementById('pc-startseed').textContent = `${plan.name} · seed ${plan.citySeed}`;
|
||||
document.getElementById('pc-go').addEventListener('click', () => { startPanel.style.display = 'none'; player.lock(); });
|
||||
player.controls.addEventListener('unlock', () => { keys.clear(); if (MODE === 'interior' && !interiorMode.digActive) leaveShop(); }); // [Lane F] Esc leaves the shop — but not when the unlock is us opening a dig (§F2)
|
||||
player.controls.addEventListener('unlock', () => { keys.clear(); if (MODE === 'interior' && !interiorMode.digActive && !interiorMode.sellActive) leaveShop(); }); // [Lane F] Esc leaves the shop — but not when the unlock is us opening a dig (§F2) or the sell card (R30 §9.4)
|
||||
// click the world to (re)grab the pointer when walking
|
||||
canvas.addEventListener('click', () => { if (!player.isLocked && !interiorMode.digActive && (MODE === 'street' || MODE === 'interior')) player.lock(); }); // [Lane F §F2] re-lock for interior walk after closing a dig
|
||||
canvas.addEventListener('click', () => { if (!player.isLocked && !interiorMode.digActive && !interiorMode.sellActive && (MODE === 'street' || MODE === 'interior')) player.lock(); }); // [Lane F §F2] re-lock for interior walk after closing a dig/sell card
|
||||
|
||||
addEventListener('resize', () => {
|
||||
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
|
||||
@ -471,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 });
|
||||
}
|
||||
@ -485,7 +602,8 @@ frame();
|
||||
window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skins,
|
||||
interiorMode, citizens, enterShop, leaveShop, isOpen, currentHour, fleet, noassets: NOASSETS,
|
||||
weather: weatherState, // [Lane F R8] Lane B contract: {state,intensity}, {clear,0} when off (Lane D reads this)
|
||||
wallet, // [Lane F R8] Lane C buy loop v0 (session wallet: cash/buy/count)
|
||||
wallet, // [Lane F R8] Lane C buy loop v0 (cash/buy/sell/count) — game-backed facade when the game is on
|
||||
game, // [Lane F R30] THE GAME (LANE_F_NOTES §30 contract) — NULL under ?classic=1 / ?game=0
|
||||
gigs: gigState, // [Lane F R12] the gig state machine — NULL flags-off (Lane B's audio tests this to
|
||||
// decide there's no gig layer at all). `.state` is a live getter: quiet|doors|on|done
|
||||
venuePresentation, // [Lane F R12] Lane B frontage + posters (null flags-off)
|
||||
@ -494,7 +612,9 @@ window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skin
|
||||
// ({fenced, reason, shopsFronted}) + `.stops`; B built it FOR F's smoke. Null flags-off.
|
||||
// [Lane F R16 — THE FLIP] the flag-intent surface: what SHOULD be on this boot (the gates verify intent vs
|
||||
// reality). Default boot = all four flipped flags true; ?classic=1 = all false + classic true; each `=0` opts out.
|
||||
flags: { classic: CLASSIC, gigs: GIGS_ON, weather: flagOn('weather'), winmap: flagOn('winmap'), tram: flagOn('tram') },
|
||||
flags: { classic: CLASSIC, gigs: GIGS_ON, weather: flagOn('weather'), winmap: flagOn('winmap'), tram: flagOn('tram'),
|
||||
game: GAME_ON, // [R30] the v7 game layer (default-on; ?game=0 / ?classic=1 → off)
|
||||
dig: DIG_ON }, // [R31] the dig flip (default-on; ?dig=0 / ?classic=1 → off) — gate reads intent here
|
||||
THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks
|
||||
// [Lane B R11 audio] street WebAudio engine — self-unlocking on the first gesture; silent with
|
||||
// zero/blocked assets or ?mute=1; ?noassets=1 ⇒ no audio fetches. Exposes window.PROCITY.audio so
|
||||
|
||||
@ -73,6 +73,7 @@ import { buildInterior, SHOP_TYPES, ARCHETYPE_KEYS, preloadStockPack, getStockPa
|
||||
import { createDig, binSeed } from './js/interiors/dig.js';
|
||||
import { createWallet } from './js/interiors/wallet.js';
|
||||
import { collapseBuyItem } from './js/interiors/stockpack.js';
|
||||
import { createSell, sellOffer, sellableIn, nearCounter, SELL_DIST } from './js/interiors/sell.js';
|
||||
|
||||
// ?stock=real — feed Lane E's GODVERSE record-sleeve pack through the stockAdapter seam.
|
||||
const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real';
|
||||
@ -522,6 +523,54 @@ async function shelfBuySoak() {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── sell counter (R30, v7.0-alpha ledger #2) — walk to the counter holding items, press E → sell card ──
|
||||
// GATE = the game layer's presence (window.PROCITY.game), exactly the in-game law: ?classic=1/?game=0
|
||||
// build no game object, so no sell card, zero errors. F's save core (R30 ledger #1) hadn't landed when
|
||||
// this was wired, so ?sellstub=1 STUBS the published contract shape ({ day, cash, collection[] } — the
|
||||
// slice C reads) purely for this harness. When F's window.PROCITY.game is real, the stub retires; the
|
||||
// sell path below is what F wires verbatim (LANE_C_PUB §9.3/§9.4).
|
||||
const SELL_STUB = new URLSearchParams(location.search).has('sellstub');
|
||||
if (SELL_STUB) {
|
||||
window.PROCITY = window.PROCITY || {};
|
||||
// STUB — R30 ledger #1's published shape, + the §9.2 asks (type, title, artist) the card needs.
|
||||
window.PROCITY.game = window.PROCITY.game || { day: 1, cash: 0, collection: [
|
||||
{ townKey: 'test', shopId: 'stub1', slotId: 'stub_0001', type: 'record', title: 'Servo at Midnight', artist: 'THE FIBROS', pricePaid: 24, dayFound: 1 },
|
||||
{ townKey: 'test', shopId: 'stub2', slotId: 'stub_0002', type: 'record', title: 'Arvo', artist: 'GALAH', pricePaid: 1, dayFound: 1 }, // $1 → offer $0 → SELL disabled (§9.1)
|
||||
{ townKey: 'test', shopId: 'stub3', slotId: 'stub_0003', type: 'book', title: 'The Long Paddock', artist: '', pricePaid: 15, dayFound: 1 },
|
||||
{ townKey: 'test', shopId: 'stub4', slotId: 'sku_untyped', pricePaid: 30, dayFound: 1 }, // NO type → never sellable (§9.2 fail-closed)
|
||||
] };
|
||||
}
|
||||
const sell = createSell();
|
||||
function trySell() {
|
||||
const game = window.PROCITY && window.PROCITY.game; // absent ⇒ no game layer ⇒ no card
|
||||
if (!game || !Array.isArray(game.collection) || !current) return false;
|
||||
if (!nearCounter(current, camera.position)) return false; // §9.4: within SELL_DIST of the bench
|
||||
const opened = sell.open({
|
||||
shopType: current.dims.type, shopName: current.recipe.label,
|
||||
items: game.collection, // READ-ONLY — sell.js cards a filtered copy
|
||||
getCash: wallet.cash,
|
||||
emitters: current.audio && current.audio.emitters,
|
||||
// The consumer's two halves (§9.3): credit the wallet + remove from the collection. In-game the
|
||||
// removal is F's, via the game API; HERE the collection is this harness's own stub, so the harness
|
||||
// splices its own array. C's modules never touch it.
|
||||
onSell: (item, offer) => {
|
||||
wallet.sell(item, offer);
|
||||
const i = game.collection.indexOf(item); if (i >= 0) game.collection.splice(i, 1);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
if (opened && controls.isLocked) controls.unlock();
|
||||
return opened;
|
||||
}
|
||||
addEventListener('keydown', e => {
|
||||
if (e.code !== 'KeyE' || e.target.matches('input,select')) return;
|
||||
if ((dig && dig.active) || sell.active) return;
|
||||
if (DIG_ON && binUnderAim()) return; // bins → dig (that listener handles it)
|
||||
if (shelfPanel.style.display === 'block') return; // shelf card already up
|
||||
trySell(); // counter → sell card
|
||||
});
|
||||
if (SELL_STUB) document.getElementById('hint').innerHTML += ' · <b>E</b> at the counter: sell';
|
||||
|
||||
// ── loop ───────────────────────────────────────────────────────────────────────
|
||||
let last = performance.now();
|
||||
function frame() {
|
||||
@ -534,7 +583,8 @@ function frame() {
|
||||
// expose for headless verification (screenshot harness, workflow checks)
|
||||
window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, drawSweep, DRAW_LAW, rebuild, get current() { return current; }, scene, camera, renderer,
|
||||
DIG_ON, STOCK_REAL, digSoak, openDigOn, binUnderAim, get dig() { return dig; }, wallet, preloadStockPack, getStockPack, makeStockAdapter,
|
||||
shelfUnderAim, showShelfCard, buyShelfOffer, shelfBuySoak };
|
||||
shelfUnderAim, showShelfCard, buyShelfOffer, shelfBuySoak,
|
||||
SELL_STUB, sell, trySell, sellOffer, sellableIn, nearCounter, SELL_DIST };
|
||||
|
||||
rebuild();
|
||||
frame();
|
||||
|
||||
@ -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
|
||||
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 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;
|
||||
|
||||
@ -18,7 +18,8 @@ export const PED_NAMES = {
|
||||
normal: ['man_worker_hivis_01', 'man_suit_01', 'man_casual_01', 'man_casual_02', 'man_casual_03',
|
||||
'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'],
|
||||
'woman_athleisure_01', 'woman_smart_01', 'woman_chef_01',
|
||||
'woman_dj_01', 'woman_dj_02', 'woman_dj_03', 'dj_techno_01', 'dj_phrtt_01'], // trellis'd DJ bodies (fbx_to_ped, 4k tris)
|
||||
comical: ['comical_luchador_01', 'comical_boy_01'],
|
||||
};
|
||||
|
||||
@ -55,10 +56,10 @@ function loadRig(ref) {
|
||||
// sit.glb fetch would break the ?classic=1 zero-fetch-delta covenant — F passes sit:true on the gig/default
|
||||
// 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 } = {}) {
|
||||
export function loadPedFleet(base = 'models/peds/', { sit = false, look = false, dance = false } = {}) {
|
||||
const fleet = {
|
||||
normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas
|
||||
walkClip: null, idleClip: null, sitClip: 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
|
||||
@ -82,10 +83,26 @@ export function loadPedFleet(base = 'models/peds/', { sit = false } = {}) {
|
||||
if (sit) jobs.push(loadRig(`${base}sit.glb`).then(r => { // R16, gig/default boot only
|
||||
const c = r && r.anims && r.anims[0];
|
||||
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.sitClip = _rotOnly(c); } }));
|
||||
// [R29 Spike 1] E's look.glb (mesh-free, 65 joints, 6.5 s 'Look'). Same gate as sit — the shell passes
|
||||
// `look: !CLASSIC`, so ?classic fetches nothing and the zero-fetch-delta covenant holds. `_rotOnly` is
|
||||
// correct here for the same reason as walk/idle: it's a STANDING clip, so there's no hip descent to
|
||||
// recover and no foot re-plant needed (that trap was sit's alone — R16).
|
||||
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;
|
||||
});
|
||||
@ -223,7 +240,7 @@ export function seatedLean(spine, angle = 0.34) {
|
||||
|
||||
// ---- makeActor: near-tier pedestrian with walk↔idle crossfade (built at nominal height; the sim
|
||||
// scales the outer Group per-citizen so pooled actors are height-agnostic and reusable). ----
|
||||
export function makeActor(rig, { walkClip, idleClip, sitClip = null, nominalHeight = 1.75 } = {}) {
|
||||
export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip = null, nominalHeight = 1.75 } = {}) {
|
||||
if (!rig || !rig.scene) return null;
|
||||
const { fig, inner, head, nominalHeight: nom } = buildFigure(rig, nominalHeight);
|
||||
const bindPlantY = inner.position.y; // R17 bench-sit: the standing feet-plant, to restore after a sit
|
||||
@ -233,9 +250,16 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, nominalHeig
|
||||
// R17 bench-sit: a third action, PLAYED ONLY when setSitting(true) — so walkers (and ?classic, where
|
||||
// sitClip is null) are byte-identical: sitA never plays, the plant never moves, setSitting is a no-op.
|
||||
const sitA = sitClip ? _action(mixer, inner, sitClip) : null;
|
||||
// [R29 Spike 1] the glance — same opt-in shape as sitA: PLAYED ONLY when setLooking(true), so walkers,
|
||||
// placeholders and ?classic (lookClip null) are byte-identical — lookA never advances the mixer.
|
||||
const lookA = lookClip ? _action(mixer, inner, lookClip) : null;
|
||||
let seatBone = null;
|
||||
if (sitA) inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
|
||||
let sitting = false;
|
||||
// [R29] foot bones, cached once for the per-frame plant (see plantFeet). Only built when a posed clip
|
||||
// exists, so walkers/?classic pay nothing.
|
||||
let footBones = null;
|
||||
if (lookA) { const fb = []; inner.traverse(o => { if (o.isBone && /(Toe|Foot)/i.test(o.name)) fb.push(o); }); footBones = fb.length ? fb : null; }
|
||||
let sitting = false, looking = false;
|
||||
let moving = null; // tri-state so the first setMoving always applies
|
||||
// both actions play; exactly one holds weight 1 at rest, setMoving transfers between them
|
||||
if (walkA) { walkA.play(); walkA.setEffectiveWeight(1); }
|
||||
@ -279,13 +303,53 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, nominalHeig
|
||||
fig.userData.procitySeated = false;
|
||||
}
|
||||
}
|
||||
// [R29 Spike 1] the standing glance during a window-shop loiter. Deliberately SIMPLER than setSitting:
|
||||
// it's a standing clip, so there is no posed foot-replant and no scale trap — the feet never leave the
|
||||
// bind plant. Unlike sit (a snap, which had to avoid a crossfade fighting the discrete replant), this
|
||||
// crossfades: idle → idle-variant reads naturally, and the sim only calls it while the ped is stopped.
|
||||
// No-op when lookClip is absent (?classic / ?noassets) ⇒ byte-identical by construction.
|
||||
function setLooking(l, fade = 0.3) {
|
||||
if (!lookA || l === looking) return;
|
||||
looking = l;
|
||||
moving = null; // force the next setMoving to re-apply on the way out
|
||||
if (l) {
|
||||
lookA.reset().play();
|
||||
if (idleA) idleA.crossFadeTo(lookA, fade, false);
|
||||
else lookA.setEffectiveWeight(1);
|
||||
if (walkA) walkA.setEffectiveWeight(0);
|
||||
} else {
|
||||
if (idleA) { idleA.reset().play(); lookA.crossFadeTo(idleA, fade, false); }
|
||||
else lookA.setEffectiveWeight(0);
|
||||
inner.position.y = bindPlantY; // drop the plant correction — back to the standing bind plant
|
||||
}
|
||||
}
|
||||
// [R29] MEASURED, and it corrected my own assumption: I expected a standing clip to need no re-plant
|
||||
// ("no hip descent to recover"). It does. `_rotOnly` drops the Hips POSITION track to stay rig-height
|
||||
// independent, so look.glb's authored weight-shift dip cannot move the hips — it comes out as the FEET
|
||||
// rising instead: lowest bone ranges −0.009…+0.205 m over the loop vs idle's −0.04…0.00. A 20 cm hover.
|
||||
// Sit's one-time re-plant can't fix it because the float VARIES per frame, so this is the per-frame twin:
|
||||
// reset to the bind plant, measure the posed feet, drop `inner` by the float / scale. Absolute (reset →
|
||||
// measure → correct), never incremental, so it cannot drift. The result reproduces the authored motion
|
||||
// exactly — the hips dip relative to the ground instead of the feet leaving it. Scale-aware like sit's.
|
||||
// Cost is paid ONLY by glancing peds (a handful), and only on frames their mixer actually ran.
|
||||
function plantFeet() {
|
||||
if (!footBones || !looking) return;
|
||||
inner.position.y = bindPlantY; // reference pose
|
||||
inner.updateWorldMatrix(true, true);
|
||||
let w = Infinity;
|
||||
for (const b of footBones) { b.getWorldPosition(_wp); if (_wp.y < w) w = _wp.y; }
|
||||
if (!isFinite(w)) return;
|
||||
const S = fig.scale.y || 1;
|
||||
inner.position.y -= (w - fig.position.y) / S; // posed soles → the footpath
|
||||
}
|
||||
// seed the clip phase so the crowd isn't in lockstep
|
||||
function setPhase(p) {
|
||||
if (walkA) walkA.time = p * (walkA.getClip().duration || 1);
|
||||
if (idleA) idleA.time = p * (idleA.getClip().duration || 1);
|
||||
if (lookA) lookA.time = p * (lookA.getClip().duration || 1); // R29: glances desync too
|
||||
}
|
||||
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
|
||||
|
||||
return { fig, inner, mixer, head, seatBone, nominalHeight: nom, setMoving, setSitting, setPhase, dispose,
|
||||
return { fig, inner, mixer, head, seatBone, nominalHeight: nom, setMoving, setSitting, setLooking, plantFeet, setPhase, dispose,
|
||||
hasClips: !!(walkA && idleA) };
|
||||
}
|
||||
|
||||
@ -35,6 +35,7 @@ const PATRON_STRIDE = 10; // m walked between patronage check
|
||||
const GIG_RANGE = 34; // m — a gig pulls peds from further than a normal shopfront
|
||||
const GIG_SURGE = 0.55; // patron chance at the venue while the gig is on (vs ~0.16 day)
|
||||
const BENCH_SIT_FRAC = 0.35; // R17: fraction of window-shop loiters that become a bench-sit (seeded)
|
||||
const GLANCE_FRAC = 0.40; // R29: fraction of the REMAINING (standing) loiters that glance around
|
||||
|
||||
// time-of-day density curve: t01 in [0,1) over a day → crowd multiplier (CITY_SPEC: lunch rush,
|
||||
// near-empty at night). Sampled at 8 control points, linearly interpolated.
|
||||
@ -93,6 +94,7 @@ class RigPool {
|
||||
release(actor) {
|
||||
if (!actor) return;
|
||||
if (actor.setSitting) actor.setSitting(false); // R17: never pool a rig mid-sit — restore the standing plant
|
||||
if (actor.setLooking) actor.setLooking(false, 0); // R29: nor mid-glance — snap back to idle before pooling
|
||||
let arr = this.free.get(actor.pedIndex);
|
||||
if (!arr) this.free.set(actor.pedIndex, arr = []);
|
||||
arr.push(actor);
|
||||
@ -258,6 +260,7 @@ export class CitizenSim {
|
||||
turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id),
|
||||
patron: null, patronTarget: null, patronTimer: 0, patronRng: rng(this.citySeed, 'patron', id),
|
||||
sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron
|
||||
glance: false, glanceRng: rng(this.citySeed, 'glance', id), // R29: ditto — independently keyed, signature untouched
|
||||
};
|
||||
if (this.mode === 'rig' && this.fleet.ready) { const pk = pickRig(this.fleet, c.pedRoll); if (pk) { c.pedIndex = pk.index; c.subject = pk.index; } }
|
||||
this._placeOnLane(c);
|
||||
@ -307,6 +310,7 @@ export class CitizenSim {
|
||||
turn: rng(this.citySeed, 'turn', id),
|
||||
loit: rng(this.citySeed, 'loiter', id),
|
||||
sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron
|
||||
glance: false, glanceRng: rng(this.citySeed, 'glance', id), // R29: ditto — independently keyed, signature untouched
|
||||
};
|
||||
// assign a real ped type if the fleet is already up (roster can grow after upgrade)
|
||||
if (this.mode === 'rig' && this.fleet.ready) {
|
||||
@ -522,7 +526,7 @@ export class CitizenSim {
|
||||
this._setImpostorLayer(atlas);
|
||||
|
||||
this.mode = 'rig';
|
||||
this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip, sitClip: this.fleet.sitClip }); // sitClip null under ?classic → bench-sit inert
|
||||
this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip, sitClip: this.fleet.sitClip, lookClip: this.fleet.lookClip }); // sit/lookClip null under ?classic → bench-sit + glance inert
|
||||
// assign real ped types to every citizen; drop placeholder near-actors so they re-acquire as rigs
|
||||
const assign = c => {
|
||||
const pk = pickRig(this.fleet, c.pedRoll);
|
||||
@ -589,6 +593,12 @@ export class CitizenSim {
|
||||
// so ?classic (no sit.glb) is inert at the source, not just at the render.
|
||||
const wantsSit = c.sitRng ? (c.sitRng() < BENCH_SIT_FRAC) : false;
|
||||
c.sit = wantsSit && !!(this.fleet && this.fleet.sitClip);
|
||||
// [R29 Spike 1] a standing glance — the other way to spend a window-shop stop. Same discipline as
|
||||
// the sit roll: draw ALWAYS from a dedicated stream (deterministic; never shifts turn/loit/patron),
|
||||
// but only flip the flag when a clip exists ⇒ ?classic inert at the source. Mutually exclusive with
|
||||
// the sit: you can't bench-sit and stand glancing, so the sit wins and the glance takes the rest.
|
||||
const wantsGlance = c.glanceRng ? (c.glanceRng() < GLANCE_FRAC) : false;
|
||||
c.glance = !c.sit && wantsGlance && !!(this.fleet && this.fleet.lookClip);
|
||||
}
|
||||
}
|
||||
this._placeOnLane(c);
|
||||
@ -732,7 +742,11 @@ export class CitizenSim {
|
||||
// so every non-sitting ped is byte-identical.
|
||||
const wantSit = !!(c.sit && c.loiter > 0 && a.setSitting);
|
||||
if (a.setSitting) a.setSitting(wantSit);
|
||||
if (!wantSit) a.setMoving?.(c.loiter <= 0);
|
||||
// [R29 Spike 1] the glance rides the same seam: a stopped ped either sits, glances, or plain idles.
|
||||
// No-op for walkers, placeholders and ?classic (no lookClip) ⇒ every non-glancing ped byte-identical.
|
||||
const wantLook = !wantSit && !!(c.glance && c.loiter > 0 && a.setLooking);
|
||||
if (a.setLooking) a.setLooking(wantLook);
|
||||
if (!wantSit && !wantLook) a.setMoving?.(c.loiter <= 0);
|
||||
} else if (want === 'mid') {
|
||||
mid.push(c);
|
||||
}
|
||||
@ -747,7 +761,9 @@ export class CitizenSim {
|
||||
const c = near[i], a = c.actor;
|
||||
c._acc += dt;
|
||||
if (a.mixer) {
|
||||
if (i < MIXER_ALWAYS) { a.mixer.update(c._acc); c._acc = 0; } else extra.push(c);
|
||||
// [R29] plantFeet must follow the mixer that posed the feet — same coupling rule the drummer's
|
||||
// post-mix lean taught us. Only glancing peds pay it; it self-guards on `looking`.
|
||||
if (i < MIXER_ALWAYS) { a.mixer.update(c._acc); c._acc = 0; if (c.glance) a.plantFeet?.(); } else extra.push(c);
|
||||
} else {
|
||||
a.tick?.(c._acc, c.loiter <= 0); c._acc = 0; // placeholder
|
||||
}
|
||||
@ -758,6 +774,8 @@ export class CitizenSim {
|
||||
for (let k = 0; k < MIXER_EXTRA && k < extra.length; k++) {
|
||||
const c = extra[(this._mixerCursor + k) % extra.length];
|
||||
c.actor.mixer.update(c._acc); c._acc = 0;
|
||||
if (c.glance) c.actor.plantFeet?.(); // R29: the round-robin tier plants on the frames its mixer ran
|
||||
|
||||
}
|
||||
this._mixerCursor = (this._mixerCursor + MIXER_EXTRA) % extra.length;
|
||||
}
|
||||
|
||||
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',
|
||||
'STOBIE POLES', 'THE FIBROS', 'WARATAH', 'THONGS', 'THE CLOTHESLINE', 'GALAH'];
|
||||
// [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.
|
||||
|
||||
@ -172,6 +172,10 @@ export function buildInterior(shop, THREE, opts) {
|
||||
const norm = normalizeShop(shop);
|
||||
const recipe = getRecipe(norm.type);
|
||||
const ctx = new Ctx(THREE, norm.seed);
|
||||
// [Lane F R30 seam — SLEEP=TOMORROW, LANE_F_NOTES §30.3] opts.stockDay salts ONLY the stock pick
|
||||
// streams (layout.js `stk-*`): the room/fittings/audio streams are untouched, so the shop is the same
|
||||
// shop every day and only what's in the crates rotates. Absent (no game / day 1 / real-sourced) ⇒ ''.
|
||||
ctx.stockSalt = opts.stockDay != null ? ':d' + (opts.stockDay >>> 0) : '';
|
||||
|
||||
// room shape → dims → shell
|
||||
const archetype = chooseArchetype(recipe, ctx.stream('archetype'), opts.archetype);
|
||||
|
||||
@ -414,7 +414,7 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
|
||||
for (const p of placed) {
|
||||
if (p.removed) continue;
|
||||
if (!p.fitting.group.parent) roomGroup.add(p.fitting.group);
|
||||
stock.fill(ctx, p.fitting, { shop, recipe, stockKind: recipe.stockKind, adapter }, ctx.stream(`stk-${p.priority}-${p.x.toFixed(2)}-${p.z.toFixed(2)}`));
|
||||
stock.fill(ctx, p.fitting, { shop, recipe, stockKind: recipe.stockKind, adapter }, ctx.stream(`stk-${p.priority}-${p.x.toFixed(2)}-${p.z.toFixed(2)}${ctx.stockSalt || ''}`)); // [Lane F R30 seam] day-salted stock rotation (§30.3)
|
||||
places.push(...(p.fitting.places || []));
|
||||
}
|
||||
|
||||
|
||||
166
web/js/interiors/sell.js
Normal file
@ -0,0 +1,166 @@
|
||||
// PROCITY Lane C — the sell counter (v7.0-alpha, R30 ledger #2). Walk to the keeper's counter holding
|
||||
// items the shop trades in → a sell card (the mirror of the dig's price sticker): item, OFFER, SELL.
|
||||
// Contract: LANE_C_PUB §9 — this file is the loader-exact authority (the §7 rule applies here too).
|
||||
//
|
||||
// THE NO-PUMP LAW IS STRUCTURAL, NOT TUNED (charter law #2): sellOffer() is strictly below what the
|
||||
// player paid for every pricePaid ≥ 1, so buy-then-immediately-sell loses ≥ $1 per round trip by
|
||||
// construction — F's monotonic-loss gate holds against the formula's shape, not a balance pass.
|
||||
//
|
||||
// Consumer pattern mirrors dig.js: C ships the interaction, F owns the in-game input hook. C consumes
|
||||
// the game collection READ-ONLY — the single side-effect is onSell(item, offer); the CONSUMER credits
|
||||
// the wallet and removes the item via the game API (never this file, never a splice of game state).
|
||||
// DOM-only: zero draws, zero GPU resources; dispose() removes every node + listener. No game layer
|
||||
// (?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). [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)));
|
||||
}
|
||||
|
||||
// Alpha matcher (§9.2): any shop of the item's type buys it — strict equality, and FAIL-CLOSED on an
|
||||
// untyped item (vacuous-gate law: a matcher that passes an untyped item would "match" everywhere).
|
||||
// Per-keeper taste / haggling / pawn-buys-everything: beta — do not widen here.
|
||||
export function sellableIn(shopType, item) {
|
||||
return !!(item && item.type && shopType && item.type === shopType);
|
||||
}
|
||||
|
||||
// Proximity test (§9.4) — pos is the interior camera position (room-local, the same frame the
|
||||
// counter pose is published in).
|
||||
export function nearCounter(room, pos, dist = SELL_DIST) {
|
||||
const c = room && room.counter && room.counter.pose;
|
||||
if (!c || !pos) return false;
|
||||
return Math.hypot(pos.x - c.x, pos.z - c.z) <= dist;
|
||||
}
|
||||
|
||||
// [R28 pattern] one-shot distance gain from the counter emitter — same math as dig.js's till.
|
||||
function emitterGain(em) {
|
||||
if (!em) return 1;
|
||||
const cam = window.PROCITY && window.PROCITY.camera && window.PROCITY.camera.position;
|
||||
if (!cam) return 1;
|
||||
const d = Math.hypot(cam.x - em.x, cam.z - em.z);
|
||||
const prox = Math.max(0, Math.min(1, (em.r - d) / em.r));
|
||||
return em.floor + (1 - em.floor) * prox;
|
||||
}
|
||||
function ringTill(em) {
|
||||
const eng = window.PROCITY && window.PROCITY.audio;
|
||||
if (!eng || !eng.playSfx) return; // no engine → silent-and-happy (?mute/?noassets inside)
|
||||
eng.playSfx('till', { gain: emitterGain(em) });
|
||||
}
|
||||
|
||||
export function createSell() {
|
||||
const el = (tag, cls) => { const e = document.createElement(tag); if (cls) e.className = cls; return e; };
|
||||
// The buy card's manila sticker, mirrored to the LEFT (the sell side of the counter). Same
|
||||
// typewriter/stamp treatment as .pcdg-panel so the two cards read as one shop's stationery.
|
||||
const css = el('style'); css.textContent = `
|
||||
.pcsl-panel{position:fixed;left:6%;top:50%;transform:translateY(-50%) rotate(1.1deg);width:262px;background:#f4ecd8;border:1px solid #cbbf9e;box-shadow:0 10px 26px rgba(0,0,0,.55);padding:15px 16px 14px;color:#241f18;font:14px "Courier New",monospace;display:none;z-index:61}
|
||||
.pcsl-panel .sticker{float:right;width:54px;height:54px;border-radius:50%;background:#bfe3c0;border:1px solid #5a9a5e;color:#1c5e2a;font:700 18px Arial;line-height:54px;text-align:center;transform:rotate(-5deg);margin:-2px -4px 4px 10px;box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
||||
.pcsl-panel h3{margin:.1em 0 3px;color:#241f18;font-size:15px;font-weight:700;text-transform:uppercase;letter-spacing:.02em}
|
||||
.pcsl-panel .ttl{color:#4c4436;font-size:13px;font-style:italic;margin:0 0 9px;line-height:1.25}
|
||||
.pcsl-panel .meta{clear:both;color:#6f6553;font-size:11px;letter-spacing:.05em;min-height:1px}
|
||||
.pcsl-panel .nav{display:flex;justify-content:space-between;align-items:center;margin-top:9px;color:#6f6553;font-size:12px}
|
||||
.pcsl-panel .nav span{cursor:pointer;padding:0 8px;user-select:none}
|
||||
.pcsl-panel .nav span:hover{color:#241f18}
|
||||
.pcsl-panel button{margin-top:11px;width:100%;padding:9px;border:1px solid #5a7a5e;background:#dcead0;color:#1c5e2a;font:700 13px "Courier New",monospace;letter-spacing:.06em;cursor:pointer}
|
||||
.pcsl-panel button:hover:not(:disabled){background:#1c5e2a;color:#dcead0;border-color:#1c5e2a}
|
||||
.pcsl-panel button:disabled{color:#9a917c;border-color:#c3b99f;background:#e7dcc2;cursor:default}
|
||||
.pcsl-panel .x{position:absolute;top:4px;left:8px;cursor:pointer;color:#a89e86;font-size:13px}
|
||||
.pcsl-toast{position:fixed;left:50%;top:40%;transform:translate(-50%,-50%);background:#0e160e;border:1px solid #3dff8b;color:#3dff8b;padding:14px 22px;font:700 15px "Courier New",monospace;z-index:62;display:none}`;
|
||||
document.head.appendChild(css);
|
||||
const panel = el('div', 'pcsl-panel');
|
||||
const toast = el('div', 'pcsl-toast');
|
||||
document.body.append(panel, toast);
|
||||
|
||||
let active = false, items = [], idx = 0;
|
||||
let onSellCb = null, onCloseCb = null, emitters = null, shopName = '';
|
||||
|
||||
let toastT = null;
|
||||
const showToast = (msg) => { toast.textContent = msg; toast.style.display = 'block'; clearTimeout(toastT); toastT = setTimeout(() => (toast.style.display = 'none'), 1400); };
|
||||
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (m) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[m]));
|
||||
const labelOf = (it) => it.artist || it.a || ''; // headline line (artist), may be empty
|
||||
const titleOf = (it) => it.title || it.t || it.sku || it.slotId || 'item'; // §9.2 fallback: honest, ugly
|
||||
|
||||
function render() {
|
||||
const it = items[idx];
|
||||
if (!it) { close(); return; }
|
||||
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) : ''}${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';
|
||||
panel.querySelector('.x').onclick = () => close();
|
||||
const pv = panel.querySelector('.pv'), nx = panel.querySelector('.nx');
|
||||
if (pv) pv.onclick = () => { idx = (idx + items.length - 1) % items.length; render(); };
|
||||
if (nx) nx.onclick = () => { idx = (idx + 1) % items.length; render(); };
|
||||
const b = panel.querySelector('button');
|
||||
if (b && !b.disabled) b.onclick = () => {
|
||||
// The single side-effect (§9.3): the consumer credits the wallet AND removes the item via the
|
||||
// game API. false = keeper veto → card unchanged, nothing moves. C never touches game state.
|
||||
const ok = onSellCb ? onSellCb(it, offer) : false;
|
||||
if (ok === false) return;
|
||||
ringTill(emitters && emitters.counter);
|
||||
showToast(`SOLD — ${esc(head)} · +$${offer}`);
|
||||
items.splice(idx, 1); // the LOCAL sellable list only, never the collection
|
||||
if (!items.length) { close(); return; }
|
||||
idx = Math.min(idx, items.length - 1);
|
||||
render();
|
||||
};
|
||||
}
|
||||
|
||||
const onKey = (e) => { if (!active) return; if (e.key === 'Escape') { e.stopPropagation(); close(); } };
|
||||
|
||||
// open({ shopType, shopName, items, getCash, onSell, onClose, emitters }) → bool (§9.6).
|
||||
// `items` = the game collection (read-only; a filtered COPY is carded). Nothing sellable → false,
|
||||
// no card, no DOM — the fail-soft half of the classic-pure law lives here too.
|
||||
function open(opts = {}) {
|
||||
if (active) return true;
|
||||
const src = Array.isArray(opts.items) ? opts.items : [];
|
||||
const sellable = src.filter((it) => sellableIn(opts.shopType, it));
|
||||
if (!sellable.length) return false;
|
||||
items = sellable; idx = 0; active = true;
|
||||
onSellCb = opts.onSell || null; onCloseCb = opts.onClose || null;
|
||||
emitters = opts.emitters || null; shopName = opts.shopName || '';
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!active) return;
|
||||
active = false; items = []; idx = 0;
|
||||
panel.style.display = 'none';
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
const cb = onCloseCb; onCloseCb = null; onSellCb = null; emitters = null;
|
||||
if (cb) cb();
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
close();
|
||||
clearTimeout(toastT);
|
||||
[css, panel, toast].forEach((e) => e.remove());
|
||||
}
|
||||
|
||||
return { open, close, dispose, get active() { return active; } };
|
||||
}
|
||||
@ -26,6 +26,19 @@ export function createWallet(seed = 1) {
|
||||
notify();
|
||||
return true;
|
||||
},
|
||||
// [R30 §9.3] sell(item, offer) — the credit side of the counter, the exact mirror of buy(): adds the
|
||||
// offer (computed by sell.js sellOffer — this file never prices) and drops the matching v0-inventory
|
||||
// entry if one exists (by identity first, then title+artist). The GAME collection is NOT touched
|
||||
// here — that removal is the consumer's, via the game API (LANE_C_PUB §9.3).
|
||||
sell(o, offer) {
|
||||
const amt = Math.max(0, Math.floor(+offer || 0));
|
||||
cash += amt;
|
||||
let i = inv.indexOf(o);
|
||||
if (i < 0) i = inv.findIndex((x) => x.title === (o.t || o.title) && x.artist === (o.a || o.artist || ''));
|
||||
if (i >= 0) inv.splice(i, 1);
|
||||
notify();
|
||||
return true;
|
||||
},
|
||||
inventory: () => inv.slice(),
|
||||
count: () => inv.length,
|
||||
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
|
||||
|
||||
@ -101,12 +101,20 @@ export function createGigState({ plan, lighting }) {
|
||||
// single-venue alias retired in R15 (B swept its cross-lane readers in R14; F migrated its own tools).
|
||||
const venueIds = [...new Set(gigs.map((g) => g.venueShopId))];
|
||||
|
||||
// one latch per venue, keyed by its night-0 gig (null → dark tonight → 'quiet')
|
||||
// one latch per venue, keyed by its night-N gig (null → dark tonight → 'quiet'). N defaults to 0 —
|
||||
// the pre-v7 "tonight" every consumer was built against. [Lane F R30 — SLEEP=TOMORROW] the game layer
|
||||
// re-keys N to (day − 1) % 7 via setWeekNight(), walking the EXISTING seeded week; no game ⇒ N stays 0
|
||||
// and this file behaves byte-identically to R29.
|
||||
let weekNight = 0;
|
||||
const latch = new Map();
|
||||
for (const id of venueIds) {
|
||||
const tonight = gigs.find((g) => g.venueShopId === id && g.night === 0) || null;
|
||||
latch.set(id, createVenueLatch(tonight, lighting));
|
||||
function buildLatches() {
|
||||
latch.clear();
|
||||
for (const id of venueIds) {
|
||||
const tonight = gigs.find((g) => g.venueShopId === id && g.night === weekNight) || null;
|
||||
latch.set(id, createVenueLatch(tonight, lighting));
|
||||
}
|
||||
}
|
||||
buildLatches();
|
||||
|
||||
// ONE listener drives every latch — the listens-not-polls law, now fanned out across the district.
|
||||
const onSegment = (e) => { if (e && e.detail) for (const l of latch.values()) l.observe(e.detail.seg); };
|
||||
@ -118,6 +126,19 @@ export function createGigState({ plan, lighting }) {
|
||||
const L = (id) => latch.get(id) || null;
|
||||
|
||||
return {
|
||||
// ── the week (R30, SLEEP=TOMORROW) ────────────────────────────────────────────────────────────
|
||||
// setWeekNight(n): re-key every venue's latch to its night-n gig. Fresh latches ⇒ paid stamps and
|
||||
// played state reset — correct: a new night means the cover is due again (John's R12 ruling). The
|
||||
// three hard-won latch laws live inside createVenueLatch, untouched. Idempotent per n (no-op when
|
||||
// the night hasn't changed, so a boot-time call on night 0 changes nothing).
|
||||
get weekNight() { return weekNight; },
|
||||
setWeekNight(n) {
|
||||
n = ((n % 7) + 7) % 7;
|
||||
if (n === weekNight) return;
|
||||
weekNight = n;
|
||||
buildLatches();
|
||||
tickAll(); // settle every fresh latch on the current segment
|
||||
},
|
||||
// ── per-venue API (R13 district) ──────────────────────────────────────────────────────────────
|
||||
get venueShopIds() { return venueIds.slice(); },
|
||||
stateOf(id) { const l = L(id); return l ? l.state : 'quiet'; },
|
||||
|
||||
@ -21,12 +21,26 @@ 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: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}
|
||||
#pc-gamebar button:hover{border-color:rgba(255,215,94,.7);color:#ffd75e}
|
||||
#pc-crate{width:344px;max-height:min(58vh,470px);overflow-y:auto;background:rgba(20,16,10,.93);border:1px solid rgba(255,215,94,.35);border-radius:12px;padding:10px 12px;pointer-events:auto;display:none}
|
||||
#pc-crate h3{font-size:13px;color:#ffd75e;margin:0 0 6px;font-weight:600;letter-spacing:.04em}
|
||||
.pc-crate-row{display:flex;gap:9px;align-items:center;padding:5px 0;border-top:1px solid rgba(255,244,230,.08)}
|
||||
.pc-thumb{width:44px;height:44px;flex:none;border-radius:4px;background-color:#3a3128;background-repeat:no-repeat;display:flex;align-items:center;justify-content:center;color:rgba(255,244,230,.55);font-size:17px;box-shadow:0 1px 3px rgba(0,0,0,.5)}
|
||||
.pc-crate-main{min-width:0}
|
||||
.pc-crate-title{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.pc-crate-artist{opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.pc-crate-meta{font-size:11px;opacity:.62}
|
||||
.pc-crate-empty{opacity:.7;padding:4px 0 2px}
|
||||
`;
|
||||
|
||||
export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }) {
|
||||
@ -53,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;
|
||||
|
||||
@ -98,6 +115,189 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
||||
.catch(() => {}); // index missing/blocked → fallback stands, zero errors
|
||||
})();
|
||||
|
||||
// ── v7 THE GAME (R30, ledger #4): the collection panel + the SLEEP surface + day/cash readout.
|
||||
// Built LAZILY on the first update() frame that sees a truthy `window.PROCITY.game` — the shell
|
||||
// constructs the game layer AFTER the HUD, and under ?classic=1 / ?game=0 it is null forever, so
|
||||
// NONE of this DOM (bar, panel, KeyC listener, help-line suffix) ever exists on those boots: the
|
||||
// R19 selector's construction-gating, applied to the game (the classic-pure law, ledger #5's gate
|
||||
// measures it). Consumes ONLY the LANE_F_NOTES §30.1 contract: day / cash / collection (read-only
|
||||
// — this UI never mutates it) / sleep(). Cover thumbs resolve the entry's sku against its pack
|
||||
// index (per-shop godverse base when godverseShopId is set, else the town-wide base) — fail-soft:
|
||||
// missing pack / unknown sku / remote atlas / ?noassets ⇒ the type-glyph placeholder stands,
|
||||
// zero errors (the R21 towns-index discipline). Parody finds (slotId, no sku) have no thumb by
|
||||
// design (§30.4). ──
|
||||
let gameUI = null; // built once; null forever when the game layer is absent
|
||||
let onGameKey = null; // KeyC crate toggle — registered only when the game UI exists
|
||||
const shopById = new Map((plan.shops || []).map((s) => [s.id, s]));
|
||||
const GAME_NOASSETS = (() => { try { const v = new URLSearchParams(location.search).get('noassets'); return v != null && v !== '0'; } catch { return false; } })();
|
||||
const TYPE_GLYPH = { record: '♪', book: '¶', toy: '✦' };
|
||||
|
||||
const _packIdx = new Map(); // `${base}|${type}` → Promise<index.json|null> — B's own read-only cache
|
||||
function packIndexFor(entry) {
|
||||
if (GAME_NOASSETS || !entry || !entry.sku || !entry.type) return Promise.resolve(null);
|
||||
const base = entry.godverseShopId != null ? `assets/stock_godverse/${entry.godverseShopId}/` : 'assets/models/';
|
||||
const key = base + '|' + entry.type;
|
||||
if (!_packIdx.has(key))
|
||||
_packIdx.set(key, fetch(`${base}stock_${entry.type}_index.json`).then((r) => (r.ok ? r.json() : null)).catch(() => null));
|
||||
return _packIdx.get(key).then((idx) => (idx && Array.isArray(idx.items) ? { idx, base } : null));
|
||||
}
|
||||
function applyThumb(el, entry) {
|
||||
packIndexFor(entry).then((p) => {
|
||||
if (!p || !el.isConnected) return;
|
||||
const it = p.idx.items.find((i) => i && String(i.id) === entry.sku);
|
||||
if (!it || !it.atlas || !Array.isArray(it.uv) || /^depot:|^https?:/.test(it.atlas)) return;
|
||||
const [u0, v0, u1, v1] = it.uv, du = u1 - u0, dv = v1 - v0;
|
||||
if (!(du > 0) || !(dv > 0)) return;
|
||||
el.textContent = ''; // atlas crop via CSS background — uv origin is top-left (image-natural), same as CSS
|
||||
el.style.backgroundImage = `url("${p.base + it.atlas}")`;
|
||||
el.style.backgroundSize = `${100 / du}% ${100 / dv}%`;
|
||||
el.style.backgroundPosition = `${du < 1 ? (u0 / (1 - du)) * 100 : 0}% ${dv < 1 ? (v0 / (1 - dv)) * 100 : 0}%`;
|
||||
});
|
||||
}
|
||||
// where the find happened: this boot's town → the shop's name off the plan; another town's find →
|
||||
// the town part of its townKey (`plansrc/town@seed`) — honest, never guessed.
|
||||
function foundAt(entry, game) {
|
||||
if (entry.townKey === game.townKey) { const s = shopById.get(entry.shopId); if (s && s.name) return s.name; }
|
||||
const m = /^[^/]+\/([^@]+)@/.exec(entry.townKey || '');
|
||||
return m ? m[1].replace(/_/g, ' ') : '?';
|
||||
}
|
||||
|
||||
function buildGameUI(game) {
|
||||
const wrap = document.createElement('div'); wrap.id = 'pc-game';
|
||||
const panel = document.createElement('div'); panel.id = 'pc-crate';
|
||||
const bar = document.createElement('div'); bar.id = 'pc-gamebar';
|
||||
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>`;
|
||||
// [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, shownWants = null, open = false;
|
||||
|
||||
function rebuildList() {
|
||||
const col = game.collection || [];
|
||||
panel.textContent = '';
|
||||
const h = document.createElement('h3');
|
||||
const spent = col.reduce((a, e) => a + (e.pricePaid || 0), 0);
|
||||
h.textContent = `THE CRATE — ${col.length} find${col.length === 1 ? '' : 's'} · $${spent} spent`;
|
||||
panel.appendChild(h);
|
||||
if (!col.length) {
|
||||
const d = document.createElement('div'); d.className = 'pc-crate-empty';
|
||||
d.textContent = 'Empty — go dig.'; panel.appendChild(d);
|
||||
}
|
||||
for (let i = col.length - 1; i >= 0; i--) { // newest find first
|
||||
const e = col[i];
|
||||
const row = document.createElement('div'); row.className = 'pc-crate-row';
|
||||
const th = document.createElement('div'); th.className = 'pc-thumb';
|
||||
th.textContent = TYPE_GLYPH[e.type] || '♦';
|
||||
applyThumb(th, e);
|
||||
const main = document.createElement('div'); main.className = 'pc-crate-main';
|
||||
const t = document.createElement('div'); t.className = 'pc-crate-title';
|
||||
t.textContent = e.title || e.sku || e.slotId || 'unknown'; // C's fallback rule (§30.1)
|
||||
const a = document.createElement('div'); a.className = 'pc-crate-artist';
|
||||
a.textContent = e.artist || '';
|
||||
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);
|
||||
// [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) {
|
||||
open = v; panel.style.display = v ? 'block' : 'none';
|
||||
if (v) { shownCount = (game.collection || []).length; rebuildList(); }
|
||||
}
|
||||
btnCrate.onclick = () => setOpen(!open);
|
||||
// SLEEP — enabled any time (alpha; home-base gating is beta). The shell's onDay hook owns the
|
||||
// wake toast; the HUD just refreshes its readouts off the contract getters.
|
||||
btnSleep.onclick = () => {
|
||||
try { game.sleep(); } catch (err) { console.warn('[hud] sleep failed:', err); }
|
||||
refresh();
|
||||
};
|
||||
function refresh() {
|
||||
const col = game.collection || [];
|
||||
if (game.day !== shownDay) { shownDay = game.day; elDay.textContent = shownDay; }
|
||||
if (game.cash !== shownCash) { shownCash = game.cash; elCash.textContent = shownCash; }
|
||||
if (col.length !== shownCount) {
|
||||
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) => { // [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, 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, wrap }; // wrap rides document.body → dispose() must remove it itself
|
||||
}
|
||||
|
||||
const ray = new THREE.Raycaster();
|
||||
ray.far = 6;
|
||||
const centre = new THREE.Vector2(0, 0);
|
||||
@ -136,6 +336,12 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
||||
frame++;
|
||||
if (frame % 6 === 0) raycastDoor();
|
||||
|
||||
// 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) {
|
||||
const h = hovered.hours;
|
||||
@ -174,9 +380,24 @@ 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,7 +19,9 @@ 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)
|
||||
|
||||
const EYE = 1.6; // interior eye height (matches interior_test.html; rooms tuned for it)
|
||||
const RADIUS = 0.30; // player collision radius inside the room
|
||||
@ -36,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
|
||||
@ -98,7 +105,7 @@ function applyLive(pack, live) {
|
||||
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
|
||||
dig: digEnabled = false, stockReal = false, wallet = null,
|
||||
occupancyOf = null, gigState = null, rosterOf = null,
|
||||
liveBase = null, sourcingOf = null }) {
|
||||
liveBase = null, sourcingOf = null, game = null }) {
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color('#0e0b07');
|
||||
|
||||
@ -114,9 +121,13 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
let current = null; // active Lane C interior handle
|
||||
let currentShop = null; // the shop record for the active interior (dig per-bin seeding)
|
||||
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)
|
||||
let sellCard = null; // [R30 §9.4] Lane C sell card, created lazily on first counter-E (only when the game is on)
|
||||
|
||||
const _fwd = new THREE.Vector3();
|
||||
const _right = new THREE.Vector3();
|
||||
@ -127,7 +138,12 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
// in-game *input/mode* hook (its LANE_C_NOTES contract) — the same consumer pattern as Lane C's
|
||||
// interior_test.html: aim at a record bin, press E → open the riffle, which owns the frame + cursor
|
||||
// (pointer must be UNLOCKED — the shell handles that on procity:digOpen). Flag off ⇒ none of this runs.
|
||||
function binUnderAim() {
|
||||
// [R30 §9.4 amendment] strictAim skips the proximity fallback. The fallback ignores aim entirely
|
||||
// (nearest bin within 2.5 m of the CAMERA), so at a counter that sits near a bin it would swallow
|
||||
// the E and the sell card could never open there. The routing below therefore asks twice: first
|
||||
// AIMED-only (the contract's "aimed bin → dig"), then — only after the sell trigger has declined —
|
||||
// the forgiving nearest-bin pick, so imperfect aim at a bin still opens the riffle away from a sale.
|
||||
function binUnderAim(strictAim = false) {
|
||||
if (!current) return null;
|
||||
const bins = [];
|
||||
current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') bins.push(o); });
|
||||
@ -139,6 +155,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
while (o && !(o.userData && o.userData.kind === 'bin')) o = o.parent;
|
||||
return o;
|
||||
}
|
||||
if (strictAim) return null;
|
||||
// Fallback (matches Lane C's interior_test.html): nearest bin within reach. Uses the bin GROUP's
|
||||
// world position, so it works even before the bin's GLB mesh has finished loading (async) and is
|
||||
// forgiving of imperfect aim — stand next to a bin, press E.
|
||||
@ -153,15 +170,53 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
if (dig.active) return;
|
||||
const p = new THREE.Vector3(); bin.getWorldPosition(p);
|
||||
const key = Math.round(p.x * 100) + '_' + Math.round(p.z * 100); // stable per-bin key (deterministic pos)
|
||||
// [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), count: 16,
|
||||
seed: binSeed((currentShop && currentShop.seed) || 1, key + dayTag), count: 16,
|
||||
shopName: (current && current.recipe && current.recipe.label) || (currentShop && currentShop.name),
|
||||
shop: currentShop,
|
||||
stockAdapter: currentAdapter, // [F2 §stock=real] real covers in the riffle (null → parody)
|
||||
gone: game ? game.pullsFor(pullPrefix) : undefined, // [R32] this bin's bought slots stay gone today
|
||||
// [F R8 — Lane C buy loop v0] bind the session wallet so pulling a sleeve deducts cash + banks it.
|
||||
// dig removes the pulled sleeve from the bin; onBuy returns false when broke (no state change).
|
||||
// [R30 — THE COLLECTION] a successful buy also records the find into game.collection (§30.1 entry
|
||||
// shape): a dig pull is a 'record' wherever it happens (an opshop bin is still a record crate);
|
||||
// sku = the pack item id when real/mint, else slotId = the bin's day-salted key + offer index.
|
||||
// [R32] the find carries its BAND (the sell counter's §9.1 basis) and the pull enters the save's
|
||||
// ledger — the slot is gone for the rest of the day even if the item is later sold on.
|
||||
getCash: wallet ? () => wallet.cash() : undefined,
|
||||
onBuy: wallet ? (item) => wallet.buy(item) : 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) {
|
||||
const sku = o && o.item && o.item.id != null ? String(o.item.id) : null;
|
||||
game.recordFind(currentShop, {
|
||||
type: 'record',
|
||||
sku: sku ?? undefined,
|
||||
slotId: sku != null ? undefined : `${key}${dayTag}#${(o && o.i) ?? 0}`,
|
||||
title: o && (o.t || o.title), artist: o && (o.a || o.artist),
|
||||
price: (o && o.price) || 0,
|
||||
band: (o && o.band) || undefined,
|
||||
});
|
||||
game.recordPull(pullPrefix + (sku != null ? 'sku:' + sku : String((o && o.i) ?? 0)));
|
||||
// [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')),
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('procity:digOpen')); // shell releases pointer-lock for the cursor
|
||||
@ -190,6 +245,16 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
if (!best) return;
|
||||
const item = best.item || {};
|
||||
if (wallet.buy({ title: item.title, artist: item.artist, price: item.price || 20 })) {
|
||||
// [R30 — THE COLLECTION] record the find (§30.1): a shelf buy's type is the pack's slot type,
|
||||
// which is the shop type by construction (SLOT_FOR: book→spine, toy→box — the only shelf packs).
|
||||
if (game) game.recordFind(currentShop, {
|
||||
type: currentShop && currentShop.type,
|
||||
sku: item.id != null ? String(item.id) : undefined,
|
||||
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`);
|
||||
} else {
|
||||
@ -197,11 +262,41 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
}
|
||||
}
|
||||
|
||||
// [Lane F R30 — THE SELL ROUTING (C's §9.4, F's held join)] E at the keeper's counter holding items
|
||||
// this shop trades in → C's sell card. F does both halves of the §9.3 seam, in this order: removal
|
||||
// via THE GAME API first (identity — removeFind false ⇒ keeper veto, nothing moves, and no credit
|
||||
// can ever be minted for an item that is not in the collection), then the credit through the SAME
|
||||
// wallet facade every buy seam uses. C's card never touches game state; this closure is the only
|
||||
// place a sale moves money or the collection. No game layer ⇒ trySell is a no-op (classic-pure, C §9.5).
|
||||
function trySell() {
|
||||
if (!game || !wallet || !current || !currentShop) return false;
|
||||
if (!nearCounter(current, camera.position)) return false; // §9.4: within SELL_DIST of the bench
|
||||
if (!sellCard) sellCard = createSell();
|
||||
if (sellCard.active) return true;
|
||||
const opened = sellCard.open({
|
||||
shopType: currentShop.type,
|
||||
shopName: (current.recipe && current.recipe.label) || currentShop.name,
|
||||
items: game.collection, // READ-ONLY — sell.js cards a filtered copy (§9.3)
|
||||
getCash: () => wallet.cash(),
|
||||
emitters: current.audio && current.audio.emitters,
|
||||
onSell: (item, offer) => {
|
||||
if (!game.removeFind(item)) return false; // not in the collection ⇒ veto: card unchanged, $0 moves
|
||||
wallet.sell(item, offer); // credit rides the proven seam (mirrors wallet.js sell)
|
||||
return true;
|
||||
},
|
||||
onClose: () => window.dispatchEvent(new CustomEvent('procity:sellClose')),
|
||||
});
|
||||
if (opened) window.dispatchEvent(new CustomEvent('procity:sellOpen')); // shell releases pointer-lock (dig precedent)
|
||||
return opened;
|
||||
}
|
||||
|
||||
if (digEnabled || wallet) {
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.code !== 'KeyE' || !current || (dig && dig.active)) return;
|
||||
if (digEnabled) { const bin = binUnderAim(); if (bin) { openDig(bin); return; } } // bin → riffle
|
||||
buyShelfUnderAim(); // shelf → buy
|
||||
if (e.code !== 'KeyE' || !current || (dig && dig.active) || (sellCard && sellCard.active)) return;
|
||||
if (digEnabled) { const bin = binUnderAim(true); if (bin) { openDig(bin); return; } } // AIMED bin → riffle (§9.4 first branch)
|
||||
if (trySell()) return; // [R30 §9.4] near the counter with ≥1 sellable → the sell card
|
||||
if (digEnabled) { const bin = binUnderAim(); if (bin) { openDig(bin); return; } } // forgiving nearest-bin (away from a sale)
|
||||
buyShelfUnderAim(); // else shelf → buy
|
||||
});
|
||||
}
|
||||
|
||||
@ -276,7 +371,15 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
// (stage + watch points still there, just nobody on them), the "quiet night" Lane C specced.
|
||||
const gigOn = !!(gigState && gigState.onOf(shop.id));
|
||||
const tonight = gigOn ? gigState.gigOf(shop.id) : null;
|
||||
// [R30 §30.3 — SLEEP=TOMORROW] the day salt for this room's stock pick streams. RUNTIME ONLY (the
|
||||
// plan never sees day — A verifies the boundary). Null (= no rotation, byte-identical to pre-v7)
|
||||
// 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,
|
||||
gig: gigOn ? { on: true, genreKey: tonight.genreKey || 'pubrock', gigId: tonight.gigId } : null });
|
||||
currentShop = shop;
|
||||
scene.add(current.group);
|
||||
@ -327,9 +430,28 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
exitArmed = false;
|
||||
let hasBins = false;
|
||||
if (digEnabled) current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') hasBins = true; });
|
||||
// [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' : '');
|
||||
+ (hasBins ? ' · E: riffle a record bin' : '')
|
||||
+ (canSell ? ' · E at the counter: sell' : '')
|
||||
+ rumor;
|
||||
banner.style.display = 'block';
|
||||
return current;
|
||||
}
|
||||
@ -387,12 +509,15 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
if (!current) return;
|
||||
window.PROCITY.audio && window.PROCITY.audio.stopInterior(); // [Lane F R11 audio] fade the interior bed out (0.7s)
|
||||
if (dig && dig.active) dig.close(); // [F2] close an open riffle before leaving (frees its per-open GPU)
|
||||
if (sellCard && sellCard.active) sellCard.close(); // [R30] the sell card never outlives the room
|
||||
keepers.disposeAll(); // Lane D: free the keeper actor(s) before the room
|
||||
if (crew) { crew.disposeAll(); crewInfo = null; } // [R12] band + crowd out before the room goes
|
||||
current.dispose();
|
||||
current = null;
|
||||
currentShop = null;
|
||||
currentAdapter = null;
|
||||
currentStockDay = null;
|
||||
currentRealSourced = false;
|
||||
banner.style.display = 'none';
|
||||
toast.style.display = 'none'; _toastT = 0;
|
||||
if (doorReturn) {
|
||||
@ -412,6 +537,10 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
||||
get crew() { return crew; }, // [R12] Lane D GigCrew (null until a gig has been entered)
|
||||
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) {
|
||||
|
||||
388
web/js/world/save.js
Normal file
@ -0,0 +1,388 @@
|
||||
// PROCITY Lane F — save.js [F-owned, round 30 / v7.0-alpha THE SAVE CORE]
|
||||
// The persistence foundation of THE GAME (V7 charter #1). Versioned localStorage save implementing
|
||||
// THE DELTA LAW: a save carries ONLY player deltas — cash, owned items, current town, day number.
|
||||
// The world is NEVER saved; it regenerates from seed (the seeded-everything law's biggest dividend:
|
||||
// saves are tiny and cannot corrupt the town). Contract published in LANE_F_NOTES §30.1 — B builds
|
||||
// the collection UI on it, C reads it at the sell counter.
|
||||
//
|
||||
// LAW (classic-pure, wired from birth): this module performs ZERO storage access at module scope.
|
||||
// Under ?classic=1 / ?game=0 createGame() is never called, so a classic boot touches localStorage
|
||||
// exactly never — the ledger-#5 classic-purity gate measures that rather than trusting it.
|
||||
//
|
||||
// The wallet facade: when the game is on, PROCITY.wallet IS game.wallet — the full Lane C v0
|
||||
// interface (cash/start/canBuy/buy/sell/inventory/count/onChange, buy/sell semantics mirroring
|
||||
// wallet.js exactly) backed by game-owned cash, so a loaded save's cash is authoritative. The
|
||||
// debit/credit seam every consumer already uses (dig onBuy, shelf buy, the cover charge, C's
|
||||
// onSell) is therefore unchanged — the proven seam, new backing.
|
||||
|
||||
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
|
||||
// the town (world state isn't in the save, so it can't).
|
||||
function validEntry(e) {
|
||||
return !!e && typeof e === 'object'
|
||||
&& typeof e.townKey === 'string' && e.townKey.length > 0
|
||||
&& (e.shopId != null || e.godverseShopId != null)
|
||||
&& ((typeof e.sku === 'string' && e.sku.length > 0)
|
||||
|| (typeof e.slotId === 'string' && e.slotId.length > 0))
|
||||
&& Number.isFinite(e.pricePaid) && e.pricePaid >= 0
|
||||
&& Number.isInteger(e.dayFound) && e.dayFound >= 1;
|
||||
}
|
||||
|
||||
// → { ok:true, state } | { ok:false, why }
|
||||
export function validateSave(obj) {
|
||||
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return { ok: false, why: 'not an object' };
|
||||
if (obj.schema !== SAVE_SCHEMA) return { ok: false, why: `schema ${JSON.stringify(obj.schema)} != ${SAVE_SCHEMA}` };
|
||||
if (!Number.isInteger(obj.day) || obj.day < 1) return { ok: false, why: `day ${obj.day} (want int >= 1)` };
|
||||
if (!Number.isFinite(obj.cash) || obj.cash < 0) return { ok: false, why: `cash ${obj.cash} (want finite >= 0)` };
|
||||
if (typeof obj.town !== 'string' || !obj.town) return { ok: false, why: 'town missing' };
|
||||
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 };
|
||||
}
|
||||
|
||||
// createGame({ townKey, startCash, storage?, onDay? })
|
||||
// townKey — this boot's `${plansrc}/${town||'default'}@${seed}` (the shell builds it)
|
||||
// startCash — fresh-game cash (the shell passes Lane C's seeded wallet.start(), so a fresh game
|
||||
// opens with exactly the cash a pre-v7 boot did)
|
||||
// storage — injectable for tests; defaults to window.localStorage (touched only from here on)
|
||||
// onDay(day)— shell hook fired after sleep()/import() changes the day (gig weekNight re-key,
|
||||
// wake at dawn). NOT fired for the boot-time load — the shell reads game.day itself.
|
||||
export function createGame({ townKey, startCash = 0, storage = null, onDay = null } = {}) {
|
||||
const store = storage || (typeof localStorage !== 'undefined' ? localStorage : null);
|
||||
|
||||
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;
|
||||
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; }
|
||||
}
|
||||
|
||||
function adopt(state) { // state has already passed validateSave
|
||||
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() {
|
||||
if (!store) return false;
|
||||
let raw = null;
|
||||
try { raw = store.getItem(SAVE_KEY); } catch (e) { return false; }
|
||||
if (raw == null) return false; // fresh machine — fresh game, silently
|
||||
let obj = null;
|
||||
try { obj = JSON.parse(raw); } catch (e) { return reject(raw, 'not JSON: ' + (e && e.message)); }
|
||||
const v = validateSave(obj);
|
||||
if (!v.ok) return reject(raw, v.why);
|
||||
adopt(v.state);
|
||||
return true;
|
||||
}
|
||||
|
||||
function reject(raw, why) { // LOUD, stashed, fresh start. The town never breaks.
|
||||
console.error(`[procity save] REJECTED (${why}) — fresh start. The rejected blob is stashed at `
|
||||
+ `localStorage['${SAVE_KEY}.rejected']; the world is seeded and untouched.`);
|
||||
try { store && store.setItem(SAVE_KEY + '.rejected', String(raw)); } catch (e) { /* stash is best-effort */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── the wallet facade — Lane C's v0 interface, game-backed (semantics mirror wallet.js) ──────
|
||||
const listeners = new Set();
|
||||
const notify = () => listeners.forEach((fn) => { try { fn(); } catch (e) {} });
|
||||
const inv = []; // session inventory (v0 semantics — covers included)
|
||||
const wallet = {
|
||||
cash: () => cash,
|
||||
start: () => Math.max(0, Math.floor(+startCash || 0)),
|
||||
canBuy: (price) => (price || 0) <= cash,
|
||||
buy(o) { // mirrors wallet.js buy() exactly
|
||||
const price = (o && o.price) || 0;
|
||||
if (price > cash) return false;
|
||||
cash -= price;
|
||||
inv.push({ title: o.t || o.title || '?', artist: o.a || o.artist || '', price, band: (o.s || o.price_band || '') });
|
||||
notify();
|
||||
return true;
|
||||
},
|
||||
sell(o, offer) { // mirrors wallet.js sell() (C §9.3): credit + drop v0 entry
|
||||
const amt = Math.max(0, Math.floor(+offer || 0));
|
||||
cash += amt;
|
||||
let i = inv.indexOf(o);
|
||||
if (i < 0) i = inv.findIndex((x) => x.title === (o.t || o.title) && x.artist === (o.a || o.artist || ''));
|
||||
if (i >= 0) inv.splice(i, 1);
|
||||
notify();
|
||||
return true;
|
||||
},
|
||||
inventory: () => inv.slice(),
|
||||
count: () => inv.length,
|
||||
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
|
||||
};
|
||||
|
||||
const game = {
|
||||
get day() { return day; },
|
||||
get cash() { return cash; },
|
||||
get collection() { return collection; }, // LIVE array — READ-ONLY by contract (§30.1)
|
||||
townKey,
|
||||
wallet,
|
||||
|
||||
save,
|
||||
load,
|
||||
|
||||
// sleep(): day+1 → shell's onDay (gig weekNight = day % 7, wake at DAWN) → save. Increment-then-
|
||||
// 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(payload());
|
||||
},
|
||||
|
||||
import(json) { // loud reject leaves CURRENT state untouched
|
||||
let obj = null;
|
||||
try { obj = JSON.parse(json); }
|
||||
catch (e) { console.error('[procity save] import REJECTED (not JSON):', e && e.message || e); return false; }
|
||||
const v = validateSave(obj);
|
||||
if (!v.ok) { console.error(`[procity save] import REJECTED (${v.why}) — state untouched`); return false; }
|
||||
adopt(v.state);
|
||||
save();
|
||||
if (onDay) { try { onDay(day); } catch (e) { console.warn('[procity save] onDay hook threw:', e); } }
|
||||
notify();
|
||||
return true;
|
||||
},
|
||||
|
||||
// ── the buy seam (interior_mode calls this after a successful wallet.buy) ──────────────────
|
||||
// info: { type, sku?|slotId?, title?, artist?, price } — entry shape per §30.1. Each purchase is
|
||||
// its own entry (two copies of a sku are two entries; removal is per-entry identity).
|
||||
recordFind(shop, info) {
|
||||
const e = {
|
||||
townKey,
|
||||
shopId: shop && shop.id != null ? shop.id : null,
|
||||
type: (info && info.type) || null, // load-bearing: sellability is type = type (C §9.2)
|
||||
pricePaid: Math.max(0, Math.floor((info && info.price) || 0)),
|
||||
dayFound: day,
|
||||
};
|
||||
if (shop && shop.godverseShopId != null) e.godverseShopId = shop.godverseShopId;
|
||||
if (info && info.sku != null) e.sku = String(info.sku);
|
||||
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;
|
||||
},
|
||||
|
||||
// ── the sell seam (C's counter: onSell ⇒ wallet.sell(item, offer) + removeFind(entry)) ─────
|
||||
removeFind(entry) { // identity removal — the ONLY way collection shrinks
|
||||
const i = collection.indexOf(entry);
|
||||
if (i < 0) return false;
|
||||
collection.splice(i, 1);
|
||||
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.
|
||||
load();
|
||||
if (typeof window !== 'undefined') window.addEventListener('beforeunload', () => { save(); });
|
||||
|
||||
return game;
|
||||
}
|
||||
@ -259,7 +259,13 @@ export function createVenuePresentation(plan, skins, scene, { assetBase = 'asset
|
||||
|
||||
return {
|
||||
group, venues, update, dispose,
|
||||
venueShopId: primaryId || (venues[0] && venues[0].venueShopId) || null, // alpha compat
|
||||
// alpha compat — [Lane B R30, F's §30.4 filing] "tonight" now tracks the game day: a live getter
|
||||
// keyed to gigs' night = weekNight (0 pre-game and on day 1 ⇒ exact pre-v7 value, gigs[0]'s venue).
|
||||
get venueShopId() {
|
||||
const n = (typeof window !== 'undefined' && window.PROCITY?.gigs?.weekNight) || 0;
|
||||
const g = gigs.find((x) => x.night === n) || (primaryId != null ? gigs[0] : null);
|
||||
return (g && g.venueShopId) || (venues[0] && venues[0].venueShopId) || null;
|
||||
},
|
||||
venueShopIds: venues.map((v) => v.venueShopId),
|
||||
queueZones,
|
||||
queueZoneFor(id) { const v = venues.find((x) => x.venueShopId === id); return v ? v.queueZone : null; },
|
||||
|
||||