Compare commits

...

9 Commits

Author SHA1 Message Date
m3ultra
0490bdd07e Lane F R15 (v3.0): docs assembly — README v3, LANE_F_NOTES §14/§15, F-progress, freezes confirmed
- README: a v3.0 'venues & gigs, THE DISTRICT' section — what shipped, the flags table, the release tour
- LANE_F_NOTES §14 (R14: F never ran, brief carried) + §15 (the R15 release-close runbook, numbers, gotcha ledger)
- F-progress: R15 release-close summary at the top
- confirmed all five freezes committed (A a3911a4/35da83c, B eee60e0/a0b5784, C 62e38c5/2446ae3, D 9a65f51/66aec0f, E 55f6698)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:38:37 +10:00
m3ultra
6a35b29352 Lane F R15 (v3.0): the release money-shot tour (ledger #4) + settled measurements (ledger #5)
tools/qa/tour_shots.py: one deterministic run capturing the release set into docs/shots/release_v3/
(+ contact.html), grouped by the flags each shot needs, all seed 20261990:
- venue_night, district_posters, queue_night (John's HERO — now with A's flipped frontage poster VISIBLE
  by the door), gig_interior (the 4-piece + real instruments + a roster crowd — 2 punters carried through),
  rain_verandah, tram_stop
- settled discipline (ledger #5): every shot measured after the async instrument GLBs / poster textures /
  chunk stream land (shoot-twice), from a clean per-group load; the queue is driven LIVE (explicit admits,
  not rAF auto-drain which automation throttles)

The hero confirms ledger #1: the frontage poster reads from the street for the first time. Interior settles
35k->182k tris (draws 56 <= 350 gate). All street shots <=300 draws / <=200k tris. 0 page errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:35:27 +10:00
m3ultra
edc0c479c1 Lane F R15 (v3.0): full-week gig soak (ledger #3) — leak/cover/roster over 7 nights
tools/qa/week_soak.py: rolls the day-clock through all 7 nights in one context, entering every playing
venue each night. Asserts latch state per plan.gigs (dark venues stay quiet), the cover STAMP per venue
per night (debited once, re-entry free, due again next night), tonight's roster clears per night (no
accumulation), and geo/tex return to a WARMED baseline across >=20 enter/exit cycles — leak proven by a
plateau (a real leak climbs every cycle; warm-up cache residue plateaus). Opt-in --soak warn-gate in
qa.sh (never blocks the tag; the core 6 stay a fast strict run).

Verified seed 20261990: 2 playing (Exchange Hotel + RSL) + 1 dark, 28 cycles, $60 covers, geo/tex flat
across the back half (115/86 -> 115/84), 0 console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:29:43 +10:00
m3ultra
687d23f654 Lane F R15 (v3.0): identity continuity — wire D's tonight-roster into the crowd (ledger #3)
The ped you followed into a venue now turns up in its crowd. D published the seam in R14; F wires the three touches:
- interior_mode.js: crew.spawn({ roster: rosterOf(shop.id) }) — the front crowd slots become tonight's roster
- index.html: rosterOf=(id)=>citizens.tonightRoster(id) into interior_mode; onAdmit=(e)=>citizens.recordVenueEntry(id,e)
  on the queue (each admit relays into the roster); the clear is automatic on the existing setGig(id,false)
- flags_check.py: smoke_continuity — asserts the seam (crowd includes roster-cap, pedIndex in tonightRoster),
  empty-roster injects nobody (shell side of D's byte-identical law), + ledger #5 settled-tris honesty

Verified: 2 queue admits carry into the crowd; empty roster 0-injected; settled interior 71k->182k tris
(C's finding — interior gate is draws <=54, no violation). 0 console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:19:19 +10:00
m3ultra
146b5f3d2d Lane F R15 (v3.0): retire the R12 single-venue alias (ledger #2)
The compat alias delegating to the primary venue is gone — every read is now keyed by venueShopId.
B swept the cross-lane readers in R14; this migrates F's own tools + the spec, one atomic commit.

- gig_state.js: delete the alpha-shape alias block + the now-unused primaryId/P; update() just ticks all latches
- flags_check.py + gig_shot.py: .venueShopId->venueShopIds[0], .state->stateOf(id), .cover->coverOf(id), .bandName->bandNameOf(id)
- CITY_SPEC \S v3: delete the two alias sentences, add the omitted nightOf(id) to the accessor list

selfcheck 14264/14264 green (base 0x3fa36874, gig 0x4f4a549d); flags_off + smoke_gigs green, 0 warn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:14:19 +10:00
m3ultra
2446ae3a5d Lane C R15 (v3.0): verify D's guitar_amp reposition (ledger #4)
D moved the backline plant (66aec0f) to C's §3 corrected pose exactly
(band.js:236 = stage.x + stage.w*0.36, deckY, stage.z - stage.d*0.22).

Verified fresh (port-isolated context, seed 20261990, D's real GigCrew + GLB backline):
- matches §3 both venues; bass clearance 0.87 m (pub) / 0.92 m (rsl) — the R14
  interpenetration (0.29 m) is resolved; on-deck; flanks C's primitive ampStack
  (4.4 m pub / 3.5 m rsl) as a clean up-stage backline; no band/crowd clash.
- rendered pub gig confirms the read (amp up-stage behind the bassist).

LANE_C_PUB §3 updated RESOLVED; freeze otherwise stands. No code change (docs only).
Nothing else blocks on C — F runs the release (continuity wiring, alias delete, week
soak, tour, tag v3.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:49:20 +10:00
m3ultra
66aec0f83c Lane D R15 (v3.0): guitar_amp up-stage per C's §3 ruling (ledger #4)
The R13 backline plant sat down-stage (stage.z + 0.15), 0.29 m from the bass rig
— it interpenetrated the bassist (C's R14 audit caught it). Move it up-stage-right
to C's blessed pose (stage.x + w*0.36, deckY, stage.z - d*0.22): 0.75 m clear of
the bass, behind the front line, flanking C's primitive ampStacks. Pose-only.

Verified: draw/tri delta 0 (same GLB moved — pub 50 draws / ~183k tris, <=350);
?noassets unaffected (amp is fleet-gated dressing → 0 props, band placeholders,
no crash). No other code — continuity + the ratified sit-clip deferral are closed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:25:08 +10:00
m3ultra
a0b578477c Lane B round 15: verify hero after A's poster flip + night poster readability (?gigs=1)
Verified A's ledger-#1 flip (35da83c): the pub's own-frontage poster moved from 15.9m behind the
building to 0.09m from the door on the +Z street face, facing the reader -- no queue_night pose
change needed (R14 prediction held). Gig golden 0x1f636349->0x4f4a549d consumed cleanly.

Fix (John-sanctioned exception to the R14 freeze): posters were unlit MeshStandardMaterial and
rendered pure black at NIGHT (measured frontage poster centre [183,71,59] day -> [4,0,1] night), so
queue_night + district_posters showed no readable bill even after A's flip. venue.js now gives posters
emissiveMap = the poster art, emissiveIntensity ramped in update() by night (0 day -> 0.55 night, same
lerp as glow/pools). Self-lights the bill after dark; daylight byte-identical (verified midday spine
poster pixel-unchanged). No new rng, no plan change, goldens frozen. ?noassets fallback self-lights too.

Verified fresh Chromium seed 20261990: queue_night (seg5, live queue) frames marquee + window glow +
streetlamp pools + a readable red poster by the door + queue punters (90 draws/29k tris);
district_posters night now shows a poster (was black, 180/138k); daylight unregressed.

-> Lane F (B->F handshake): HERO VERIFIED. Shoot queue_night at seg 5 (NIGHT) -- poster now reads there
   and night is needed for pools+glow; drive the queue live (rAF throttled in automation). Poster
   night-emissive rides the shell's per-frame venuePresentation.update(gigState.byVenue).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:11:53 +10:00
m3ultra
35da83c347 Lane A round 15: frontage poster +Z street-face flip (ledger #1) + gig golden re-pin
R14 review found every venue's own-frontage poster on the building's −Z BACK since R13 (~16 m
behind the street face, B measured 15.9 m) — invisible from the street, why queue_night framed no
poster. Ledger #1, A-first (B's hero re-shoot + F's tour hang off this).

- gigs.js buildPosters item 1: flip to the +Z street facade (B's canon: buildings.js facade zf=+d/2
  + venue.js door/queue/camera are +Z) AND ry=lot.ry+π so the printed face points +Z at the queue-side
  camera (else DoubleSide shows a mirrored band name). Proven: pub+rsl frontage posters now sit 0.06 m
  from B's door, printed-face·(+Z street)=1.000. Spine posters untouched.
- Gig golden re-pinned 0x1f636349 → 0x4f4a549d (the round's ONE sanctioned move; posters moved).
  Synthetic golden 0x3fa36874 frozen. §v3 FROZEN marker records the amendment (amendment law).
- selfcheck: the +Z facade is the block-interior side in plan coords, so corner venues (band_room/rsl
  fronting one street with another behind — seed 1 lot#481, 1.58 m from a crossing centreline) seat the
  frontage poster near that street. That's B's building geometry (the door is there too), not a mid-road
  defect — so the no-bitumen assert now EXEMPTS building-bound frontage posters, keeps it for spine
  posters. 400-seed sweep green again: 14264/14264.
- CITY_SPEC §v3 amended (poster prose → +Z facade + ry+π; golden line; frontage/spine carriageway split;
  recorded amendment in the FROZEN marker). LANE_A_NOTES §R15 hands B/F the new golden + a corner-venue
  eyeball note. Convention settled: visible venue face is +Z; selfcheck's −Z 'faces frontEdge' is the
  sim/GLB convention (π-off the visual front, the R13 RY_FLIP ambiguity).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:48:17 +10:00
29 changed files with 859 additions and 85 deletions

View File

@ -3,7 +3,21 @@
*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 14) · owner: PROCITY-C · reviewer: Fable
Last updated: 2026-07-16 (round 15) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-16 (round 15, v3.0: closing the release) — verified D's amp fix
R15 §Lane C (one item): verify D's `guitar_amp` reposition (ledger #4). D moved the plant (commit `66aec0f`)
to C's §3 corrected pose exactly — `band.js:236 amp.position.set(stage.x + stage.w·0.36, deckY, stage.z
stage.d·0.22)`. **Verified (fresh port-isolated context, seed 20261990, D's real GigCrew + GLB backline):**
matches §3 (both venues); **bass clearance 0.87 m (pub) / 0.92 m (rsl)** — the R14 interpenetration (0.29 m)
is gone; on-deck within footprint; flanks C's primitive ampStack (opposite side, 4.4 m pub / 3.5 m rsl) as a
clean up-stage backline; no clash with band/crowd/watchPoints. Rendered pub gig confirms the read (amp up-stage
behind the bassist, primitive up-stage-left, band clear). LANE_C_PUB §3 updated RESOLVED. Freeze otherwise
stands; no other C change this round. Nothing else blocks on C — F runs the release (continuity wiring + smoke,
alias deletion, week soak, tour, tag `v3.0`).
---

View File

@ -4,6 +4,26 @@
---
## Round 15 (v3.0 RELEASE CLOSE, `?gigs=1`) — F ran last; the release is closed
R14 landed AE's hardening but **F never ran**, so v3.0 stayed untagged. R15 = the close. All deps were in
before F ran (A's poster flip + new golden `0x4f4a549d`, D's amp + R14 continuity seam, B/C verifies). The
six-item brief, each committed atomically:
- **Continuity wiring + smoke** (`687d23f`) — the ped you followed is in the crowd: `crew.spawn({roster})` +
the queue `onAdmit → recordVenueEntry` relay; `smoke_continuity` asserts the seam on identities + settled tris.
- **Alias deletion** (`146b5f3`) — the R12 scalar alias retired; every read keyed by `venueShopId`; tools + spec migrated.
- **Full-week soak** (`edc0c47`, `tools/qa/week_soak.py`) — 7 nights × all venues: cover/roster per night, leak-free (plateau), 28 cycles.
- **Money-shot tour** (`6a35b29`, `tools/qa/tour_shots.py`) — `docs/shots/release_v3/` + contact.html; hero `queue_night` shows A's now-visible poster; settled measurements.
- **Docs** — README v3 section, LANE_F_NOTES §14/§15, this note, five freezes confirmed.
- **Acceptance + tag**`qa.sh --strict` 6/6 + continuity smoke + week soak green → tag **`v3.0`**.
**Ledger #5 vindicated C:** the gig interior settles to **~182k tris** after the 5 instrument GLBs load (not
the ~34k measured pre-load); the interior gate is **draws** (≤56), so no budget violation — the tour + smoke
now measure settled. Full runbook: [`LANE_F_NOTES.md §15`](docs/LANES/LANE_F_NOTES.md).
---
## Round 13 (v3.0-beta — THE DISTRICT, `?gigs=1`) — WIRED + VERIFIED + TAGGED
AE landed, then D (4-piece + instruments + queue + RY fix). F wired the district together and it fires.

View File

@ -47,3 +47,31 @@ standalone test pages); F integrates after they land.
Seeded everything, `Math.random` banned in gen code · the game must run with zero assets ·
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`)
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 **24 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.
**Flags** (all default-off unless noted; compose freely):
| 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 |
**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`.

View File

@ -1,10 +1,37 @@
# LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check all-green (14264/14264); rounds 214 closed** (see below).
v3.0 RELEASE round: 400-seed district invariant sweep landed + CITY_SPEC §v3 frozen at `v3.0`. Goldens
unchanged (synthetic `0x3fa36874`, gig `0x1f636349`) — no golden moved, no rng draw added this round.*
*Status: **all deliverables landed; self-check all-green (14264/14264); rounds 215 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.*
*Date: 2026-07-16 · owner: PROCITY-A (Opus 4.8)*
## 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
own-frontage poster had been on the building's **Z back** since R13 — ~16 m behind the street face
(B measured 15.9 m), invisible from the street, which is why John's `queue_night` framed no poster.
Delivered (one commit):
1. **Frontage-poster fix** (`gigs.js` buildPosters item 1): flipped to the **+Z street facade** (B's
canon) AND `ry = lot.ry + π` so the printed face points `+Z` at the queue-side camera (else `DoubleSide`
renders a mirrored band name). Proven: pub + rsl frontage posters now sit **0.06 m from B's door**,
printed-face·(+Z street) = **1.000**. Spine posters untouched.
2. **Gig golden re-pinned `0x1f636349 → 0x4f4a549d`** — the round's ONE sanctioned move (posters moved);
synthetic `0x3fa36874` frozen. §v3 FROZEN marker records the amendment (amendment law).
3. **Selfcheck poster check refined**: the brief expected the 400-seed off-carriageway assert to stay
green, but the `+Z` facade is the block-interior side in *plan* coords, so **corner venues** (a band_room/
rsl fronting one street with another behind — seed 1 lot#481, 1.58 m from a crossing centreline) put the
frontage poster near that street. That's B's building geometry (the door is there too), not a mid-road
defect — so the no-bitumen rule now **exempts building-bound frontage posters** and keeps it for
free-standing **spine** posters. Sweep green again: **14264/14264**.
4. **CITY_SPEC §v3 amended** (poster prose → `+Z` facade + `ry+π`; golden line; frontage/spine carriageway
split; recorded amendment in the FROZEN marker). LANE_A_NOTES §R15 hands B/F the new golden + a
corner-venue eyeball note.
Convention settled: the visible venue face is `+Z` (`buildings.js` facade `zf=+d/2`; `venue.js` door/queue
`+Z`). The selfcheck's `facing=(sin,cos)` "faces frontEdge" is the sim/GLB convention, 180° off the
visual front — the same π-ambiguity D fixed for the fleet in R13.
## Round 14 (2026-07-16) — v3.0 RELEASE: invariant sweep + CITY_SPEC §v3 freeze
Release round, Lane A scope = harden + freeze (no new systems). **No golden moved** (synthetic

View File

@ -213,6 +213,11 @@ night in v1; interior-mapping shader is a stretch goal).
> never silent drift. **One edit is still pending and lands with Lane F's alias-retirement commit** (R13
> debt #1): the R12 single-venue compat alias in the runtime block below retires; everything else here is
> final for the `v3.0` tag.
>
> *(**Recorded amendment — ROUND15, ledger #1:** the frontage poster was flipped to the `+Z` street
> 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.)*
> **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 —
@ -267,9 +272,8 @@ gigs: [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover
(startSeg1 = DUSK) → on → done`. (Day-segment→hour map is `lighting.js`: `0 DAWN … 5 NIGHT`.)
**Runtime surface** F publishes on `window.PROCITY.gigs` (B's `venue.js`/`audio.js` and D's `setGig` read
it): `.byVenue → { [venueShopId]: 'quiet'|'doors'|'on'|'done' }` plus per-venue getters
`stateOf/onOf/openOf/gigOf/coverOf/bandNameOf/paidOf(id)` + `markPaidOf(id)` and `.venueShopIds`. The R12
single-venue alias (`.state/.on/.open/.venueShopId/.cover/.paid/…` → the **primary** venue = `gigs[0]`'s)
is kept for **one round** so mid-round single-venue callers don't break; it retires next round.
`stateOf/onOf/openOf/gigOf/coverOf/bandNameOf/nightOf/paidOf(id)` + `markPaidOf(id)` and `.venueShopIds`.
Every read is keyed by `venueShopId` — there is no scalar alias (the R12 single-venue alias retired in R15).
- `cover` — integer in `{0} [2,10]`, **~half free town-wide, skewed by kind**: pub 50% free / `$210`,
RSL 45% free / `$25` (cheap — the bistro pays the bills), band_room 70% free / `$26` (a hat on a
milk crate). F debits the wallet **per venue** at each door; a night at two venues is two covers.
@ -279,11 +283,14 @@ gigs: [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover
**`plan.posters`** — town-wide gig advertising (Lane B renders): `posters: [ { id, gigId, x, z, ry } ]`.
Only **tonight's** (night 0) playing venues are advertised. Each such venue gets one poster on **its own
frontage** (seated on the facade plane, `lot.x + front·(d/2+ε)`), 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 runs — that IS the district reading. `ry` follows the one house convention (front/outward
normal = local `Z` at `ry=0`; `ry = atan2(nx, nz)`), so a poster faces the footpath that reads it. B
places E's poster skin at `(x, z)` rotated `ry`; F wires "poster → that venue's tonight-gig".
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
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".
**The street-corridor law (ROUND13 debt #5 — published as functions, not plan data).** `edge.width` is
the **full corridor** (carriageway + verge/footpath, centreline ± width/2), never the carriageway alone —
@ -292,8 +299,11 @@ pure functions over an edge (no plan fields change ⇒ base goldens stay frozen)
barrel: **`roadWidth(edge)`** (full carriageway width — main 10 m, side 68, lane = all, arcade 0; the
kerb sits at `roadWidth/2` from the centreline),
**`vergeBand(edge) → [inner,outer]`** (where footpath furniture lives), **`poleOffset(edge)`** (where a
pole/poster seats — just behind the kerb). Posters, poles, verandah posts belong in the verge band; the
selfcheck asserts no poster stands within `roadWidth/2` of any edge centreline.
pole/poster seats — just behind the kerb). Poles, verandah posts and **spine** posters belong in the verge
band; the selfcheck asserts no **spine** poster stands within `roadWidth/2` of any edge centreline. A
**frontage** poster is exempt — it is bound to its venue building (the +Z facade B draws), so at a corner
venue whose facade abuts a crossing street it may legitimately sit near that street's kerb (B's building
geometry, not a mid-road defect).
**Band names + the drop-in.** `bandName(seed)` generates 90s-Aussie pub-rock names from wordlists.
`web/assets/custom_bands.json` is an **OPTIONAL** editable drop-in (schema `{ "bands": ["The Feral
@ -306,8 +316,9 @@ 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:
**`0x1f636349`** (ROUND13 re-pin — the district changed gig-plan output; was `0xa6ae5a5e`, the one-pub
alpha). Asserted by `selfcheck.js` alongside the frozen v2 goldens; changing gig output re-pins it.
**`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.
## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read)

View File

@ -2,6 +2,36 @@
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
## 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)
The frontage-poster fix moved `plan.posters`, so the gig golden re-pinned — the round's ONE sanctioned
move (amendment law; §v3 marker records it). B: re-shoot `queue_night` against this. F: the smoke's gig
golden is now `0x4f4a549d`.
### The fix (gigs.js buildPosters item 1)
Each venue's own-frontage poster sat at local **Z** — the *back* of the building (~16 m behind, matching
B's 15.9 m), invisible from the street, which is why `queue_night` framed no poster. Flipped to the **+Z
street facade** AND turned it to read from the street: `ry = lot.ry + π` so its printed face points `+Z`
at the queue-side camera (else `DoubleSide` shows a mirrored band name). Proven: pub + rsl frontage posters
now sit **0.06 m from B's door**, printed-face·(+Z street) = **1.000**. Only item 1; spine posters untouched.
### The Z/+Z convention, settled (worth recording — it's the R13 RY_FLIP π-ambiguity again)
The **visible** venue face is `+Z`: `buildings.js` builds the facade/door at `zf = +d/2` ("front face at
local +Z"), and `venue.js` seats the door-glow/queue/poster-camera on `(sin ry, cos ry)` = `+Z`. The
selfcheck's `facing = (sin ry,cos ry)` "every lot faces its frontEdge" is the **sim/GLB** convention,
180° off from the visual front (same ambiguity D fixed at the source for the fleet in R13). A poster faces
the *viewer* when its printed face = `(sin ry,cos ry)` points at them, i.e. `ry = atan2(away-from-viewer)`.
### → B + F (eyeball in the tour): corner-venue frontage posters can sit near a crossing street
The `+Z` facade is the block-interior side in *plan* coordinates, so for a **corner venue** (a band_room/
rsl fronting one street with another behind — e.g. seed 1 band_room lot#481, 1.58 m from a side-street
centreline) the frontage poster lands near that crossing street's kerb. That's B's building geometry (the
door is there too), not a mid-road defect — so the selfcheck's no-bitumen assert now **exempts frontage
posters** (they're building-bound) and keeps it for free-standing **spine** posters. The pub hero is clean
(29.9 m of open ground on `+Z`). If a corner poster reads as floating over a road in the tour, that's a
v3.1 venue-placement tweak, not a v3.0 blocker.
## Round 14 (2026-07-16) — v3.0 RELEASE: invariant sweep + CITY_SPEC §v3 frozen
Release round — no new systems, no golden moves. **Synthetic `0x3fa36874` and gig `0x1f636349` are

View File

@ -1,5 +1,43 @@
# LANE B — NOTES (measured)
## 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`
street face, `ry = lot.ry + π`; gig golden re-pinned `0x1f636349 → 0x4f4a549d`, synthetic frozen).
**A's placement is correct — verified.** The pub's own-frontage poster moved from **15.9 m behind the
building** (R13/R14) to **0.09 m from the door on the +Z street face**, facing the reader (A's `ry+π`
composes with venue.js's `+π` render → the printed face points along the street outward normal, name
not mirrored). No pose change needed — my R14 prediction held.
**But a night-lighting gap surfaced (and John sanctioned the fix).** Posters are unlit
`MeshStandardMaterial` (emissive `000000`); nothing illuminates them at night (marquee/pools/glow are
emissive *materials*, not lights). Measured the frontage poster centre pixel: **DAY `[183,71,59]`
(vivid) → NIGHT `[4,0,1]` (pure black)** — so `queue_night` (seg 5) still framed no readable bill, the
very thing A's golden move was for; `district_posters` was dark for the same reason. **Fix (venue.js,
John-sanctioned exception to the R14 freeze):** posters get `emissiveMap = <the poster art>` +
`emissive = white`, `emissiveIntensity` **ramped in `update()` by night** (0 by day → 0.55 at night,
same lerp as the glow/pools). Self-lights the bill after dark; **daylight stays byte-identical**
(emissive 0 → verified the midday SCREAMING UTES spine poster is pixel-unchanged, no glow/wash). No new
rng, no plan change, goldens untouched (`0x3fa36874` / `0x4f4a549d` both frozen). `?noassets` fallback
poster self-lights too (its canvas exists, zero fetch).
Verified fresh Chromium, seed 20261990:
- **`queue_night`** (seg 5, live queue spawned): marquee + window glow + streetlamp pools + **readable
red poster by the door** + queue punters + dark CLOSED neighbours. 90 draws / 29k tris.
- **`district_posters`** (seg 5): a venue poster now reads on the spine (was black). 180 draws / 138k tris.
- **Daylight** spine poster: byte-identical to R13/R14 (day emissive ramps to ~0). `venue_night` reads.
- Spine posters face the street everywhere (A's flip only moved item-1 frontage posters; item-2 pole
posters unchanged, still readable).
**→ Lane F (B→F handshake): HERO VERIFIED.** `queue_night` frames queue + marquee + A's now-visible
poster; drive the queue live in `tour_shots.py` (the rAF is throttled in automation — I spawn D's
`VenueQueue` manually to preview). Shoot at **seg 5 (NIGHT)** — the poster now reads there (fix above),
and night is required for the pools + window glow. Poster night-readability is on venue.js's `update()`
ramp, so the shell's per-frame `venuePresentation.update(gigState.byVenue)` already drives it.
---
## Round 14 (Fable's ROUND14 → Lane B) — v3.0 RELEASE: alias retirement + the tour audit
Release round — no new systems. Three B files touched (`audio.js`, `venue.js`, `dbg.js`); no world

View File

@ -97,20 +97,17 @@ 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 ruling (v3.0)
## 3. `guitar_amp` placement (v3.0 — RESOLVED R15)
D plants E's `guitar_amp` GLB (`band.js _addBackline`) at `(stage.x + stage.w·0.32, deckY, stage.z + 0.15)`.
**Ruling: reposition up-stage — this pose is a v3.1 fix for D, not blessed as-is.** That `z = stage.z + 0.15`
is **down-stage** (audience side): the 0.5 m amp lands 0.29 m from the bass rig, so it **interpenetrates the
bassist** and reads as clutter, not a backline. Move it **up-stage** behind the front line and clear of both
the bass rig *and* C's own primitive ampStacks. C's `placeStage` plants **12 primitive `ampStack` fittings**
at `(±stage.w·0.28, deckY, stage.z stage.d·0.18)` (up-stage, always rendered, the asset-free backline
fallback — no GLB slot, so they stay primitive). A clean spot for D's GLB: **`(stage.w·0.36, deckY, stage.z
stage.d·0.22)`** (up-stage-right, clears the bass and offsets from the primitives). With the GLB up-stage it
reads as a flanking backline with C's primitive. *Redundancy note:* the primitive + GLB coexist (23 amps);
fully unifying them — C exposes a `stage.backline[]` slot list that D's GLB fills, replacing the primitive —
is a **v3.1** item (no new contract fields in the frozen v3.0). Since the interpenetration is cosmetic and
`?gigs=1` is opt-in, it does not block the v3.0 tag; D corrects the pose when convenient.
**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 **12 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).
## 4. Budgets (measured R14, seed 20261990, fresh context)
- **Empty shells** (`drawSweep` glb-off): band_room **124** · pub **126** · rsl **135** — town-wide worst

View File

@ -6,6 +6,18 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
---
## 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,
ratified sit-clip deferral) is closed.* The R13 amp sat at `z = stage.z + 0.15` (down-stage) — 0.29 m from
the bass rig, interpenetrating the bassist (C's audit caught it). Moved up-stage-right to C's blessed pose
**`(stage.x + w·0.36, deckY, stage.z d·0.22)`** = (1.44, 0.32, 3.27) at the pub: **0.75 m clear** of the
bass, behind the front line, flanking C's primitive ampStacks. Verified: draw/tri delta **0** (same GLB
moved — pub interior 50 draws · ~183k tris), `?noassets` unaffected (amp is fleet-gated dressing → `props 0`,
band placeholders, no crash).
---
## ROUND 14 — v3.0 release: identity continuity (the ped you followed is in the crowd)
*PROCITY-D, 2026-07-16. The last charter promise. Behind `?gigs=1`; files `sim.js`, `band.js`, `queue.js`.

View File

@ -523,3 +523,74 @@ line in `smoke_gigs §2b`.)
(`queueInfo`) so the queue smoke goes live, and (b) the band polish (E's instrument GLBs in hand, drummer
facing/RY_FLIP) — the band already lands at 4, human-sized, 0 errors, so this is polish not a blocker. Once D
commits: re-run the gates, flip the queue assert, capture the district money shots, tag **v3.0-beta**.
*(R13 epilogue: D landed mid-session; F wired the queue (D shipped `VenueQueue`, left the street-side
lifecycle to F), the queue smoke went live, both money shots shipped, and **`v3.0-beta`** was tagged +
pushed. The `queueInfo` proposal above was superseded by D's `VenueQueue.count()` + F's shell `queueCountOf`.)*
---
## 14. Round 14 — F never ran; the brief carried in full to R15.
R14 was the "v3.0 release round." AE landed excellent hardening (D's identity-continuity seam, B's
alias-reader retirement + `queue_night` hero + 19-bookmark tour audit, A's 400-seed invariant sweep + §v3
freeze, C's adversarial venue audit, E's provenance freeze) **but Lane F never ran** — so v3.0 was not
tagged and F's whole release brief (continuity wiring, alias deletion, week soak, the tour, the tag) was
untouched. Fable carried it verbatim into R15. Nothing F-authored landed in R14.
## 15. Round 15 — THE v3.0 RELEASE CLOSE (`?gigs=1`). F ran last; the six-item brief, all green.
A first (poster flip + golden re-pin) → B/C verify → D one-line amp move → **F closes**. All deps were in
before F ran (verified against origin). The six F items (ROUND15 §Lane F), each committed atomically by
pathspec:
1. **Continuity wiring + smoke** (ledger #3) — D published the seam in R14; F wired its three touches:
`crew.spawn(…, { roster: rosterOf(shop.id) })` in `interior_mode.js` (front crowd slots become tonight's
roster), `rosterOf:(id)=>citizens.tonightRoster(id)` + `onAdmit:(e)=>citizens.recordVenueEntry(id,e)` on
the queue in the shell (the clear is automatic on `setGig(id,false)`). New `smoke_continuity` asserts the
seam on **identities** (crowd ⊇ roster ∩ cap · every `member.pedIndex ∈ tonightRoster` · `crewInfo.fromRoster`),
the empty-roster path injects nobody (shell side of D's byte-identical law), and **settled tris**
(ledger #5). Commit `687d23f`.
2. **Alias deletion** (ledger #2) — the R12 single-venue scalar alias is **gone**; every read is keyed by
`venueShopId`. Deleted the alias block + `primaryId`/`P` in `gig_state.js`; migrated `flags_check.py` +
`gig_shot.py` (`.venueShopId→venueShopIds[0]` · `.state→stateOf(id)` · `.cover→coverOf(id)` ·
`.bandName→bandNameOf(id)`); deleted the two CITY_SPEC §v3 alias sentences + added the omitted
`nightOf(id)`. B's R14 sweep left the cross-lane readers clean; only F's tools remained. One atomic
commit `146b5f3`.
3. **Full-week soak** (`tools/qa/week_soak.py`, opt-in `qa.sh --soak`, non-strict) — 7 nights × every playing
venue in one context: latch state per `plan.gigs` (dark venues quiet), cover **stamped per venue per
night** (debited once, re-entry free, due again next night), roster **clears per night** (no
accumulation), and geo/tex return to a **warmed baseline** across ≥20 cycles — a leak is unbounded
per-cycle growth; warm-up cache residue **plateaus** (the memTrail proves it). Commit `edc0c47`.
4. **The money-shot tour** (`tools/qa/tour_shots.py`, ledger #4/#5) — one deterministic run → `docs/shots/
release_v3/` + `contact.html`: `venue_night`, `district_posters`, **`queue_night` (John's hero — A's
flipped frontage poster now reads from the street)**, `gig_interior` (4-piece + real instruments + a
roster crowd), `rain_verandah`, `tram_stop`. **Settled discipline**: measured after the async instrument
GLBs / posters / chunks land (shoot-twice), clean per-group loads; queue driven **live** (explicit admits,
not throttled rAF auto-drain). Commit `6a35b29`.
5. **Docs assembly** — README v3 section (what shipped · flags table · the tour), this §14/§15 runbook,
F-progress current, the five freezes confirmed committed (A `a3911a4`/`35da83c`, B `eee60e0`/`a0b5784`,
C `62e38c5`/`2446ae3`, D `9a65f51`/`66aec0f`, E `55f6698`).
6. **Acceptance + tag**`qa.sh --strict` 6/6 + `smoke_continuity` + `week_soak.py` green → tag **`v3.0`**.
### Numbers (measured, seed 20261990 → "Boolarra Heads", 3 venues)
| thing | number |
|---|---|
| district | 3 venues — 2 playing (The Exchange Hotel `gig-pubrock` + an RSL `gig-covers`), 1 dark |
| gig interior, settled | 4-piece · crowd 8 (2 from the roster) · **56 draws** · **35k→182k tris** (pre-load→settled; gate is draws ≤350) |
| street @ venue block, night | `queue_night` 121 draws / 62k tris · `district_posters` 186 / 134k (busiest) — all ≤300/200k |
| week soak | 28 enter/exit cycles · $60 covers · geo/tex flat across the back half (115/86→115/84) · 0 errors |
| continuity seam | 2 queue admits carried into the crowd · empty roster 0-injected · `member.pedIndex ∈ tonightRoster` |
| **settled-tris honesty** (ledger #5) | C was right: the interior settles to **~182k** tris after the 5 instrument GLBs load, not the ~34k measured pre-load |
### The gotcha ledger closed this round
- **Alias retirement is a three-party move**: B swept cross-lane readers (R14), A left the spec sentences
for F (avoiding a doc-ahead-of-code race), F deleted the code + migrated its own tools + the spec in ONE
commit. No half-renamed state ever existed.
- **Settled measurement** (ledger #5): a headless smoke that reads `renderer.info` right after `enterShop`
sees the room BEFORE its async instrument GLBs decode — ~34k, not the ~182k settled truth. The tour + the
continuity smoke both wait/shoot-twice now; the interior gate stays on **draws** (which is load-order
stable), so there was never a budget violation, only a dishonest number.
- **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.

View File

@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><title>PROCITY v3.0 — 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.0 — release money-shot tour <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>121 draws · 51,899 tris · queue 2 · 14 posters</figcaption></figure><figure><img src="district_posters.png" alt="district_posters" loading="lazy"><figcaption><b>district_posters</b><br>186 draws · 134,068 tris · queue 2 · 14 posters</figcaption></figure><figure><img src="queue_night.png" alt="queue_night" loading="lazy"><figcaption><b>queue_night</b><br>121 draws · 61,738 tris · queue 2 · 14 posters</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) · 56 draws · 35,216→182,657 tris (pre-load→settled)</figcaption></figure><figure><img src="rain_verandah.png" alt="rain_verandah" loading="lazy"><figcaption><b>rain_verandah</b><br>116 draws · 51,643 tris</figcaption></figure><figure><img src="tram_stop.png" alt="tram_stop" loading="lazy"><figcaption><b>tram_stop</b><br>105 draws · 172,187 tris</figcaption></figure></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 946 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

View File

@ -630,9 +630,10 @@ def smoke_gigs(p):
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();
const id = P.gigs.venueShopIds[0]; // R15: no scalar alias the primary venue by id
D.setSegment(5); // NIGHT the gig is on
const state = P.gigs.state;
D.enterShop(P.gigs.venueShopId);
const state = P.gigs.stateOf(id);
D.enterShop(id);
await new Promise(r => setTimeout(r, 500));
const room = P.interiorMode.current;
if (!room) return { entered: false, state };
@ -777,7 +778,7 @@ def smoke_gigs(p):
q = pg.evaluate("""async () => {
const P = window.PROCITY, D = window.DBG;
if (typeof P.queueCountOf !== 'function') return { wired: false };
const id = P.gigs.venueShopId;
const id = P.gigs.venueShopIds[0];
D.setSegment(4); await new Promise(r => setTimeout(r, 700)); // DUSK doors open, the line forms
const atDoors = P.queueCountOf(id) | 0;
D.setSegment(0); await new Promise(r => setTimeout(r, 700)); // DAWN closing time ('done'), line gone
@ -806,7 +807,7 @@ def smoke_gigs(p):
st = pg.evaluate("""async () => {
const P = window.PROCITY, D = window.DBG;
D.setSegment(5); // NIGHT frontage lit, posters up
const id = P.gigs.venueShopId, shop = (P.plan.shops || []).find(s => s.id === id);
const id = P.gigs.venueShopIds[0], shop = (P.plan.shops || []).find(s => s.id === id);
const lot = shop && (P.plan.lots || []).find(l => l.id === shop.lot);
if (lot) {
const fx = Math.sin(lot.ry || 0), fz = Math.cos(lot.ry || 0);
@ -834,15 +835,16 @@ def smoke_gigs(p):
boot(pg, 'gigs=1')
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
D.setSegment(5);
const cover = P.gigs.cover;
const cover = P.gigs.coverOf(id);
if (!cover) return { skip: true, cover };
const before = P.wallet.cash();
D.enterShop(P.gigs.venueShopId); await new Promise(r => setTimeout(r, 350));
D.enterShop(id); await new Promise(r => setTimeout(r, 350));
const inRoom1 = !!P.interiorMode.current, afterPay = P.wallet.cash();
D.exitShop(); await new Promise(r => setTimeout(r, 250));
const mid = P.wallet.cash();
D.enterShop(P.gigs.venueShopId); await new Promise(r => setTimeout(r, 350));
D.enterShop(id); await new Promise(r => setTimeout(r, 350));
const inRoom2 = !!P.interiorMode.current, afterReentry = P.wallet.cash();
D.exitShop(); await new Promise(r => setTimeout(r, 250));
// now go broke and try again on the SAME night (stamp already paid still free), then roll the
@ -850,7 +852,7 @@ def smoke_gigs(p):
P.wallet.buy({ title: 'qa: drain', price: P.wallet.cash() });
D.setSegment(1); D.setSegment(5); // NIGHT MORNING NIGHT = a new night, cover due
const brokeCash = P.wallet.cash();
D.enterShop(P.gigs.venueShopId); await new Promise(r => setTimeout(r, 350));
D.enterShop(id); await new Promise(r => setTimeout(r, 350));
const inRoomBroke = !!P.interiorMode.current;
return { cover, before, afterPay, inRoom1, mid, afterReentry, inRoom2, brokeCash, inRoomBroke,
inv: P.wallet.inventory().map(i => i.title) };
@ -882,10 +884,11 @@ def smoke_gigs(p):
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
free = 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
D.setSegment(5);
const cover = P.gigs.cover, before = P.wallet.cash();
D.enterShop(P.gigs.venueShopId); await new Promise(r => setTimeout(r, 350));
return { cover, before, after: P.wallet.cash(), inRoom: !!P.interiorMode.current, band: P.gigs.bandName };
const cover = P.gigs.coverOf(id), before = P.wallet.cash();
D.enterShop(id); await new Promise(r => setTimeout(r, 350));
return { cover, before, after: P.wallet.cash(), inRoom: !!P.interiorMode.current, band: P.gigs.bandNameOf(id) };
}""")
if free['cover'] == 0 and free['inRoom'] and free['after'] == free['before']:
OK(f"cover: free night (seed {FREE_GIG_SEED}, {free['band']}) → walked straight in, wallet untouched (${free['after']})")
@ -906,12 +909,13 @@ def smoke_gigs(p):
pg.keyboard.press('Space')
na = 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
D.setSegment(5);
D.enterShop(P.gigs.venueShopId); await new Promise(r => setTimeout(r, 450));
D.enterShop(id); await new Promise(r => setTimeout(r, 450));
const info = P.interiorMode.crewInfo;
return { state: P.gigs.state, inRoom: !!P.interiorMode.current, band: info && info.band,
return { state: P.gigs.stateOf(id), inRoom: !!P.interiorMode.current, band: info && info.band,
crowd: info && info.crowd, gigs: (P.plan.gigs || []).length,
bandName: P.gigs.bandName, audio: P.audio && P.audio.state.manifest };
bandName: P.gigs.bandNameOf(id), audio: P.audio && P.audio.state.manifest };
}""")
if na['inRoom'] and na['band'] == 4 and na['crowd'] > 0:
OK(f"?noassets=1&gigs=1: the gig still happens (4-piece band, crowd {na['crowd']}, placeholders)")
@ -927,6 +931,88 @@ def smoke_gigs(p):
b.close()
def smoke_continuity(p):
"""R15 identity continuity (ledger #3): the ped who queued/surged into a venue turns up in its crowd.
Exercises F's real relay end-to-end — VenueQueue.admitOne() → onAdmit → citizens.recordVenueEntry →
tonightRoster(id) GigCrew.spawn({roster}) the front crowd slots carry those identities. Asserts the
seam on identities (crewInfo.fromRoster + member.pedIndex tonightRoster), the invariant crowd
roster cap, and that an EMPTY roster injects nobody (D's byte-identical law, proven here on the shell
side; D's sim soak proves the sim side). Also ledger #5 — reports SETTLED tris (after the async
instrument GLBs land), not the pre-load number C caught the R13 smoke reporting."""
head('SMOKE: continuity (R15 — the ped you followed is in the crowd · empty-roster clean · settled tris)')
if not gigs_landed(p):
print('· gigs=1 not landed (no plan.gigs) — skipping (Lane A)')
return
b, pg, errs = new_page(p)
try:
boot(pg, 'gigs=1')
pg.keyboard.press('Space')
res = pg.evaluate("""async () => {
const P = window.PROCITY, D = window.DBG;
const id = P.gigs.venueShopIds[0];
const rosterOf = () => (P.citizens.tonightRoster(id) || []).map(e => e.pedIndex);
// 1) EMPTY-ROSTER PATH enter before any admit; crew must inject only what's in the roster
// (usually 0). This is the shell side of D's "empty roster = byte-identical to R13" law.
D.setSegment(5); // NIGHT the gig is on
const rosterAtEmpty = rosterOf();
D.enterShop(id); await new Promise(r => setTimeout(r, 550));
const ei = P.interiorMode.crewInfo;
const emptyFromRoster = ei ? ei.fromRoster : -1, emptyCrowd = ei ? ei.crowd : 0;
D.exitShop(); await new Promise(r => setTimeout(r, 250));
// 2) POPULATED PATH let the street loop spawn the queue, then admit a few EXPLICITLY (rAF
// auto-drain is throttled in automation, per B's note). Each admit fires onAdmit → recordVenueEntry.
for (let i = 0; i < 8; i++) await new Promise(r => requestAnimationFrame(r));
const q = P.venueQueues && P.venueQueues.get(id);
const admitted = [];
if (q) for (let i = 0; i < 3 && q.count() > 0; i++) { const e = q.admitOne(); if (e) admitted.push(e.pedIndex); }
const roster = rosterOf();
D.enterShop(id); await new Promise(r => setTimeout(r, 700)); // build + crew spawn
const info = P.interiorMode.crewInfo, room = P.interiorMode.current;
const crowd = P.interiorMode.crew ? P.interiorMode.crew.members.filter(m => m.part === 'crowd') : [];
const rosterCrowd = crowd.filter(m => m.fromRoster).map(m => m.pedIndex);
// 3) SETTLED TRIS (ledger #5) — immediately after build vs after the instrument GLBs + rig atlas land
P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera);
const trisImmediate = P.renderer.info.render.triangles, draws = P.renderer.info.render.calls;
await new Promise(r => setTimeout(r, 1600));
P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera);
const trisSettled = P.renderer.info.render.triangles, drawsSettled = P.renderer.info.render.calls;
return { rosterAtEmpty, emptyFromRoster, emptyCrowd, admitted, roster, rosterCrowd,
fromRoster: info ? info.fromRoster : -1, crowd: info ? info.crowd : 0,
watchPoints: room ? room.watchPoints.length : 0,
trisImmediate, draws, trisSettled, drawsSettled };
}""")
# empty-roster path: crew injects exactly what's in the roster at that moment (0 unless surge beat us)
want_empty = min(len(res['rosterAtEmpty']), res['emptyCrowd'])
if res['emptyFromRoster'] == want_empty:
OK(f"empty roster: crew injected {res['emptyFromRoster']} from a {len(res['rosterAtEmpty'])}-entry roster "
f"(crowd {res['emptyCrowd']} — no phantom continuity, R13-identical path)")
else:
FAIL(f"empty roster: fromRoster {res['emptyFromRoster']} != roster∩cap {want_empty} (roster leaked)")
# the queue admits were recorded into tonightRoster (the onAdmit → recordVenueEntry relay)
roster, admitted, rosterCrowd = res['roster'], res['admitted'], res['rosterCrowd']
if admitted and all(a in roster for a in admitted):
OK(f"relay: {len(admitted)} queue admits → tonightRoster ({admitted} ⊆ roster of {len(roster)})")
else:
FAIL(f"relay: queue admits {admitted} not all in tonightRoster {roster} (onAdmit→recordVenueEntry broke)")
# THE SEAM — crowd ⊇ roster ∩ cap, and every roster-crowd member's pedIndex is genuinely in the roster
cap = res['crowd']
want = min(len(roster), cap)
subset_ok = set(rosterCrowd).issubset(set(roster))
if res['fromRoster'] == want and want > 0 and subset_ok and set(roster[:cap]).issubset(set(rosterCrowd)):
OK(f"continuity seam: {res['fromRoster']} of {cap} crowd came from the roster "
f"(crowd ⊇ roster∩cap ✓, all pedIndex ∈ tonightRoster ✓) — the ped you followed is in the crowd")
else:
FAIL(f"continuity seam: fromRoster {res['fromRoster']} vs roster∩cap {want}; rosterCrowd {rosterCrowd} "
f"vs roster {roster} (subset={subset_ok})")
# ledger #5 — settled-tris honesty (report both; interior gate is draws, which stays low)
OK(f"settled tris: {res['trisImmediate']:,} pre-load → {res['trisSettled']:,} settled "
f"(instrument GLBs; draws {res['draws']}{res['drawsSettled']}{GIG_DRAWS_MAX} gate)")
if errs: FAIL(f"continuity: {len(errs)} console error(s); first: {errs[0][:140]}")
else: OK('continuity: 0 console errors')
finally:
b.close()
def main():
srv = ensure_server()
try:
@ -951,7 +1037,8 @@ def main():
smoke_shelfbuy(p) # new (warn) — buy-anywhere (book/toy shelves)
smoke_tram(p) # new (warn) — tram (auto-skips if not landed)
smoke_audio(p) # R11 — audio house-law (silent-happy · pre-gesture · mute · noassets · leak)
smoke_gigs(p) # R12 — v3.0-alpha gig layer (schedule · band · crowd · budget · cover · noassets)
smoke_gigs(p) # R12/13 — the district gig layer (schedule · band · crowd · budget · cover · noassets)
smoke_continuity(p) # R15 — identity continuity (the ped you followed is in the crowd · settled tris)
finally:
if srv: srv.terminate()

View File

@ -111,6 +111,21 @@ else
soft_skip "interior figure-scale gate" "Playwright venv absent (python3 -m venv tools/.venv && …/pip install playwright)"
fi
# ── Gate 6 (Lane F, round 15): full-week gig soak — OPT-IN (--soak), NON-STRICT ─
# The v3.0 release soak: 7 nights × every playing venue in one context — latch state per plan.gigs,
# cover stamp per venue per night, roster clears per night, leak geo/tex to a warmed baseline across
# ≥20 enter/exit cycles. Slow (~90s) + diagnostic, so it is OPT-IN and warn-level (never blocks a tag);
# the core 6 gates stay a fast strict run. Wired per ROUND15 §Lane F.3.
SOAK_RUN=0; for a in "$@"; do [ "$a" = "--soak" ] && SOAK_RUN=1; done
if [ "$SOAK_RUN" = 1 ]; then
if [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then
warn_gate "full-week gig soak (7 nights · cover/roster per night · leak-free · Lane F R15)" \
tools/.venv/bin/python tools/qa/week_soak.py
else
soft_skip "full-week gig soak" "Playwright venv absent"
fi
fi
# ── Summary ──────────────────────────────────────────────────────────────────
hr
printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d warn%s · %s%d skipped%s\n' \

View File

@ -46,11 +46,12 @@ def ensure_server():
INTERIOR_JS = r"""
async () => {
const P = window.PROCITY, D = window.DBG, THREE = P.THREE;
const id = P.gigs.venueShopIds[0]; // R15: no scalar alias read the primary venue by id
D.setSegment(5); // NIGHT the gig is on
D.enterShop(P.gigs.venueShopId);
D.enterShop(id);
await new Promise(r => setTimeout(r, 900)); // room build + crew spawn + rig clips settle
const room = P.interiorMode.current;
if (!room) return { ok: false, why: 'not in the venue', state: P.gigs.state };
if (!room) return { ok: false, why: 'not in the venue', state: P.gigs.stateOf(id) };
const st = room.stage;
P.camera.position.set(st.x + 0.2, 1.6, room.dims.D / 2 - 1.2);
P.camera.lookAt(new THREE.Vector3(st.x, 1.35, st.z));
@ -58,8 +59,8 @@ async () => {
P.renderer.render(P.interiorMode.scene, P.camera);
const crew = P.interiorMode.crew, mem = crew ? crew.members : [];
const band = mem.filter(m => m.part === 'band');
return { ok: true, venue: (P.plan.shops.find(s => s.id === P.gigs.venueShopId) || {}).name,
band: P.gigs.bandName, state: P.gigs.state, cover: P.gigs.cover, crew: P.interiorMode.crewInfo,
return { ok: true, venue: (P.plan.shops.find(s => s.id === id) || {}).name,
band: P.gigs.bandNameOf(id), state: P.gigs.stateOf(id), cover: P.gigs.coverOf(id), crew: P.interiorMode.crewInfo,
roles: band.map(m => m.role),
dancers: mem.filter(m => m.part === 'crowd' && m.dance).length,
draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles };
@ -76,9 +77,9 @@ async () => {
if (P.mode === 'interior') { D.exitShop(); await new Promise(r => setTimeout(r, 350)); }
D.shot('venue_night'); // B's bookmark: seg 5 + pose at the pub frontage
await new Promise(r => setTimeout(r, 1600)); // frontage ramp + queue spawn + chunk/poster stream settle
const id = P.gigs.venueShopId;
const id = P.gigs.venueShopIds[0];
return { ok: P.mode === 'street', venue: (P.plan.shops.find(s => s.id === id) || {}).name,
state: P.gigs.stateOf ? P.gigs.stateOf(id) : P.gigs.state,
state: P.gigs.stateOf(id),
queue: P.queueCountOf ? P.queueCountOf(id) : null,
posters: (P.plan.posters || []).length, venues: (P.gigs.venueShopIds || []).length,
draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles };

176
tools/qa/tour_shots.py Normal file
View File

@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""PROCITY Lane F — the v3.0 release money-shot tour (round 15 · ?gigs=1). One deterministic run.
Captures the release set into docs/shots/release_v3/ (+ a contact.html), grouped by the flags each shot
needs, all from seed 20261990 (deterministic, reproducible never hand-cropped):
venue_night lit pub frontage + streetlamp pools + posters at night (?gigs=1)
district_posters the spine at night: poster poles + lit frontages receding (?gigs=1)
queue_night JOHN'S HERO: close on the pub door at 'on', queue + marquee + A's now-visible poster
gig_interior the 4-piece with real instruments + a roster crowd (continuity) (?gigs=1)
rain_verandah a wet night outside the lit video-shop window (?weather=rain&winmap=1)
tram_stop the bus shelter the tram pauses at (?tram=1)
Discipline (ROUND15 ledger #5 + B's note): every shot is measured SETTLED — after the async instrument
GLBs / poster textures / chunk stream land (shoot-twice), from a clean per-group load (no orphan-chunk
inflation); the queue is driven LIVE (rAF is throttled in automation, so we run the animate loop + admit
explicitly rather than trust auto-drain).
Run: tools/.venv/bin/python tools/qa/tour_shots.py [--seed N]
Needs the Playwright venv + Lane B's ?dbg=1 hook (incl. queue_night). Writes full-frame 1280x720 PNGs.
"""
import sys, time, socket, subprocess, pathlib
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'
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).
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},
]
# settle + re-pose a street bookmark once its live/async content (queue, instrument GLBs, poster textures,
# streamed chunks) has landed, then report SETTLED stats.
STREET_JS = r"""
async (name) => {
const P = window.PROCITY, D = window.DBG;
D.shot(name); // initial pose + segment (2 renders)
for (let i = 0; i < 8; i++) await new Promise(r => requestAnimationFrame(r)); // animate loop: queue spawns
const q = (P.gigs && P.venueQueues) ? P.venueQueues.get(P.gigs.venueShopIds[0]) : null;
await new Promise(r => setTimeout(r, 1400)); // async GLB / poster / chunk settle
const stat = D.shot(name); // RE-POSE now that everything is loaded the settled frame
const queue = (P.gigs && P.queueCountOf) ? P.queueCountOf(P.gigs.venueShopIds[0]) : null;
const posters = (P.plan.posters || []).length;
return { name, draws: stat.drawCalls, tris: stat.tris, queue, posters };
}
"""
INTERIOR_JS = r"""
async () => {
const P = window.PROCITY, D = window.DBG, THREE = P.THREE;
const id = P.gigs.venueShopIds[0];
D.setSegment(5);
// drive the queue live + admit a couple so the interior crowd carries roster identities (continuity)
for (let i = 0; i < 8; i++) await new Promise(r => requestAnimationFrame(r));
const q = P.venueQueues && P.venueQueues.get(id);
if (q) for (let i = 0; i < 3 && q.count() > 0; i++) q.admitOne();
D.enterShop(id); await new Promise(r => setTimeout(r, 900));
const room = P.interiorMode.current; if (!room) return { ok: false };
const st = room.stage;
P.camera.position.set(st.x + 0.2, 1.6, room.dims.D / 2 - 1.2);
P.camera.lookAt(new THREE.Vector3(st.x, 1.35, st.z));
// shoot-twice / settled discipline (ledger #5): immediate vs after the instrument GLBs land
P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera);
const trisImmediate = P.renderer.info.render.triangles;
await new Promise(r => setTimeout(r, 1600));
P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera);
const info = P.interiorMode.crewInfo;
return { ok: true, draws: P.renderer.info.render.calls, trisImmediate, trisSettled: P.renderer.info.render.triangles,
band: info ? info.band : 0, crowd: info ? info.crowd : 0, fromRoster: info ? info.fromRoster : 0,
venue: (P.plan.shops.find(s => s.id === id) || {}).name, bandName: P.gigs.bandNameOf(id) };
}
"""
def port_up(port):
with socket.socket() as s:
s.settimeout(0.4)
return s.connect_ex(('127.0.0.1', port)) == 0
def ensure_server():
if port_up(PORT):
return None
proc = subprocess.Popen(['python3', '-m', 'http.server', str(PORT)], cwd=str(ROOT / 'web'),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(50):
if port_up(PORT): return proc
time.sleep(0.1)
proc.terminate(); sys.exit(f'could not start http.server on :{PORT}')
def boot(pg, flags):
pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1&{flags}')
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:
pg.wait_for_function('() => window.PROCITY && (!window.PROCITY.fleet || window.PROCITY.fleet.ready)', timeout=15000)
except Exception:
print(' ! fleet never readied — shooting placeholders')
def contact_html(shots):
cards = []
for s in shots:
cap = s['caption']
cards.append(
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>'
'<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'<div class="grid">{"".join(cards)}</div>')
def main():
try:
from playwright.sync_api import sync_playwright
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/)")
OUTDIR.mkdir(parents=True, exist_ok=True)
srv = ensure_server()
shots, errs_total = [], 0
try:
with sync_playwright() as p:
b = p.chromium.launch()
for grp in GROUPS:
pg = b.new_page(viewport={'width': 1280, 'height': 720})
errs = []
pg.on('pageerror', lambda e: errs.append(str(e)))
boot(pg, grp['flags'])
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}")
if grp['interior']:
info = pg.evaluate(INTERIOR_JS)
if info.get('ok'):
out = OUTDIR / 'gig_interior.png'
pg.screenshot(path=str(out))
cap = (f"{info['venue']} · {info['bandName']} · {info['band']}-piece · crowd {info['crowd']} "
f"({info['fromRoster']} from roster) · {info['draws']} draws · "
f"{info['trisImmediate']:,}{info['trisSettled']:,} tris (pre-load→settled)")
shots.append({'name': 'gig_interior', 'file': 'gig_interior.png', 'caption': cap})
print(f" \033[32m●\033[0m gig_interior: {cap}")
else:
print(" \033[31m✗\033[0m gig_interior: could not stage")
errs_total += len(errs)
if errs: print(f" ! {len(errs)} page error(s) in [{grp['flags']}]: {errs[0][:100]}")
pg.close()
b.close()
finally:
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")
sys.exit(1 if errs_total or len(shots) < 6 else 0)
if __name__ == '__main__':
main()

222
tools/qa/week_soak.py Normal file
View File

@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""PROCITY Lane F — the full-week gig soak (round 15, v3.0 release · ?gigs=1). 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:
latch state matches the schedule playing venues go 'on' at NIGHT, dark venues stay 'quiet';
the cover STAMP is per venue per night debited once at the door, free on re-entry, DUE AGAIN once
the night rolls (paidOf resets); the wallet is topped up so 7 nights of cover actually get exercised;
tonight's ROSTER clears per night — it never accumulates across nights (D's dispersal on gig-end);
walla/applause don't double-fire — the crew's audio is set once per visit, 0 console errors;
geometries/textures return to a warmed baseline after EVERY enter/exit no leak across 20 cycles.
The runtime models "tonight" as night 0 (matching A's posters + B's frontage, all night-0-keyed); this
soak rolls the day-clock 7× to stress cover-recharge / roster-clear / leak-freeness across the week.
Run: tools/.venv/bin/python tools/qa/week_soak.py [--seed N]
Needs the Playwright venv + Lane B's ?dbg=1 hook. Exit 0 = clean; non-zero = a leak/discipline failure.
"""
import sys, time, socket, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = 8130
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
if '--seed' in sys.argv:
SEED = int(sys.argv[sys.argv.index('--seed') + 1])
fails = []
def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}")
def OK(m): print(f" \033[32m✓\033[0m {m}")
def port_up(port):
with socket.socket() as s:
s.settimeout(0.4)
return s.connect_ex(('127.0.0.1', port)) == 0
def ensure_server():
if port_up(PORT):
return None
proc = subprocess.Popen(['python3', '-m', 'http.server', str(PORT)], cwd=str(ROOT / 'web'),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(50):
if port_up(PORT): return proc
time.sleep(0.1)
proc.terminate(); sys.exit(f'could not start http.server on :{PORT}')
# The whole soak runs in one async evaluate so the browser drives the clock/enter/exit tightly and returns
# aggregate results. Sleeps are inline promises; the GLB cache is warmed before the baseline so its one-time
# population isn't misread as a leak (per LANE_F_NOTES §9).
SOAK_JS = r"""
async () => {
const P = window.PROCITY, D = window.DBG;
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const memNow = () => ({ geo: P.renderer.info.memory.geometries, tex: P.renderer.info.memory.textures });
const nameOf = (id) => (P.plan.shops.find(s => s.id === id) || {}).name;
const topUp = () => { if (P.wallet.cash() < 150) P.wallet.buy({ title: 'soak-topup', price: -1000 }); };
const venues = P.gigs.venueShopIds || [];
// which venues actually play tonight (night 0 = the runtime's tonight)
D.setSegment(5);
const playing = venues.filter(id => P.gigs.stateOf(id) === 'on');
const dark = venues.filter(id => !playing.includes(id));
// WARM the shared GLB cache: TWO enter/exit passes over every playing venue with a long settle, so the
// one-time population (per-kind fittings, the 5 instrument GLBs, the crowd ped rigs the fleet lazy-loads)
// is captured in the baseline rather than misread as a leak (per LANE_F_NOTES §9). Real leaks are
// per-cycle unbounded growth; the memTrail below proves the district plateaus after warm-up regardless.
for (let w = 0; w < 2; w++) for (const id of playing) { D.enterShop(id); await sleep(1300); D.exitShop(); await sleep(350); }
const base = memNow();
const nights = [];
const memTrail = []; // geo/tex after each night a real leak keeps climbing; warm-up residue plateaus
let cycles = 0, coverCharged = 0, leakAfter = null, maxRosterAtEntry = 0;
for (let n = 0; n < 7; n++) {
D.setSegment(5); // NIGHT the gig is on
topUp();
const rec = { night: P.gigs.nightOf(playing[0] != null ? playing[0] : venues[0]), venues: [], darkOk: true };
// dark venues must read 'quiet' at NIGHT (no crew, no charge)
for (const id of dark) if (P.gigs.stateOf(id) !== 'quiet') rec.darkOk = false;
for (const id of playing) {
const rosterAtEntry = (P.citizens.tonightRoster(id) || []).length; // should stay ~0 cleared each night
maxRosterAtEntry = Math.max(maxRosterAtEntry, rosterAtEntry);
const cover = P.gigs.coverOf(id), paidBefore = P.gigs.paidOf(id), cashBefore = P.wallet.cash();
D.enterShop(id); await sleep(650);
const entered = !!P.interiorMode.current;
const info = P.interiorMode.crewInfo;
const charged = cashBefore - P.wallet.cash();
const paidAfter = P.gigs.paidOf(id);
// re-entry the same night must be FREE (stamp holds)
D.exitShop(); await sleep(250);
const cashMid = P.wallet.cash();
D.enterShop(id); await sleep(400);
const reCharged = cashMid - P.wallet.cash();
D.exitShop(); await sleep(300);
const m = memNow();
cycles += 2;
if (charged > 0) coverCharged += charged;
rec.venues.push({ id, name: nameOf(id), entered, band: info ? info.band : 0, crowd: info ? info.crowd : 0,
cover, paidBefore, paidAfter, charged, reCharged, rosterAtEntry,
geoDelta: m.geo - base.geo, texDelta: m.tex - base.tex });
}
rec.mem = memNow(); memTrail.push(rec.mem); // memory after this night for the plateau check
nights.push(rec);
// roll the night forward: NIGHT DAWN MORNING MIDDAY. The day turns the night counter ticks,
// the cover stamp clears (due again), and D disperses the roster. Verify the stamp reset next loop.
D.setSegment(0); D.setSegment(1); D.setSegment(2);
// confirm the stamp cleared for the playing venues (cover due again)
D.setSegment(5);
for (const v of rec.venues) v.stampClearedNextNight = (P.gigs.paidOf(v.id) === false);
}
// top up cycles to 20 if a small town gave us fewer (extra free re-entries of a playing venue)
while (cycles < 20 && playing.length) {
D.setSegment(5);
D.enterShop(playing[0]); await sleep(400); D.exitShop(); await sleep(250);
cycles += 1;
}
const end = memNow();
leakAfter = { geo: end.geo - base.geo, tex: end.tex - base.tex };
return { playing: playing.map(nameOf), dark: dark.map(nameOf), base, end, leakAfter, cycles,
coverCharged, maxRosterAtEntry, nights, memTrail };
}
"""
def main():
try:
from playwright.sync_api import sync_playwright
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)")
srv = ensure_server()
try:
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1280, 'height': 720})
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.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:
pg.wait_for_function('() => window.PROCITY && (!window.PROCITY.fleet || window.PROCITY.fleet.ready)', timeout=15000)
except Exception:
print(' ! fleet never readied — soaking the placeholder crew')
if not pg.evaluate("() => !!(window.PROCITY.gigs && window.PROCITY.plan.gigs && window.PROCITY.plan.gigs.length)"):
sys.exit('gigs=1 not landed (no plan.gigs) — nothing to soak')
res = pg.evaluate(SOAK_JS)
b.close()
finally:
if srv: srv.terminate()
print(f" district: playing {res['playing']} · dark {res['dark']}")
print(f" baseline geo/tex {res['base']['geo']}/{res['base']['tex']} → end {res['end']['geo']}/{res['end']['tex']}")
# 1) every playing venue fired every night; dark stayed quiet; band 4; roster cleared
entered_all = all(v['entered'] and v['band'] == 4 for nt in res['nights'] for v in nt['venues'])
dark_ok = all(nt['darkOk'] for nt in res['nights'])
if entered_all and dark_ok:
OK(f"state: all playing venues 'on' + 4-piece every night, {len(res['dark'])} dark venue(s) stayed quiet (7 nights)")
else:
FAIL(f"state: a venue failed to fire / a dark venue lit / band≠4 (dark_ok={dark_ok})")
# 2) cover stamps per venue per night — charged once, re-entry free, due again next night
reentry_free = all(v['reCharged'] == 0 for nt in res['nights'] for v in nt['venues'])
charged_when_due = all((v['charged'] > 0) == (v['cover'] > 0 and not v['paidBefore'])
for nt in res['nights'] for v in nt['venues'])
stamp_resets = all(v.get('stampClearedNextNight') for nt in res['nights'] for v in nt['venues'])
if reentry_free and charged_when_due and stamp_resets:
OK(f"cover: stamped per venue per night (total ${res['coverCharged']} over 7 nights), re-entry free, resets each night")
else:
FAIL(f"cover: reentry_free={reentry_free} charged_when_due={charged_when_due} stamp_resets={stamp_resets}")
# 3) roster clears per night — never accumulates
if res['maxRosterAtEntry'] <= 2:
OK(f"roster: cleared each night — max {res['maxRosterAtEntry']} lingering at any night's entry (no accumulation)")
else:
FAIL(f"roster: {res['maxRosterAtEntry']} entries lingering at a night's entry — not clearing between nights")
# 4) leak — a real leak climbs EVERY cycle; warm-up residue plateaus. Assert the memory is FLAT across the
# back half of the week (a plateau = nothing accumulating), and report the trajectory + net vs baseline.
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)
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)")
else:
FAIL(f"LEAK: geo {lk['geo']:+d}/tex {lk['tex']:+d} still climbing (night trail {traj}) across {res['cycles']} cycles")
if res['cycles'] < 20:
FAIL(f"cycles: only {res['cycles']} enter/exit (want ≥20)")
else:
OK(f"cycles: {res['cycles']} enter/exit over the week (≥20)")
if errs:
FAIL(f"{len(errs)} console error(s) during the soak; first: {errs[0][:140]}")
else:
OK('0 console errors across the full-week soak')
print()
if fails:
print(f"\033[31m● {len(fails)} FAIL\033[0m — week soak found a leak/discipline issue.")
sys.exit(1)
print(f"\033[32m● PASS\033[0m — full week clean: leak-free, cover per night, roster clears, ${res['coverCharged']} covers, {res['cycles']} cycles.")
sys.exit(0)
if __name__ == '__main__':
main()

View File

@ -188,7 +188,8 @@ const gigState = (GIGS_ON && plan.gigs && plan.gigs.length) ? createGigState({ p
// [Lane F R9] occupancyOf closure defers to `citizens` (declared just below); only invoked at enter()
// time (door click), long after citizens is initialized — so the forward reference is safe.
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS,
dig: DIG_ON, stockReal: STOCK_REAL, wallet, occupancyOf: (id) => citizens.occupancyOf(id), gigState });
dig: DIG_ON, stockReal: STOCK_REAL, wallet, occupancyOf: (id) => citizens.occupancyOf(id),
rosterOf: (id) => citizens.tonightRoster(id), gigState }); // [R15] identity continuity: the crowd IS tonight's roster
// [Lane F §F2] the riffle is cursor-driven (DOM buttons + click-a-sleeve), so release pointer-lock while it's
// open and re-lock (on click) after. digActive gates the unlock→leaveShop guard below so opening a bin
// doesn't read as "walked out of the shop".
@ -385,7 +386,10 @@ function frame() {
if (open && !q) {
q = new VenueQueue({ citySeed: plan.citySeed, fleet });
q.spawn(scene, { queueZone: venuePresentation && venuePresentation.queueZoneFor(id),
gigId: (gigState.gigOf(id) || {}).gigId | 0 });
gigId: (gigState.gigOf(id) || {}).gigId | 0,
// [R15] identity continuity: relay each queue admit → D's tonight roster, so the
// punter who queued turns up in the interior crowd (clear is automatic at setGig(id,false)).
onAdmit: (entry) => citizens.recordVenueEntry(id, entry) });
venueQueues.set(id, q);
} else if (!open && q) { q.disposeAll(); venueQueues.delete(id); }
if (q) q.update(dt);

View File

@ -230,8 +230,10 @@ export class GigCrew {
if (!gltf || this._gen !== gen) return;
const amp = gltf.scene.clone(true);
amp.traverse((o) => { if (o.isMesh) { o.castShadow = false; o.frustumCulled = false; } });
// off to stage-right of the drum riser, on the deck, facing the crowd (world +Z). Room-local coords.
amp.position.set(stage.x + (stage.w || 3) * 0.32, (stage.deckY || 0), stage.z + 0.15);
// 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, zd·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);
});

View File

@ -202,9 +202,14 @@ function buildPosters(plan, citySeed, venues, gigs) {
const gig = tonight.get(v.shop.id);
const lot = gig && lotById.get(v.shop.lot);
if (!lot) continue;
const fx = -Math.sin(lot.ry), fz = -Math.cos(lot.ry); // house convention: front = local Z
// ROUND15 (ledger #1): B's buildings put the street facade on local +Z (frontage/queue seat on
// (sin ry, cos ry)); this once used Z, so the poster sat ~16 m BEHIND the building, invisible from
// the street — it's why queue_night framed no poster. Seat it on the +Z street face, AND turn it to
// 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
add(lot.x + fx * off, lot.z + fz * off, lot.ry, gig.gigId);
add(lot.x + fx * off, lot.z + fz * off, lot.ry + Math.PI, gig.gigId);
}
// 2. the spine run — seeded pole positions along main edges, on the verge, facing the footpath.

View File

@ -194,9 +194,21 @@ function districtInvariants(plan, label) {
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`);
// every poster clears its nearest edge's carriageway — the R12 bug was posters mid-road on nodes.
// 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).
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
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);
@ -295,7 +307,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 = 0x1f636349; // ROUND13 re-pin: multi-venue district changed the gig-plan output (was 0xa6ae5a5e, the one-pub alpha)
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 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')}`);

View File

@ -97,10 +97,9 @@ function createVenueLatch(gig, lighting) {
export function createGigState({ plan, lighting }) {
const gigs = (plan && plan.gigs) || [];
// The district's venues, in first-seen (gigId) order. Primary = gigs[0].venueShopId — the alpha's
// "tonight", kept as the compat alias so B's mid-round single-venue callers still light the right pub.
// The district's venues, in first-seen (gigId) order. Every read is keyed by venueShopId — the R12
// single-venue alias retired in R15 (B swept its cross-lane readers in R14; F migrated its own tools).
const venueIds = [...new Set(gigs.map((g) => g.venueShopId))];
const primaryId = gigs.length ? gigs[0].venueShopId : null;
// one latch per venue, keyed by its night-0 gig (null → dark tonight → 'quiet')
const latch = new Map();
@ -117,7 +116,6 @@ export function createGigState({ plan, lighting }) {
tickAll(); // settle every latch on the boot segment
const L = (id) => latch.get(id) || null;
const P = () => L(primaryId);
return {
// ── per-venue API (R13 district) ──────────────────────────────────────────────────────────────
@ -134,21 +132,8 @@ export function createGigState({ plan, lighting }) {
// the map B's venue.update() and audio.js consume: a fresh { [venueShopId]: 'quiet'|'doors'|'on'|'done' }.
// Called per street frame over a handful of venues — cheap.
get byVenue() { const o = {}; for (const [id, l] of latch) o[id] = l.tick(); return o; },
// shell calls this once per street frame — ticks every latch (so B/D read fresh state) and returns the
// primary state for the alpha-compat caller.
update() { tickAll(); return P() ? P().state : 'quiet'; },
// ── alpha-shape alias → the PRIMARY venue (kept for one round; B reads it mid-round) ─────────────
get state() { const p = P(); return p ? p.state : 'quiet'; },
get on() { const p = P(); return p ? p.on : false; },
get open() { const p = P(); return p ? p.open : false; },
get gig() { const p = P(); return p ? p.gig : null; },
get venueShopId() { return primaryId; },
get bandName() { const p = P(); return p ? p.bandName : null; },
get cover() { const p = P(); return p ? p.cover : 0; },
get night() { const p = P(); return p ? p.night : 0; },
get paid() { const p = P(); return p ? p.paid : false; },
markPaid() { const p = P(); if (p) p.markPaid(); },
// shell calls this once per street frame — ticks every latch so B/D read fresh per-venue state.
update() { tickAll(); },
dispose() { if (typeof window !== 'undefined') window.removeEventListener('procity:segment', onSegment); },
};

View File

@ -38,7 +38,7 @@ const WALLA_KEY = 'crowd-walla'; // [R13] manifest.ambience — crowd murmur l
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
dig: digEnabled = false, stockReal = false, wallet = null,
occupancyOf = null, gigState = null }) {
occupancyOf = null, gigState = null, rosterOf = null }) {
const scene = new THREE.Scene();
scene.background = new THREE.Color('#0e0b07');
@ -228,15 +228,18 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
seedKey: `${shop.id}#${i}`,
}));
}
// [Lane F R12 — the gig itself] Lane D's GigCrew at Lane C's poses: 3 on the deck, up to 8 at the watch
// points (~⅓ seeded to dance — John's "a bit of both"). D's own spawn caps the crowd at watchPoints.length
// and reuses the gate-protected spawnRig path, so the R10 no-giants gate covers these rigs too.
// Instruments are D's primitives this round — E's instrument GLBs landed but nothing wires them yet
// (D's `opts.instrumentFor` seam is open); asset law says a primitive band is still a gig. → LANE_F_NOTES §12.
// [Lane F R12 — the gig itself] Lane D's GigCrew at Lane C's poses: a 4-piece on the deck, up to 8/12 at
// the watch points (~⅓ seeded to dance — John's "a bit of both"). D's own spawn caps the crowd at
// watchPoints.length and reuses the gate-protected spawnRig path, so the R10 no-giants gate covers these
// rigs too; E's instrument GLBs ride in via D's instrumentFor seam.
// [Lane F R15 — identity continuity (ledger #3)] pass D's TONIGHT ROSTER so the front crowd slots ARE the
// punters who came in (surge occupants + queue admits, keyed by venue). D fills the rest with seeded
// strangers; an EMPTY roster is byte-identical to R13 (D's law), which is what a fresh/quiet venue gets.
crewInfo = null;
if (gigOn && current.stage && current.watchPoints && current.watchPoints.length) {
if (!crew) crew = new GigCrew({ citySeed: plan.citySeed, fleet });
crewInfo = crew.spawn(current.group, { stage: current.stage, watchPoints: current.watchPoints, gig: tonight });
crewInfo = crew.spawn(current.group, { stage: current.stage, watchPoints: current.watchPoints,
gig: tonight, roster: rosterOf ? rosterOf(shop.id) : [] });
}
doorReturn = { x: camera.position.x, y: camera.position.y, z: camera.position.z, ry: camera.rotation.y };
camera.position.set(current.spawn.x, EYE, current.spawn.z);

View File

@ -50,6 +50,7 @@ export function createVenuePresentation(plan, skins, scene, { assetBase = 'asset
// pick ONE seeded skin per gig, and render each group as a single InstancedMesh. Town-wide the whole
// poster layer is ≤ (tonight's playing venues) draws instead of one mesh + one canvas per bill.
let posterDraws = 0;
const posterMats = []; // ramped emissive at night (R15 ledger #1 completion) so posters read after dark
{
const byGig = new Map();
for (const p of posters) {
@ -63,7 +64,11 @@ export function createVenuePresentation(plan, skins, scene, { assetBase = 'asset
const band = gig ? gig.bandName : 'LIVE TONIGHT';
const skin = POSTER_SKINS[(((gid | 0) + (plan.citySeed >>> 0)) % POSTER_SKINS.length + POSTER_SKINS.length) % POSTER_SKINS.length];
const tex = posterTexture(assetBase, skin, band, NOASSETS); disposables.push(tex);
const mat = new THREE.MeshStandardMaterial({ map: tex, roughness: 0.92, metalness: 0, side: THREE.DoubleSide });
// emissiveMap = the poster art, ramped 0 (day) → ~0.55 (night) in update(): posters are otherwise
// unlit MeshStandard and render pure black at NIGHT (measured [4,0,1]), so the queue_night hero and
// district_posters showed no readable bill after A's +Z flip. Day stays byte-identical (emissive 0).
const mat = new THREE.MeshStandardMaterial({ map: tex, emissive: new THREE.Color(0xffffff), emissiveMap: tex, emissiveIntensity: 0, roughness: 0.92, metalness: 0, side: THREE.DoubleSide });
posterMats.push(mat);
const geo = new THREE.PlaneGeometry(POSTER_W, POSTER_H);
disposables.push(mat, geo);
const inst = new THREE.InstancedMesh(geo, mat, list.length);
@ -224,6 +229,14 @@ export function createVenuePresentation(plan, skins, scene, { assetBase = 'asset
if (Math.abs(poolT - poolMat.opacity) < 0.004) poolMat.opacity = poolT;
if (poolMesh) poolMesh.visible = poolMat.opacity > 0.01;
}
// posters self-light at night so the bill reads (unlit MeshStandard → black after dark). Day = 0,
// so daylight tour shots stay byte-identical; the ramp lifts every poster (frontage + spine) at night.
const posterT = night ? 0.55 : 0;
for (let i = 0; i < posterMats.length; i++) {
const pm = posterMats[i];
pm.emissiveIntensity += (posterT - pm.emissiveIntensity) * 0.1;
if (Math.abs(posterT - pm.emissiveIntensity) < 0.004) pm.emissiveIntensity = posterT;
}
}
update(null); // seed: no gig states yet → every venue quiet (frontage dark, pools follow night)