Compare commits
7 Commits
b9e3f3a92d
...
9277238462
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9277238462 | ||
|
|
a4946b7ca2 | ||
|
|
ab7a8d0edf | ||
|
|
b322c998db | ||
|
|
2b13dd4ad7 | ||
|
|
54a030b38b | ||
|
|
42b11e717d |
@ -3,7 +3,35 @@
|
||||
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
|
||||
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
|
||||
|
||||
Last updated: 2026-07-16 (round 15) · owner: PROCITY-C · reviewer: Fable
|
||||
Last updated: 2026-07-16 (round 16) · owner: PROCITY-C · reviewer: Fable
|
||||
|
||||
---
|
||||
|
||||
## Update 2026-07-16 (round 16, v3.1 THE FLIP) — the backline handoff (C's half of ledger #5)
|
||||
|
||||
R16 §Lane C (ledger #5, C's half): unify the amp. **`room.stage.backline[]`** now exposes 2 seeded up-stage
|
||||
amp slots `{x,z,ry,y}` (pure data, no meshes), and C's **primitive ampStacks are dropped** from `placeStage` —
|
||||
Lane D plants E's `guitar_amp` GLB at `backline[0]` (its own primitive fallback under `?noassets`), one amp
|
||||
system. Shape published in [LANE_C_PUB.md](docs/LANES/LANE_C_PUB.md) §3 (v3.1 amendment marker; rest of the
|
||||
frozen doc unchanged). **C→D handshake: backline shape is live.**
|
||||
|
||||
- `backline[0]` = stage-right `(stage.x + stage.w·0.36, deckY, stage.z − stage.d·0.22)` == D's R15-verified
|
||||
amp pose exactly, so wiring D's plant to it moves **nothing** on screen. `backline[1]` = stage-left spare
|
||||
(mirror). ry=π, y=deckY.
|
||||
- **Verified fresh (port-isolated, seed 20261990):** both slots up-stage, on-deck, clear of all 4 bandPoses
|
||||
(nearest-bass **0.87 m pub / 0.70 m band_room / 0.92 m rsl**) and all watchPoints (≥1.95 m); determinism
|
||||
byte-identical (placement + stage + watchPoints); 0 leftover ampStack prop groups; drawSweep glb-off venues
|
||||
**pub 122 · band_room 120 · rsl 131** (was 126/124/135 — the dropped primitives shaved ~4 draws each), worst
|
||||
161 (opshop/hall, unchanged). ≤350 ✓.
|
||||
- **Determinism/golden safety:** removing the ampStack loop only drops draws on the `stage` rng sub-stream,
|
||||
which nothing downstream reads; the `placement` snapshot excludes ampStacks (never tracked), so it's
|
||||
byte-identical. Interiors aren't hashed into any golden; the `?classic=1` boot has no venues → untouched.
|
||||
- Disjoint from F's flip (interiors don't read flags) — landed early so D isn't blocked. **Verify with D
|
||||
once wired** (ledger #5 D-half): re-render each archetype, confirm the GLB at `backline[0]` doesn't
|
||||
interpenetrate (pending D's R16 commit).
|
||||
|
||||
**Code:** `layout.js` placeStage (backline[] + drop the amp loop), `interiors.js` API doc. `theme.js` untouched
|
||||
(recipes carry no ampStack fitting — the primitives were placeStage-planted, not recipe fittings).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -4,6 +4,27 @@
|
||||
|
||||
---
|
||||
|
||||
## Round 16 (v3.1 THE FLIP) — F bookends; the town shows its best by default
|
||||
|
||||
The gig layer soaked through three v3 stages, so v3.1 flips it (and the mature v2 flags) ON by default.
|
||||
F owns the flip; A–E landed their polish against it (seated drummer, town-wide pools, backline, corner-poster).
|
||||
|
||||
**Opening commit `a4946b7` (the round's foundation):**
|
||||
- **The flip** — `index.html` `CLASSIC` + `flagOn()`: gigs/weather/tram default-on, `loadPedFleet({sit:!CLASSIC})`,
|
||||
`window.PROCITY.flags`; `buildings.js` winmap default-on + classic-aware. **`?classic=1`** forces the exact v2 boot.
|
||||
- **The covenant moved** — `flags_off_regression → classic_regression` (byte-identical `0x3fa36874`); a new
|
||||
`default_boot_gate` (all four flags on, gig layer present, gig golden `0xb1d48ea1`, budget); every gig smoke
|
||||
re-pointed to the DEFAULT boot; qa.sh gate renamed.
|
||||
- **Seated-stature gate** (ledger #3) — `interior_scale_check.py` + `flags_check.py` accept `procitySeated` figs
|
||||
at [0.9,1.5] m; the drummer passes at **1.32 m**.
|
||||
|
||||
Verified: `?classic` byte-identical (0x3fa36874, gig absent); default = full town (3 venues / 3 genres, gig golden,
|
||||
112 draws @ venue block); classic/default/district/continuity/cover all green. **Close: re-baseline, re-shot tour
|
||||
→ `release_v3_1/` + a `?classic=1` before/after, docs (CITY_SPEC v3.1 flip+classic law, README flags table,
|
||||
LANE_F_NOTES §16), tag `v3.1`.**
|
||||
|
||||
---
|
||||
|
||||
## Round 15 (v3.0 RELEASE CLOSE, `?gigs=1`) — F ran last; the release is closed
|
||||
|
||||
R14 landed A–E's hardening but **F never ran**, so v3.0 stayed untagged. R15 = the close. All deps were in
|
||||
|
||||
51
README.md
@ -48,30 +48,35 @@ Seeded everything, `Math.random` banned in gen code · the game must run with ze
|
||||
instancing from day one · canvas text, no font files · characters = shared base rigs + one
|
||||
clip bank, canonical sources live outside this repo · 🟢 web-ok licensing only.
|
||||
|
||||
## v3.0 — venues & gigs, THE DISTRICT (`?gigs=1`)
|
||||
## v3.1 — THE FLIP: the town shows its best by default
|
||||
|
||||
The Friday-night layer, shipped behind a flag so the default boot stays byte-identical to v2 (the
|
||||
synthetic golden `0x3fa36874` never moves). Turn it on and each town seeds **2–4 venues** — a pub, a
|
||||
band room, an RSL — with a **full seeded week of gigs** (`plan.gigs`), town-wide **posters**, and a
|
||||
Friday night that reads: a **4-piece band** (front-line trio + drummer) with real instruments on the
|
||||
stage, a mixed standing/dancing **crowd**, an outdoor **queue** at the door, a **cover charge** per
|
||||
venue (paid & free nights), muffled **gig spill** through the wall + **walla and applause** inside,
|
||||
**streetlamp pools** and lit frontages for the night walk, and **identity continuity** — the ped you
|
||||
followed in off the street turns up in the crowd. Deterministic from the seed; asset-free
|
||||
(`?noassets=1`) it all still happens with placeholders and zero fetches.
|
||||
The Friday-night layer soaked through alpha, beta, and the v3.0 release — so **v3.1 flips it on by
|
||||
default**, and the mature v2 flags flip with it. Boot the plain URL and each town seeds **2–4 venues**
|
||||
(pub, band room, RSL) with a **full seeded week of gigs**, town-wide **posters**, and a Friday night that
|
||||
reads: a **4-piece band** (trio + a **seated drummer**) with real instruments, a mixed **crowd**, an
|
||||
outdoor **queue**, a per-venue **cover charge**, muffled **gig spill** + **walla and applause**,
|
||||
town-wide **streetlamp pools**, and **identity continuity** — the ped you followed in is in the crowd.
|
||||
Plus **weather**, the **tram**, and the parallax interior-**window shader**, all live by default.
|
||||
|
||||
**Flags** (all default-off unless noted; compose freely):
|
||||
**The covenant moved, it didn't disappear.** `?classic=1` boots the **exact v2 town** — byte-identical
|
||||
forever (synthetic golden `0x3fa36874`, zero gig layer, zero fetch delta). That is the new frozen
|
||||
regression target; the default boot is deterministic to the gig golden `0xb1d48ea1`. Asset-free
|
||||
(`?noassets=1`) composes with the default boot and stays silent-and-fine.
|
||||
|
||||
| flag | what |
|
||||
|---|---|
|
||||
| `?seed=N` | town seed (default `20261990`) |
|
||||
| `?gigs=1` | the v3.0 gig layer — venues, week schedule, band/crowd/queue, cover, posters, night pass |
|
||||
| `?weather=rain` · `?tram=1` · `?winmap=1` | wet strip · tram loop · parallax interior-window shader |
|
||||
| `?roster=stream` | chunk-streamed citizen density (**on by default**) · `?pop=N` tunes it |
|
||||
| `?dig=1` · `?stock=real` | crate-riffle on record bins · real GODVERSE sleeves in the stock |
|
||||
| `?noassets=1` | asset-free — zero network, 100% placeholders, still legible |
|
||||
| `?plansrc=osm` · `?mute=1` · `?dbg=1` | OSM plan source · silence · the `window.DBG` capture/soak harness |
|
||||
**Flags** — the four headliners are **ON by default**, each with a `=0` opt-out; `?classic=1` is the
|
||||
master off:
|
||||
|
||||
**Release tour:** [`docs/shots/release_v3/contact.html`](docs/shots/release_v3/) — the six money shots
|
||||
(`queue_night` is the hero) with settled measurements. **QA:** `tools/qa.sh --strict` (6 gates) +
|
||||
`tools/qa/week_soak.py` (the full-week soak) + the continuity/gig smokes in `tools/flags_check.py`.
|
||||
| flag | default | what |
|
||||
|---|---|---|
|
||||
| `?classic=1` | off | **the exact v2 boot** — all four below off, byte-identical to the frozen baseline |
|
||||
| `?gigs=0` | **on** | the gig district — venues, week schedule, band/crowd/queue, cover, posters, night pass |
|
||||
| `?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 |
|
||||
| `?dig=0` · `?stock=real` | dig on | crate-riffle on record bins · real GODVERSE sleeves in the stock |
|
||||
| `?noassets=1` · `?plansrc=osm` · `?mute=1` · `?dbg=1` | off | asset-free (zero network) · OSM plan source · silence · the `window.DBG` capture/soak harness |
|
||||
| `?seed=N` | `20261990` | town seed |
|
||||
|
||||
**Release tour:** [`docs/shots/release_v3_1/contact.html`](docs/shots/release_v3_1/) — the money shots on
|
||||
the default boot, plus a `?classic=1` before/after. **QA:** `tools/qa.sh --strict` (classic regression +
|
||||
default-boot gate + smokes + no-giants) `--soak` (the full-week soak); the seated drummer passes the
|
||||
no-giants gate at the seated stature band `[0.9,1.5] m`.
|
||||
|
||||
@ -1,10 +1,37 @@
|
||||
# LANE A — CITYGEN · progress (PROCITY-A)
|
||||
|
||||
*Status: **all deliverables landed; self-check all-green (14264/14264); rounds 2–15 closed** (see below).
|
||||
v3.0 release close-out: frontage-poster +Z flip (ledger #1) + the one sanctioned gig-golden move
|
||||
(`0x1f636349 → 0x4f4a549d`); synthetic golden `0x3fa36874` frozen.*
|
||||
*Status: **all deliverables landed; self-check all-green (14769/14769); rounds 2–16 closed** (see below).
|
||||
v3.1 (THE FLIP): corner-poster fix via `pickVenues` +Z-clearance bias (ledger #6, gig golden
|
||||
`0x4f4a549d → 0xb1d48ea1`) + a no-spine osm fixture (silverton, #7). Classic/synthetic `0x3fa36874` frozen.*
|
||||
*Date: 2026-07-16 · owner: PROCITY-A (Opus 4.8)*
|
||||
|
||||
## Round 16 (2026-07-16) — v3.1 THE FLIP: corner-poster clearance bias (#6) + no-spine osm fixture (#7)
|
||||
|
||||
Lane A ran **first** among the parallel lanes (F bookends with the flip; A's golden feeds F's default-boot
|
||||
gate). The R15 finding was that a corner venue's frontage poster can sit near a crossing kerb.
|
||||
|
||||
Delivered (one commit):
|
||||
1. **Corner-poster fix — via `pickVenues`, not a nudge.** I proved the brief's proposed fix (nudge along
|
||||
the facade) is geometrically impossible: the crossing edge is **parallel** to the facade and spans it
|
||||
(0/187 along-facade slides help). Root cause: band_room/rsl land on through-lots on narrow warehouse
|
||||
blocks, so the `+Z` wall (B's door side) faces the block's other street, overlapping its carriageway by
|
||||
up to ~3 m. **Surfaced to Fable; ruling: bias `pickVenues`.** It now prefers a band_room/rsl candidate
|
||||
whose `+Z` facade clears every carriageway by ≥ `POSTER_CLEAR` (0.5 m), broadening off-spine when the
|
||||
fringe is uniformly narrow. Result: **0/851** frontage posters below floor across 400 seeds (worst
|
||||
0.51 m); pub untouched (hero shot unmoved). Feasibility pre-checked: a clear lot always exists.
|
||||
2. **Gig golden re-pinned `0x4f4a549d → 0xb1d48ea1`** — the sanctioned move (posters moved); synthetic/
|
||||
classic `0x3fa36874` frozen. §v3 FROZEN marker records the amendment.
|
||||
3. **Selfcheck: exemption → floor.** R15's blanket frontage exemption is now a real invariant — frontage
|
||||
posters must clear their kerb by ≥ `POSTER_CLEAR` (exported from the barrel), spine posters keep ≥ 0.
|
||||
4. **No-spine osm fixture `silverton`** (ledger #7): a constructed single-row town (all shops one latitude
|
||||
⇒ 0 `main` edges). Closes the R14 gap (both real fixtures had a spine); proves `pickVenues`' no-spine
|
||||
branch + `buildPosters`' no-main fallback. Base golden pinned `0xd4b351c9`; sweep asserts 0 main edges +
|
||||
graceful 2–4-venue placement. Selfcheck **ALL GREEN 14769/14769**; scaffold + consistency GREEN.
|
||||
5. **CITY_SPEC §v3 amended** (golden line, poster clearance-bias prose, no-spine note, recorded amendment).
|
||||
Left the prime-flag-law blockquote for F (the v3.1 classic-law + flags-table is F's §v3 amendment).
|
||||
|
||||
For F: default-boot golden = `0xb1d48ea1`; `?classic=1` regression target = `0x3fa36874`.
|
||||
|
||||
## Round 15 (2026-07-16) — the frontage-poster +Z flip (ledger #1) + one sanctioned golden move
|
||||
|
||||
Lane A ran **first** (B's hero re-shoot + F's tour hang off it). The R14 review found every venue's
|
||||
|
||||
@ -218,12 +218,34 @@ night in v1; interior-mapping shader is a stretch goal).
|
||||
> facade (it had been on the `−Z` back, invisible from the street); this moved `plan.posters`, so the gig
|
||||
> golden re-pinned `0x1f636349 → 0x4f4a549d` in the same commit under the amendment law above. The
|
||||
> synthetic golden `0x3fa36874` did not move — this is the round's one sanctioned gig-golden move.)*
|
||||
>
|
||||
> *(**Recorded amendment — ROUND16, ledger #6:** on a narrow through-lot a band_room/rsl's `+Z` facade
|
||||
> can abut the block's *other* street, putting its frontage poster over that carriageway. `pickVenues`
|
||||
> now **prefers a band_room/rsl lot whose facade clears every carriageway by ≥ `POSTER_CLEAR`** (0.5 m;
|
||||
> broadens off-spine if the warehouse fringe is uniformly narrow). This moved `plan.posters`, so the gig
|
||||
> golden re-pinned `0x4f4a549d → 0xb1d48ea1`; synthetic `0x3fa36874` still frozen. The v3.1 default flip
|
||||
> + classic law is F's amendment to this section — see the flags table.)*
|
||||
|
||||
> **Prime flag law.** The gig layer is a **post-hoc augmentation** applied by the selector only when
|
||||
> `?gigs=1`. With the flag OFF, `generatePlanFor(seed, src)` returns the byte-identical v2 base plan —
|
||||
> **all v2 goldens stay frozen**. So this section adds fields that exist *only on gig-on plans*; it does
|
||||
> not touch the v2 contract above. Deterministic from `(citySeed, customBands)`; works on synthetic AND
|
||||
> osm towns. Node-pure (no THREE, no runtime fetch — the shell loads the band drop-in and passes it in).
|
||||
> **Prime flag law.** The gig layer is a **post-hoc augmentation** applied by the selector when the gig
|
||||
> flag is on. With it off, `generatePlanFor(seed, src)` returns the byte-identical v2 base plan — **all v2
|
||||
> goldens stay frozen**. So this section adds fields that exist *only on gig-on plans*; it does not touch
|
||||
> the v2 contract above. Deterministic from `(citySeed, customBands)`; works on synthetic AND osm towns.
|
||||
> Node-pure (no THREE, no runtime fetch — the shell loads the band drop-in and passes it in).
|
||||
>
|
||||
> **Prime flag law — REWRITTEN at `v3.1` (ROUND16, THE FLIP; Lane F amendment).** The covenant MOVED, it
|
||||
> 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
|
||||
> **`?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
|
||||
> baseline **forever** — synthetic fingerprint `0x3fa36874`, 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.
|
||||
> - **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)
|
||||
> now run against the **default boot with all four flags live**. `?noassets=1` composes with it and stays
|
||||
> silent-and-fine.
|
||||
> - The four flags read through one rule in the shell — `flagOn(name) = !classic && param !== '0'` (winmap
|
||||
> reads the same rule in `buildings.js`); the shell publishes the intent on `window.PROCITY.flags`, and the
|
||||
> seated drummer's `sit.glb` rides `!classic` so classic keeps its zero-fetch-delta.
|
||||
>
|
||||
> *(v3.0-alpha was one pub, one genre, one night's posters. **ROUND13 → v3.0-beta, the district:** 2–4
|
||||
> venues of three kinds, a full week's schedule across town, town-wide posters. Same flag, same laws.)*
|
||||
@ -286,8 +308,10 @@ Only **tonight's** (night 0) playing venues are advertised. Each such venue gets
|
||||
frontage** — seated on the **+Z street facade** (B's canon: `buildings.js` facade + `venue.js` door/queue/
|
||||
camera are all `+Z`; position `lot + (sin ry, cos ry)·(d/2+ε)`), turned to read *from* the street
|
||||
(`ry = lot.ry + π` so its printed face points `+Z` at the queue-side camera — **ROUND15 ledger #1**; before
|
||||
R15 it was on the `−Z` back and invisible from the street). Plus a seeded run along the spine — 2 per main
|
||||
edge, at `t ∈ [0.2,0.8]` of the block, on **either verge**. Different venues' posters share the same pole
|
||||
R15 it was on the `−Z` back and invisible from the street). So the poster never sits over a back-street's
|
||||
bitumen, **`pickVenues` prefers a band_room/rsl lot whose facade clears every carriageway by ≥ `POSTER_CLEAR`**
|
||||
(ROUND16 ledger #6 — exported from the barrel; the selfcheck asserts the same floor). Plus a seeded run along
|
||||
the spine — 2 per main edge (or, when a town has no main spine, every edge), at `t ∈ [0.2,0.8]` of the block, on **either verge**. Different venues' posters share the same pole
|
||||
runs — that IS the district reading. `ry` follows the house convention (a poster's printed face =
|
||||
`(−sin ry, −cos ry)`, so `atan2` of the *away-from-viewer* normal); B places E's poster skin at `(x, z)`
|
||||
rotated `ry`; F wires "poster → that venue's tonight-gig".
|
||||
@ -316,9 +340,11 @@ by seed, spread **night-major across the whole week's slots town-wide** (not one
|
||||
canonical half-open predicate (`open ≤ hour < close`) for D's gig-night patronage surge and everyone else.
|
||||
|
||||
**Gig golden** (guards gig-layer drift; pure-generator path, no custom file), seed `20261990` synthetic:
|
||||
**`0x4f4a549d`** (**ROUND15 re-pin** — the frontage-poster +Z flip, ledger #1, moved `plan.posters`; was
|
||||
`0x1f636349` the R13 district, `0xa6ae5a5e` the R12 alpha). Asserted by `selfcheck.js` alongside the frozen
|
||||
v2 goldens; changing gig output re-pins it.
|
||||
**`0xb1d48ea1`** (**ROUND16 re-pin** — the band_room/rsl facade-clearance bias, ledger #6, moved
|
||||
`plan.posters`; was `0x4f4a549d` the R15 +Z flip, `0x1f636349` the R13 district, `0xa6ae5a5e` the R12
|
||||
alpha). Asserted by `selfcheck.js` alongside the frozen v2/classic golden; changing gig output re-pins it.
|
||||
The gig sweep now also runs a **no-spine osm fixture** (`silverton`, single-row) proving `pickVenues`
|
||||
places venues with no main spine (ROUND16 ledger #7).
|
||||
|
||||
## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read)
|
||||
|
||||
|
||||
@ -2,6 +2,41 @@
|
||||
|
||||
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
|
||||
|
||||
## Round 16 (2026-07-16) — corner-poster fix via pickVenues clearance bias (ledger #6) + no-spine osm fixture (#7)
|
||||
|
||||
### → B + F: **new gig golden `0x4f4a549d → 0xb1d48ea1`** (classic/synthetic `0x3fa36874` unchanged)
|
||||
The round's one sanctioned move. **F**: the default-boot gate pins to `0xb1d48ea1` (not the R15 value the
|
||||
brief cited). **B**: re-shoot against this. Amendment law; §v3 marker records it.
|
||||
|
||||
### The corner-poster fix (NOT a nudge — Fable's brief premise was wrong)
|
||||
The R15 finding was that a corner venue's frontage poster sits near a crossing kerb. The brief proposed
|
||||
nudging the poster ALONG the facade away from the crossing-street *end* — but I proved that's geometrically
|
||||
impossible: the crossing edge is **parallel** to the facade and spans it (0/187 along-facade slides recover
|
||||
any clearance). Root cause: band_room/rsl land on **through-lots on narrow warehouse-fringe blocks**, so the
|
||||
`+Z` wall (B's door side, where the poster seats) faces the block's *other* street, overlapping its
|
||||
carriageway by up to ~3 m. **Fable's ruling (asked mid-round): bias `pickVenues`.** So:
|
||||
- `gigs.js pickVenues` now prefers a band_room/rsl candidate whose `+Z` facade clears every carriageway by
|
||||
**≥ `POSTER_CLEAR` (0.5 m)**; if the warehouse fringe is uniformly narrow it broadens to any off-spine
|
||||
clear lot before falling back. Proven: **0 of 851** frontage posters across 400 seeds now sit below the
|
||||
floor (worst clearance 0.51 m). Pub is untouched (spine-fronted, `+Z` open — the hero shot doesn't move).
|
||||
- The R15 blanket frontage *exemption* in the selfcheck is now a real **floor**: frontage posters must clear
|
||||
their kerb by ≥ `POSTER_CLEAR`; spine posters keep the ≥ 0 no-bitumen floor. `POSTER_CLEAR` is exported
|
||||
from the barrel — import it, don't re-guess. (`−0.02` in the assert absorbs r2 poster-coord rounding.)
|
||||
|
||||
### The no-spine osm fixture (ledger #7): `silverton`
|
||||
`osm_fixture.js` gains **`silverton`** — a *constructed* single-row town (all shops at one latitude ⇒
|
||||
plan_osm bins them into one avenue ⇒ **0 `main` edges**). Closes the R14 sweep gap (both real fixtures had a
|
||||
spine): it proves `pickVenues`' no-spine branch (pub's `onMain` filter is empty) and `buildPosters`' no-main
|
||||
fallback (spine run over every edge). Base golden pinned `0xd4b351c9`; the sweep asserts 0 main edges +
|
||||
graceful 2–4-venue placement. `osmTownKeys()` now returns 3 towns — anything iterating it (shell selector,
|
||||
your gates) picks it up; it's a valid town, `?plansrc=osm&town=silverton`.
|
||||
|
||||
### → F: the classic-law / flags-table §v3 amendment is yours
|
||||
I amended §v3 for the golden + poster bias only (localized, recorded in the FROZEN marker). The v3.1 flip
|
||||
(default-on, `?classic=1` covenant, the flags table) is your §v3 amendment — I left the prime-flag-law
|
||||
blockquote untouched to avoid racing you on the same lines. My classic golden (`0x3fa36874`) is your
|
||||
`?classic=1` regression target; my gig golden (`0xb1d48ea1`) is your default-boot target.
|
||||
|
||||
## Round 15 (2026-07-16) — the frontage-poster +Z flip (ledger #1) + the one sanctioned golden move
|
||||
|
||||
### → B + F: **new gig golden `0x1f636349 → 0x4f4a549d`** (synthetic `0x3fa36874` unchanged)
|
||||
|
||||
@ -1,5 +1,55 @@
|
||||
# LANE B — NOTES (measured)
|
||||
|
||||
## Round 16 (Fable's ROUND16 → Lane B) — v3.1 THE FLIP: town-wide pools + default-boot re-baseline
|
||||
|
||||
Two deliverables. Verified fresh Chromium, seed 20261990. (Ran before F's flip landed — my pools are
|
||||
gig-gated so they work under `?gigs=1` now, and the re-baseline uses **explicit** all-four-flags URLs =
|
||||
the post-flip default-boot content; F re-confirms against the real default at close.)
|
||||
|
||||
### 1. Town-wide streetlamp pools (ledger #4) — `venue.js`
|
||||
Extended the R13 instanced-pool pass from venue frontages to **every streetlight + bus shelter**. New
|
||||
`townPoolSpecs(plan)` replicates furniture.js's exact lamp cadence off the plan's street graph (a lamp
|
||||
every 18 m from `s=8`, alternating sides, at `off = halfRoad+0.7`) and reuses the exported
|
||||
`busShelterStops(plan)` — **read-only, `furniture.js` untouched** (imported the one function). All pools
|
||||
(venue + town) ride the **one existing InstancedMesh**, so town-wide is still **~1 draw**:
|
||||
- **381 pool instances / 1 draw / ~760 tris** for Boolarra Heads (22 edges, 27 nodes). Night-driven via
|
||||
the existing opacity ramp; `fog: true` now (was false) so distant pools fade into the night fog
|
||||
instead of floating as dots over un-built chunks (pools aren't chunk-streamed). A/B'd `fog:false` —
|
||||
no dramatic difference and a dot-risk on bigger towns, so `fog:true` is the keeper.
|
||||
- **Gig-gated → classic parity by construction:** venue.js only exists under the gig layer, so
|
||||
`?classic=1` (gigs off) is pool-free = v2 parity. No always-on `furniture.js` change (the v3.0 blocker).
|
||||
- Pure function of the plan (deterministic cadence, no rng) → **no new seeded draws** (release law).
|
||||
|
||||
### 2. Default-boot budget re-baseline (ledger #4) — the number the budget law now means
|
||||
Measured on the **default boot content** (gigs + `weather=rain` worst-case + winmap + tram all live),
|
||||
settled (shoot-twice / clean-load to defeat the orphan-chunk teleport inflation):
|
||||
|
||||
| view (default boot, all flags) | draws (composer total) | tris | gate |
|
||||
|---|---|---|---|
|
||||
| **busiest venue block** (pub, NIGHT, rain, 3/4) | **~157 scene / ~170 total** | **66k** | ≤300 / ≤200k ✅ |
|
||||
| `venue_night` | 122 | 53k | ✅ |
|
||||
| `queue_night` | 121 | 61k | ✅ |
|
||||
| `district_posters` / `street_noon` (wide main spine) | **185** | 131k | ✅ (worst tour shot) |
|
||||
| `market_square` (densest daytime, fresh) | 97 | 52k | ✅ |
|
||||
|
||||
**No budget bust.** The flip adds ~3 draws (rain Points 1 + tram 2; winmap 0 — it's a shader on
|
||||
existing windows), and the **town-wide pools add ~0** (1 instanced mesh). Worst default-boot street
|
||||
view measured is **185 total draws / 131k tris** — ~115 draw / ~70k tri margin. (Pools are night-only,
|
||||
so the pre-existing daytime dense-citizen ceiling — Lane D territory, ~275 total per R13's live-walk —
|
||||
is unchanged by this round; winmap/rain add ≤3 there. F's live week-soak confirms the full-density number.)
|
||||
|
||||
### 3. Bookmark spot-check on the default boot
|
||||
All **19 bookmarks resolve, un-letterboxed, 0 page errors** with all four flags on. Rain particles +
|
||||
tram + winmap now appear in the old opt-in frames and compose cleanly — `street_noon` rainy (177 draws
|
||||
/ 114k tris) frames the strip with rain + storm sky + wet road, nothing blocked. No pose fixes needed.
|
||||
|
||||
**→ Lane F (B→F handshake — the default worst-case numbers):** default boot, busiest venue block =
|
||||
**~170 total draws / 66k tris**; worst street view (wide main spine) = **185 / 131k**; densest daytime
|
||||
= 97 / 52k. All ≤300 / ≤200k. Town-wide pools are ~1 draw. Re-confirm against the real default once
|
||||
your flip lands; the pool sprite is procedural (no asset, E has nothing to do).
|
||||
|
||||
---
|
||||
|
||||
## Round 15 (Fable's ROUND15 → Lane B) — verify the hero after A's poster flip (v3.0 release)
|
||||
|
||||
Task: re-verify `queue_night` after A's ledger-#1 flip (`35da83c`: `buildPosters` item 1 → `+sin,+cos`
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
> **v3.0-FROZEN** · 2026-07-16 · durable contract for Lane D + Lane F. Describes the *shipped* state;
|
||||
> changes require a version bump (CITY_SPEC amendment law). Verified R13 (build) + R14 (release audit).
|
||||
> **v3.1 amendment (2026-07-16):** §3 becomes the `stage.backline[]` contract (ledger #5 — the amp
|
||||
> unification); C dropped its primitive ampStacks. Everything else in this doc is unchanged and stands.
|
||||
|
||||
*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
|
||||
@ -97,17 +99,22 @@ No `opts.gig` → a normal interior: `stage` + `watchPoints` are still returned
|
||||
`gigKey`** (room-tone + maybe the seeded radio `musicKey`), and D spawns no crowd. A seeded dark night reads
|
||||
true — the venue is just a quiet pub/club.
|
||||
|
||||
## 3. `guitar_amp` placement (v3.0 — RESOLVED R15)
|
||||
## 3. `stage.backline[]` — the amp slots (v3.1, ledger #5)
|
||||
|
||||
**Resolved.** D plants E's `guitar_amp` GLB (`band.js _addBackline`) up-stage-right at **`(stage.x +
|
||||
stage.w·0.36, deckY, stage.z − stage.d·0.22)`** — C's corrected pose (ledger #4). It's on-deck, up-stage of
|
||||
the front line, and flanks C's own primitive ampStack (stage-left) as a clean backline. **Verified R15 (fresh
|
||||
context, seed 20261990):** clearance to the bass rig **0.87 m (pub) / 0.92 m (rsl)** — no interpenetration
|
||||
(the original R14 pose `stage.z + 0.15` was down-stage, 0.29 m off the bass, and clipped it); amp↔primitive
|
||||
4.4 m (pub) / 3.5 m (rsl); on-deck both. C's `placeStage` plants **1–2 primitive `ampStack` fittings** at
|
||||
`(±stage.w·0.28, deckY, stage.z − stage.d·0.18)` as the asset-free backline fallback (no GLB slot, so they
|
||||
stay primitive). *v3.1:* full unification — C exposes a `stage.backline[]` slot list that D's GLB fills,
|
||||
replacing the primitive — stays deferred (no new contract fields in v3.0).
|
||||
`room.stage.backline = [ { x, z, ry, y } ×2 ]` — seeded up-stage amp/spare-cab poses, **pure data** (C plants
|
||||
no amp meshes as of v3.1). This is the unified backline: **Lane D plants E's `guitar_amp` GLB here** (its own
|
||||
primitive fallback under `?noassets`) — one amp system, GLB when present. C's old primitive ampStacks are
|
||||
**dropped** from all three venue recipes.
|
||||
- **slot[0]** = stage-right (the primary amp) at `(stage.x + stage.w·0.36, deckY, stage.z − stage.d·0.22)` —
|
||||
identical to D's R15-verified plant, so wiring D's amp to `backline[0]` moves nothing on screen.
|
||||
**slot[1]** = stage-left spare cab (mirror). `ry = π` faces the audience (+Z, the §0 convention); `y = deckY`.
|
||||
- **Guarantees** (verified fresh, seed 20261990, pub/band_room/rsl): every slot is **up-stage of the front
|
||||
line**, flanks the drum riser, **clear of all 4 bandPoses** (nearest-bass clearance **0.87 m pub / 0.70 m
|
||||
band_room / 0.92 m rsl**) and **all watchPoints** (≥1.95 m), and **on-deck** (within the deck footprint).
|
||||
Deterministic per `shop.seed`. **Lane D:** verify no interpenetration once wired (C's R15 clearance discipline).
|
||||
- **Draw impact:** dropping C's primitives shaved ~4 draws off each empty venue shell (drawSweep glb-off:
|
||||
pub 122 · band_room 120 · rsl 131 ≤350); D's GLB/primitive at `backline[0]` adds it back on the gig night.
|
||||
- *v3.2 (parked):* the drummer sit clip, instrument LOD, and C's `audioEmitter` idea.
|
||||
|
||||
## 4. Budgets (measured R14, seed 20261990, fresh context)
|
||||
- **Empty shells** (`drawSweep` glb-off): band_room **124** · pub **126** · rsl **135** — town-wide worst
|
||||
|
||||
@ -6,6 +6,56 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
|
||||
|
||||
---
|
||||
|
||||
## ROUND 16 — v3.1 THE FLIP: the drummer finally sits + backline unification
|
||||
|
||||
*PROCITY-D, 2026-07-16. Ledger #2 (sit-clip wiring) + #5 (amp at C's `backline[]`). Files `rigs.js`,
|
||||
`band.js`. Verified against C's landed `stage.backline[]` + E's `sit.glb`; F's flip/gate pending (I built to
|
||||
F's published seated-stature contract). **Byte-identical proof holds** (the load-bearing requirement).*
|
||||
|
||||
### The drummer sits (ledger #2) — and Fable's prescribed `_rotWithHips` is WRONG for these rigs
|
||||
The R14 recon + F's brief prescribed a `_rotWithHips` variant (keep `Hips.quaternion` to preserve the
|
||||
pelvic tilt). **In-shell it MANGLES the pose** — head folds to hip height, body laid flat — because the
|
||||
fleet rigs' bind-pose Hips orientation differs from the Mixamo clip's, exactly the failure `_rotOnly`'s own
|
||||
comment documents ("a different-orientation source lays it flat"). So the sit clip **drops `Hips.quaternion`
|
||||
like every other clip** (rides the existing `_rotOnly`); the seated read comes from the leg-bone bends +
|
||||
a new **seated foot-replant** in `spawnRig`. `_rotWithHips` is removed.
|
||||
- **`spawnRig({seated})`** (OPT-IN): after the clip plays, tick the mixer once and re-plant the POSED
|
||||
lowest bone to y=0 so the drummer's feet sit on the deck (recovers the hip descent). Standing spawns
|
||||
skip it entirely.
|
||||
- **The drummer**: uses `fleet.sitClip` when present (retiring the R14 `SEAT_DROP` sink — it sits on the
|
||||
riser and the re-plant seats it), tags `fig.userData.procitySeated = true` (F's gate). Reads seated from
|
||||
the **crowd POV** (the kit hides the lower body); measured stature **~1.1–1.3 m** (F's seated gate
|
||||
[0.9,1.5] ✓). Falls back to the `SEAT_DROP` standing-sunk hack if `sitClip` is absent (?noassets, or
|
||||
before F wires `{sit}`), so it never breaks a boot.
|
||||
- **Honest limit**: without the pelvic tilt (Hips.quaternion unusable), it's an upright bent-leg sit — a
|
||||
bit stiff from a hard side/overhead angle, hidden by the kit from the front. A cleaner pose is a manual
|
||||
pelvic-lean pass (v3.2). It's a genuine improvement over the standing-sunk hack (real bent legs, feet on
|
||||
the deck, passes the seated gate) — good enough for the flip.
|
||||
|
||||
### Byte-identical proof (keepers / browsers / walkers) — the load-bearing part
|
||||
The changes are a PARALLEL/opt-in path: `_rotOnly` untouched (walk/idle unchanged), `spawnRig`'s re-plant
|
||||
is `seated`-gated (default off), `fleet.sitClip` is additive, `makeActor` (walkers) untouched. Verified
|
||||
in-shell with `fleet.sitClip` **null AND loaded**: streamed walkers **188/188** face travel, keeper is a
|
||||
standing rig (stature **1.718**, `innerRotY:π`, `replanted:false` — the seated re-plant does NOT run). No
|
||||
existing consumer moves.
|
||||
|
||||
### → Lane F: the `{sit}` load seam (classic-safe by construction)
|
||||
`loadPedFleet(base, { sit })` — pass **`sit: <gig-layer active>`** (true on the default boot, **false under
|
||||
`?classic=1`**). Gated because a raw `sit.glb` fetch would break the classic zero-fetch-delta covenant;
|
||||
default is `sit:false`, and absent/off → `fleet.sitClip` null → the drummer falls back gracefully. My code
|
||||
is committed graceful, so it lands safely before F's commit; the drummer only sits once F passes `sit:true`
|
||||
(same commit as F's [0.9,1.5] seated-stature gate).
|
||||
|
||||
### Amp at C's `stage.backline[]` (ledger #5)
|
||||
`_addBackline` now plants at **C's `stage.backline[0]`** (`{x,z,ry,y}`, up-stage, clear of every bandPose
|
||||
+ watchPoint) instead of D's R15 hardcoded pose — C confirmed `backline[0]` == that pose, so nothing moves
|
||||
on screen (pub 1.44, 0.32, −3.27, ry=π; **0.75 m bass clearance**). Since C dropped its primitive
|
||||
ampStacks, D now owns the whole backline: **placeholder-persists** (a primitive amp shows immediately, the
|
||||
`guitar_amp` GLB swaps in), and the primitive is the **`?noassets` fallback** (fleet-gated, no fetch).
|
||||
Verified: GLB swaps in, primitive under `?noassets`, no crash, 0 fetch.
|
||||
|
||||
---
|
||||
|
||||
## ROUND 15 — v3.0 close: the amp one-liner (ledger #4)
|
||||
|
||||
*PROCITY-D, 2026-07-16. Sole task: move `band.js _addBackline` to C's §3 pose. Everything else (continuity,
|
||||
|
||||
@ -1,5 +1,43 @@
|
||||
# LANE E — cross-lane notes
|
||||
|
||||
## Round 16 — v3.1 THE FLIP: the drummer's sit clip (E asset half) ⟨v3.1 amendment⟩
|
||||
|
||||
> Ledger #2 asset half — the drummer finally sits. **E produces the clip; D wires it** (`_rotWithHips`
|
||||
> + `spawnRig` foot re-plant + F's seated-stature gate). **`sit.glb` delivered, verified, committed +
|
||||
> on depot.** → **Lane D: it's ready** (`web/models/peds/sit.glb`, canon binds 195/195, seated confirmed).
|
||||
|
||||
**Asset:** `web/models/peds/sit.glb` — a mesh-free animation clip, **structurally identical to the
|
||||
shipped `walk.glb`/`idle.glb`**: 66 nodes (65 `mixamorig` bones + root), 0 meshes, 1 dangling skin
|
||||
(inverseBindMatrices + 65 joints, referenced by 0 nodes — the vestigial artifact Blender glTF I/O
|
||||
leaves, which makes glTF tools read it as an armature), 195 channels (65 bones × TRS). 109 KB, no Draco.
|
||||
|
||||
**Source + provenance:** Adobe **Mixamo `Sitting_Idle.fbx`** (533 KB) — fetched by `scp` from the
|
||||
tailnet source box `johnking@100.91.239.7:~/Documents/mixamo-fetch/out/` per Lane D's recipe (the
|
||||
local `~/Documents/mixamo-fetch` path is stale/empty). Converted on-device via Blender headless
|
||||
(`pipeline/fbx_to_clip.py`, **new tool**): import FBX → export skinned GLB → pure-python mesh-strip
|
||||
that keeps the skeleton + dangling skin + animation and re-indexes/compacts. Raw Mixamo `mixamorig:`
|
||||
naming is already canonical (`rigs.js._canon` normalizes `mixamorig<N>` → `mixamorig`, so no bone
|
||||
rename). **Licence zone: Adobe Mixamo royalty-free animation license — the SAME zone as the
|
||||
already-shipped `walk`/`idle` clips** (the whole ped fleet is Mixamo-rigged). Mesh stripped, so only
|
||||
the skeleton *motion* ships (retargeted onto the game's own peds), never Mixamo character geometry.
|
||||
|
||||
**Verified (E's half — D verifies the wired in-scene pose):**
|
||||
- **three.js** (vendored GLTFLoader): loads, dur 4.33 s, 195 tracks, 65 bones, **195/195 bind** to a
|
||||
ped skeleton after `_canon`, **0 NaN** track values (mesh-strip re-index is clean).
|
||||
- **Blender** (the tool that measures `walk` at 1.276 m standing): the clip poses **seated** — Hips
|
||||
descend 0.955 m (standing) → **0.299 m**, stature 1.276 → 0.758 m. Eyes-on bone-dot render confirms
|
||||
the folded seated silhouette vs walk's upright one: `docs/shots/laneE/sit_clip.png` (left walk, right sit).
|
||||
|
||||
**Depot:** published to `digalot.fyi/3god` via the passwordless tailnet ingress under **John's R13
|
||||
authorization** (`_published.json` 38 → 39; confirmed 200 on the public depot). **NOTE (benign):**
|
||||
`sit.glb` — like `vintage-cash-register.glb` — is non-`procity_`-prefixed, so `publish.py --verify`'s
|
||||
`procity_`-only drift filter lists it as "not live" (a **false drift** — it IS live). Cosmetic only;
|
||||
`validate_manifest` gates are unaffected (clips are not manifest GLBs).
|
||||
|
||||
**"Manifest":** ped clips load by **convention** (`rigs.js` `base='models/peds/'`), NOT via
|
||||
`web/assets/manifest.json` — so there is no asset-manifest entry, same as `walk`/`idle`. D owns the
|
||||
loader wiring (`spawnRig`/fleet). Bench-sit loiter (Fable's stretch goal) reuses this same clip.
|
||||
|
||||
## Round 14 — v3.0 PROVENANCE FREEZE ✅ (release round)
|
||||
|
||||
> **FROZEN for the v3.0 tag** (2026-07-16). Lane E R14 is a provenance close-out — **no new synthesis**
|
||||
@ -30,6 +68,7 @@
|
||||
| Skins (2D) | 96 | 90sDJsim ports (7 facade + 2 ground) + thriftgod R1 store-art + `flux_local` gap-fill fronts/posters | `gen_skins.py --local` (on-device) | 🟢 web-ok |
|
||||
| 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 |
|
||||
| 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) |
|
||||
|
||||
**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).
|
||||
|
||||
@ -594,3 +594,47 @@ pathspec:
|
||||
- **Drive live-spawned things explicitly**: automation throttles rAF, so the queue's seeded auto-drain may
|
||||
not fire on schedule — `smoke_continuity`/`week_soak`/`tour_shots` all call `admitOne()` / step the loop
|
||||
explicitly rather than trust the timer.
|
||||
|
||||
---
|
||||
|
||||
## 16. Round 16 — v3.1 THE FLIP (`?classic=1` is the new covenant). F bookends; opening below, close after.
|
||||
|
||||
The town shows its best by default. After three v3 stages the gig layer flips ON, and the mature v2 flags
|
||||
(`weather`, `winmap`, `tram`) flip with it. F owns the flip; A–E landed their v3.1 polish against it (D's
|
||||
seated drummer, B's town-wide pools + default re-baseline, C's backline unification, A's corner-poster +
|
||||
no-spine fixture, E's `sit.glb`).
|
||||
|
||||
### The flip (ledger #1) — the covenant MOVED from "flags off" to `?classic=1`
|
||||
- **Shell** (`index.html`): `const CLASSIC = has('classic') && get('classic')!=='0'`, `const flagOn = name
|
||||
=> !CLASSIC && get(name)!=='0'`. `gigs`/`weather`/`tram` construct via `flagOn`; `loadPedFleet({sit:!CLASSIC})`
|
||||
gives the drummer his clip while keeping classic zero-fetch; `window.PROCITY.flags` publishes the intent.
|
||||
- **`buildings.js`**: `winmap` reads the SAME rule (default-on, classic-off) — the one cross-lane flip line,
|
||||
because winmap's flag lives in B's file. Marked `[Lane F R16 THE FLIP]`.
|
||||
- **Two goldens, two gates:** classic → `0x3fa36874` (byte-identical v2 forever, the frozen regression
|
||||
target); default → the gig golden `0xb1d48ea1`. `flags_off_regression` became **`classic_regression`**
|
||||
(boot `?classic=1`); a new **`default_boot_gate`** proves the no-flag boot is the full experience (all four
|
||||
flags on, gig layer present, gig golden green, budget). Every gig smoke re-points to the DEFAULT boot.
|
||||
|
||||
### Seated-stature gate (ledger #3) — published in the brief, no mid-round handshake
|
||||
A seated figure fails the standing no-giants floor by design. Contract (in the round brief so D built to it):
|
||||
D tags seated figs `fig.userData.procitySeated = true`; F's `interior_scale_check.py` + `flags_check.py`
|
||||
accept them at **stature ∈ [0.9, 1.5] m** (still < ceiling, feet planted); standing stays [1.4, 2.0]. Landed
|
||||
in the opening commit. Verified live: the seated drummer passes at **1.32 m stature**.
|
||||
|
||||
### Numbers (measured, seed 20261990 → "Boolarra Heads")
|
||||
| thing | number |
|
||||
|---|---|
|
||||
| `?classic=1` | byte-identical v2: fingerprint 0x3fa36874, gig layer absent, streamed roster on, 160 draws / 21k tris |
|
||||
| default boot | all four flags on · gig golden 0xb1d48ea1 · 14 gigs, 15 posters · **112 draws @ the venue block** (≤300) |
|
||||
| district (default) | 3 venues, **3 playing** (corner-poster re-pin shifted the schedule) · 3 genres live |
|
||||
| seated drummer | **1.32 m** stature — passes the seated band [0.9,1.5]; the standing crew 1.68–1.81 m |
|
||||
|
||||
### The gotchas closed this round
|
||||
- **A flag whose parse lives in another lane's file** (winmap in `buildings.js`) still flips as part of F's
|
||||
structural change — one marked line, same `!classic && !=='0'` rule as the shell.
|
||||
- **selfcheck prints the base fingerprint in its summary but asserts the GIG golden silently**, so the
|
||||
default-boot gate gates on the ALL-GREEN run (which includes the gig-golden assert), not a stdout substring.
|
||||
- **`P.winmap` isn't exposed** — the default gate verifies winmap *intent* via `window.PROCITY.flags`; the
|
||||
actual shader is B's own `winmap` smoke.
|
||||
- **`?classic` must zero the fetch delta**, so `sit.glb` is gated on `!CLASSIC` (not just on the fleet) — a
|
||||
raw sit fetch under classic would break the covenant even though the drummer never spawns there.
|
||||
|
||||
BIN
docs/shots/laneE/sit_clip.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
1
docs/shots/release_v3_1/contact.html
Normal file
@ -0,0 +1 @@
|
||||
<!doctype html><meta charset="utf-8"><title>PROCITY v3.1 — release tour</title><style>body{background:#14110c;color:#f4efe6;font:14px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;margin:24px}h1{font-weight:700}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:18px}figure{margin:0;background:#1d1810;border-radius:10px;overflow:hidden}img{width:100%;display:block}figcaption{padding:8px 12px;color:#cdc4b2}b{color:#ffd9a0}</style><h1>PROCITY v3.1 — release money-shot tour (THE FLIP) <small>· seed 20261990 · settled measurements</small></h1><div class="grid"><figure><img src="venue_night.png" alt="venue_night" loading="lazy"><figcaption><b>venue_night</b><br>124 draws · 57,669 tris · queue 2 · 15 posters</figcaption></figure><figure><img src="district_posters.png" alt="district_posters" loading="lazy"><figcaption><b>district_posters</b><br>189 draws · 139,838 tris · queue 2 · 15 posters</figcaption></figure><figure><img src="queue_night.png" alt="queue_night" loading="lazy"><figcaption><b>queue_night</b><br>122 draws · 62,763 tris · queue 2 · 15 posters</figcaption></figure><figure><img src="tram_stop.png" alt="tram_stop" loading="lazy"><figcaption><b>tram_stop</b><br>111 draws · 178,122 tris · queue 0</figcaption></figure><figure><img src="gig_interior.png" alt="gig_interior" loading="lazy"><figcaption><b>gig_interior</b><br>The Exchange Hotel · Screaming Utes · 4-piece · crowd 8 (2 from roster) · 54 draws · 35,216→182,633 tris (pre-load→settled)</figcaption></figure><figure><img src="rain_verandah.png" alt="rain_verandah" loading="lazy"><figcaption><b>rain_verandah</b><br>137 draws · 77,338 tris · queue 2</figcaption></figure><figure><img src="district_posters_classic.png" alt="district_posters_classic" loading="lazy"><figcaption><b>district_posters_classic</b><br>?classic=1 — the town before v3 (no gig layer, no pools)</figcaption></figure></div>
|
||||
BIN
docs/shots/release_v3_1/district_posters.png
Normal file
|
After Width: | Height: | Size: 219 KiB |
BIN
docs/shots/release_v3_1/district_posters_classic.png
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
docs/shots/release_v3_1/gig_interior.png
Normal file
|
After Width: | Height: | Size: 944 KiB |
BIN
docs/shots/release_v3_1/queue_night.png
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
docs/shots/release_v3_1/rain_verandah.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
docs/shots/release_v3_1/tram_stop.png
Normal file
|
After Width: | Height: | Size: 843 KiB |
BIN
docs/shots/release_v3_1/venue_night.png
Normal file
|
After Width: | Height: | Size: 334 KiB |
@ -36,5 +36,6 @@
|
||||
"procity_street_novelty_record_01.glb",
|
||||
"procity_street_park_bench_01.glb",
|
||||
"procity_street_streetlight_01.glb",
|
||||
"sit.glb",
|
||||
"vintage-cash-register.glb"
|
||||
]
|
||||
134
pipeline/fbx_to_clip.py
Normal file
@ -0,0 +1,134 @@
|
||||
"""PROCITY mixamo-clip converter — a Mixamo `.fbx` animation → a mesh-free house clip GLB, the exact
|
||||
shape of the shipped `web/models/peds/walk.glb` / `idle.glb`: armature + one animation, NO mesh, but
|
||||
WITH the (now dangling) skin that Blender's glTF I/O leaves behind — 65 `mixamorig` joints +
|
||||
inverseBindMatrices, referenced by zero nodes. That vestigial skin is what makes glTF tooling (and
|
||||
Blender re-import) reconstruct the file as an armature; three.js binds the clip by track name via
|
||||
Lane D's `rigs.js` `_canon` regardless, so no bone rename is needed.
|
||||
|
||||
Runs headless on this box's Blender:
|
||||
BL=/Applications/Blender.app/Contents/MacOS/Blender
|
||||
"$BL" --background --python fbx_to_clip.py -- IN.fbx OUT.glb [ClipName]
|
||||
|
||||
Two stages, one command: (1) Blender imports the FBX (mesh+armature+anim) and exports a SKINNED GLB
|
||||
(the skin needs the mesh to be emitted); (2) a pure-python glTF strip removes the mesh + its geometry
|
||||
accessors, re-indexing/compacting so only the skeleton + dangling skin + animation remain (≈100 KB).
|
||||
"""
|
||||
import bpy, sys, os, json, struct, tempfile
|
||||
|
||||
ARGV = sys.argv[sys.argv.index("--") + 1:]
|
||||
FBX, OUT = ARGV[0], ARGV[1]
|
||||
CLIP = ARGV[2] if len(ARGV) > 2 else "Clip"
|
||||
os.makedirs(os.path.dirname(os.path.abspath(OUT)), exist_ok=True)
|
||||
|
||||
|
||||
def wipe():
|
||||
for o in list(bpy.data.objects):
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
for coll in (bpy.data.meshes, bpy.data.materials, bpy.data.images, bpy.data.armatures, bpy.data.actions):
|
||||
for d in list(coll):
|
||||
if d.users == 0:
|
||||
coll.remove(d)
|
||||
|
||||
|
||||
# ── stage 2: pure-python glTF mesh-strip (keep skeleton + dangling skin + animation) ──────────────
|
||||
def read_glb(p):
|
||||
d = open(p, "rb").read()
|
||||
off = 12; js = None; binblob = b""
|
||||
while off < len(d):
|
||||
clen, ctype = struct.unpack("<II", d[off:off + 8]); off += 8
|
||||
chunk = d[off:off + clen]; off += clen
|
||||
if ctype == 0x4E4F534A: js = json.loads(chunk)
|
||||
elif ctype == 0x004E4942: binblob = chunk
|
||||
return js, binblob
|
||||
|
||||
|
||||
def write_glb(js, binblob, out):
|
||||
jb = json.dumps(js, separators=(",", ":")).encode()
|
||||
jb += b" " * ((4 - len(jb) % 4) % 4)
|
||||
bb = binblob + b"\x00" * ((4 - len(binblob) % 4) % 4)
|
||||
total = 12 + 8 + len(jb) + 8 + len(bb)
|
||||
with open(out, "wb") as f:
|
||||
f.write(b"glTF"); f.write(struct.pack("<II", 2, total))
|
||||
f.write(struct.pack("<II", len(jb), 0x4E4F534A)); f.write(jb)
|
||||
f.write(struct.pack("<II", len(bb), 0x004E4942)); f.write(bb)
|
||||
|
||||
|
||||
def strip_mesh(inpath, outpath):
|
||||
js, binblob = read_glb(inpath)
|
||||
keep = set() # accessors to retain: skin IBMs + all anim I/O
|
||||
for sk in js.get("skins", []):
|
||||
if "inverseBindMatrices" in sk:
|
||||
keep.add(sk["inverseBindMatrices"])
|
||||
for an in js.get("animations", []):
|
||||
for s in an["samplers"]:
|
||||
keep.add(s["input"]); keep.add(s["output"])
|
||||
mesh_nodes = {i for i, n in enumerate(js["nodes"]) if "mesh" in n}
|
||||
js.pop("meshes", None) # drop mesh defs; skins[] stay (become dangling)
|
||||
for n in js["nodes"]:
|
||||
n.pop("mesh", None); n.pop("skin", None)
|
||||
# orphan the former-mesh nodes from the scene graph (leave them in the array so node indices —
|
||||
# skin.joints, animation channel targets — stay valid; three.js ignores unreachable nodes)
|
||||
for sc in js.get("scenes", []):
|
||||
if "nodes" in sc:
|
||||
sc["nodes"] = [x for x in sc["nodes"] if x not in mesh_nodes]
|
||||
for n in js["nodes"]:
|
||||
if "children" in n:
|
||||
n["children"] = [x for x in n["children"] if x not in mesh_nodes]
|
||||
if not n["children"]:
|
||||
n.pop("children")
|
||||
# re-index accessors → keep only the retained set
|
||||
old_acc = js["accessors"]
|
||||
keep_sorted = sorted(keep)
|
||||
accmap = {old: i for i, old in enumerate(keep_sorted)}
|
||||
js["accessors"] = [old_acc[i] for i in keep_sorted]
|
||||
# keep bufferViews referenced by retained accessors; compact the binary
|
||||
bv_keep = sorted({old_acc[i]["bufferView"] for i in keep_sorted if "bufferView" in old_acc[i]})
|
||||
bvmap = {old: i for i, old in enumerate(bv_keep)}
|
||||
old_bv = js["bufferViews"]
|
||||
newbin = bytearray(); newbvs = []
|
||||
for old in bv_keep:
|
||||
bv = dict(old_bv[old]); start = bv.get("byteOffset", 0); length = bv["byteLength"]
|
||||
pad = (4 - len(newbin) % 4) % 4; newbin += b"\x00" * pad
|
||||
bv["byteOffset"] = len(newbin); newbin += binblob[start:start + length]
|
||||
newbvs.append(bv)
|
||||
js["bufferViews"] = newbvs
|
||||
for a in js["accessors"]:
|
||||
if "bufferView" in a:
|
||||
a["bufferView"] = bvmap[a["bufferView"]]
|
||||
for sk in js.get("skins", []):
|
||||
if "inverseBindMatrices" in sk:
|
||||
sk["inverseBindMatrices"] = accmap[sk["inverseBindMatrices"]]
|
||||
for an in js.get("animations", []):
|
||||
for s in an["samplers"]:
|
||||
s["input"] = accmap[s["input"]]; s["output"] = accmap[s["output"]]
|
||||
js["buffers"] = [{"byteLength": len(newbin)}]
|
||||
write_glb(js, bytes(newbin), outpath)
|
||||
return js
|
||||
|
||||
|
||||
def main():
|
||||
wipe()
|
||||
bpy.ops.import_scene.fbx(filepath=FBX)
|
||||
arms = [o for o in bpy.data.objects if o.type == "ARMATURE"]
|
||||
if not arms:
|
||||
raise SystemExit("no armature in FBX")
|
||||
arm = arms[0]
|
||||
n_mesh = len([o for o in bpy.data.objects if o.type == "MESH"])
|
||||
if arm.animation_data and arm.animation_data.action:
|
||||
arm.animation_data.action.name = CLIP
|
||||
bones = [b.name for b in arm.data.bones]
|
||||
mixamo = [b for b in bones if b.startswith("mixamorig")]
|
||||
bpy.ops.object.select_all(action="SELECT") # armature + mesh → Blender emits a skin
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".glb", delete=False).name
|
||||
bpy.ops.export_scene.gltf(filepath=tmp, export_format="GLB", use_selection=True,
|
||||
export_animations=True, export_yup=True, export_skins=True)
|
||||
js = strip_mesh(tmp, OUT)
|
||||
os.unlink(tmp)
|
||||
sz = os.path.getsize(OUT)
|
||||
print(f"CLIP_DONE {os.path.basename(OUT)} bones={len(bones)} (mixamorig {len(mixamo)}) "
|
||||
f"mesh_stripped={n_mesh} meshes_out={len(js.get('meshes', []))} skins_out={len(js.get('skins', []))} "
|
||||
f"nodes={len(js['nodes'])} anims={len(js.get('animations', []))} {sz // 1024}KB")
|
||||
|
||||
|
||||
main()
|
||||
@ -27,7 +27,8 @@ ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
PORT = 8130
|
||||
HOST = f'http://127.0.0.1:{PORT}'
|
||||
SEED = 20261990
|
||||
GOLDEN_HASH = 0x3fa36874 # frozen determinism anchor (Lane A selfcheck) — UNCHANGED by the R7 flip
|
||||
GOLDEN_HASH = 0x3fa36874 # frozen v2 determinism anchor (?classic boot) — UNCHANGED by the flips
|
||||
GIG_GOLDEN = 0xb1d48ea1 # [R16] the DEFAULT boot's gig-augmented plan fingerprint (A's R16 corner-poster re-pin)
|
||||
# flags-off DBG.shot('street_noon') snapshot. RE-PINNED at the R7 roster flip (prime-law amendment):
|
||||
# old (v1.1, roster-off): 163 draws, 19000 tris
|
||||
# new (R7, roster default-on): 164 draws, ~66000 tris
|
||||
@ -52,6 +53,12 @@ def WARN(m): warns.append(m); print(f" \033[33m! WARN\033[0m {m}")
|
||||
def OK(m): print(f" \033[32m✓\033[0m {m}")
|
||||
def head(m): print(f"\n\033[1m{m}\033[0m")
|
||||
|
||||
def stature_ok(f):
|
||||
# [R16 ledger #3] seated figs (the drummer — fig.userData.procitySeated) pass the no-giants floor at a
|
||||
# SEATED band [0.9,1.5] m; standing figs stay [1.4,2.0]. A seated human fails the standing floor by design.
|
||||
lo, hi = (0.9, 1.5) if f.get('seated') else (1.4, 2.0)
|
||||
return lo <= f['stature'] <= hi
|
||||
|
||||
def port_up(port):
|
||||
with socket.socket() as s:
|
||||
s.settimeout(0.4)
|
||||
@ -89,7 +96,7 @@ def gigs_landed(p):
|
||||
"""Boot ?gigs=1; landed iff Lane A's augmentation produced a schedule (else the flag is inert)."""
|
||||
b, pg, _ = new_page(p)
|
||||
try:
|
||||
boot(pg, 'gigs=1')
|
||||
boot(pg, '')
|
||||
return bool(pg.evaluate("() => !!(window.PROCITY.plan.gigs && window.PROCITY.plan.gigs.length)"))
|
||||
except Exception:
|
||||
return False
|
||||
@ -110,11 +117,15 @@ def plansrc_landed(p):
|
||||
b.close()
|
||||
|
||||
# ── the three checks ─────────────────────────────────────────────────────────
|
||||
def flags_off_regression(p):
|
||||
head('FLAGS-OFF REGRESSION (prime-law enforcement)')
|
||||
def classic_regression(p):
|
||||
# [R16 — THE FLIP] The prime-law covenant MOVED from "flags off" to "?classic=1": the classic boot must
|
||||
# stay byte-identical to the frozen v2 baseline FOREVER — synthetic fingerprint 0x3fa36874, zero gig
|
||||
# layer, no opt-in flags, the R7 streamed roster (which IS the v2.0 baseline). Same assertions as the old
|
||||
# flags-off gate, now booted with ?classic=1 (the four flipped flags are off, everything else v2-default).
|
||||
head('CLASSIC REGRESSION (?classic=1 → the frozen v2 covenant)')
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, '')
|
||||
boot(pg, 'classic=1')
|
||||
state = pg.evaluate("""() => {
|
||||
const P=window.PROCITY, D=window.DBG;
|
||||
D.shot('street_noon'); // fixed pose → comparable draws/tris
|
||||
@ -138,26 +149,26 @@ def flags_off_regression(p):
|
||||
if hash_ok and r.returncode == 0: OK(f'plan fingerprint == golden {hex(GOLDEN_HASH)} (selfcheck green)')
|
||||
else: FAIL(f'plan fingerprint != golden {hex(GOLDEN_HASH)} — determinism/prime-law broken')
|
||||
|
||||
# winmap + dig stay OPT-IN (off by default). The streamed roster is now the shipping DEFAULT
|
||||
# (R7 flip / prime-law amendment) — so it must be ON, not off, on a no-flag boot.
|
||||
# winmap flips OFF under ?classic (it's default-on now); dig stays opt-in. The streamed roster IS the
|
||||
# v2.0 baseline (R7 flip / prime-law amendment) — so it must be ON under ?classic.
|
||||
if state['winmap'] or state['digActive']:
|
||||
FAIL(f"an opt-in v2 flag is active on default boot (winmap/dig): {state}")
|
||||
else: OK('opt-in flags off on default boot (winmap/dig)')
|
||||
FAIL(f"a flipped/opt-in flag leaked into ?classic (winmap/dig): {state}")
|
||||
else: OK('flipped + opt-in flags off under ?classic (winmap/dig)')
|
||||
if state['streamMode']:
|
||||
OK('R7 roster flip in effect — streamed roster ACTIVE on default boot')
|
||||
else: FAIL('roster flip REGRESSED — default boot is not streaming (expected streamMode:true)')
|
||||
OK('R7 roster flip in effect — streamed roster ACTIVE under ?classic (the v2 baseline)')
|
||||
else: FAIL('roster flip REGRESSED — ?classic is not streaming (expected streamMode:true)')
|
||||
|
||||
# [R12] gig layer absent flags-off (prime flag law for v3). Any of these leaking means ?gigs=1
|
||||
# has escaped its flag and the frozen v2 goldens are living on borrowed time.
|
||||
# [R12/R16] gig layer wholly absent under ?classic. Any of these present means the gig layer escaped
|
||||
# the classic gate and the frozen v2 covenant is broken.
|
||||
gig_leaks = [k for k in ('gigState', 'planGigs', 'posters', 'pub', 'venueGeo') if state[k]]
|
||||
if not gig_leaks:
|
||||
OK('v3 gig layer wholly absent flags-off (no state machine, no plan.gigs/posters, no pub, no venue geometry)')
|
||||
OK('v3 gig layer wholly absent under ?classic (no state machine, no plan.gigs/posters, no pub, no venue geometry)')
|
||||
else:
|
||||
FAIL(f"v3 gig layer LEAKED into the default boot: {[(k, state[k]) for k in gig_leaks]}")
|
||||
FAIL(f"v3 gig layer LEAKED into ?classic: {[(k, state[k]) for k in gig_leaks]}")
|
||||
|
||||
if state['draws'] <= STREET_DRAWS_MAX and state['tris'] <= STREET_TRIS_MAX:
|
||||
OK(f"flags-off street view within budget (draws {state['draws']}≤{STREET_DRAWS_MAX}, tris {state['tris']}≤{STREET_TRIS_MAX})")
|
||||
else: FAIL(f"flags-off street view OVER budget: draws {state['draws']}, tris {state['tris']}")
|
||||
OK(f"?classic street view within budget (draws {state['draws']}≤{STREET_DRAWS_MAX}, tris {state['tris']}≤{STREET_TRIS_MAX})")
|
||||
else: FAIL(f"?classic street view OVER budget: draws {state['draws']}, tris {state['tris']}")
|
||||
|
||||
# Drift WARN is DRAWS-ONLY — tris is crowd-variable post-flip (guarded by the hard cap above).
|
||||
dd = abs(state['draws'] - BASE_DRAWS) / BASE_DRAWS
|
||||
@ -166,8 +177,8 @@ def flags_off_regression(p):
|
||||
else:
|
||||
WARN(f"draws drifted >40% from baseline ({state['draws']} vs {BASE_DRAWS}) — investigate")
|
||||
|
||||
if errs: FAIL(f"{len(errs)} console error(s) on default boot; first: {errs[0][:140]}")
|
||||
else: OK('0 console errors on default boot')
|
||||
if errs: FAIL(f"{len(errs)} console error(s) under ?classic; first: {errs[0][:140]}")
|
||||
else: OK('0 console errors under ?classic')
|
||||
|
||||
# R7 escape hatch: ?roster=v1 must restore the fixed roster (streamMode:false), render, no errors.
|
||||
b2, pg2, errs2 = new_page(p)
|
||||
@ -183,6 +194,54 @@ def flags_off_regression(p):
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
|
||||
def default_boot_gate(p):
|
||||
# [R16 — THE FLIP] The enforcement arm of the DEFAULT law: the no-flag boot is now the full experience —
|
||||
# all four flags on, the gig layer present, deterministic to the gig golden. This is the number every
|
||||
# budget/smoke now means (its inverse is classic_regression above).
|
||||
head('DEFAULT-BOOT GATE (the flip → the town shows its best by default)')
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, '')
|
||||
state = pg.evaluate("""() => {
|
||||
const P = window.PROCITY, D = window.DBG;
|
||||
D.setSegment(5); D.shot('venue_night'); // NIGHT at a venue → the busiest default view (gigs+pools+winmap+tram)
|
||||
const i = D.info();
|
||||
return { flags: P.flags,
|
||||
gigState: !!P.gigs, planGigs: (P.plan.gigs||[]).length, posters: (P.plan.posters||[]).length,
|
||||
pub: !!(P.plan.shops||[]).find(s => s.venue), venueGeo: !!P.scene.getObjectByName('venue-presentation'),
|
||||
winmapActive: !!(P.winmap && P.winmap.active), tram: !!P.scene.getObjectByName('tram'),
|
||||
draws: i.drawCalls, tris: i.tris };
|
||||
}""")
|
||||
f = state['flags'] or {}
|
||||
if f.get('gigs') and f.get('weather') and f.get('winmap') and f.get('tram') and not f.get('classic'):
|
||||
OK('default boot flips all four ON (gigs · weather · winmap · tram), classic off')
|
||||
else:
|
||||
FAIL(f"default boot flag intent wrong: {f}")
|
||||
present = [k for k in ('gigState', 'planGigs', 'posters', 'pub', 'venueGeo') if state[k]]
|
||||
if len(present) == 5 and state['tram']:
|
||||
OK(f"the town is live: gig layer present ({state['planGigs']} gigs, {state['posters']} posters, venue geo) · tram up (winmap is a window shader — its own smoke)")
|
||||
else:
|
||||
FAIL(f"default boot not fully live: gig={present}, tram={state['tram']}")
|
||||
# The default boot's plan IS the gig-augmented plan; selfcheck pins its fingerprint to the gig golden
|
||||
# (0xb1d48ea1) INTERNALLY and is ALL GREEN only if it matches. (selfcheck prints the base fingerprint
|
||||
# in its summary but asserts the gig golden silently — so we gate on the green run, not a substring.)
|
||||
r = subprocess.run(['node', 'web/js/citygen/selfcheck.js'], cwd=str(ROOT), capture_output=True, text=True)
|
||||
if r.returncode == 0 and 'ALL GREEN' in r.stdout:
|
||||
OK(f"default plan pins to the gig golden {hex(GIG_GOLDEN)} (selfcheck ALL GREEN — the gig-layer golden asserts pass)")
|
||||
else:
|
||||
FAIL(f"selfcheck not green — the gig golden {hex(GIG_GOLDEN)} or determinism broke: {r.stdout[-160:]}")
|
||||
# budget on the DEFAULT boot at the busiest venue block (the number the budget law now means)
|
||||
if state['draws'] <= STREET_DRAWS_MAX and state['tris'] <= STREET_TRIS_MAX:
|
||||
OK(f"default-boot street budget @ venue block: {state['draws']} draws ≤ {STREET_DRAWS_MAX}, {state['tris']:,} tris ≤ {STREET_TRIS_MAX:,}")
|
||||
else:
|
||||
FAIL(f"default-boot street OVER budget @ venue block: {state['draws']} draws / {state['tris']:,} tris (Fable scope call — not silent-trim)")
|
||||
if errs: FAIL(f"{len(errs)} console error(s) on the default boot; first: {errs[0][:140]}")
|
||||
else: OK('0 console errors on the default boot')
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
|
||||
def smoke(p, label, query, is_combo=False):
|
||||
head(f'SMOKE: {label} (?{query})')
|
||||
b, pg, errs = new_page(p)
|
||||
@ -612,7 +671,7 @@ def smoke_gigs(p):
|
||||
for _ in range(2):
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, 'gigs=1')
|
||||
boot(pg, '')
|
||||
sigs.append(pg.evaluate("() => JSON.stringify({g: window.PROCITY.plan.gigs, p: window.PROCITY.plan.posters})"))
|
||||
if errs: FAIL(f"gigs boot: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||||
finally:
|
||||
@ -626,7 +685,7 @@ def smoke_gigs(p):
|
||||
# 2) the gig night itself: band · crowd · dance mix · budget · the live bed · no giants
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, 'gigs=1')
|
||||
boot(pg, '')
|
||||
pg.keyboard.press('Space') # gesture → AudioContext unlock (headless-safe, no pointer-lock)
|
||||
night = pg.evaluate("""async () => {
|
||||
const P = window.PROCITY, D = window.DBG, THREE = P.THREE, v = new THREE.Vector3();
|
||||
@ -650,7 +709,8 @@ def smoke_gigs(p):
|
||||
(m.actor.inner || m.actor.fig).traverse(o => { if (o.isBone && /head/i.test(o.name)) {
|
||||
o.getWorldPosition(v); crown = Math.max(crown, v.y); } });
|
||||
return { part: m.part, kind: m.kind, dance: !!m.dance,
|
||||
crown: +crown.toFixed(3), stature: +(crown - (m.base.y || 0)).toFixed(3) };
|
||||
crown: +crown.toFixed(3), stature: +(crown - (m.base.y || 0)).toFixed(3),
|
||||
seated: !!(m.actor.fig.userData && m.actor.fig.userData.procitySeated) }; // [R16 #3]
|
||||
});
|
||||
// Force one clean interior frame rather than trusting whatever the last rAF left in
|
||||
// renderer.info — a throttled/backgrounded tab otherwise reports the last STREET frame
|
||||
@ -693,7 +753,7 @@ def smoke_gigs(p):
|
||||
FAIL(f"gig-night interior budget: {night['draws']} draws > {GIG_DRAWS_MAX} (tris {night['tris']:,})")
|
||||
# no giants on the stage/crowd rigs (R10 gate, inherited by the gig path)
|
||||
rigs = [f for f in night['figs'] if f['kind'] == 'rig']
|
||||
bad = [f for f in rigs if not (1.4 <= f['stature'] <= 2.0) or f['crown'] >= night['H']]
|
||||
bad = [f for f in rigs if not stature_ok(f) or f['crown'] >= night['H']] # [R16 #3] seated exemption
|
||||
if not rigs:
|
||||
WARN(f"no-giants: crew spawned as placeholders (no fleet) — rig check n/a ({len(night['figs'])} figs)")
|
||||
elif bad:
|
||||
@ -719,7 +779,7 @@ def smoke_gigs(p):
|
||||
WARN(f"district: could not read manifest music keys ({e}) — gigKey-resolves check is JS-only")
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, 'gigs=1')
|
||||
boot(pg, '')
|
||||
pg.keyboard.press('Space')
|
||||
district = pg.evaluate("""async () => {
|
||||
const P = window.PROCITY, D = window.DBG, THREE = P.THREE, v = new THREE.Vector3();
|
||||
@ -737,7 +797,8 @@ def smoke_gigs(p):
|
||||
let crown = 0; m.actor.fig.updateWorldMatrix(true, true);
|
||||
(m.actor.inner || m.actor.fig).traverse(o => { if (o.isBone && /head/i.test(o.name)) {
|
||||
o.getWorldPosition(v); crown = Math.max(crown, v.y); } });
|
||||
return { part: m.part, kind: m.kind, stature: +(crown - (m.base.y || 0)).toFixed(3) };
|
||||
return { part: m.part, kind: m.kind, stature: +(crown - (m.base.y || 0)).toFixed(3),
|
||||
seated: !!(m.actor.fig.userData && m.actor.fig.userData.procitySeated) }; // [R16 #3]
|
||||
});
|
||||
P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera);
|
||||
out.push({ id, kind: shop && shop.venueKind, genreKey: shop && shop.genreKey,
|
||||
@ -767,7 +828,7 @@ def smoke_gigs(p):
|
||||
if d['band'] == 4: OK(f" {tag}: 4-piece band")
|
||||
else: FAIL(f" {tag}: expected a 4-piece, got {d['band']}")
|
||||
rigs = [f for f in d['figs'] if f['kind'] == 'rig']
|
||||
bad = [f for f in rigs if not (1.4 <= f['stature'] <= 2.0)]
|
||||
bad = [f for f in rigs if not stature_ok(f)] # [R16 #3] seated exemption
|
||||
if rigs and bad: FAIL(f" {tag}: {len(bad)} giant rig(s) by stature: {bad[:2]}")
|
||||
if len(genres_seen) >= 2:
|
||||
OK(f"district: {len(genres_seen)} distinct genres verified live ({', '.join(sorted(genres_seen))})")
|
||||
@ -803,7 +864,7 @@ def smoke_gigs(p):
|
||||
# NIGHT with the gig on, let the block stream in, and sample the settled composer frame.
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, 'gigs=1')
|
||||
boot(pg, '')
|
||||
st = pg.evaluate("""async () => {
|
||||
const P = window.PROCITY, D = window.DBG;
|
||||
D.setSegment(5); // NIGHT — frontage lit, posters up
|
||||
@ -832,7 +893,7 @@ def smoke_gigs(p):
|
||||
# 3) cover ruling, PAID night (seed 20261990 → $10): debit once · no re-charge on re-entry · broke = knockback
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, 'gigs=1')
|
||||
boot(pg, '')
|
||||
paid = pg.evaluate("""async () => {
|
||||
const P = window.PROCITY, D = window.DBG;
|
||||
const id = P.gigs.venueShopIds[0]; // R15: no scalar alias — the primary venue by id
|
||||
@ -879,7 +940,7 @@ def smoke_gigs(p):
|
||||
# 4) cover ruling, FREE night (seed 1234 → cover 0): walk straight in, wallet untouched
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
pg.goto(f'{HOST}/index.html?seed={FREE_GIG_SEED}&dbg=1&gigs=1')
|
||||
pg.goto(f'{HOST}/index.html?seed={FREE_GIG_SEED}&dbg=1')
|
||||
pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=20000)
|
||||
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
|
||||
free = pg.evaluate("""async () => {
|
||||
@ -905,7 +966,7 @@ def smoke_gigs(p):
|
||||
if ('/assets/audio/' in r.url or r.url.endswith('.ogg') or r.url.endswith('.m4a')
|
||||
or 'custom_bands' in r.url or r.url.endswith('.glb')) else None)
|
||||
try:
|
||||
boot(pg, 'gigs=1&noassets=1')
|
||||
boot(pg, 'noassets=1')
|
||||
pg.keyboard.press('Space')
|
||||
na = pg.evaluate("""async () => {
|
||||
const P = window.PROCITY, D = window.DBG;
|
||||
@ -945,7 +1006,7 @@ def smoke_continuity(p):
|
||||
return
|
||||
b, pg, errs = new_page(p)
|
||||
try:
|
||||
boot(pg, 'gigs=1')
|
||||
boot(pg, '')
|
||||
pg.keyboard.press('Space')
|
||||
res = pg.evaluate("""async () => {
|
||||
const P = window.PROCITY, D = window.DBG;
|
||||
@ -1023,7 +1084,8 @@ def main():
|
||||
else:
|
||||
print("· plansrc=osm not landed (Lane A) — skipping that flag")
|
||||
|
||||
flags_off_regression(p)
|
||||
classic_regression(p) # [R16] the frozen v2 covenant moved here: ?classic=1 → byte-identical
|
||||
default_boot_gate(p) # [R16] the flip: the no-flag boot is the full experience (all four on)
|
||||
interior_budget(p)
|
||||
for fl in flags:
|
||||
smoke(p, fl.split('=')[0], fl)
|
||||
|
||||
@ -90,7 +90,7 @@ FLAGS_SKIP=0; for a in "$@"; do [ "$a" = "--no-flags" ] && FLAGS_SKIP=1; done
|
||||
if [ "$FLAGS_SKIP" = 1 ]; then
|
||||
soft_skip "v2 flags harness" "--no-flags"
|
||||
elif [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then
|
||||
run_gate "v2 flags harness (flags-off regression · per-flag · all-on combo · STRICT r7)" \
|
||||
run_gate "flags harness (classic regression · default-boot gate · per-flag · smokes · STRICT · R16 flip)" \
|
||||
tools/.venv/bin/python tools/flags_check.py
|
||||
else
|
||||
soft_skip "v2 flags harness" "Playwright venv absent (python3 -m venv tools/.venv && …/pip install playwright)"
|
||||
|
||||
@ -34,6 +34,7 @@ if '--seed' in sys.argv:
|
||||
SEED = int(sys.argv[sys.argv.index('--seed') + 1])
|
||||
|
||||
LO, HI = 1.4, 2.0 # human crown band (Round-10 gate)
|
||||
SEAT_LO, SEAT_HI = 0.9, 1.5 # [R16 ledger #3] seated figs (the drummer): a seated human's feet→crown span, still < ceiling, feet planted
|
||||
FOOT_EPS = 0.05 # feet must plant at floor y=0
|
||||
SAMPLE_TYPES = ['opshop', 'toy', 'video', 'record', 'book', 'milkbar', 'clothes', 'hardware']
|
||||
|
||||
@ -76,7 +77,8 @@ MEASURE_JS = r"""
|
||||
(a.inner || a.fig).traverse(o => { if (o.isBone) { o.getWorldPosition(v);
|
||||
if (/head/i.test(o.name)) crown = Math.max(crown, v.y); foot = Math.min(foot, v.y); } });
|
||||
return { kind: k.kind, browse: !!k.browse, crown: +crown.toFixed(3), foot: +foot.toFixed(3),
|
||||
stature: +(crown - foot).toFixed(3) }; // [R13 debt #6] feet→crown span (platform-independent)
|
||||
stature: +(crown - foot).toFixed(3), // [R13 debt #6] feet→crown span (platform-independent)
|
||||
seated: !!(a.fig && a.fig.userData && a.fig.userData.procitySeated) }; // [R16 ledger #3] seated-fig tag
|
||||
});
|
||||
return { H: +H.toFixed(2), figs };
|
||||
}
|
||||
@ -124,7 +126,8 @@ def main():
|
||||
bad = []
|
||||
for f in figs:
|
||||
total_figs += 1
|
||||
if not (LO <= f['stature'] <= HI): bad.append(f"stature {f['stature']}m out of [{LO},{HI}]")
|
||||
lo, hi = (SEAT_LO, SEAT_HI) if f.get('seated') else (LO, HI) # [R16 #3] seated exemption
|
||||
if not (lo <= f['stature'] <= hi): bad.append(f"stature {f['stature']}m out of [{lo},{hi}]{' seated' if f.get('seated') else ''}")
|
||||
elif not (f['crown'] < H): bad.append(f"crown {f['crown']}m >= ceiling {H}m")
|
||||
elif abs(f['foot']) >= FOOT_EPS: bad.append(f"feet at {f['foot']}m (not planted)")
|
||||
if bad:
|
||||
|
||||
@ -24,14 +24,18 @@ ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
|
||||
PORT = 8130
|
||||
HOST = f'http://127.0.0.1:{PORT}'
|
||||
SEED = 20261990
|
||||
OUTDIR = ROOT / 'docs' / 'shots' / 'release_v3'
|
||||
OUTDIR = ROOT / 'docs' / 'shots' / 'release_v3_1'
|
||||
if '--seed' in sys.argv:
|
||||
SEED = int(sys.argv[sys.argv.index('--seed') + 1])
|
||||
|
||||
# shot groups — each is one clean page load with the flags that shot needs (deterministic, same seed).
|
||||
# [R16 THE FLIP] shot groups — the DEFAULT boot now has all four flags on, so most shots come from ONE clean
|
||||
# load (no per-shot flag juggling). Rain is still forced (the default seed rolls clear); ?classic=1 gives the
|
||||
# before/after comparison. Deterministic, same seed.
|
||||
GROUPS = [
|
||||
{'flags': 'gigs=1', 'street': ['venue_night', 'district_posters', 'queue_night'], 'interior': True},
|
||||
{'flags': 'weather=rain&tram=1&winmap=1', 'street': ['rain_verandah', 'tram_stop'], 'interior': False},
|
||||
{'flags': '', 'street': ['venue_night', 'district_posters', 'queue_night', 'tram_stop'], 'interior': True},
|
||||
{'flags': 'weather=rain', 'street': ['rain_verandah'], 'interior': False},
|
||||
# the town before v3 — the exact v2 boot; same main-street pose as district_posters for a clean A/B
|
||||
{'flags': 'classic=1', 'street': ['district_posters'], 'interior': False, 'suffix': '_classic'},
|
||||
]
|
||||
|
||||
# settle + re-pose a street bookmark once its live/async content (queue, instrument GLBs, poster textures,
|
||||
@ -112,12 +116,12 @@ def contact_html(shots):
|
||||
f'<figure><img src="{s["file"]}" alt="{s["name"]}" loading="lazy">'
|
||||
f'<figcaption><b>{s["name"]}</b><br>{cap}</figcaption></figure>')
|
||||
return (
|
||||
'<!doctype html><meta charset="utf-8"><title>PROCITY v3.0 — release tour</title>'
|
||||
'<!doctype html><meta charset="utf-8"><title>PROCITY v3.1 — release tour</title>'
|
||||
'<style>body{background:#14110c;color:#f4efe6;font:14px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;margin:24px}'
|
||||
'h1{font-weight:700}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:18px}'
|
||||
'figure{margin:0;background:#1d1810;border-radius:10px;overflow:hidden}img{width:100%;display:block}'
|
||||
'figcaption{padding:8px 12px;color:#cdc4b2}b{color:#ffd9a0}</style>'
|
||||
f'<h1>PROCITY v3.0 — release money-shot tour <small>· seed {SEED} · settled measurements</small></h1>'
|
||||
f'<h1>PROCITY v3.1 — release money-shot tour (THE FLIP) <small>· seed {SEED} · settled measurements</small></h1>'
|
||||
f'<div class="grid">{"".join(cards)}</div>')
|
||||
|
||||
|
||||
@ -127,7 +131,7 @@ def main():
|
||||
except ImportError:
|
||||
sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium')
|
||||
|
||||
print(f"\033[1mv3.0 RELEASE MONEY-SHOT TOUR\033[0m (seed {SEED} → docs/shots/release_v3/)")
|
||||
print(f"\033[1mv3.1 RELEASE MONEY-SHOT TOUR\033[0m (seed {SEED} → docs/shots/release_v3_1/)")
|
||||
OUTDIR.mkdir(parents=True, exist_ok=True)
|
||||
srv = ensure_server()
|
||||
shots, errs_total = [], 0
|
||||
@ -139,15 +143,17 @@ def main():
|
||||
errs = []
|
||||
pg.on('pageerror', lambda e: errs.append(str(e)))
|
||||
boot(pg, grp['flags'])
|
||||
sfx = grp.get('suffix', '')
|
||||
for name in grp['street']:
|
||||
stat = pg.evaluate(STREET_JS, name)
|
||||
out = OUTDIR / f'{name}.png'
|
||||
pg.screenshot(path=str(out))
|
||||
cap = f"{stat['draws']} draws · {stat['tris']:,} tris"
|
||||
if stat.get('queue') is not None: cap += f" · queue {stat['queue']}"
|
||||
if name in ('district_posters', 'venue_night', 'queue_night'): cap += f" · {stat['posters']} posters"
|
||||
shots.append({'name': name, 'file': f'{name}.png', 'caption': cap})
|
||||
print(f" \033[32m●\033[0m {name}: {cap}")
|
||||
fname = f'{name}{sfx}.png'
|
||||
pg.screenshot(path=str(OUTDIR / fname))
|
||||
cap = (f"?classic=1 — the town before v3 (no gig layer, no pools)" if sfx
|
||||
else f"{stat['draws']} draws · {stat['tris']:,} tris")
|
||||
if not sfx and stat.get('queue') is not None: cap += f" · queue {stat['queue']}"
|
||||
if not sfx and name in ('district_posters', 'venue_night', 'queue_night'): cap += f" · {stat['posters']} posters"
|
||||
shots.append({'name': name + sfx, 'file': fname, 'caption': cap})
|
||||
print(f" \033[32m●\033[0m {name}{sfx}: {cap}")
|
||||
if grp['interior']:
|
||||
info = pg.evaluate(INTERIOR_JS)
|
||||
if info.get('ok'):
|
||||
@ -168,7 +174,7 @@ def main():
|
||||
if srv: srv.terminate()
|
||||
|
||||
(OUTDIR / 'contact.html').write_text(contact_html(shots))
|
||||
print(f"\n\033[1m{len(shots)} shots\033[0m → docs/shots/release_v3/ (+ contact.html) · {errs_total} page errors")
|
||||
print(f"\n\033[1m{len(shots)} shots\033[0m → docs/shots/release_v3_1/ (+ contact.html) · {errs_total} page errors")
|
||||
sys.exit(1 if errs_total or len(shots) < 6 else 0)
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PROCITY Lane F — the full-week gig soak (round 15, v3.0 release · ?gigs=1). NON-STRICT qa extra.
|
||||
"""PROCITY Lane F — the full-week gig soak (round 15→16, on the DEFAULT boot (v3.1 flip)). NON-STRICT qa extra.
|
||||
|
||||
Rolls the clock through all 7 nights in ONE browser context, entering every playing venue each night,
|
||||
and proves the shell's per-night gig machinery holds over the week with no leaks:
|
||||
@ -135,7 +135,7 @@ def main():
|
||||
except ImportError:
|
||||
sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium')
|
||||
|
||||
print(f"\033[1mFULL-WEEK GIG SOAK\033[0m (seed {SEED} · 7 nights × all venues · leak/cover/roster · ?gigs=1)")
|
||||
print(f"\033[1mFULL-WEEK GIG SOAK\033[0m (seed {SEED} · 7 nights × all venues · leak/cover/roster · default boot)")
|
||||
srv = ensure_server()
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
@ -144,7 +144,7 @@ def main():
|
||||
errs = []
|
||||
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
|
||||
pg.on('pageerror', lambda e: errs.append(str(e)))
|
||||
pg.goto(f'{HOST}/index.html?seed={SEED}&gigs=1&dbg=1')
|
||||
pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1') # [R16] the DEFAULT boot now IS the gig boot
|
||||
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=25000)
|
||||
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
|
||||
try:
|
||||
@ -190,15 +190,17 @@ def main():
|
||||
lk = res['leakAfter']
|
||||
trail = res['memTrail']
|
||||
tail = trail[-3:] if len(trail) >= 3 else trail
|
||||
flat = all(m['geo'] == tail[0]['geo'] and m['tex'] == tail[0]['tex'] for m in tail)
|
||||
# a real leak CLIMBS every cycle; a flat OR DECREASING back half (GC / atlas settle) is not a leak, and
|
||||
# ending AT OR BELOW the warmed baseline can't be one either. Only monotone growth into the tail fails.
|
||||
climbing = (tail[-1]['geo'] > tail[0]['geo']) or (tail[-1]['tex'] > tail[0]['tex'])
|
||||
traj = " → ".join(f"{m['geo']}/{m['tex']}" for m in trail)
|
||||
if lk['geo'] == 0 and lk['tex'] == 0:
|
||||
OK(f"leak-free: geo/tex back to baseline across {res['cycles']} cycles (night trail {traj})")
|
||||
elif flat:
|
||||
OK(f"leak-free: warm-up residue geo {lk['geo']:+d}/tex {lk['tex']:+d}, then FLAT across the back half "
|
||||
f"— no per-cycle growth (night trail {traj}, {res['cycles']} cycles)")
|
||||
if lk['geo'] <= 0 and lk['tex'] <= 0:
|
||||
OK(f"leak-free: geo/tex ended at/below the warmed baseline (net {lk['geo']:+d}/{lk['tex']:+d}) across {res['cycles']} cycles (trail {traj})")
|
||||
elif not climbing:
|
||||
OK(f"leak-free: warm-up residue geo {lk['geo']:+d}/tex {lk['tex']:+d}, then flat/settling across the back half "
|
||||
f"— no per-cycle growth (trail {traj}, {res['cycles']} cycles)")
|
||||
else:
|
||||
FAIL(f"LEAK: geo {lk['geo']:+d}/tex {lk['tex']:+d} still climbing (night trail {traj}) across {res['cycles']} cycles")
|
||||
FAIL(f"LEAK: geo {lk['geo']:+d}/tex {lk['tex']:+d} still climbing (trail {traj}) across {res['cycles']} cycles")
|
||||
|
||||
if res['cycles'] < 20:
|
||||
FAIL(f"cycles: only {res['cycles']} enter/exit (want ≥20)")
|
||||
|
||||
@ -82,12 +82,18 @@ const NOASSETS = params.has('noassets') && params.get('noassets') !== '0';
|
||||
// LANE_A_NOTES F2). Zero network either way. Guarded: falls back to synthetic generatePlan, then fixture.
|
||||
const PLANSRC = params.get('plansrc') === 'osm' ? 'osm' : 'synthetic';
|
||||
const TOWN = params.get('town') || undefined; // [Lane F R8] &town=<key> selects an OSM town (A: melbourne|katoomba)
|
||||
// [Lane F R12 — v3.0-alpha gig layer] ?gigs=1 turns on Lane A's post-hoc gig augmentation (the pub venue,
|
||||
// plan.gigs, plan.posters) and everything F wires off it: the state machine, B's lit frontage + muffled
|
||||
// spill, C's band room, D's band + crowd, the cover charge at the door. Default OFF ⇒ generatePlanFor
|
||||
// returns the byte-identical v2 plan and NONE of the gig modules are ever constructed (prime flag law —
|
||||
// the flags-off regression gate in tools/flags_check.py enforces exactly that).
|
||||
const GIGS_ON = params.has('gigs') && params.get('gigs') !== '0';
|
||||
// [Lane F R16 — THE FLIP (v3.1)] The town shows its best by DEFAULT: gigs / weather / winmap / tram are ON
|
||||
// unless individually opted out (?gigs=0, ?weather=0, …) or ?classic=1 forces the exact v2 boot. The prime
|
||||
// law is REWRITTEN, not repealed: the byte-identical covenant (synthetic fingerprint 0x3fa36874, zero gig
|
||||
// layer, zero fetch delta) moved from "flags off" to "?classic=1". `flagOn(name)`: default-on, `=0` opts
|
||||
// out, classic is the master off. winmap flips the same way in buildings.js; the drummer's sit clip rides !CLASSIC.
|
||||
const CLASSIC = params.has('classic') && params.get('classic') !== '0';
|
||||
const flagOn = (name) => !CLASSIC && params.get(name) !== '0';
|
||||
// [Lane F R12/16] The gig layer — now DEFAULT-ON: Lane A's post-hoc augmentation (venues, plan.gigs,
|
||||
// plan.posters) + everything F wires off it (the state machine, B's frontage/spill, C's rooms, D's
|
||||
// band/crowd/queue, the cover charge). ?gigs=0 or ?classic=1 ⇒ generatePlanFor returns the byte-identical
|
||||
// v2 plan and NONE of the gig modules are constructed (the classic-regression gate enforces exactly that).
|
||||
const GIGS_ON = flagOn('gigs');
|
||||
// John's OPTIONAL band-name drop-in (web/assets/custom_bands.json — Lane A owns the file + schema; edit it
|
||||
// and reload, the names get priority on the town's posters). Absent file → pure generator, zero errors.
|
||||
// ZERO fetch when the flag is off or under ?noassets=1 (asset law). `no-cache` on purpose: this file exists
|
||||
@ -166,7 +172,7 @@ const minimap = createMinimap(plan);
|
||||
// unless) a GLB lands, so the town is fully playable the instant it boots.
|
||||
// • rig fleet: models/peds/*.glb → shared GLB actors; CitizenSim + keepers upgrade from placeholders.
|
||||
// • manifest: Lane E's assets/manifest.json → interior fitting GLBs (loaded from the 3GOD depot).
|
||||
const fleet = NOASSETS ? null : loadPedFleet('models/peds/');
|
||||
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
|
||||
if (!NOASSETS) preloadManifest(); // prime the manifest so the first interior can upgrade its fittings
|
||||
// [Lane F integration] interior bridge — owns the 'interior' branch (Lane C buildInterior + Lane D keeper)
|
||||
// [Lane F §F2] ?dig=1 enables Lane C's crate-riffle inside record interiors (v2). Off ⇒ byte-identical to v1.
|
||||
@ -232,14 +238,16 @@ document.addEventListener('visibilitychange', () => citizens.setPaused(document.
|
||||
// [Lane F R8 — Lane B weather] ?weather=1 seeded (seed 20261990 default town rolls 'clear'); ?weather=rain|
|
||||
// overcast|clear forces a state. Self-contained module; flag-off never constructs it (byte-identical). D reads
|
||||
// window.PROCITY.weather (always a valid {state,intensity} — {clear,0} when off), it does NOT import weather.js.
|
||||
// [Lane F R16 flip] weather DEFAULT-ON (seeded — the default town rolls its own state); ?weather=rain|overcast|
|
||||
// clear forces one; ?weather=0 or ?classic=1 → off (byte-identical). Self-contained; D reads window.PROCITY.weather.
|
||||
const _wp = params.get('weather');
|
||||
const weather = (_wp && _wp !== '0')
|
||||
? createWeather({ scene, plan, skins, lighting, camera, force: /^(clear|overcast|rain)$/.test(_wp) ? _wp : null })
|
||||
const weather = flagOn('weather')
|
||||
? createWeather({ scene, plan, skins, lighting, camera, force: /^(clear|overcast|rain)$/.test(_wp || '') ? _wp : null })
|
||||
: null;
|
||||
const weatherState = weather ? weather.state : { state: 'clear', intensity: 0 };
|
||||
// [Lane F R9 — Lane B tram] ?tram=1: one seeded tram looping the main-street spine, door-dwell at the
|
||||
// bus shelters. Self-contained (2 draws); flag-off never constructs it (byte-identical). Non-blocking for v2.0.
|
||||
const tram = (params.get('tram') && params.get('tram') !== '0') ? createTram({ scene, plan, camera, lighting }) : null;
|
||||
const tram = flagOn('tram') ? createTram({ scene, plan, camera, lighting }) : null; // [R16 flip] default-on; ?tram=0 / ?classic=1 → off
|
||||
// [Lane F R12 — Lane B venue.js] the street side of the gig: E's poster skins at A's plan.posters (band name
|
||||
// overprinted like a shop sign) + the lit pub frontage, whose marquee/sign glow F ramps from the gig state.
|
||||
// Self-contained module; never constructed flags-off (byte-identical), like weather/tram.
|
||||
@ -426,6 +434,9 @@ window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skin
|
||||
// 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)
|
||||
venueQueues, queueCountOf, // [Lane F R13] Lane D outdoor queue per venue + count accessor (F's smoke reads it)
|
||||
// [Lane F R16 — THE FLIP] the flag-intent surface: what SHOULD be on this boot (the gates verify intent vs
|
||||
// reality). Default boot = all four flipped flags true; ?classic=1 = all false + classic true; each `=0` opts out.
|
||||
flags: { classic: CLASSIC, gigs: GIGS_ON, weather: flagOn('weather'), winmap: flagOn('winmap'), tram: flagOn('tram') },
|
||||
THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks
|
||||
// [Lane B R11 audio] street WebAudio engine — self-unlocking on the first gesture; silent with
|
||||
// zero/blocked assets or ?mute=1; ?noassets=1 ⇒ no audio fetches. Exposes window.PROCITY.audio so
|
||||
|
||||
@ -59,6 +59,17 @@ function micStandPrim() {
|
||||
g.position.set(0, 0, -0.26); // stands just in front of the singer (fig-local -Z)
|
||||
return tagOwn(g);
|
||||
}
|
||||
// R16: the backline amp fallback (C dropped its primitive ampStacks; this stands in when the guitar_amp
|
||||
// GLB is absent / ?noassets). A room prop (not fig-local): base at y=0, grille on the +Z (crowd) face.
|
||||
function ampPrim() {
|
||||
const g = new THREE.Group();
|
||||
const cab = new THREE.MeshStandardMaterial({ color: AMP, roughness: 0.7, metalness: 0.05 });
|
||||
const cloth = new THREE.MeshStandardMaterial({ color: AMPCLOTH, roughness: 0.9 });
|
||||
const box = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.6, 0.29), cab); box.position.y = 0.3;
|
||||
const grille = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.46, 0.02), cloth); grille.position.set(0, 0.3, 0.15);
|
||||
g.add(box, grille);
|
||||
return tagOwn(g);
|
||||
}
|
||||
// a cheap kit: kick drum + snare + a floor tom + two cymbals on stands. Reads as "drum kit" behind the
|
||||
// seated drummer; the GLB replaces it when present. Sits on the riser (y = SEAT_DROP compensates the sink).
|
||||
function drumKitPrim() {
|
||||
@ -125,15 +136,18 @@ export class GigCrew {
|
||||
}
|
||||
|
||||
// one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot.
|
||||
_make(target, key, { x, y, z, ry, height, pedIndex = null }) {
|
||||
// `seated` (R16): the drummer — use E's sit.glb pose (fleet.sitClip) + spawnRig's foot-replant + tag the
|
||||
// fig for F's stature gate; falls back to the standing idle (caller sinks it via SEAT_DROP) if no sitClip.
|
||||
_make(target, key, { x, y, z, ry, height, pedIndex = null, seated = false }) {
|
||||
const r = rng(this.citySeed, 'gig', key);
|
||||
const h = height != null ? height : 1.6 + r() * 0.3;
|
||||
let actor, kind, usedIndex = null;
|
||||
if (this.fleet && this.fleet.ready) {
|
||||
const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r());
|
||||
const rig = pk && this.fleet.all[pk.index];
|
||||
const sp = rig && spawnRig(rig, { ry, height: h, clip: this.fleet.idleClip, phase: r() });
|
||||
if (sp) { actor = sp; kind = 'rig'; usedIndex = pk.index; } // usedIndex: the fleet ped shown — F's continuity smoke reads it
|
||||
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 });
|
||||
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'; }
|
||||
actor.fig.position.set(x, y, z);
|
||||
@ -158,9 +172,12 @@ export class GigCrew {
|
||||
stage.bandPoses.forEach((p, i) => {
|
||||
const seated = p.role === 'drums' || !!p.seated;
|
||||
const poseY = p.y != null ? p.y : (stage.deckY || 0); // C tags per-pose y (front line deckY, drums riser)
|
||||
const y = seated ? poseY - SEAT_DROP : poseY; // sink the drummer so the kit hides the legs
|
||||
// R16: a real sit clip seats the rig on the riser (spawnRig re-plants the feet) → no sink; only the
|
||||
// no-clip fallback still sinks SEAT_DROP so the kit hides the standing legs.
|
||||
const seatedRig = seated && !!(this.fleet && this.fleet.sitClip);
|
||||
const y = seated ? poseY - (seatedRig ? 0 : SEAT_DROP) : poseY;
|
||||
const { actor, kind, pedIndex } = this._make(roomGroup, `band:${gid}:${p.role}:${i}`,
|
||||
{ x: p.x, y, z: p.z, ry: p.ry, pedIndex: distinct.length ? distinct[i % distinct.length] : null,
|
||||
{ x: p.x, y, z: p.z, ry: p.ry, seated, pedIndex: distinct.length ? distinct[i % distinct.length] : null,
|
||||
height: 1.68 + rng(this.citySeed, 'gigh', `${gid}:${i}`)() * 0.2 });
|
||||
const member = { actor, kind, pedIndex, part: 'band', role: p.role, dance: false, seated,
|
||||
base: { x: p.x, y, z: p.z, ry: p.ry }, phase: rng(this.citySeed, 'gigp', `b${gid}:${i}`)() * Math.PI * 2, extra: [] };
|
||||
@ -218,24 +235,31 @@ export class GigCrew {
|
||||
});
|
||||
}
|
||||
|
||||
// A guitar amp as backline dressing, planted on the deck just off the riser (the classic pub backline).
|
||||
// Dressing, not an instrument — planted in the room group (this.props), GLB-only (skipped under ?noassets).
|
||||
// The backline amp, planted at C's up-stage `stage.backline[0]` (R16 ledger #5 — C dropped its primitive
|
||||
// ampStacks; D owns the whole backline now). Placeholder-persists: a primitive amp shows immediately (the
|
||||
// ?noassets fallback too), then E's guitar_amp GLB swaps in when it resolves. Falls back to the R15
|
||||
// hardcoded up-stage-right pose if C's backline isn't present. Room-local coords; grille to the crowd.
|
||||
_addBackline(roomGroup, stage, gen) {
|
||||
if (!this.fleet || !this.fleet.ready) return;
|
||||
const slot = (stage.backline && stage.backline[0]) ||
|
||||
{ x: stage.x + (stage.w || 3) * 0.36, z: stage.z - (stage.d || 3) * 0.22, ry: 0 };
|
||||
const place = (obj) => {
|
||||
obj.position.set(slot.x, stage.deckY || 0, slot.z);
|
||||
if (slot.ry != null) obj.rotation.y = slot.ry;
|
||||
roomGroup.add(obj); this.props.push(obj); return obj;
|
||||
};
|
||||
const prim = place(ampPrim()); // primitive amp immediately (asset-free backline)
|
||||
if (!this.fleet || !this.fleet.ready) return; // ?noassets → primitive only, no fetch
|
||||
loadManifest().then((mani) => {
|
||||
if (!mani || !mani.fittings || this._gen !== gen) return;
|
||||
const entry = mani.fittings.guitar_amp;
|
||||
if (!entry || !entry.file) return;
|
||||
return loadGLB(`depot:${entry.file}`).then((gltf) => {
|
||||
if (!gltf || this._gen !== gen) return;
|
||||
if (!gltf || this._gen !== gen || !prim.parent) return; // room rebuilt / disposed mid-flight
|
||||
const amp = gltf.scene.clone(true);
|
||||
amp.traverse((o) => { if (o.isMesh) { o.castShadow = false; o.frustumCulled = false; } });
|
||||
// R15 (C's §3 ruling): up-stage-right behind the front line — clears the bass rig and offsets from
|
||||
// C's primitive ampStacks (at ±w·0.28, z−d·0.18), so GLB + primitive read as a flanking backline.
|
||||
// The old z+0.15 was down-stage and interpenetrated the bassist. Room-local coords; grille to the crowd.
|
||||
amp.position.set(stage.x + (stage.w || 3) * 0.36, (stage.deckY || 0), stage.z - (stage.d || 3) * 0.22);
|
||||
roomGroup.add(amp);
|
||||
this.props.push(amp);
|
||||
place(amp); // GLB swaps in over the primitive
|
||||
const i = this.props.indexOf(prim); if (i >= 0) this.props.splice(i, 1);
|
||||
disposePrim(prim); // free the primitive's own geo/mats
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -277,7 +301,7 @@ export class GigCrew {
|
||||
for (const ex of m.extra) disposePrim(ex); // GLB clones (shared geo/mats) are skipped inside
|
||||
m.actor.dispose && m.actor.dispose();
|
||||
}
|
||||
for (const p of this.props) { if (p.parent) p.parent.remove(p); } // GLB dressing — cached geo/mats, detach only
|
||||
for (const p of this.props) disposePrim(p); // GLB dressing → detach only; the ?noassets primitive amp → freed (ownGeo)
|
||||
this.members.length = 0;
|
||||
this.props.length = 0;
|
||||
this._gen++; // invalidate any GLB load still in flight
|
||||
|
||||
@ -36,6 +36,12 @@ function canonRig(r) {
|
||||
// flat). The character keeps its own upright bind root; the game translates walkers itself.
|
||||
const _rotOnly = c => new THREE.AnimationClip(c.name, c.duration,
|
||||
c.tracks.filter(t => t.name.endsWith('.quaternion') && !/Hips\.quaternion$/i.test(t.name)));
|
||||
// R16 sit clip note: the sit pose ALSO rides `_rotOnly`. R16 first tried keeping `Hips.quaternion` (a
|
||||
// `_rotWithHips` variant, per the R14 recon) to preserve the pelvic tilt — but the fleet rigs' bind-pose
|
||||
// Hips orientation differs from the Mixamo source, so keeping the sit Hips.quaternion LAYS THE BODY FLAT
|
||||
// (head folded to hip height — verified in-shell), exactly the failure the comment above documents for
|
||||
// walk/idle. So the sit clip drops Hips.quaternion like every other clip; the seated read comes from the
|
||||
// leg-bone bends + spawnRig's seated foot-replant. Reads seated from the crowd POV (the kit hides the legs).
|
||||
|
||||
// core loadGLB returns the whole gltf (scene + animations); adapt to the {scene, anims} rig shape.
|
||||
function loadRig(ref) {
|
||||
@ -45,10 +51,14 @@ function loadRig(ref) {
|
||||
// loadPedFleet(base) → a live Fleet object, returned immediately (ready:false). Arrays fill in as
|
||||
// GLBs land; fleet.whenReady resolves when every load settled. The town runs on placeholders until
|
||||
// then, then upgradeStreetPeople swaps them (see sim.js). Missing files just leave the fleet smaller.
|
||||
export function loadPedFleet(base = 'models/peds/') {
|
||||
// `opts.sit` (R16): also load sit.glb → fleet.sitClip (the seated-drummer pose). GATED because a raw
|
||||
// sit.glb fetch would break the ?classic=1 zero-fetch-delta covenant — F passes sit:true on the gig/default
|
||||
// boot, sit:false (default) under ?classic. Absent/off → fleet.sitClip stays null and the drummer falls
|
||||
// back to the standing-sunk hack, so this never breaks a boot. Does NOT touch fleet.ready / determinism.
|
||||
export function loadPedFleet(base = 'models/peds/', { sit = false } = {}) {
|
||||
const fleet = {
|
||||
normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas
|
||||
walkClip: null, idleClip: null,
|
||||
walkClip: null, idleClip: null, sitClip: null,
|
||||
ready: false, whenReady: null,
|
||||
};
|
||||
// DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array
|
||||
@ -69,6 +79,9 @@ export function loadPedFleet(base = 'models/peds/') {
|
||||
jobs.push(loadRig(`${base}idle.glb`).then(r => {
|
||||
const c = r && r.anims && r.anims[0];
|
||||
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.idleClip = _rotOnly(c); } }));
|
||||
if (sit) jobs.push(loadRig(`${base}sit.glb`).then(r => { // R16, gig/default boot only
|
||||
const c = r && r.anims && r.anims[0];
|
||||
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.sitClip = _rotOnly(c); } }));
|
||||
fleet.whenReady = Promise.all(jobs).then(() => {
|
||||
fleet.normal = nSlots.filter(Boolean);
|
||||
fleet.comical = cSlots.filter(Boolean);
|
||||
@ -169,14 +182,28 @@ function _disposeInner(inner) {
|
||||
}
|
||||
|
||||
// ---- spawnRig: single-clip figure (keepers, impostor baking). Height baked in. ----
|
||||
export function spawnRig(rig, { ry = 0, clip = null, height = 1.75, phase = 0 } = {}) {
|
||||
export function spawnRig(rig, { ry = 0, clip = null, height = 1.75, phase = 0, seated = false } = {}) {
|
||||
if (!rig || !rig.scene) return null;
|
||||
const { fig, inner, head } = buildFigure(rig, height);
|
||||
fig.rotation.y = ry;
|
||||
const mixer = new THREE.AnimationMixer(inner);
|
||||
const chosen = clip || rig.anims.find(c => /idle/i.test(c.name)) || rig.anims[1] || rig.anims[0];
|
||||
const act = _action(mixer, inner, chosen);
|
||||
if (act) { act.time = phase * (act.getClip().duration || 0); act.play(); }
|
||||
if (act) {
|
||||
act.time = phase * (act.getClip().duration || 0); act.play();
|
||||
// R16 SEATED re-plant (OPT-IN — standing spawns skip this, so keepers/browsers/band are byte-identical).
|
||||
// buildFigure planted the BIND pose; a sit clip lowers the hips + swings the legs, so re-plant the POSED
|
||||
// feet to y=0: tick the mixer once to bake the pose, find the lowest bone, drop the figure so the soles
|
||||
// return to the deck. One-time (a drummer's feet don't travel); this recovers the hip descent that
|
||||
// _rotWithHips leaves out by dropping the (scale-unsafe) Hips.position track.
|
||||
if (seated) {
|
||||
mixer.update(0);
|
||||
inner.updateWorldMatrix(true, true);
|
||||
let m = Infinity;
|
||||
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); m = Math.min(m, _wp.y); } });
|
||||
if (isFinite(m)) inner.position.y -= m;
|
||||
}
|
||||
}
|
||||
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
|
||||
return { fig, inner, mixer, height, head, dispose };
|
||||
}
|
||||
|
||||
@ -10,12 +10,26 @@
|
||||
// → town-wide. See LANE_A_NOTES §Round 13 for the interface C/D/B/F hang off.
|
||||
|
||||
import { rng, seedFor, mulberry32, pick, shuffle, irange } from '../core/prng.js';
|
||||
import { SHOP_TYPES, poleOffset } from '../core/registry.js';
|
||||
import { SHOP_TYPES, poleOffset, roadWidth } from '../core/registry.js';
|
||||
import { shopName, bandName } from './names.js';
|
||||
|
||||
const r2 = v => Math.round(v * 100) / 100;
|
||||
const r4 = v => Math.round(v * 10000) / 10000;
|
||||
|
||||
// A venue's own frontage poster seats on the +Z street facade, `d/2 + FACADE_PROUD` out from the lot
|
||||
// centre (shared by pickVenues' clearance test and buildPosters). ROUND16 (ledger #6): band_room/rsl
|
||||
// selection prefers a lot whose facade clears every carriageway by ≥ POSTER_CLEAR, so the poster can
|
||||
// never sit on a back-street's bitumen (a narrow through-lot otherwise abuts the block's other street).
|
||||
// selfcheck asserts the same floor — import POSTER_CLEAR there, don't re-guess it.
|
||||
export const POSTER_CLEAR = 0.5;
|
||||
const FACADE_PROUD = 0.06;
|
||||
// nearest point on segment AB to P (same helper the selfcheck uses; kept local so gigs.js stays pure).
|
||||
const nearestSeg = (px, pz, ax, az, bx, bz) => {
|
||||
const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz || 1;
|
||||
let t = ((px - ax) * dx + (pz - az) * dz) / L2; t = Math.max(0, Math.min(1, t));
|
||||
return [ax + dx * t, az + dz * t];
|
||||
};
|
||||
|
||||
// A night gig: doors at DUSK (seg4), on during NIGHT (seg5) — see lighting.js segment→hour map.
|
||||
const NIGHTS = 7;
|
||||
const GIG_START_SEG = 5; // NIGHT (22:00) — gig is "on"
|
||||
@ -55,11 +69,25 @@ function pickVenues(plan, citySeed) {
|
||||
const blockById = new Map(plan.blocks.map(b => [b.id, b]));
|
||||
const mainEdges = new Set(plan.streets.edges.filter(e => e.kind === 'main').map(e => e.id));
|
||||
|
||||
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
|
||||
const lotOf = s => lotById.get(s.lot);
|
||||
const blockKindOf = s => { const l = lotOf(s), b = l && blockById.get(l.block); return b ? b.kind : null; };
|
||||
const onMain = s => { const l = lotOf(s); return !!l && mainEdges.has(l.frontEdge); };
|
||||
const dist2 = s => { const l = lotOf(s); return l ? l.x * l.x + l.z * l.z : 0; };
|
||||
const farthestFirst = arr => arr.slice().sort((a, b) => dist2(b) - dist2(a) || a.id - b.id);
|
||||
// clearance of a shop's +Z frontage-poster point to the nearest carriageway (ROUND16 ledger #6).
|
||||
const facadeClear = s => {
|
||||
const l = lotOf(s); if (!l) return Infinity;
|
||||
const off = l.d / 2 + FACADE_PROUD;
|
||||
const px = l.x + Math.sin(l.ry) * off, pz = l.z + Math.cos(l.ry) * off;
|
||||
let best = Infinity;
|
||||
for (const e of plan.streets.edges) {
|
||||
const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
|
||||
const [nx, nz] = nearestSeg(px, pz, a.x, a.z, b.x, b.z);
|
||||
best = Math.min(best, Math.hypot(px - nx, pz - nz) - roadWidth(e) / 2);
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
const base = plan.shops.filter(s => {
|
||||
if (s.openLate) return false; // must not displace the night landmark
|
||||
@ -93,6 +121,16 @@ function pickVenues(plan, citySeed) {
|
||||
if (far.length) cand = far;
|
||||
}
|
||||
}
|
||||
// ROUND16 ledger #6: keep the off-spine kinds off a back-street's bitumen. Prefer candidates whose
|
||||
// +Z facade clears every carriageway by ≥ POSTER_CLEAR; if the warehouse fringe is uniformly narrow,
|
||||
// broaden to ANY off-spine clear lot before falling back. (pub is spine-fronted — its +Z is open —
|
||||
// so it keeps its placement untouched; the hero shot doesn't move.) Always a clear lot exists in the
|
||||
// synthetic town; the fallback only ever fires on a degenerate/osm plan.
|
||||
if (kind === 'band_room' || kind === 'rsl') {
|
||||
let clear = cand.filter(s => facadeClear(s) >= POSTER_CLEAR);
|
||||
if (!clear.length) clear = pool.filter(s => !onMain(s) && facadeClear(s) >= POSTER_CLEAR);
|
||||
if (clear.length) cand = clear;
|
||||
}
|
||||
if (!cand.length) cand = pool; // last resort: anything left
|
||||
const shortlist = farthestFirst(cand).slice(0, Math.min(5, cand.length));
|
||||
const shop = pick(rng(citySeed, 'venuepick', i), shortlist);
|
||||
@ -208,7 +246,7 @@ function buildPosters(plan, citySeed, venues, gigs) {
|
||||
// read FROM the street: venue.js renders a poster's printed face along (−sin ry, −cos ry) = A's
|
||||
// outward normal, so ry = lot.ry + π points that face outward (else DoubleSide shows a mirrored name).
|
||||
const fx = Math.sin(lot.ry), fz = Math.cos(lot.ry); // +Z: the street facade (B's canon)
|
||||
const off = lot.d / 2 + 0.06; // out onto the facade plane, proud of it
|
||||
const off = lot.d / 2 + FACADE_PROUD; // out onto the facade plane, proud of it
|
||||
add(lot.x + fx * off, lot.z + fz * off, lot.ry + Math.PI, gig.gigId);
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap, isOpen } from './plan.js';
|
||||
export { shopName, townName, bandName } from './names.js';
|
||||
export { generatePlanOSM, osmTownKeys } from './plan_osm.js';
|
||||
export { withGigs, gigKeyFor } from './gigs.js';
|
||||
export { withGigs, gigKeyFor, POSTER_CLEAR } from './gigs.js';
|
||||
// The street-corridor law + venue vocabulary, re-exported so consumers get the whole Lane A contract
|
||||
// from one import (ROUND13). `roadWidth`/`vergeBand`/`poleOffset` are the road-vs-verge split of
|
||||
// `edge.width` — see the note in registry.js; `gigKeyFor` is the one and only genre→audio-key mapping.
|
||||
|
||||
@ -134,6 +134,30 @@ export const OSM_TOWNS = {
|
||||
{"id":11242974232,"name":"Sanctus Catholic Books and Gift Shop","type":"book","lat":-33.715718,"lon":150.31095,"suburb":""}
|
||||
],
|
||||
},
|
||||
// ROUND16 (ledger #7): a CONSTRUCTED single-row town — every shop shares one latitude, so plan_osm
|
||||
// bins them into ONE avenue and builds NO main spine (spineNodes.length === 1 ⇒ 0 'main' edges). Unlike
|
||||
// Melbourne/Katoomba (8 lat rows ⇒ a multi-node spine), this proves the no-spine placement branch of
|
||||
// gigs.js pickVenues + the no-main-spine poster fallback in buildPosters. Not a scraped cluster; the
|
||||
// names are flavour (Silverton is a real one-street NSW town). ids are synthetic (9-prefixed) seeds.
|
||||
silverton: {
|
||||
town: "Silverton",
|
||||
source: "constructed single-row fixture (no-spine test, ROUND16 ledger #7)",
|
||||
center: {"lat":-31.8600,"lon":141.2255},
|
||||
shops: [
|
||||
{"id":9000001,"name":"Silverton Op Shop","type":"opshop","lat":-31.8600,"lon":141.2200,"suburb":"Silverton"},
|
||||
{"id":9000002,"name":"Old School Book Exchange","type":"book","lat":-31.8600,"lon":141.2210,"suburb":"Silverton"},
|
||||
{"id":9000003,"name":"Outback Vinyl","type":"record","lat":-31.8600,"lon":141.2220,"suburb":"Silverton"},
|
||||
{"id":9000004,"name":"Silverton Video","type":"video","lat":-31.8600,"lon":141.2230,"suburb":"Silverton"},
|
||||
{"id":9000005,"name":"Desert Trading Post","type":"pawn","lat":-31.8600,"lon":141.2240,"suburb":"Silverton"},
|
||||
{"id":9000006,"name":"Mundi Collectables","type":"toy","lat":-31.8600,"lon":141.2250,"suburb":"Silverton"},
|
||||
{"id":9000007,"name":"CWA Op Shop","type":"opshop","lat":-31.8600,"lon":141.2260,"suburb":"Silverton"},
|
||||
{"id":9000008,"name":"Ghost Town Books","type":"book","lat":-31.8600,"lon":141.2270,"suburb":"Silverton"},
|
||||
{"id":9000009,"name":"Silverton Corner Store","type":"milkbar","lat":-31.8600,"lon":141.2280,"suburb":"Silverton"},
|
||||
{"id":9000010,"name":"The Old Wares","type":"pawn","lat":-31.8600,"lon":141.2290,"suburb":"Silverton"},
|
||||
{"id":9000011,"name":"Dusty Grooves","type":"record","lat":-31.8600,"lon":141.2300,"suburb":"Silverton"},
|
||||
{"id":9000012,"name":"Salvos Silverton","type":"opshop","lat":-31.8600,"lon":141.2310,"suburb":"Silverton"}
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_TOWN = "melbourne";
|
||||
|
||||
@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js';
|
||||
import { generatePlanOSM, osmTownKeys } from './plan_osm.js';
|
||||
import { generatePlanFor, gigKeyFor } from './index.js';
|
||||
import { generatePlanFor, gigKeyFor, POSTER_CLEAR } from './index.js';
|
||||
import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth } from '../core/registry.js';
|
||||
import { xmur3 } from '../core/prng.js';
|
||||
|
||||
@ -190,25 +190,24 @@ function districtInvariants(plan, label) {
|
||||
}
|
||||
ok(clash === null, `${label}: no band plays two venues on one night` + (clash !== null ? ` (night ${clash})` : ''));
|
||||
|
||||
// ── posters: town-wide, on the verge, never on the bitumen (debt #5) ──
|
||||
// ── posters: town-wide, never on the bitumen — with a real FRONTAGE floor (ROUND16 ledger #6) ──
|
||||
const gigIds = new Set(plan.gigs.map(g => g.gigId));
|
||||
ok(Array.isArray(plan.posters) && plan.posters.every(p => gigIds.has(p.gigId) && isFiniteNum(p.x) && isFiniteNum(p.z) && isFiniteNum(p.ry)), `${label}: posters reference a real gig with finite coords`);
|
||||
ok(plan.posters.every(p => plan.gigs.find(g => g.gigId === p.gigId).night === 0), `${label}: posters advertise tonight's gigs`);
|
||||
// The no-bitumen rule (the R12 regression: spine posters at node.x+2) is for FREE-STANDING verge
|
||||
// posters. A venue's own FRONTAGE poster is bound to the building B draws — since R15 it sits on the
|
||||
// +Z street facade (B's canon: buildings.js facade + venue.js door/queue/camera are all +Z). A corner
|
||||
// venue whose facade abuts a crossing street legitimately seats its poster near that street's kerb —
|
||||
// B's building geometry, not a mid-road defect — so frontage posters are exempt here and validated by
|
||||
// the placement asserts above (they reference tonight's gigs with finite coords).
|
||||
// Two poster classes, two floors. A FRONTAGE poster sits on its venue's +Z street facade (B's canon:
|
||||
// buildings.js facade + venue.js door/queue/camera are +Z); ROUND16 pickVenues now PREFERS a band_room/
|
||||
// rsl lot whose facade clears every carriageway, so — no more R15 blanket exemption — a frontage poster
|
||||
// must clear its nearest kerb by ≥ POSTER_CLEAR. A SPINE poster is free-standing on the verge and keeps
|
||||
// the plain no-bitumen floor (≥ 0, the R12 regression guard). −0.02 absorbs r2 poster-coord rounding vs
|
||||
// pickVenues' un-rounded clearance test (worst observed frontage clearance is ~0.51 m).
|
||||
const lotByIdD = new Map(plan.lots.map(l => [l.id, l]));
|
||||
const onFacade = p => venues.some(v => {
|
||||
const lot = lotByIdD.get(v.lot); if (!lot) return false;
|
||||
const off = lot.d / 2 + 0.06; // gigs.js buildPosters item 1
|
||||
const off = lot.d / 2 + 0.06; // gigs.js buildPosters item 1 (FACADE_PROUD)
|
||||
return Math.hypot(p.x - (lot.x + Math.sin(lot.ry) * off), p.z - (lot.z + Math.cos(lot.ry) * off)) < 0.5;
|
||||
});
|
||||
let onRoad = null;
|
||||
for (const p of plan.posters) {
|
||||
if (onFacade(p)) continue; // frontage poster: on the venue building, not the verge
|
||||
let best = Infinity, bestEdge = null;
|
||||
for (const e of plan.streets.edges) {
|
||||
const a = nodeById2(plan, e.a), b = nodeById2(plan, e.b);
|
||||
@ -217,11 +216,13 @@ function districtInvariants(plan, label) {
|
||||
const d = Math.hypot(p.x - nx, p.z - nz);
|
||||
if (d < best) { best = d; bestEdge = e; }
|
||||
}
|
||||
// −1e-6 immunises the exact kerb-line boundary against r2 coord rounding (only reachable on a
|
||||
// no-main-spine fallback where poleOffset == roadWidth/2; never hit by the current sweep).
|
||||
if (bestEdge && best < roadWidth(bestEdge) / 2 - 1e-6) { onRoad = [p.id, bestEdge.id, +best.toFixed(2)]; break; }
|
||||
if (!bestEdge) continue;
|
||||
const frontage = onFacade(p);
|
||||
const clearance = best - roadWidth(bestEdge) / 2;
|
||||
const floor = frontage ? POSTER_CLEAR - 0.02 : -1e-6;
|
||||
if (clearance < floor) { onRoad = [p.id, bestEdge.id, +clearance.toFixed(2), frontage ? 'frontage' : 'spine']; break; }
|
||||
}
|
||||
ok(onRoad === null, `${label}: no poster stands on a carriageway` + (onRoad ? ` (poster ${onRoad[0]} is ${onRoad[2]}m from edge ${onRoad[1]}'s centreline)` : ''));
|
||||
ok(onRoad === null, `${label}: every poster clears its kerb (frontage ≥ ${POSTER_CLEAR} m, spine ≥ 0)` + (onRoad ? ` (${onRoad[3]} poster ${onRoad[0]} clears edge ${onRoad[1]} by only ${onRoad[2]} m)` : ''));
|
||||
}
|
||||
const nodeById2 = (plan, id) => plan.streets.nodes.find(n => n.id === id);
|
||||
|
||||
@ -271,7 +272,7 @@ for (const s of SEEDS) {
|
||||
// (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.
|
||||
section('OSM plan source parity (fixture-driven, zero-network)');
|
||||
const OSM_GOLDENS = { melbourne: 0x34cfdec0, katoomba: 0x0f652510 };
|
||||
const OSM_GOLDENS = { melbourne: 0x34cfdec0, katoomba: 0x0f652510, silverton: 0xd4b351c9 };
|
||||
const OSM_SEEDS = [20261990, 1, 42, 777];
|
||||
for (const town of osmTownKeys()) {
|
||||
const report = {};
|
||||
@ -307,7 +308,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)`);
|
||||
}
|
||||
// gig-layer golden (guards gig-layer output drift; pure-generator path, no custom file)
|
||||
const GIG_GOLDEN = 0x4f4a549d; // ROUND15 re-pin: frontage posters flipped to the +Z street face (ledger #1 bug fix). Was 0x1f636349 (R13 district), 0xa6ae5a5e (R12 alpha).
|
||||
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 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')}`);
|
||||
@ -333,14 +334,22 @@ for (const s of SEEDS) if (s > SWEEP_N) districtInvariants(generatePlanFor(s, 's
|
||||
ok([2, 3, 4].every(n => sweepVenueCounts.has(n)),
|
||||
`sweep: venue count spans {2,3,4} across ${SWEEP_N} seeds (saw {${[...sweepVenueCounts].sort((a, b) => a - b).join(',')}})`);
|
||||
|
||||
// osm graceful placement: katoomba is the smallest fixture (19 shops) with NO warehouse district, so
|
||||
// pickVenues' band_room/rsl rules hit their off-spine fallback — yet still land 2–4 valid venues incl.
|
||||
// a pub. (Both osm fixtures keep a main spine, so this proves the no-warehouse branch, not no-spine.)
|
||||
// osm graceful placement — two branches of pickVenues' degradation, each proven by a fixture that
|
||||
// genuinely lacks the structure the synthetic town has:
|
||||
// • katoomba (19 shops): NO warehouse district ⇒ band_room/rsl hit their off-spine fallback.
|
||||
// • silverton (single-row): NO main spine at all ⇒ pub's onMain filter is empty and buildPosters'
|
||||
// spine run falls back to every edge (ROUND16 ledger #7 — closes the R14 gap where both osm
|
||||
// fixtures had a spine). Both still land 2–4 valid venues incl. a pub.
|
||||
for (const seed of GIG_SEEDS) {
|
||||
const plan = generatePlanFor(seed, 'osm', { gigs: true, town: 'katoomba' });
|
||||
ok(plan.blocks.every(b => b.kind !== 'warehouse'),
|
||||
const kt = generatePlanFor(seed, 'osm', { gigs: true, town: 'katoomba' });
|
||||
ok(kt.blocks.every(b => b.kind !== 'warehouse'),
|
||||
`sweep osm/katoomba ${seed}: fixture has no warehouse district (graceful-placement precondition)`);
|
||||
districtInvariants(plan, `sweep osm/katoomba ${seed}`);
|
||||
districtInvariants(kt, `sweep osm/katoomba ${seed}`);
|
||||
|
||||
const sv = generatePlanFor(seed, 'osm', { gigs: true, town: 'silverton' });
|
||||
ok(!sv.streets.edges.some(e => e.kind === 'main'),
|
||||
`sweep osm/silverton ${seed}: fixture has no main spine (no-spine placement precondition)`);
|
||||
districtInvariants(sv, `sweep osm/silverton ${seed}`);
|
||||
}
|
||||
|
||||
// ── 4. every facade skin referenced by the registry exists on disk ──────────────────
|
||||
|
||||
@ -25,10 +25,12 @@
|
||||
// watchPoints: [ {x,z,ry,dance,slotIndex} ], // VENUE (R12/13, else []) — audience poses facing the stage;
|
||||
// // dance ~1/3 true. Cap by kind: pub/band_room 8, rsl 12. Same
|
||||
// // frame + ry convention as counter.stand (rig-front = local −Z).
|
||||
// stage: { x,z,w,d,deckY,riserY,frontZ,bandPoses }, // VENUE (R12/13, else null) — the band stage. bandPoses[4]
|
||||
// // (R13): front-line trio (guitar/vocal/bass) on the deck + a drummer
|
||||
// // (role 'drums', seated) up-stage on the riser. Each pose carries y
|
||||
// // (front line = deckY, drummer = riserY). ry=π faces the audience.
|
||||
// stage: { x,z,w,d,deckY,riserY,frontZ,bandPoses,backline }, // VENUE (else null) — the band stage. bandPoses[4]:
|
||||
// // front-line trio (guitar/vocal/bass) on the deck + a drummer (role
|
||||
// // 'drums', seated) up-stage on the riser. Each pose carries y (front
|
||||
// // line = deckY, drummer = riserY). ry=π faces the audience. backline[]
|
||||
// // (v3.1) = up-stage amp slots {x,z,ry,y} where Lane D plants the
|
||||
// // guitar_amp GLB (primitive fallback under ?noassets).
|
||||
// dims: { W, D, H, archetype, type }, // room metrics
|
||||
// placement, // deterministic placement summary (deep-equal per seed)
|
||||
// pathOK, // door→counter connectivity held (always true; carved if needed)
|
||||
@ -176,7 +178,7 @@ export function buildInterior(shop, THREE, opts) {
|
||||
counter: lay.counter, // { mesh, pose:{x,z,ry}, stand:{x,z,ry} } — Lane D keeper spawn pose
|
||||
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 []
|
||||
stage: lay.stage || null, // venue: { x,z,w,d,deckY,riserY,frontZ,bandPoses[4] } (4-piece: trio + drummer on the riser); 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)
|
||||
dims: { ...dims, archetype, type: recipe.key },
|
||||
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
|
||||
|
||||
@ -209,10 +209,12 @@ function placeBrowsePoints(grid, placed, counterStand, spawnCell, r) {
|
||||
return pts;
|
||||
}
|
||||
|
||||
// ── VENUE stage (pub, round 12 · behind ?gigs=1) ──────────────────────────────────────
|
||||
// Places a low stage centred on the back wall, PA cabinets flanking it on the floor, and 1–2 amps on the
|
||||
// deck. Stage + PA go into `placed` at priority 8 so the door→counter path loop never pulls them. Returns
|
||||
// the stage frame the crowd faces + 3 seeded band poses on the deck (Lane D drops the band trio in).
|
||||
// ── VENUE stage (v3, ?gigs) ───────────────────────────────────────────────────────────
|
||||
// Places a low stage centred on the back wall + PA cabinets flanking it on the floor (both into `placed` at
|
||||
// priority 8 so the door→counter path loop never pulls them). Returns the stage frame the crowd faces, the
|
||||
// 4 band poses on the deck, and `backline[]` — up-stage amp slots (v3.1, ledger #5) where Lane D plants E's
|
||||
// guitar_amp GLB. C no longer plants its own primitive amps: one unified backline, GLB when present, D's
|
||||
// primitive fallback under ?noassets. Pure pose data; Lane D drops the rigs + amp in.
|
||||
function placeStage(ctx, grid, W, D, roomGroup, r, spec = {}) {
|
||||
const wf = spec.deckWidthFrac || 0.58, deckH = spec.deckH || 0.32; // per-kind (round 13): band_room
|
||||
const sw = Math.min(W - 1.4, Math.max(3.0, W * wf)), sd = spec.deckDepth || 2.0; // barely a riser, rsl bigger
|
||||
@ -234,12 +236,7 @@ function placeStage(ctx, grid, W, D, roomGroup, r, spec = {}) {
|
||||
out.push({ fitting: pf, x: px, z: pz, ry: 0, hw: phw, hd: phd, rect: prect, priority: 8, kind: 'prop' });
|
||||
}
|
||||
|
||||
const deckY = f.deckY || deckH; // 1–2 amps on the deck behind the band
|
||||
for (let i = 0, n = 1 + (r() < 0.5 ? 1 : 0); i < n; i++) {
|
||||
const af = buildFitting('ampStack', ctx, {}, r);
|
||||
af.group.position.set(cx + (i === 0 ? -1 : 1) * sw * 0.28, deckY, cz - sd * 0.18);
|
||||
roomGroup.add(af.group);
|
||||
}
|
||||
const deckY = f.deckY || deckH; // deck top surface (band + backline stand height)
|
||||
|
||||
// 4-PIECE BAND (round 13, debt #3): front-line trio across the deck lip + a drummer up-stage on the
|
||||
// riser. ry=π faces the audience (+Z) under the rig-front=−Z house convention (see LANE_C_PUB.md). Each
|
||||
@ -251,7 +248,18 @@ function placeStage(ctx, grid, W, D, roomGroup, r, spec = {}) {
|
||||
}));
|
||||
const drums = { x: round(cx), z: round(cz - sd / 4), ry: round(Math.PI), role: 'drums', y: riserY, seated: true };
|
||||
const bandPoses = [...front, drums];
|
||||
return { placed: out, stage: { x: cx, z: cz, w: sw, d: sd, deckY, riserY, frontZ: round(cz + sd / 2), bandPoses } };
|
||||
|
||||
// BACKLINE (v3.1, ledger #5): up-stage amp/spare-cab slots — the unified backline that replaces C's old
|
||||
// primitive ampStacks. Pure data (no meshes): Lane D plants E's guitar_amp GLB here (primitive fallback
|
||||
// under ?noassets). Up-stage of the front line, flanking the drum riser, clear of every bandPose AND all
|
||||
// watchPoints (which sit downstage of frontZ), on-deck. slot[0] = stage-right (primary), slot[1] = spare
|
||||
// stage-left. ry=π faces the audience (+Z), same −Z convention as the band; y = deckY. Deterministic.
|
||||
// slot[0] == D's R15-verified guitar_amp pose (stage.w·0.36, deckY, stage.z − stage.d·0.22, 0.87 m bass
|
||||
// clearance), so plumbing D's plant to backline[0] moves nothing on screen.
|
||||
const backline = [sw * 0.36, -sw * 0.36].map(bx => ({
|
||||
x: round(cx + bx), z: round(cz - sd * 0.22), ry: round(Math.PI), y: round(deckY),
|
||||
}));
|
||||
return { placed: out, stage: { x: cx, z: cz, w: sw, d: sd, deckY, riserY, frontZ: round(cz + sd / 2), bandPoses, backline } };
|
||||
}
|
||||
|
||||
// ── WATCH POINTS (venue) — seeded audience floor poses facing the stage ──
|
||||
|
||||
@ -59,7 +59,8 @@ function windowMaterial() {
|
||||
// all windows (per-shop variation via aTint/aSeed attributes), same 1 draw/chunk as v1. Respects §3.5
|
||||
// via the same uHour/uNight + aHours: closed-at-night rooms go dark, open rooms glow warm. Fully
|
||||
// procedural (no fetch). Flag-off path is untouched (v1 windowMaterial()).
|
||||
const WINMAP = (() => { try { const p = new URLSearchParams(location.search); return p.has('winmap') && p.get('winmap') !== '0'; } catch (e) { return false; } })();
|
||||
// [Lane F R16 — THE FLIP] winmap DEFAULT-ON; ?winmap=0 opts out, ?classic=1 forces off (the v2 covenant).
|
||||
const WINMAP = (() => { try { const p = new URLSearchParams(location.search); const classic = p.has('classic') && p.get('classic') !== '0'; return !classic && p.get('winmap') !== '0'; } catch (e) { return false; } })();
|
||||
const ROOM_TINTS = [ // muted 90s interior wall colours (RGB 0..1), seeded per shop
|
||||
[0.56, 0.44, 0.33], [0.42, 0.47, 0.52], [0.52, 0.47, 0.37], [0.47, 0.42, 0.46],
|
||||
[0.54, 0.50, 0.41], [0.40, 0.45, 0.40], [0.58, 0.49, 0.40], [0.45, 0.48, 0.55],
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
// ry under A's poster convention (front = local −Z); posters are rendered at A's (x,z,ry) verbatim.
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { busShelterStops } from './furniture.js'; // read-only: shelter (tram-stop) positions for pools
|
||||
|
||||
const POSTER_W = 1.05, POSTER_H = 1.5; // ~A2 bill on a pole/wall
|
||||
const POSTER_SKINS = ['grunge', 'retro', 'screenprint', 'xerox']; // E's poster pack (assets/gen/poster-*.jpg)
|
||||
@ -169,11 +170,18 @@ export function createVenuePresentation(plan, skins, scene, { assetBase = 'asset
|
||||
});
|
||||
}
|
||||
|
||||
// ── one instanced streetlamp-pool mesh for the whole district (night-driven) ──────────────────────
|
||||
// ── town-wide streetlamp pools (R16 ledger #4): a warm pool under every streetlight + bus shelter,
|
||||
// not just venue frontages. Positions are replicated from the plan's street graph using furniture.js's
|
||||
// exact lamp cadence (READ-ONLY — we don't touch furniture.js), so a pool lands under each real lamp.
|
||||
// Gig-gated by construction (venue.js only exists under the gig layer ⇒ ?classic stays pool-free); one
|
||||
// instanced mesh ⇒ still ~1 draw town-wide. Night-driven via the same opacity ramp as the venue pools.
|
||||
for (const s of townPoolSpecs(plan)) poolSpecs.push(s);
|
||||
|
||||
// ── one instanced streetlamp-pool mesh for the whole town (night-driven) ──────────────────────────
|
||||
let poolMesh = null, poolMat = null;
|
||||
if (poolSpecs.length) {
|
||||
const poolTex = poolTexture(); disposables.push(poolTex);
|
||||
poolMat = new THREE.MeshBasicMaterial({ map: poolTex, transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide, fog: false, toneMapped: false });
|
||||
poolMat = new THREE.MeshBasicMaterial({ map: poolTex, transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide, fog: true, toneMapped: false });
|
||||
const pg = new THREE.PlaneGeometry(1, 1); pg.rotateX(-Math.PI / 2); // lies flat, normal +Y
|
||||
disposables.push(poolMat, pg);
|
||||
poolMesh = new THREE.InstancedMesh(pg, poolMat, poolSpecs.length);
|
||||
@ -265,6 +273,30 @@ export function createVenuePresentation(plan, skins, scene, { assetBase = 'asset
|
||||
const r2 = (v) => Math.round(v * 100) / 100;
|
||||
const r4 = (v) => Math.round(v * 10000) / 10000;
|
||||
|
||||
// ── town-wide pool positions: one under every streetlight + bus shelter, derived from the plan's street
|
||||
// graph. This MIRRORS furniture.js's streetlight rule (a lamp every 18 m from s=8, alternating sides, at
|
||||
// off = halfRoad+0.7) so pools sit under the real lamps without importing furniture internals. Pure
|
||||
// function of the plan (no rng — the cadence is deterministic), so it adds no seeded draws (release law).
|
||||
function townPoolSpecs(plan) {
|
||||
const specs = [];
|
||||
const nodeById = new Map((plan.streets?.nodes || []).map((n) => [n.id, n]));
|
||||
for (const e of (plan.streets?.edges || [])) {
|
||||
const a = nodeById.get(e.a), b = nodeById.get(e.b);
|
||||
if (!a || !b) continue;
|
||||
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1;
|
||||
const ux = dx / len, uz = dz / len, px = -uz, pz = ux;
|
||||
const halfRoad = (e.width || 8) / 2;
|
||||
let side = 1;
|
||||
for (let s = 8; s < len - 4; s += 18) {
|
||||
const off = (halfRoad + 0.7) * side;
|
||||
specs.push({ x: a.x + ux * s + px * off, z: a.z + uz * s + pz * off, diam: 3.4 });
|
||||
side = -side;
|
||||
}
|
||||
}
|
||||
for (const st of busShelterStops(plan)) specs.push({ x: st.x, z: st.z, diam: 3.6 });
|
||||
return specs;
|
||||
}
|
||||
|
||||
// ── a warm radial streetlamp-pool sprite (procedural, no fetch): bright warm centre → transparent edge.
|
||||
function poolTexture() {
|
||||
const S = 128;
|
||||
|
||||