Compare commits
62 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 | ||
|
|
cdce67c8f8 | ||
|
|
488067004c | ||
|
|
640bdbb55d | ||
|
|
999c9d653f | ||
|
|
afad68b114 | ||
|
|
d9100962f6 | ||
|
|
e93bece0f4 | ||
|
|
b2d29ba112 | ||
|
|
c1e862cf7c | ||
|
|
94fce8ea4b | ||
|
|
8a1d2e91e8 | ||
|
|
4c2702fc58 | ||
|
|
83ed848f16 | ||
|
|
630042fbe1 | ||
|
|
f91d734b32 | ||
|
|
39cfc3d3ed | ||
|
|
70743db47e | ||
|
|
bee7563a76 | ||
|
|
cedc5872fb | ||
|
|
6960bdd279 | ||
|
|
01f398960b | ||
|
|
839575b28d | ||
|
|
2cc6c32c73 |
@ -4,6 +4,25 @@
|
|||||||
plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's
|
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.
|
`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
|
## 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.
|
Ship-v2.0 round. Two deliverables, both v2-law clean, `qa.sh --strict` GREEN, flags-off byte-identical.
|
||||||
|
|||||||
185
C-progress.md
@ -3,7 +3,190 @@
|
|||||||
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
|
*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.*
|
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
|
||||||
|
|
||||||
Last updated: 2026-07-17 (round 26) · owner: PROCITY-C · reviewer: Fable
|
Last updated: 2026-07-18 (round 30) · owner: PROCITY-C · reviewer: Fable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### The trap my own parked note set
|
||||||
|
v3.0's deferred note said: *"`userData.audioEmitter = musicKey` so Lane B can position/spatialise the
|
||||||
|
bed"*. **Both halves were wrong**, and the second one is dangerous:
|
||||||
|
|
||||||
|
1. **The shape.** The engine holds no reference to the room group — it would have to traverse a scene it
|
||||||
|
cannot reach. Emitters belong on `room.audio` as data, where they ride the **existing**
|
||||||
|
`playInterior(room.audio)` call: no new lifecycle, no traverse, no dispose hook, no F plumbing.
|
||||||
|
2. **The method.** "Spatialise" invites a `PannerNode`. **Nothing in this tree sets `ctx.listener`** (zero
|
||||||
|
hits, whole tree), so a panner resolves against a listener frozen at (0,0,0) — and since rooms are built
|
||||||
|
in room-local coords at the origin, that is *the room's own centre, forever*. Measured with
|
||||||
|
`OfflineAudioContext` on a real pub stage bed:
|
||||||
|
|
||||||
|
| wiring | RMS at door | RMS at stage | ratio |
|
||||||
|
|---|---|---|---|
|
||||||
|
| panner, listener never set (**the engine as it ships**) | 0.106905 | 0.106905 | **1.0000** |
|
||||||
|
| panner + listener tracking the player | 0.055665 | 0.500000 | 8.98 |
|
||||||
|
|
||||||
|
It **plays, throws nothing, warns nothing, and never changes by one sample as you cross the room** —
|
||||||
|
while failing at the only thing it exists to do. *"Routed through a panner?"* yes. *"Audible in a
|
||||||
|
venue?"* yes. *"In a shop?"* yes. All green, all worthless. **The only assertion that can see this is a
|
||||||
|
DELTA: gain at the door ≠ gain at the stage.** Vacuous-gate law, 4th application — and this one had no
|
||||||
|
author to blame but me. There is no listener code anywhere to teach a lane otherwise.
|
||||||
|
|
||||||
|
**The house idiom was already right**: tram rumble (R=55), door spill (R=9), gig spill (R=26) are all
|
||||||
|
hand-rolled distance-gains against `PROCITY.camera.position`. No listener, no panner. Emitters match it.
|
||||||
|
|
||||||
|
### What shipped (C's files only)
|
||||||
|
- **`room.audio.emitters`** = `{ stage?, counter? }`, room-local, 3-dp, **18/18 same-seed deep-equal**.
|
||||||
|
`r` (reach) is the **room diagonal, not a constant** — measured diagonals 9.81–15.63 m, and scaling to
|
||||||
|
the room keeps door→stage proximity in a tight **0.42–0.59** band from a pokey band_room to a hall pub.
|
||||||
|
`floor` is the room-filling residue, **never 0** — a PA fills a pub; what changes as you walk in is the
|
||||||
|
*balance*, not the presence. Swing **2.65–4.52 dB** (John R20: *subtle changes are great*).
|
||||||
|
- **The till rings from the counter** — *audible positioning in a normal shop with **no engine change***,
|
||||||
|
via B's already-public `playSfx(key,{gain})`: C does its own distance math, hands B a scalar. Measured
|
||||||
|
through the real BUY button in a real record shop: **0.9489 @ 0.60 m vs 0.6436 @ 4.19 m = 3.37 dB**;
|
||||||
|
negative control (no emitters) fires at exactly **1.0**. `sfx-till.ogg` had been on disk since 15 Jul and
|
||||||
|
had **never once played**. Routing, not content — exactly as the brief said.
|
||||||
|
- **`?mute=1` now silences the crate riffle.** See below.
|
||||||
|
|
||||||
|
**Why the venue half is an ask, not a ship:** a **one-shot** resolves its gain once, at fire time, from a
|
||||||
|
position C already knows. A **bed** must be re-gained every frame inside the engine's `update()` loop —
|
||||||
|
B's file. The split is structural, not political. B got a ~12-line paste-ready diff in the house idiom; C
|
||||||
|
did not touch audio.js. The letter of "consulted not modified" could have been satisfied by reaching
|
||||||
|
through B's exposed `state.layers` diag to rewire its graph from outside — **that backdoor is strictly
|
||||||
|
worse than the edit it avoids** (invisible to B, breaks on any change of B's). Refused.
|
||||||
|
|
||||||
|
### Two bugs found on the way
|
||||||
|
- **F drops emitters on the one night they matter.** `interior_mode.js:289` rebuilds the spec on a gig
|
||||||
|
night: `{ musicKey: ra.gigKey, toneKey: WALLA_KEY }` — every unnamed field dies. Measured on a real
|
||||||
|
gig-night pub: `quietNight_emittersSurvive: true`, **`gigNight_emittersSurvive: false`**. Silent, fail-
|
||||||
|
soft, no error. -> F: don't just spread `ra` — pass emitters **deliberately, gig-night only**, because on
|
||||||
|
a quiet night the radio and room-tone genuinely *are* room-filling.
|
||||||
|
- **`?mute=1` never silenced the riffle (R5 → R28).** `dig.js` opened a **second, private AudioContext**
|
||||||
|
straight to `destination`, breaking both of audio.js's stated laws (*ONE AudioContext*; *mute forces
|
||||||
|
silence*). It survived three epochs because **no gate asserts mute ⇒ silence**: the engine returns its
|
||||||
|
silent surface and looks honest *from its own side*, while the sound came from somewhere it never knew
|
||||||
|
existed. *A gate that asks the engine whether it is muted can only ever answer yes.* Fixed by reading
|
||||||
|
B's **public** `.muted` getter (consulted, not modified), URL fallback when standalone. Measured with a
|
||||||
|
positive control so the probe is provably able to see sound: live 1 ctx / 1 source; **muted 0 / 0**.
|
||||||
|
Blips stay procedural on purpose — zero-fetch, deterministic, and they still work under `?noassets=1`
|
||||||
|
where a sampled riffle cannot. Only mute was broken; only mute was fixed.
|
||||||
|
|
||||||
|
### Harness honesty (again)
|
||||||
|
First till rig reported a clean 3.37 dB — from a **contaminated harness**. `createDig` appends a panel per
|
||||||
|
call and `close()` doesn't remove it, so `querySelector` was clicking **trial 1's stale button** every
|
||||||
|
time. The tell was `panelUp: false` on all three trials, and the **negative control** returning 0.6436
|
||||||
|
where it must return 1.0. The gains happened to match the formula exactly — I still refused them and
|
||||||
|
re-ran isolated (`panelCount: 1`, `panelUp: true`, control **1.0** ✓). R27's lesson holds: *never report a
|
||||||
|
number from a rig you have watched lie, even when you like the number.* The control is what caught it.
|
||||||
|
|
||||||
|
QA GREEN (6/0/0/0). Docs: **LANE_C_AUDIO.md → v3.1** (contract + both asks + every measurement above).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update 2026-07-17 (round 27, v5.0 THE LIVE CRATE) — the tier-2 seam + LANE_C_PUB v5-FROZEN
|
||||||
|
|
||||||
|
R27 §Lane C (ledgers #2 + #6). Wave 1 — ran after F tagged `v5.0-beta` (`78848ed`, 7/0/0).
|
||||||
|
|
||||||
|
### The seam ruling (§8): bless the direct wire — but the *form* was the real question
|
||||||
|
Fable asked: publish a post-build hook, or bless the direct wire? **Answer: bless it — no lifecycle hook.**
|
||||||
|
F already holds `currentAdapter` (`interior_mode.js:67`) and passes it to `dig.open()` **per dig** (`:111`),
|
||||||
|
so the live read lands, F updates the pack, and the next riffle picks it up. Read not landed / slow / dead ⇒
|
||||||
|
the adapter is simply the tier-1 pack ⇒ the dig never blocks. No C lifecycle change.
|
||||||
|
|
||||||
|
**But the question underneath it — *how* `gone` applies — is C's, and the obvious wiring is wrong.**
|
||||||
|
`pickRealOffers` indexes `pack.items[(rr()*len)|0]`, so **filtering the array reshuffles the crate**.
|
||||||
|
Measured across 5 crates: **one record selling changed 21 OTHER records** (6/2/2/6/5 collateral). Pick-then-
|
||||||
|
omit — draw from the stable full list, drop gone *after* — has **0 collateral**: a crate with 3 sold shows
|
||||||
|
13, not a different 16. Selling one record must remove one record, not re-stock the shop.
|
||||||
|
|
||||||
|
**And the trap: both forms hide the sold record.** So F's planned sandbox assertion — "a real `gone` item
|
||||||
|
never renders" — **goes green on the wrong wiring**. It cannot see the 21. → **F: assert on collateral (the
|
||||||
|
survivors are unchanged across a `gone` delta), not absence.** That's the vacuous-gate law's third
|
||||||
|
application, and the cheapest to miss, because it passes.
|
||||||
|
|
||||||
|
**Landed** (`dig.js`): `pack.gone` (Set|array) omitted at pick time; **F's invariant published** — update
|
||||||
|
item *fields* in place (live price) freely, **never add/remove/reorder `items[]`**; that identity+order IS
|
||||||
|
the seeded pick's stability (the same lesson as R25's positional ids, one layer up).
|
||||||
|
**Determinism boundary sits exactly here:** server down ⇒ byte-identical tier 1 (gate-able); server up ⇒ the
|
||||||
|
crate legitimately changes — tier 2 is non-deterministic *by design*, so **no gate may assert dig determinism
|
||||||
|
with the server up**, and `pack.gone` never enters a seeded stream or a golden.
|
||||||
|
**Scoped honestly:** the bin fan is decorative (a different seeded pick from the dig's offers), so a sold
|
||||||
|
cover may linger there until rebuild — that's when I'd publish a post-build hook, and the epoch's claim
|
||||||
|
doesn't need it, because *the crate you riffle is true*.
|
||||||
|
|
||||||
|
**Verified** — pure-function measurement for the collateral claim, then end-to-end through the real dig's
|
||||||
|
public surface (pull → Esc → scroll → pull): sold record gone · **survivors 4/4 · collateral 0** · `items[]`
|
||||||
|
untouched at 120 · live in-place price shows on the next pull. *My first dig harness returned the same title
|
||||||
|
4× (I never un-pulled), so its "survivors" number was a harness artifact — I re-ran it properly rather than
|
||||||
|
report a metric I'd just watched lie.*
|
||||||
|
|
||||||
|
### Docs freeze (§6): LANE_C_PUB is **v5.0-FROZEN**, whole document
|
||||||
|
Marked, with the case law kept at the top rather than buried: three of this doc's amendments exist because a
|
||||||
|
rule of mine met a case written *after* it (§7's naming vs its own loader; §7.2a's `sku_` before mint) — and
|
||||||
|
both times the lane building to my rule took the red. The header now says it plainly: **when a line here
|
||||||
|
meets a case it didn't foresee, the line is the stale thing — amend it, don't bend the case.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
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
|
## 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
|
**Every crate is different, and F measured it rather than taking G's word.** Not by entering one lucky
|
||||||
|
|||||||
@ -3,6 +3,63 @@
|
|||||||
*Lane brief: `docs/LANES/LANE_G_GODVERSE.md`. This file follows your progress-file convention —
|
*Lane brief: `docs/LANES/LANE_G_GODVERSE.md`. This file follows your progress-file convention —
|
||||||
newest round first. Fable-on-ultra writing.*
|
newest round first. Fable-on-ultra writing.*
|
||||||
|
|
||||||
|
## Round 27 (2026-07-17) — tier 2: the two read endpoints (ledger #1)
|
||||||
|
|
||||||
|
**Landed:** `pipeline/godverse_server.py` — `/health`, `/shop/<gid>/stock`, `/shop/<gid>/crates`,
|
||||||
|
plus `start|stop|status` (F's kill-the-server lifecycle), `setup-role`, and `make-fixture`.
|
||||||
|
Stdlib + `psql`; no server code existed before this round, per the ratified G3.
|
||||||
|
|
||||||
|
**Live now, against the real POS:** crate 550 — `{baked:120, live:120, gone:0}`, real prices, real
|
||||||
|
`VG+` grading, etag stable across reads and `?since=` → **304**. `/crates` returns **315 real
|
||||||
|
crates** with the shop's own labels — `TECHNO` (57), `$12-$15 TECHNO` (46), `DRUM N BASS` (66).
|
||||||
|
|
||||||
|
**The three fences, each enforced by the machine (measured, not reviewed):**
|
||||||
|
|
||||||
|
| fence | proof |
|
||||||
|
|---|---|
|
||||||
|
| the credential — `godverse_ro` | `UPDATE inventory` → **permission denied** · `SELECT FROM customer` → **permission denied** |
|
||||||
|
| write verbs absent (§7 ruling) | `POST /reserve` → **501** · `POST /buy` → **501** · `GET` either → 404 |
|
||||||
|
| metadata only | no `cover`, no atlas bytes on the wire — covers stay static tier-1 files |
|
||||||
|
| tier 2 = real shops only | a `mint` shop's `/stock` → **404** (no POS to be live about), not an empty crate |
|
||||||
|
| the id fence | `gone[]` carries the atlas `id` verbatim → `gone.includes(item.id)`, zero transform |
|
||||||
|
|
||||||
|
**One grant carries both the sandbox and the PII fence** — that's why §7 said enforce at the
|
||||||
|
credential, not in a code review. `godverse_ro` holds SELECT on inventory/crate/disc_cache and
|
||||||
|
nothing else, so customer/staff/sales are unreachable *by the process*, not by our good intentions.
|
||||||
|
|
||||||
|
**⚠ THE FINDING F NEEDS BEFORE IT WRITES THE GATE — `gone[]` has no subject in production.**
|
||||||
|
The mechanism is right; the data has no movement. Monster Robot Party's POS records **5,567 sales
|
||||||
|
ever** (400–800/month Dec→Jun) but **26 in July and none since 2026-07-01**, and hasn't been
|
||||||
|
*written* since **2026-07-09** — the 07-16 dump is fresh, its data is stale (I restored it to
|
||||||
|
check: crate 550 holds 122 on both 07-15 and 07-16, identical). **0 of the crate's 120 records
|
||||||
|
have ever sold.** So `gone` is `[]` against the real POS, permanently, until the shop trades again.
|
||||||
|
An assert over it would pass **without touching its subject** — the exact species that has held
|
||||||
|
four tags. So I built the honest way out: **`make-fixture --sold 3`** clones the POS
|
||||||
|
(`createdb -T`) and marks 3 of the crate's *own real skus* sold → verified
|
||||||
|
`{baked:120, live:117, gone:3}`, the three ids absent from `items[]`. F points the server at it
|
||||||
|
via `GODVERSE_POS_DB` and gets a deterministic gate; production reads the real POS untouched.
|
||||||
|
**A fixture is not a lie; a green assert over an absent subject is.**
|
||||||
|
|
||||||
|
**Lifecycle for the kill-the-server gate (ledger #3):** `stop` is a **SIGKILL**, not a graceful
|
||||||
|
drain — the gate tests a death, not a goodbye. Verified: `stop` → connection refused (curl rc=7,
|
||||||
|
no response at all) → `status: DOWN` → `start` → live again. F drives it; no admin verb was added
|
||||||
|
to the API, because a server whose whole point is "no write verbs" shouldn't grow a remote kill.
|
||||||
|
|
||||||
|
**Three corrections this round forced into my own G3 §4 (design → as-built):**
|
||||||
|
1. **`gone:[sku]` → `gone:[id]`.** §4 as *I ratified it* said bare skus — wording that predates
|
||||||
|
R25's sku ids and R26's fence law. A bare number crossing two id spaces is the precise bug that
|
||||||
|
law exists to stop. **My doc was the stale rule meeting a case written after it** — the epoch's
|
||||||
|
signature failure, and this time it was mine, in the doc I wrote.
|
||||||
|
2. `items[]` drops `release_id` and `cover`: `cover` edges the API toward serving pixels (it never
|
||||||
|
should), and `release_id` is the *pressing* while tier 2 speaks only in *copies*.
|
||||||
|
3. **Write verbs answer 501, not the 404 my own docstring claimed** — caught by running it. 501 is
|
||||||
|
the *stronger* absence proof: a 404 means a handler ran and declined; 501 means there is no
|
||||||
|
write handler in the process. **→ F: assert 501.**
|
||||||
|
|
||||||
|
**→ Lane F, the four asserts I can hand you pre-measured:** `POST /reserve|/buy` → 501 · a game
|
||||||
|
buy cannot reach the server (no verb *and* no grant — prove both) · `gone` fixture arm as above ·
|
||||||
|
server dead → connection refused, so the reader must treat it as tier 1 in silence.
|
||||||
|
|
||||||
## Round 26 (2026-07-17) — the mint baker, the crate menu, the manifest (ledgers #2, #6)
|
## Round 26 (2026-07-17) — the mint baker, the crate menu, the manifest (ledgers #2, #6)
|
||||||
|
|
||||||
**Landed (main, pathspec-atomic):**
|
**Landed (main, pathspec-atomic):**
|
||||||
|
|||||||
30
README.md
@ -14,9 +14,36 @@ cd web && python3 -m http.server 8130
|
|||||||
# http://localhost:8130/map.html (2D plan debugger — Lane A)
|
# 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
|
## Read first
|
||||||
|
|
||||||
- **`docs/CITY_SPEC.md`** — the treaty: schema, units, seeds, budgets, file ownership.
|
- **`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.
|
- **`docs/RESEARCH.md`** — what the parent repos already solved, with exact paths to port from.
|
||||||
|
|
||||||
## The lanes (parallel Opus 4.8 sessions)
|
## The lanes (parallel Opus 4.8 sessions)
|
||||||
@ -73,6 +100,7 @@ master off:
|
|||||||
| `?weather=0` · `?tram=0` · `?winmap=0` | **on** | seeded weather · the tram loop · the parallax interior-window shader |
|
| `?weather=0` · `?tram=0` · `?winmap=0` | **on** | seeded weather · the tram loop · the parallax interior-window shader |
|
||||||
| `?roster=v1` · `?pop=N` | stream | chunk-streamed citizen density is the baseline; `?roster=v1` restores the fixed roster |
|
| `?roster=v1` · `?pop=N` | stream | chunk-streamed citizen density is the baseline; `?roster=v1` restores the fixed roster |
|
||||||
| `?dig=0` · `?stock=real` | dig on | crate-riffle on record bins · real GODVERSE sleeves in the stock |
|
| `?dig=0` · `?stock=real` | dig on | crate-riffle on record bins · real GODVERSE sleeves in the stock |
|
||||||
|
| `?live=1` · `?live=<url>` | **off** | **tier 2** — live stock from the GODVERSE server (sold-in-the-real-shop means gone in the game). Opt-in by design: absent ⇒ byte-identical to tier 1, and the game never asks a question it has no reason to expect an answer to |
|
||||||
| `?noassets=1` · `?plansrc=osm` · `?mute=1` · `?dbg=1` | off | asset-free (zero network) · OSM plan source · silence · the `window.DBG` capture/soak harness |
|
| `?noassets=1` · `?plansrc=osm` · `?mute=1` · `?dbg=1` | off | asset-free (zero network) · OSM plan source · silence · the `window.DBG` capture/soak harness |
|
||||||
| `?plansrc=osm&town=<key>` | — | boot a **real Australian town**: fixtures `melbourne`/`katoomba`/`silverton`, or a real OSM cache `bendigo_real`/`castlemaine_real`/`fremantle_real`/`katoomba_real`/`newtown_real` (ODbL, `web/assets/towns/SOURCES.md`). **REAL ROADS:** a cache with `roads[]` (schema v2) builds the CityPlan street graph from the town's **actual OSM streets**; without `roads[]` a cache marches onto synthetic avenues. Or just **pick a town from the HUD selector** — no URL needed |
|
| `?plansrc=osm&town=<key>` | — | boot a **real Australian town**: fixtures `melbourne`/`katoomba`/`silverton`, or a real OSM cache `bendigo_real`/`castlemaine_real`/`fremantle_real`/`katoomba_real`/`newtown_real` (ODbL, `web/assets/towns/SOURCES.md`). **REAL ROADS:** a cache with `roads[]` (schema v2) builds the CityPlan street graph from the town's **actual OSM streets**; without `roads[]` a cache marches onto synthetic avenues. Or just **pick a town from the HUD selector** — no URL needed |
|
||||||
|
|
||||||
@ -115,7 +143,7 @@ the one below, so the game never depends on a server to be a game:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **0 · parody** | seeded canvas sleeves (v1) — any boot, always works | yes — the QA baseline |
|
| **0 · parody** | seeded canvas sleeves (v1) — any boot, always works | yes — the QA baseline |
|
||||||
| **1 · static real** | per-shop atlases baked from the shop's own POS | yes — atlases are files |
|
| **1 · static real** | per-shop atlases baked from the shop's own POS | yes — atlases are files |
|
||||||
| 2 · live GODVERSE | live stock, sold-means-gone *(design ratified, not built)* | no — by design |
|
| **2 · live GODVERSE** | `?live=1` — live stock off the real till: **sell a record in the shop and it's gone from the game crate** | **no — by design** |
|
||||||
|
|
||||||
**The offline law:** the server enriches, never gates. `?noassets=1` still fetches **zero bytes** and plays
|
**The offline law:** the server enriches, never gates. `?noassets=1` still fetches **zero bytes** and plays
|
||||||
silent-and-fine; a shop whose atlas is missing falls soft to parody rather than borrowing someone else's
|
silent-and-fine; a shop whose atlas is missing falls soft to parody rather than borrowing someone else's
|
||||||
|
|||||||
@ -1,11 +1,88 @@
|
|||||||
# LANE A — CITYGEN · progress (PROCITY-A)
|
# LANE A — CITYGEN · progress (PROCITY-A)
|
||||||
|
|
||||||
*Status: **all deliverables landed; self-check ALL GREEN (156,211/156,211); rounds 2–25 closed** (see below).
|
*Status: **all deliverables landed; self-check ALL GREEN (156,352/156,352); rounds 2–30 closed**.
|
||||||
R25 (v5.0-alpha): the sweep reconciled with **Ruling 2** (keyed asserts are per-layer), the uniqueness check
|
R30 (v7.0-alpha ledger #6): the rotation boundary VERIFIED — F's `day` salt rides runtime streams only,
|
||||||
rebuilt so it can actually fail, the **orphaned-atlas gate** added, and **six goldens pinned** — F's tag is
|
plan generation structurally cannot receive it; all goldens frozen, zero re-pins.*
|
||||||
unblocked from my side. classic `0x3fa36874` / gig `0xb1d48ea1` frozen.*
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
The ruling asked me to land B's one-liner in the real-roads seating and to **measure the marched path
|
||||||
|
rather than assume it was clean**. I measured all three paths, and the ruling's premise was wrong in the
|
||||||
|
half that mattered: **synthetic is broken too**. Not "every real town since R18" — **every town, every
|
||||||
|
path, since v1**. Confirmed numerically (0 toward / 681 away, dot −1.0000) and then with my own eyes:
|
||||||
|
`?classic=1`, synthetic main street = three blank boxes; walk into the block and NUMBAT PLAYTHINGS is
|
||||||
|
there, facing the dirt. 637 of 681 lots pointed their facade at **no street at all**.
|
||||||
|
|
||||||
|
- **Four sites, one sign.** `plan.js:122` (synthetic strip), `plan.js:244` (market row), `plan_osm:330`
|
||||||
|
(marched avenue), `plan_osm:443` (real roads — B's proven one-liner). All `atan2(outward)` where the
|
||||||
|
intent was `atan2(−outward)`, and **every one says so in its own comment**. The code never did what it
|
||||||
|
said; CITY_SPEC:71 blessed the result by writing the SIM convention into the LOTS contract.
|
||||||
|
After: synthetic 681/681, marched + all 3 fixtures 100%, real-roads katoomba **0/72 → 72/72** (B's number).
|
||||||
|
- **The covenant moved, on John's ruling.** Fixing synthetic necessarily moves `0x3fa36874`, frozen
|
||||||
|
"forever" since R16 — not Lane A's call, so I stopped and asked. Ruling: **amend and fix all paths** (the
|
||||||
|
covenant is anti-drift machinery, not a defect preservative). **51 goldens re-pinned in one commit**;
|
||||||
|
amendment recorded in CITY_SPEC as a one-time, ruling-gated precedent.
|
||||||
|
- **A second bug the fix exposed, fixed with it:** `buildRealRoads` seated blocks at `KERB = 4 m` from the
|
||||||
|
**centreline**, but a `main` carriageway is 10 m wide (kerb at 5 m) — **every main-street building has
|
||||||
|
stood 1 m inside the road since R18**. Invisible until the shopfront turned around and its poster landed
|
||||||
|
in the traffic lane. Blocks now seat behind the kerb per A's own corridor law. Drops 6.4% → **6.6%**, counted.
|
||||||
|
- **The cross-convention gate (the lesson made law).** It does not restate a spec line — that is the trap F
|
||||||
|
proved (72/72 "facing" over blank walls). It replicates **Lane B's own** `buildings.js` maths and asks
|
||||||
|
whether the quad the *renderer* draws lands nearer the street than the lot centre. Fires if A's `ry` **or**
|
||||||
|
B's `zf` drifts. It would have caught this on day one.
|
||||||
|
- **→ F (blocking their re-gate):** `scaffold_check` is RED until F re-pins the covenant hashes duplicated
|
||||||
|
in **F's own files** (`flags_check.py` `GOLDEN_HASH`/`GIG_GOLDEN`, `scaffold_check.mjs` `TOWN_GOLDENS`,
|
||||||
|
two tour captions). Exact values handed over in LANE_A_NOTES §R27w5; I can't touch F's files.
|
||||||
|
|
||||||
|
*Previous status: 156,212/156,212; rounds 2–27 closed (see below).
|
||||||
|
R27 (v5.0 close): **CITY_SPEC v5 section** (tier ladder · `sourcing` vs `tier` · the three id-space fences ·
|
||||||
|
the manifest · Ruling 2) + the **charter risk list judged line by line** — risk #1 deliberately CARRIED, not
|
||||||
|
pre-retired, because F's kill-the-server gate is what retires it. classic `0x3fa36874` / gig `0xb1d48ea1`
|
||||||
|
frozen; **no golden moved this epoch**.*
|
||||||
*Date: 2026-07-17 · owner: PROCITY-A (Opus 4.8)*
|
*Date: 2026-07-17 · owner: PROCITY-A (Opus 4.8)*
|
||||||
|
|
||||||
|
## Round 27 (2026-07-17) — v5.0 THE LIVE CRATE: the docs freeze (ledger #6)
|
||||||
|
|
||||||
|
Lane A's half of the epoch close. Docs only; **no code, no golden moved** — correct, since tier 2 touches no
|
||||||
|
plan, no cache, no atlas bytes. selfcheck **ALL GREEN 156,212/156,212**.
|
||||||
|
|
||||||
|
- **CITY_SPEC v5 section**, written from the artifacts rather than the briefs (the epoch's own lesson):
|
||||||
|
the **tier ladder** (0 parody / 1 static real / 2 live, each degrading softly); **`sourcing` is not
|
||||||
|
`tier`** — both ship in every index, `tier: 1` is the rung and a mint atlas is *still* rung 1, while
|
||||||
|
`sourcing: "real"|"mint"` is provenance (15 keyed atlases = **1 real + 14 mint**); the **three id-space
|
||||||
|
fences**; the **manifest** (existence declared, never probed — the root fix for fail-soft-404 vs the
|
||||||
|
zero-console-errors law; 15 entries, 15 dirs); **Ruling 2**; and A's single seam, `godverseShopId`.
|
||||||
|
- **The id fences, verified not restated.** The plan-id/godverse-id trap is still live in the data and I
|
||||||
|
reproduced it: in `redhill_godverse`, bare **`31`** is "Wholefood Cafe" as a plan id and "Silky Oak
|
||||||
|
Furnature" as a godverse id — the exact ambiguity that made F's `enterShop(31)` assert vacuously on the
|
||||||
|
wrong shop. It's a standing rule, not a fixed bug, and the spec says so.
|
||||||
|
- **The risk list judged, without pre-retiring anyone's gate.** #1 (tier-2 latency) **CARRIED** — written in
|
||||||
|
wave 2, F's reader doesn't exist yet, and *nothing retires that line except the gate going green*;
|
||||||
|
retiring it from a doc while the code is unwritten is the precise species this epoch spent four holds
|
||||||
|
catching. #2 **retired** (measured: max 2 atlases/shop vs C's cap of 2; mint at 16 items/1024², only the
|
||||||
|
real POS shop at 2048²; the mecca case served by crate rotation — the remedy the risk itself named).
|
||||||
|
#3 **retired — designed out, not solved**: it assumed fuzzy name-matching, and there is no matcher;
|
||||||
|
Monster Robot is in *neither* census nor OSM, so no matcher could ever have found it. #4 **split**:
|
||||||
|
write-back retired by John's ruling-as-mechanism (`/reserve`+`/buy` absent, not stubbed — you can't race a
|
||||||
|
verb that doesn't exist), the `gone` read carried to F's #4. Two new carried: **#6 orphaned atlas**,
|
||||||
|
**#7 "real" is one shop wide** (14/15 are mint — honest and marked, but the headline rests on one shop).
|
||||||
|
- **The gate that armed itself.** My R25 orphan gate SKIPped on `newtown_godverse` while no atlas keyed
|
||||||
|
there; G's mint atlases landed and it **began binding unattended** — 156,211 → **156,212**, and that +1 is
|
||||||
|
the gate arming itself. Near-miss carried: redhill's lift drops keyed shop **2286 "Empire Revival"**;
|
||||||
|
G stocked neighbour **2287**. Stocking 2286 today would orphan it.
|
||||||
|
|
||||||
## Round 25 (2026-07-17) — v5.0-alpha: the sweep vs Ruling 2 + the six pins (ledger #1)
|
## Round 25 (2026-07-17) — v5.0-alpha: the sweep vs Ruling 2 + the six pins (ledger #1)
|
||||||
|
|
||||||
All 8 tree reds cleared — **6 were my own gate asserting Ruling 2 away**, 2 were goldens awaiting my pin.
|
All 8 tree reds cleared — **6 were my own gate asserting Ruling 2 away**, 2 were goldens awaiting my pin.
|
||||||
|
|||||||
@ -28,7 +28,21 @@ considered and parked: nothing in v1 needs it, and the asset pipeline (GLB, web-
|
|||||||
|
|
||||||
- **Metres. +Y up. Ground plane is XZ.** City origin (0,0) = centre of the main square.
|
- **Metres. +Y up. Ground plane is XZ.** City origin (0,0) = centre of the main square.
|
||||||
- **Right-handed three.js defaults.** Buildings face their street; a facade's outward normal
|
- **Right-handed three.js defaults.** Buildings face their street; a facade's outward normal
|
||||||
points at its street edge. GLB convention: origin at base, facing **−Z**, real-world scale.
|
points at its street edge.
|
||||||
|
- **THE TWO CONVENTIONS — they are different things, and conflating them cost nine rounds
|
||||||
|
(ROUND27). Read both:**
|
||||||
|
- **The FACADE convention (the lots contract, `lot.ry`): the visible shopfront is local `+Z`** ⇒
|
||||||
|
world `(sin ry, cos ry)`. This is B's canon and the de facto truth since v1 — `buildings.js`
|
||||||
|
draws the facade quad and door at local `z = +d/2`, `venue.js` seats the door-glow/queue/
|
||||||
|
posters there. **`lot.ry` aims +Z at `frontEdge`.**
|
||||||
|
- **The SIM/RIG convention (props, not lots): a GLB is modelled facing `−Z`** (origin at base,
|
||||||
|
real-world scale) ⇒ rig-forward is world `(−sin ry, −cos ry)`. This describes *how a model is
|
||||||
|
authored*, **not which way a lot faces.**
|
||||||
|
- **They are opposites, so a check written in one silently passes over a world built in the
|
||||||
|
other.** CITY_SPEC:71 wrote the SIM convention into the LOTS contract by ambiguity; each
|
||||||
|
generator implemented one half; every golden stayed green over a town whose every shopfront
|
||||||
|
faced away. **Never assert a lot's facing against a spec line — assert it against the
|
||||||
|
geometry the renderer actually draws** (`selfcheck.js` now does exactly that).
|
||||||
- **Seeded everything, `Math.random()` is banned in generation code.**
|
- **Seeded everything, `Math.random()` is banned in generation code.**
|
||||||
`web/js/core/prng.js` is the only randomness source:
|
`web/js/core/prng.js` is the only randomness source:
|
||||||
- `seedFor(citySeed, kind, id)` → stable uint32 (xmur3 hash of `"seed:kind:id"`).
|
- `seedFor(citySeed, kind, id)` → stable uint32 (xmur3 hash of `"seed:kind:id"`).
|
||||||
@ -68,8 +82,11 @@ Schema v1 (Lane A owns it; extend, don't break):
|
|||||||
// onto the block so consumers can theme it without a lookup (Lane A extension, non-breaking).
|
// onto the block so consumers can theme it without a lookup (Lane A extension, non-breaking).
|
||||||
lots: [ { id, block, x, z, w, d, ry, frontEdge, use } ],
|
lots: [ { id, block, x, z, w, d, ry, frontEdge, use } ],
|
||||||
// use: 'shop' | 'anchor' | 'house' | 'yard' | 'stall' | 'infill'
|
// use: 'shop' | 'anchor' | 'house' | 'yard' | 'stall' | 'infill'
|
||||||
// ry: Y-rotation (radians) so a GLB modelled facing −Z ends up with its facade's OUTWARD normal
|
// ry: Y-rotation (radians) so the lot's VISIBLE FACADE — local +Z — points at frontEdge.
|
||||||
// pointing at frontEdge. World facing = (−sin ry, −cos ry). Lane B rotates the shell by ry.
|
// Facade/door/frontage normal = local +Z ⇒ world (sin ry, cos ry). Lane B rotates the shell by ry
|
||||||
|
// and draws the shopfront at local z = +d/2 (buildings.js), so +Z IS the street face. ROUND27.
|
||||||
|
// (The SIM/rig convention — a GLB modelled facing −Z, world (−sin ry, −cos ry) — is a DIFFERENT
|
||||||
|
// thing and is documented as such below. It is not the lots contract. See the amendment.)
|
||||||
// frontEdge: id of the street edge this lot fronts (guaranteed to exist and be geometrically adjacent).
|
// frontEdge: id of the street edge this lot fronts (guaranteed to exist and be geometrically adjacent).
|
||||||
shops: [ { id, lot, type, name, sign, seed, facadeSkin, storeys, hours: [open, close], openLate? } ]
|
shops: [ { id, lot, type, name, sign, seed, facadeSkin, storeys, hours: [open, close], openLate? } ]
|
||||||
// type: see SHOP TYPES below. hours: [open, close], 24h integers, 0 ≤ open < close ≤ 23.
|
// type: see SHOP TYPES below. hours: [open, close], 24h integers, 0 ≤ open < close ≤ 23.
|
||||||
@ -256,9 +273,30 @@ night in v1; interior-mapping shader is a stretch goal).
|
|||||||
> did not disappear. `gigs`, `weather`, `winmap`, `tram` now default **ON**; the default boot is the full
|
> did not disappear. `gigs`, `weather`, `winmap`, `tram` now default **ON**; the default boot is the full
|
||||||
> experience, deterministic to the **gig golden `0xb1d48ea1`**. Each flag keeps a `=0` opt-out, and
|
> experience, deterministic to the **gig golden `0xb1d48ea1`**. Each flag keeps a `=0` opt-out, and
|
||||||
> **`?classic=1`** forces all four off at once:
|
> **`?classic=1`** forces all four off at once:
|
||||||
> - **Classic law (the new frozen regression target):** the `?classic=1` boot is byte-identical to the v2
|
> - **Classic law (the frozen regression target):** the `?classic=1` boot is byte-identical to the v2
|
||||||
> baseline **forever** — synthetic fingerprint `0x3fa36874`, zero gig layer, **zero fetch delta** (no
|
> baseline — synthetic fingerprint **`0x5f76e76`**, zero gig layer, **zero fetch delta** (no
|
||||||
> `custom_bands.json`, no `sit.glb`). The old "flags-off regression" gate is now the **classic-regression** gate.
|
> `custom_bands.json`, no `sit.glb`). The old "flags-off regression" gate is now the **classic-regression** gate.
|
||||||
|
>
|
||||||
|
> > **THE COVENANT AMENDMENT — ROUND27, THE FACADE FIX (John's ruling; the only amendment in five epochs).**
|
||||||
|
> > The covenant froze `0x3fa36874` "forever" from R16 to R27. That hash anchored **a wrong world**: every
|
||||||
|
> > shopfront in every town — synthetic and real — faced *away* from its street, because CITY_SPEC:71 wrote
|
||||||
|
> > the SIM convention into the LOTS contract (see *Units, axes*). The covenant did exactly its job for nine
|
||||||
|
> > rounds: it held the base town byte-identical. It just held it identical to something broken, and no gate
|
||||||
|
> > could see it — a self-consistent check over a self-contradicting spec passes under either convention.
|
||||||
|
> >
|
||||||
|
> > **The covenant's purpose is anti-drift, not the preservation of a defect.** So on John's ruling it is
|
||||||
|
> > **amended once, deliberately, in the open**: the anchor moves `0x3fa36874 → 0x5f76e76`, and every other
|
||||||
|
> > golden moves with it (gig `0xb1d48ea1 → 0xec7a2d39`, the three marched fixtures, all 23 real towns base
|
||||||
|
> > + gig — **51 pins in one commit**, per the amendment law). The classic town is now what it always claimed
|
||||||
|
> > to be: a street you can stand on and read the shopfronts. **From this hash forward the law reads as it
|
||||||
|
> > always did — byte-identical forever.** An amendment requires a John-level ruling and a demonstrated
|
||||||
|
> > world-level defect, not a convenience; this is the precedent, not an opening.
|
||||||
|
> >
|
||||||
|
> > *Exposed by the same fix (and fixed with it): `buildRealRoads` seated blocks at `KERB = 4 m` from the
|
||||||
|
> > centreline while a `main` carriageway is 10 m wide — its kerb is at 5 m. Every main-street building has
|
||||||
|
> > stood 1 m inside the road since R18. Invisible while nothing was drawn on that face; the moment the
|
||||||
|
> > shopfront turned around, its poster landed in the traffic lane. Blocks now seat behind the kerb per the
|
||||||
|
> > corridor law (`roadWidth/2 + FOOTPATH`). Pack drops 6.4% → 6.6%, counted.*
|
||||||
> - **Default law:** the no-flag boot pins to the gig golden, and every gig / district / continuity / cover
|
> - **Default law:** the no-flag boot pins to the gig golden, and every gig / district / continuity / cover
|
||||||
> smoke **and the budget law** (interior ≤350 draws · street ≤300 / 200k tris, at the busiest venue block)
|
> smoke **and the budget law** (interior ≤350 draws · street ≤300 / 200k tris, at the busiest venue block)
|
||||||
> now run against the **default boot with all four flags live**. `?noassets=1` composes with it and stays
|
> now run against the **default boot with all four flags live**. `?noassets=1` composes with it and stays
|
||||||
@ -516,6 +554,79 @@ nothing outside the cache-schema path).
|
|||||||
> count beats teleport.** The frozen set is unmoved by the entire epoch: synthetic `0x3fa36874`, gig
|
> count beats teleport.** The frozen set is unmoved by the entire epoch: synthetic `0x3fa36874`, gig
|
||||||
> `0xb1d48ea1`, marched fixtures, classic.
|
> `0xb1d48ea1`, marched fixtures, classic.
|
||||||
|
|
||||||
|
## The stock ladder (v5 — THE REAL SHOP; frozen at `v5.0`, ROUND27 ledger #6)
|
||||||
|
|
||||||
|
> **v4 made the towns real; v5 makes the shops real.** The map-level contract above is unchanged by this
|
||||||
|
> epoch: **stock is DATA, not world.** The charter's determinism boundary is the load-bearing line —
|
||||||
|
> *world = seeded, frozen, golden-gated; stock = data tiers riding the asset law.* Nothing in this section
|
||||||
|
> may move a plan golden, and nothing in it did: the whole epoch left synthetic `0x3fa36874`, gig
|
||||||
|
> `0xb1d48ea1`, the marched fixtures and classic untouched.
|
||||||
|
|
||||||
|
**The tier ladder.** Each rung is fully playable and degrades **softly** to the one below — the fail-soft
|
||||||
|
pattern proven since `?noassets` on day one, made structural:
|
||||||
|
|
||||||
|
| tier | what | flag | deterministic? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **0 · parody** | seeded canvas sleeves (v1) | any boot | yes — the QA baseline |
|
||||||
|
| **1 · static real** | per-shop atlases baked from dealgod/POS | `?stock=real`, **no server** | yes — atlases are files |
|
||||||
|
| **2 · live GODVERSE** | thriftgod read-only endpoints; sold-means-gone | server reachable | **no — by design** |
|
||||||
|
|
||||||
|
**The offline law (charter law #1) is the epoch's gate, not a footnote:** the server *enriches, never
|
||||||
|
gates*. No boot path, gate, or tag may depend on a reachable server. Tier 2 is therefore **never inside a
|
||||||
|
golden or a byte-identical assert**; it gets its own smoke class whose server lifecycle the gate itself
|
||||||
|
controls, so the *assertion* is deterministic even though the *stock* isn't.
|
||||||
|
|
||||||
|
**`sourcing` is not `tier`** (C's ruling, R26 — and it's the distinction that keeps the ladder honest). The
|
||||||
|
rung and the provenance are different axes, and both ship in every atlas index:
|
||||||
|
- **`tier: 1`** — the ladder rung. A minted atlas is *still* a static file, so it is *still* rung 1.
|
||||||
|
- **`sourcing: "real" | "mint"`** — where the contents came from. **`real`** = a POS snapshot (Monster Robot
|
||||||
|
Party's actual crates). **`mint`** = a seeded `random.Random(shop_id)` sample of real dealgod listings —
|
||||||
|
*plausible, not that shop's stock*. Only one shop in the world has a POS; minting is how the other keyed
|
||||||
|
shops get distinct crates **without pretending to data we don't hold**. Current pack: **15 keyed atlases =
|
||||||
|
1 real + 14 mint**. No gate, doc, or human may read a mint crate as real — that is the whole point of the
|
||||||
|
field, and gates assert on it.
|
||||||
|
|
||||||
|
**The id-space fences.** Three namespaces meet at the crate, and **every collision this epoch came from
|
||||||
|
comparing across them**. They are fences, not decoration:
|
||||||
|
|
||||||
|
| space | form | scope | fenced from |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **plan `shop.id`** | small int, `0..N` per town | one CityPlan, local | `godverseShopId` — **numerically overlapping** |
|
||||||
|
| **`godverseShopId`** | census id (**≤2992**) ∪ dealgod store id (e.g. **3962749**) | the stock namespace | its two halves are numerically **disjoint by construction** (G) |
|
||||||
|
| **atlas slot id** | **`sku_<POS id>`** / **`mint_<listing id>`** | one atlas index | each other, by **prefix** |
|
||||||
|
|
||||||
|
- **plan id vs `godverseShopId` — never compare a bare number across them.** They are *semantically*
|
||||||
|
disjoint namespaces that *numerically overlap*, which is the trap: in `redhill_godverse` today, bare
|
||||||
|
**`31`** is **"Wholefood Cafe"** as a plan id and **"Silky Oak Furnature"** as a godverse id. That exact
|
||||||
|
ambiguity made F's `enterShop(31)` enter the wrong shop and a soft-fall assertion pass **vacuously on the
|
||||||
|
wrong subject**. Verified still live in the data — so this is a standing rule, not a fixed bug.
|
||||||
|
- **`sku_` vs `mint_` is a sourcing-scoped namespace fence** (C §7.2a as amended, R26 wave 4). A crate
|
||||||
|
wearing the other sourcing's prefix **fails**. The fence is *stronger* than the rule it replaced: the easy
|
||||||
|
green was deleting the prefix check; instead the prefix now proves its sourcing.
|
||||||
|
- **Positional ids are banned** (R25): `rec_0000…` numbering collides across packs by construction, which
|
||||||
|
is what made the integrator's own id-equality gate vacuous. Slot ids are sku-derived and unique across
|
||||||
|
packs, stable across re-emits.
|
||||||
|
|
||||||
|
**The manifest — `web/assets/stock_godverse/index.json`** (G emits, F consumes, E validates, C's §7.2d).
|
||||||
|
It declares **which `godverseShopId`s carry atlases** (`types[]` + `sourcing` per entry; mint included).
|
||||||
|
**Existence is declared, never probed.** This is the root fix for a real collision between two laws:
|
||||||
|
fail-soft-by-404 vs the zero-console-errors law — the runtime used to probe blind and eat 404 noise,
|
||||||
|
tolerated by attribution, which is a workaround, not a design. No manifest entry ⇒ no fetch ⇒ **zero 404s,
|
||||||
|
no exceptions**. Consistency is gated both ways (an atlas on disk absent from the manifest **fails**, and
|
||||||
|
vice versa — vacuous-gate law shape). Today: **15 entries, 15 dirs on disk.**
|
||||||
|
|
||||||
|
**Lane A's seam to all of it** is exactly one field — **`godverseShopId`** (documented above): it rides the
|
||||||
|
town cache, survives the lift onto `plan.shops[]`, and is what the runtime resolves `stock_godverse/<id>/`
|
||||||
|
from. **`'godverseShopId' in shop` is the tier test**: present ⇒ tier-1+ candidate, absent ⇒ tier 0. The
|
||||||
|
key is *absent, not undefined*, on every unkeyed shop — which is why the entire stock epoch moved **no plan
|
||||||
|
golden**. `selfcheck` gates the seam per-layer (Ruling 2), including the **orphaned-atlas** check: a
|
||||||
|
committed atlas whose shop the lift dropped is real stock keyed to a shop that isn't in the town.
|
||||||
|
|
||||||
|
**Ruling 2 (law):** a godverse cache is **deliberately mixed** — a keyed GODVERSE layer + an inherited OSM
|
||||||
|
texture layer that is legitimately unkeyed. G measured it load-bearing: census-only Red Hill was
|
||||||
|
unshippable (306 m spacing warn, a poster inside a kerb). **No gate may assert it away** — mine did, and
|
||||||
|
called two healthy towns broken.
|
||||||
|
|
||||||
## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read)
|
## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read)
|
||||||
|
|
||||||
> **`web/js/core/registry.js` is the machine-readable source of truth** — import `SHOP_TYPES` for
|
> **`web/js/core/registry.js` is the machine-readable source of truth** — import `SHOP_TYPES` for
|
||||||
|
|||||||
@ -93,28 +93,64 @@ boot path, gate, or tag that may touch the network.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. API surface [DESIGN]
|
## 4. API surface [AS-BUILT — R27, `pipeline/godverse_server.py`]
|
||||||
|
|
||||||
Read-only in v5.0; the write verbs are drafted here but **gated on §7**.
|
Read-only, per §7's ruling. **The write verbs are not "gated" — they do not exist.**
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /godverse/v1/shop/<godverse_id>/stock?crate=<id>&since=<iso>
|
GET /godverse/v1/health -> { ok, tier, pos_db, shops:[godverse_id] }
|
||||||
-> { shop_id, crate, tier, items:[{sku, slot, release_id, artist, title,
|
GET /godverse/v1/shop/<godverse_id>/stock?since=<etag>
|
||||||
price, condition, sleeve_cond, cover, state}],
|
-> { shop_id, tier, crate, items:[{id, slot, artist, title,
|
||||||
gone:[sku], etag, served_at }
|
price, condition, sleeve_cond, state}],
|
||||||
GET /godverse/v1/shop/<godverse_id>/crates -> crate list + counts (the rotation menu)
|
gone:[id], counts, etag, served_at } · 304 when ?since= matches
|
||||||
POST /godverse/v1/reserve { sku, session } -> { ok, holds_until } [GATED — §7]
|
GET /godverse/v1/shop/<godverse_id>/crates -> { shop_id, tier, crates:[{id,name,label,count}], etag }
|
||||||
POST /godverse/v1/buy { sku, session, hold } -> { ok, receipt } [GATED — §7]
|
GET anything else -> 404 (the ruling is in the body)
|
||||||
|
POST/PUT/PATCH/DELETE anything -> 501 — no write handler exists in the process
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Three corrections this doc owes to the code (measured R27, not reasoned):**
|
||||||
|
|
||||||
|
1. **`gone:[sku]` → `gone:[id]`.** As ratified, §4 said bare skus. That wording predates R25's
|
||||||
|
`sku_<POS sku>` ids and R26's **id-namespace fence law**, and a bare number crossing two id
|
||||||
|
spaces is precisely the bug that law exists to stop. `gone[]` now carries the atlas's own `id`
|
||||||
|
verbatim, so the client does `gone.includes(item.id)` with zero transformation. *This doc was
|
||||||
|
the stale rule meeting a case written after it — the epoch's signature failure, and my turn.*
|
||||||
|
2. **`items[]` drops `release_id` and `cover`.** `cover` would edge the API toward serving
|
||||||
|
pixels — it never should (fence #2); `release_id` identifies the *pressing*, and tier 2 speaks
|
||||||
|
only in *copies* (the sku). Both were design-time noise.
|
||||||
|
3. **Write verbs answer 501, not 404.** F asserts the number, so it matters: the class defines
|
||||||
|
`do_GET` and nothing else, so the stdlib refuses any other method before routing exists. **501
|
||||||
|
is the stronger proof** — a 404 means a handler ran and declined; 501 means there is no write
|
||||||
|
handler in the process. Adding `do_POST` to answer 404 would implement the forbidden thing.
|
||||||
|
|
||||||
Design rules:
|
Design rules:
|
||||||
|
|
||||||
- **The atlas is never served by the API.** Covers stay static files (tier 1). The server sends
|
- **The atlas is never served by the API.** Covers stay static files (tier 1). The server sends
|
||||||
*facts* (what's still there, what it costs), never pixels. A tier-2 shop with a dead server is
|
*facts* (what's still there, what it costs), never pixels. A tier-2 shop with a dead server is
|
||||||
visually identical to tier 1 — that's the fail-soft.
|
visually identical to tier 1 — that's the fail-soft.
|
||||||
- **`gone[]` is the whole point of tier 2.** The client already holds the tier-1 crate; the server
|
- **`gone[]` is the whole point of tier 2.** The client already holds the tier-1 crate; the server
|
||||||
says only what changed. A sold record is a `sku` in `gone[]` → `collapseBuyItem()` (already
|
says only what changed. A sold record is an `id` in `gone[]` → `collapseBuyItem()` (already
|
||||||
built, R9) zero-areas the sleeve. Sold-means-gone is a *diff*, not a re-fetch.
|
built, R9) zero-areas the sleeve. Sold-means-gone is a *diff*, not a re-fetch.
|
||||||
|
|
||||||
|
> ### ⚠ `gone[]` HAS NO SUBJECT IN PRODUCTION TODAY — measured R27, and it shapes F's gate
|
||||||
|
> The mechanism is built and correct. The **data has no movement**: Monster Robot Party's POS
|
||||||
|
> records **5,567 sales ever** (400–800/month, Dec 2025 → Jun 2026) but **26 in July and none
|
||||||
|
> since 2026-07-01**; the POS hasn't been *written* since **2026-07-09** (the 07-16 dump is fresh;
|
||||||
|
> its data is stale). And **0 of crate 550's 120 records have ever sold**. So against the real POS
|
||||||
|
> `gone` is `[]`, permanently, until the shop trades again.
|
||||||
|
>
|
||||||
|
> **Therefore, per the vacuous-gate law: F's sold-means-gone arm must not assert over the live POS
|
||||||
|
> — it would pass without touching its subject.** Two honest routes, and G built the second:
|
||||||
|
> 1. Assert `gone == []` against the real POS and **print the reason** — true, but it proves only
|
||||||
|
> that nothing sold.
|
||||||
|
> 2. **Point the server at a labelled fixture** (`godverse_server.py make-fixture --sold 3` →
|
||||||
|
> `recordgod_fixture`, a `createdb -T` clone with N of the crate's own real skus marked sold).
|
||||||
|
> Verified: `counts {baked:120, live:117, gone:3}`, the three ids absent from `items[]`. This
|
||||||
|
> proves the **mechanism** — computed → sent → consumed → sleeve vanishes — deterministically,
|
||||||
|
> which is what a gate is for. Production still reads the real POS, untouched.
|
||||||
|
>
|
||||||
|
> **A fixture is not a lie; a green assert over an absent subject is.** The epoch has held four
|
||||||
|
> tags on exactly that distinction.
|
||||||
- **`etag`/`since`** so a re-enter is a 304, not a payload.
|
- **`etag`/`since`** so a re-enter is a 304, not a payload.
|
||||||
- Shop id is the **godverse id** (§1a), never a POS internal id. **R24 naming, as landed:** the
|
- Shop id is the **godverse id** (§1a), never a POS internal id. **R24 naming, as landed:** the
|
||||||
town cache carries `shop.godverseShopId` (A's schema field, camelCase, read by the JS runtime);
|
town cache carries `shop.godverseShopId` (A's schema field, camelCase, read by the JS runtime);
|
||||||
|
|||||||
@ -2,6 +2,130 @@
|
|||||||
|
|
||||||
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
|
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**
|
||||||
|
This is the one thing blocking your re-gate. `scaffold_check` is **RED** until you re-pin, and I can't
|
||||||
|
touch your files. Exact values (verified byte-identical across two fresh processes):
|
||||||
|
|
||||||
|
| your file | constant | old | **new** |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `tools/flags_check.py:30` | `GOLDEN_HASH` | `0x3fa36874` | **`0x5f76e76`** |
|
||||||
|
| `tools/flags_check.py:31` | `GIG_GOLDEN` | `0xb1d48ea1` | **`0xec7a2d39`** |
|
||||||
|
| `tools/qa/scaffold_check.mjs:231` | `TOWN_GOLDENS.melbourne` | `0x34cfdec0` | **`0x9c7e76b3`** |
|
||||||
|
| `tools/qa/scaffold_check.mjs:231` | `TOWN_GOLDENS.katoomba` | `0x0f652510` | **`0xf3aafec8`** |
|
||||||
|
| `tools/qa/scaffold_check.mjs:225` | the comment quoting all three | — | same three |
|
||||||
|
| `tools/qa/tour_v5.py:42`, `tour_shots.py:10` | captions quoting `0x3fa36874` | — | **`0x5f76e76`** |
|
||||||
|
|
||||||
|
Also: `flags_check.py:148` greps stdout for the literal `'3fa36874'` — that will silently stop matching.
|
||||||
|
|
||||||
|
### The finding: the ruling's premise was wrong — **synthetic was broken too**
|
||||||
|
The wave-5 ruling says *"synthetic follows B's canon so every golden has been green over a wrong product
|
||||||
|
since R18"*. Half right, and the wrong half is load-bearing. **Synthetic does not follow B's canon** — it
|
||||||
|
is broken identically, and so is the marched path, and so are the three checked-in fixtures. It is not
|
||||||
|
"every real town since R18": it is **every town, every path, since v1**. Measured in B's canon before
|
||||||
|
touching anything, then confirmed with my own eyes in the browser:
|
||||||
|
|
||||||
|
| path | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| synthetic (`plan.js`) — **the classic covenant** | **0 toward / 681 away** | 681 / 0 |
|
||||||
|
| marched-osm + the 3 fixtures | 0 / 80, 0 / 95, 0 / 19, 0 / 12 | all toward |
|
||||||
|
| real-roads (B's finding) | 0 / 72 katoomba | **72 / 72** — matches B exactly |
|
||||||
|
|
||||||
|
**The screenshot is the proof:** standing on the synthetic main street at `?classic=1`, lot 0 — three blank
|
||||||
|
grey boxes. Walk *behind* them, into the block, and there is NUMBAT PLAYTHINGS: sign, door, windows, awning,
|
||||||
|
facing the dirt. Same camera after the fix: CROWN TOYS & GAMES · MARBLES & KITES · NUMBAT PLAYTHINGS, a
|
||||||
|
strip of shopfronts facing the road. **637 of 681 synthetic lots had their facade pointing at no street at
|
||||||
|
all.**
|
||||||
|
|
||||||
|
**Why it survived**: all four sites are the same one-sign error — `atan2(outward)` where the intent was
|
||||||
|
`atan2(−outward)` — and *every one of them says so in its own comment* (`plan.js:122` "facade faces back
|
||||||
|
down the outward normal, to the street"). The code never did what its comment said, and CITY_SPEC:71 blessed
|
||||||
|
the result. Nobody was wrong; the spec was.
|
||||||
|
|
||||||
|
### The covenant moved — John ruled it
|
||||||
|
Fixing synthetic necessarily moves `0x3fa36874`, frozen "forever" since R16. That was not mine to decide.
|
||||||
|
**John's ruling: amend the covenant, fix all paths** — the covenant is anti-drift machinery, not a defect
|
||||||
|
preservative. **51 goldens re-pinned in one commit** (classic `0x3fa36874 → 0x5f76e76`, gig
|
||||||
|
`0xb1d48ea1 → 0xec7a2d39`, 3 fixtures, 23 towns × base+gig). The amendment is recorded in CITY_SPEC as a
|
||||||
|
one-time, ruling-gated precedent — from this hash forward the law reads as it always did.
|
||||||
|
|
||||||
|
### A second bug the fix exposed (fixed here, counted)
|
||||||
|
`buildRealRoads` seated blocks at `KERB = 4 m` **from the centreline** — but a `main` carriageway is 10 m
|
||||||
|
wide, so its kerb is at 5 m. **Every main-street building has stood 1 m inside the road since R18.** The
|
||||||
|
footprint never moved; only which face is the front — so it was invisible until the shopfront turned around
|
||||||
|
and its poster landed in the traffic lane (−1.07 m). Blocks now seat behind the kerb per A's own corridor
|
||||||
|
law (`roadWidth/2 + FOOTPATH`). Pack drops **6.4% → 6.6%**, counted, not hidden.
|
||||||
|
|
||||||
|
### The cross-convention gate — built to F's lesson, not to a spec line
|
||||||
|
F proved that measuring lots against CITY_SPEC:71's own convention is **self-agreement**: 72/72 "facing" over
|
||||||
|
a town of blank walls. So the new gate does not restate a spec line. It **replicates Lane B's own placement
|
||||||
|
maths** from `buildings.js` (`toWorld(lot, 0, y, d/2 + 0.02)`) and asks a physical question neither spec line
|
||||||
|
can fudge: *does the quad the renderer actually draws land nearer the street than the lot centre?* It fires
|
||||||
|
if A's `ry` sign drifts **or** if B's `zf` sign drifts. It would have caught this on day one of v1.
|
||||||
|
|
||||||
|
## Round 27 (2026-07-17) — v5.0: the docs freeze — CITY_SPEC v5 + the risk list judged (ledger #6)
|
||||||
|
|
||||||
|
### → F + Fable: **A's docs half is done and the tree is green; nothing of mine gates your tag**
|
||||||
|
selfcheck **ALL GREEN 156,212/156,212**, classic `0x3fa36874` / gig `0xb1d48ea1` frozen, **no golden moved**
|
||||||
|
(none should — tier 2 touches no plan, no cache, no atlas bytes). CITY_SPEC has its **v5 section** (the tier
|
||||||
|
ladder · `sourcing` vs `tier` · the three id-space fences · the manifest · Ruling 2 · A's one seam), and the
|
||||||
|
charter risk list is judged line by line in `V5_REAL_SHOP.md`.
|
||||||
|
|
||||||
|
### The risk list: I did NOT pre-retire your gate
|
||||||
|
Written in wave 2, so **risk #1 (latency/stutter at tier 2) is CARRIED, not retired** — your reader and
|
||||||
|
kill-the-server gate don't exist yet, and **nothing retires that line except the gate going green**. Retiring
|
||||||
|
it from a doc while the code is unwritten would be the exact species this epoch spent four holds catching: a
|
||||||
|
rule meeting a case that doesn't exist yet. #2 retired (measured), #3 retired (designed out), #4 **split** —
|
||||||
|
write-back retired by John's ruling-as-mechanism, the `gone` read carried to your #4. Two new carried lines
|
||||||
|
(#6 orphaned atlas, #7 "real" is one shop wide).
|
||||||
|
|
||||||
|
### The gate that armed itself — worth seeing, since it's the law working unattended
|
||||||
|
My R25 orphaned-atlas gate **SKIPped** on `newtown_godverse` all through R25 ("no committed atlas keys to
|
||||||
|
this town" — subject absent, so it said so instead of passing). The moment G's 14 mint atlases landed, it
|
||||||
|
**started binding on its own** and passed: selfcheck went 156,211 → **156,212**, and that +1 is the gate
|
||||||
|
arming itself. Nobody edited it. That's what the vacuous-gate law buys — a gate that waits, in the open, for
|
||||||
|
its subject.
|
||||||
|
|
||||||
|
**Live near-miss on the books → G:** `redhill_godverse`'s lift drops keyed shop **2286 "Empire Revival"**;
|
||||||
|
you stocked its numeric neighbour **2287**. Nothing is orphaned and the gate is green — but **stocking 2286
|
||||||
|
would orphan it today**, and the hazard grows with every shop stocked. Carried as risk #6.
|
||||||
|
|
||||||
|
### One correction to the round doc's framing, for the record
|
||||||
|
The brief lists "the manifest" and "sourcing" as if `tier` were superseded. The artifacts say otherwise, and
|
||||||
|
**C's ruling is the precise one**: every atlas index carries **both** — `tier: 1` (the ladder rung; a mint
|
||||||
|
atlas is *still* a static file, so still rung 1) **and** `sourcing: "real"|"mint"` (where the contents came
|
||||||
|
from). They are different axes. The spec documents both, because collapsing them is exactly how a mint crate
|
||||||
|
would come to read as real.
|
||||||
|
|
||||||
## Round 25 (2026-07-17) — v5.0-alpha: the sweep reconciled with Ruling 2, six goldens pinned (ledger #1)
|
## Round 25 (2026-07-17) — v5.0-alpha: the sweep reconciled with Ruling 2, six goldens pinned (ledger #1)
|
||||||
|
|
||||||
### → F: **the tree is green — all 8 reds are gone, nothing of mine is pending** (the handshake)
|
### → F: **the tree is green — all 8 reds are gone, nothing of mine is pending** (the handshake)
|
||||||
|
|||||||
@ -1,5 +1,261 @@
|
|||||||
# LANE B — NOTES (measured)
|
# 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 —
|
||||||
|
1.4 m of flap on a 0.14 m canopy — cannot move 1% of the frame. It is not the gain; it is the
|
||||||
|
geometry. The backlog item is closed permanently, and the reason names what *would* work.**
|
||||||
|
|
||||||
|
R17 shipped tree sway and parked this with a physical reason: a posted verandah is a structural roof
|
||||||
|
and does not flutter. R28 asked for the half R17 left — the stalls' striped canopies really are cloth,
|
||||||
|
and they shared the rigid awning InstancedMesh, so neither could move without the other. That part was
|
||||||
|
all true, and the split worked. The cloth just turned out to be invisible.
|
||||||
|
|
||||||
|
### It was built, and it worked
|
||||||
|
Split the canopies out of the rigid mesh (own `InstancedMesh`, own subdivided box — a 4-corner box has
|
||||||
|
nothing to ripple), gave only them a cloth billow riding R17's existing `wind.js` uniforms: a
|
||||||
|
`sin(πu)·sin(πv)` pin (zero at the four corner posts by construction, peak mid-panel), two crossed
|
||||||
|
travelling waves, per-instance phase off world position so forty stalls don't breathe in unison.
|
||||||
|
Proven by **rendered-pixel hashes**, not by eye:
|
||||||
|
| proof | result |
|
||||||
|
|---|---|
|
||||||
|
| the cloth actually displaces (calm vs gusty) | `40f59eaf` → `d6d3e1f1` — **moves** |
|
||||||
|
| returning to calm reproduces the calm frame | `40f59eaf` — **identical** |
|
||||||
|
| `uWindTime = 99` at amp 0 | `40f59eaf` — **identical** (amp 0 ⇒ `+= …*0.0`, the R17 pattern) |
|
||||||
|
| **`?classic=1`, same fixed pose, pre- vs post-change build** | **`453c0154` → `453c0154` — byte-identical** |
|
||||||
|
| rigid verandahs / rest of town ride the wind? | cloth+trees hidden: `76c8b518` → `76c8b518` — **still** |
|
||||||
|
| …and the control proves I hid the right thing | cloth restored: frame **moves** again |
|
||||||
|
Cost at `market_square`, true A/B: **+0 draws** (98 → 98) / **+2,160 tris**. +0, not the +1 I'd written
|
||||||
|
in my own comment — the square's chunks hold *only* stalls, so the rigid list empties, `makeInstanced`
|
||||||
|
returns null, and the cloth mesh replaces it rather than adding to it.
|
||||||
|
|
||||||
|
### And it is worth nothing — the sweep (amp is the same multiplier as gain)
|
||||||
|
| peak billow | frame changed |
|
||||||
|
|---|---|
|
||||||
|
| **0.02 m — CALM, the default weather amp** | **0.05%** |
|
||||||
|
| **0.14 m — STORM, the most any player ever sees** | **0.21%** |
|
||||||
|
| 0.35 m (2.5× over-driven) | 0.33% |
|
||||||
|
| 0.70 m (5×, absurd on a 0.14 m slab) | 0.49% |
|
||||||
|
| **1.40 m (10×, a canopy flapping a body-length)** | **0.77%** |
|
||||||
|
At the player's actual browsing pose, storm-force: **0.00%, max channel delta 1**. Not one pixel.
|
||||||
|
|
||||||
|
**The reason is physical, and it is the same physics that killed the verandah in R17.** A canopy is a
|
||||||
|
horizontal sheet at y 2.2 viewed by a 1.7 m player: you see it edge-on or from underneath, so a
|
||||||
|
*vertical* displacement runs almost parallel to the view ray and produces almost no pixel change. You
|
||||||
|
cannot see a flat horizontal sheet flex vertically while standing under it. No gain fixes that — 10×
|
||||||
|
buys 0.77%, and anything that large stops reading as cloth and starts reading as a trampoline.
|
||||||
|
|
||||||
|
**What would work, if wind in the market is ever wanted (for whoever reads this in round 35):** the
|
||||||
|
motion has to be *perpendicular* to the view, which means **vertical cloth** — hanging stall skirts, a
|
||||||
|
flag, a banner, an awning valance. That is an **asset** (Lane E), not a shader. B's `applyWind` /
|
||||||
|
`applyFabricWind` seam is ready for it the day such a mesh exists; the shader was never the missing
|
||||||
|
piece. **Ledger #2 leaves the backlog forever.**
|
||||||
|
|
||||||
|
### Kept from the round
|
||||||
|
Nothing shipped: `buildings.js` and `wind.js` are byte-identical to HEAD. Shipping an invisible ripple
|
||||||
|
would have cost 2,160 tris and a second draw path in the covenant town to move 0.05% of a calm frame.
|
||||||
|
*The R17 rule was "ships or dies". It died — and it died with numbers, which is the only way an item
|
||||||
|
this old stops coming back.*
|
||||||
|
|
||||||
|
**Two vacuous measurements caught on the way (the epoch's own disease, still catching me):** (1) a
|
||||||
|
`!!material.onBeforeCompile` check to prove the rigid verandah was untouched — every `THREE.Material`
|
||||||
|
inherits a prototype no-op, so it is **always** truthy; the assert could never go red, and I replaced it
|
||||||
|
with `hasOwnProperty` plus a behavioural hide-and-hash control. (2) a gain sweep that reported the
|
||||||
|
identical 0.21% at gains 0.4→4.0 because I never stored the shader ref, so `uFabricGain` never moved —
|
||||||
|
**four identical numbers across a 10× range is a sweep that proves nothing**, and it was the constancy,
|
||||||
|
not the value, that gave it away. Both would have passed as green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Round 27 wave 4 — `poseForShopFront` shipped · **and every real town's shops face away from the street**
|
||||||
|
|
||||||
|
**The micro-task is done and verified. In doing it I found why F photographed empty road: it is not
|
||||||
|
the pose. Every shopfront in every OSM town has faced away from its street since R18, and the tours
|
||||||
|
have been photographing the backs of buildings for nine rounds. Root cause is a CITY_SPEC that
|
||||||
|
contradicts itself. The fix is one line in A's `plan_osm.js` — proven, then reverted, because it
|
||||||
|
isn't mine and it needs a ruling.**
|
||||||
|
|
||||||
|
### 1. The deliverable — `DBG.poseForShopFront(sel, dist = 8)`
|
||||||
|
Poses at a NAMED SHOP's frontage: door centred, sign band + R26 crate mark in shot, under the verandah.
|
||||||
|
`clusterPose()` answers "where is the town's HEART", which is the wrong question for a named shop —
|
||||||
|
Red Hill's heart doesn't contain Monster Robot, which is exactly how F got three frames of empty road.
|
||||||
|
|
||||||
|
Keyed through **one shop selector**: F's R22/R24 resolver lived inside `enterShop()`, so I extracted it
|
||||||
|
rather than grow a second key scheme beside it — a selector bug resolves the wrong shop *and looks like
|
||||||
|
it worked* (F's own first cut proved that), so the id-namespace law now has exactly one home.
|
||||||
|
Verified live, and this is the case that matters:
|
||||||
|
| call | resolves to | |
|
||||||
|
|---|---|---|
|
||||||
|
| `poseForShopFront(31)` | **Wholefood Cafe** (plan id 31) | + `ambiguous:` "31 is also the godverseShopId of Silky Oak Furnature — use 'g:31'" |
|
||||||
|
| `poseForShopFront('g:31')` | **Silky Oak Furnature** (godverse id 31) | the prefix IS the fence |
|
||||||
|
| `poseForShopFront('g:999999')` | `{error}` | never throws, never guesses |
|
||||||
|
|
||||||
|
Framing proven by **projection**, not by eye — `doorCentreNdcX = 0.0000` (exactly), door / sign / mark
|
||||||
|
all inside the frustum. Derived from buildings.js rather than hardcoded: door is centred on the lot
|
||||||
|
front (so `off = 0`, unlike `poseForShop`'s `off = 1.0`); sign band is `min(h,FACADE_H)−0.35 ± 0.25` ⇒
|
||||||
|
**y 2.40–2.90**; the mark is **y 2.45–2.52**; `camY = 2.0` sits below the `AWNING_Y = 2.95` verandah, so
|
||||||
|
a ray rising to a target ≤ 2.9 can never cross it — the sign is clear *by geometry*, at any distance.
|
||||||
|
|
||||||
|
### 2. Correcting my own R27 intel to F — I gave F two wrong numbers
|
||||||
|
My R27 note said the mark "rides high (y ≈ 3.3)" and the vinyl landmark blocks only 4% of it. **Both
|
||||||
|
wrong.** The mark is at y 2.45–2.52; I had ray-scanned y 3.0–3.6, which is empty awning air *above the
|
||||||
|
sign* — that is why it read ~0. Re-measured on the true band: the `novelty_record` disc (top y **2.50**)
|
||||||
|
reaches **into** the mark band and clips **~15% of the strip from every offset** — moving the camera only
|
||||||
|
changes *which* 15%, so there is nothing to dodge. The recommendation (**shoot head-on**) survives, but
|
||||||
|
for the opposite reason to the one I gave: not because the vinyl is clear of the mark, but because it
|
||||||
|
never isn't, and the repeating spines read through it anyway.
|
||||||
|
|
||||||
|
### 3. THE FINDING — real-town shopfronts face the paddock, not the street
|
||||||
|
Standing on Musgrave Road (Monster Robot's own `frontEdge: 1316`) and looking at the shop returns a
|
||||||
|
**blank grey back wall**: no sign, no door, no windows, no mark. Measured, not eyeballed:
|
||||||
|
| town | shop lots w/ frontEdge | facing their street | **180° backwards** |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `redhill_godverse` | 35 | **0** | **35** (dot = −1.000) |
|
||||||
|
| `katoomba_real` | 72 | **0** | **72** (dot = −1.000) |
|
||||||
|
| synthetic (default) | — | — | none — looks right, always has |
|
||||||
|
|
||||||
|
dot = **−1.000 exactly**, every lot, both towns: a convention, not a defect. Katoomba's `street_noon` —
|
||||||
|
the bookmark the release tours have shot for nine rounds — is **46 draws / 19,680 tris of bare road**.
|
||||||
|
|
||||||
|
**Root cause: CITY_SPEC contradicts itself, and the two generators each implement one half.**
|
||||||
|
- **CITY_SPEC:71** (the lots contract): *"ry: … a GLB modelled facing −Z ends up with its facade's OUTWARD
|
||||||
|
normal pointing at frontEdge. **World facing = (−sin ry, −cos ry)**"* → **−Z is the facade.**
|
||||||
|
- **CITY_SPEC:335** (posters, R15): *"seated on the **+Z street facade** (**B's canon**: buildings.js facade +
|
||||||
|
venue.js door/queue/camera are all +Z; `lot + (sin ry, cos ry)·(d/2+ε)`)"* → **+Z is the facade.**
|
||||||
|
|
||||||
|
`plan_osm.js:443` obeys the first (`ry = atan2(nx, nz)`, its comment says *"facade (−Z) faces road"*).
|
||||||
|
`plan.js` (synthetic), `buildings.js`, `venue.js` and the R15 posters obey the second. Synthetic — the
|
||||||
|
town every golden and every one of us tests — follows B's canon and looks correct, which is precisely
|
||||||
|
why nine rounds of real-town work never caught it. **This is the epoch's own signature species: a rule
|
||||||
|
meeting a case written after it.** The +Z canon is the de-facto standard (v1→v5, every synthetic golden,
|
||||||
|
posters R15 ledger #1); `plan_osm.js` is the lone dissenter.
|
||||||
|
|
||||||
|
**Proven, then reverted.** One line in A's file — `ry = Math.atan2(-nx, -nz)` — flips katoomba from
|
||||||
|
**0/72 facing → 72/72 facing**, and turns B's `poseForShopFront` camera from **26 m out in a paddock into
|
||||||
|
4 m from Musgrave Road**: the money shot F specified, shopfront + mark + kerb + a citizen at the door.
|
||||||
|
I did not commit it. It is A's file, it moves every real-town plan (⇒ real-town goldens re-pin under
|
||||||
|
amendment law), and a spec that contradicts itself is Fable's to rule on, not mine to pick a side of.
|
||||||
|
|
||||||
|
**This also re-reads my own R22 diagnosis.** I recorded "chunk-buildings **348 tris** (0.2%)" in katoomba's
|
||||||
|
busiest venue block and credited the atlas/instancing work. 348 tris is ~29 boxes: that was not efficiency,
|
||||||
|
it was the buildings being **absent from the frame**, and I read the evidence as a win. The number was
|
||||||
|
right; my reading of it was wrong, for five rounds.
|
||||||
|
|
||||||
|
Files: `web/js/world/dbg.js` (the helper + the extracted selector). No other lane's file touched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Round 27 (on-call) — the R26 handshake **verified against G's real manifest**; intel for F's #5 money shot
|
||||||
|
|
||||||
|
R27 owes B nothing (*"B/D: notes current; nothing else owed"*, *"B/D on-call"*). This is on-call work: F's
|
||||||
|
tour (#5) is specified as *Monster Robot Party's frontage, **B's mark visible*** — a claim my R26 verification
|
||||||
|
could not actually test, because G's manifest didn't exist yet and I proved the treatment against a **temp
|
||||||
|
manifest I scaffolded and deleted**. G has since shipped the real one (`36d10ca`). Claim now measured, not
|
||||||
|
assumed.
|
||||||
|
|
||||||
|
**Monster Robot Party is in `redhill_godverse`, not `newtown_godverse`** — the only town B ever tested the
|
||||||
|
mark in. F's money shot is posed in a town this treatment had never been verified against. It works.
|
||||||
|
|
||||||
|
### The R26 handshake held, unmodified
|
||||||
|
G's real emission is `{version:1, shops:[{godverseShopId, types, sourcing}]}` — 15 entries, lands inside B's
|
||||||
|
tolerant reader (`.shops`, objects carrying `godverseShopId`) with **zero coordination**, exactly as the R26
|
||||||
|
note promised. **The `tier` → `sourcing` rename (Fable's waves-1-2 ruling) did not touch B**: the mark
|
||||||
|
deliberately never reads that field, so a rename of it cannot break the footpath. *The "don't leak tier"
|
||||||
|
call paid a dividend it wasn't designed for — a cue that reads no field cannot break when the field moves.*
|
||||||
|
|
||||||
|
### Both arms proven live — the discriminating arm for the first time
|
||||||
|
R26's temp manifest marked **all 18** keyed shops, so "manifest is the authority, not the key" was never
|
||||||
|
actually exercised. G's real manifest makes it load-bearing: redhill has **9 keyed, only 4 stocked**.
|
||||||
|
Measured at Monster Robot's frontage (live chunk set), ground truth vs render:
|
||||||
|
| shop | keyed | in manifest | rendered |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Monster Robot Party #3962749** (`real`) | ✓ | ✓ | **marked** ✓ |
|
||||||
|
| Silky Oak Furnature #31 | ✓ | ✗ | **bare** ✓ |
|
||||||
|
| Thrift Shop and Eternity Boutique #908 | ✓ | ✗ | **bare** ✓ |
|
||||||
|
|
||||||
|
Two keyed shops stand metres from a stocked one wearing no mark, because they have no crate. **The street
|
||||||
|
advertises exactly the crates that exist.** (#31 is the same shop as Fable's R25 id-namespace bug — its
|
||||||
|
godverse id collides with a plan id; the mark reads `godverseShopId` only, so it is unaffected.)
|
||||||
|
|
||||||
|
Frontage: **135 draws / 74,354 tris**, 0 page errors, manifest prefetched at boot.
|
||||||
|
|
||||||
|
### Intel for F's #5 (no action needed from B)
|
||||||
|
The **`novelty_record` landmark** (B's furniture — a giant 0x1a1a1a vinyl disc on a post, marking record
|
||||||
|
shops) stands 2.5 m off Monster Robot's frontage and is **prominent in the head-on frame**. Measured
|
||||||
|
occlusion by ray-grid, so F can frame without guessing:
|
||||||
|
| band | head-on (8 m) | off-axis (+4 m) |
|
||||||
|
|---|---|---|
|
||||||
|
| **mark + sign** | **4%** blocked | 0% |
|
||||||
|
| window glass | 22% blocked | **44%** blocked |
|
||||||
|
|
||||||
|
**Shoot head-on.** The vinyl sits low (y≈1.3) and the mark rides high (y≈3.3), so it never meaningfully
|
||||||
|
occludes the mark; stepping off-axis *doubles* the window occlusion instead of helping. A giant vinyl record
|
||||||
|
in the frame of a record-shop money shot is a feature, not a blemish. *(I first read the black disc as a
|
||||||
|
render bug — it isn't; it's the landmark doing its job. Recorded so the next reader doesn't re-open it.)*
|
||||||
|
|
||||||
|
No code changed this round; notes-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Round 26 (Fable's ROUND26 → Lane B) — #5 the polish pick: **the stocked shopfront reads from the footpath**
|
## Round 26 (Fable's ROUND26 → Lane B) — #5 the polish pick: **the stocked shopfront reads from the footpath**
|
||||||
|
|
||||||
**Shipped. A stocked godverse shop now tells you it's diggable before you reach the door — a crate of
|
**Shipped. A stocked godverse shop now tells you it's diggable before you reach the door — a crate of
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
# LANE C — interior audio contract (v3.0-FROZEN) → for Lane F + Lane B
|
# LANE C — interior audio contract (v3.1) → for Lane F + Lane B
|
||||||
|
|
||||||
> **v3.0-FROZEN** · 2026-07-16 · durable contract for Lane F + Lane B. Describes the shipped state; changes
|
> **v3.1** · 2026-07-17 (R28) · durable contract for Lane F + Lane B. Amends v3.0-FROZEN: adds
|
||||||
> require a version bump.
|
> `room.audio.emitters` (§Emitters) and retires the v3.0 deferred `userData.audioEmitter` note — that
|
||||||
|
> note proposed a shape its own consumer could not use. **The line was the stale thing.**
|
||||||
|
|
||||||
*`buildInterior` returns `room.audio = { musicKey, toneKey }`, seeded per shop. Lane F's interior_mode just
|
*`buildInterior` returns `room.audio = { musicKey, toneKey, gigKey?, emitters }`, seeded per shop. Lane F's
|
||||||
plays it; Lane B's audio.js resolves the keys → files.*
|
interior_mode just plays it; Lane B's audio.js resolves the keys → files.*
|
||||||
|
|
||||||
> **Scope:** this doc covers the **base interior audio** (the seeded music bed + room-tone every shop
|
> **Scope:** this doc covers the **base interior audio** (the seeded music bed + room-tone every shop
|
||||||
> returns). The **gig audio** — `room.audio.gigKey`, the live-band bed, canonical form `gig-<genreKey>`
|
> returns). The **gig audio** — `room.audio.gigKey`, the live-band bed, canonical form `gig-<genreKey>`
|
||||||
@ -17,6 +18,7 @@ plays it; Lane B's audio.js resolves the keys → files.*
|
|||||||
room.audio = {
|
room.audio = {
|
||||||
musicKey, // string | null — an interior MUSIC bed key in manifest.audio.music, or null (no music)
|
musicKey, // string | null — an interior MUSIC bed key in manifest.audio.music, or null (no music)
|
||||||
toneKey, // string — an interior ROOM-TONE key in manifest.audio.ambience (scope:'interior')
|
toneKey, // string — an interior ROOM-TONE key in manifest.audio.ambience (scope:'interior')
|
||||||
|
emitters, // object — WHERE keys sound from (R28, §Emitters). Never null; may be {}.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- **Keys, not files.** `buildInterior` stays synchronous and asset-free — it only *names* what should
|
- **Keys, not files.** `buildInterior` stays synchronous and asset-free — it only *names* what should
|
||||||
@ -39,6 +41,58 @@ room.audio = {
|
|||||||
Verified (9 types × 8 seeds): 0 bad keys, 0 nondeterminism, every key resolves + matches its manifest
|
Verified (9 types × 8 seeds): 0 bad keys, 0 nondeterminism, every key resolves + matches its manifest
|
||||||
`types` membership; dedicated-music types are stable, non-music types vary on/off by seed.
|
`types` membership; dedicated-music types are stable, non-music types vary on/off by seed.
|
||||||
|
|
||||||
|
## Emitters — WHERE a key sounds from (R28)
|
||||||
|
|
||||||
|
```js
|
||||||
|
room.audio.emitters = {
|
||||||
|
stage?: { x, y, z, r, floor }, // VENUE ONLY — the band + the crowd sound from here
|
||||||
|
counter?: { x, y, z, r, floor }, // the till
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Room-local metres, 3-dp, deterministic (18/18 same-seed deep-equal across 6 types × 3 seeds).
|
||||||
|
**Absent emitter ⇒ room-filling**, which stays the right default for a shop radio (the v3.0 call, kept).
|
||||||
|
|
||||||
|
- **`r` is the REACH** — the distance at which the emitter has decayed to `floor`. It is the **room
|
||||||
|
diagonal**, not a constant. Measured over 8 types × 4 seeds: diagonals run **9.81–15.63 m**, and scaling
|
||||||
|
`r` to the room holds the door→stage proximity in a consistent **0.42–0.59** band from a pokey
|
||||||
|
band_room to a hall pub. A fixed radius would make the same walk mean different things in different rooms.
|
||||||
|
- **`floor` is the room-filling RESIDUE, never 0.** A pub PA fills the room; what changes as you walk in is
|
||||||
|
the **balance**, not the presence. `stage` 0.35, `counter` 0.15 (a till is a point source, a PA is not).
|
||||||
|
- **The curve** (both consumers use exactly this):
|
||||||
|
```js
|
||||||
|
const d = Math.hypot(e.x - cam.x, e.z - cam.z); // emitters and the interior camera are
|
||||||
|
const prox = Math.max(0, Math.min(1, (e.r - d) / e.r)); // BOTH room-local — no transform
|
||||||
|
const gain = e.floor + (1 - e.floor) * prox; // never 0
|
||||||
|
```
|
||||||
|
Measured swing door→stage: **2.65–4.52 dB** across pub / band_room / rsl. Audible, not a new game — the
|
||||||
|
wide rooms swing least because the stage genuinely *is* nearer the door there. (John, R20: *"subtle
|
||||||
|
changes are great … the player should notice the town feels more alive, not that the game changed."*)
|
||||||
|
|
||||||
|
### DO NOT use a PannerNode. It is inert here. (measured)
|
||||||
|
Nothing in the tree sets `ctx.listener` — zero hits, whole tree. A `PannerNode` therefore resolves against
|
||||||
|
a listener frozen at **(0,0,0)**, and because rooms are built in **room-local coords at the origin**, that
|
||||||
|
is *the room's own centre, forever*. Measured via `OfflineAudioContext`, a stage bed in a real pub:
|
||||||
|
|
||||||
|
| wiring | RMS at the door | RMS at the stage | ratio |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **panner, listener never set** (the engine as it ships) | 0.106905 | 0.106905 | **1.0000** |
|
||||||
|
| panner + listener tracking the player | 0.055665 | 0.500000 | 8.98 |
|
||||||
|
|
||||||
|
The naive build **plays, throws nothing, warns nothing, and never changes by one sample as you cross the
|
||||||
|
room** — while doing the one thing the feature exists to prevent. Every plausible gate goes green on it:
|
||||||
|
*"is it routed through a panner?"* yes. *"is it audible in a venue?"* yes. *"in a shop?"* yes.
|
||||||
|
**-> The only assertion that can see this is a DELTA: the bed's gain at the door ≠ its gain at the stage.**
|
||||||
|
Assert the change, not the presence. (Vacuous-gate law, 4th application.)
|
||||||
|
|
||||||
|
This trap was set by **this document's own v3.0 note** — *"`userData.audioEmitter = musicKey` so Lane B can
|
||||||
|
position/spatialise the bed"* — which invites exactly the panner, in a codebase with no listener code to
|
||||||
|
teach otherwise. Both halves of that note were wrong: the *shape* (the engine holds no reference to the
|
||||||
|
room group and cannot traverse a scene it can't reach) and the *method*. It is retired.
|
||||||
|
|
||||||
|
**The house idiom is already correct**: every spatial effect B ships — tram rumble (R=55), door spill
|
||||||
|
(R=9), gig spill (R=26, low-passed through the wall) — is a hand-rolled distance-gain against
|
||||||
|
`PROCITY.camera.position`. No listener, no panner. Emitters are the same shape as what already works.
|
||||||
|
|
||||||
## Lane F wiring (interior_mode)
|
## Lane F wiring (interior_mode)
|
||||||
```
|
```
|
||||||
on enter(room):
|
on enter(room):
|
||||||
@ -50,9 +104,92 @@ on exit / dispose(room):
|
|||||||
Nothing before the first gesture; `?mute=1`/`?noassets=1` short-circuit before any fetch. `room.audio`
|
Nothing before the first gesture; `?mute=1`/`?noassets=1` short-circuit before any fetch. `room.audio`
|
||||||
is plain data on the return — no new lifecycle, no dispose hook needed from Lane C's side.
|
is plain data on the return — no new lifecycle, no dispose hook needed from Lane C's side.
|
||||||
|
|
||||||
## Deferred idea (v3.1+, not in the frozen contract)
|
---
|
||||||
A `places`-tagged visual source ("the music comes from *there*") — e.g. a radio/hifi mesh with
|
|
||||||
`userData.audioEmitter = musicKey` so Lane B can position/spatialise the bed. Only the record shop has a
|
# R28 — what C shipped, and the two asks
|
||||||
fitting for it today (`listeningCorner`); milk-bar/video would need a new radio prop. A tidy v3.1 item: the
|
|
||||||
`userData.audioEmitter` hook + the two props. v3.0 plays the bed non-spatially (room-filling), which is the
|
**C shipped (this round, C's files only):**
|
||||||
right default for an interior anyway.
|
1. `room.audio.emitters` — the data above. Rides the **existing** `playInterior(room.audio)` call: no new
|
||||||
|
lifecycle, no traverse, no dispose hook.
|
||||||
|
2. **The till rings from the counter** (`dig.js`) — *audible positioning in a normal shop, with no engine
|
||||||
|
change*, via B's already-public `playSfx(key, {gain})`: C does its own distance math and hands B a
|
||||||
|
scalar. Measured through the real BUY button in a real record shop — **0.9489 at the counter (0.60 m)
|
||||||
|
vs 0.6436 at the door (4.19 m), 3.37 dB**; negative control (`emitters` omitted) fires at exactly
|
||||||
|
**1.0**. `sfx-till.ogg` has been on disk since 15 Jul and had **never once played**. Routing, not content.
|
||||||
|
3. **`?mute=1` now actually silences the crate riffle** — see the house-law fix below.
|
||||||
|
|
||||||
|
**Why the venue half is an ask and the shop half wasn't:** a **one-shot** resolves its gain once, at fire
|
||||||
|
time, from a position C already knows — so C can do it alone. A **bed** must be re-gained every frame from
|
||||||
|
inside the engine's `update()` loop. That loop is B's file. The split is structural, not political.
|
||||||
|
|
||||||
|
## ASK → Lane B (audio.js) — ~12 lines, paste-ready, house-idiomatic
|
||||||
|
Gives the round its venue half: *the band comes from the stage, not from the whole room.*
|
||||||
|
```js
|
||||||
|
// near the other interior state:
|
||||||
|
let iEmit = null, iMusicG = 0.5, iToneG = 0.35;
|
||||||
|
|
||||||
|
async function playInterior(spec) {
|
||||||
|
// … existing body …
|
||||||
|
iEmit = spec.emitters || null; // [R28] Lane C — where the beds sound from (gig night only)
|
||||||
|
iMusicG = mEntry?.gain ?? 0.5;
|
||||||
|
iToneG = tEntry?.gain ?? 0.35;
|
||||||
|
swapBed(L.iMusic, spec.musicKey || null, mEntry || null, iMusicG, 1.0);
|
||||||
|
swapBed(L.iTone, spec.toneKey || null, tEntry || null, iToneG, 1.0);
|
||||||
|
}
|
||||||
|
function stopInterior() { iEmit = null; ramp(L.iMusic.g.gain, 0, 0.7); ramp(L.iTone.g.gain, 0, 0.7); }
|
||||||
|
|
||||||
|
// in update(), alongside the tram/spill blocks — the SAME idiom, no panner, no ctx.listener:
|
||||||
|
if (!street && iEmit && iEmit.stage) {
|
||||||
|
const e = iEmit.stage, d = Math.hypot(e.x - _cam.x, e.z - _cam.z);
|
||||||
|
const k = e.floor + (1 - e.floor) * Math.max(0, Math.min(1, (e.r - d) / e.r));
|
||||||
|
ramp(L.iMusic.g.gain, (L.iMusic.src ? iMusicG : 0) * k, 0.25);
|
||||||
|
ramp(L.iTone.g.gain, (L.iTone.src ? iToneG : 0) * k, 0.25);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Emitters are room-local; the interior camera is room-local. Same space, no transform. Your call entirely —
|
||||||
|
C has not touched audio.js.
|
||||||
|
|
||||||
|
**Second, smaller ask (not this round):** expose the sfx bus or the ctx so `dig.js` can retire its private
|
||||||
|
AudioContext (below). C will not open that seam unilaterally.
|
||||||
|
|
||||||
|
## ASK → Lane F (interior_mode.js) — 1 line, and it is load-bearing
|
||||||
|
`interior_mode.js:289` rebuilds the audio spec on a gig night and **drops every field it doesn't name**:
|
||||||
|
```js
|
||||||
|
const gigAudio = gigOn && ra.gigKey ? { musicKey: ra.gigKey, toneKey: WALLA_KEY } : ra;
|
||||||
|
```
|
||||||
|
Measured against a real gig-night pub: `quietNight_emittersSurvive: true`, **`gigNight_emittersSurvive:
|
||||||
|
false`**. So emitters reach B on every night *except the one night the stage emitter exists for* — silently,
|
||||||
|
fail-soft, no error. Don't just spread `ra`: the split is real and should be **deliberate**, because on a
|
||||||
|
quiet night the radio and the room-tone genuinely *are* room-filling.
|
||||||
|
```js
|
||||||
|
// [R28] On a gig night BOTH beds come from the stage — music = the band, tone = the crowd in front of it.
|
||||||
|
// On a quiet night the radio + room-tone fill the room, so no emitter is passed. Deliberate, not a spread.
|
||||||
|
const gigAudio = gigOn && ra.gigKey
|
||||||
|
? { musicKey: ra.gigKey, toneKey: WALLA_KEY, emitters: ra.emitters }
|
||||||
|
: { musicKey: ra.musicKey, toneKey: ra.toneKey };
|
||||||
|
```
|
||||||
|
**Also, when you next touch `openDig`:** pass `emitters: current.audio.emitters` to `dig.open()` and the
|
||||||
|
till becomes positional. Optional — omitted, it still rings, just un-positioned. Never required.
|
||||||
|
|
||||||
|
## House-law fix — `?mute=1` did not silence the crate riffle (R5 → R28)
|
||||||
|
`dig.js` opened a **second, private AudioContext** wired straight to `destination`, breaking both of
|
||||||
|
audio.js's stated laws (*ONE AudioContext*; *`?mute=1` forces silence*). The riffle sang right through
|
||||||
|
`?mute=1` and never saw B's master gain. **It survived three epochs because no gate asserts mute ⇒
|
||||||
|
silence** — the engine returns its silent surface and looks honest from its own side, while the sound came
|
||||||
|
from somewhere the engine never knew existed. *A gate that asks the engine whether it is muted can only
|
||||||
|
ever answer yes.*
|
||||||
|
|
||||||
|
Fixed in `dig.js` by reading B's **public** `.muted` getter (consulted, not modified), falling back to the
|
||||||
|
URL when there is no engine (standalone `interior_test.html`). Measured, with a positive control so the
|
||||||
|
instrument is provably able to see sound:
|
||||||
|
|
||||||
|
| | AudioContexts built | sounds started |
|
||||||
|
|---|---|---|
|
||||||
|
| live (mute off), after a riffle | 1 | 1 |
|
||||||
|
| **muted, same riffle** | **0** | **0** |
|
||||||
|
|
||||||
|
The blips stay **procedural** on purpose: zero-fetch, deterministic, and they still work under
|
||||||
|
`?noassets=1` where a sampled riffle cannot. Only mute was broken; only mute was fixed.
|
||||||
|
|
||||||
|
**-> F: assert SILENCE, not muted-state.** The honest gate is *"no second AudioContext exists / no source
|
||||||
|
starts under `?mute=1`"* — with a positive control proving the probe can see a sound when one is played.
|
||||||
|
|||||||
@ -1,4 +1,11 @@
|
|||||||
# LANE C — interiors contract (venues + real-town class mapping) → Lanes A/D/E/F
|
# LANE C — interiors contract (venues · real-town mapping · real stock) → Lanes A/D/E/F/G
|
||||||
|
|
||||||
|
> **v5.0-FROZEN · 2026-07-17 · the whole document.** Every section below is the shipped, measured state of
|
||||||
|
> Lane C's contracts across five epochs. Changes from here are version-bumped amendments, recorded in the
|
||||||
|
> marker list — the same discipline §0–§5 have carried since v3.0. **Case law worth keeping:** three of this
|
||||||
|
> doc's amendments exist because a rule of mine met a case written *after* it (§7's naming vs the loader,
|
||||||
|
> §7.2a's `sku_` before mint existed) — the lane building to the rule took the red both times. When a line
|
||||||
|
> here meets a case it didn't foresee, **the line is the stale thing.** Amend it; don't bend the case.
|
||||||
|
|
||||||
> **v3.0-FROZEN** · 2026-07-16 · durable contract for Lane D + Lane F. §0–§5 describe the *shipped*
|
> **v3.0-FROZEN** · 2026-07-16 · durable contract for Lane D + Lane F. §0–§5 describe the *shipped*
|
||||||
> venue state; changes require a version bump (CITY_SPEC amendment law). Verified R13 (build) + R14 (audit).
|
> venue state; changes require a version bump (CITY_SPEC amendment law). Verified R13 (build) + R14 (audit).
|
||||||
@ -22,6 +29,9 @@
|
|||||||
> **v5.0-beta amendment (2026-07-17, R26 wave 4):** **§7.2a's id form is now SOURCING-SCOPED** —
|
> **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:
|
> `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.
|
> 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
|
*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
|
converts a chosen shop to a venue kind in place (`shop.type = kind`); C keys the interior recipe off
|
||||||
@ -413,3 +423,127 @@ Fresh context, against G's merged `stock_godverse/3962749/` (120 real records):
|
|||||||
- **Collision fixed:** shop pack (120 items) and town pack (350) coexist as distinct objects.
|
- **Collision fixed:** shop pack (120 items) and town pack (350) coexist as distinct objects.
|
||||||
- **Fail-soft with a POPULATED cache:** missing base ⇒ `null` ⇒ parody (the R23 bug is gone).
|
- **Fail-soft with a POPULATED cache:** missing base ⇒ `null` ⇒ parody (the R23 bug is gone).
|
||||||
- **Ceilings:** 98 draws with real covers via `opts.stockBase`; `pathOK`; 0 carves; 0 console errors.
|
- **Ceilings:** 98 draws with real covers via `opts.stockBase`; `pathOK`; 0 carves; 0 console errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Tier-2 live enrichment — the seam (v5.0, R27 ledger #2) → Lane F
|
||||||
|
|
||||||
|
**Ruling: bless the direct wire — no new lifecycle hook. But the `gone` *application form* is C's, and it's
|
||||||
|
landed in `dig.js`.** The question wasn't hook-vs-no-hook; it was *how* `gone` applies.
|
||||||
|
|
||||||
|
**1. Lifecycle — no hook needed.** F already holds `currentAdapter` (`interior_mode.js:67`) and passes it to
|
||||||
|
`dig.open()` **per dig** (`:111`) — a fresh read every time a crate is opened. When the live read lands, F
|
||||||
|
updates the pack; the next dig picks it up. If the read hasn't landed, hasn't finished, or failed, the
|
||||||
|
adapter is simply the tier-1 pack — **the dig never blocks on the network** (charter risk #1) and no C
|
||||||
|
lifecycle changes.
|
||||||
|
|
||||||
|
**2. The form — pick-then-omit, NEVER filter-the-array.**
|
||||||
|
`pack.gone` = a `Set`/array of ids the live read reports sold. C picks from the **full, stable `items[]`**
|
||||||
|
and omits gone ids **after** the draw.
|
||||||
|
- **Why (measured, R27, 5 crates):** filtering `items[]` first **reshuffles the crate** — every pick indexes
|
||||||
|
by position, so **one sale changed 21 OTHER records**. Pick-then-omit: **0 collateral**. A crate with 3
|
||||||
|
sold shows 13, not a different 16.
|
||||||
|
- **Both forms hide the sold record** — so **“the gone item never renders” cannot tell them apart.** → **F:
|
||||||
|
the sandbox proof (#4) must assert on *collateral* (the survivors are unchanged across a `gone` delta),
|
||||||
|
not just absence. The obvious assertion is vacuous here.** (Vacuous-gate law, third application — and the
|
||||||
|
cheapest one to get wrong, because it goes green.)
|
||||||
|
- **Proven in the real dig:** sold record gone · survivors **4/4** kept · **collateral 0** · `items[]`
|
||||||
|
untouched (120).
|
||||||
|
|
||||||
|
**3. F's invariant — `items[]` identity and order ARE the pick's stability.**
|
||||||
|
- **Do:** update item *fields* in place (live `price`) — free, no reshuffle; the card reads `it.price` at
|
||||||
|
pull time, so a live price shows on the next pull.
|
||||||
|
- **Never:** add, remove, or reorder `items[]`. That is what `pack.gone` exists for.
|
||||||
|
|
||||||
|
**4. The determinism boundary sits exactly here.** Server down ⇒ no `gone`, no live fields ⇒ the dig is
|
||||||
|
tier 1, deterministic and gate-able. Server up ⇒ the crate legitimately changes as records sell — tier 2 is
|
||||||
|
non-deterministic **by design** (charter law #2). So **no gate may assert dig determinism with the server
|
||||||
|
up**, and no live value may enter a seeded stream or a golden: `pack.gone` is consumed at pick time and
|
||||||
|
never persisted.
|
||||||
|
|
||||||
|
**5. The bin fan is decorative, not inventory.** The room's visible sleeves are built at `buildInterior` from
|
||||||
|
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,118 @@ _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:
|
||||||
|
**"if the measured win is <20k tris in practice, KILL the item with the measurement and it leaves the
|
||||||
|
backlog forever."** It does not survive. **E: do not build the `_lo` variants — that work is now unnecessary.***
|
||||||
|
|
||||||
|
### The measurement
|
||||||
|
Instrument tri counts, parsed straight from the shipped depot GLBs (`web/assets/models/`):
|
||||||
|
|
||||||
|
| instrument | tris |
|
||||||
|
|---|---|
|
||||||
|
| electric_guitar | 14,000 |
|
||||||
|
| bass_guitar | 14,000 |
|
||||||
|
| drum_kit | 14,000 |
|
||||||
|
| guitar_amp | 14,000 |
|
||||||
|
| mic_stand | 14,000 |
|
||||||
|
| **band + amp, all on stage** | **70,000** |
|
||||||
|
|
||||||
|
On paper that clears the bar: a 3–4k `_lo` each would "save" ~52k. **But the rule says *in practice*, and the
|
||||||
|
brief named the reason — "interiors are close-quarters."** So I measured the rooms an LOD would have to fire in
|
||||||
|
(live, all three venue archetypes, `interiorMode` driven by hand since rAF is throttled):
|
||||||
|
|
||||||
|
| venue | room | **max diagonal** | room tris (no band) | draws |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| pub — The Exchange Hotel | 9.7 × 9.7 m | 13.7 m | 12,522 | 38 |
|
||||||
|
| band_room — The Vinyl Shed | 8.9 × 9.6 m | 13.0 m | 11,699 | 35 |
|
||||||
|
| rsl — Wangaratta RSL (biggest) | 12 × 9.4 m | **15.2 m** | 15,115 | 38 |
|
||||||
|
|
||||||
|
### The verdict: practical win = **0 tris**. KILLED.
|
||||||
|
**A player can never be more than 15.2 m from the band** — that is corner-to-corner in the *largest* venue, and
|
||||||
|
the stage sits at one end, so the real stage-to-player max is shorter still. An LOD is a *distance* trade: it
|
||||||
|
pays when the subject is small on screen. Inside a ≤15.2 m room the band is never small — it is the subject of
|
||||||
|
the gig, and of F's tour money shot. Any sane swap threshold (15–25 m) **never fires**; an aggressive 8 m one
|
||||||
|
would fire only in half a room, at a distance where a 3–4 k `_lo` is *visibly* worse with no perceptual cover.
|
||||||
|
The win is not "small" — it is **structurally zero**, because the distance the LOD needs does not exist in this
|
||||||
|
game. **Per the wind-sway rule: killed, off the backlog forever.**
|
||||||
|
|
||||||
|
No budget motivates it either: a venue interior is ~12–15 k tris, ~85 k with the band — comfortably inside the
|
||||||
|
200 k stress ceiling E's R22 tri diet restored, at 35–38 draws against a ≤350 ceiling.
|
||||||
|
|
||||||
|
### Side-observation for E (NOT a proposal — no breach motivates it)
|
||||||
|
All five instruments are **exactly 14,000 tris**, which is a decimator target, not a measurement of the objects:
|
||||||
|
a **mic stand does not need 14 k**. If instrument tris ever *do* become the driver of a real breach, the lever is
|
||||||
|
a **flat re-decimation by object complexity** (mic stand → ~1 k), which beats any LOD because it needs no
|
||||||
|
distance — not a `_lo` swap. Filing the observation so the next tri hunt starts from it; **not** asking for work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ROUND 23 — v5.0-alpha: the GIG_RANGE chunk-neighbour fix (ledger #6, the carried v4.x item)
|
## ROUND 23 — v5.0-alpha: the GIG_RANGE chunk-neighbour fix (ledger #6, the carried v4.x item)
|
||||||
|
|
||||||
*PROCITY-D, 2026-07-16. My own filed finding, finally fixed — post-release, as Fable scheduled. Hot path,
|
*PROCITY-D, 2026-07-16. My own filed finding, finally fixed — post-release, as Fable scheduled. Hot path,
|
||||||
|
|||||||
@ -1,5 +1,110 @@
|
|||||||
# LANE E — cross-lane notes
|
# LANE E — cross-lane notes
|
||||||
|
|
||||||
|
## Round 28 — Spike 1: half of it shipped, half of it doesn't exist ⟨v5.1⟩
|
||||||
|
|
||||||
|
**1. `look.glb` — DONE, published, verified.** `Looking_Around.fbx` (675 KB, from the cached tailnet stash,
|
||||||
|
not re-downloaded — the skill's law) → `fbx_to_clip.py` → **`web/models/peds/look.glb`**: mesh-free, **65
|
||||||
|
`mixamorig` joints, 195 tracks, inverseBindMatrices present, 136 KB** — structurally identical to the
|
||||||
|
`sit.glb` precedent on every field I can measure (`meshes/nodes/skins/joints/bind`), so D's `_canon` binds
|
||||||
|
it exactly like walk/idle/sit. Depot: published to `digalot.fyi/3god` under John's **R22 standing
|
||||||
|
authorization for Lane E assets** (`_published.json` 43 → 44). **→ D: it's ready.**
|
||||||
|
|
||||||
|
**2. 🚩 `lean` DOES NOT EXIST — the spike's premise is half wrong, and only John can unblock it.** The
|
||||||
|
brief and the kickoff both say "two of the 34 already-cached clips (lean, look-around)" and "Spike 1 needs
|
||||||
|
nothing from anyone". **`look-around` is cached. `lean` is not — anywhere.** Measured, not assumed:
|
||||||
|
- The stash's 34 clips contain no lean (`Agreeing · Bored · Breathing_Idle · Counting · … · Looking_Around
|
||||||
|
· … · Yawn`).
|
||||||
|
- `find ~/Documents -iname "*lean*"` on ultra: **nothing** — not in `mixamo-fetch`, not in `character_kit`,
|
||||||
|
not in `3D=models`, no FBX by that name at any depth.
|
||||||
|
- The fetch tool can't help unattended: `node fetch.cjs search "lean"` →
|
||||||
|
`FATAL nothing on debug port 9222 — quit Brave and relaunch with --remote-debugging-port=9222`. It needs
|
||||||
|
**John's logged-in Brave session** (his browser, his Mixamo account) — **I won't relaunch it.**
|
||||||
|
|
||||||
|
**→ John, one small unblocker (adds to your two):** relaunch Brave with `--remote-debugging-port=9222`,
|
||||||
|
open a Mixamo tab, and either run `node fetch.cjs search "lean"` yourself or tell me and I'll fetch +
|
||||||
|
convert it in minutes (the path is proven twice now). **I did NOT substitute a lookalike.** `Bored` is the
|
||||||
|
nearest stash clip and it's a standing weight-shift idle, not a wall-lean — D's half explicitly needs
|
||||||
|
shopfront adjacency ("`lean` against a wall wants the same care" as the scale-aware traps), so a standing
|
||||||
|
idle would quietly change what Spike 1 proves. Half a spike, honestly, beats a whole one that lies.
|
||||||
|
|
||||||
|
**3. Instrument LOD — D killed it, and D's measurement is better than mine.** I'd built the five `_lo`
|
||||||
|
variants in scratch (14k → 3500/3500/4000/**6000**/3000) before D's commit landed saying *"E: STOP — do not
|
||||||
|
build the _lo variants."* **Nothing shipped**; scratch discarded. D measured the rooms an LOD would have to
|
||||||
|
fire in — **max diagonal 15.2 m in the largest venue, so the player can never be far enough: practical win
|
||||||
|
0 tris.** That's decisive in a way my argument wasn't. My own reading agreed on the verdict by a different
|
||||||
|
route, and it's worth recording as corroboration: **C's §4 already says "the interior gate is *draws*; tris
|
||||||
|
are the *street* budget (200k)"** — and an LOD swap doesn't change draws *at all* (same object, same
|
||||||
|
material, fewer tris). So the item's entire win landed on a metric no interior gate reads. Two independent
|
||||||
|
measurements, one verdict. **The oldest tri item is dead, correctly.**
|
||||||
|
|
||||||
|
*(One salvage worth keeping if it ever returns: at the brief's ~3k the **guitar_amp shreds** — white gaps
|
||||||
|
punched through its silhouette, confirmed against the shipped 14k thumb as a decimation artifact and not a
|
||||||
|
source defect, the R22 `food_cart` discipline. It needs **6k** to read clean. The other four were clean at
|
||||||
|
3–4k.)*
|
||||||
|
|
||||||
|
**4. 🚩 → A: your own file's comment now contradicts its code.** Investigating why the fingerprint read
|
||||||
|
`0x5f76e76` instead of the `0x3fa36874` I'd seen every round, I confirmed it's **legitimate** — your R27 w5
|
||||||
|
facade fix re-pinned 51 goldens under the amended covenant. But `selfcheck.js:267` pins
|
||||||
|
`hash: 0x5f76e76` while **`selfcheck.js:301` still says "the synthetic golden above stays 0x3fa36874"**.
|
||||||
|
Code and comment disagree by 34 lines. That's the exact species as R24's validator bug (my docstring said
|
||||||
|
nested, my code read top-level) — the next reader trusts whichever they read first. Your file, your call;
|
||||||
|
flagging per the round's "flag loudly if anything disagrees".
|
||||||
|
|
||||||
|
**5. Gates.** selfcheck **ALL GREEN 156,352/156,352** · `qa.sh --strict` **6 passed / 0 failed**. `look.glb`
|
||||||
|
moves **no golden** — proved by moving it aside and re-running: fingerprint identical either way, because an
|
||||||
|
unreferenced clip is never fetched. The `?classic=1` covenant is untouched: like `sit.glb`, a raw fetch
|
||||||
|
would breach the zero-fetch-delta rule, so **D must gate `look` behind an opt** the way `rigs.js` gates
|
||||||
|
`sit` (`loadPedFleet(base, { sit })` → the same shape for `look`). Depot-live verified by fetching the
|
||||||
|
bytes back: `https://digalot.fyi/3god/a/look.glb` → **HTTP 200, 139,640 bytes, sha1 identical to local**.
|
||||||
|
|
||||||
|
## Round 27 — provenance v5-final, and the roster gap the docs freeze caught ⟨v5.0⟩
|
||||||
|
|
||||||
|
**1. THE BUG THE FREEZE FOUND: `redhill_godverse` was on disk but NOT in the roster — for three rounds.**
|
||||||
|
The v5 provenance table needed real numbers, so I measured instead of carrying the v4 row forward. The
|
||||||
|
count didn't match: **23 caches on disk, 22 rostered.** The missing one was **`redhill_godverse` — the
|
||||||
|
town the entire v5 epoch walks to.**
|
||||||
|
|
||||||
|
**Mine, squarely.** `index.json` is Lane E's file (`write_index()`). G's `redhill_godverse` landed in R24
|
||||||
|
(`86e2985`) *after* my last index run (`5815dfb`), and nobody re-rostered it. **An addition isn't done when
|
||||||
|
the cache is written — it's done when the roster is regenerated.** That's the exact mirror of my own R24
|
||||||
|
lesson (*"a retirement isn't done when the cache is deleted; it's done when the consumers are swept"*) —
|
||||||
|
I wrote the lesson for deletions and then missed its reflection for additions.
|
||||||
|
|
||||||
|
**Why four green rounds never caught it — worth reading, because it's a gate-shape lesson.** Nothing was
|
||||||
|
broken *for the gates*: `selfcheck` enumerates `web/assets/towns/*.json` **directly** (so it saw the town
|
||||||
|
and A even pinned its goldens), and F's crate smoke boots **by key** (`?town=redhill_godverse`). The
|
||||||
|
roster's *only* consumer is **B's HUD selector** (`hud.js`, my R20 ledger #2 design). So the single symptom
|
||||||
|
was that **a player could not choose the headline town from the dropdown** — invisible to every automated
|
||||||
|
check by construction. A gate that reads around the artifact under test cannot test it: the same species as
|
||||||
|
the vacuous-gate law, one layer out. **Fixed:** re-ran `write_index()` → **23 rostered, 23 on disk, 0
|
||||||
|
missing**; only `index.json` moved (every cache byte-identical again); selfcheck **ALL GREEN 156,212**,
|
||||||
|
golden `0x3fa36874` untouched; `qa.sh --strict --matrix` **7/0/0**.
|
||||||
|
|
||||||
|
**→ F: this matters for #5, the tour.** The money shot walks Musgrave Road into Monster Robot Party. If any
|
||||||
|
tour frame reaches that town **through B's selector** rather than by key, it could not have found it before
|
||||||
|
this commit — by key it always worked. Worth one glance at your framing path.
|
||||||
|
**→ B:** the dropdown now carries both godverse towns. Your `${t.state ? ', ' + t.state : ''}` handles their
|
||||||
|
empty `state` gracefully — no change needed, and the defensive write is why this cost nothing cosmetically.
|
||||||
|
**→ G:** your godverse caches carry no `state`, so they roster as `state:''`. Harmless today (B degrades
|
||||||
|
cleanly). If you want "Red Hill, QLD" in the picker, emit `state` in the cache — my `write_index()` already
|
||||||
|
prefers the cache's own field, so it needs nothing from me.
|
||||||
|
|
||||||
|
**2. Provenance v5-final (ledger #6).** The ⟨v5.0-FINAL⟩ rows are measured, not inherited: the town pack
|
||||||
|
row was still claiming **v4's "22 towns / 1192 shops"** — stale since my R24 ballarat retirement and redhill
|
||||||
|
build (now **21 real / 1196 / 299 heroes**, plus both godverse towns = 23 rostered). Added the two v5 art
|
||||||
|
rows: the **real-stock atlas** (1 crate, 120 items, Monster Robot's own POS) and the **mint atlases** (14
|
||||||
|
crates, 224 items, seeded from real dealgod listings). **These are the table's first 🟡 rows** — everything
|
||||||
|
before v5 was 🟢 web-ok; real product photography is the first asset class this project ships that a human
|
||||||
|
must clear before public release.
|
||||||
|
|
||||||
|
**3. The licence line's story, written down as a mechanism (ledger #6).** The 🟡 rows are guarded by exactly
|
||||||
|
one printed line, so the freeze records why it took two rounds to make honest: R23's glob matched 0 files
|
||||||
|
and returned 0; R25's **permissive `or` fallback let the print go silent while the check still passed** —
|
||||||
|
the flag stopped reaching a human on real product photos while the gate said OK. Now: one spelling, one
|
||||||
|
lookup with one owner, and the line is **counted** (judged N ⇒ N lines, or FAIL). **Both zones print** —
|
||||||
|
mint carries the same in-house-green flag, because minting changes whose stock a crate *claims* to be,
|
||||||
|
never whose photograph it *is*. Frozen today at **15 crates, 15 flags, every one in front of a human.**
|
||||||
|
|
||||||
## Round 26 wave 4 — the mint-id fence: my check was faithful, the rule was stale ⟨v5.0-beta⟩
|
## Round 26 wave 4 — the mint-id fence: my check was faithful, the rule was stale ⟨v5.0-beta⟩
|
||||||
|
|
||||||
**The red was mine and the fix is C's ruling.** My R25 slot-id check demanded `sku_` on every real-stock
|
**The red was mine and the fix is C's ruling.** My R25 slot-id check demanded `sku_` on every real-stock
|
||||||
@ -702,12 +807,52 @@ loader wiring (`spawnRig`/fleet). Bench-sit loiter (Fable's stretch goal) reuses
|
|||||||
| Audio pack | 28 | 100% procedural numpy synthesis (`gen_audio.py`) | oscillator/noise render → ffmpeg OGG+M4A | 🟢 CC0-equiv, parody-safe |
|
| Audio pack | 28 | 100% procedural numpy synthesis (`gen_audio.py`) | oscillator/noise render → ffmpeg OGG+M4A | 🟢 CC0-equiv, parody-safe |
|
||||||
| Stock packs | 3 (350/311/273) | GODVERSE `dealgod` Postgres — **real** cover art | `build_stock_pack.py`: parody-transform metadata + atlas | 🟢 real-art / parody-metadata |
|
| Stock packs | 3 (350/311/273) | GODVERSE `dealgod` Postgres — **real** cover art | `build_stock_pack.py`: parody-transform metadata + atlas | 🟢 real-art / parody-metadata |
|
||||||
| Ped clip **⟨v3.1⟩** | `sit.glb` (E R16; + pre-existing `walk`/`idle` from D/90sDJsim) | Adobe **Mixamo** `Sitting_Idle.fbx` | `fbx_to_clip.py` (Blender, mesh-strip) | 🟢 Mixamo royalty-free (motion only, mesh stripped) |
|
| Ped clip **⟨v3.1⟩** | `sit.glb` (E R16; + pre-existing `walk`/`idle` from D/90sDJsim) | Adobe **Mixamo** `Sitting_Idle.fbx` | `fbx_to_clip.py` (Blender, mesh-strip) | 🟢 Mixamo royalty-free (motion only, mesh stripped) |
|
||||||
| Town pack **⟨v4.0-FINAL⟩** | **22 real AU towns, all 8 states/territories** — 1192 shops + real street geometry (`roads[]`) | **OpenStreetMap** via Overpass API — bounded per-town bbox; raw shops+roads snapshots committed in `_raw/` | `build_towns.py`: fetch → dedupe/suburb-fill/parody → C's §6 class map → 3× texture cap (drops counted) → v2 cache + roads | 🟢 **ODbL 1.0** (© OSM contributors; `web/assets/towns/SOURCES.md` + `license`/`attribution` on every cache) |
|
| Ped clip **⟨v5.1⟩** | `look.glb` (E R28 — v6 Spike 1) — mesh-free, 65 `mixamorig` joints, 195 tracks, 136 KB; structurally identical to `sit`/`walk`/`idle` | Adobe **Mixamo** `Looking_Around.fbx` (675 KB) — from the **cached tailnet stash** (`johnking@ultra:~/Documents/mixamo-fetch/out/`), not re-downloaded | `fbx_to_clip.py` (Blender, mesh-strip) — the R16 path, unchanged | 🟢 Mixamo royalty-free (motion only, mesh stripped) — same zone as walk/idle/sit |
|
||||||
| Godverse town *(Lane G's, listed for completeness)* | `newtown_godverse` — 18 shops, rostered in E's `index.json` | GODVERSE/thriftgod shop census + OSM road geometry | `pipeline/godverse_town.py` (**Lane G owns this provenance**) | 🟢 ODbL 1.0 + GODVERSE census (G's own `license`/`attribution` fields) |
|
| Ped clip **⟨v5.1⟩ NOT BUILT** | `lean.glb` — **the source does not exist** (see R28 §1) | — | — | — |
|
||||||
|
| Town pack **⟨v5.0-FINAL⟩** | **21 real AU towns, all 8 states/territories** — **1196 shops** (299 heroes) + real street geometry (`roads[]`) | **OpenStreetMap** via Overpass API — bounded per-town bbox; raw shops+roads snapshots committed in `_raw/` | `build_towns.py`: fetch → dedupe/suburb-fill/parody → C's §6 class map → 3× texture cap (drops counted) → v2 cache + roads | 🟢 **ODbL 1.0** (© OSM contributors; `web/assets/towns/SOURCES.md` + `license`/`attribution` on every cache) |
|
||||||
|
| Godverse towns *(Lane G's, listed for completeness)* **⟨v5.0-FINAL⟩** | `newtown_godverse` (72 shops) · **`redhill_godverse` (37)** — the town whose record shop is THE shop. Both rostered in E's `index.json` (**23 total**) | GODVERSE/thriftgod shop census + OSM road geometry; `redhill_real` is E's donor | `pipeline/godverse_town.py` (**Lane G owns this provenance**) | 🟢 ODbL 1.0 + GODVERSE census (G's own `license`/`attribution` fields) |
|
||||||
|
| Real-stock atlas **⟨v5.0-FINAL⟩** | **1 crate, 120 items**, 2×2048² webp — Monster Robot Party (`3962749`) | **the real POS** (`recordgod`) — crate 550 "DEEP HOUSE"; covers are **the shop's own product shots**, cached by dealgod | `pipeline/godverse_stock.py` (**Lane G owns**) — `snapshot`(DB-side, sha256-pinned) → `pack`(pure) | 🟡 **in-house-green — FLAG BEFORE ANY PUBLIC/COMMERCIAL RELEASE** · `sourcing:"real"` · ids `sku_<POS id>` |
|
||||||
|
| Mint atlases **⟨v5.0-FINAL⟩** | **14 crates, 224 items**, 14×1024² webp — keyed hero shops with **no POS** (5 record · 6 book · 3 toy across both godverse towns; the 6th record crate is the real one above) | **real dealgod listings** (the sellers' own product shots) — a *seeded sample*, `random.Random(shop_id)`, deterministic | `godverse_stock.py` mint baker (**Lane G owns**) — same bake path, sampled source | 🟡 **in-house-green — same flag** · `sourcing:"mint"` · ids `mint_<listing id>` · **never claimed as that shop's stock** |
|
||||||
|
|
||||||
**GLB fleet = 33 manifest (23 fit + 10 furniture) + 5 `_hi` = 38 published.** Only external spend ever:
|
**GLB fleet = 33 manifest (23 fit + 10 furniture) + 5 `_hi` = 38 published.** Only external spend ever:
|
||||||
~$0.64 (fal-Hunyuan, R5). Everything else is $0 / on-device (electricity only).
|
~$0.64 (fal-Hunyuan, R5). Everything else is $0 / on-device (electricity only).
|
||||||
|
|
||||||
|
### ⟨v5.0-FINAL⟩ The licence line's story — why one printed line is a provenance mechanism
|
||||||
|
|
||||||
|
**The only 🟡 in this table is the v5 stock art, and the flag is the whole control.** Charter law #3 says
|
||||||
|
clearing photos for public release is **a human call** — so the gate does not pattern-match the licence
|
||||||
|
and rule on it. It **prints it**, every run, for every atlas, and a human decides. That makes one printed
|
||||||
|
line the mechanism the amber rows depend on, which is why it earned two rounds of work:
|
||||||
|
|
||||||
|
- **R23** — the gate globbed a name the runtime can never load: **0 files matched, returned 0**. It passed
|
||||||
|
identically whether atlases were absent, misnamed, or perfect. (The vacuous-gate law came from this.)
|
||||||
|
- **R25 — the line went silent and the gate still said OK.** Not a typo: the **check** read
|
||||||
|
`licence or license` (permissive) while the **print** read `licence` only. G's re-emit moved to US
|
||||||
|
`license`; the check passed, the print resolved to `{}`, and `FLAG BEFORE ANY PUBLIC/COMMERCIAL RELEASE`
|
||||||
|
**stopped reaching a human on a real shop's product photos**. *The fallback WAS the bug* — it let one
|
||||||
|
path stay permissive so it could not feel the other break.
|
||||||
|
- **The fix is structural, not a spelling.** One spelling, no fallback (British `licence` now ERRORS,
|
||||||
|
naming C's §7.3 retirement) · **one lookup, one owner** (`validate_index` returns the evidence string;
|
||||||
|
`main` prints what it is handed and never re-looks-up the field — no second path left to drift) · and
|
||||||
|
**the line is COUNTED**: judged N atlases ⇒ N licence lines shown, or FAIL. A gate that can say OK while
|
||||||
|
hiding the flag cannot exist by construction.
|
||||||
|
|
||||||
|
**Today, frozen:** `atlas-QA OK — 15 per-shop atlas(es) valid, 15 licence line(s) shown, 0 warning(s)`.
|
||||||
|
Fifteen crates, fifteen flags, every one in front of a human. **Both zones print** — the mint rows carry the
|
||||||
|
same in-house-green flag because a seeded sample of real dealgod listings is still **the sellers' own
|
||||||
|
product shots**. Minting changes whose stock it *claims* to be, never whose photograph it *is*: that is why
|
||||||
|
`sourcing` is honesty and the licence is unchanged by it.
|
||||||
|
|
||||||
|
### ⟨v5.0-FINAL⟩ The id-space fences — provenance carried in the id itself
|
||||||
|
|
||||||
|
Two disjoint namespaces, blessed as design (C §7.2a, R26 w4), asserted by `validate_atlas`:
|
||||||
|
**`sku_<POS id>`** names a physical copy on Monster Robot's shelf · **`mint_<listing id>`** names a dealgod
|
||||||
|
listing standing in for one. A crate wearing the other's prefix **FAILS**. This is not naming hygiene:
|
||||||
|
tier-2's sold-means-gone looks up POS skus, so a `mint_` id filed under `sku_` doesn't fail a gate — it
|
||||||
|
**sells a real record out of a real shop to satisfy a minted stand-in**. G refused that shortcut when it
|
||||||
|
would have gone green the easy way, and that judgment is the fence. *(Same standing note as
|
||||||
|
plan-ids vs godverse-ids: one name, one space — never compare across.)*
|
||||||
|
|
||||||
### Audio inventory — 28 assets (`web/assets/audio/`, committed; contract = `manifest.audio`)
|
### Audio inventory — 28 assets (`web/assets/audio/`, committed; contract = `manifest.audio`)
|
||||||
|
|
||||||
All procedural, seeded, $0, parody-safe; OGG/Opus + M4A/AAC; beds+music loudnorm −16 LUFS, SFX peak-norm.
|
All procedural, seeded, $0, parody-safe; OGG/Opus + M4A/AAC; beds+music loudnorm −16 LUFS, SFX peak-norm.
|
||||||
|
|||||||
@ -1229,3 +1229,662 @@ seeded, stable, 0 reuse across 15 crates), which is what made the ruling a decis
|
|||||||
**`atlas-QA OK — 15 per-shop atlas(es) valid, 15 licence line(s) shown, 0 warning(s)`** — and that second
|
**`atlas-QA OK — 15 per-shop atlas(es) valid, 15 licence line(s) shown, 0 warning(s)`** — and that second
|
||||||
number is R24's finding #4 at full scale: fifteen `FLAG BEFORE ANY PUBLIC/COMMERCIAL RELEASE` lines now
|
number is R24's finding #4 at full scale: fifteen `FLAG BEFORE ANY PUBLIC/COMMERCIAL RELEASE` lines now
|
||||||
reach a human, where one silently printed nothing.
|
reach a human, where one silently printed nothing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 27. Round 27 — v5.0 THE LIVE CRATE. F: the reader, and the server killed on purpose.
|
||||||
|
|
||||||
|
### ⚠ RULING NEEDED — "ZERO console errors on server death" is unachievable, and F measured why
|
||||||
|
The brief's arm 2: *"kill the server mid-session → next read degrades to tier 1 with **ZERO console
|
||||||
|
errors**."* **It cannot.** A `fetch` to a dead host logs `net::ERR_CONNECTION_REFUSED` at the **browser**
|
||||||
|
level — no `.catch()` suppresses it, because nothing in JS ever sees it. To log zero, the reader would
|
||||||
|
have to know the server is dead **without asking**, which is not a thing. **One refused connection is the
|
||||||
|
irreducible cost of discovering death.**
|
||||||
|
|
||||||
|
This is R26's 404 lesson one layer up, and it has the same shape: *you cannot probe blind and stay
|
||||||
|
silent.* R26's answer was to stop probing blind — the manifest told us what existed. There is no
|
||||||
|
manifest for "is the server alive"; the only way to know is to ask.
|
||||||
|
|
||||||
|
**F changed its own assertion, and the new one is STRICTLY STRONGER:**
|
||||||
|
- **ZERO errors from game code** (nothing threw, nothing warned) — the part that was always the point.
|
||||||
|
- **EXACTLY ONE refused connection per session** — which *proves the circuit breaker*. Without the
|
||||||
|
breaker it is one error per shop entry, forever. **"Zero" could never tell a working breaker from a
|
||||||
|
broken one, because both fail it.** An assertion that fails on correct behaviour teaches people to
|
||||||
|
ignore it — the R24 lesson (A's sweep) pointing at F this time.
|
||||||
|
|
||||||
|
**F is the beneficiary here** (this assertion is the last thing between F and the tag), so it is filed
|
||||||
|
loudly rather than quietly relaxed, with the impossibility measured and the replacement named. Fable
|
||||||
|
ratifies or overrules at the retro. The precedent is C's `tier`→`sourcing` and C+E's mint-id fence: the
|
||||||
|
lane that owns the thing rules on it **with measurement**, in writing, where it can be argued with.
|
||||||
|
|
||||||
|
### ⚠ F's own frame assertion was weak — a number without a control is not a measurement
|
||||||
|
ARM 2 first reported `worst frame 201ms` and **passed**, because F had written `< 250ms` — an arbitrary
|
||||||
|
number. 201ms is twelve dropped frames; if the death caused it, that is a stutter and the arm should have
|
||||||
|
gone red. **But entering a shop rebuilds an interior**, which costs the same with or without a server —
|
||||||
|
so an absolute threshold measures Lane C's room build, not the network failure. Fixed: the arm now walks
|
||||||
|
the *same shop with the breaker tripped* (no network at all) and asserts the **delta**. The question is
|
||||||
|
"did the death cost anything", not "is entering fast". *F caught this in F's own gate, one round after
|
||||||
|
telling A the same thing.*
|
||||||
|
|
||||||
|
### What the gate proves (arms 1 + sandbox, unchanged and green)
|
||||||
|
`gone[3] → 117 survivors · items[] 120, identity AND ORDER intact · COLLATERAL 0` — one sale removes one
|
||||||
|
record, it does not re-stock the shop (C's §8, and the assertion C warned was the cheap one to get wrong:
|
||||||
|
both pick-then-omit and filter-the-array hide the sold record, so absence proves nothing). `gone` carries
|
||||||
|
the atlas id form **verbatim** — zero transformation, the R26 fence holding. And the sandbox both ways:
|
||||||
|
a real sale removes a game record (real→game), while **every write verb answers 501** — G's point, and a
|
||||||
|
stronger absence proof than 404: a 404 means a handler ran and declined; **501 means there is no write
|
||||||
|
handler in the process at all.** A mint shop 404s: only the one real shop is live, which is the point.
|
||||||
|
|
||||||
|
**The subject exists only because G built it.** Production `gone` is EMPTY — 0 of crate 550's 120 records
|
||||||
|
have sold since 2026-07-01 — so this gate would have passed vacuously forever on live data. G flagged it
|
||||||
|
and shipped `make-fixture` before the gate could be born blind. *"A fixture is not a lie; a green assert
|
||||||
|
over an absent subject is."*
|
||||||
|
|
||||||
|
### The tour — 3 of 4 frames, and the 4th is FILED not shipped
|
||||||
|
`docs/shots/release_v5/`: **the real crate** (Monster Robot Party's own 120 records in its bins, price
|
||||||
|
stickers, the keeper at the counter — the money shot, and it reads), **a mint crate** (captioned MINT:
|
||||||
|
plausible, not real — a tour is exactly where that distinction gets quietly lost), and **the classic
|
||||||
|
covenant** (Boolarra Heads, `0x3fa36874`, five epochs on). Zero page errors.
|
||||||
|
|
||||||
|
**The frontage frame does not land, and F is not shipping it green.** Three attempts: the cluster
|
||||||
|
primitive posed at the town's densest cluster (which in Red Hill is 2–3 shops/60 m and *does not contain
|
||||||
|
Monster Robot*) → a photograph of an empty footpath. Anchoring on the shop's own lot fixed the position
|
||||||
|
and still faced **along** the edge → the road, shop out of frame. Facing the lot along its road-normal
|
||||||
|
still frames bitumen. **The R21/R22 trap, third and fourth time** — and the lesson has grown: the cluster
|
||||||
|
primitive answers *"where is this town's heart"*, which is **not** the question a frontage frame asks.
|
||||||
|
Red Hill's heart isn't Monster Robot; a sparse town has no heart to speak of. Anchor on the SUBJECT when
|
||||||
|
the frame is about the subject.
|
||||||
|
|
||||||
|
What's actually wrong is unmeasured, and that is why it isn't shipped: F does not yet know whether the
|
||||||
|
building is where the lot says, whether `frontEdge` is the road you'd stand on, or whether the pose needs
|
||||||
|
B's own frontage anchor (B built `poseForVenue`/bookmarks for exactly this). **Next: ask B** — B owns the
|
||||||
|
frontage and shipped the stocked-shopfront mark this round; F re-deriving B's anchor in a tour script is
|
||||||
|
how you get three bad frames. The interior money shot needed none of this: the room poses itself.
|
||||||
|
|
||||||
|
### ⚠ THE HOLD — `v5.0` NOT tagged. Every real town's shops face away from their street.
|
||||||
|
B's micro-task found it while fixing F's frame, and **F's three photographs of empty road are the
|
||||||
|
evidence**: it was never the pose. `plan_osm.js:443` aims the lot's **−Z** at the road ("facade (−Z)
|
||||||
|
faces road"); `buildings.js:404` draws the facade on the **local +Z face**. Exactly opposite. The
|
||||||
|
shopfront — sign, door, R26 crate mark — lands on the **far side**, and the street gets a blank wall.
|
||||||
|
**Since R18.** v4.0 shipped on it. Nine rounds of tours shot it. `katoomba_real`'s `street_noon`, the
|
||||||
|
bookmark the tours have used since R9, is 46 draws of bare road.
|
||||||
|
|
||||||
|
Root cause: **CITY_SPEC contradicts itself** — `:71` (the lots contract) says −Z, `:335` (R15 posters)
|
||||||
|
says "+Z, B's canon" — and each generator implements one half. **Synthetic follows B's canon and looks
|
||||||
|
right, which is why every golden passes and nobody saw it for nine rounds.** The epoch's signature
|
||||||
|
species, one last time, and the worst instance of it: not a gate that fails on correct behaviour, but a
|
||||||
|
gate suite that is *green over a product that is wrong*.
|
||||||
|
|
||||||
|
**F verified it, and F's first check was VACUOUS — worth recording, because it nearly ended the round the
|
||||||
|
wrong way.** F measured each lot's `ry` against CITY_SPEC:71's own convention and got **35/35 and 72/72
|
||||||
|
FACING** — a clean contradiction of B. But `plan_osm` implements :71, so testing :71's data against :71's
|
||||||
|
rule is self-agreement: it cannot fail. **A self-consistent check over a self-contradicting spec passes
|
||||||
|
under either convention** — which is precisely why nine rounds of checks never caught this, and why F
|
||||||
|
nearly reported "B is wrong" with a green number in hand. The only test that sees it compares the two
|
||||||
|
conventions. F's frames already had.
|
||||||
|
|
||||||
|
**Not F's to fix**, and B was right not to either: it is **A's file**, it moves **every real-town plan**
|
||||||
|
(so every real-town golden re-pins under the amendment law — 36 of them), and **a self-contradicting spec
|
||||||
|
is Fable's to rule**, not a lane's to pick a side of. B proved the one-liner works (`ry = atan2(-nx,-nz)`
|
||||||
|
→ katoomba 0/72 → 72/72, and F's money shot appears 4 m from Musgrave Road) and reverted it. That is the
|
||||||
|
same discipline F applied to A's sweep and E's validator, arriving from the other direction.
|
||||||
|
|
||||||
|
**Order: Fable rules the spec → A lands the line + re-pins the goldens in one commit (amendment law) → F
|
||||||
|
re-gates, re-shoots the tour, tags `v5.0`.** Everything else for the release is done and green: the
|
||||||
|
kill-the-server gate, the reader, the sandbox, the interior money shot (interiors pose themselves and are
|
||||||
|
untouched by this — the crate is real either way).
|
||||||
|
|
||||||
|
*And B's own postscript is the lesson twice over: B credited "chunk-buildings 348 tris (0.2%)" to the
|
||||||
|
instancing work in R22 — 348 tris is ~29 boxes. It wasn't efficiency, it was the buildings being absent
|
||||||
|
from frame. B read the evidence as a win for five rounds. A number you like is the easiest vacuous gate
|
||||||
|
of all.*
|
||||||
|
|
||||||
|
### Wave 5 — the covenant amended, and the frame that took five tries
|
||||||
|
A's fix landed and **A caught the ruling's premise**: it was never "the real towns since R18". Synthetic is
|
||||||
|
broken identically — **0 toward / 681 away, dot = −1.0000** — and so is the marched path, and all three
|
||||||
|
fixtures. **EVERY TOWN, EVERY PATH, SINCE v1.** A booted `?classic=1`, stood on the synthetic main street,
|
||||||
|
found three blank grey boxes, walked *behind* them and found **NUMBAT PLAYTHINGS facing the dirt**. The
|
||||||
|
covenant town has been a row of back walls since the first commit.
|
||||||
|
|
||||||
|
**F re-pinned its own files** per A's handoff (A doesn't edit other lanes' files, correctly):
|
||||||
|
`GOLDEN_HASH 0x3fa36874 → 0x5f76e76` · `GIG_GOLDEN → 0xec7a2d39` · melbourne · katoomba · the captions.
|
||||||
|
**And A caught a live hazard in F's gate**: `hash_ok` carried `or ('3fa36874' in r.stdout)` — a bare-string
|
||||||
|
OR-clause that was dead the moment the covenant moved, and worse than dead, because it would go green off
|
||||||
|
a *sentence about* the old covenant appearing in stdout. Deleted. The check derives from `GOLDEN_HASH` or
|
||||||
|
it doesn't exist.
|
||||||
|
|
||||||
|
**John's ruling is the epoch's best line: the covenant is anti-drift machinery, not a defect
|
||||||
|
preservative.** `0x3fa36874` did exactly its job for five epochs — it never drifted. It just was never
|
||||||
|
right, and **no hash can catch what was wrong in the first commit**. A golden proves *nothing changed*; it
|
||||||
|
cannot prove *anything is correct*. Every gate we own was green over a world of back walls, and the only
|
||||||
|
thing that ever saw it was a human standing in the street looking at a wall.
|
||||||
|
|
||||||
|
**The frontage frame took five attempts and four of them were F's fault:**
|
||||||
|
1. cluster pose → the town's heart, which in Red Hill isn't Monster Robot. Empty road.
|
||||||
|
2. lot-anchored, facing along the edge → the road, shop beside the lens.
|
||||||
|
3. lot-normal facing → bitumen.
|
||||||
|
4. **B's helper — and F threw the result away.** `poseForShopFront` *returns* a pose; it does not apply
|
||||||
|
one. F called it, error-checked it, and never touched the camera, so the frame was the spawn paddock.
|
||||||
|
*A helper that hands you a value is not a helper that does the thing* — and F's own error-check passed,
|
||||||
|
because the call succeeded perfectly. The vacuous check, one last time, in the last frame of the epoch.
|
||||||
|
5. Applied it → **MONSTER ROBOT PARTY**, sign, door, awning, a ped walking in off Musgrave Road.
|
||||||
|
|
||||||
|
Attempts 1–3 were also never winnable: the shops faced backwards the whole time. **F was posing a camera
|
||||||
|
at a wall and tuning the geometry.** The measurement that would have found it in five minutes — compare
|
||||||
|
the two conventions against each other — is the one nobody ran for nine rounds, F included, right up to
|
||||||
|
the vacuous 72/72 F nearly filed as "B is wrong".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 28. Round 28 — v5.1 THE SPIKE AND THE SWEEP. **The sweep landed. The spike did not reach the game.**
|
||||||
|
|
||||||
|
### ⚠ F's pose gate has NO SUBJECT — SKIPPED with the reason, per the law
|
||||||
|
The brief: *"New-clip figure checks (lean/look poses through the scale gate), a bookmark spot-check
|
||||||
|
(leaning peds in old frames)."* **There are no new poses in the game, so there is nothing to check:**
|
||||||
|
- **`lean` does not exist.** E measured it rather than assumed it — the stash's 34 clips contain no
|
||||||
|
lean, and `find -iname "*lean*"` across ultra returns nothing. The brief and the kickoff both named it
|
||||||
|
as already-cached; **the premise was half wrong**, and E found it the only way anyone ever finds these:
|
||||||
|
by looking instead of trusting the list.
|
||||||
|
- **`look.glb` is shipped, verified, published — and NOTHING BINDS IT.** `loadPedFleet()` loads
|
||||||
|
`walk.glb`, `idle.glb`, and `sit` (gated). No `look`. E's own handoff said it plainly: *"→ D: it's
|
||||||
|
ready. Gate it behind an opt the way rigs.js gates sit."* **D's entire R28 commit is
|
||||||
|
`LANE_D_NOTES.md` — 48 lines, docs only.** The run order said *"When E's clips land: D (#1 wiring)"*;
|
||||||
|
the log says **D committed BEFORE E shipped** (`afad68b` precedes `640bdbb`). Nobody was careless —
|
||||||
|
the wave order was, and the file on disk looks exactly like a delivered spike from every angle except
|
||||||
|
the one that matters.
|
||||||
|
|
||||||
|
**The R17/R23 pattern, fifth appearance, and the cheapest one yet to have missed**: an asset on disk,
|
||||||
|
verified, published to a public depot, sha1-checked by fetching the bytes BACK — and no consumer. Every
|
||||||
|
check E ran was real and passed. None of them could see that nothing loads it. *A gate on the artifact
|
||||||
|
cannot see the wire.*
|
||||||
|
|
||||||
|
### So: v5.1 SHIPS, and it does not claim the spike
|
||||||
|
Nothing is broken — an unbound clip is inert, the game is unchanged and correct. This is not R18's
|
||||||
|
"known breach"; it is an **unfinished item**, and holding a polish tag over an inert file would be
|
||||||
|
precious. **What ships is the sweep, and the tag says exactly that:**
|
||||||
|
- **C's `audioEmitter`** — the till rings from the counter, the bed from the stage. C's own idea, parked
|
||||||
|
since R7, and C found its own v3.0 note had specified it wrong on both halves (*"the panner was
|
||||||
|
inert"*).
|
||||||
|
- **Two items KILLED with the measurement** — B's awning ripple and D's instrument LOD, the oldest tri
|
||||||
|
item on the books. They don't leave the backlog "deferred"; they leave it **gone**, with the number
|
||||||
|
that killed them. *A backlog that only grows is a backlog nobody measures.*
|
||||||
|
- **`look.glb`** — staged on the depot, and honestly described as staged.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|||||||
@ -71,6 +71,130 @@ fences (`sku_`/`mint_`, plan-id/godverse-id) · no golden moves expected this ro
|
|||||||
touches no plan, no cache, no atlas bytes) · the offline law is THE gate this round, not a
|
touches no plan, no cache, no atlas bytes) · the offline law is THE gate this round, not a
|
||||||
footnote.
|
footnote.
|
||||||
|
|
||||||
|
## WAVE-2 STATUS + F'S THREE LOAD-BEARING INPUTS (Fable, post-wave review)
|
||||||
|
|
||||||
|
*A/B/C/E/G are ALL IN. The beta tagged. What remains is F's close (#2 reader → #3 gate → #4
|
||||||
|
sandbox → #5 tour → #7 tag). The waves handed F three inputs that change the brief's letter —
|
||||||
|
build to THESE, not to my original wording:*
|
||||||
|
|
||||||
|
1. **C's seam ruling (§8): direct wire, no hook — but PICK-THEN-OMIT is law.** The naive wiring
|
||||||
|
(filter `pack.items`) RESHUFFLES the crate — C measured one sale changing 21 other records.
|
||||||
|
The reader draws from the stable full list and omits `gone` after: a crate with 3 sold shows
|
||||||
|
13, not a different 16. **Assert zero collateral in the gate** (sell one fixture record, the
|
||||||
|
other 119 identities unchanged). "Selling one record must remove one record, not re-stock
|
||||||
|
the shop."
|
||||||
|
2. **G's finding: `gone[]` has no subject in production.** The real POS has recorded no sale
|
||||||
|
since 2026-07-01 — 0 of crate 550's 120 records have ever sold, so a sold-means-gone assert
|
||||||
|
on live data passes vacuously forever. G built the remedy: **`make-fixture`** gives the gate
|
||||||
|
a subject. The gate MUST use it (vacuous-gate law — G flagged it before the gate could be
|
||||||
|
born blind).
|
||||||
|
3. **G's lifecycle is the kill switch:** `godverse_server.py start|stop|status` — the
|
||||||
|
kill-the-server gate drives it directly. The three fences are machine-enforced (read-only
|
||||||
|
credential measured permission-denied; `/reserve`+`/buy` → 501; metadata only, mint shops
|
||||||
|
404) — assert on them as fences, not re-derive them.
|
||||||
|
|
||||||
|
*Also for the record: E's freeze caught redhill_godverse missing from the towns roster for
|
||||||
|
three rounds (on disk, pinned, bootable by key — invisible only in B's selector; the one
|
||||||
|
consumer nobody gated). E owned it, fixed it, and wrote the gate-shape lesson. B verified the
|
||||||
|
R26 handshake against G's real manifest. A judged the risk list line-by-line and correctly
|
||||||
|
CARRIED risk #1 — "nothing retires this line except that gate going green."*
|
||||||
|
|
||||||
|
## WAVE-3 STATUS + TWO RATIFICATIONS (Fable, post-reader review)
|
||||||
|
|
||||||
|
*F's reader is IN (`39cfc3d`): pick-then-omit with `gone`/`price` applied IN PLACE (identity
|
||||||
|
and order preserved), fire-and-forget with a 1200 ms abort + circuit breaker, real-sourced
|
||||||
|
shops only. Browser-proven: 3 fixture sales → 0 collateral; server killed mid-session → 13.1 ms
|
||||||
|
enter, 14 ms worst frame gap, zero errors. Two F rulings are hereby RATIFIED as law:*
|
||||||
|
|
||||||
|
1. **Tier 2 is opt-in: `?live=1` / `?live=<url>`; absent → byte-identical to the beta.** F's
|
||||||
|
reasoning is the R26 lesson applied to a server — a default boot must never ask a question
|
||||||
|
that has no answer on machines that aren't John's. "Tier 2 is a place you go, not a thing
|
||||||
|
that happens to you." Auto-discovery is a v5.x question with a reachability contract.
|
||||||
|
2. **The collateral assertion is the canonical sold-means-gone gate.** "The gone item never
|
||||||
|
renders" cannot discriminate pick-then-omit from filter-the-array — both hide the record;
|
||||||
|
only COLLATERAL (the other 119 identities and order unchanged) tells them apart. Gates
|
||||||
|
assert collateral. (The vacuous-gate law's most precise application yet.)
|
||||||
|
|
||||||
|
**F's remaining close, in order:** formalize the browser proofs as the permanent smoke class
|
||||||
|
(#3's three arms + #4's both-directions sandbox, collateral-asserted, fixture-subject via
|
||||||
|
`make-fixture`) → the v5 tour (#5 — the crate walk, the mint-shop honest caption, the classic
|
||||||
|
control) → docs (#6 F-side: README v5 + the flags table gains `?live`) → `qa.sh --strict
|
||||||
|
--matrix` + the new smoke class → tag **`v5.0`** → push. Then Fable writes the epoch retro.
|
||||||
|
|
||||||
|
## WAVE-4 (Fable): two ratifications, one micro-task, and the tag path
|
||||||
|
|
||||||
|
**RATIFIED — F's §27 console-error ruling, and it is strictly stronger than the brief it
|
||||||
|
replaces.** The brief's "ZERO console errors on server death" (my wording, echoed in A's risk
|
||||||
|
line #1) is physically unachievable: a dead-host fetch logs `ERR_CONNECTION_REFUSED` at the
|
||||||
|
browser level where no `.catch()` reaches — to log zero, the reader would have to know the
|
||||||
|
server is dead without asking. One refusal is the irreducible cost of discovering death. The
|
||||||
|
ratified assertion: **zero errors from game code + EXACTLY ONE refused connection** — which
|
||||||
|
proves the circuit breaker, where "zero" could never distinguish a working breaker from a
|
||||||
|
broken one (both fail it). F filed this loudly as the beneficiary instead of relaxing it
|
||||||
|
quietly; that is the standard. (A: your risk-line wording is superseded by this record — a
|
||||||
|
one-line amendment at your leisure, not a blocker.)
|
||||||
|
|
||||||
|
**RATIFIED — the control-based frame assertion.** F's original `<250 ms` threshold passed
|
||||||
|
while measuring Lane C's room build and calling it network behaviour; the control (same walk,
|
||||||
|
breaker tripped, no network: 202 ms) exposed it, and the death's true cost is **Δ+2 ms**.
|
||||||
|
Assertions on frame cost are DELTAS AGAINST A CONTROL from here on, not absolute numbers —
|
||||||
|
the R21 lesson, now law for performance gates.
|
||||||
|
|
||||||
|
**THE MICRO-TASK — B: the frontage pose (F's filed ask).** F produced three frames of empty
|
||||||
|
road trying to re-derive your frontage anchor, then stopped — correctly (the cluster primitive
|
||||||
|
answers "where is the town's heart", and Red Hill's heart doesn't contain Monster Robot; a
|
||||||
|
frontage frame asks a different question). You own the frontage geometry, the R26 shopfront
|
||||||
|
mark, and the `poseForVenue` machinery: publish a pose helper or bookmark that frames a shop's
|
||||||
|
frontage BY LOT (keyed off `shop.lot` / godverseShopId — `poseForShopFront(shopOrId, dist)`),
|
||||||
|
verified on Monster Robot (mark visible, door centred, sleeves-through-glass a bonus). One
|
||||||
|
commit, B-pathspec. This also closes the gap for every future godverse tour frame.
|
||||||
|
|
||||||
|
**Then F closes:** the 4th frame through B's helper → full `qa.sh --strict --matrix` +
|
||||||
|
the (now-permanent, corrected) kill-the-server smoke class → tag **`v5.0`** → push. Fable's
|
||||||
|
retro follows.
|
||||||
|
|
||||||
|
## WAVE-5 (Fable): THE SPEC RULING — the facade is +Z, and the fifth hold stands
|
||||||
|
|
||||||
|
*B's micro-task found why F photographed empty road three times: it was never the pose.
|
||||||
|
`plan_osm.js:443` aims the lot's −Z at the road (implementing CITY_SPEC:71); `buildings.js:404`
|
||||||
|
draws the facade on +Z (CITY_SPEC:335, B's canon). The spec contradicts itself; each generator
|
||||||
|
implemented one half; synthetic follows B's canon so every golden has been green over a wrong
|
||||||
|
product since R18 — v4.0 shipped on it. The epoch's signature species at its worst: not a gate
|
||||||
|
failing on correct behaviour, but a green suite over a wrong world.*
|
||||||
|
|
||||||
|
**THE RULING: the visible facade is LOCAL +Z. B's canon is canonical.** It has been the de
|
||||||
|
facto truth since v1 (buildings.js drew it there from the start; venue.js seats door-glow/
|
||||||
|
queue/posters there; A's own R15 write-up said "the visible venue face is +Z"). CITY_SPEC:71's
|
||||||
|
−Z line described the SIM convention (rig-forward at ry=0) and got written into the lots
|
||||||
|
contract by ambiguity — the same −Z/+Z species as R13's RY_FLIP and R15's posters, now at the
|
||||||
|
root. The spec amends so the lots contract states: **facade/door/frontage = local +Z**; the
|
||||||
|
sim/rig-forward convention is documented separately as the different thing it is.
|
||||||
|
|
||||||
|
**A — the fix, the sweep, the pins (ONE commit):**
|
||||||
|
1. Land B's proven one-liner in the real-roads seating (`ry = atan2(-nx,-nz)` — B measured
|
||||||
|
katoomba 0/72 → 72/72 facing and F's money shot works 4 m from Musgrave Road; B reverted it
|
||||||
|
because it's your file).
|
||||||
|
2. **Measure the marched-osm path for the same flaw** — don't assume it's clean because nobody
|
||||||
|
noticed; that's how this one survived nine rounds. State the verdict either way.
|
||||||
|
3. Amend CITY_SPEC:71 per the ruling, re-pin every moved golden (the 36 real-town pins move;
|
||||||
|
marched/synthetic only if your measurement says so), all in the same commit per the
|
||||||
|
amendment law.
|
||||||
|
4. **The cross-convention gate (the lesson made law):** the selfcheck gains an assert measured
|
||||||
|
in B's canon — every lot's +Z facade faces its frontEdge street side — so the two
|
||||||
|
conventions are compared AGAINST EACH OTHER forever, not each against its own spec line.
|
||||||
|
A self-consistent check over a self-contradicting spec passes under either convention;
|
||||||
|
only the cross-check sees it. (F proved this the hard way and recorded it.)
|
||||||
|
|
||||||
|
**F — then the close:** re-gate (the matrix against A's re-pins), re-shoot the tour (the
|
||||||
|
frontage frame through B's `poseForShopFront` — and `street_noon` on real towns, which has
|
||||||
|
been "46 draws of bare road" since R18, becomes a real street), `qa.sh --strict --matrix`,
|
||||||
|
tag **`v5.0`**, push.
|
||||||
|
|
||||||
|
**B — done, with honours:** the helper (door-centred by projection, `doorCentreNdcX = 0.0000`),
|
||||||
|
the selector extracted to ONE home (`'g:31'` — the id-namespace fence now has a single
|
||||||
|
resolver), the self-correction of your own R27 intel, and the discipline of proving A's fix
|
||||||
|
then reverting it. Nothing further owed.
|
||||||
|
|
||||||
## THE EXACT RUN ORDER
|
## THE EXACT RUN ORDER
|
||||||
|
|
||||||
0. **WAVE 0: F re-runs and tags `v5.0-beta`.** Hard stop until it lands.
|
0. **WAVE 0: F re-runs and tags `v5.0-beta`.** Hard stop until it lands.
|
||||||
|
|||||||
63
docs/LANES/ROUND28_INSTRUCTIONS.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# PROCITY — Round 28 lane instructions (from Fable, integrator)
|
||||||
|
|
||||||
|
Date: 2026-07-17 · **v5.0 SHIPPED, retro written ([RETRO_V5.md](../RETRO_V5.md)). Nothing is
|
||||||
|
owed anywhere.** Round 28 is deliberately LIGHT: v6's first spike (the one that needs nothing
|
||||||
|
new) plus one parked polish item per lane — the items that have waited politely since as far
|
||||||
|
back as R7. Tag **`v5.1`** at close. The v6 spikes 2/3 stay gated on John's mocap vids and the
|
||||||
|
ultra integration call; nobody starts them.
|
||||||
|
|
||||||
|
## The ledger
|
||||||
|
|
||||||
|
1. **SPIKE 1 — the free stash (E + D; v6 groundwork, the charter's first thread).**
|
||||||
|
- **E**: convert two cached Mixamo clips from the tailnet stash — **`lean`** and
|
||||||
|
**`look-around`** — via the proven `fbx_to_clip.py` path (mesh-free, 65-bone, licence
|
||||||
|
table lines per clip: Mixamo royalty-free, same zone as walk/idle/sit). Depot + local per
|
||||||
|
the sit.glb precedent.
|
||||||
|
- **D**: wire them into street loiter — a window-shopping stop occasionally leans
|
||||||
|
(shopfront-adjacent) or looks around (anywhere). Seeded rolls on your existing `benchsit`
|
||||||
|
pattern (own rng stream, identity signature untouched), classic-safe by the same
|
||||||
|
`!CLASSIC` fetch gate, byte-identical proofs as ever (setSitting taught you the
|
||||||
|
scale-aware traps — `lean` against a wall wants the same care).
|
||||||
|
- Acceptance: a fresh boot shows peds leaning and glancing; classic/`?noassets` untouched;
|
||||||
|
the R16 seated-stature precedent covers any pose the no-giants gate misjudges (tag
|
||||||
|
`procityPosed` if needed — F's gate arm from R16 generalizes).
|
||||||
|
2. **Awning fabric ripple (B — parked since R17's wind round, your promised pick).** The
|
||||||
|
striped fabric canopies share the rigid awning InstancedMesh; split them (or attribute-mask
|
||||||
|
them) and give ONLY the fabric a gentle wind ripple riding your existing `wind.js` uniforms.
|
||||||
|
Weather-driven like the trees; `?classic`/`?weather=0` byte-identical by the same proof
|
||||||
|
pattern; budget stated (~0 expected — it's your shader).
|
||||||
|
3. **`audioEmitter` (C — your own parked idea, at last).** Positional interior audio: the gig
|
||||||
|
bed/walla emitting from the stage rather than the room, the till from the counter. Scope it
|
||||||
|
yourself (it's your idea — the brief states acceptance only): audible positioning in a venue
|
||||||
|
+ a normal shop, fail-soft under `?mute`/`?noassets`, no new assets (it's routing, not
|
||||||
|
content), B's audio engine consulted not modified (publish the ask if you need an engine
|
||||||
|
seam — B's file).
|
||||||
|
4. **Instrument LOD (E + D — the oldest tri item, v3.1-era).** E: `_lo` variants (~3–4k) for
|
||||||
|
the five instruments (the `_hi` depot pattern in reverse); D: distance swap in the band
|
||||||
|
(interiors are close-quarters — if the measured win is <20k tris in practice, KILL the item
|
||||||
|
with the measurement and it leaves the backlog forever, the wind-sway rule).
|
||||||
|
5. **The gate + the tag (F, last).** New-clip figure checks (lean/look poses through the
|
||||||
|
scale gate), a bookmark spot-check (leaning peds in old frames), `qa.sh --strict --matrix`,
|
||||||
|
notes §28, tag **`v5.1`**, push. Nothing here moves a golden (clips and shaders are outside
|
||||||
|
every plan hash) — flag loudly if anything disagrees.
|
||||||
|
6. **A + G — rest.** A: on-call (nothing should touch a plan). G: your next work is v5.x
|
||||||
|
Option B when John sets a hold budget, or mocap support on ultra — nothing on m3 this round.
|
||||||
|
|
||||||
|
## THE EXACT RUN ORDER
|
||||||
|
|
||||||
|
1. **Start together: E (#1 clips + #4 `_lo` variants) + B (#2) + C (#3).**
|
||||||
|
2. **When E's clips land: D (#1 wiring, then #4 swap-or-kill).**
|
||||||
|
3. **When B/C/D are in: F (#5)** — gate, tag `v5.1`, push.
|
||||||
|
|
||||||
|
Waves: **[E, B, C] → [D] → [F]**. A/G rest.
|
||||||
|
|
||||||
|
## Standing
|
||||||
|
|
||||||
|
- **John — two unblockers for v6, both yours, both parallel to this round:** (1) film 2–3
|
||||||
|
mocap test vids into MOCAPGOD's queue (`docs/CAPTURE.md` there — the crate-riffle first);
|
||||||
|
(2) nudge Fable-on-ultra for the MOCAPGOD-C/MIRPAMO integration call. When both exist,
|
||||||
|
R29 runs Spikes 2/3 and the v6 charter ratifies on their results.
|
||||||
|
- Also from the retro, the real-world note: **the shop POS has recorded no sale since
|
||||||
|
2026-07-01** — check the till sync when you're next in.
|
||||||
|
- **Backlog after this round** (if #4 kills cleanly): tram-stop tri diet · flag retirement ·
|
||||||
|
Option B · tier-2 auto-discovery · the v6 tracks. The polish debt is nearly drained.
|
||||||
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).
|
||||||
122
docs/RETRO_V5.md
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
# PROCITY v5 — THE REAL SHOP: the epoch retro
|
||||||
|
|
||||||
|
*Fable (m3, integrator), 2026-07-17, written after the `v5.0` tag (`b2d29ba`) per ROUND27 §7.
|
||||||
|
Epoch span: R23 → R27. Tags: `v5.0-alpha` → `v5.0-beta` → `v5.0`. The first two-studio epoch:
|
||||||
|
Lane G (ultra) went from merged guest to the lane that shipped the epoch's server.*
|
||||||
|
|
||||||
|
## What shipped, in one breath
|
||||||
|
|
||||||
|
The tier ladder, whole: **344 real-or-minted items across 15 keyed crates in two godverse
|
||||||
|
towns**, every crate distinct (105 pairs, 0 shared titles), sourcing-honest (`real`/`mint` as
|
||||||
|
law, prefixes as namespace fences); Monster Robot Party's actual crate 550 riffled and bought
|
||||||
|
in-game off its actual POS; **tier 2 live** — read-only endpoints over the real till, fences
|
||||||
|
enforced at the DB credential, `/reserve`+`/buy` absent by John's A-then-B ruling; and the
|
||||||
|
epoch's defining proof: **a real HTTP server killed mid-session costs the game Δ+2 ms and
|
||||||
|
exactly one refused connection** — the offline law as an executable, not a sentence. Plus the
|
||||||
|
finale nobody ordered: **every shopfront in every town finally faces its street.**
|
||||||
|
|
||||||
|
## The five holds — the epoch's spine
|
||||||
|
|
||||||
|
Every hold was correct; every hold traced to the same species (a rule or contract meeting a
|
||||||
|
case written before the case existed); every hold was released by its owner, never by the
|
||||||
|
beneficiary:
|
||||||
|
|
||||||
|
1. **R23** — the crate was real but had no street, no identity, no compatible contract; the
|
||||||
|
gate built to catch it was blind-green, and #7a as briefed would have passed vacuously.
|
||||||
|
2. **R24→25** — E's rebuild sat uncommitted; tagging would have produced a tag whose tree
|
||||||
|
lacked the code that produced its green.
|
||||||
|
3. **R26** — the mint-id rule demanded a POS sku from crates that never saw a till; G refused
|
||||||
|
the easy green because colliding id spaces in the economy layer eventually sells the wrong
|
||||||
|
record.
|
||||||
|
4. **R27 (reader)** — "zero console errors on server death" was physically impossible; the
|
||||||
|
replacement (exactly one refusal) is strictly stronger because it proves the breaker.
|
||||||
|
5. **R27 (facade)** — the fifth hold found the oldest bug in the project.
|
||||||
|
|
||||||
|
## The finale: the facade, and the covenant amendment
|
||||||
|
|
||||||
|
B's frontage helper — a micro-task — exposed that `plan_osm` aimed lots' −Z at the street
|
||||||
|
while `buildings.js` draws facades on +Z. The wave-5 ruling named it "every real town since
|
||||||
|
R18" and ordered A to *measure the marched path rather than assume it clean*. **A's measurement
|
||||||
|
caught the ruling's own premise: it was EVERY town, EVERY path, since v1** — all four seating
|
||||||
|
sites wrote `atan2(outward)` under comments that said `-outward`; 637 of 681 synthetic lots
|
||||||
|
faced no street at all; the classic covenant town was a row of back walls, and A proved it the
|
||||||
|
only way left — standing in the street, looking at NUMBAT PLAYTHINGS facing the dirt.
|
||||||
|
|
||||||
|
**John ruled the covenant amendment**, and his ruling is the epoch's best line: *the covenant
|
||||||
|
is anti-drift machinery, not a defect preservative.* `0x3fa36874` did its job for five epochs —
|
||||||
|
it never drifted. It was never right, and no hash can catch what was wrong in the first commit.
|
||||||
|
**A golden proves nothing changed; it cannot prove anything is correct.** 51 goldens re-pinned
|
||||||
|
in one commit; F re-pinned its own files per A's handoff (lane boundaries held to the last);
|
||||||
|
and A caught, in passing, a bare-string OR-clause in F's classic gate that would have gone
|
||||||
|
green off a *sentence about* the old covenant. The cross-convention gate is now in the
|
||||||
|
selfcheck so the two conventions are compared against each other forever.
|
||||||
|
|
||||||
|
## The epoch's signature species: the vacuous gate
|
||||||
|
|
||||||
|
Named as law in R24, then found NINE times — including three inside the law's own
|
||||||
|
applications: the empty-glob validator, the covers-render #7a, my id-equality gate spec
|
||||||
|
(positional ids collide), F's selector entering the wrong shop, the silent licence line, the
|
||||||
|
impossible zero-errors assert, F's 72/72 self-agreement (testing a spec line's data against
|
||||||
|
the same line), F's attempt-4 error-check (the call succeeded; the pose was never applied),
|
||||||
|
and the stdout OR-clause. The case law that came out of it is the epoch's real export:
|
||||||
|
|
||||||
|
- **A gate proves it touched its subject** (fetch log, matched-file count, id equality —
|
||||||
|
something falsifiable) or it is not a gate.
|
||||||
|
- **The collateral assertion**: when correct and broken implementations both hide the obvious
|
||||||
|
evidence, assert on what discriminates (the other 119 records unchanged).
|
||||||
|
- **Cross-convention checks**: two subsystems implementing "the same" convention are compared
|
||||||
|
against each other, never each against its own spec line.
|
||||||
|
- **Control-deltas for performance**: an absolute threshold measures the wrong thing politely.
|
||||||
|
- **A named exception starts protecting new bugs the moment the real fix exists** (F's 404
|
||||||
|
tolerance — deleted, and the deletion caught a ninth invisible probe).
|
||||||
|
- **Id prefixes are namespace fences**; plan ids, godverse ids, POS skus and listing ids are
|
||||||
|
four spaces, and no bare number crosses them.
|
||||||
|
|
||||||
|
## What worked — keep doing
|
||||||
|
|
||||||
|
1. **Restraint under benefit.** F declined to touch A's sweep, E's validator, and E's
|
||||||
|
uncommitted file across three separate holds where one edit would have unblocked its own
|
||||||
|
tag; B proved the facade one-liner and reverted it; A handed F's re-pins to F instead of
|
||||||
|
editing F's files. The party that benefits never releases its own hold.
|
||||||
|
2. **Ownership sentences.** "The red was mine" (E) · "my rule was the stale thing" (C) ·
|
||||||
|
"it fired 54/72 on a good town" (A) · "four of the five attempts were F's fault" (F).
|
||||||
|
Nobody defended; everybody measured.
|
||||||
|
3. **The contract's author is the authority for the contract's shape** — and when contract
|
||||||
|
and runtime disagree, **the runtime is the authority** (R24's root-cause ruling).
|
||||||
|
4. **Machine-enforced fences beat reviewed promises**: the read-only credential (measured
|
||||||
|
permission-denied), 501s for absent verbs, metadata-only responses.
|
||||||
|
5. **The two-studio pattern matured**: G went from branch-merged guest (R23) to full lane on
|
||||||
|
main (R25) to shipping the epoch's server (R27), with John's rulings recorded as mechanism.
|
||||||
|
|
||||||
|
## What cost us — fixed or fenced
|
||||||
|
|
||||||
|
1. **Comments and specs are not measurements.** All four facade sites' comments described the
|
||||||
|
correct behaviour above code doing the opposite, for 27 rounds. The cross-convention gate
|
||||||
|
is the fix; the lesson is older: nothing is true because a document says it.
|
||||||
|
2. **Consistency masquerades as correctness.** Every subsystem agreed with itself, so every
|
||||||
|
gate was green over a wrong world; only a human in the street saw it. Money shots and
|
||||||
|
walk-arounds are QA, not decoration — John's browser stroll started the v6 charter, and
|
||||||
|
A's street-level eyeball confirmed the covenant bug.
|
||||||
|
3. **My briefs carried five corrected errors this epoch** (the `tier` field, the id-equality
|
||||||
|
gate, the zero-errors assert, the "wire on entry" default that F correctly made opt-in,
|
||||||
|
and the wave-5 premise). All five were caught by the contract-first channel or by
|
||||||
|
measurement before they became bugs. Briefs state acceptance; lanes own method; the
|
||||||
|
integrator is not exempt from measurement-over-brief.
|
||||||
|
|
||||||
|
## Carried forward
|
||||||
|
|
||||||
|
- **v5.x**: Option B reservation (chartered, behind John's hold budget) · tier-2
|
||||||
|
auto-discovery (needs a reachability contract) · more POS shops if monsterrobot gains
|
||||||
|
siblings · **a shop-ops note for John, not a code item: the POS has recorded no sale since
|
||||||
|
2026-07-01 — G proved the dump is fresh but the data is stale; worth checking the till's
|
||||||
|
sync before the next live demo.**
|
||||||
|
- **v4.x tail** (unchanged): thin-tail clustering floor · seating overflow tail · instrument
|
||||||
|
LOD · tram-stop tri diet · awning ripple · audioEmitter · flag retirement.
|
||||||
|
- **v6 — THE LIVING STREET**: charter drafted (`docs/V6_LIVING_STREET.md`), spikes gated on
|
||||||
|
John's mocap vids (the MOCAPGOD queue is still empty) and the MOCAPGOD-C/MIRPAMO
|
||||||
|
integration call on ultra.
|
||||||
|
|
||||||
|
*— Fable. Twenty-seven rounds, eleven tags, five correct holds, one amended covenant. The game
|
||||||
|
now sells a record that is really in the crate, in a shop that is really on the street, in a
|
||||||
|
town that is really in Australia — and for the first time since v1, the shops are facing you
|
||||||
|
when you walk up the road.*
|
||||||
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.
|
||||||
@ -81,3 +81,52 @@ plus this epoch's own:
|
|||||||
5. **Settled non-risks:** the dig/wallet/stockAdapter machinery (shipped v2, soaked 16 rounds);
|
5. **Settled non-risks:** the dig/wallet/stockAdapter machinery (shipped v2, soaked 16 rounds);
|
||||||
static-atlas determinism (E's byte-identical pipeline discipline); the fail-soft ladder
|
static-atlas determinism (E's byte-identical pipeline discipline); the fail-soft ladder
|
||||||
(proven pattern from `?noassets` day one).
|
(proven pattern from `?noassets` day one).
|
||||||
|
|
||||||
|
### Retired-or-carried at the v5.0 close (ROUND27 ledger #6 — Lane A, line by line)
|
||||||
|
|
||||||
|
Verdicts against what shipped, not against the plan. Written in **wave 2**, so tier-2 items that F's reader
|
||||||
|
and kill-the-server gate will decide are **carried, not pre-retired** — the gate retires them, not this doc.
|
||||||
|
|
||||||
|
1. **CARRIED — and it is this round's defining gate, not a footnote.** The only risk here the epoch has not
|
||||||
|
yet measured. The reader (#2) and the kill-the-server gate (#3) don't exist as I write this; **nothing
|
||||||
|
retires this line except that gate going green** — boot server-up (live fields consumed), kill mid-session
|
||||||
|
(degrade to tier 1, zero console errors, frame-time measured across the failure), boot server-down (pure
|
||||||
|
tier 1). Until then the charter's offline law is a promise, and the whole point of #3 is that a promise
|
||||||
|
isn't a gate. *(If tier 2 proves bigger than one round, F holds and this line simply carries — four holds
|
||||||
|
have proven nobody forces an epoch close.)*
|
||||||
|
2. **RETIRED — the remedy the risk named is the remedy that shipped.** It predicted "pagination/crate
|
||||||
|
rotation rather than bigger atlases", and that is exactly what happened: the mecca case is served by the
|
||||||
|
**crate-rotation menu**, not by growing the sheet. Measured across all **15 keyed atlases**: max **2
|
||||||
|
atlases/shop** against C's hard cap of 2; the 14 mint shops sit at **16 items / 1 atlas / 1024²** and only
|
||||||
|
the one real POS shop earns 2048² (**120 items, 2 atlases, 1.2 MB**) under C §7.2's 60+ item rule. E's
|
||||||
|
validator enforces the ceilings from the committed files alone. Total: 344 items.
|
||||||
|
3. **RETIRED — the risk was designed out, not solved.** It assumed a **fuzzy-matching** problem ("names
|
||||||
|
drift, shops move") and demanded mismatches be counted. There is no matcher: identity is an **explicit
|
||||||
|
opaque key** (`godverseShopId`) that G emits, so there is nothing to mis-match. The predicted failure
|
||||||
|
couldn't occur in the predicted form — and the case that would have broken any name-matcher proves it:
|
||||||
|
**Monster Robot Party is in neither the census nor OSM**, so no matching strategy could ever have found
|
||||||
|
it; it is keyed by injection. Mis-stocking is fenced structurally instead: duplicate ids **error**,
|
||||||
|
uniqueness runs over defined ids, and the **orphaned-atlas gate** fails if a stocked shop isn't seated.
|
||||||
|
4. **SPLIT — the write half RETIRED by ruling, the read half CARRIED to F's #4.** *Write-back:* retired, and
|
||||||
|
retired properly — John's **A-then-B** ruling is recorded as **mechanism, not config**: `/reserve` and
|
||||||
|
`/buy` are **ABSENT, not stubbed**. Race conditions and reservation semantics cannot occur because the
|
||||||
|
verbs do not exist; "prove the absence" is a stronger guarantee than any sandbox flag, since a sandbox a
|
||||||
|
config typo can flip isn't a sandbox. *(Option B's reservation upgrade is chartered as v5.x, behind a
|
||||||
|
hold budget — it re-opens this line deliberately, when it is built.)* *Read (`gone`, real→game):* carried
|
||||||
|
— F's #4 asserts both directions this round.
|
||||||
|
5. **Unchanged, and extended by what the epoch settled:** static-atlas determinism proved in practice
|
||||||
|
(byte-identical re-bakes, including G's index-only sku re-emit that moved no `.webp` bytes); the
|
||||||
|
fail-soft ladder proved on every rung; and the **id-space fences** (`sku_`/`mint_`, plan-id vs
|
||||||
|
godverse-id) now have case law behind them rather than good intentions.
|
||||||
|
6. **NEW, CARRIED — the orphaned atlas.** The lift legitimately drops shops (overlap-resolve, counted since
|
||||||
|
R19); dropping one that carries a **committed atlas** means real stock keyed to a shop that isn't in the
|
||||||
|
town. Gated in `selfcheck` since R25 and **currently green** — and the gate armed itself this round
|
||||||
|
without anyone touching it: it SKIPped on `newtown_godverse` while no atlas keyed there, and **began
|
||||||
|
binding the moment G's mint atlases landed**. Live near-miss on the books: `redhill_godverse`'s lift drops
|
||||||
|
keyed shop **2286 "Empire Revival"**; G stocked its numeric neighbour **2287** instead. Carried because
|
||||||
|
the hazard grows with every shop stocked — **stocking 2286 would orphan it today.**
|
||||||
|
7. **NEW, CARRIED — "real" is one shop wide.** 14 of 15 keyed atlases are **mint**; exactly one is a POS
|
||||||
|
snapshot, because exactly one shop has a POS. That is honest and marked (`sourcing`), not a defect — but
|
||||||
|
the epoch's headline claim rests on **one** shop, and any future claim of scale needs either more POS
|
||||||
|
shops (v5.x: "if monsterrobot ever gets siblings") or continued care that `sourcing` stays legible to
|
||||||
|
humans, not just gates.
|
||||||
|
|||||||
98
docs/V6_LIVING_STREET.md
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
# PROCITY v6 — THE LIVING STREET (epoch charter — DRAFT for John)
|
||||||
|
|
||||||
|
*Fable (m3), 2026-07-17. John's idea, from a browser walk down the strip: the crowd is
|
||||||
|
believable but narrow — one walk, one idle, one sit, everyone jaywalks, nobody owns the street.
|
||||||
|
Status: DRAFT — v5.0 (THE LIVE CRATE) is still closing; this charters the v6 conversation the
|
||||||
|
retro was already going to open. Applies to PROCITY first and 90sDJsim by depot (the Lane-G
|
||||||
|
two-studio pattern, proven).*
|
||||||
|
|
||||||
|
## The vision
|
||||||
|
|
||||||
|
The street stops being 24 clones who walk anywhere and becomes a town of *people*: personas
|
||||||
|
with their own gaits, errands and manners; footpaths used like footpaths; the odd car easing
|
||||||
|
down the main street; a busker outside the record shop doing a move John actually performed in
|
||||||
|
his actual shop. **Every asset generated, rigged, animated and shipped on our own machines —
|
||||||
|
zero cloud, zero subscriptions, everything owned.**
|
||||||
|
|
||||||
|
## The stack (measured today, not aspirational)
|
||||||
|
|
||||||
|
| piece | state | what it gives us |
|
||||||
|
|---|---|---|
|
||||||
|
| **mixamo-fetch** (tailnet source box) | **34 clips cached, only 3 wired** (walk/idle/sit) | the free stash, already in hand — lean, look-around, phone, talk, carry… |
|
||||||
|
| **MOCAPGOD** (ultra) | Lanes 0/A/B **DONE**: phone video → HSMR/SKEL → BVH, MPS-verified, ~1.6× realtime, mixamorig name-map shipped | film John → skeleton motion. Lanes C (retarget) + D (clip factory) remain |
|
||||||
|
| **MIRPAMO** (both Macs) | repo live: **a local offline Mixamo** — autorig (landmark → mixamorig fit → bone-heat weights) + retarget → `character@clip.glb` | new rigged BODIES from any mesh + clip retargeting, no Adobe |
|
||||||
|
| **MODELBEAST** (m3ultra :8777) | flux_local (Apache, free) · **hunyuan3d_mlx local** · trellis_mac · sf3d · mflux_image_edit · seedvr2 | concept art → 3D meshes → texture/outfit variants, $0 |
|
||||||
|
| **3GOD depot** | live, no-Draco law, two-studio consumption proven (Lane G) | one publish, every game (PROCITY · 90sDJsim · festifun) consumes |
|
||||||
|
| **PROCITY runtime** | ped fleet (19 rigged, `_canon` mixamorig), sit.glb clip pattern proven (R16), persona hooks half-exist (loiter/dance/bench/patronage) | the body the assets walk into |
|
||||||
|
| **A's street exports** | `roadWidth/vergeBand/poleOffset` per edge — **shipped R15, barely consumed** | the footpath law is one D constraint away, not a new system |
|
||||||
|
|
||||||
|
**MOCAPGOD Lane C and MIRPAMO's retarget are the same machinery** — an integration decision
|
||||||
|
for Fable-ultra before either builds it twice.
|
||||||
|
|
||||||
|
## THE SPIKES (do these FIRST — cheap, each kills the biggest unknown in one thread)
|
||||||
|
|
||||||
|
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 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 —
|
||||||
|
the integration call) → clip GLB → depot → **a ped riffles a crate on Musgrave Road,
|
||||||
|
in-game**. *Proves:* film-yourself-to-NPC works end to end. Needs: MOCAPGOD Lane C/D
|
||||||
|
sessions on ultra + John's test vids in the queue (it's empty — that's the ask).
|
||||||
|
3. **SPIKE 3 — a generated body (the stretch).** flux_local concept ("older shopper with
|
||||||
|
trolley, 90s AU high street") → hunyuan3d_mlx mesh → **MIRPAMO autorig** → walk retarget →
|
||||||
|
in-game past the no-giants gate. *Proves (or honestly fails):* autorig quality on generated
|
||||||
|
meshes — the least-proven link, which is exactly why it's a spike and not a plan line.
|
||||||
|
|
||||||
|
## The epoch tracks (after v5.0 tags + the retro + the spikes report)
|
||||||
|
|
||||||
|
1. **CLIPS** — wire the 34; fetch more free categories; the MOCAPGOD bespoke set (riffle,
|
||||||
|
till-operation, busker strum, lean-on-wall, trolley-push, phone-check). Licence rail: the
|
||||||
|
HSMR/SKEL NC-risk (MOCAPGOD Decision 1) means mocap-derived clips stay **in-house** until
|
||||||
|
reviewed — same discipline as the item photos, same flag line.
|
||||||
|
2. **PERSONAS (D)** — a seeded archetype layer per citizen: {body variant, clip set, speed
|
||||||
|
band, loiter/dance/bench biases, errand routes (pick a shop, walk there, dwell, move on —
|
||||||
|
patronage formalized into visible purpose)}. The persona is DATA riding the existing sim;
|
||||||
|
determinism per citizen id; identity signatures unchanged.
|
||||||
|
3. **STREET DISCIPLINE (D + B + A)** — the footpath law: peds walk the `vergeBand`, cross at
|
||||||
|
nodes (zebra-preferred on real roads — B's crossings already render); **occasional cars**:
|
||||||
|
seeded parked cars (instanced, furniture pattern) + 1–3 movers on the mains (the tram
|
||||||
|
pattern: deterministic path-followers, ~2 draws each, stated budgets). v0 non-interacting:
|
||||||
|
cars don't yield, peds don't jaywalk — both true by construction because each stays in its
|
||||||
|
band.
|
||||||
|
4. **BODIES (E + MIRPAMO)** — outfit/texture variety on the existing fleet first (
|
||||||
|
mflux_image_edit reskins — free variety, zero rig risk), MIRPAMO-autorigged newcomers
|
||||||
|
second, per Spike 3's verdict.
|
||||||
|
|
||||||
|
## Laws (inherited + this epoch's)
|
||||||
|
|
||||||
|
Everything behind **`?streetlife=1`** (cars possibly split as `?traffic=1` — F's call at
|
||||||
|
wiring), default-OFF until soaked, `?classic=1` byte-identical as ever · budgets stated per
|
||||||
|
system (cars and new rigs are draw/tri line items) · determinism from citySeed + citizen id ·
|
||||||
|
asset law (every new clip/body rides the manifest + depot + `?noassets` fail-soft) · licence
|
||||||
|
law (LICENSES.md per clip source: Mixamo royalty-free / MOCAPGOD in-house-NC-flagged /
|
||||||
|
generated CC0-equiv) · vacuous-gate law (a persona gate proves a persona DID something, not
|
||||||
|
that one exists) · measurement over brief.
|
||||||
|
|
||||||
|
## Sequencing (the honest order)
|
||||||
|
|
||||||
|
1. **Now:** v5.0 closes per ROUND27 — nothing here touches it.
|
||||||
|
2. **In parallel, zero collision (different repos):** MOCAPGOD Lanes C/D run on ultra ·
|
||||||
|
John drops 2–3 test vids in the MOCAPGOD queue · a MIRPAMO autorig smoke on one
|
||||||
|
hunyuan3d_mlx mesh.
|
||||||
|
3. **After the v5 retro:** the spikes run as v6 wave 1; this charter ratifies (or amends)
|
||||||
|
on their results; the tracks become rounds.
|
||||||
|
|
||||||
|
## John — the asks that unblock the spikes
|
||||||
|
|
||||||
|
1. **Film the test vids** (Spike 2 blocks on an empty MOCAPGOD queue): the capture protocol
|
||||||
|
is `docs/CAPTURE.md` in MOCAPGOD — the crate-riffle plus any two more moves you fancy.
|
||||||
|
2. **The MOCAPGOD C-vs-MIRPAMO integration call** is Fable-ultra's to make — nudge that
|
||||||
|
session when you're next on the M1.
|
||||||
|
3. Nothing else — the free stash (Spike 1) needs no new material at all.
|
||||||
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/release_v5/a_mint_crate.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/shots/release_v5/classic_covenant.png
Normal file
|
After Width: | Height: | Size: 784 KiB |
8
docs/shots/release_v5/contact.html
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<!doctype html><meta charset=utf-8><title>PROCITY v5.0 — THE LIVE CRATE</title>
|
||||||
|
<style>body{background:#111;color:#ddd;font:15px/1.6 -apple-system,sans-serif;max-width:1100px;margin:40px auto;padding:0 20px}img{width:100%;border-radius:6px;margin:10px 0}h1{font-weight:600}p{color:#aaa}hr{border:0;border-top:1px solid #333;margin:36px 0}</style>
|
||||||
|
<h1>PROCITY v5.0 — THE LIVE CRATE</h1>
|
||||||
|
<p>One real record shop, on its real street, holding its real stock — and a server that can die mid-session without the game noticing.</p>
|
||||||
|
<hr><h3>musgrave_road</h3><img src='musgrave_road.png'><p>the frontage — Monster Robot Party on Musgrave Road, Red Hill, Brisbane. It is on this road in the game because it is on this road · Red Hill · retail heart: 2 shops/60m · 91 draws · 62,576 tris</p>
|
||||||
|
<hr><h3>the_real_crate</h3><img src='the_real_crate.png'><p>THE REAL CRATE — Monster Robot Party (147 Musgrave Rd): 120 records off its own till, real covers, real titles, real prices. Sell one in the shop and it leaves this crate · Monster Robot Party · 120 records from assets/stock_godverse/3962749/ · 40 draws · 40,239 tris</p>
|
||||||
|
<hr><h3>a_mint_crate</h3><img src='a_mint_crate.png'><p>a MINT crate — plausible, NOT real: a seeded sample standing in for a shop whose till we don't hold. Same tier, same offline guarantee, different provenance — and the atlas says so in its own index · Presents of Mind · 16 records from assets/stock_godverse/767/ · 51 draws · 30,897 tris</p>
|
||||||
|
<hr><h3>classic_covenant</h3><img src='classic_covenant.png'><p>?classic=1 — the exact v2 town, byte-identical forever (0x5f76e76). Five epochs, one covenant · Boolarra Heads · retail heart: 35 shops/60m · 162 draws · 39,259 tris</p>
|
||||||
BIN
docs/shots/release_v5/musgrave_road.png
Normal file
|
After Width: | Height: | Size: 778 KiB |
22
docs/shots/release_v5/shots.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "musgrave_road",
|
||||||
|
"file": "musgrave_road.png",
|
||||||
|
"caption": "the frontage \u2014 Monster Robot Party on Musgrave Road, Red Hill, Brisbane. It is on this road in the game because it is on this road \u00b7 Red Hill \u00b7 retail heart: 2 shops/60m \u00b7 91 draws \u00b7 62,576 tris"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "the_real_crate",
|
||||||
|
"file": "the_real_crate.png",
|
||||||
|
"caption": "THE REAL CRATE \u2014 Monster Robot Party (147 Musgrave Rd): 120 records off its own till, real covers, real titles, real prices. Sell one in the shop and it leaves this crate \u00b7 Monster Robot Party \u00b7 120 records from assets/stock_godverse/3962749/ \u00b7 40 draws \u00b7 40,239 tris"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a_mint_crate",
|
||||||
|
"file": "a_mint_crate.png",
|
||||||
|
"caption": "a MINT crate \u2014 plausible, NOT real: a seeded sample standing in for a shop whose till we don't hold. Same tier, same offline guarantee, different provenance \u2014 and the atlas says so in its own index \u00b7 Presents of Mind \u00b7 16 records from assets/stock_godverse/767/ \u00b7 51 draws \u00b7 30,897 tris"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "classic_covenant",
|
||||||
|
"file": "classic_covenant.png",
|
||||||
|
"caption": "?classic=1 \u2014 the exact v2 town, byte-identical forever (0x5f76e76). Five epochs, one covenant \u00b7 Boolarra Heads \u00b7 retail heart: 35 shops/60m \u00b7 162 draws \u00b7 39,259 tris"
|
||||||
|
}
|
||||||
|
]
|
||||||
BIN
docs/shots/release_v5/the_real_crate.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
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.
|
||||||
@ -1,4 +1,5 @@
|
|||||||
[
|
[
|
||||||
|
"look.glb",
|
||||||
"procity_fit_arcade_cabinet_01.glb",
|
"procity_fit_arcade_cabinet_01.glb",
|
||||||
"procity_fit_bass_guitar_01.glb",
|
"procity_fit_bass_guitar_01.glb",
|
||||||
"procity_fit_bass_guitar_01_hi.glb",
|
"procity_fit_bass_guitar_01_hi.glb",
|
||||||
|
|||||||
302
pipeline/godverse_server.py
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Lane G — GODVERSE tier-2: the two READ endpoints (R27 ledger #1).
|
||||||
|
|
||||||
|
The charter's last rung, under John's ratified A-then-B ruling (G3 §7): **read-live,
|
||||||
|
sell-sandboxed**. The server ENRICHES, NEVER GATES — every boot path, gate and tag works with
|
||||||
|
this process dead. Nothing here can write to the shop.
|
||||||
|
|
||||||
|
python3 pipeline/godverse_server.py setup-role # the read-only grant (idempotent)
|
||||||
|
python3 pipeline/godverse_server.py start|stop|status # lifecycle — F's kill-the-server gate
|
||||||
|
python3 pipeline/godverse_server.py serve # foreground
|
||||||
|
python3 pipeline/godverse_server.py make-fixture --sold 3 # a controlled POS for F's gate
|
||||||
|
|
||||||
|
THE SURFACE (G3 §4, as built):
|
||||||
|
GET /godverse/v1/health -> { ok, shops } (is the server up)
|
||||||
|
GET /godverse/v1/shop/<godverse_id>/stock -> live crate metadata + `gone` + etag
|
||||||
|
GET /godverse/v1/shop/<godverse_id>/crates -> the real crate menu (rotation)
|
||||||
|
GET anything else -> 404 (with the ruling in the body)
|
||||||
|
POST/PUT/PATCH/DELETE anything -> **501**, measured — see below
|
||||||
|
|
||||||
|
**`/reserve` and `/buy` are ABSENT, not stubbed** — no route, no handler, no flag. A sandbox a
|
||||||
|
config typo can flip isn't a sandbox (G3 §7).
|
||||||
|
|
||||||
|
**→ Lane F, assert 501 and not 404 for the write verbs.** This class defines `do_GET` and nothing
|
||||||
|
else, so the stdlib answers any other method with 501 "Unsupported method" before routing exists
|
||||||
|
at all. That is a *stronger* absence proof than a 404: a 404 would mean a handler ran and chose to
|
||||||
|
decline, whereas 501 means **there is no write handler in the process**. Adding `do_POST` just to
|
||||||
|
answer 404 would be implementing the very thing the ruling forbids. (My own docstring claimed 404
|
||||||
|
until I measured it — the same doc-vs-code species this epoch keeps catching, caught here by
|
||||||
|
running it.)
|
||||||
|
|
||||||
|
THREE FENCES, each enforced by the machine and not by a code review:
|
||||||
|
1. **The credential.** We connect as `godverse_ro`: SELECT on inventory/crate/disc_cache and
|
||||||
|
NOTHING else. Measured — `UPDATE inventory` => "permission denied"; `SELECT FROM customer`
|
||||||
|
=> "permission denied". That one grant carries the sandbox AND G3 §9's PII fence.
|
||||||
|
2. **Metadata only.** The API serves facts, never pixels. Covers stay static tier-1 files. A
|
||||||
|
tier-2 shop with a dead server is VISUALLY IDENTICAL to tier 1 — that is the fail-soft.
|
||||||
|
3. **The id-namespace fence** (R26 law). `gone[]` carries the atlas's own `id` form verbatim
|
||||||
|
(`sku_<POS sku>`), so the client does `gone.includes(item.id)` with ZERO transformation.
|
||||||
|
*G3 §4 as ratified said `gone:[sku]` — bare numbers. That wording predates R25's sku ids and
|
||||||
|
R26's fence, and a bare number crossing two id spaces is the exact bug the fence law exists
|
||||||
|
to stop. The design doc was the stale thing; §4 is updated as-built.*
|
||||||
|
|
||||||
|
TIER 2 IS FOR REAL SHOPS ONLY. A `sourcing: "mint"` shop has no POS to be live about, so its
|
||||||
|
`/stock` is a 404 — not an empty crate. Monster Robot Party is the only real shop; that's the
|
||||||
|
point: one shop, live, true.
|
||||||
|
"""
|
||||||
|
import argparse, hashlib, json, os, signal, subprocess, sys, tempfile, time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
STOCK = os.path.join(ROOT, "web", "assets", "stock_godverse")
|
||||||
|
PORT = int(os.environ.get("GODVERSE_PORT", "8778"))
|
||||||
|
# Runtime state lives OUTSIDE the repo: a pidfile is not an artifact, and a stray one in the tree
|
||||||
|
# is exactly the kind of thing that gets committed by a hurried `git add`.
|
||||||
|
PIDFILE = os.path.join(tempfile.gettempdir(), f"godverse-{PORT}.pid")
|
||||||
|
# The POS. Swap this and the server is live against a different shop-state — which is how F's
|
||||||
|
# gate stays deterministic without anyone faking real data (see make-fixture).
|
||||||
|
POS_DB = os.environ.get("GODVERSE_POS_DB", "recordgod")
|
||||||
|
POS_USER = os.environ.get("GODVERSE_POS_USER", "godverse_ro")
|
||||||
|
POS_HOST = os.environ.get("GODVERSE_POS_HOST", "127.0.0.1")
|
||||||
|
|
||||||
|
READONLY_TABLES = ("inventory", "crate", "disc_cache") # G3 §9: never customer/staff/sales
|
||||||
|
|
||||||
|
|
||||||
|
def q(sql, db=None):
|
||||||
|
"""Read-only query as godverse_ro. psycopg2 isn't on this box and the CLI keeps the
|
||||||
|
dependency surface at zero — the same call thriftgod's own server would make."""
|
||||||
|
dsn = f"host={POS_HOST} dbname={db or POS_DB} user={POS_USER}"
|
||||||
|
r = subprocess.run(["psql", dsn, "-tAF\t", "-c", sql], capture_output=True, text=True)
|
||||||
|
if r.returncode:
|
||||||
|
raise RuntimeError(r.stderr.strip().splitlines()[-1] if r.stderr.strip() else "psql failed")
|
||||||
|
return [ln.split("\t") for ln in r.stdout.splitlines() if ln.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def manifest():
|
||||||
|
with open(os.path.join(STOCK, "index.json")) as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
|
||||||
|
|
||||||
|
def real_shops():
|
||||||
|
"""Real-sourced (POS-backed) shops, per the manifest. NOTE the key is `godverseShopId`
|
||||||
|
(camelCase): the manifest is read by the JS runtime, so it wears the JS-side spelling, while
|
||||||
|
an atlas index wears the Python-side `shop.godverse_id`. That split is recorded in G3 §4 —
|
||||||
|
and I still read it wrong first time, which is the whole reason it's recorded."""
|
||||||
|
return [s for s in manifest().get("shops", []) if s.get("sourcing") == "real"]
|
||||||
|
|
||||||
|
|
||||||
|
def real_shop(gid):
|
||||||
|
"""The shop's shipped index, but ONLY if it is a real-sourced (POS-backed) shop."""
|
||||||
|
for s in real_shops():
|
||||||
|
if str(s.get("godverseShopId")) == str(gid):
|
||||||
|
for kind in s.get("types", ["record"]):
|
||||||
|
p = os.path.join(STOCK, str(gid), f"stock_{kind}_index.json")
|
||||||
|
if os.path.isfile(p):
|
||||||
|
with open(p) as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def etag_of(payload):
|
||||||
|
"""Deterministic: same POS state => same etag. `served_at` is deliberately OUTSIDE it —
|
||||||
|
a clock in the etag would make every read a cache miss."""
|
||||||
|
return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def stock(gid):
|
||||||
|
idx = real_shop(gid)
|
||||||
|
if not idx:
|
||||||
|
return None
|
||||||
|
baked = {it["id"]: it["id"][4:] for it in idx["items"] if it["id"].startswith("sku_")}
|
||||||
|
if not baked:
|
||||||
|
return None
|
||||||
|
skus = ",".join("'" + s.replace("'", "''") + "'" for s in baked.values())
|
||||||
|
rows = q(f"""SELECT i.sku, COALESCE(i.slot_number::text,''), i.in_stock,
|
||||||
|
COALESCE(d.artist,''), COALESCE(d.title, COALESCE(i.title,'')),
|
||||||
|
i.price, COALESCE(i.condition,''), COALESCE(i.sleeve_cond,'')
|
||||||
|
FROM inventory i LEFT JOIN disc_cache d USING (release_id)
|
||||||
|
WHERE i.sku IN ({skus}) ORDER BY i.slot_number NULLS LAST, i.sku""")
|
||||||
|
live, present = [], set()
|
||||||
|
for sku, slot, in_stock, artist, title, price, cond, sleeve in rows:
|
||||||
|
present.add(sku)
|
||||||
|
if in_stock != "t":
|
||||||
|
continue
|
||||||
|
live.append({"id": f"sku_{sku}", "slot": int(slot) if slot else None,
|
||||||
|
"artist": artist, "title": title,
|
||||||
|
"price": float(price) if price else None,
|
||||||
|
"condition": cond, "sleeve_cond": sleeve, "state": "shelf"})
|
||||||
|
# GONE = baked into the tier-1 atlas, no longer on the shelf. Direction is real -> game ONLY:
|
||||||
|
# a record that sold IN THE REAL SHOP leaves the game crate. A game buy cannot appear here —
|
||||||
|
# there is no verb to carry it and no grant to write it (see the module docstring).
|
||||||
|
gone = sorted(i for i, sku in baked.items() if sku not in present or
|
||||||
|
sku not in {r[0] for r in rows if r[2] == "t"})
|
||||||
|
body = {"shop_id": int(gid), "tier": 2, "crate": idx.get("crate"),
|
||||||
|
"items": live, "gone": gone,
|
||||||
|
"counts": {"baked": len(baked), "live": len(live), "gone": len(gone)}}
|
||||||
|
body["etag"] = etag_of(body)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def crates(gid):
|
||||||
|
if not real_shop(gid):
|
||||||
|
return None
|
||||||
|
rows = q("""SELECT c.id, c.name, COALESCE(c.label_text,''), count(i.sku)
|
||||||
|
FROM crate c LEFT JOIN inventory i ON i.crate_id = c.id AND i.in_stock
|
||||||
|
GROUP BY c.id, c.name, c.label_text HAVING count(i.sku) > 0
|
||||||
|
ORDER BY c.id""")
|
||||||
|
body = {"shop_id": int(gid), "tier": 2,
|
||||||
|
"crates": [{"id": int(a), "name": b, "label": c, "count": int(n)} for a, b, c, n in rows]}
|
||||||
|
body["etag"] = etag_of(body)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
class H(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass # the gate reads assertions, not our chatter
|
||||||
|
|
||||||
|
def _send(self, code, obj, etag=None):
|
||||||
|
raw = json.dumps(obj).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(raw)))
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*") # the game is served from :8130
|
||||||
|
if etag:
|
||||||
|
self.send_header("ETag", etag)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(raw)
|
||||||
|
|
||||||
|
def _404(self):
|
||||||
|
self._send(404, {"error": "not found",
|
||||||
|
"note": "read-only surface: /health, /shop/<id>/stock, /shop/<id>/crates. "
|
||||||
|
"Write verbs (/reserve, /buy) are ABSENT by ruling — G3 §7, "
|
||||||
|
"A-then-B: read-live, sell-sandboxed."})
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
u = urlparse(self.path)
|
||||||
|
parts = [p for p in u.path.strip("/").split("/") if p]
|
||||||
|
try:
|
||||||
|
if parts == ["godverse", "v1", "health"]:
|
||||||
|
return self._send(200, {"ok": True, "tier": 2, "pos_db": POS_DB,
|
||||||
|
"shops": [s["godverseShopId"] for s in real_shops()]})
|
||||||
|
if len(parts) == 5 and parts[:3] == ["godverse", "v1", "shop"]:
|
||||||
|
gid, verb = parts[3], parts[4]
|
||||||
|
body = stock(gid) if verb == "stock" else crates(gid) if verb == "crates" else None
|
||||||
|
if body is None:
|
||||||
|
return self._404()
|
||||||
|
if parse_qs(u.query).get("since", [None])[0] == body["etag"]:
|
||||||
|
self.send_response(304); self.send_header("ETag", body["etag"])
|
||||||
|
self.send_header("Content-Length", "0"); self.end_headers()
|
||||||
|
return
|
||||||
|
body["served_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
return self._send(200, body, etag=body["etag"])
|
||||||
|
except Exception as e:
|
||||||
|
# A dead/denied POS must look like a dead server to the client: it degrades to tier 1.
|
||||||
|
return self._send(503, {"error": "pos unavailable", "detail": str(e)[:200]})
|
||||||
|
self._404()
|
||||||
|
|
||||||
|
# No do_POST / do_PUT / do_PATCH / do_DELETE. BaseHTTPRequestHandler answers 501 for verbs it
|
||||||
|
# has no handler for; the routes do not exist at all. Absent, not stubbed.
|
||||||
|
|
||||||
|
|
||||||
|
def serve():
|
||||||
|
print(f"godverse tier-2 :{PORT} · POS={POS_DB} as {POS_USER} (read-only) · write verbs ABSENT")
|
||||||
|
ThreadingHTTPServer(("0.0.0.0", PORT), H).serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
def setup_role(_):
|
||||||
|
"""The grant that IS the sandbox (G3 §7). Idempotent."""
|
||||||
|
subprocess.run(["psql", "-q", "-d", "postgres", "-c",
|
||||||
|
"DO $$ BEGIN CREATE ROLE godverse_ro LOGIN; "
|
||||||
|
"EXCEPTION WHEN duplicate_object THEN NULL; END $$;"], check=True)
|
||||||
|
for db in (POS_DB, f"{POS_DB}_live", f"{POS_DB}_fixture"):
|
||||||
|
chk = subprocess.run(["psql", "-tAc", f"SELECT 1 FROM pg_database WHERE datname='{db}'",
|
||||||
|
"-d", "postgres"], capture_output=True, text=True).stdout.strip()
|
||||||
|
if not chk:
|
||||||
|
continue
|
||||||
|
subprocess.run(["psql", "-q", "-d", db, "-c", f"GRANT CONNECT ON DATABASE {db} TO godverse_ro;",
|
||||||
|
"-c", "GRANT USAGE ON SCHEMA public TO godverse_ro;",
|
||||||
|
"-c", f"GRANT SELECT ON {', '.join('public.' + t for t in READONLY_TABLES)} "
|
||||||
|
"TO godverse_ro;"], check=True)
|
||||||
|
print(f" {db}: SELECT on {', '.join(READONLY_TABLES)} — and nothing else")
|
||||||
|
|
||||||
|
|
||||||
|
def make_fixture(args):
|
||||||
|
"""A CONTROLLED POS for F's sold-means-gone arm.
|
||||||
|
|
||||||
|
Why this exists: the real POS has no movement in our window — last sale 2026-07-01, last write
|
||||||
|
2026-07-09, and 0 of crate 550's 120 records have ever sold, so a real `gone` is EMPTY and an
|
||||||
|
assertion over it would have no subject (the vacuous-gate law). This is the honest way out: a
|
||||||
|
labelled fixture proves the MECHANISM deterministically (gone computed -> sent -> consumed ->
|
||||||
|
sleeve vanishes), while production reads the real POS unchanged. A fixture is not a lie; a
|
||||||
|
green assert over an absent subject is.
|
||||||
|
"""
|
||||||
|
fx = f"{POS_DB}_fixture"
|
||||||
|
subprocess.run(["dropdb", "--if-exists", fx], check=True)
|
||||||
|
subprocess.run(["createdb", "-T", POS_DB, fx], check=True)
|
||||||
|
idx = real_shop(args.shop)
|
||||||
|
skus = [it["id"][4:] for it in idx["items"] if it["id"].startswith("sku_")][:args.sold]
|
||||||
|
lst = ",".join(f"'{s}'" for s in skus)
|
||||||
|
subprocess.run(["psql", "-q", "-d", fx, "-c",
|
||||||
|
f"UPDATE inventory SET in_stock=false, sold_date=now() WHERE sku IN ({lst});"],
|
||||||
|
check=True)
|
||||||
|
setup_role(args)
|
||||||
|
print(f"fixture {fx}: {len(skus)} record(s) marked SOLD -> they must appear in `gone` and "
|
||||||
|
f"must never render:\n " + "\n ".join("sku_" + s for s in skus))
|
||||||
|
|
||||||
|
|
||||||
|
def start(_):
|
||||||
|
if status(None, quiet=True):
|
||||||
|
print("already running"); return
|
||||||
|
os.makedirs(os.path.dirname(PIDFILE), exist_ok=True)
|
||||||
|
p = subprocess.Popen([sys.executable, os.path.abspath(__file__), "serve"],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True)
|
||||||
|
open(PIDFILE, "w").write(str(p.pid))
|
||||||
|
for _ in range(50):
|
||||||
|
time.sleep(0.1)
|
||||||
|
if status(None, quiet=True):
|
||||||
|
print(f"started pid={p.pid} :{PORT}"); return
|
||||||
|
print("failed to start"); sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def stop(_):
|
||||||
|
"""F's kill-the-server gate calls this mid-session. SIGKILL, not a graceful drain: the gate is
|
||||||
|
testing what happens when the server DIES, not when it says goodbye."""
|
||||||
|
try:
|
||||||
|
pid = int(open(PIDFILE).read().strip())
|
||||||
|
os.kill(pid, signal.SIGKILL)
|
||||||
|
os.remove(PIDFILE)
|
||||||
|
print(f"killed pid={pid}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"not running ({e})")
|
||||||
|
|
||||||
|
|
||||||
|
def status(_, quiet=False):
|
||||||
|
import urllib.request
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/godverse/v1/health", timeout=1) as r:
|
||||||
|
up = json.loads(r.read())
|
||||||
|
if not quiet:
|
||||||
|
print(f"UP :{PORT} · POS={up['pos_db']} · real shops={up['shops']}")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
if not quiet:
|
||||||
|
print(f"DOWN :{PORT}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||||
|
for name, fn in (("serve", lambda a: serve()), ("start", start), ("stop", stop),
|
||||||
|
("status", status), ("setup-role", setup_role)):
|
||||||
|
sub.add_parser(name).set_defaults(func=fn)
|
||||||
|
mf = sub.add_parser("make-fixture", help="a controlled POS so F's gate has a subject")
|
||||||
|
mf.add_argument("--shop", default="3962749")
|
||||||
|
mf.add_argument("--sold", type=int, default=3)
|
||||||
|
mf.set_defaults(func=make_fixture)
|
||||||
|
a = ap.parse_args()
|
||||||
|
a.func(a)
|
||||||
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"
|
||||||
1371
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.
|
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).
|
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
|
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}'
|
HOST = f'http://127.0.0.1:{PORT}'
|
||||||
SEED = 20261990
|
SEED = 20261990
|
||||||
if '--seed' in sys.argv:
|
if '--seed' in sys.argv:
|
||||||
|
|||||||
@ -222,13 +222,13 @@ if (existsSync(R('web/js/citygen/plan.js'))) {
|
|||||||
`${p1?.streets?.edges?.length ?? '?'} edges, name "${p1?.name ?? '?'}"`);
|
`${p1?.streets?.edges?.length ?? '?'} edges, name "${p1?.name ?? '?'}"`);
|
||||||
|
|
||||||
// [F2/R8] plan-source selector — pin goldens keyed by (seed, plansrc, TOWN).
|
// [F2/R8] plan-source selector — pin goldens keyed by (seed, plansrc, TOWN).
|
||||||
// Lane A contract: synthetic 0x3fa36874 (selfcheck) · osm/melbourne 0x34cfdec0 · osm/katoomba 0x0f652510.
|
// Lane A contract: synthetic 0x5f76e76 (selfcheck) · osm/melbourne 0x9c7e76b3 · osm/katoomba 0xf3aafec8.
|
||||||
// Guarded: no-op until A ships generatePlanFor + generatePlanOSM.
|
// Guarded: no-op until A ships generatePlanFor + generatePlanOSM.
|
||||||
try {
|
try {
|
||||||
const bar = await import(R('web/js/citygen/index.js'));
|
const bar = await import(R('web/js/citygen/index.js'));
|
||||||
if (typeof bar.generatePlanFor === 'function' && typeof bar.generatePlanOSM === 'function') {
|
if (typeof bar.generatePlanFor === 'function' && typeof bar.generatePlanOSM === 'function') {
|
||||||
const { xmur3 } = await import(R('web/js/core/prng.js'));
|
const { xmur3 } = await import(R('web/js/core/prng.js'));
|
||||||
const TOWN_GOLDENS = { melbourne: '0x34cfdec0', katoomba: '0x0f652510' };
|
const TOWN_GOLDENS = { melbourne: '0x9c7e76b3', katoomba: '0xf3aafec8' };
|
||||||
for (const [town, golden] of Object.entries(TOWN_GOLDENS)) {
|
for (const [town, golden] of Object.entries(TOWN_GOLDENS)) {
|
||||||
const a = bar.generatePlanFor(20261990, 'osm', { town }), b2 = bar.generatePlanFor(20261990, 'osm', { town });
|
const a = bar.generatePlanFor(20261990, 'osm', { town }), b2 = bar.generatePlanFor(20261990, 'osm', { town });
|
||||||
assert(JSON.stringify(a) === JSON.stringify(b2),
|
assert(JSON.stringify(a) === JSON.stringify(b2),
|
||||||
|
|||||||
@ -7,7 +7,7 @@ The v4 thesis in five frames, into docs/shots/release_v4/ (+ contact.html). The
|
|||||||
• fitzroy_mecca — the mecca (160 shops, 2× anything absorbed before this epoch)
|
• fitzroy_mecca — the mecca (160 shops, 2× anything absorbed before this epoch)
|
||||||
• toowoomba_thintail — the thin tail (12 shops): proof the floor still reads as a town
|
• toowoomba_thintail — the thin tail (12 shops): proof the floor still reads as a town
|
||||||
• synthetic_control — the procedural town, UNCHANGED: the control the whole epoch was measured against
|
• synthetic_control — the procedural town, UNCHANGED: the control the whole epoch was measured against
|
||||||
• classic_covenant — `?classic=1`: the v2 covenant made visible, byte-identical forever (0x3fa36874)
|
• classic_covenant — `?classic=1`: the v2 covenant made visible, byte-identical forever (0x5f76e76)
|
||||||
|
|
||||||
Posing: every real-town frame resolves through Lane B's `DBG` retail-heart anchor / `clusterPose` — the
|
Posing: every real-town frame resolves through Lane B's `DBG` retail-heart anchor / `clusterPose` — the
|
||||||
primitive that came out of F's R21 finding (per-EDGE shop counts are ~1 on a real graph, so "densest edge"
|
primitive that came out of F's R21 finding (per-EDGE shop counts are ~1 on a real graph, so "densest edge"
|
||||||
|
|||||||
200
tools/qa/tour_v5.py
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""PROCITY v5.0 RELEASE TOUR — THE LIVE CRATE (Lane F, R27 ledger #5)
|
||||||
|
|
||||||
|
The crate walk, in the order you'd actually do it: Musgrave Road → Monster Robot Party's frontage (B's
|
||||||
|
stocked-shopfront mark) → inside, riffling ITS OWN records → a mint shop, captioned honestly → the
|
||||||
|
`?classic=1` control that still boots the exact v2 town.
|
||||||
|
|
||||||
|
Posing: the street frames go through F's R21 cluster primitive (reused from tour_shots.py). Per-EDGE shop
|
||||||
|
counts are ~1 on a real graph, so "the busiest edge" poses you at bare bitumen — the Fitzroy trap, twice
|
||||||
|
learned. The interior frames pose themselves: the room builds you a metre inside its own door.
|
||||||
|
|
||||||
|
Honesty rules baked in, not bolted on:
|
||||||
|
· the mint frame says MINT in its caption. A minted crate is plausible, not real, and a tour is exactly
|
||||||
|
where that distinction gets quietly lost.
|
||||||
|
· the real frame's caption carries the shop's actual name and address, because it IS one.
|
||||||
|
· nothing here boots ?live=1. A tour frame must be reproducible by anyone who clones this repo, and
|
||||||
|
tier 2 needs a POS none of them have. The live tier is proven by the gate, which owns its own server;
|
||||||
|
the tour shows tier 1, which is what ships in the box.
|
||||||
|
"""
|
||||||
|
import json, subprocess, sys, time, pathlib
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT / 'tools' / 'qa'))
|
||||||
|
PORT = 8131
|
||||||
|
HOST = f'http://127.0.0.1:{PORT}'
|
||||||
|
SEED = 20261990
|
||||||
|
OUTDIR = ROOT / 'docs' / 'shots' / 'release_v5'
|
||||||
|
SHOP = 3962749
|
||||||
|
|
||||||
|
# name, query, segment, mode, caption
|
||||||
|
V5_TOUR = [
|
||||||
|
('musgrave_road', f'plansrc=osm&town=redhill_godverse&stock=real&seed={SEED}', 3, f'frontage:{SHOP}',
|
||||||
|
"the frontage — Monster Robot Party on Musgrave Road, Red Hill, Brisbane. It is on this road in the "
|
||||||
|
"game because it is on this road"),
|
||||||
|
('the_real_crate', f'plansrc=osm&town=redhill_godverse&stock=real&dig=1&seed={SEED}', 3, f'shop:{SHOP}',
|
||||||
|
"THE REAL CRATE — Monster Robot Party (147 Musgrave Rd): 120 records off its own till, real covers, "
|
||||||
|
"real titles, real prices. Sell one in the shop and it leaves this crate"),
|
||||||
|
('a_mint_crate', f'plansrc=osm&town=redhill_godverse&stock=real&dig=1&seed={SEED}', 3, 'mint',
|
||||||
|
"a MINT crate — plausible, NOT real: a seeded sample standing in for a shop whose till we don't hold. "
|
||||||
|
"Same tier, same offline guarantee, different provenance — and the atlas says so in its own index"),
|
||||||
|
('classic_covenant', f'classic=1&seed={SEED}', 2, 'street',
|
||||||
|
"?classic=1 — the exact v2 town, byte-identical forever (0x5f76e76). Five epochs, one covenant"),
|
||||||
|
]
|
||||||
|
|
||||||
|
SHOT_JS = r"""
|
||||||
|
async ({ mode, seg, shop }) => {
|
||||||
|
const P = window.PROCITY, D = window.DBG, T = P.THREE;
|
||||||
|
D.setSegment(seg);
|
||||||
|
const plan = P.plan, shops = plan.shops || [];
|
||||||
|
|
||||||
|
if (mode.startsWith('shop:') || mode === 'mint') {
|
||||||
|
let target;
|
||||||
|
if (mode === 'mint') {
|
||||||
|
const man = await (await fetch('assets/stock_godverse/index.json')).json();
|
||||||
|
const mint = new Set((man.shops||[]).filter(s => s.sourcing === 'mint').map(s => s.godverseShopId));
|
||||||
|
const byId = new Map((man.shops||[]).map(s => [s.godverseShopId, s]));
|
||||||
|
target = shops.find(s => mint.has(s.godverseShopId) &&
|
||||||
|
(byId.get(s.godverseShopId).types||[]).includes(s.type));
|
||||||
|
} else {
|
||||||
|
target = shops.find(s => s.godverseShopId === shop);
|
||||||
|
}
|
||||||
|
if (!target) return { err: 'no target shop for ' + mode };
|
||||||
|
D.enterShop('g:' + target.godverseShopId);
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
const si = D.stockInfo();
|
||||||
|
// pose: stand back from the spawn looking into the room, so the crates are in frame
|
||||||
|
const im = P.interiorMode, sp = im.current.spawn;
|
||||||
|
P.camera.position.set(sp.x, 1.6, sp.z + 0.2);
|
||||||
|
P.camera.rotation.set(0, sp.ry || 0, 0);
|
||||||
|
P.camera.updateMatrixWorld();
|
||||||
|
// Beauty shot: hide EVERY overlay, not just the street HUD. R21 taught this on the street frames
|
||||||
|
// and F only fixed it there — the first v5 interior frame came back with "press Esc to leave"
|
||||||
|
// across the top of the money shot. Same lesson, second surface.
|
||||||
|
// Hide EVERY DOM overlay rather than guessing ids — the interior banner is created at runtime, so a
|
||||||
|
// hardcoded selector list is exactly how the HUD sneaks back into the next money shot.
|
||||||
|
for (const el of document.body.children) if (el.tagName !== 'CANVAS') el.style.display = 'none';
|
||||||
|
for (let i = 0; i < 3; i++) await new Promise(r => requestAnimationFrame(r));
|
||||||
|
const info = D.info();
|
||||||
|
return { name: si.name, gid: si.godverseShopId, items: si.packItems, base: si.base,
|
||||||
|
shops: shops.length, edges: (plan.streets.edges||[]).length,
|
||||||
|
draws: info.drawCalls, tris: info.tris };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── street ──
|
||||||
|
// [R27w5] The frontage frame now goes through B's `poseForShopFront` — B's helper, B's geometry, B's
|
||||||
|
// shop. F re-derived this three times and produced three photographs of empty road; the fourth attempt
|
||||||
|
// would have been the same mistake with more arithmetic. The lane that owns the frontage owns the pose.
|
||||||
|
// (And the empty road was never the pose at all — the shops faced backwards. Fixed by A, wave 5.)
|
||||||
|
const nById = new Map(plan.streets.nodes.map(n => [n.id, n]));
|
||||||
|
const lots = (plan.lots || []).filter(l => l.use === 'shop' || l.use === 'anchor');
|
||||||
|
let hub = -1, best = null;
|
||||||
|
if (mode.startsWith('frontage:')) {
|
||||||
|
const r = D.poseForShopFront('g:' + mode.split(':')[1], 8);
|
||||||
|
if (!r || r.error) return { err: 'poseForShopFront: ' + JSON.stringify(r) };
|
||||||
|
// poseForShopFront RETURNS a pose; it does not apply one. F called it, error-checked it, and threw
|
||||||
|
// the result away — so the camera never moved and the frame was the spawn paddock. A helper that
|
||||||
|
// hands you a value is not a helper that does the thing.
|
||||||
|
P.camera.position.set(r.pos[0], r.pos[1], r.pos[2]);
|
||||||
|
P.camera.lookAt(r.look[0], r.look[1], r.look[2]);
|
||||||
|
P.camera.updateMatrixWorld();
|
||||||
|
const sh = shops.find(x => x.godverseShopId === +mode.split(':')[1]);
|
||||||
|
const lot = sh ? (plan.lots || []).find(l => l.id === sh.lot) : null;
|
||||||
|
hub = lot ? lots.filter(o => Math.hypot(o.x - lot.x, o.z - lot.z) < 60).length : -1;
|
||||||
|
} else {
|
||||||
|
for (const l of lots) { let c = 0; for (const o of lots) if (Math.hypot(o.x - l.x, o.z - l.z) < 60) c++;
|
||||||
|
if (c > hub) { hub = c; best = l; } }
|
||||||
|
if (best) {
|
||||||
|
const e = (plan.streets.edges || []).find(x => x.id === best.frontEdge) || plan.streets.edges[0];
|
||||||
|
const a = nById.get(e.a), b2 = nById.get(e.b);
|
||||||
|
const dx = b2.x - a.x, dz = b2.z - a.z, L = Math.hypot(dx, dz) || 1, ux = dx / L, uz = dz / L;
|
||||||
|
let t = (best.x - a.x) * ux + (best.z - a.z) * uz;
|
||||||
|
t = Math.max(6, Math.min(L - 6, t));
|
||||||
|
const back = Math.min(26, t);
|
||||||
|
D.teleport(a.x + ux * (t - back), a.z + uz * (t - back), Math.atan2(ux, uz));
|
||||||
|
} else { D.shot('street_noon'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 1800));
|
||||||
|
for (const el of document.body.children) if (el.tagName !== 'CANVAS') el.style.display = 'none';
|
||||||
|
for (let i = 0; i < 3; i++) await new Promise(r => requestAnimationFrame(r));
|
||||||
|
const info = D.info();
|
||||||
|
return { name: plan.name, shops: shops.length, edges: (plan.streets.edges || []).length,
|
||||||
|
hub, draws: info.drawCalls, tris: info.tris };
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_server():
|
||||||
|
import socket
|
||||||
|
s = socket.socket()
|
||||||
|
try:
|
||||||
|
s.connect(('127.0.0.1', PORT)); s.close(); return None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
srv = subprocess.Popen([sys.executable, '-m', 'http.server', str(PORT)],
|
||||||
|
cwd=str(ROOT / 'web'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
time.sleep(1.2)
|
||||||
|
return srv
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except ImportError:
|
||||||
|
sys.exit('playwright not installed')
|
||||||
|
print(f"\033[1mv5.0 RELEASE TOUR — THE LIVE CRATE\033[0m ({len(V5_TOUR)} frames → docs/shots/release_v5/)")
|
||||||
|
OUTDIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
srv = ensure_server()
|
||||||
|
shots, errs_total = [], 0
|
||||||
|
try:
|
||||||
|
with sync_playwright() as p:
|
||||||
|
b = p.chromium.launch()
|
||||||
|
for name, query, seg, mode, cap in V5_TOUR:
|
||||||
|
pg = b.new_page(viewport={'width': 1280, 'height': 720})
|
||||||
|
errs = []
|
||||||
|
pg.on('pageerror', lambda e: errs.append(str(e)))
|
||||||
|
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
|
||||||
|
pg.goto(f'{HOST}/index.html?{query}&dbg=1')
|
||||||
|
pg.wait_for_function('window.PROCITY && window.PROCITY.plan && window.DBG && window.DBG.ready',
|
||||||
|
timeout=25000)
|
||||||
|
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
|
||||||
|
try:
|
||||||
|
pg.wait_for_function('() => !window.PROCITY.fleet || window.PROCITY.fleet.ready', timeout=15000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
r = pg.evaluate(SHOT_JS, {'mode': mode, 'seg': seg, 'shop': SHOP})
|
||||||
|
if r.get('err'):
|
||||||
|
print(f" \033[31m✗\033[0m {name}: {r['err']}"); errs_total += 1; pg.close(); continue
|
||||||
|
pg.screenshot(path=str(OUTDIR / f'{name}.png'))
|
||||||
|
extra = (f" · {r['items']} records from {r['base']}" if r.get('items')
|
||||||
|
else (f" · retail heart: {r['hub']} shops/60m" if r.get('hub') else ''))
|
||||||
|
caption = (f"{cap} · {r['name']}{extra} · {r['draws']} draws · {r['tris']:,} tris")
|
||||||
|
shots.append({'name': name, 'file': f'{name}.png', 'caption': caption})
|
||||||
|
print(f" \033[32m●\033[0m {name}: {caption}")
|
||||||
|
errs_total += len(errs)
|
||||||
|
if errs:
|
||||||
|
print(f" \033[31m{len(errs)} page error(s)\033[0m: {errs[0][:110]}")
|
||||||
|
pg.close()
|
||||||
|
b.close()
|
||||||
|
finally:
|
||||||
|
if srv:
|
||||||
|
srv.terminate()
|
||||||
|
|
||||||
|
html = ["<!doctype html><meta charset=utf-8><title>PROCITY v5.0 — THE LIVE CRATE</title>",
|
||||||
|
"<style>body{background:#111;color:#ddd;font:15px/1.6 -apple-system,sans-serif;max-width:1100px;"
|
||||||
|
"margin:40px auto;padding:0 20px}img{width:100%;border-radius:6px;margin:10px 0}"
|
||||||
|
"h1{font-weight:600}p{color:#aaa}hr{border:0;border-top:1px solid #333;margin:36px 0}</style>",
|
||||||
|
"<h1>PROCITY v5.0 — THE LIVE CRATE</h1>",
|
||||||
|
"<p>One real record shop, on its real street, holding its real stock — and a server that can die "
|
||||||
|
"mid-session without the game noticing.</p>"]
|
||||||
|
for s in shots:
|
||||||
|
html.append(f"<hr><h3>{s['name']}</h3><img src='{s['file']}'><p>{s['caption']}</p>")
|
||||||
|
(OUTDIR / 'contact.html').write_text('\n'.join(html))
|
||||||
|
(OUTDIR / 'shots.json').write_text(json.dumps(shots, indent=1))
|
||||||
|
print(f"\n → {OUTDIR}/contact.html ({len(shots)} frames, {errs_total} page error(s))")
|
||||||
|
return 0 if (len(shots) == len(V5_TOUR) and errs_total == 0) else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@ -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
|
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).
|
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
|
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}'
|
HOST = f'http://127.0.0.1:{PORT}'
|
||||||
STREET_DRAWS_MAX, STREET_TRIS_MAX = 300, 200_000
|
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
|
# [R21 ledger #6c] B's R20 tram ruling: on a REAL-ROADS town the tram only runs if its best main chain
|
||||||
|
|||||||
@ -20,9 +20,17 @@ the "© OpenStreetMap contributors (ODbL)" credit visible.
|
|||||||
**`index.json` is the machine-readable roster** (key/town/state/shops/roads) — read it rather than
|
**`index.json` is the machine-readable roster** (key/town/state/shops/roads) — read it rather than
|
||||||
hardcoding a list. Every cache carries the same terms; per-town numbers live in each cache's `counts`.
|
hardcoding a list. Every cache carries the same terms; per-town numbers live in each cache's `counts`.
|
||||||
|
|
||||||
**The pack: 21 real towns · 1196 shops · 299 heroes** (+ Lane G's `newtown_godverse` = 22 rostered in
|
**The pack: 21 real towns · 1196 shops · 299 heroes** (+ Lane G's `newtown_godverse` and `redhill_godverse`
|
||||||
`index.json`), every one fetched from OSM Overpass with a bounded per-town bbox, ODbL 1.0, raw snapshots
|
= **23 rostered** in `index.json`), every one fetched from OSM Overpass with a bounded per-town bbox, ODbL
|
||||||
in `_raw/` (shops + roads per town), fetched 2026-07-16 (`redhill_real` 2026-07-17).
|
1.0, raw snapshots in `_raw/` (shops + roads per town), fetched 2026-07-16 (`redhill_real` 2026-07-17).
|
||||||
|
|
||||||
|
> **⟨v5.0-FINAL⟩ roster fix (R27):** `redhill_godverse` — the town the whole v5 epoch walks to — was **on
|
||||||
|
> disk but missing from `index.json` for three rounds**. G's cache landed *after* my last `write_index()`
|
||||||
|
> run and nobody re-rostered it. Every gate stayed green because `selfcheck` reads the directory directly
|
||||||
|
> and F's smoke boots the town **by key**; only B's HUD selector reads this roster, so the sole symptom was
|
||||||
|
> that **a player could not choose it from the dropdown**. Mine to own: the index is Lane E's, and an
|
||||||
|
> addition isn't done when the cache is written — it's done when the roster is regenerated. (The mirror of
|
||||||
|
> R24's lesson, where a *retirement* wasn't done until the consumers were swept.) See LANE_E_NOTES R27.
|
||||||
|
|
||||||
| state | towns |
|
| state | towns |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@ -144,7 +144,7 @@
|
|||||||
"key": "newtown_godverse",
|
"key": "newtown_godverse",
|
||||||
"town": "Newtown",
|
"town": "Newtown",
|
||||||
"state": "",
|
"state": "",
|
||||||
"shops": 18,
|
"shops": 72,
|
||||||
"roads": true,
|
"roads": true,
|
||||||
"source": "godverse+osm"
|
"source": "godverse+osm"
|
||||||
},
|
},
|
||||||
@ -164,6 +164,14 @@
|
|||||||
"roads": true,
|
"roads": true,
|
||||||
"source": "osm"
|
"source": "osm"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "redhill_godverse",
|
||||||
|
"town": "Red Hill",
|
||||||
|
"state": "",
|
||||||
|
"shops": 37,
|
||||||
|
"roads": true,
|
||||||
|
"source": "godverse+osm"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "redhill_real",
|
"key": "redhill_real",
|
||||||
"town": "Red Hill",
|
"town": "Red Hill",
|
||||||
|
|||||||
161
web/index.html
@ -31,10 +31,11 @@
|
|||||||
<div id="pc-start">
|
<div id="pc-start">
|
||||||
<h1>PROCITY</h1>
|
<h1>PROCITY</h1>
|
||||||
<div class="sub">A procedurally generated, walkable 90s-Australian shopping town.
|
<div class="sub">A procedurally generated, walkable 90s-Australian shopping town.
|
||||||
Every door opens (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>
|
<button id="pc-go">walk into town</button>
|
||||||
<div class="seed" id="pc-startseed"></div>
|
<div class="seed" id="pc-startseed"></div>
|
||||||
<div class="sub" style="font-size:12px;opacity:.7">WASD move · shift run · mouse look · click a door · [ ] 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>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<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 { 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 { VenueQueue } from './js/citizens/queue.js'; // [Lane F R13] Lane D outdoor gig queue (?gigs=1)
|
||||||
import { preloadManifest, preloadStockPack } from './js/interiors/interiors.js'; // [Lane F] Lane E manifest + Lane C/E stock pack
|
import { preloadManifest, preloadStockPack } from './js/interiors/interiors.js'; // [Lane F] Lane E manifest + Lane C/E stock pack
|
||||||
|
import { createGame } from './js/world/save.js'; // [Lane F R30] v7 THE SAVE CORE (contract: LANE_F_NOTES §30)
|
||||||
|
import { ARTISTS } from './js/interiors/dig.js'; // [R35] the parody roster — a fresh game's seeded first want
|
||||||
|
import { rng, pick } from './js/core/prng.js'; // [R35] seeded first-want draw (runtime stream, fresh key)
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error('[procity] init failed:', err);
|
console.error('[procity] init failed:', err);
|
||||||
@ -167,7 +171,8 @@ const skins = createSkinLibrary();
|
|||||||
const ground = buildGround(plan, skins);
|
const ground = buildGround(plan, skins);
|
||||||
scene.add(ground.group);
|
scene.add(ground.group);
|
||||||
|
|
||||||
const lighting = createLighting(scene, plan, skins, { radius: RADIUS, shadows: SHADOWS });
|
const lighting = createLighting(scene, plan, skins, { radius: RADIUS, shadows: SHADOWS,
|
||||||
|
dawnSky: CLASSIC ? null : 'high-cirrus' }); // [R32 §31.6-3] dawn reads dawn; classic keeps the frozen dome
|
||||||
lighting.attachRenderer(renderer);
|
lighting.attachRenderer(renderer);
|
||||||
|
|
||||||
const ctx = { skins, citySeed: plan.citySeed, plan, night: lighting.isNight(), radius: RADIUS };
|
const ctx = { skins, citySeed: plan.citySeed, plan, night: lighting.isNight(), radius: RADIUS };
|
||||||
@ -186,14 +191,30 @@ const minimap = createMinimap(plan);
|
|||||||
// unless) a GLB lands, so the town is fully playable the instant it boots.
|
// unless) a GLB lands, so the town is fully playable the instant it boots.
|
||||||
// • rig fleet: models/peds/*.glb → shared GLB actors; CitizenSim + keepers upgrade from placeholders.
|
// • rig fleet: models/peds/*.glb → shared GLB actors; CitizenSim + keepers upgrade from placeholders.
|
||||||
// • manifest: Lane E's assets/manifest.json → interior fitting GLBs (loaded from the 3GOD depot).
|
// • manifest: Lane E's assets/manifest.json → interior fitting GLBs (loaded from the 3GOD depot).
|
||||||
const fleet = NOASSETS ? null : loadPedFleet('models/peds/', { sit: !CLASSIC }); // [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
|
if (!NOASSETS) preloadManifest(); // prime the manifest so the first interior can upgrade its fittings
|
||||||
// [Lane F integration] interior bridge — owns the 'interior' branch (Lane C buildInterior + Lane D keeper)
|
// [Lane F integration] interior bridge — owns the 'interior' branch (Lane C buildInterior + Lane D keeper)
|
||||||
// [Lane F §F2] ?dig=1 enables Lane C's crate-riffle inside record interiors (v2). Off ⇒ byte-identical to v1.
|
// [Lane F R31 — THE DIG FLIP] The crate-riffle is DEFAULT-ON: `?dig=0` opts out, `?classic=1` forces it off
|
||||||
const DIG_ON = params.has('dig') && params.get('dig') !== '0';
|
// — 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
|
// [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.
|
// 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;
|
const STOCK_REAL = params.get('stock') === 'real' && !NOASSETS;
|
||||||
|
// [Lane F R27 §2 — TIER 2, OPT-IN] `?live=1` → the default GODVERSE base; `?live=<url>` → that base;
|
||||||
|
// absent → NO tier 2 at all, byte-identical to the beta. Opt-in is deliberate and is the R26 lesson
|
||||||
|
// applied to a server: a default boot that probed for a GODVERSE host would ask a question with no
|
||||||
|
// answer on every machine that isn't John's, and "it only logs an error when the server's away" is
|
||||||
|
// exactly the blind probe the manifest was built to end. Tier 2 is a place you go, not a thing that
|
||||||
|
// happens to you. Auto-discovery, if ever, is a v5.x question with a reachability contract behind it.
|
||||||
|
const LIVE_ARG = NOASSETS ? null : params.get('live');
|
||||||
|
const LIVE_BASE = !LIVE_ARG || LIVE_ARG === '0' ? null
|
||||||
|
: (LIVE_ARG === '1' ? 'http://127.0.0.1:8778' : LIVE_ARG.replace(/\/$/, ''));
|
||||||
|
let STOCK_SOURCING = null; // (godverseShopId) → 'real'|'mint'|null, from G's manifest (set below)
|
||||||
// [Lane F R9] preload ALL three packs so record dig sleeves AND book-spine/toy-box shelves (buy-anywhere)
|
// [Lane F R9] preload ALL three packs so record dig sleeves AND book-spine/toy-box shelves (buy-anywhere)
|
||||||
// are real. Fail-soft per pack (missing → parody canvas). getStockPack(type) is what buildInterior reads.
|
// are real. Fail-soft per pack (missing → parody canvas). getStockPack(type) is what buildInterior reads.
|
||||||
// [Lane F R24 §6a — THE REAL CRATE] The town-wide packs stay exactly as they were (every shop without a
|
// [Lane F R24 §6a — THE REAL CRATE] The town-wide packs stay exactly as they were (every shop without a
|
||||||
@ -222,27 +243,105 @@ if (STOCK_REAL) {
|
|||||||
const e = byId.get(s.godverseShopId);
|
const e = byId.get(s.godverseShopId);
|
||||||
if (e && (e.types || []).includes(s.type)) preloadStockPack(s.type, { base: godverseBaseFor(s) });
|
if (e && (e.types || []).includes(s.type)) preloadStockPack(s.type, { base: godverseBaseFor(s) });
|
||||||
}
|
}
|
||||||
|
// [R27 §2] the same manifest already knows real vs mint — the tier-2 reader asks only real shops.
|
||||||
|
STOCK_SOURCING = (id) => (byId.get(id) || {}).sourcing || null;
|
||||||
}
|
}
|
||||||
} catch (e) { /* no manifest ⇒ no per-shop packs ⇒ parody. The ladder's bottom rung, unchanged. */ }
|
} catch (e) { /* no manifest ⇒ no per-shop packs ⇒ parody. The ladder's bottom rung, unchanged. */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// [Lane F R8 — Lane C buy loop v0] ONE session wallet (seeded start cash) bound to the dig BUY across shops.
|
// [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.
|
// 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)
|
// [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
|
// → 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
|
// 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).
|
// 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;
|
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()
|
// [Lane F R9] occupancyOf closure defers to `citizens` (declared just below); only invoked at enter()
|
||||||
// time (door click), long after citizens is initialized — so the forward reference is safe.
|
// time (door click), long after citizens is initialized — so the forward reference is safe.
|
||||||
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS,
|
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS,
|
||||||
dig: DIG_ON, stockReal: STOCK_REAL, wallet, occupancyOf: (id) => citizens.occupancyOf(id),
|
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
|
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
|
||||||
|
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
|
// [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
|
// 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".
|
// doesn't read as "walked out of the shop".
|
||||||
window.addEventListener('procity:digOpen', () => player.unlock());
|
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
|
// [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);
|
// 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
|
// `?roster=stream` is still accepted (now a no-op — stream is default). Gated on Lane D's GO: E1's
|
||||||
@ -304,8 +403,37 @@ const venuePresentation = gigState ? createVenuePresentation(plan, skins, scene)
|
|||||||
const venueQueues = gigState ? new Map() : null; // venueShopId → VenueQueue
|
const venueQueues = gigState ? new Map() : null; // venueShopId → VenueQueue
|
||||||
const queueCountOf = (id) => { const q = venueQueues && venueQueues.get(id); return q ? q.count() : 0; };
|
const queueCountOf = (id) => { const q = venueQueues && venueQueues.get(id); return q ? q.count() : 0; };
|
||||||
|
|
||||||
// spawn on the footpath near the west end of the strip, looking east down main street
|
// [R32 §31.6-1 — THE CLUSTER-POSE SPAWN] The frozen pose (-96, 9, looking east) was tuned for the
|
||||||
player.teleport(-96, 9, -Math.PI / 2);
|
// synthetic fixture; on real OSM towns it faced dirt and building-backs (the R31 playtest's #1).
|
||||||
|
// Non-classic boots now spawn at one END of the shop cluster's dominant axis, ON the footpath door-line
|
||||||
|
// (a point every shop already computes for patronage — known walkable), looking down the strip toward
|
||||||
|
// the cluster's heart. Deterministic (pure plan math, no draws — no stream moves), and ?classic=1
|
||||||
|
// keeps the frozen pose forever (the covenant is a POSE here, not just a plan).
|
||||||
|
let SPAWN = { x: -96, z: 9, yaw: -Math.PI / 2 };
|
||||||
|
if (!CLASSIC && plan.shops && plan.shops.length >= 2) {
|
||||||
|
// main-street shop doors only (block kind — both generators stamp it); every-door fallback
|
||||||
|
const msBlocks = new Set((plan.blocks || []).filter((b) => b.kind === 'mainstreet').map((b) => b.id));
|
||||||
|
const doorOf = (s) => {
|
||||||
|
const l = plan.lots.find((x) => x.id === s.lot); if (!l) return null;
|
||||||
|
const ry = l.ry || 0;
|
||||||
|
return { x: l.x - Math.sin(ry) * (l.d / 2 + 2.6), z: l.z - Math.cos(ry) * (l.d / 2 + 2.6), block: l.block };
|
||||||
|
};
|
||||||
|
let doors = plan.shops.map(doorOf).filter(Boolean);
|
||||||
|
const ms = doors.filter((d) => msBlocks.has(d.block));
|
||||||
|
if (ms.length >= 2) doors = ms;
|
||||||
|
if (doors.length >= 2) {
|
||||||
|
let cx = 0, cz = 0; for (const d of doors) { cx += d.x; cz += d.z; } cx /= doors.length; cz /= doors.length;
|
||||||
|
let vx = 0, vz = 0; for (const d of doors) { vx += (d.x - cx) ** 2; vz += (d.z - cz) ** 2; }
|
||||||
|
const ax = vx >= vz ? 'x' : 'z'; // the strip's dominant axis
|
||||||
|
let stand = doors[0], bd = Infinity; // stand at the door nearest the cluster's heart
|
||||||
|
for (const d of doors) { const dd = (d.x - cx) ** 2 + (d.z - cz) ** 2; if (dd < bd) { bd = dd; stand = d; } }
|
||||||
|
let lo = Infinity, hi = -Infinity;
|
||||||
|
for (const d of doors) { lo = Math.min(lo, d[ax]); hi = Math.max(hi, d[ax]); }
|
||||||
|
const dir = (hi - stand[ax] >= stand[ax] - lo) ? 1 : -1; // face the longer run of shopfronts
|
||||||
|
SPAWN = { x: stand.x, z: stand.z, yaw: ax === 'x' ? Math.atan2(-dir, 0) : Math.atan2(0, -dir) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.teleport(SPAWN.x, SPAWN.z, SPAWN.yaw);
|
||||||
chunks.warmup(player.position);
|
chunks.warmup(player.position);
|
||||||
lighting.update(0.0001, player.position);
|
lighting.update(0.0001, player.position);
|
||||||
|
|
||||||
@ -377,9 +505,9 @@ addEventListener('blur', () => keys.clear());
|
|||||||
const startPanel = document.getElementById('pc-start');
|
const startPanel = document.getElementById('pc-start');
|
||||||
document.getElementById('pc-startseed').textContent = `${plan.name} · seed ${plan.citySeed}`;
|
document.getElementById('pc-startseed').textContent = `${plan.name} · seed ${plan.citySeed}`;
|
||||||
document.getElementById('pc-go').addEventListener('click', () => { startPanel.style.display = 'none'; player.lock(); });
|
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
|
// 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', () => {
|
addEventListener('resize', () => {
|
||||||
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
|
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
|
||||||
@ -458,8 +586,10 @@ function frame() {
|
|||||||
renderer.info.reset(); // zero the counters, then let the composer's passes accumulate this frame
|
renderer.info.reset(); // zero the counters, then let the composer's passes accumulate this frame
|
||||||
composer.render();
|
composer.render();
|
||||||
} else if (MODE === 'interior') {
|
} else if (MODE === 'interior') {
|
||||||
|
hud.tickGame(); // [R32 §31.6-2] the crate/day/cash surface lives in shops too (yields to the cards)
|
||||||
if (interiorMode.update(dt, player.isLocked ? keys : EMPTY)) leaveShop(); // [Lane F] walk to the door → back to street
|
if (interiorMode.update(dt, player.isLocked ? keys : EMPTY)) leaveShop(); // [Lane F] walk to the door → back to street
|
||||||
} else if (MODE === 'map') {
|
} else if (MODE === 'map') {
|
||||||
|
hud.tickGame(); // [R32] map mode: the same check hides the surface (the map owns the screen)
|
||||||
camera.getWorldDirection(_fwd);
|
camera.getWorldDirection(_fwd);
|
||||||
minimap.draw(player.position, { x: _fwd.x, z: _fwd.z });
|
minimap.draw(player.position, { x: _fwd.x, z: _fwd.z });
|
||||||
}
|
}
|
||||||
@ -472,7 +602,8 @@ frame();
|
|||||||
window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skins,
|
window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skins,
|
||||||
interiorMode, citizens, enterShop, leaveShop, isOpen, currentHour, fleet, noassets: NOASSETS,
|
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)
|
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
|
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
|
// 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)
|
venuePresentation, // [Lane F R12] Lane B frontage + posters (null flags-off)
|
||||||
@ -481,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.
|
// ({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
|
// [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.
|
// 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
|
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
|
// [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
|
// 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 { createDig, binSeed } from './js/interiors/dig.js';
|
||||||
import { createWallet } from './js/interiors/wallet.js';
|
import { createWallet } from './js/interiors/wallet.js';
|
||||||
import { collapseBuyItem } from './js/interiors/stockpack.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.
|
// ?stock=real — feed Lane E's GODVERSE record-sleeve pack through the stockAdapter seam.
|
||||||
const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real';
|
const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real';
|
||||||
@ -522,6 +523,54 @@ async function shelfBuySoak() {
|
|||||||
return out;
|
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 ───────────────────────────────────────────────────────────────────────
|
// ── loop ───────────────────────────────────────────────────────────────────────
|
||||||
let last = performance.now();
|
let last = performance.now();
|
||||||
function frame() {
|
function frame() {
|
||||||
@ -534,7 +583,8 @@ function frame() {
|
|||||||
// expose for headless verification (screenshot harness, workflow checks)
|
// 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,
|
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,
|
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();
|
rebuild();
|
||||||
frame();
|
frame();
|
||||||
|
|||||||
@ -138,7 +138,7 @@ export class GigCrew {
|
|||||||
// one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot.
|
// one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot.
|
||||||
// `seated` (R16): the drummer — use E's sit.glb pose (fleet.sitClip) + spawnRig's foot-replant + tag the
|
// `seated` (R16): the drummer — use E's sit.glb pose (fleet.sitClip) + spawnRig's foot-replant + tag the
|
||||||
// fig for F's stature gate; falls back to the standing idle (caller sinks it via SEAT_DROP) if no sitClip.
|
// fig for F's stature gate; falls back to the standing idle (caller sinks it via SEAT_DROP) if no sitClip.
|
||||||
_make(target, key, { x, y, z, ry, height, pedIndex = null, seated = false }) {
|
_make(target, key, { x, y, z, ry, height, pedIndex = null, seated = false, dance = false }) {
|
||||||
const r = rng(this.citySeed, 'gig', key);
|
const r = rng(this.citySeed, 'gig', key);
|
||||||
const h = height != null ? height : 1.6 + r() * 0.3;
|
const h = height != null ? height : 1.6 + r() * 0.3;
|
||||||
let actor, kind, usedIndex = null;
|
let actor, kind, usedIndex = null;
|
||||||
@ -146,7 +146,10 @@ export class GigCrew {
|
|||||||
const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r());
|
const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r());
|
||||||
const rig = pk && this.fleet.all[pk.index];
|
const rig = pk && this.fleet.all[pk.index];
|
||||||
const useSit = seated && !!this.fleet.sitClip; // real seated pose iff E's sit clip is loaded
|
const useSit = seated && !!this.fleet.sitClip; // real seated pose iff E's sit clip is loaded
|
||||||
const sp = rig && spawnRig(rig, { ry, height: h, clip: useSit ? this.fleet.sitClip : this.fleet.idleClip, phase: r(), seated: useSit });
|
// dancers play a real dance clip (separate 'gigdance' stream keeps the pick off the existing draws)
|
||||||
|
const dcs = this.fleet.danceClips || [];
|
||||||
|
const danceClip = (dance && dcs.length) ? dcs[(rng(this.citySeed, 'gigdance', key)() * dcs.length) | 0] : null;
|
||||||
|
const sp = rig && spawnRig(rig, { ry, height: h, clip: useSit ? this.fleet.sitClip : (danceClip || this.fleet.idleClip), phase: r(), seated: useSit });
|
||||||
if (sp) { actor = sp; kind = 'rig'; usedIndex = pk.index; if (useSit) sp.fig.userData.procitySeated = true; }
|
if (sp) { actor = sp; kind = 'rig'; usedIndex = pk.index; if (useSit) sp.fig.userData.procitySeated = true; }
|
||||||
}
|
}
|
||||||
if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'gig-body', key), { height: h }); actor.fig.rotation.y = ry; kind = 'placeholder'; }
|
if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'gig-body', key), { height: h }); actor.fig.rotation.y = ry; kind = 'placeholder'; }
|
||||||
@ -200,8 +203,8 @@ export class GigCrew {
|
|||||||
pts.forEach((w, i) => {
|
pts.forEach((w, i) => {
|
||||||
const who = roster[i] || null;
|
const who = roster[i] || null;
|
||||||
const { actor, kind, pedIndex } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`,
|
const { actor, kind, pedIndex } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`,
|
||||||
who ? { x: w.x, y: 0, z: w.z, ry: w.ry, pedIndex: who.pedIndex, height: who.height }
|
who ? { x: w.x, y: 0, z: w.z, ry: w.ry, pedIndex: who.pedIndex, height: who.height, dance: !!w.dance }
|
||||||
: { x: w.x, y: 0, z: w.z, ry: w.ry });
|
: { x: w.x, y: 0, z: w.z, ry: w.ry, dance: !!w.dance });
|
||||||
if (who) fromRoster++;
|
if (who) fromRoster++;
|
||||||
this.members.push({ actor, kind, pedIndex, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false, fromRoster: !!who,
|
this.members.push({ actor, kind, pedIndex, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false, fromRoster: !!who,
|
||||||
base: { x: w.x, y: 0, z: w.z, ry: w.ry }, phase: rng(this.citySeed, 'gigp', `c${gid}:${w.slotIndex}`)() * Math.PI * 2, extra: [] });
|
base: { x: w.x, y: 0, z: w.z, ry: w.ry }, phase: rng(this.citySeed, 'gigp', `c${gid}:${w.slotIndex}`)() * Math.PI * 2, extra: [] });
|
||||||
@ -284,10 +287,14 @@ export class GigCrew {
|
|||||||
f.rotation.z = Math.sin(t * 3.1 + ph) * 0.055;
|
f.rotation.z = Math.sin(t * 3.1 + ph) * 0.055;
|
||||||
f.position.y = b.y + Math.abs(Math.sin(t * 3.1 + ph)) * 0.02;
|
f.position.y = b.y + Math.abs(Math.sin(t * 3.1 + ph)) * 0.02;
|
||||||
}
|
}
|
||||||
} else if (m.dance) { // dancer: bounce + sway + a little step
|
} else if (m.dance) { // dancer
|
||||||
f.position.y = b.y + Math.abs(Math.sin(t * 5.2 + ph)) * 0.075;
|
if (this.fleet && this.fleet.danceClips && this.fleet.danceClips.length) {
|
||||||
f.rotation.z = Math.sin(t * 2.6 + ph) * 0.07;
|
f.rotation.y = b.ry + Math.sin(t * 0.6 + ph) * 0.10; // real dance clip drives the body; just a slow facing drift
|
||||||
f.rotation.y = b.ry + Math.sin(t * 1.7 + ph) * 0.13;
|
} else { // fallback (classic/noassets): procedural bounce + sway
|
||||||
|
f.position.y = b.y + Math.abs(Math.sin(t * 5.2 + ph)) * 0.075;
|
||||||
|
f.rotation.z = Math.sin(t * 2.6 + ph) * 0.07;
|
||||||
|
f.rotation.y = b.ry + Math.sin(t * 1.7 + ph) * 0.13;
|
||||||
|
}
|
||||||
} else { // stander: slow weight shift, feet planted
|
} else { // stander: slow weight shift, feet planted
|
||||||
f.rotation.z = Math.sin(t * 0.9 + ph) * 0.022;
|
f.rotation.z = Math.sin(t * 0.9 + ph) * 0.022;
|
||||||
f.rotation.y = b.ry + Math.sin(t * 0.5 + ph) * 0.03;
|
f.rotation.y = b.ry + Math.sin(t * 0.5 + ph) * 0.03;
|
||||||
|
|||||||
@ -18,7 +18,8 @@ export const PED_NAMES = {
|
|||||||
normal: ['man_worker_hivis_01', 'man_suit_01', 'man_casual_01', 'man_casual_02', 'man_casual_03',
|
normal: ['man_worker_hivis_01', 'man_suit_01', 'man_casual_01', 'man_casual_02', 'man_casual_03',
|
||||||
'man_grunge_01', 'man_elder_01', 'man_athletic_01', 'man_bouncer_01', 'man_soldier_ww2_01',
|
'man_grunge_01', 'man_elder_01', 'man_athletic_01', 'man_bouncer_01', 'man_soldier_ww2_01',
|
||||||
'man_dj_streetwear_01', 'woman_casual_01', 'woman_casual_02', 'woman_business_01',
|
'man_dj_streetwear_01', 'woman_casual_01', 'woman_casual_02', 'woman_business_01',
|
||||||
'woman_athleisure_01', 'woman_smart_01', 'woman_chef_01'],
|
'woman_athleisure_01', 'woman_smart_01', 'woman_chef_01',
|
||||||
|
'woman_dj_01', 'woman_dj_02', 'woman_dj_03', 'dj_techno_01', 'dj_phrtt_01'], // trellis'd DJ bodies (fbx_to_ped, 4k tris)
|
||||||
comical: ['comical_luchador_01', 'comical_boy_01'],
|
comical: ['comical_luchador_01', 'comical_boy_01'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -55,10 +56,10 @@ function loadRig(ref) {
|
|||||||
// sit.glb fetch would break the ?classic=1 zero-fetch-delta covenant — F passes sit:true on the gig/default
|
// sit.glb fetch would break the ?classic=1 zero-fetch-delta covenant — F passes sit:true on the gig/default
|
||||||
// boot, sit:false (default) under ?classic. Absent/off → fleet.sitClip stays null and the drummer falls
|
// boot, sit:false (default) under ?classic. Absent/off → fleet.sitClip stays null and the drummer falls
|
||||||
// back to the standing-sunk hack, so this never breaks a boot. Does NOT touch fleet.ready / determinism.
|
// back to the standing-sunk hack, so this never breaks a boot. Does NOT touch fleet.ready / determinism.
|
||||||
export function loadPedFleet(base = 'models/peds/', { sit = false } = {}) {
|
export function loadPedFleet(base = 'models/peds/', { sit = false, look = false, dance = false } = {}) {
|
||||||
const fleet = {
|
const fleet = {
|
||||||
normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas
|
normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas
|
||||||
walkClip: null, idleClip: null, sitClip: null,
|
walkClip: null, idleClip: null, sitClip: null, lookClip: null, danceClips: [],
|
||||||
ready: false, whenReady: null,
|
ready: false, whenReady: null,
|
||||||
};
|
};
|
||||||
// DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array
|
// DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array
|
||||||
@ -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
|
if (sit) jobs.push(loadRig(`${base}sit.glb`).then(r => { // R16, gig/default boot only
|
||||||
const c = r && r.anims && r.anims[0];
|
const c = r && r.anims && r.anims[0];
|
||||||
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.sitClip = _rotOnly(c); } }));
|
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.whenReady = Promise.all(jobs).then(() => {
|
||||||
fleet.normal = nSlots.filter(Boolean);
|
fleet.normal = nSlots.filter(Boolean);
|
||||||
fleet.comical = cSlots.filter(Boolean);
|
fleet.comical = cSlots.filter(Boolean);
|
||||||
fleet.all = [...fleet.normal, ...fleet.comical];
|
fleet.all = [...fleet.normal, ...fleet.comical];
|
||||||
|
fleet.danceClips = danceSlots.filter(Boolean);
|
||||||
fleet.ready = fleet.all.length > 0 && !!fleet.walkClip;
|
fleet.ready = fleet.all.length > 0 && !!fleet.walkClip;
|
||||||
return fleet;
|
return fleet;
|
||||||
});
|
});
|
||||||
@ -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
|
// ---- 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). ----
|
// 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;
|
if (!rig || !rig.scene) return null;
|
||||||
const { fig, inner, head, nominalHeight: nom } = buildFigure(rig, nominalHeight);
|
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
|
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
|
// 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.
|
// 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;
|
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;
|
let seatBone = null;
|
||||||
if (sitA) inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
|
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
|
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
|
// both actions play; exactly one holds weight 1 at rest, setMoving transfers between them
|
||||||
if (walkA) { walkA.play(); walkA.setEffectiveWeight(1); }
|
if (walkA) { walkA.play(); walkA.setEffectiveWeight(1); }
|
||||||
@ -279,13 +303,53 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, nominalHeig
|
|||||||
fig.userData.procitySeated = false;
|
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
|
// seed the clip phase so the crowd isn't in lockstep
|
||||||
function setPhase(p) {
|
function setPhase(p) {
|
||||||
if (walkA) walkA.time = p * (walkA.getClip().duration || 1);
|
if (walkA) walkA.time = p * (walkA.getClip().duration || 1);
|
||||||
if (idleA) idleA.time = p * (idleA.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); }
|
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) };
|
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_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 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 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,
|
// 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.
|
// near-empty at night). Sampled at 8 control points, linearly interpolated.
|
||||||
@ -93,6 +94,7 @@ class RigPool {
|
|||||||
release(actor) {
|
release(actor) {
|
||||||
if (!actor) return;
|
if (!actor) return;
|
||||||
if (actor.setSitting) actor.setSitting(false); // R17: never pool a rig mid-sit — restore the standing plant
|
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);
|
let arr = this.free.get(actor.pedIndex);
|
||||||
if (!arr) this.free.set(actor.pedIndex, arr = []);
|
if (!arr) this.free.set(actor.pedIndex, arr = []);
|
||||||
arr.push(actor);
|
arr.push(actor);
|
||||||
@ -258,6 +260,7 @@ export class CitizenSim {
|
|||||||
turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id),
|
turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id),
|
||||||
patron: null, patronTarget: null, patronTimer: 0, patronRng: rng(this.citySeed, 'patron', 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
|
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; } }
|
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);
|
this._placeOnLane(c);
|
||||||
@ -307,6 +310,7 @@ export class CitizenSim {
|
|||||||
turn: rng(this.citySeed, 'turn', id),
|
turn: rng(this.citySeed, 'turn', id),
|
||||||
loit: rng(this.citySeed, 'loiter', id),
|
loit: rng(this.citySeed, 'loiter', id),
|
||||||
sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron
|
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)
|
// assign a real ped type if the fleet is already up (roster can grow after upgrade)
|
||||||
if (this.mode === 'rig' && this.fleet.ready) {
|
if (this.mode === 'rig' && this.fleet.ready) {
|
||||||
@ -522,7 +526,7 @@ export class CitizenSim {
|
|||||||
this._setImpostorLayer(atlas);
|
this._setImpostorLayer(atlas);
|
||||||
|
|
||||||
this.mode = 'rig';
|
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
|
// assign real ped types to every citizen; drop placeholder near-actors so they re-acquire as rigs
|
||||||
const assign = c => {
|
const assign = c => {
|
||||||
const pk = pickRig(this.fleet, c.pedRoll);
|
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.
|
// 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;
|
const wantsSit = c.sitRng ? (c.sitRng() < BENCH_SIT_FRAC) : false;
|
||||||
c.sit = wantsSit && !!(this.fleet && this.fleet.sitClip);
|
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);
|
this._placeOnLane(c);
|
||||||
@ -732,7 +742,11 @@ export class CitizenSim {
|
|||||||
// so every non-sitting ped is byte-identical.
|
// so every non-sitting ped is byte-identical.
|
||||||
const wantSit = !!(c.sit && c.loiter > 0 && a.setSitting);
|
const wantSit = !!(c.sit && c.loiter > 0 && a.setSitting);
|
||||||
if (a.setSitting) a.setSitting(wantSit);
|
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') {
|
} else if (want === 'mid') {
|
||||||
mid.push(c);
|
mid.push(c);
|
||||||
}
|
}
|
||||||
@ -747,7 +761,9 @@ export class CitizenSim {
|
|||||||
const c = near[i], a = c.actor;
|
const c = near[i], a = c.actor;
|
||||||
c._acc += dt;
|
c._acc += dt;
|
||||||
if (a.mixer) {
|
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 {
|
} else {
|
||||||
a.tick?.(c._acc, c.loiter <= 0); c._acc = 0; // placeholder
|
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++) {
|
for (let k = 0; k < MIXER_EXTRA && k < extra.length; k++) {
|
||||||
const c = extra[(this._mixerCursor + k) % extra.length];
|
const c = extra[(this._mixerCursor + k) % extra.length];
|
||||||
c.actor.mixer.update(c._acc); c._acc = 0;
|
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;
|
this._mixerCursor = (this._mixerCursor + MIXER_EXTRA) % extra.length;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -119,7 +119,11 @@ export function generatePlan(citySeed) {
|
|||||||
const c3 = [A.x + nx * (half + stripDepth), A.z + nz * (half + stripDepth)];
|
const c3 = [A.x + nx * (half + stripDepth), A.z + nz * (half + stripDepth)];
|
||||||
const blockId = addBlock(districtId, districtKind, [c0, c1, c2, c3]);
|
const blockId = addBlock(districtId, districtKind, [c0, c1, c2, c3]);
|
||||||
out.blockId = blockId;
|
out.blockId = blockId;
|
||||||
const ry = Math.atan2(nx, nz); // facade faces back down the outward normal, to the street
|
// ROUND27 THE FACADE FIX: aim the VISIBLE facade (local +Z — B's canon, what buildings.js draws) back
|
||||||
|
// down the outward normal, at the street. The old `atan2(nx, nz)` put +Z along the OUTWARD normal, i.e.
|
||||||
|
// facing away — this line's own comment has described the intended result since v1; only the sign was
|
||||||
|
// wrong. It read correct because CITY_SPEC:71 wrote the SIM convention (−Z) into the lots contract.
|
||||||
|
const ry = Math.atan2(-nx, -nz); // facade (+Z) faces back down the outward normal, to the street
|
||||||
const r = rng(citySeed, streamKind, streamId);
|
const r = rng(citySeed, streamKind, streamId);
|
||||||
const iStart = band.insetStart ?? 0, iEnd = band.insetEnd ?? 0;
|
const iStart = band.insetStart ?? 0, iEnd = band.insetEnd ?? 0;
|
||||||
let t = iStart; const end = len - iEnd;
|
let t = iStart; const end = len - iEnd;
|
||||||
@ -237,11 +241,14 @@ export function generatePlan(citySeed) {
|
|||||||
const dMarket = addDistrict('market', eastEdge - marketW / 2, (zLo + zHi) / 2);
|
const dMarket = addDistrict('market', eastEdge - marketW / 2, (zLo + zHi) / 2);
|
||||||
const poly = [[eastEdge, zLo], [eastEdge, zHi], [eastEdge - marketW, zHi], [eastEdge - marketW, zLo]];
|
const poly = [[eastEdge, zLo], [eastEdge, zHi], [eastEdge - marketW, zHi], [eastEdge - marketW, zLo]];
|
||||||
const block = addBlock(dMarket, 'market', poly);
|
const block = addBlock(dMarket, 'market', poly);
|
||||||
// Facades face +x (EAST, toward the spine — the market sits west of it). ry is the OUTWARD normal
|
// Facades face +x (EAST, toward the spine — the market sits west of it). ROUND27 THE FACADE FIX: ry now
|
||||||
// (street→lot); a west-of-street lot's outward normal is −x, so ry = atan2(−1, 0) (= −π/2). The
|
// aims the VISIBLE facade (local +Z, B's canon) at the street, so a west-of-street lot wants +Z = +x ⇒
|
||||||
// facade then faces −(outward) = +x, matching marchStrip's convention. frontEdge is the spine
|
// ry = atan2(+1, 0) (= +π/2). It was atan2(−1, 0), which pointed +Z at −x — away from the spine, into
|
||||||
// segment each lot actually fronts by z-band: stalls (z≈−100..−23) front segment 2; dept (z=55) → 3.
|
// the market's back. Same one-sign error as marchStrip above, and the old comment described the
|
||||||
const ryEast = Math.atan2(-1, 0);
|
// intended result ("the facade then faces −(outward) = +x") that the code did not produce.
|
||||||
|
// frontEdge is the spine segment each lot actually fronts by z-band: stalls (z≈−100..−23) front
|
||||||
|
// segment 2; dept (z=55) → 3.
|
||||||
|
const ryEast = Math.atan2(1, 0);
|
||||||
const stallEdge = spineEdges[2]; // spine segment spanning z∈[−133,0] — the stalls' band
|
const stallEdge = spineEdges[2]; // spine segment spanning z∈[−133,0] — the stalls' band
|
||||||
const deptEdge = spineEdges[3]; // spine segment spanning z∈[0,133] — the dept's band
|
const deptEdge = spineEdges[3]; // spine segment spanning z∈[0,133] — the dept's band
|
||||||
const COLS = 5, ROWS = 8, pitch = 11, sSize = 4.5; // tidy rows in the southern ~2/3 of the square
|
const COLS = 5, ROWS = 8, pitch = 11, sSize = 4.5; // tidy rows in the southern ~2/3 of the square
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
// A future pass can honour exact projected positions; this lands a booting, invariants-green seam.
|
// A future pass can honour exact projected positions; this lands a booting, invariants-green seam.
|
||||||
|
|
||||||
import { rng, seedFor, mulberry32, pick, frange } from '../core/prng.js';
|
import { rng, seedFor, mulberry32, pick, frange } from '../core/prng.js';
|
||||||
import { SHOP_TYPES } from '../core/registry.js';
|
import { SHOP_TYPES, roadWidth } from '../core/registry.js';
|
||||||
import { OSM_TOWNS, DEFAULT_TOWN } from './osm_fixture.js';
|
import { OSM_TOWNS, DEFAULT_TOWN } from './osm_fixture.js';
|
||||||
import { lotCorners, obbOverlap } from './plan.js'; // shared geometry — overlap-resolve real-road lots
|
import { lotCorners, obbOverlap } from './plan.js'; // shared geometry — overlap-resolve real-road lots
|
||||||
|
|
||||||
@ -327,7 +327,11 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
|
|||||||
const half = AV_W / 2, sd = DEPTH + 2;
|
const half = AV_W / 2, sd = DEPTH + 2;
|
||||||
const block = addBlock(district, 'mainstreet',
|
const block = addBlock(district, 'mainstreet',
|
||||||
[[SPINE_CLEAR, zPos - half], [avLen, zPos - half], [avLen, zPos - half - sd], [SPINE_CLEAR, zPos - half - sd]]);
|
[[SPINE_CLEAR, zPos - half], [avLen, zPos - half], [avLen, zPos - half - sd], [SPINE_CLEAR, zPos - half - sd]]);
|
||||||
const ry = Math.atan2(0, -1); // facade faces +z (north) to the avenue
|
// ROUND27 THE FACADE FIX: the lots sit SOUTH of the avenue (z = zPos − half − DEPTH/2), so the
|
||||||
|
// facade must face +z (north) at it. `atan2(0,-1)` (= π) pointed local +Z at −z — due south, away.
|
||||||
|
// The comment was right; the code was 180° off. Measured, not assumed: the marched path was NOT
|
||||||
|
// clean (the ruling asked me to check rather than trust that nobody had noticed).
|
||||||
|
const ry = Math.atan2(0, 1); // facade (+Z) faces +z (north) to the avenue
|
||||||
let t = SPINE_CLEAR;
|
let t = SPINE_CLEAR;
|
||||||
rowShops.forEach((p, j) => { const f = fr[j]; addShop(addLot(block, t + f / 2, zPos - (half + DEPTH / 2), f, DEPTH, ry, avEdge, 'shop'), p.s); t += f + GAP; });
|
rowShops.forEach((p, j) => { const f = fr[j]; addShop(addLot(block, t + f / 2, zPos - (half + DEPTH / 2), f, DEPTH, ry, avEdge, 'shop'), p.s); t += f + GAP; });
|
||||||
});
|
});
|
||||||
@ -347,6 +351,7 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
|
|||||||
// shopfronts, not detached boxes. Drops 7.9% → 5.5% across the five; the residual stays COUNTED, and
|
// shopfronts, not detached boxes. Drops 7.9% → 5.5% across the five; the residual stays COUNTED, and
|
||||||
// dropping a crowded shop beats teleporting it onto a street it isn't on.
|
// dropping a crowded shop beats teleporting it onto a street it isn't on.
|
||||||
const EPS = 6, SNAP = 3, KERB = 4, NODE_CLEAR = 3, RGAP = 0.5; // fidelity + geometry knobs (metres)
|
const EPS = 6, SNAP = 3, KERB = 4, NODE_CLEAR = 3, RGAP = 0.5; // fidelity + geometry knobs (metres)
|
||||||
|
const FOOTPATH = 1.5; // ROUND27: kerb→shopfront. KERB alone is centreline-relative and sits INSIDE a main carriageway.
|
||||||
const JOIN_TOL = 12, BRIDGE_MAX = 200, SEAT_MAX = 200; // ROUND19 fragmentation ruling (metres)
|
const JOIN_TOL = 12, BRIDGE_MAX = 200, SEAT_MAX = 200; // ROUND19 fragmentation ruling (metres)
|
||||||
const WKIND = { main: SPINE_W, side: AV_W, lane: 8, arcade: 6 };
|
const WKIND = { main: SPINE_W, side: AV_W, lane: 8, arcade: 6 };
|
||||||
// every dropped shop is counted (Fable's ROUND19 ruling) — and counted BY CAUSE, so the density
|
// every dropped shop is counted (Fable's ROUND19 ruling) — and counted BY CAUSE, so the density
|
||||||
@ -440,8 +445,18 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
|
|||||||
for (const { e, side, list } of perEdge.values()) {
|
for (const { e, side, list } of perEdge.values()) {
|
||||||
list.sort((p, q) => p.t - q.t || p.s.id - q.s.id);
|
list.sort((p, q) => p.t - q.t || p.s.id - q.s.id);
|
||||||
const dx = e.b.x - e.a.x, dz = e.b.z - e.a.z, len = Math.hypot(dx, dz) || 1, ux = dx / len, uz = dz / len;
|
const dx = e.b.x - e.a.x, dz = e.b.z - e.a.z, len = Math.hypot(dx, dz) || 1, ux = dx / len, uz = dz / len;
|
||||||
const nx = side * -uz, nz = side * ux, ry = Math.atan2(nx, nz); // outward normal on the shop's real side; facade (−Z) faces road
|
// ROUND27 THE FACADE FIX (B's proven one-liner — B measured katoomba 0/72 → 72/72 and reverted it
|
||||||
const off = KERB, dep = DEPTH;
|
// because this is Lane A's file). (nx,nz) is the OUTWARD normal on the shop's real side; the facade
|
||||||
|
// (local +Z, B's canon) must face back down it, at the road — so atan2(−nx, −nz), not atan2(nx, nz).
|
||||||
|
const nx = side * -uz, nz = side * ux, ry = Math.atan2(-nx, -nz); // outward normal on the shop's real side; facade (+Z) faces road
|
||||||
|
// ROUND27, exposed BY the facade fix: `KERB` (4 m) is measured from the CENTRELINE, but a `main`
|
||||||
|
// street's carriageway is 10 m wide — its kerb is at 5 m. So every main-street building has stood
|
||||||
|
// 1 m INSIDE the road since R18. It was invisible while the facade faced away (nothing was drawn or
|
||||||
|
// asserted on that side); the moment the shopfront turned around, its poster landed in the traffic
|
||||||
|
// lane (−1.07 m). The building never moved — only which face is the front. Seat blocks behind the
|
||||||
|
// kerb per A's own corridor law (`roadWidth/2` IS the kerb), plus a footpath to stand on.
|
||||||
|
const pe = edges.find(x => x.id === e.id);
|
||||||
|
const off = Math.max(KERB, roadWidth(pe) / 2 + FOOTPATH), dep = DEPTH;
|
||||||
const block = addBlock(district, 'mainstreet', [
|
const block = addBlock(district, 'mainstreet', [
|
||||||
[e.a.x + nx * off, e.a.z + nz * off], [e.b.x + nx * off, e.b.z + nz * off],
|
[e.a.x + nx * off, e.a.z + nz * off], [e.b.x + nx * off, e.b.z + nz * off],
|
||||||
[e.b.x + nx * (off + dep), e.b.z + nz * (off + dep)], [e.a.x + nx * (off + dep), e.a.z + nz * (off + dep)]]);
|
[e.b.x + nx * (off + dep), e.b.z + nz * (off + dep)], [e.a.x + nx * (off + dep), e.a.z + nz * (off + dep)]]);
|
||||||
|
|||||||
@ -25,8 +25,23 @@ const section = s => console.log('\n' + s);
|
|||||||
const SEEDS = [20261990, 1, 42, 777, 2000000000, 8675309];
|
const SEEDS = [20261990, 1, 42, 777, 2000000000, 8675309];
|
||||||
const SOLID = new Set(['shop', 'anchor', 'house', 'stall']); // lots realised as building shells by Lane B
|
const SOLID = new Set(['shop', 'anchor', 'house', 'stall']); // lots realised as building shells by Lane B
|
||||||
|
|
||||||
// A lot's outward facade normal from its ry (GLB faces -Z at ry=0 ⇒ world facing = (-sin ry,-cos ry)).
|
// ROUND27 THE FACADE FIX — B's canon is canonical: the VISIBLE facade is local +Z ⇒ world (sin ry, cos ry).
|
||||||
const facing = l => [-Math.sin(l.ry), -Math.cos(l.ry)];
|
// This helper used to return (−sin, −cos), the SIM/rig convention that CITY_SPEC:71 mis-wrote into the lots
|
||||||
|
// contract. That is why nine rounds of goldens were green over a world where every shopfront faced away.
|
||||||
|
const facing = l => [Math.sin(l.ry), Math.cos(l.ry)];
|
||||||
|
|
||||||
|
// THE CROSS-CONVENTION GATE (ROUND27 wave 5 — the lesson made law).
|
||||||
|
// F proved the trap: measuring lot.ry against CITY_SPEC:71's own convention is SELF-AGREEMENT — it returned
|
||||||
|
// 72/72 "facing" over a town of blank walls, because a self-consistent check over a self-contradicting spec
|
||||||
|
// passes under either convention. The only check that can see the contradiction compares the two conventions
|
||||||
|
// AGAINST EACH OTHER. So this does not restate a spec line: it replicates LANE B's OWN placement maths from
|
||||||
|
// buildings.js (`toWorld(lot, 0, y, zf)` with `zf = d/2 + 0.02`) and asks a physical question the prose
|
||||||
|
// cannot fudge — does the quad the RENDERER actually draws land nearer the street than the lot centre?
|
||||||
|
// If either side drifts (A's ry sign, or B's zf sign), this fires.
|
||||||
|
const facadeQuadWorld = l => { // buildings.js: toWorld(lot, 0, y, d/2 + 0.02)
|
||||||
|
const cos = Math.cos(l.ry || 0), sin = Math.sin(l.ry || 0), zf = l.d / 2 + 0.02;
|
||||||
|
return [l.x + zf * sin, l.z + zf * cos];
|
||||||
|
};
|
||||||
// nearest point on segment AB to point P
|
// nearest point on segment AB to point P
|
||||||
function nearestOnSeg(px, pz, ax, az, bx, bz) {
|
function nearestOnSeg(px, pz, ax, az, bx, bz) {
|
||||||
const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz || 1;
|
const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz || 1;
|
||||||
@ -70,13 +85,24 @@ function structuralSuite(plan, label) {
|
|||||||
ok(Object.values(lotShopCounts).every(c => c === 1), `${label}: no lot has two shops`);
|
ok(Object.values(lotShopCounts).every(c => c === 1), `${label}: no lot has two shops`);
|
||||||
ok(plan.shops.every(sh => SOLID.has(plan.lots[sh.lot].use)), `${label}: every shop sits on a solid lot`);
|
ok(plan.shops.every(sh => SOLID.has(plan.lots[sh.lot].use)), `${label}: every shop sits on a solid lot`);
|
||||||
|
|
||||||
let backwards = null;
|
let backwards = null, drawnAway = null, worstGain = 0;
|
||||||
for (const l of plan.lots) {
|
for (const l of plan.lots) {
|
||||||
const e = plan.streets.edges.find(x => x.id === l.frontEdge); const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
|
const e = plan.streets.edges.find(x => x.id === l.frontEdge); const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
|
||||||
const [nx, nz] = nearestOnSeg(l.x, l.z, a.x, a.z, b.x, b.z); const [fx, fz] = facing(l);
|
const [nx, nz] = nearestOnSeg(l.x, l.z, a.x, a.z, b.x, b.z); const [fx, fz] = facing(l);
|
||||||
if ((nx - l.x) * fx + (nz - l.z) * fz < -0.01) { backwards = l.id; break; }
|
if ((nx - l.x) * fx + (nz - l.z) * fz < -0.01) { if (backwards === null) backwards = l.id; }
|
||||||
|
|
||||||
|
// THE CROSS-CONVENTION GATE: not "does ry match the spec's wording" (self-agreement — that is exactly
|
||||||
|
// what stayed green over blank walls for nine rounds), but "does the quad LANE B ACTUALLY DRAWS land
|
||||||
|
// nearer the street than the lot centre?". Physical, falsifiable, and blind to both spec lines.
|
||||||
|
const [qx, qz] = facadeQuadWorld(l);
|
||||||
|
const dCentre = Math.hypot(nx - l.x, nz - l.z);
|
||||||
|
const dFacade = Math.hypot(nx - qx, nz - qz);
|
||||||
|
if (dFacade >= dCentre) { if (drawnAway === null) drawnAway = l.id; }
|
||||||
|
worstGain = Math.max(worstGain, dFacade - dCentre);
|
||||||
}
|
}
|
||||||
ok(backwards === null, `${label}: every lot faces its frontEdge` + (backwards !== null ? ` (lot ${backwards} backwards)` : ''));
|
ok(backwards === null, `${label}: every lot faces its frontEdge` + (backwards !== null ? ` (lot ${backwards} backwards)` : ''));
|
||||||
|
ok(drawnAway === null, `${label}: the RENDERED facade (B's +Z quad, buildings.js maths) faces the street` +
|
||||||
|
(drawnAway !== null ? ` — lot ${drawnAway}'s shopfront is drawn on the far side (worst: ${worstGain.toFixed(2)} m FARTHER from the road than the lot centre)` : ''));
|
||||||
|
|
||||||
const byBlock = {};
|
const byBlock = {};
|
||||||
for (const l of plan.lots) (byBlock[l.block] ||= []).push(l);
|
for (const l of plan.lots) (byBlock[l.block] ||= []).push(l);
|
||||||
@ -238,7 +264,7 @@ for (const s of SEEDS) {
|
|||||||
// ── 1b. golden fingerprint: guards against silent output DRIFT across code revisions ────
|
// ── 1b. golden fingerprint: guards against silent output DRIFT across code revisions ────
|
||||||
// (If you intentionally change generator output, run selfcheck, copy the printed hash here.)
|
// (If you intentionally change generator output, run selfcheck, copy the printed hash here.)
|
||||||
section('golden fingerprint (output-drift guard)');
|
section('golden fingerprint (output-drift guard)');
|
||||||
const GOLDEN = { seed: 20261990, hash: 0x3fa36874 };
|
const GOLDEN = { seed: 20261990, hash: 0x5f76e76 };
|
||||||
{
|
{
|
||||||
const hash = xmur3(JSON.stringify(generatePlan(GOLDEN.seed)))() >>> 0;
|
const hash = xmur3(JSON.stringify(generatePlan(GOLDEN.seed)))() >>> 0;
|
||||||
ok(hash === GOLDEN.hash, `seed ${GOLDEN.seed}: fingerprint 0x${hash.toString(16)} matches golden 0x${(GOLDEN.hash >>> 0).toString(16)}`);
|
ok(hash === GOLDEN.hash, `seed ${GOLDEN.seed}: fingerprint 0x${hash.toString(16)} matches golden 0x${(GOLDEN.hash >>> 0).toString(16)}`);
|
||||||
@ -274,7 +300,7 @@ for (const s of SEEDS) {
|
|||||||
// (round 8) Real-data towns get the same coverage as synthetic; only the synthetic-brief checks are
|
// (round 8) Real-data towns get the same coverage as synthetic; only the synthetic-brief checks are
|
||||||
// skipped. Goldens are pinned per town; the synthetic golden above stays 0x3fa36874.
|
// skipped. Goldens are pinned per town; the synthetic golden above stays 0x3fa36874.
|
||||||
section('OSM plan source parity (fixture-driven, zero-network)');
|
section('OSM plan source parity (fixture-driven, zero-network)');
|
||||||
const OSM_GOLDENS = { melbourne: 0x34cfdec0, katoomba: 0x0f652510, silverton: 0xd4b351c9 };
|
const OSM_GOLDENS = { melbourne: 0x9c7e76b3, katoomba: 0xf3aafec8, silverton: 0x6d74c4 };
|
||||||
const OSM_SEEDS = [20261990, 1, 42, 777];
|
const OSM_SEEDS = [20261990, 1, 42, 777];
|
||||||
for (const town of osmTownKeys()) {
|
for (const town of osmTownKeys()) {
|
||||||
const report = {};
|
const report = {};
|
||||||
@ -310,7 +336,7 @@ ok(JSON.stringify(generatePlanFor(42, 'synthetic', { gigs: true })) === JSON.str
|
|||||||
ok(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true, customBands: custom })) === JSON.stringify(pc), `custom bands: deterministic given (seed, list)`);
|
ok(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true, customBands: custom })) === JSON.stringify(pc), `custom bands: deterministic given (seed, list)`);
|
||||||
}
|
}
|
||||||
// gig-layer golden (guards gig-layer output drift; pure-generator path, no custom file)
|
// gig-layer golden (guards gig-layer output drift; pure-generator path, no custom file)
|
||||||
const GIG_GOLDEN = 0xb1d48ea1; // ROUND16 re-pin: pickVenues prefers band_room/rsl lots whose +Z facade clears crossing kerbs (ledger #6). Was 0x4f4a549d (R15 +Z flip), 0x1f636349 (R13 district), 0xa6ae5a5e (R12 alpha).
|
const GIG_GOLDEN = 0xec7a2d39; // ROUND16 re-pin: pickVenues prefers band_room/rsl lots whose +Z facade clears crossing kerbs (ledger #6). Was 0x4f4a549d (R15 +Z flip), 0x1f636349 (R13 district), 0xa6ae5a5e (R12 alpha).
|
||||||
{
|
{
|
||||||
const hash = xmur3(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true })))() >>> 0;
|
const hash = xmur3(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true })))() >>> 0;
|
||||||
if (GIG_GOLDEN === 0) console.log(` ⚠ gig golden UNPINNED → set GIG_GOLDEN = 0x${hash.toString(16).padStart(8, '0')}`);
|
if (GIG_GOLDEN === 0) console.log(` ⚠ gig golden UNPINNED → set GIG_GOLDEN = 0x${hash.toString(16).padStart(8, '0')}`);
|
||||||
@ -571,34 +597,34 @@ const TOWNS_DIR = join(HERE, '..', '..', 'assets', 'towns');
|
|||||||
// the fragmentation ruling (junction-protected DP + cull/bridge) + E's spine-poster fix. Amendment law:
|
// the fragmentation ruling (junction-protected DP + cull/bridge) + E's spine-poster fix. Amendment law:
|
||||||
// the alpha changed nothing outside the cache-schema path; synthetic/fixtures/classic/default stay frozen.
|
// the alpha changed nothing outside the cache-schema path; synthetic/fixtures/classic/default stay frozen.
|
||||||
const REAL_TOWN_GOLDENS = {
|
const REAL_TOWN_GOLDENS = {
|
||||||
adelaide_real: 0x5c790920,
|
adelaide_real: 0x4b9137a5,
|
||||||
ballarat_real: 0xf0a9e8a1,
|
ballarat_real: 0xf0a9e8a1,
|
||||||
bendigo_real: 0xc175194e,
|
bendigo_real: 0x44f5ef2d,
|
||||||
bowral_real: 0xb23ee067,
|
bowral_real: 0x5574436b,
|
||||||
braddon_real: 0x2f388775,
|
braddon_real: 0xf03da36c,
|
||||||
brunswick_real: 0x7ecb5c3e,
|
brunswick_real: 0xb548149e,
|
||||||
castlemaine_real: 0x26c128c9,
|
castlemaine_real: 0xf3118387,
|
||||||
darwin_real: 0xcfb1917f,
|
darwin_real: 0xd45d7042,
|
||||||
daylesford_real: 0x4ec7352c,
|
daylesford_real: 0x96a92068,
|
||||||
fitzroy_real: 0x6549f61b,
|
fitzroy_real: 0x1cb08059,
|
||||||
fremantle_real: 0x73d385df,
|
fremantle_real: 0x17954f0a,
|
||||||
geelong_real: 0x2f65591e,
|
geelong_real: 0xf90f4463,
|
||||||
glebe_real: 0x908e1835,
|
glebe_real: 0xa9418848,
|
||||||
hobart_real: 0xa96328b9,
|
hobart_real: 0xbcee1ccb,
|
||||||
katoomba_real: 0x420f677d,
|
katoomba_real: 0x126a66cb,
|
||||||
launceston_real: 0x32b1ab7b,
|
launceston_real: 0xa2bb4e71,
|
||||||
marrickville_real: 0x60148e37,
|
marrickville_real: 0x8c8d39aa,
|
||||||
newcastle_real: 0xbb2c3050,
|
newcastle_real: 0x61de3375,
|
||||||
newtown_godverse: 0xcf69b387,
|
newtown_godverse: 0x1e8d49b9,
|
||||||
newtown_real: 0x741c18ec,
|
newtown_real: 0x64a98d7e,
|
||||||
northbridge_real: 0x1a645848,
|
northbridge_real: 0xb0c24e12,
|
||||||
// toowoomba_real RETIRED by E in R23 (cache deleted) — pin removed with it. A golden for a town that
|
// toowoomba_real RETIRED by E in R23 (cache deleted) — pin removed with it. A golden for a town that
|
||||||
// cannot load is dead config: it can never fire, so it can never fail. Same species as the round's
|
// cannot load is dead config: it can never fire, so it can never fail. Same species as the round's
|
||||||
// vacuous-gate law, one layer down. (E tried the re-bbox first and it GAMED my spacing metric — median
|
// vacuous-gate law, one layer down. (E tried the re-bbox first and it GAMED my spacing metric — median
|
||||||
// NN 395.7 → 89.4 m "passes" while hub density fell 3/12 → 2/12, the pack's worst. Right call.)
|
// NN 395.7 → 89.4 m "passes" while hub density fell 3/12 → 2/12, the pack's worst. Right call.)
|
||||||
redhill_godverse: 0xb2224939,
|
redhill_godverse: 0x40d30a31,
|
||||||
redhill_real: 0x6046700f,
|
redhill_real: 0x5b851696,
|
||||||
westend_real: 0x5b1e5ffe,
|
westend_real: 0x6032d73a,
|
||||||
};
|
};
|
||||||
// ROUND20: the GIG-layer golden per real town (district + capped posters) — the base golden above is
|
// ROUND20: the GIG-layer golden per real town (district + capped posters) — the base golden above is
|
||||||
// gigs-off so it never captured the poster cap. Pinned here so the ROUND20 poster cap (and the density
|
// gigs-off so it never captured the poster cap. Pinned here so the ROUND20 poster cap (and the density
|
||||||
@ -606,31 +632,31 @@ const REAL_TOWN_GOLDENS = {
|
|||||||
// ROUND21: re-pinned for the venue cluster-adjacency bias (D's relocation finding). Only the GIG goldens
|
// ROUND21: re-pinned for the venue cluster-adjacency bias (D's relocation finding). Only the GIG goldens
|
||||||
// move — venue selection is gig-layer, so the base town goldens above are untouched.
|
// move — venue selection is gig-layer, so the base town goldens above are untouched.
|
||||||
const REAL_TOWN_GIG_GOLDENS = {
|
const REAL_TOWN_GIG_GOLDENS = {
|
||||||
adelaide_real: 0x24ad3c27,
|
adelaide_real: 0xda729bae,
|
||||||
ballarat_real: 0xf097c331,
|
ballarat_real: 0xf097c331,
|
||||||
bendigo_real: 0x646b2c23,
|
bendigo_real: 0xe3e8d892,
|
||||||
bowral_real: 0xac809f0b,
|
bowral_real: 0x3f37bbcd,
|
||||||
braddon_real: 0xc3e5cfc0,
|
braddon_real: 0xbc51aed2,
|
||||||
brunswick_real: 0x8c08914e,
|
brunswick_real: 0xc0a90188,
|
||||||
castlemaine_real: 0xf9713a69,
|
castlemaine_real: 0x3c3cf400,
|
||||||
darwin_real: 0xbc5605b5,
|
darwin_real: 0xd37b37cd,
|
||||||
daylesford_real: 0x2fb34c8a,
|
daylesford_real: 0x7b24c5c5,
|
||||||
fitzroy_real: 0x690e5ef9,
|
fitzroy_real: 0xcd31b1ab,
|
||||||
fremantle_real: 0xcf3426fc,
|
fremantle_real: 0xc6449dc,
|
||||||
geelong_real: 0x6f47ec2f,
|
geelong_real: 0xcd0ceac4,
|
||||||
glebe_real: 0x042f01fb,
|
glebe_real: 0x8d2b12ca,
|
||||||
hobart_real: 0x22f80d7b,
|
hobart_real: 0xda3211c9,
|
||||||
katoomba_real: 0xbf586b77,
|
katoomba_real: 0x5eece2d4,
|
||||||
launceston_real: 0xeaf7ff4d,
|
launceston_real: 0xb619b65b,
|
||||||
marrickville_real: 0xea21be51,
|
marrickville_real: 0x5dc00b5f,
|
||||||
newcastle_real: 0x66fc2bf9,
|
newcastle_real: 0xfc3cb144,
|
||||||
newtown_godverse: 0x1e266f60,
|
newtown_godverse: 0x4153dee2,
|
||||||
newtown_real: 0xacdb3eee,
|
newtown_real: 0xb6a75bb1,
|
||||||
northbridge_real: 0x83a1d5be,
|
northbridge_real: 0x49fe0f2b,
|
||||||
// toowoomba_real RETIRED by E in R23 — see the note on the base map above.
|
// toowoomba_real RETIRED by E in R23 — see the note on the base map above.
|
||||||
redhill_godverse: 0xc6f80e18,
|
redhill_godverse: 0xb53f695d,
|
||||||
redhill_real: 0x673785ee,
|
redhill_real: 0x21db6d1,
|
||||||
westend_real: 0xb2c7ddb6,
|
westend_real: 0xac9e69ea,
|
||||||
};
|
};
|
||||||
// `index.json` is E's towns INDEX (key/town/state/shops/roads for B's selector), not a town cache — skip it.
|
// `index.json` is E's towns INDEX (key/town/state/shops/roads for B's selector), not a town cache — skip it.
|
||||||
const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json') && f !== 'index.json');
|
const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json') && f !== 'index.json');
|
||||||
|
|||||||
58
web/js/interiors/bible.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
// PROCITY Lane C — bible.js [v7.0-beta wave 1, R32 — THE GUIDE + THE GEM (charter system #3)]
|
||||||
|
// The in-game price guide: thriftgod's BIBLE concept as a VALUE BAND per item. The band is the
|
||||||
|
// 90s guide book, not an oracle — it names a range ("guide $25–59"), never a verdict. A **gem**
|
||||||
|
// is an item ASKING under its band's low edge; spotting one is the player's skill, so nothing in
|
||||||
|
// any UI ever says "gem".
|
||||||
|
//
|
||||||
|
// THE DERIVATION RULING (R32): band NUMBERS mirror Lane E's pipeline thresholds
|
||||||
|
// (pipeline/build_stock_pack.py price_band(): record 8/25/60 · book 5/15/40 · toy 6/20/50 —
|
||||||
|
// duplicated verbatim in godverse_stock.py, validated by validate_pack.py). We derive ranges in
|
||||||
|
// JS from the band LABEL the packs already carry instead of re-baking band data into atlases —
|
||||||
|
// so charter risk #3 (a re-emitted pack orphaning owned skus) never opens. If E's thresholds
|
||||||
|
// move, this table moves with them in the same commit (the two files name each other).
|
||||||
|
//
|
||||||
|
// THE STRUCTURAL CONSEQUENCE (why the no-pump law survives the beta, LANE_C_PUB §9.1):
|
||||||
|
// real/mint pack items derive `price_band` FROM `price`, so real asking always sits INSIDE its
|
||||||
|
// band ⇒ bandLow ≤ asking ⇒ sellOffer(bandLow) ≤ bandLow − 1 < asking: every real round trip
|
||||||
|
// stays strictly negative with the basis swapped. Only PARODY stock (proceduralSleeves) rolls a
|
||||||
|
// seeded under-band gem — and per-day scarcity (the save's `pulls`) makes each gem profit ONCE.
|
||||||
|
// Growth is bounded by finds, not arbitrage loops — charter law #2, made structural.
|
||||||
|
//
|
||||||
|
// Zero DOM, zero draws, zero fetch, zero storage: a pure table + pure functions. ?classic=1 /
|
||||||
|
// ?dig=0 never import a caller, and importing this module alone has no effect at all.
|
||||||
|
|
||||||
|
// kind → band → [low, high]; `high: null` = open-ended (the card renders "$60+").
|
||||||
|
// Lows/highs are E's threshold edges: a band [t0, t1) renders as $t0–(t1−1).
|
||||||
|
export const BANDS = {
|
||||||
|
record: { bargain: [2, 7], standard: [8, 24], collector: [25, 59], grail: [60, null] },
|
||||||
|
book: { bargain: [1, 4], standard: [5, 14], collector: [15, 39], grail: [40, null] },
|
||||||
|
toy: { bargain: [2, 5], standard: [6, 19], collector: [20, 49], grail: [50, null] },
|
||||||
|
};
|
||||||
|
export const BAND_ORDER = ['bargain', 'standard', 'collector', 'grail'];
|
||||||
|
|
||||||
|
// bandRange('record','collector') → [25, 59] | null on unknown kind/band (fail-closed: no range,
|
||||||
|
// no guide line, no basis swap — never an invented number).
|
||||||
|
export function bandRange(kind, band) {
|
||||||
|
const k = BANDS[kind];
|
||||||
|
const r = k && k[String(band || '').toLowerCase()];
|
||||||
|
return r ? [r[0], r[1]] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The guide line for a card stamp — "guide $25–59" / "guide $60+" / '' when unknowable.
|
||||||
|
export function guideText(kind, band) {
|
||||||
|
const r = bandRange(kind, band);
|
||||||
|
if (!r) return '';
|
||||||
|
return r[1] == null ? `guide $${r[0]}+` : `guide $${r[0]}–${r[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sell basis for a collection entry (LANE_C_PUB §9.1, the pre-ratified beta swap): bandLow
|
||||||
|
// when the entry carries a band the BIBLE knows, else the alpha basis (pricePaid) — so every
|
||||||
|
// alpha-era find (no band field) sells exactly as it did before this round. The sellOffer()
|
||||||
|
// CLAMP SHAPE is sell.js's and survives untouched; this function only chooses the number fed in.
|
||||||
|
export function sellBasis(entry) {
|
||||||
|
if (entry && entry.band) {
|
||||||
|
const r = bandRange(entry.type, entry.band);
|
||||||
|
if (r) return r[0];
|
||||||
|
}
|
||||||
|
return entry ? (entry.pricePaid != null ? entry.pricePaid : entry.price) : 0;
|
||||||
|
}
|
||||||
@ -10,34 +10,48 @@
|
|||||||
// runs unless the shell/test-page opens it, so `?dig=0` is byte-identical to v1.
|
// runs unless the shell/test-page opens it, so `?dig=0` is byte-identical to v1.
|
||||||
|
|
||||||
import { mulberry32, xmur3 } from '../core/prng.js';
|
import { mulberry32, xmur3 } from '../core/prng.js';
|
||||||
|
import { BANDS, guideText } from './bible.js'; // [R32] THE BIBLE — band ranges + the card's guide stamp
|
||||||
|
|
||||||
const MAXFLIP = 1.95, BACK = -0.12;
|
const MAXFLIP = 1.95, BACK = -0.12;
|
||||||
const SLEEVE = [0.31, 0.31, 0.0060]; // w, h, thickness (packed a touch chunkier than vinyl)
|
const SLEEVE = [0.31, 0.31, 0.0060]; // w, h, thickness (packed a touch chunkier than vinyl)
|
||||||
// 90s-Australian crate genres → sleeve card colour.
|
// 90s-Australian crate genres → sleeve card colour.
|
||||||
const GENRE = [['ROCK', '#7a2a3a'], ['SOUL', '#b0894a'], ['JAZZ', '#2a4a6a'], ['DISCO', '#8a3a7a'],
|
const GENRE = [['ROCK', '#7a2a3a'], ['SOUL', '#b0894a'], ['JAZZ', '#2a4a6a'], ['DISCO', '#8a3a7a'],
|
||||||
['COUNTRY', '#6a5a2a'], ['PUNK', '#3a3a3a'], ['REGGAE', '#2a6a4a'], ['POP', '#b05a8a']];
|
['COUNTRY', '#6a5a2a'], ['PUNK', '#3a3a3a'], ['REGGAE', '#2a6a4a'], ['POP', '#b05a8a']];
|
||||||
const ARTISTS = ['THE VERANDAHS', 'DEZ & THE UTES', 'MERINO', 'HILLS HOIST', 'THE ESKIES', 'GOON BAG',
|
// [R35] exported: the shell seeds a fresh game's FIRST WANT from this roster (THE FIRST FIVE MINUTES).
|
||||||
'STOBIE POLES', 'THE FIBROS', 'WARATAH', 'THONGS', 'THE CLOTHESLINE', 'GALAH'];
|
export const ARTISTS = ['THE VERANDAHS', 'DEZ & THE UTES', 'MERINO', 'HILLS HOIST', 'THE ESKIES', 'GOON BAG',
|
||||||
|
'STOBIE POLES', 'THE FIBROS', 'WARATAH', 'THONGS', 'THE CLOTHESLINE', 'GALAH'];
|
||||||
const TITLES = ['Sunburnt', 'Back o’ Bourke', 'Servo at Midnight', 'Long Weekend', 'Tinny',
|
const TITLES = ['Sunburnt', 'Back o’ Bourke', 'Servo at Midnight', 'Long Weekend', 'Tinny',
|
||||||
'Southerly Buster', 'Milk Bar Dreams', 'Cane Toad Blues', 'Ute Muster', 'Arvo'];
|
'Southerly Buster', 'Milk Bar Dreams', 'Cane Toad Blues', 'Ute Muster', 'Arvo'];
|
||||||
const CONDS = ['VG+', 'VG', 'G+', 'NM', 'EX'];
|
const CONDS = ['VG+', 'VG', 'G+', 'NM', 'EX'];
|
||||||
|
|
||||||
// Deterministic sleeve list for a bin — the "existing procedural stock" as riffle-able offers.
|
// Deterministic sleeve list for a bin — the "existing procedural stock" as riffle-able offers.
|
||||||
|
// [R32 — THE BIBLE + THE GEM] Every parody sleeve now rolls a seeded BAND (the guide's range) and an
|
||||||
|
// ASKING price with seeded variance: non-gems price INSIDE their band; a 10% gem roll (collector/grail
|
||||||
|
// only — an under-band bargain is not a thrill) prices UNDER the band's low edge, which is the entire
|
||||||
|
// op-shop fantasy made mechanical. Draw COUNT is fixed per item (9 rolls, outcome-independent) so the
|
||||||
|
// stream stays deterministic and re-orderable never. `rare` (the ★) now means grail, matching the real
|
||||||
|
// packs — the card never says "gem"; spotting the asking-under-guide gap is the player's skill.
|
||||||
|
const PARODY_BAND = (roll) => (roll < 0.25 ? 'bargain' : roll < 0.70 ? 'standard' : roll < 0.92 ? 'collector' : 'grail');
|
||||||
|
const GRAIL_ASK_HI = 120; // parody grail asking ceiling (band renders "$60+")
|
||||||
function proceduralSleeves(seed, n) {
|
function proceduralSleeves(seed, n) {
|
||||||
const r = mulberry32(seed >>> 0);
|
const r = mulberry32(seed >>> 0);
|
||||||
const out = [];
|
const out = [];
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const g = GENRE[(r() * GENRE.length) | 0];
|
const g = GENRE[(r() * GENRE.length) | 0];
|
||||||
out.push({
|
const a = ARTISTS[(r() * ARTISTS.length) | 0];
|
||||||
i, kind: 'record',
|
const t = TITLES[(r() * TITLES.length) | 0];
|
||||||
a: ARTISTS[(r() * ARTISTS.length) | 0],
|
const y = 1971 + ((r() * 28) | 0);
|
||||||
t: TITLES[(r() * TITLES.length) | 0],
|
const cond = CONDS[(r() * CONDS.length) | 0];
|
||||||
y: 1971 + ((r() * 28) | 0),
|
const band = PARODY_BAND(r());
|
||||||
s: g[0], color: g[1],
|
const [lo, hi] = BANDS.record[band];
|
||||||
cond: CONDS[(r() * CONDS.length) | 0],
|
const priceRoll = r(), gemRoll = r(), gemPriceRoll = r(); // always drawn — fixed stream shape
|
||||||
price: 2 + ((r() * 28) | 0),
|
const gem = gemRoll < 0.10 && (band === 'collector' || band === 'grail');
|
||||||
rare: r() < 0.12,
|
// gem asking sits STRICTLY under the keeper's own bandLow offer (min(lo−1, floor(lo/2)) — sell.js),
|
||||||
});
|
// so a spotted gem always nets margin: collector asks $2–9 vs offer $12 · grail $5–20 vs $30.
|
||||||
|
const price = gem
|
||||||
|
? (band === 'grail' ? 5 + ((gemPriceRoll * 16) | 0) : 2 + ((gemPriceRoll * 8) | 0))
|
||||||
|
: lo + ((priceRoll * ((hi == null ? GRAIL_ASK_HI : hi) - lo + 1)) | 0);
|
||||||
|
out.push({ i, kind: 'record', a, t, y, s: g[0], color: g[1], cond, price, band, rare: band === 'grail' });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@ -103,6 +117,8 @@ export function createDig(THREE, renderer) {
|
|||||||
|
|
||||||
let recs = [], cursor = 0, vel = 0, active = false, pulled = null, lastFloor = -1;
|
let recs = [], cursor = 0, vel = 0, active = false, pulled = null, lastFloor = -1;
|
||||||
let crate = null, offers = [], onCloseCb = null, onBuyCb = null, getCashCb = null;
|
let crate = null, offers = [], onCloseCb = null, onBuyCb = null, getCashCb = null;
|
||||||
|
let onWantCb = null, isWantedCb = null; // [R33] the wantlist seams — absent (?game=0/classic) ⇒ no WANT DOM at all
|
||||||
|
let emitters = null; // [R28] room.audio.emitters, per open — where the till sounds from. Optional.
|
||||||
// shared across opens (persistent): sleeves are all the same box; sides + crate timber never vary
|
// shared across opens (persistent): sleeves are all the same box; sides + crate timber never vary
|
||||||
const sleeveGeo = trackGeo(new THREE.BoxGeometry(SLEEVE[0], SLEEVE[1], SLEEVE[2]));
|
const sleeveGeo = trackGeo(new THREE.BoxGeometry(SLEEVE[0], SLEEVE[1], SLEEVE[2]));
|
||||||
const sideMat = trackMat(new THREE.MeshStandardMaterial({ color: 0x8a8070, roughness: 0.85 }));
|
const sideMat = trackMat(new THREE.MeshStandardMaterial({ color: 0x8a8070, roughness: 0.85 }));
|
||||||
@ -127,6 +143,9 @@ export function createDig(THREE, renderer) {
|
|||||||
.pcdg-panel button{margin-top:13px;width:100%;padding:9px;border:1px solid #8a7f66;background:#e7dcc2;color:#241f18;font:700 13px "Courier New",monospace;letter-spacing:.06em;cursor:pointer}
|
.pcdg-panel button{margin-top:13px;width:100%;padding:9px;border:1px solid #8a7f66;background:#e7dcc2;color:#241f18;font:700 13px "Courier New",monospace;letter-spacing:.06em;cursor:pointer}
|
||||||
.pcdg-panel button:hover:not(:disabled){background:#241f18;color:#f4e11a;border-color:#241f18}
|
.pcdg-panel button:hover:not(:disabled){background:#241f18;color:#f4e11a;border-color:#241f18}
|
||||||
.pcdg-panel button:disabled{color:#9a917c;border-color:#c3b99f;cursor:default}
|
.pcdg-panel button:disabled{color:#9a917c;border-color:#c3b99f;cursor:default}
|
||||||
|
.pcdg-panel .want{margin-top:7px;text-align:center;font:700 11px "Courier New",monospace;letter-spacing:.08em;color:#8a7f66;cursor:pointer;user-select:none}
|
||||||
|
.pcdg-panel .want:hover{color:#241f18}
|
||||||
|
.pcdg-panel .want.on{color:#b3121f;cursor:default}
|
||||||
.pcdg-panel .x{position:absolute;top:4px;left:8px;cursor:pointer;color:#a89e86;font-size:13px}
|
.pcdg-panel .x{position:absolute;top:4px;left:8px;cursor:pointer;color:#a89e86;font-size:13px}
|
||||||
.pcdg-out{position:fixed;left:16px;top:16px;background:#1d1d2b;border:1px solid #666;color:#d8d8e0;padding:8px 14px;font:13px "Courier New",monospace;cursor:pointer;display:none;z-index:60}
|
.pcdg-out{position:fixed;left:16px;top:16px;background:#1d1d2b;border:1px solid #666;color:#d8d8e0;padding:8px 14px;font:13px "Courier New",monospace;cursor:pointer;display:none;z-index:60}
|
||||||
.pcdg-out:hover{border-color:#3dff8b;color:#3dff8b}
|
.pcdg-out:hover{border-color:#3dff8b;color:#3dff8b}
|
||||||
@ -141,8 +160,29 @@ export function createDig(THREE, renderer) {
|
|||||||
document.body.append(hint, curLbl, panel, outBtn, toast);
|
document.body.append(hint, curLbl, panel, outBtn, toast);
|
||||||
|
|
||||||
// ── procedural audio ──
|
// ── procedural audio ──
|
||||||
|
// [R28] HOUSE AUDIO LAW (audio.js, Lane B): (1) ONE AudioContext; (2) ?mute=1 forces silence. This
|
||||||
|
// file broke both from R5 to R28 — a second, private AudioContext wired straight to `destination`,
|
||||||
|
// so the riffle sang right through ?mute=1 and never saw B's master gain. It survived three epochs
|
||||||
|
// because NO gate asserts mute ⇒ silence: the engine dutifully returns its silent surface, `?mute`
|
||||||
|
// looks honest from the engine's side, and the sound came from somewhere the engine never knew about.
|
||||||
|
// A gate that asks the engine whether it's muted can only ever answer yes. -> F: assert SILENCE, not
|
||||||
|
// muted-state (there is no second AudioContext ⇔ the law holds).
|
||||||
|
// The blips STAY procedural on purpose: zero-fetch, deterministic, and they still work under
|
||||||
|
// ?noassets=1, where a sampled riffle cannot. Only mute was broken; fix only mute.
|
||||||
|
// Routing them through B's bus (so master gain applies) needs a ctx/bus seam this lane must not open
|
||||||
|
// unilaterally — published as the R28 ask in LANE_C_AUDIO.md §Emitters.
|
||||||
|
const MUTED_BY_URL = (() => {
|
||||||
|
try { const p = new URLSearchParams(location.search).get('mute'); return p != null && p !== '0'; }
|
||||||
|
catch { return false; }
|
||||||
|
})();
|
||||||
|
function muted() {
|
||||||
|
const eng = window.PROCITY && window.PROCITY.audio; // B's PUBLIC getter — consulted, not modified
|
||||||
|
if (eng && typeof eng.muted === 'boolean') return eng.muted;
|
||||||
|
return MUTED_BY_URL; // standalone interior_test: no engine to ask
|
||||||
|
}
|
||||||
let actx;
|
let actx;
|
||||||
function blip(kind) {
|
function blip(kind) {
|
||||||
|
if (muted()) return;
|
||||||
try {
|
try {
|
||||||
actx = actx || new (window.AudioContext || window.webkitAudioContext)();
|
actx = actx || new (window.AudioContext || window.webkitAudioContext)();
|
||||||
if (kind === 'fwip') {
|
if (kind === 'fwip') {
|
||||||
@ -228,24 +268,66 @@ export function createDig(THREE, renderer) {
|
|||||||
const line = [o.y, o.s].filter(Boolean).join(' '); // "1974 SOUL" | "SOUL" | "" — never " · "
|
const line = [o.y, o.s].filter(Boolean).join(' '); // "1974 SOUL" | "SOUL" | "" — never " · "
|
||||||
if (line) stamps.push(`<span class="tag">${esc(line)}</span>`);
|
if (line) stamps.push(`<span class="tag">${esc(line)}</span>`);
|
||||||
if (o.rare) stamps.push(`<span class="star">★ ${o.real ? 'GRAIL' : 'RARE'}</span>`);
|
if (o.rare) stamps.push(`<span class="star">★ ${o.real ? 'GRAIL' : 'RARE'}</span>`);
|
||||||
|
// [R32 THE BIBLE] the guide stamp — a RANGE off the 90s guide book, never a verdict. Present-fields-
|
||||||
|
// only law holds: an unknown band leaves no stamp (guideText returns ''). The asking price sits on
|
||||||
|
// the sticker; whether it's under the guide is for the PLAYER to notice — the card never says gem.
|
||||||
|
const guide = guideText('record', o.band);
|
||||||
|
if (guide) stamps.push(`<span class="tag">${esc(guide)}</span>`);
|
||||||
|
// [R33] the WANT line — only when the game layer wired the seams (construction-gated DOM: a
|
||||||
|
// ?game=0 / classic card carries no wantlist trace). Already-wanted reads as a stamp, not a button.
|
||||||
|
const wanted = isWantedCb ? !!isWantedCb(o) : false;
|
||||||
|
const wantLine = onWantCb
|
||||||
|
? `<div class="want${wanted ? ' on' : ''}">${wanted ? '★ ON THE WANTLIST' : '☆ WANT IT — hunt one cheaper'}</div>`
|
||||||
|
: '';
|
||||||
panel.innerHTML = `<div class="x">✕</div>`
|
panel.innerHTML = `<div class="x">✕</div>`
|
||||||
+ `<div class="sticker">$${esc(o.price)}</div>`
|
+ `<div class="sticker">$${esc(o.price)}</div>`
|
||||||
+ `<h3>${esc(o.a)}</h3>`
|
+ `<h3>${esc(o.a)}</h3>`
|
||||||
+ (o.t ? `<div class="ttl">“${esc(o.t)}”</div>` : '')
|
+ (o.t ? `<div class="ttl">“${esc(o.t)}”</div>` : '')
|
||||||
+ (stamps.length ? `<div class="stamps">${stamps.join('')}</div>` : '')
|
+ (stamps.length ? `<div class="stamps">${stamps.join('')}</div>` : '')
|
||||||
+ `<button ${o.price > cash ? 'disabled' : ''}>${o.price > cash ? 'NOT ENOUGH CASH' : 'BUY IT'}</button>`;
|
+ `<button ${o.price > cash ? 'disabled' : ''}>${o.price > cash ? 'NOT ENOUGH CASH' : 'BUY IT'}</button>`
|
||||||
|
+ wantLine;
|
||||||
panel.style.display = 'block';
|
panel.style.display = 'block';
|
||||||
panel.querySelector('.x').onclick = unpull;
|
panel.querySelector('.x').onclick = unpull;
|
||||||
|
const wantEl = panel.querySelector('.want');
|
||||||
|
if (wantEl && !wanted) wantEl.onclick = () => {
|
||||||
|
if (onWantCb(o) !== false) { wantEl.classList.add('on'); wantEl.textContent = '★ ON THE WANTLIST'; wantEl.onclick = null; }
|
||||||
|
};
|
||||||
const b = panel.querySelector('button');
|
const b = panel.querySelector('button');
|
||||||
if (b && !b.disabled) b.onclick = () => {
|
if (b && !b.disabled) b.onclick = () => {
|
||||||
// STUB economy (round 6+): onBuy may veto; default is a toast + remove-from-bin.
|
// STUB economy (round 6+): onBuy may veto; default is a toast + remove-from-bin.
|
||||||
const ok = onBuyCb ? onBuyCb(o) : true;
|
const ok = onBuyCb ? onBuyCb(o) : true;
|
||||||
if (ok === false) { unpull(); return; }
|
if (ok === false) { unpull(); return; }
|
||||||
blip('thunk');
|
// [R28] the sale rings on the till across the shop; the thunk stays as the no-engine fallback so
|
||||||
|
// the standalone harness (and ?noassets) keeps its sold cue.
|
||||||
|
if (!sfxFrom('till', emitters && emitters.counter)) blip('thunk');
|
||||||
showToast(`SOLD — ${o.a} · $${o.price}`);
|
showToast(`SOLD — ${o.a} · $${o.price}`);
|
||||||
removeEntry(entry);
|
removeEntry(entry);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// [R28 audioEmitter] The till rings from the COUNTER, not from inside your head. You riffle at a bin,
|
||||||
|
// so the counter is 2.83–8.15 m away (measured, 8 types × 4 seeds) — a real distance to carry.
|
||||||
|
// This needs NO engine seam: B's playSfx(key,{gain}) is already public, so C does its own distance
|
||||||
|
// math and hands B a scalar. That is the whole reason a ONE-SHOT is tractable and a BED is not — a
|
||||||
|
// one-shot resolves its gain once, at fire time, from a position C already knows; a bed has to be
|
||||||
|
// re-gained every frame from inside the engine's update loop, which is B's file. Hence the split ask.
|
||||||
|
// `sfx-till.ogg` has been on disk since 15 Jul and has never once played: this is routing, not content.
|
||||||
|
// No emitter (F hasn't plumbed it, or no counter) ⇒ gain 1 ⇒ a plain, un-positioned till. No engine
|
||||||
|
// ⇒ silence. Both fail soft, both still ring true.
|
||||||
|
function emitterGain(em) {
|
||||||
|
if (!em) return 1;
|
||||||
|
const cam = window.PROCITY && window.PROCITY.camera && window.PROCITY.camera.position;
|
||||||
|
if (!cam) return 1; // no camera to measure from → room-filling
|
||||||
|
const d = Math.hypot(cam.x - em.x, cam.z - em.z); // emitters are ROOM-LOCAL; so is the interior camera
|
||||||
|
const prox = Math.max(0, Math.min(1, (em.r - d) / em.r));
|
||||||
|
return em.floor + (1 - em.floor) * prox; // never 0 — a till across the shop is quiet, not absent
|
||||||
|
}
|
||||||
|
function sfxFrom(key, em) {
|
||||||
|
const eng = window.PROCITY && window.PROCITY.audio;
|
||||||
|
if (!eng || !eng.playSfx) return false; // standalone/no engine → silent-and-happy
|
||||||
|
eng.playSfx(key, { gain: emitterGain(em) }); // ?mute / ?noassets / pre-gesture handled inside
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
let toastT = null;
|
let toastT = null;
|
||||||
function showToast(msg) { toast.textContent = msg; toast.style.display = 'block'; clearTimeout(toastT); toastT = setTimeout(() => (toast.style.display = 'none'), 1400); }
|
function showToast(msg) { toast.textContent = msg; toast.style.display = 'block'; clearTimeout(toastT); toastT = setTimeout(() => (toast.style.display = 'none'), 1400); }
|
||||||
|
|
||||||
@ -286,10 +368,24 @@ export function createDig(THREE, renderer) {
|
|||||||
document.addEventListener('keydown', onKey, true);
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
|
||||||
// ?stock=real: seeded pick of real pack items (same bin seed → same crate contents).
|
// ?stock=real: seeded pick of real pack items (same bin seed → same crate contents).
|
||||||
function pickRealOffers(pack, seed, n) {
|
// TIER-2 SEAM (v5.0): `pack.gone` = ids the live read says sold IN THE REAL SHOP. We pick from the
|
||||||
|
// FULL, stable `pack.items` and omit gone ids AFTER the draw — never from a filtered array.
|
||||||
|
// Measured (R27, 5 crates): filtering the array first reshuffles the crate — one sale changed 21 OTHER
|
||||||
|
// records, because every pick indexes by position. Pick-then-omit: 0 collateral, the sold record simply
|
||||||
|
// leaves and the survivors keep their slots (a crate with 3 sold shows 13, not a different 16).
|
||||||
|
// 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, pulledGone) {
|
||||||
const rr = mulberry32(seed >>> 0), out = [];
|
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++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const it = pack.items[(rr() * pack.items.length) | 0];
|
const it = pack.items[(rr() * pack.items.length) | 0];
|
||||||
|
if (gone && gone.has(it.id)) continue; // sold in the real shop → this slot is simply empty
|
||||||
|
// [R32 scarcity] the PLAYER bought it (this day, this bin) → same pick-then-omit seam, same
|
||||||
|
// zero-collateral guarantee: the pull leaves, the survivors keep their slots.
|
||||||
|
if (pulledGone && pulledGone.has('sku:' + it.id)) continue;
|
||||||
// Real fields straight off the atlas index (§7.2c): condition/sleeve_cond are OPTIONAL — present on
|
// Real fields straight off the atlas index (§7.2c): condition/sleeve_cond are OPTIONAL — present on
|
||||||
// POS-sourced items, absent on mint ones that had no genuine grade. `y` (year) is never in the index,
|
// POS-sourced items, absent on mint ones that had no genuine grade. `y` (year) is never in the index,
|
||||||
// so it stays empty and the card simply omits it. No invented values: an unstated grade stays unstated.
|
// so it stays empty and the card simply omits it. No invented values: an unstated grade stays unstated.
|
||||||
@ -299,19 +395,30 @@ export function createDig(THREE, renderer) {
|
|||||||
out.push({ real: true, pack, item: it, a: it.artist, t: it.title, price: it.price,
|
out.push({ real: true, pack, item: it, a: it.artist, t: it.title, price: it.price,
|
||||||
s: band === 'GRAIL' ? '' : band, y: '',
|
s: band === 'GRAIL' ? '' : band, y: '',
|
||||||
cond: it.condition || '', sleeveCond: it.sleeve_cond || '',
|
cond: it.condition || '', sleeveCond: it.sleeve_cond || '',
|
||||||
|
band: it.price_band || null, // [R32] canonical band for the guide stamp + the sell basis
|
||||||
rare: it.price_band === 'grail' });
|
rare: it.price_band === 'grail' });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// open({ seed, count, shopName, shop, stockAdapter, onClose, onBuy, getCash })
|
// 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 = {}) {
|
function open(opts = {}) {
|
||||||
active = true;
|
active = true;
|
||||||
onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null;
|
onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null;
|
||||||
|
onWantCb = opts.onWant || null; isWantedCb = opts.isWanted || null; // [R33] wantlist seams
|
||||||
|
emitters = opts.emitters || null;
|
||||||
const count = opts.count || 16;
|
const count = opts.count || 16;
|
||||||
|
const gone = opts.gone instanceof Set && opts.gone.size ? opts.gone : null;
|
||||||
// riffle items: real pack (?stock=real) via the stockAdapter seam, else seeded procedural sleeves.
|
// riffle items: real pack (?stock=real) via the stockAdapter seam, else seeded procedural sleeves.
|
||||||
const src = opts.stockAdapter && opts.stockAdapter(opts.shop, 'sleeve');
|
const src = opts.stockAdapter && opts.stockAdapter(opts.shop, 'sleeve');
|
||||||
offers = (src && src.real) ? pickRealOffers(src.pack, opts.seed ?? 1, count) : proceduralSleeves(opts.seed ?? 1, count);
|
offers = (src && src.real) ? pickRealOffers(src.pack, opts.seed ?? 1, count, gone)
|
||||||
|
: proceduralSleeves(opts.seed ?? 1, count).filter((o) => !(gone && gone.has(String(o.i))));
|
||||||
curLbl.textContent = (opts.shopName ? opts.shopName + ' — ' : '');
|
curLbl.textContent = (opts.shopName ? opts.shopName + ' — ' : '');
|
||||||
[hint, curLbl, outBtn].forEach(e => e.style.display = 'block');
|
[hint, curLbl, outBtn].forEach(e => e.style.display = 'block');
|
||||||
buildStack(offers);
|
buildStack(offers);
|
||||||
@ -349,7 +456,16 @@ export function createDig(THREE, renderer) {
|
|||||||
[css, hint, curLbl, panel, outBtn, toast].forEach(e => e.remove());
|
[css, hint, curLbl, panel, outBtn, toast].forEach(e => e.remove());
|
||||||
}
|
}
|
||||||
|
|
||||||
return { scene, camera, open, close, update, dispose, get active() { return active; } };
|
// [R32 — the honest-gate surface, R24 precedent] What is in THIS open bin, BY IDENTITY: original
|
||||||
|
// offer index, asking, band, sku (pack items). Read-only snapshots; null when closed. F's bible/
|
||||||
|
// scarcity gate asserts on these instead of trusting "the crate looked right".
|
||||||
|
function currentOffers() {
|
||||||
|
if (!active) return null;
|
||||||
|
return recs.map((r) => ({ i: r.offer.i ?? null, price: r.offer.price, band: r.offer.band || null,
|
||||||
|
sku: r.offer.item ? String(r.offer.item.id) : null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scene, camera, open, close, update, dispose, currentOffers, get active() { return active; } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// A stable per-bin seed from the shop + a bin index, so a given bin riffles the same order every time.
|
// A stable per-bin seed from the shop + a bin index, so a given bin riffles the same order every time.
|
||||||
|
|||||||
@ -128,6 +128,43 @@ function audioFor(type, ctx, opts, shop) {
|
|||||||
return audio;
|
return audio;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── audio EMITTERS (round 28) — WHERE a key sounds from ────────────────────────────
|
||||||
|
// room.audio.emitters says which room-local point each bed/sfx belongs to, so the gig comes from the
|
||||||
|
// stage and the till from the counter instead of from the whole room. Pure data on the EXISTING
|
||||||
|
// `playInterior(room.audio)` call — no new lifecycle, no traverse, no dispose hook (the v3.0 note
|
||||||
|
// proposed `userData.audioEmitter` on a prop mesh; that shape is wrong — the engine holds no reference
|
||||||
|
// to the room group and would have to walk a scene it can't reach. The line was the stale thing).
|
||||||
|
//
|
||||||
|
// `r` is the REACH: distance at which the emitter has decayed to `floor`. It is the ROOM DIAGONAL, not
|
||||||
|
// a constant — measured across 8 types × 4 seeds, diagonals run 9.81–15.63 m, and scaling by the room
|
||||||
|
// keeps the door→stage proximity in a consistent 0.42–0.59 band from a pokey band_room to a hall pub.
|
||||||
|
// `floor` is the room-filling RESIDUE and is never 0: a pub PA fills the room, what changes as you walk
|
||||||
|
// in is the BALANCE, not the presence. floor 0.35 ⇒ ~4 dB door→stage — audible, not a new game.
|
||||||
|
//
|
||||||
|
// NOTE FOR THE CONSUMER (measured, R28): do NOT spatialise these with a PannerNode. Nothing in the tree
|
||||||
|
// sets `ctx.listener`, so a panner resolves against a listener frozen at (0,0,0) — which, because rooms
|
||||||
|
// are built in room-local coords at the origin, is the room's own centre, forever. Measured via
|
||||||
|
// OfflineAudioContext: stage bed RMS 0.106905 at the door, 0.106905 at the stage — ratio 1.0000, no
|
||||||
|
// error, no warning, and it never changes as you cross the room. Use a distance-gain against
|
||||||
|
// PROCITY.camera.position, the way the tram/spill/gig-spill already do. See LANE_C_AUDIO.md §Emitters.
|
||||||
|
const EMITTER_FLOOR = { stage: 0.35, counter: 0.15 }; // PA fills a room; a till is a point source
|
||||||
|
const round = (v) => Math.round(v * 1000) / 1000; // same 3-dp form layout.js publishes poses in
|
||||||
|
|
||||||
|
function emittersFor(lay, dims) {
|
||||||
|
const r = round(Math.hypot(dims.W, dims.D)); // the room's own reach
|
||||||
|
const em = {};
|
||||||
|
if (lay.stage) {
|
||||||
|
// ear height above the deck, at the band's centre — not the deck surface
|
||||||
|
em.stage = { x: round(lay.stage.x), y: round((lay.stage.deckY || 0) + 1.2), z: round(lay.stage.z),
|
||||||
|
r, floor: EMITTER_FLOOR.stage };
|
||||||
|
}
|
||||||
|
if (lay.counter) {
|
||||||
|
em.counter = { x: lay.counter.pose.x, y: 1.1, z: lay.counter.pose.z,
|
||||||
|
r, floor: EMITTER_FLOOR.counter };
|
||||||
|
}
|
||||||
|
return em;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildInterior(shop, THREE, opts) {
|
export function buildInterior(shop, THREE, opts) {
|
||||||
opts = opts || {}; // tolerate undefined AND explicit null
|
opts = opts || {}; // tolerate undefined AND explicit null
|
||||||
const t0 = (typeof performance !== 'undefined' ? performance.now() : 0);
|
const t0 = (typeof performance !== 'undefined' ? performance.now() : 0);
|
||||||
@ -135,6 +172,10 @@ export function buildInterior(shop, THREE, opts) {
|
|||||||
const norm = normalizeShop(shop);
|
const norm = normalizeShop(shop);
|
||||||
const recipe = getRecipe(norm.type);
|
const recipe = getRecipe(norm.type);
|
||||||
const ctx = new Ctx(THREE, norm.seed);
|
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
|
// room shape → dims → shell
|
||||||
const archetype = chooseArchetype(recipe, ctx.stream('archetype'), opts.archetype);
|
const archetype = chooseArchetype(recipe, ctx.stream('archetype'), opts.archetype);
|
||||||
@ -185,7 +226,10 @@ export function buildInterior(shop, THREE, opts) {
|
|||||||
browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9)
|
browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9)
|
||||||
watchPoints: lay.watchPoints || [], // venue: [ {x,z,ry,dance,slotIndex} ] audience poses, cap by kind (pub/band_room 8, rsl 12); else []
|
watchPoints: lay.watchPoints || [], // venue: [ {x,z,ry,dance,slotIndex} ] audience poses, cap by kind (pub/band_room 8, rsl 12); else []
|
||||||
stage: lay.stage || null, // venue: { x,z,w,d,deckY,riserY,frontZ,bandPoses[4],backline[] } (4-piece + up-stage amp slots); else null
|
stage: lay.stage || null, // venue: { x,z,w,d,deckY,riserY,frontZ,bandPoses[4],backline[] } (4-piece + up-stage amp slots); else null
|
||||||
audio: audioFor(recipe.key, ctx, opts, norm.raw), // { musicKey, toneKey, gigKey? } — Lane E manifest.audio keys (R11/R12)
|
// { musicKey, toneKey, gigKey?, emitters } — Lane E manifest.audio keys (R11/R12) + WHERE they sound
|
||||||
|
// from (R28). emitters.stage is venue-only; emitters.counter is always present. Absent emitter ⇒
|
||||||
|
// room-filling, which stays the right default for a shop radio (the v3.0 call, unchanged).
|
||||||
|
audio: { ...audioFor(recipe.key, ctx, opts, norm.raw), emitters: emittersFor(lay, dims) },
|
||||||
dims: { ...dims, archetype, type: recipe.key },
|
dims: { ...dims, archetype, type: recipe.key },
|
||||||
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
|
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
|
||||||
placement: lay.placement,
|
placement: lay.placement,
|
||||||
|
|||||||
@ -414,7 +414,7 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
|
|||||||
for (const p of placed) {
|
for (const p of placed) {
|
||||||
if (p.removed) continue;
|
if (p.removed) continue;
|
||||||
if (!p.fitting.group.parent) roomGroup.add(p.fitting.group);
|
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 || []));
|
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();
|
notify();
|
||||||
return true;
|
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(),
|
inventory: () => inv.slice(),
|
||||||
count: () => inv.length,
|
count: () => inv.length,
|
||||||
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
|
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
|
||||||
|
|||||||
@ -90,6 +90,84 @@ export function installDBG(deps) {
|
|||||||
// nearest shop matching pred, else nearest shop overall → a pose is always produced
|
// nearest shop matching pred, else nearest shop overall → a pose is always produced
|
||||||
const pickShop = (pred) => { const s = byDist(); return poseForShop(s.find(pred) || s[0]); };
|
const pickShop = (pred) => { const s = byDist(); return poseForShop(s.find(pred) || s[0]); };
|
||||||
|
|
||||||
|
// ── the shop selector: THE ID-NAMESPACE LAW, one implementation ─────────────────────────────
|
||||||
|
// [Lane F R22/R24 wrote this inside enterShop(); extracted verbatim in R27 w4 so the frontage
|
||||||
|
// pose below obeys the SAME law instead of growing a second, subtly different key scheme. A
|
||||||
|
// selector bug that resolves the wrong shop looks like it works — F's first cut proved that —
|
||||||
|
// so there is exactly one place to get it right.]
|
||||||
|
// resolveShop(116) → by PLAN id
|
||||||
|
// resolveShop('g:3962749') → by godverseShopId (the prefix IS the namespace fence)
|
||||||
|
// resolveShop('record') → first shop of that registry type
|
||||||
|
// resolveShop('The Exchange Hotel') → exact name, else name substring (case-insensitive)
|
||||||
|
// resolveShop(shopObject) → passed through
|
||||||
|
// Resolution order is most-specific-first so an id-shaped string can never be shadowed by a name.
|
||||||
|
// THE TWO ID SPACES COLLIDE: a GODVERSE shop has a plan id (local, renumbered per town, 1..N) and
|
||||||
|
// a godverseShopId (the real POS/dealgod id — what the atlas and every G-side query names it by).
|
||||||
|
// Monster Robot is plan 8 / godverse 3962749. Silky Oak's godverse id is 31 and another shop's
|
||||||
|
// PLAN id is also 31 — so a bare number is genuinely ambiguous. Bare number ⇒ plan id (unchanged,
|
||||||
|
// back-compatible); 'g:<n>' ⇒ godverse id, explicitly. When a bare number matches BOTH we say so
|
||||||
|
// in the return rather than pick quietly.
|
||||||
|
const resolveShop = (sel) => {
|
||||||
|
const shops = plan.shops || [];
|
||||||
|
let s = null, ambiguousWith = null;
|
||||||
|
if (sel && typeof sel === 'object') {
|
||||||
|
s = shops.includes(sel) ? sel : (sel.id != null ? shops.find((x) => x.id === sel.id) : null);
|
||||||
|
} else if (typeof sel === 'number') {
|
||||||
|
s = shops.find((x) => x.id === sel) || null;
|
||||||
|
const g = shops.find((x) => x.godverseShopId === sel) || null;
|
||||||
|
if (s && g && g !== s) ambiguousWith = { godverseShopId: sel, name: g.name };
|
||||||
|
if (!s) s = g;
|
||||||
|
} else if (typeof sel === 'string') {
|
||||||
|
const q = sel.trim().toLowerCase();
|
||||||
|
const gm = /^g(?:odverse)?:(\d+)$/.exec(q);
|
||||||
|
if (gm) {
|
||||||
|
const n = Number(gm[1]);
|
||||||
|
s = shops.find((x) => x.godverseShopId === n) || null;
|
||||||
|
if (!s) return { error: `no shop with godverseShopId ${n}` };
|
||||||
|
} else {
|
||||||
|
s = shops.find((x) => String(x.id) === q) // id-as-string
|
||||||
|
|| shops.find((x) => (x.name || '').toLowerCase() === q) // exact name
|
||||||
|
|| shops.find((x) => (x.type || '').toLowerCase() === q) // registry type
|
||||||
|
|| shops.find((x) => (x.name || '').toLowerCase().includes(q)); // name substring
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!s) return { error: `no shop matching ${JSON.stringify(sel)}` };
|
||||||
|
return { shop: s, ambiguousWith };
|
||||||
|
};
|
||||||
|
const ambiguityNote = (sel, a, verb) => `${sel} is also the godverseShopId of "${a.name}" ` +
|
||||||
|
`— ${verb} by PLAN id; use 'g:${sel}' for the godverse shop`;
|
||||||
|
|
||||||
|
// ── frontage pose (R27 w4 — Lane F's ask for the godverse tour frames) ──────────────────────
|
||||||
|
// clusterPose() answers "where is the town's HEART", which is the wrong question for a named
|
||||||
|
// shop: Red Hill's heart doesn't contain Monster Robot, so three tour frames photographed empty
|
||||||
|
// road. This poses at a SHOP instead. Measured against buildings.js rather than eyeballed:
|
||||||
|
// • door — buildings.js centres it on the lot front (dw 1.2, y 0.02–2.2), so off = 0
|
||||||
|
// (NOT poseForShop's off = 1.0) puts it on the frame's centre line.
|
||||||
|
// • sign+mark — sign band is y (min(h,FACADE_H) − 0.35) ± 0.25 ⇒ 2.40–2.90 for a normal shop;
|
||||||
|
// B's R26 crate mark is the strip at y ≈ 2.45–2.52 (atlas cell rows 128–152).
|
||||||
|
// • verandah — the awning slab sits at y 2.95 and reaches 2.2 m over the footpath. Any camera
|
||||||
|
// BELOW it sees the whole sign, because a ray rising from camY < 2.9 to a target
|
||||||
|
// ≤ 2.9 can never cross 2.95. Hence camY 2.0: eye-level, and clear by geometry.
|
||||||
|
// The novelty_record landmark (a 1.47 m vinyl disc, top y 2.50) stands ~1.3 m proud of record
|
||||||
|
// shopfronts and unavoidably clips ~15% of the mark strip from EVERY offset — moving the camera
|
||||||
|
// only changes WHICH 15%, so there is nothing to dodge and the repeating spines still read.
|
||||||
|
const poseForShopFront = (sel, dist = 8) => {
|
||||||
|
const r = resolveShop(sel);
|
||||||
|
if (r.error) return r;
|
||||||
|
const lot = lotOf(r.shop);
|
||||||
|
if (!lot) return { error: `shop "${r.shop.name}" has no lot` };
|
||||||
|
const ry = lot.ry || 0, fx = Math.sin(ry), fz = Math.cos(ry);
|
||||||
|
const half = (lot.d || 8) / 2;
|
||||||
|
const frontX = lot.x + fx * half, frontZ = lot.z + fz * half;
|
||||||
|
const out = {
|
||||||
|
pos: [frontX + fx * dist, 2.0, frontZ + fz * dist], // off = 0 ⇒ the door is the centre line
|
||||||
|
look: [frontX, 1.9, frontZ], // near-level: door low, sign band high
|
||||||
|
shop: r.shop.name, id: r.shop.id, godverseShopId: r.shop.godverseShopId ?? null,
|
||||||
|
};
|
||||||
|
if (r.ambiguousWith) out.ambiguous = ambiguityNote(sel, r.ambiguousWith, 'posed');
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
// ── lot- and node-based poses (the district bookmarks aren't all shopfronts) ──
|
// ── lot- and node-based poses (the district bookmarks aren't all shopfronts) ──
|
||||||
const poseForLot = (lot, dist = 11) => {
|
const poseForLot = (lot, dist = 11) => {
|
||||||
if (!lot) return null;
|
if (!lot) return null;
|
||||||
@ -232,6 +310,17 @@ export function installDBG(deps) {
|
|||||||
townAnchor: { ...townAnchor, anchored: REAL_ROADS, ref: { x: REF.x, z: REF.z } },
|
townAnchor: { ...townAnchor, anchored: REAL_ROADS, ref: { x: REF.x, z: REF.z } },
|
||||||
clusterPose: (kinds = ['main']) => streetViewPose(kinds),
|
clusterPose: (kinds = ['main']) => streetViewPose(kinds),
|
||||||
|
|
||||||
|
// ── R27 w4: the frontage pose, published for Lane F's godverse tour frames ──
|
||||||
|
// clusterPose finds the town's heart; this finds a NAMED SHOP's frontage — door centred, sign
|
||||||
|
// band and B's crate mark in shot, under the verandah. Keyed through the one shop selector, so
|
||||||
|
// it inherits the id-namespace law: `poseForShopFront('g:3962749')` for a godverse shop,
|
||||||
|
// a bare number for a plan id (and it TELLS you when that number is ambiguous).
|
||||||
|
// DBG.poseForShopFront('g:3962749') → Monster Robot Party's frontage at 8 m
|
||||||
|
// DBG.poseForShopFront('Egg Records', 7) → by name, closer
|
||||||
|
// Returns { pos, look, shop, id, godverseShopId } — the same {pos,look} every bookmark uses —
|
||||||
|
// or { error } for an unknown selector / a shop with no lot. Never throws, never guesses.
|
||||||
|
poseForShopFront,
|
||||||
|
|
||||||
// time-of-day (hands the day cycle to the harness so captures are deterministic)
|
// time-of-day (hands the day cycle to the harness so captures are deterministic)
|
||||||
setSegment(seg) { lighting.setPaused(true); lighting.setSegment(seg | 0); return lighting.getClock(); },
|
setSegment(seg) { lighting.setPaused(true); lighting.setSegment(seg | 0); return lighting.getClock(); },
|
||||||
|
|
||||||
@ -277,33 +366,13 @@ export function installDBG(deps) {
|
|||||||
// like it worked. Bare number ⇒ plan id (unchanged, back-compatible); `'g:<n>'` ⇒ godverse id,
|
// like it worked. Bare number ⇒ plan id (unchanged, back-compatible); `'g:<n>'` ⇒ godverse id,
|
||||||
// explicitly. When a bare number matches BOTH, we say so in the return rather than pick quietly.
|
// explicitly. When a bare number matches BOTH, we say so in the return rather than pick quietly.
|
||||||
enterShop(sel) {
|
enterShop(sel) {
|
||||||
const shops = plan.shops || [];
|
const r = resolveShop(sel);
|
||||||
let s = null, ambiguousWith = null;
|
if (r.error) return r;
|
||||||
if (typeof sel === 'number') {
|
const s = r.shop;
|
||||||
s = shops.find((x) => x.id === sel) || null;
|
|
||||||
const g = shops.find((x) => x.godverseShopId === sel) || null;
|
|
||||||
if (s && g && g !== s) ambiguousWith = { godverseShopId: sel, name: g.name };
|
|
||||||
if (!s) s = g;
|
|
||||||
} else if (typeof sel === 'string') {
|
|
||||||
const q = sel.trim().toLowerCase();
|
|
||||||
const gm = /^g(?:odverse)?:(\d+)$/.exec(q);
|
|
||||||
if (gm) {
|
|
||||||
const n = Number(gm[1]);
|
|
||||||
s = shops.find((x) => x.godverseShopId === n) || null;
|
|
||||||
if (!s) return { error: `no shop with godverseShopId ${n}` };
|
|
||||||
} else {
|
|
||||||
s = shops.find((x) => String(x.id) === q) // id-as-string
|
|
||||||
|| shops.find((x) => (x.name || '').toLowerCase() === q) // exact name
|
|
||||||
|| shops.find((x) => (x.type || '').toLowerCase() === q) // registry type
|
|
||||||
|| shops.find((x) => (x.name || '').toLowerCase().includes(q)); // name substring
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!s) return { error: `no shop matching ${JSON.stringify(sel)}` };
|
|
||||||
enterShop(s.id, s.name);
|
enterShop(s.id, s.name);
|
||||||
const out = { entered: s.id, name: s.name, type: s.type,
|
const out = { entered: s.id, name: s.name, type: s.type,
|
||||||
godverseShopId: s.godverseShopId || null };
|
godverseShopId: s.godverseShopId || null };
|
||||||
if (ambiguousWith) out.ambiguous = `${sel} is also the godverseShopId of "${ambiguousWith.name}" ` +
|
if (r.ambiguousWith) out.ambiguous = ambiguityNote(sel, r.ambiguousWith, 'entered');
|
||||||
`— entered by PLAN id; use 'g:${sel}' for the godverse shop`;
|
|
||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
exitShop() { window.dispatchEvent(new CustomEvent('procity:exitShop')); return { exited: true }; },
|
exitShop() { window.dispatchEvent(new CustomEvent('procity:exitShop')); return { exited: true }; },
|
||||||
|
|||||||
@ -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).
|
// 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))];
|
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();
|
const latch = new Map();
|
||||||
for (const id of venueIds) {
|
function buildLatches() {
|
||||||
const tonight = gigs.find((g) => g.venueShopId === id && g.night === 0) || null;
|
latch.clear();
|
||||||
latch.set(id, createVenueLatch(tonight, lighting));
|
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.
|
// 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); };
|
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;
|
const L = (id) => latch.get(id) || null;
|
||||||
|
|
||||||
return {
|
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) ──────────────────────────────────────────────────────────────
|
// ── per-venue API (R13 district) ──────────────────────────────────────────────────────────────
|
||||||
get venueShopIds() { return venueIds.slice(); },
|
get venueShopIds() { return venueIds.slice(); },
|
||||||
stateOf(id) { const l = L(id); return l ? l.state : 'quiet'; },
|
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 b{color:#ffd75e;font-weight:600}
|
||||||
#pc-dbg .warn{color:#ff9a6a}
|
#pc-dbg .warn{color:#ff9a6a}
|
||||||
#pc-clock{font-size:15px;margin-bottom:3px}
|
#pc-clock{font-size:15px;margin-bottom:3px}
|
||||||
#pc-toast{position:absolute;left:50%;bottom:64px;transform:translateX(-50%);background:rgba(20,16,10,.9);padding:10px 18px;border-radius:10px;font-size:15px;opacity:0;transition:opacity .25s;text-shadow:0 1px 2px #000}
|
#pc-toast{position:fixed;left:50%;bottom:64px;transform:translateX(-50%);background:rgba(20,16,10,.9);padding:10px 18px;border-radius:10px;font-size:15px;opacity:0;transition:opacity .25s;text-shadow:0 1px 2px #000;z-index:31;max-width:80vw;font:15px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;color:#f4efe6;pointer-events:none}
|
||||||
#pc-help{position:absolute;left:12px;bottom:12px;background:rgba(20,16,10,.5);padding:7px 11px;border-radius:8px;opacity:.85}
|
#pc-help{position:absolute;left:12px;bottom:12px;background:rgba(20,16,10,.5);padding:7px 11px;border-radius:8px;opacity:.85}
|
||||||
#pc-townpick{position:absolute;left:10px;top:10px;pointer-events:auto;opacity:.8;transition:opacity .12s}
|
#pc-townpick{position:absolute;left:10px;top:10px;pointer-events:auto;opacity:.8;transition:opacity .12s}
|
||||||
#pc-townpick:hover{opacity:1}
|
#pc-townpick:hover{opacity:1}
|
||||||
#pc-townpick select{background:rgba(20,16,10,.7);color:#f4efe6;border:1px solid rgba(255,215,94,.35);border-radius:8px;padding:5px 9px;font:12px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;max-width:230px;cursor:pointer}
|
#pc-townpick select{background:rgba(20,16,10,.7);color:#f4efe6;border:1px solid rgba(255,215,94,.35);border-radius:8px;padding:5px 9px;font:12px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;max-width:230px;cursor:pointer}
|
||||||
#pc-townpick select:focus{outline:1px solid rgba(255,215,94,.6)}
|
#pc-townpick select:focus{outline:1px solid rgba(255,215,94,.6)}
|
||||||
|
#pc-game{position: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 }) {
|
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 $ = (id) => root.querySelector('#' + id);
|
||||||
const elTip = $('pc-tip'), elClock = $('pc-clock'), elFps = $('pc-fps'), elDraws = $('pc-draws'),
|
const elTip = $('pc-tip'), elClock = $('pc-clock'), elFps = $('pc-fps'), elDraws = $('pc-draws'),
|
||||||
elTris = $('pc-tris'), elChunks = $('pc-chunks'), elToast = $('pc-toast');
|
elTris = $('pc-tris'), elChunks = $('pc-chunks'), elToast = $('pc-toast');
|
||||||
|
// [R35] toasts surface in EVERY mode (beats fire inside shops; a SLEEP taken at a counter must
|
||||||
|
// still show its morning paper) — same body-mount rule as #pc-game, same dispose duty.
|
||||||
|
document.body.appendChild(elToast);
|
||||||
$('pc-town').textContent = plan.name || '—';
|
$('pc-town').textContent = plan.name || '—';
|
||||||
$('pc-seed').textContent = plan.citySeed;
|
$('pc-seed').textContent = plan.citySeed;
|
||||||
|
|
||||||
@ -98,6 +115,189 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
|||||||
.catch(() => {}); // index missing/blocked → fallback stands, zero errors
|
.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();
|
const ray = new THREE.Raycaster();
|
||||||
ray.far = 6;
|
ray.far = 6;
|
||||||
const centre = new THREE.Vector2(0, 0);
|
const centre = new THREE.Vector2(0, 0);
|
||||||
@ -136,6 +336,12 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
|||||||
frame++;
|
frame++;
|
||||||
if (frame % 6 === 0) raycastDoor();
|
if (frame % 6 === 0) raycastDoor();
|
||||||
|
|
||||||
|
// v7 game surface — lazy construction + 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)
|
// tooltip — shows CLOSED (with opening hour) when the shop is shut at the current hour (§3.5)
|
||||||
if (hovered) {
|
if (hovered) {
|
||||||
const h = hovered.hours;
|
const h = hovered.hours;
|
||||||
@ -174,9 +380,24 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
|
|||||||
|
|
||||||
function dispose() {
|
function dispose() {
|
||||||
renderer.domElement.removeEventListener('click', onClick);
|
renderer.domElement.removeEventListener('click', onClick);
|
||||||
|
if (onGameKey) removeEventListener('keydown', onGameKey);
|
||||||
|
if (gameUI && gameUI.wrap) gameUI.wrap.remove(); // [R32] the game surface lives on document.body now
|
||||||
|
elToast.remove(); // [R35] so does the toast
|
||||||
root.remove();
|
root.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { update, tickToast, showToast, setVisible, dispose, getHovered: () => hovered,
|
// [R32/R33] the game surface ticks in EVERY mode (the street path reaches it via update(); interior/
|
||||||
|
// map call this directly) — and BUILDS here too, so the surface exists no matter which mode first
|
||||||
|
// sees a truthy game. day/cash/crate stay live while you shop; the card-yield check runs here.
|
||||||
|
function tickGame() {
|
||||||
|
if (!gameUI) {
|
||||||
|
const g = window.PROCITY && window.PROCITY.game;
|
||||||
|
if (g) gameUI = buildGameUI(g);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gameUI.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { update, tickToast, showToast, setVisible, tickGame, dispose, getHovered: () => hovered,
|
||||||
getFps: () => fpsShown };
|
getFps: () => fpsShown };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,9 @@ import { buildInterior, getStockPack, makeStockAdapter } from '../interiors/inte
|
|||||||
import { KeeperManager } from '../citizens/keepers.js'; // Lane D — shopkeeper behind the counter
|
import { KeeperManager } from '../citizens/keepers.js'; // Lane D — shopkeeper behind the counter
|
||||||
import { GigCrew } from '../citizens/band.js'; // Lane D — band trio + audience (R12, ?gigs=1)
|
import { GigCrew } from '../citizens/band.js'; // Lane D — band trio + audience (R12, ?gigs=1)
|
||||||
import { createDig, binSeed } from '../interiors/dig.js'; // Lane C — crate-riffle (v2, gated on ?dig=1)
|
import { createDig, binSeed } from '../interiors/dig.js'; // Lane C — crate-riffle (v2, gated on ?dig=1)
|
||||||
|
import { mulberry32, xmur3 } from '../core/prng.js'; // [R33] the rumor roll (seeded, runtime-only)
|
||||||
import { collapseBuyItem } from '../interiors/stockpack.js'; // Lane C — R9 buy-anywhere (book spines / toy boxes)
|
import { collapseBuyItem } from '../interiors/stockpack.js'; // Lane C — R9 buy-anywhere (book spines / toy boxes)
|
||||||
|
import { createSell, nearCounter } from '../interiors/sell.js'; // Lane C — R30 sell counter (F owns this hook, §9.4)
|
||||||
|
|
||||||
const EYE = 1.6; // interior eye height (matches interior_test.html; rooms tuned for it)
|
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
|
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.
|
// lives in CITY_SPEC §v3. See LANE_F_NOTES §12.
|
||||||
const WALLA_KEY = 'crowd-walla'; // [R13] manifest.ambience — crowd murmur layered under the gig bed while 'on'
|
const WALLA_KEY = 'crowd-walla'; // [R13] manifest.ambience — crowd murmur layered under the gig bed while 'on'
|
||||||
|
|
||||||
|
// [R33 — THE RUMOR] where a keeper's mutter can point: the repo's real cached towns (display names).
|
||||||
|
// Flavor + a travel nudge, not a promise — the 90s had no stock lookups, and the town-wide packs mean
|
||||||
|
// every town CAN surface any hunted record eventually. Full persona lines are D's when the clip work lands.
|
||||||
|
const RUMOR_TOWNS = ['Katoomba', 'Newtown', 'Fremantle', 'Bendigo', 'Castlemaine'];
|
||||||
|
|
||||||
// [Lane F R24 §6a] The per-shop stock base for a GODVERSE shop — A's `godverseShopId` → C's per-shop
|
// [Lane F R24 §6a] The per-shop stock base for a GODVERSE shop — A's `godverseShopId` → C's per-shop
|
||||||
// pack location (LANE_C_PUB §7.3/§7.5). Exported and used by BOTH the shell's boot preload and the room's
|
// pack location (LANE_C_PUB §7.3/§7.5). Exported and used by BOTH the shell's boot preload and the room's
|
||||||
// lookup, deliberately: C's cache identity is the LITERAL (type, base) pair, so if the two sides built
|
// lookup, deliberately: C's cache identity is the LITERAL (type, base) pair, so if the two sides built
|
||||||
@ -47,9 +54,58 @@ export function godverseBaseFor(shop) {
|
|||||||
return id ? `assets/stock_godverse/${id}/` : undefined;
|
return id ? `assets/stock_godverse/${id}/` : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Lane F R27 §2 — THE TIER-2 READER] Live stock, per C's §8 ruling: direct wire, no lifecycle hook.
|
||||||
|
// F already holds `currentAdapter` and hands it to `dig.open()` per dig, so a fresh read is picked up by
|
||||||
|
// the NEXT dig for free. The read is fire-and-forget: if it hasn't landed, hasn't finished, or failed,
|
||||||
|
// the adapter is simply the tier-1 pack. THE DIG NEVER BLOCKS ON THE NETWORK (charter risk #1).
|
||||||
|
//
|
||||||
|
// What it may touch is deliberately tiny, and C's §8.3 is the reason: `items[]` identity and ORDER are
|
||||||
|
// the pick's stability. So we set `pack.gone` (C picks from the full list and omits after — filtering the
|
||||||
|
// array instead reshuffles the crate: C measured ONE sale changing 21 other records) and we update
|
||||||
|
// `price` IN PLACE. We never add, remove, or reorder items[]. Live values are consumed at pick time and
|
||||||
|
// never persisted — no golden, no seeded stream (charter law #2, the determinism boundary sits here).
|
||||||
|
const LIVE_TIMEOUT_MS = 1200; // beyond this the shop is tier 1 for this dig; the player never waits
|
||||||
|
const _liveCache = new Map(); // godverseShopId → { etag, gone:Set, prices:Map } (etag-cached per §4)
|
||||||
|
let _liveDown = false; // circuit breaker: one failure and we stop asking until reload
|
||||||
|
|
||||||
|
async function readLiveStock(base, shop, pack) {
|
||||||
|
if (_liveDown || !base || !shop.godverseShopId) return null;
|
||||||
|
const id = shop.godverseShopId;
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctl.abort(), LIVE_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const prev = _liveCache.get(id);
|
||||||
|
const r = await fetch(`${base}/godverse/v1/shop/${id}/stock`,
|
||||||
|
{ signal: ctl.signal, headers: prev ? { 'If-None-Match': prev.etag } : {} });
|
||||||
|
if (r.status === 304 && prev) { applyLive(pack, prev); return prev; }
|
||||||
|
if (!r.ok) return null; // 404 = not a real shop. Tier 1. Silently.
|
||||||
|
const d = await r.json();
|
||||||
|
const live = { etag: d.etag, gone: new Set(d.gone || []),
|
||||||
|
prices: new Map((d.items || []).map((i) => [i.id, i.price])) };
|
||||||
|
_liveCache.set(id, live);
|
||||||
|
applyLive(pack, live);
|
||||||
|
return live;
|
||||||
|
} catch (e) {
|
||||||
|
// Dead / unreachable / slow / aborted → tier 1 EXACTLY, and we stop asking. The offline law is not
|
||||||
|
// a fallback path here, it is the same path: the pack was already complete before we asked.
|
||||||
|
_liveDown = true;
|
||||||
|
return null;
|
||||||
|
} finally { clearTimeout(timer); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLive(pack, live) {
|
||||||
|
if (!pack) return;
|
||||||
|
pack.gone = live.gone; // C omits these AFTER the draw (§8.2)
|
||||||
|
for (const it of (pack.items || [])) { // live price, IN PLACE (§8.3) — no reshuffle
|
||||||
|
const p = live.prices.get(it.id);
|
||||||
|
if (p != null) it.price = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
|
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
|
||||||
dig: digEnabled = false, stockReal = false, wallet = null,
|
dig: digEnabled = false, stockReal = false, wallet = null,
|
||||||
occupancyOf = null, gigState = null, rosterOf = null }) {
|
occupancyOf = null, gigState = null, rosterOf = null,
|
||||||
|
liveBase = null, sourcingOf = null, game = null }) {
|
||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
scene.background = new THREE.Color('#0e0b07');
|
scene.background = new THREE.Color('#0e0b07');
|
||||||
|
|
||||||
@ -65,9 +121,13 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
|||||||
let current = null; // active Lane C interior handle
|
let current = null; // active Lane C interior handle
|
||||||
let currentShop = null; // the shop record for the active interior (dig per-bin seeding)
|
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 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 doorReturn = null; // { x, y, z, ry } on the street to restore on exit
|
||||||
let exitArmed = false; // disarmed at spawn (spawn sits by the door); arms once player steps inside
|
let exitArmed = false; // disarmed at spawn (spawn sits by the door); arms once player steps inside
|
||||||
let dig = null; // [F2] Lane C crate-riffle instance, created lazily on first use (only if ?dig=1)
|
let dig = null; // [F2] Lane C crate-riffle instance, created lazily on first use (only if ?dig=1)
|
||||||
|
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 _fwd = new THREE.Vector3();
|
||||||
const _right = new THREE.Vector3();
|
const _right = new THREE.Vector3();
|
||||||
@ -78,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
|
// 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
|
// 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.
|
// (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;
|
if (!current) return null;
|
||||||
const bins = [];
|
const bins = [];
|
||||||
current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') bins.push(o); });
|
current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') bins.push(o); });
|
||||||
@ -90,6 +155,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
|||||||
while (o && !(o.userData && o.userData.kind === 'bin')) o = o.parent;
|
while (o && !(o.userData && o.userData.kind === 'bin')) o = o.parent;
|
||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
|
if (strictAim) return null;
|
||||||
// Fallback (matches Lane C's interior_test.html): nearest bin within reach. Uses the bin GROUP's
|
// 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
|
// 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.
|
// forgiving of imperfect aim — stand next to a bin, press E.
|
||||||
@ -104,15 +170,53 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
|||||||
if (dig.active) return;
|
if (dig.active) return;
|
||||||
const p = new THREE.Vector3(); bin.getWorldPosition(p);
|
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)
|
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({
|
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),
|
shopName: (current && current.recipe && current.recipe.label) || (currentShop && currentShop.name),
|
||||||
shop: currentShop,
|
shop: currentShop,
|
||||||
stockAdapter: currentAdapter, // [F2 §stock=real] real covers in the riffle (null → parody)
|
stockAdapter: currentAdapter, // [F2 §stock=real] real covers in the riffle (null → parody)
|
||||||
|
gone: game ? game.pullsFor(pullPrefix) : undefined, // [R32] this bin's bought slots stay gone today
|
||||||
// [F R8 — Lane C buy loop v0] bind the session wallet so pulling a sleeve deducts cash + banks it.
|
// [F R8 — Lane C buy loop v0] bind the session wallet so pulling a sleeve deducts cash + banks it.
|
||||||
// dig removes the pulled sleeve from the bin; onBuy returns false when broke (no state change).
|
// dig removes the pulled sleeve from the bin; onBuy returns false when broke (no state change).
|
||||||
|
// [R30 — THE COLLECTION] a successful buy also records the find into game.collection (§30.1 entry
|
||||||
|
// 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,
|
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')),
|
onClose: () => window.dispatchEvent(new CustomEvent('procity:digClose')),
|
||||||
});
|
});
|
||||||
window.dispatchEvent(new CustomEvent('procity:digOpen')); // shell releases pointer-lock for the cursor
|
window.dispatchEvent(new CustomEvent('procity:digOpen')); // shell releases pointer-lock for the cursor
|
||||||
@ -141,6 +245,16 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
|||||||
if (!best) return;
|
if (!best) return;
|
||||||
const item = best.item || {};
|
const item = best.item || {};
|
||||||
if (wallet.buy({ title: item.title, artist: item.artist, price: item.price || 20 })) {
|
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
|
collapseBuyItem(mesh, best); // zero-area the bought quad in place
|
||||||
flashToast(`SOLD — ${item.title || 'item'} · $${item.price || 20} · $${wallet.cash()} left`);
|
flashToast(`SOLD — ${item.title || 'item'} · $${item.price || 20} · $${wallet.cash()} left`);
|
||||||
} else {
|
} else {
|
||||||
@ -148,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) {
|
if (digEnabled || wallet) {
|
||||||
addEventListener('keydown', (e) => {
|
addEventListener('keydown', (e) => {
|
||||||
if (e.code !== 'KeyE' || !current || (dig && dig.active)) return;
|
if (e.code !== 'KeyE' || !current || (dig && dig.active) || (sellCard && sellCard.active)) return;
|
||||||
if (digEnabled) { const bin = binUnderAim(); if (bin) { openDig(bin); return; } } // bin → riffle
|
if (digEnabled) { const bin = binUnderAim(true); if (bin) { openDig(bin); return; } } // AIMED bin → riffle (§9.4 first branch)
|
||||||
buyShelfUnderAim(); // shelf → buy
|
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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -214,13 +358,28 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
|||||||
// for taking tolerances out. Lookup only: a shop with no pack gets null → parody, silently.
|
// for taking tolerances out. Lookup only: a shop with no pack gets null → parody, silently.
|
||||||
const gBase = stockReal ? godverseBaseFor(shop) : undefined;
|
const gBase = stockReal ? godverseBaseFor(shop) : undefined;
|
||||||
currentAdapter = stockReal ? makeStockAdapter(getStockPack(shop.type, gBase)) : null;
|
currentAdapter = stockReal ? makeStockAdapter(getStockPack(shop.type, gBase)) : null;
|
||||||
|
// [R27 §2] Tier 2, fire-and-forget. ONLY for a real-sourced shop: G's fence 404s a mint shop, and
|
||||||
|
// asking anyway would be R26's blind probe wearing a server. The manifest already told us which is
|
||||||
|
// which, so we ask only where an answer exists. No await — the room is already built and complete.
|
||||||
|
if (stockReal && gBase && liveBase && sourcingOf && sourcingOf(shop.godverseShopId) === 'real') {
|
||||||
|
const pack = getStockPack(shop.type, gBase);
|
||||||
|
if (pack) readLiveStock(liveBase, shop, pack);
|
||||||
|
}
|
||||||
// [Lane F R13 THE DISTRICT] Is THIS shop the venue with a gig on right now? Per-venue query — a town has
|
// [Lane F R13 THE DISTRICT] Is THIS shop the venue with a gig on right now? Per-venue query — a town has
|
||||||
// 2–4 venues, each with its own latch keyed by shop id. Lane C only stamps room.audio.gigKey when we pass
|
// 2–4 venues, each with its own latch keyed by shop id. Lane C only stamps room.audio.gigKey when we pass
|
||||||
// opts.gig, so pass NULL unless it's really on: a quiet-night venue then builds as a normal interior
|
// opts.gig, so pass NULL unless it's really on: a quiet-night venue then builds as a normal interior
|
||||||
// (stage + watch points still there, just nobody on them), the "quiet night" Lane C specced.
|
// (stage + watch points still there, just nobody on them), the "quiet night" Lane C specced.
|
||||||
const gigOn = !!(gigState && gigState.onOf(shop.id));
|
const gigOn = !!(gigState && gigState.onOf(shop.id));
|
||||||
const tonight = gigOn ? gigState.gigOf(shop.id) : null;
|
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,
|
current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter,
|
||||||
|
stockDay: currentStockDay,
|
||||||
gig: gigOn ? { on: true, genreKey: tonight.genreKey || 'pubrock', gigId: tonight.gigId } : null });
|
gig: gigOn ? { on: true, genreKey: tonight.genreKey || 'pubrock', gigId: tonight.gigId } : null });
|
||||||
currentShop = shop;
|
currentShop = shop;
|
||||||
scene.add(current.group);
|
scene.add(current.group);
|
||||||
@ -271,9 +430,28 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
|||||||
exitArmed = false;
|
exitArmed = false;
|
||||||
let hasBins = false;
|
let hasBins = false;
|
||||||
if (digEnabled) current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') hasBins = true; });
|
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`
|
banner.textContent = `🚪 ${name || shop.name || 'shop'} — walk out the door or press Esc to leave`
|
||||||
+ (gigOn ? ` · 🎸 ${String(tonight.bandName).toUpperCase()} — LIVE` : '')
|
+ (gigOn ? ` · 🎸 ${String(tonight.bandName).toUpperCase()} — LIVE` : '')
|
||||||
+ (hasBins ? ' · E: riffle a record bin' : '');
|
+ (hasBins ? ' · E: riffle a record bin' : '')
|
||||||
|
+ (canSell ? ' · E at the counter: sell' : '')
|
||||||
|
+ rumor;
|
||||||
banner.style.display = 'block';
|
banner.style.display = 'block';
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
@ -331,12 +509,15 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
|
|||||||
if (!current) return;
|
if (!current) return;
|
||||||
window.PROCITY.audio && window.PROCITY.audio.stopInterior(); // [Lane F R11 audio] fade the interior bed out (0.7s)
|
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 (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
|
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
|
if (crew) { crew.disposeAll(); crewInfo = null; } // [R12] band + crowd out before the room goes
|
||||||
current.dispose();
|
current.dispose();
|
||||||
current = null;
|
current = null;
|
||||||
currentShop = null;
|
currentShop = null;
|
||||||
currentAdapter = null;
|
currentAdapter = null;
|
||||||
|
currentStockDay = null;
|
||||||
|
currentRealSourced = false;
|
||||||
banner.style.display = 'none';
|
banner.style.display = 'none';
|
||||||
toast.style.display = 'none'; _toastT = 0;
|
toast.style.display = 'none'; _toastT = 0;
|
||||||
if (doorReturn) {
|
if (doorReturn) {
|
||||||
@ -356,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 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 crewInfo() { return crewInfo; }, // [R12] { band, crowd } actually spawned — F's smokes assert on this
|
||||||
get digActive() { return !!(dig && dig.active); }, // [F2] shell reads this to keep pointer-lock/Esc sane while riffling
|
get digActive() { return !!(dig && dig.active); }, // [F2] shell reads this to keep pointer-lock/Esc sane while riffling
|
||||||
|
get sellActive() { return !!(sellCard && sellCard.active); }, // [R30 §9.4] same contract for the sell card
|
||||||
|
// [R32 — honest-gate surface] the open bin's offers by identity (index/ask/band/sku); null when no
|
||||||
|
// dig is open. The bible/scarcity gate asserts on THESE, not on how the crate looked.
|
||||||
|
get digOffers() { return dig && dig.active ? dig.currentOffers() : null; },
|
||||||
|
|
||||||
// [Lane F R24 — the honest-gate surface, per the vacuous-gate law] What stock is in THIS room, BY
|
// [Lane F R24 — the honest-gate surface, per the vacuous-gate law] What stock is in THIS room, BY
|
||||||
// IDENTITY. R23's #7a could go green on "real covers render" while the room held the generic v2 pack
|
// IDENTITY. R23's #7a could go green on "real covers render" while the room held the generic v2 pack
|
||||||
|
|||||||
@ -38,17 +38,29 @@ function gradientEquirect(THREE) {
|
|||||||
return tex;
|
return tex;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLighting(scene, plan, skins, { radius = 3, shadows = true } = {}) {
|
export function createLighting(scene, plan, skins, { radius = 3, shadows = true, dawnSky = null } = {}) {
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
group.name = 'lighting';
|
group.name = 'lighting';
|
||||||
scene.add(group);
|
scene.add(group);
|
||||||
|
|
||||||
// sky dome — seeded photo skin, darkened at night via material colour
|
// sky dome — seeded photo skin, darkened at night via material colour
|
||||||
|
// [R32 §31.6-3] `dawnSky` (option, shell passes null under ?classic=1): the default dome is a
|
||||||
|
// golden-SUNSET photo, so DAWN read as dusk-orange (the R31 playtest's cosmetic #3). During the DAWN
|
||||||
|
// segment only, the dome swaps to a cool morning skin, then back. A weather setSky() override
|
||||||
|
// (rain/overcast) OWNS the dome outright — the dawn swap never fights it.
|
||||||
const skyName = plan.sky || 'golden-arvo';
|
const skyName = plan.sky || 'golden-arvo';
|
||||||
const skyMat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.BackSide, depthWrite: false, fog: false });
|
const skyMat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.BackSide, depthWrite: false, fog: false });
|
||||||
|
let externalSky = null; // set by setSky() (weather) — wins over base + dawn forever after
|
||||||
|
let shownSky = skyName;
|
||||||
skins.skyTex(skyName, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
|
skins.skyTex(skyName, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
|
||||||
const dome = new THREE.Mesh(new THREE.SphereGeometry(650, 24, 14), skyMat);
|
const dome = new THREE.Mesh(new THREE.SphereGeometry(650, 24, 14), skyMat);
|
||||||
group.add(dome);
|
group.add(dome);
|
||||||
|
function applySkyFor(segIdx) {
|
||||||
|
const want = externalSky || (segIdx === 0 && dawnSky ? dawnSky : skyName);
|
||||||
|
if (want === shownSky) return;
|
||||||
|
shownSky = want;
|
||||||
|
skins.skyTex(want, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
|
||||||
|
}
|
||||||
|
|
||||||
const hemi = new THREE.HemisphereLight(0xd8e8ff, 0xa89878, 1.0);
|
const hemi = new THREE.HemisphereLight(0xd8e8ff, 0xa89878, 1.0);
|
||||||
group.add(hemi);
|
group.add(hemi);
|
||||||
@ -76,6 +88,7 @@ export function createLighting(scene, plan, skins, { radius = 3, shadows = true
|
|||||||
|
|
||||||
function apply(i) {
|
function apply(i) {
|
||||||
const [, sunI, sunC, hSky, hGnd, hI, skyTint, fogC, exposure, isNight] = SEG[i];
|
const [, sunI, sunC, hSky, hGnd, hI, skyTint, fogC, exposure, isNight] = SEG[i];
|
||||||
|
applySkyFor(i); // [R32] dawn dome swap (no-op under classic / weather override / other segments)
|
||||||
sun.intensity = sunI; sun.color.set(sunC);
|
sun.intensity = sunI; sun.color.set(sunC);
|
||||||
hemi.intensity = hI; hemi.color.set(hSky); hemi.groundColor.set(hGnd);
|
hemi.intensity = hI; hemi.color.set(hSky); hemi.groundColor.set(hGnd);
|
||||||
_skyTint.set(skyTint); skyMat.color.copy(_skyTint);
|
_skyTint.set(skyTint); skyMat.color.copy(_skyTint);
|
||||||
@ -95,7 +108,11 @@ export function createLighting(scene, plan, skins, { radius = 3, shadows = true
|
|||||||
|
|
||||||
// Swap the sky-dome skin at runtime (weather.js uses this for a matching rainy/overcast sky).
|
// Swap the sky-dome skin at runtime (weather.js uses this for a matching rainy/overcast sky).
|
||||||
// The per-segment day/night colour tint (skyMat.color in apply) still rides on top of the new map.
|
// The per-segment day/night colour tint (skyMat.color in apply) still rides on top of the new map.
|
||||||
function setSky(name) { skins.skyTex(name, (t) => { skyMat.map = t; skyMat.needsUpdate = true; }); }
|
// [R32] a caller-set sky is an OVERRIDE: it wins over the base skin and the dawn swap from then on.
|
||||||
|
function setSky(name) {
|
||||||
|
externalSky = name; shownSky = name;
|
||||||
|
skins.skyTex(name, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
|
||||||
|
}
|
||||||
|
|
||||||
const _sunOff = new THREE.Vector3();
|
const _sunOff = new THREE.Vector3();
|
||||||
function update(dt, playerPos) {
|
function update(dt, playerPos) {
|
||||||
|
|||||||
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 {
|
return {
|
||||||
group, venues, update, dispose,
|
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),
|
venueShopIds: venues.map((v) => v.venueShopId),
|
||||||
queueZones,
|
queueZones,
|
||||||
queueZoneFor(id) { const v = venues.find((x) => x.venueShopId === id); return v ? v.queueZone : null; },
|
queueZoneFor(id) { const v = venues.find((x) => x.venueShopId === id); return v ? v.queueZone : null; },
|
||||||
|
|||||||