Lane D R13 (v3.0-beta): 4-piece + instruments, outdoor queue, multi-venue surge, RY fix

- RY audit (debt #4): the ped GLB fleet's visual front is +Z but the whole
  system assumed -Z (sim heading math, C's pose ry, placeholder, impostor).
  Fixed at the source (rigs.js buildFigure normalises every rig to -Z front)
  and dropped band.js RY_FLIP. Corrects keepers/browsers (faced the back wall)
  and streamed walkers (moon-walked): 223/223 gig + 188/188 flags-off now face
  travel. Visual only, no golden move. keepers.js/placeholder.js/impostor.js
  unchanged (fixed for free). Canonical convention -> LANE_D_NOTES for C.
- Instruments (debt #2): E's manifest GLBs swap over the primitives async
  (placeholder-persists, fleet-gated -> 0 fetch under ?noassets), guitar/bass
  held, mic + kit planted, guitar_amp dressing.
- Drummer (debt #3): 4-piece, seated at C's drums pose (sink + kit occlusion;
  reads from the front/crowd POV - a sit clip is the polish).
- Outdoor queue (new queue.js): seeded 2-6 line at the frontage, drains on
  admitOne() + auto-drain, consumes B's queueZone or a frontage-hugging door
  fallback. F wiring seam documented in LANE_D_NOTES.
- Multi-venue surge (sim.js): setGig -> Set of concurrent venues.
- Interior gig 56 draws <=350; ?noassets+?gigs placeholders, silent-and-fine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-16 00:00:05 +10:00
parent 5dac2cac5a
commit fefbb4b806
5 changed files with 445 additions and 51 deletions

View File

@ -6,6 +6,101 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
---
## ROUND 13 — v3.0-beta the district: the RY fix, instruments/drummer, the queue, multi-venue surge
*PROCITY-D, 2026-07-15. Behind `?gigs=1`; verified in-shell against A's landed 3-venue plan (pub #116
`pubrock` / rsl #404 `covers` / band_room #490 `grunge`, 14 gigs / 14 posters) and C's landed 4-pose
stage (guitar/vocal/bass + a seated `drums` on the riser). Files: `rigs.js`, `band.js`, new `queue.js`,
`sim.js`. `keepers.js` / `placeholder.js` / `impostor.js` UNCHANGED — the RY fix at the source corrects
them for free.*
### Debt #4 — the RY audit: fixed once at the source (⚠ this changes the flags-off street VISUAL)
**Finding (confirmed in-scene):** the imported ped GLBs' visual front is local **+Z**, but *everything
downstream assumes Z* — `sim.js` heading math (`facing = atan2(-dx,-dz)`), C's pose `ry`
(counter.stand / bandPoses / watchPoints / browse points), the placeholder (`placeholder.js:61` builds
toes to Z *on purpose*, "like the GLB rigs"), and the impostor bake/pick convention. So the GLB was the
lone outlier: **keepers faced the back wall, browsers faced away, and every streamed rig walker moon-walked
(faced anti-travel)** — all subtly, for ~12 rounds, because near+mid were consistently-wrong-together
(seamless) and peds are mostly seen from behind. R12's band carried a local `RY_FLIP=π` that patched only
the gig.
**Fix:** one line in `rigs.js buildFigure``inner.rotation.y = Math.PI` — normalises every rig's front
to Z, matching the rest of the system. Then `RY_FLIP` is deleted from `band.js` (band/crowd use C's `ry`
verbatim). Because the impostor atlas bakes this same `buildFigure`/`spawnRig` output and the layer's
yaw-pick already uses the Z convention, near↔mid stays seamless (both flip together). It's **purely
visual** — a Y-rotation leaves every bone's world-Y untouched, so head-height / feet-plant, the no-giants
gate, and all seeded identity/determinism are byte-unchanged (**no golden move**).
**Verified:** keeper now faces the shop floor (was the back wall); **223/223** gig-mode + **188/188**
flags-off streamed walkers face their edge-travel direction (avg dot **1.000**; was 1.0); band faces the
crowd + crowd faces the stage with `RY_FLIP` gone; `?noassets` placeholder band (which the old flip left
facing the backdrop) is now correct too.
- **→ Lane C (pose contract, please land in LANE_C_PUB.md):** *"Rig visual front is canonical **local Z**
(`rigs.js buildFigure` normalises the GLB's native +Z). All pose `ry` (counter.stand, bandPoses,
watchPoints, browse points) are the Z-front heading the rig should face — no lane compensates; the R12
`RY_FLIP` is gone."* Your existing `ry` values were already correct under this — nothing to change.
- **→ Lane F / Fable (flags-off flag):** the fix is in the shared rig stack, so the **default (flags-off)
street crowd now faces travel** instead of moon-walking. Determinism + the golden plan hash are
untouched (visual only), but if any regression snapshots *pixels* it will move — it's a bug fix, re-pin
if needed (improvement, not a leak).
### Debt #2 — instrument GLBs wired (placeholder-persists, fail-soft, fleet-gated)
`band.js` keeps its primitive instruments as the immediate/`?noassets` fallback, then swaps E's manifest
GLBs in async when they resolve (same pattern as `interiors/glb.js`). Role → fitting: `guitar`
`electric_guitar`, `bass`→`bass_guitar`, `vocal`→`mic_stand` (planted), `drums`→`drum_kit` (planted at the
riser), plus one `guitar_amp` as backline dressing. Manifest is **self-fetched** (promise-cached) so band.js
stays decoupled from C's glb.js, and the whole upgrade is **gated on `fleet.ready`** → under `?noassets`
(fleet null) it never fetches (proven: fresh-tab network shows **0** `procity_fit_*` / `manifest`
requests). Disposal-guarded by a generation counter; GLB clones share the cached geo/mats (detach-only,
never dispose — only the `ownGeo`-tagged primitives are freed). Orientation lives in one `GLB_FIT` table
(guitars need a +Z roll to sling; the kit is scaled 0.66 to fit C's tight 0.73 m vocal↔drums gap).
### Debt #3 — the drummer (4-piece, Fable's ruling)
`bandPoses.forEach` already picks up C's 4th `drums` pose (`seated:true`, riser `y`). The drummer sinks
`SEAT_DROP` (0.52 m) so the kit hides the straight legs and it reads seated **from the front — the crowd
POV / money shot**; from a hard side angle the sink shows (a standing rig faked as seated). A true seated
pose is a clip job — see the wishlist. Build is **defensive**: if John had vetoed to a trio, C simply
omits the pose and the band is 3 with zero D change.
### The outdoor queue (charter item — new `web/js/citizens/queue.js`)
`VenueQueue`: a seeded 26 line on the same gate-protected rig path, idle-animated, that drains as punters
are admitted and empties by close. Consumes B's `venue.queueZone` when present, else a straight line off
the door — the fallback trails along the **frontage tangent** so it hugs the building instead of spilling
onto the road. Verified: spawn (5 rigs, human-sized, hug the frontage, face the venue), `admitOne()` drains
the head + shuffles everyone up to target, and a seeded auto-drain safety. **F seam** (wire per venue off
the street-side gig state):
```js
const q = new VenueQueue({ citySeed: plan.citySeed, fleet });
q.spawn(streetGroup, { door, queueZone, gigId }); // at 'doors' (+ 'on' overflow); door = {x,z,ry}, ry = facing the entrance
q.admitOne(); // on each admitted punter (cover-charge / entry hook)
q.update(dt); // each street frame — idle + lerp + slow auto-drain
q.disposeAll(); // at 'done' / venue out of view
```
**→ Lane B:** publish `venue.queueZone = { x, z, ry, len }``(x,z)` = head by the door, `ry` = the
heading a punter faces (toward the entrance), `len` = line length; the line trails single-file opposite the
facing, so pick `ry` to run it along the verandah.
### Multi-venue surge (`sim.js`)
`setGig(venueShopId, on)` widened from a single `_gigVenue` to a `_gigVenues` **Set** so several concurrent
gigs each pull their own block; `_nearestOpenShop` now prefers the nearest gig venue in `GIG_RANGE` across
all of them. F's R12 single-venue call is a subset — still works. Non-gig patronage path is byte-identical.
### Measured budgets (seed 20261990, "Boolarra Heads", The Exchange Hotel #116)
| thing | number | budget |
|---|---|---|
| gig-night interior, rig fleet + GLB instruments | **56 draws** · 183k tris | ≤350 draws ✓ |
| — of which instruments (5 GLBs @ ~14k, E's ≤15k target) | ~70k tris | (no interior tri cap) |
| band / crowd | **4** on the deck (3 front + seated drummer) · **8** at 8 watch points | crowd ≤ watchPoints ✓ |
| outdoor queue | seeded **26** rigs (~1 draw each) at the frontage | stated |
| `?noassets`+`?gigs` | band+queue placeholders, **0** GLB/manifest fetch | silent-and-fine ✓ |
| flags-off street walkers face travel | 188/188 (dot 1.0) | determinism byte-unchanged ✓ |
*RSL (#404) fields the crowd-cap stress case: `CROWD_CAP` is 12, real cap = `watchPoints.length` per venue
(≤ always holds). Re-measure the RSL interior once its archetype dressing lands.*
---
## ROUND 9 — interior presence: occupancy truth + browser rigs (→ Lane F handoff below)
**Follow a ped into a shop and find them browsing.** D owns occupancy truth; C owns the browse points;
@ -493,7 +588,10 @@ bind directly through the canonical `mixamorig` skeleton exactly like walk/idle,
data task, not new rig work. **Verify each exists before requesting; never re-download.**
Wanted for richer loiter/keeper behaviour, in priority order:
1. `sit` — bench-sit at benches/verandah steps (sim loiter already has the hook; needs the clip).
1. `sit` — bench-sit at benches/verandah steps (sim loiter has the hook; needs the clip). **R13: the gig
drummer also wants this (or a bespoke `drum`/`play-drums` loop)** — the 4th band member is currently a
standing rig sunk `SEAT_DROP` behind the kit, which reads seated only from the front. A real seated clip
retires the sink hack (band.js `SEAT_DROP` + the `m.seated` branch in `update`).
2. `lean` — loiter against a shopfront (window-shopping stops).
3. `look-around` / `idle-look` — window-shopping variety + a better keeper greet than the body-turn.
4. `talk` / `gesture` — pairs chatting on the footpath (spawn in seeded 2-groups).

View File

@ -1,30 +1,39 @@
// PROCITY Lane D — the gig band + crowd (v3.0-alpha, behind ?gigs=1).
// PROCITY Lane D — the gig band + crowd (v3.0-beta, behind ?gigs=1).
//
// A Friday night at the pub: three musicians on Lane C's stage deck and a mixed crowd at C's
// watch points — some standing and watching, some dancing (John's "a bit of both"). Built on the
// same gate-protected rig path as keepers/browsers (spawnRig → buildFigure, so no giants), with a
// placeholder fallback when the fleet is absent (?noassets — a gig with no GLBs is still a gig).
// A Friday night at the venue: a 4-piece band on Lane C's stage (guitar / vocal / bass across the
// front, a seated drummer on the up-stage riser) and a mixed crowd at C's watch points — some
// standing, some dancing (John's "a bit of both"). Built on the gate-protected rig path
// (spawnRig → buildFigure, so no giants), with a placeholder fallback when the fleet is absent
// (?noassets — a gig with no GLBs is still a gig).
//
// Instruments: primitives show immediately (asset law), then E's manifest GLBs swap in async where
// present (placeholder-persists, like interiors/glb.js). guitar/bass held at the chest, mic stand +
// drum kit planted, an amp as backline dressing. Gated on fleet.ready so ?noassets never fetches.
//
// Seam: F builds one GigCrew per venue interior and calls spawn() when the gig state is doors/on,
// update(dt) each interior frame, disposeAll() on gig-off / room exit. C owns the stage + watch
// points (room.stage / room.watchPoints); A owns the gig (plan.gigs); E's instrument GLBs replace
// the primitives here when present (pass opts.instrumentFor). Deterministic from (citySeed, gigId).
// points (room.stage / room.watchPoints); A owns the gig (plan.gigs). Deterministic from (citySeed, gigId).
import * as THREE from 'three';
import { rng, shuffle } from '../core/prng.js';
import { pickRig, spawnRig } from './rigs.js';
import { makePlaceholder } from './placeholder.js';
import { loadGLB } from '../core/loaders.js';
const CROWD_CAP = 8; // ≤ watchPoints; F's smoke asserts crowd ≤ watchPoints
const MIC = 0x222226, POLE = 0x9a9a9e;
// C's watchPoints/bandPoses `ry` point the fleet mesh 180° from where spawnRig plants it (verified
// in-scene: ry=π faced the band at the backdrop, ry→stage faced the crowd at the door). Flip on spawn
// so the band faces the audience and the crowd faces the stage. (Flagged to C/F to reconcile the seam.)
const RY_FLIP = Math.PI;
const CROWD_CAP = 12; // hard ceiling; the real cap is watchPoints.length per venue (≤ always
// holds — F's smoke asserts it). RSL fields the most (812 watch points).
const MIC = 0x222226, POLE = 0x9a9a9e, KIT = 0x8f2b24, CHROME = 0xc8c8cc, AMP = 0x15151a, AMPCLOTH = 0x3a3020;
const SEAT_DROP = 0.52; // the drummer sits: sink the rig so the kit hides the straight legs.
// Reads seated from the front (the crowd POV / money shot); a true
// sit is a clip job — see the LANE_D_NOTES clip wishlist.
// ---- primitive instruments (E's GLBs swap in over these; a gig with no GLBs is still a gig) ----
// Each returns an Object3D in the actor's OUTER-fig local frame. After the R13 facing-normalise the
// rig's front (its chest) is fig-local -Z, so instruments sit on the -Z side and ride the sway.
// Primitives own their geometry/material (tagged ownGeo) so disposeAll frees them; GLB clones share
// the cached gltf's geo/mats and are only detached.
function tagOwn(g) { g.userData.ownGeo = true; return g; }
// ---- primitive instruments (E's GLBs replace these; footprint-compatible, held at the chest) ----
// Each returns an Object3D positioned in the actor's OUTER-fig space (origin at the feet, +Z front),
// so it rides the performer's procedural sway. Cheap shared-nothing meshes (3 instruments per gig).
function guitarPrim(bodyCol, big = false) {
const g = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({ color: bodyCol, roughness: 0.5, metalness: 0.1 });
@ -34,10 +43,9 @@ function guitarPrim(bodyCol, big = false) {
const neck = new THREE.Mesh(new THREE.BoxGeometry(0.05, big ? 0.85 : 0.72, 0.03), neckMat);
neck.position.set(bw * 0.1, bh * 0.5 + (big ? 0.42 : 0.36), 0);
g.add(body, neck);
// the fleet mesh faces its local +Z (verified in-scene), so the chest — and the instrument — is +Z.
g.position.set(0.02, 1.02, 0.19); // across the chest, toward the audience
g.rotation.set(0.15, 0, big ? 0.7 : 0.62); // slung diagonally, body angled out to the crowd
return g;
g.position.set(0.02, 1.02, -0.19); // across the chest (fig-local -Z = the audience-facing front)
g.rotation.set(0.15, 0, big ? -0.7 : -0.62); // slung diagonally, body angled out to the crowd
return tagOwn(g);
}
function micStandPrim() {
const g = new THREE.Group();
@ -48,26 +56,75 @@ function micStandPrim() {
new THREE.MeshStandardMaterial({ color: MIC, roughness: 0.6 }));
head.position.y = 1.44;
g.add(pole, head);
g.position.set(0, 0, 0.26); // stands just in front of the singer (+Z = audience side)
return g;
g.position.set(0, 0, -0.26); // stands just in front of the singer (fig-local -Z)
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() {
const g = new THREE.Group();
const shell = new THREE.MeshStandardMaterial({ color: KIT, roughness: 0.4, metalness: 0.1 });
const chrome = new THREE.MeshStandardMaterial({ color: CHROME, roughness: 0.3, metalness: 0.7 });
const skin = new THREE.MeshStandardMaterial({ color: 0xe8e4d8, roughness: 0.6 });
const kick = new THREE.Mesh(new THREE.CylinderGeometry(0.30, 0.30, 0.42, 16), shell);
kick.rotation.x = Math.PI / 2; kick.position.set(0, 0.30, 0);
const kickHead = new THREE.Mesh(new THREE.CircleGeometry(0.29, 16), skin);
kickHead.position.set(0, 0.30, -0.215); kickHead.rotation.y = Math.PI;
const snare = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.16, 0.16, 14), chrome);
snare.position.set(-0.32, 0.58, -0.10);
const tom = new THREE.Mesh(new THREE.CylinderGeometry(0.19, 0.19, 0.22, 14), shell);
tom.position.set(0.34, 0.52, -0.06);
const mkCymbal = (x, y, r) => {
const c = new THREE.Group();
const stand = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, y, 6), chrome);
stand.position.y = y / 2;
const disc = new THREE.Mesh(new THREE.CylinderGeometry(r, r, 0.01, 16), chrome);
disc.position.y = y;
c.add(stand, disc); c.position.x = x; return c;
};
g.add(kick, kickHead, snare, tom, mkCymbal(-0.44, 0.86, 0.18), mkCymbal(0.50, 0.94, 0.20));
g.position.set(0, SEAT_DROP, -0.60); // on the riser, in front of the seated drummer (fig-local -Z)
return tagOwn(g);
}
function instrumentFor(role) {
if (role === 'guitar') return guitarPrim(0xb23b2e); // sunburst-ish red
if (role === 'bass') return guitarPrim(0x2f3a52, true); // dark blue, bigger body + longer neck
if (role === 'guitar') return guitarPrim(0xb23b2e); // sunburst-ish red
if (role === 'bass') return guitarPrim(0x2f3a52, true); // dark blue, bigger body + longer neck
if (role === 'vocal') return micStandPrim();
if (role === 'drums') return drumKitPrim();
return null;
}
// role → E's manifest fitting id + its transform in the actor's fig-local frame (front/chest = -Z).
// GLBs are base-origin metres (glb_law): guitar/bass stand upright (long axis +Y = neck, thin face along
// Z), so a +Z roll slings them diagonally across the chest; the mic stand is upright as-is; the kit is
// scaled to fit C's tight riser gap (vocalist 0.73 m in front of the drummer) and planted in front of the
// sunk drummer. Tuned on-screen. Body-origin → position is where the strap-button / base sits.
const GLB_FIT = {
guitar: { id: 'electric_guitar', pos: [0.14, 0.60, -0.15], rot: [0.22, 0.0, 0.72], scale: 1 },
bass: { id: 'bass_guitar', pos: [0.14, 0.58, -0.15], rot: [0.22, 0.0, 0.66], scale: 1 },
vocal: { id: 'mic_stand', pos: [0.00, 0.00, -0.30], rot: [0, 0, 0], scale: 0.9 },
drums: { id: 'drum_kit', pos: [0.00, SEAT_DROP, -0.30], rot: [0, Math.PI, 0], scale: 0.66 },
};
// manifest (self-fetched, promise-cached, fail-soft). ONLY called behind a fleet.ready gate, so under
// ?noassets (fleet === null) this never runs → zero asset fetch, primitives everywhere. Decoupled from
// Lane C's interiors/glb.js on purpose (band.js is asset-law self-contained).
let _manifestP = null;
function loadManifest() {
return (_manifestP ||= fetch('assets/manifest.json').then(r => (r.ok ? r.json() : null)).catch(() => null));
}
export class GigCrew {
constructor({ citySeed = 20261990, fleet = null } = {}) {
this.citySeed = citySeed >>> 0;
this.fleet = fleet;
this.members = []; // { actor, kind:'rig'|'placeholder', part:'band'|'crowd', role, dance, base:{x,y,z,ry}, phase, extra:[] }
this.members = []; // { actor, kind, part, role, dance, seated, base:{x,y,z,ry}, phase, extra:[] }
this.props = []; // planted dressing added straight to the room group (amp) — freed on disposeAll
this._gen = 0; // bumped every spawn/dispose; async GLB swaps bail if the gen moved (room gone)
this.t = 0;
}
// one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot.
// pedIndex forces a specific fleet ped (band members are picked distinct); else a seeded pickRig.
_make(target, key, { x, y, z, ry, height, pedIndex = null }) {
const r = rng(this.citySeed, 'gig', key);
const h = height != null ? height : 1.6 + r() * 0.3;
@ -84,40 +141,91 @@ export class GigCrew {
return { actor, kind };
}
// spawn(roomGroup, { stage, watchPoints, gig }) — band trio on the deck + crowd at the watch points.
// spawn(roomGroup, { stage, watchPoints, gig }) — the band on the deck + crowd at the watch points.
spawn(roomGroup, { stage = null, watchPoints = [], gig = { gigId: 0 }, instrumentFor: instFor = instrumentFor } = {}) {
this.disposeAll();
const gid = gig.gigId | 0;
this._gen++;
const gen = this._gen;
// ── band: 3 performers on the deck (C's bandPoses: guitar / vocal / bass) ──
// ── band: C's bandPoses (guitar / vocal / bass across the front, + a seated `drums` on the riser) ──
if (stage && stage.bandPoses) {
// distinct fleet peds for the trio, seeded per gig (a band with two identical members reads wrong)
// distinct fleet peds per gig (a band with two identical members reads wrong)
const distinct = (this.fleet && this.fleet.ready)
? shuffle(rng(this.citySeed, 'gigband', gid), this.fleet.all.map((_, i) => i)) : [];
stage.bandPoses.forEach((p, i) => {
const ry = p.ry + RY_FLIP;
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
const { actor, kind } = this._make(roomGroup, `band:${gid}:${p.role}:${i}`,
{ x: p.x, y: stage.deckY || 0, z: p.z, ry, pedIndex: distinct.length ? distinct[i % distinct.length] : null,
{ x: p.x, y, z: p.z, ry: p.ry, pedIndex: distinct.length ? distinct[i % distinct.length] : null,
height: 1.68 + rng(this.citySeed, 'gigh', `${gid}:${i}`)() * 0.2 });
const extra = [];
const member = { actor, kind, 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: [] };
const inst = instFor(p.role);
if (inst) { actor.fig.add(inst); extra.push(inst); }
this.members.push({ actor, kind, part: 'band', role: p.role, dance: false,
base: { x: p.x, y: stage.deckY || 0, z: p.z, ry }, phase: rng(this.citySeed, 'gigp', `b${gid}:${i}`)() * Math.PI * 2, extra });
if (inst) { actor.fig.add(inst); member.extra.push(inst); }
this.members.push(member);
this._upgradeInstrument(member, gen); // async GLB swap over the primitive (fleet-gated)
});
this._addBackline(roomGroup, stage, gen); // amp dressing beside the riser (async, dressing)
}
// ── crowd: stand-and-watch or dance at C's watch points ──
const pts = watchPoints.slice(0, CROWD_CAP);
const pts = watchPoints.slice(0, Math.min(CROWD_CAP, watchPoints.length));
pts.forEach((w) => {
const ry = w.ry + RY_FLIP;
const { actor, kind } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`, { x: w.x, y: 0, z: w.z, ry });
this.members.push({ actor, kind, part: 'crowd', role: 'fan', dance: !!w.dance,
base: { x: w.x, y: 0, z: w.z, ry }, phase: rng(this.citySeed, 'gigp', `c${gid}:${w.slotIndex}`)() * Math.PI * 2, extra: [] });
const { actor, kind } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`, { x: w.x, y: 0, z: w.z, ry: w.ry });
this.members.push({ actor, kind, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false,
base: { x: w.x, y: 0, z: w.z, ry: w.ry }, phase: rng(this.citySeed, 'gigp', `c${gid}:${w.slotIndex}`)() * Math.PI * 2, extra: [] });
});
return { band: this.members.filter(m => m.part === 'band').length, crowd: pts.length };
}
// Swap a band member's primitive instrument for E's manifest GLB, placeholder-persists style. Fail-soft
// at every step (no manifest / no entry / depot unreachable → the primitive stays), and generation-guarded
// so a GLB that resolves after the room is gone (or re-spawned) is dropped, never attached to a dead rig.
_upgradeInstrument(member, gen) {
if (!this.fleet || !this.fleet.ready) return; // ?noassets → primitive only, no fetch
const cfg = GLB_FIT[member.role];
if (!cfg) return;
loadManifest().then((mani) => {
if (!mani || !mani.fittings || this._gen !== gen) return;
const entry = mani.fittings[cfg.id];
if (!entry || !entry.file) return;
return loadGLB(`depot:${entry.file}`).then((gltf) => {
if (!gltf || this._gen !== gen || !member.actor.fig.parent) return; // room rebuilt / disposed mid-flight
const inst = gltf.scene.clone(true); // shares cached geo/mats — detach only, never dispose
inst.position.set(...cfg.pos);
inst.rotation.set(...cfg.rot);
inst.scale.setScalar(cfg.scale || 1);
inst.traverse((o) => { if (o.isMesh) { o.castShadow = false; o.frustumCulled = false; } });
for (const ex of member.extra) { if (ex.parent) ex.parent.remove(ex); disposePrim(ex); }
member.extra.length = 0;
member.actor.fig.add(inst);
member.extra.push(inst);
});
});
}
// 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).
_addBackline(roomGroup, stage, gen) {
if (!this.fleet || !this.fleet.ready) return;
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;
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);
roomGroup.add(amp);
this.props.push(amp);
});
});
}
// procedural motion layered on the idle clip (seeded phase → the crowd never metronomes in sync).
update(dt) {
this.t += dt;
@ -127,7 +235,10 @@ export class GigCrew {
else m.actor.tick && m.actor.tick(dt, false); // placeholder idle
const f = m.actor.fig, b = m.base, ph = m.phase;
if (m.part === 'band') {
if (m.role === 'vocal') { // singer: gentle sway toward the mic
if (m.seated) { // drummer: seated bob + a little shoulder work, planted
f.position.y = b.y + Math.abs(Math.sin(t * 3.4 + ph)) * 0.03;
f.rotation.z = Math.sin(t * 3.4 + ph) * 0.04;
} else if (m.role === 'vocal') { // singer: gentle sway toward the mic
f.rotation.z = Math.sin(t * 2.0 + ph) * 0.035;
f.position.z = b.z + Math.sin(t * 1.3 + ph) * 0.02;
} else { // guitar / bass: rhythmic strum-sway + tiny bob
@ -149,10 +260,21 @@ export class GigCrew {
disposeAll() {
for (const m of this.members) {
if (m.actor.fig.parent) m.actor.fig.parent.remove(m.actor.fig);
for (const ex of m.extra) ex.traverse(o => { if (o.isMesh) { o.geometry.dispose(); o.material.dispose && o.material.dispose(); } });
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
this.members.length = 0;
this.props.length = 0;
this._gen++; // invalidate any GLB load still in flight
this.t = 0;
}
}
// Free a primitive instrument's own geometry/materials. GLB clones share the cached gltf's geo/mats,
// so they are detached from the scene by disposeAll but NEVER disposed here (tagged: no ownGeo).
function disposePrim(obj) {
if (obj.parent) obj.parent.remove(obj);
if (!obj.userData || !obj.userData.ownGeo) return; // GLB clone → shared resources, leave them cached
obj.traverse((o) => { if (o.isMesh) { o.geometry.dispose(); o.material.dispose && o.material.dispose(); } });
}

158
web/js/citizens/queue.js Normal file
View File

@ -0,0 +1,158 @@
// PROCITY Lane D — the outdoor gig queue (v3.0-beta charter item, behind ?gigs=1).
//
// While the doors are open, a short line of punters waits at the venue frontage and drains into the
// room as people are admitted — gone by closing (`done`). Street-side companion to the interior
// GigCrew: same gate-protected rig path (spawnRig → buildFigure, so no giants; rigs face -Z after the
// R13 facing-normalise), same placeholder fallback under ?noassets (a queue of boxes is still a queue).
//
// Seam (Lane F wires it from the street-side gig state, per venue):
// const q = new VenueQueue({ citySeed: plan.citySeed, fleet });
// q.spawn(streetGroup, { door, queueZone, gigId }); // at 'doors' (and 'on' overflow)
// q.admitOne(); // when a punter is admitted (F's cover-charge / entry hook)
// q.update(dt); // each street frame — idle shuffle + lerp + slow auto-drain
// q.disposeAll(); // at 'done' / venue out of view
//
// Geometry contract (consistent with the post-R13 -Z rig front):
// door = { x, z, ry } ry = the heading a punter at the door FACES to look at the entrance.
// queueZone = { x, z, ry, len } | null Lane B's marker: (x,z) = head by the door, ry = that same facing,
// len = line length (m). Absent → a straight line trailing the door.
// The line trails BEHIND the head (opposite the facing), punters spaced along it, everyone facing the door.
import * as THREE from 'three';
import { rng } from '../core/prng.js';
import { pickRig, spawnRig } from './rigs.js';
import { makePlaceholder } from './placeholder.js';
const SPACING = 0.85; // m between punters down the line
const QUEUE_MIN = 2, QUEUE_MAX = 6;
const DRAIN_MIN = 3.5, DRAIN_MAX = 7.0; // s between auto-admissions (seeded) — the safety drain if F
// doesn't couple admitOne() to real entries; empties over the night.
const STEP_EASE = 4.0; // how fast a punter slides up into a vacated slot
export class VenueQueue {
constructor({ citySeed = 20261990, fleet = null } = {}) {
this.citySeed = citySeed >>> 0;
this.fleet = fleet;
this.members = []; // { actor, kind, slot, target:{x,z}, base:{ry}, phase }
this.parent = null;
this.gid = 0;
this._gen = 0;
this.t = 0;
this._drainAt = 0;
}
// Build the seeded line. `count` overrides the seeded 2..6 (F may cap to the room's spare capacity).
spawn(parent, { door = null, queueZone = null, gigId = 0, count = null } = {}) {
this.disposeAll();
this._gen++;
this.gid = gigId | 0;
this.parent = parent;
const anchor = this._anchor(door, queueZone);
if (!anchor) return { queue: 0 };
this.anchor = anchor;
const r = rng(this.citySeed, 'queuecount', this.gid);
const n = count != null ? Math.max(0, count | 0) : QUEUE_MIN + Math.floor(r() * (QUEUE_MAX - QUEUE_MIN + 1));
for (let i = 0; i < n; i++) this._spawnAt(i);
this._drainAt = this.t + this._nextDrainGap();
return { queue: this.members.length };
}
// Resolve the head + facing + trail direction from B's zone, else a straight line off the door.
// Punters always face `ry` (toward the entrance; post-R13 rigs face world (-sin ry, -cos ry)). B's
// zone trails single-file the opposite way (B lays it down the verandah via x/z/ry/len). The door-only
// fallback instead trails along the frontage TANGENT, so the line hugs the building rather than spilling
// into the road — the "along the frontage" read, best-effort until B's queueZone lands.
_anchor(door, queueZone) {
const src = queueZone || door;
if (!src || src.x == null) return null;
const ry = src.ry || 0;
const trail = queueZone
? { x: Math.sin(ry), z: Math.cos(ry) } // opposite the facing → classic single file
: { x: Math.cos(ry), z: -Math.sin(ry) }; // perpendicular to facing → along the frontage
return { x: src.x, z: src.z, ry, trail, len: queueZone && queueZone.len ? queueZone.len : null };
}
_spawnAt(slot) {
const a = this.anchor;
// even spacing; if B gave a finite zone length, don't overrun it (compress to fit)
const gap = (a.len && this.members ? Math.min(SPACING, a.len / Math.max(1, QUEUE_MAX - 1)) : SPACING);
const tx = a.x + a.trail.x * gap * slot;
const tz = a.z + a.trail.z * gap * slot;
const key = `${this.gid}:${slot}`;
const r = rng(this.citySeed, 'queueh', key);
const h = 1.58 + r() * 0.34;
let actor, kind;
if (this.fleet && this.fleet.ready) {
const pk = pickRig(this.fleet, r());
const rig = pk && this.fleet.all[pk.index];
const sp = rig && spawnRig(rig, { ry: a.ry, height: h, clip: this.fleet.idleClip, phase: r() });
if (sp) { actor = sp; kind = 'rig'; }
}
if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'queuebody', key), { height: h }); actor.fig.rotation.y = a.ry; kind = 'placeholder'; }
actor.fig.position.set(tx, 0, tz);
this.parent.add(actor.fig);
this.members.push({ actor, kind, slot, target: { x: tx, z: tz }, base: { ry: a.ry },
phase: rng(this.citySeed, 'queuep', key)() * Math.PI * 2 });
}
_gap() {
const a = this.anchor;
return (a && a.len ? Math.min(SPACING, a.len / Math.max(1, QUEUE_MAX - 1)) : SPACING);
}
_nextDrainGap() { return DRAIN_MIN + rng(this.citySeed, 'queuedrain', `${this.gid}:${this.members.length}:${this.t | 0}`)() * (DRAIN_MAX - DRAIN_MIN); }
// Admit the head of the line (nearest the door): it walks in, everyone else shuffles up one slot.
// Returns the admitted count so far (F can stop when the room is at watchPoint cap).
admitOne() {
if (!this.members.length) return 0;
// the head = the smallest slot (closest to the anchor)
let headIdx = 0;
for (let i = 1; i < this.members.length; i++) if (this.members[i].slot < this.members[headIdx].slot) headIdx = i;
const head = this.members.splice(headIdx, 1)[0];
if (head.actor.fig.parent) head.actor.fig.parent.remove(head.actor.fig);
head.actor.dispose && head.actor.dispose();
// shuffle everyone forward one slot → new target one step closer to the door
const a = this.anchor, gap = this._gap();
for (const m of this.members) {
m.slot = Math.max(0, m.slot - 1);
m.target.x = a.x + a.trail.x * gap * m.slot;
m.target.z = a.z + a.trail.z * gap * m.slot;
}
this._drainAt = this.t + this._nextDrainGap();
return 1;
}
// idle shuffle + slide up into vacated slots. The seeded auto-drain is a safety so the line empties
// over the night even if F doesn't couple admitOne() to real entries; call disposeAll() at `done`.
update(dt, { autoDrain = true } = {}) {
this.t += dt;
const t = this.t;
if (autoDrain && this.members.length && t >= this._drainAt) this.admitOne();
for (const m of this.members) {
if (m.actor.mixer) m.actor.mixer.update(dt);
else m.actor.tick && m.actor.tick(dt, false);
const f = m.actor.fig, ph = m.phase;
// ease toward the (possibly just-vacated) slot
f.position.x += (m.target.x - f.position.x) * Math.min(1, dt * STEP_EASE);
f.position.z += (m.target.z - f.position.z) * Math.min(1, dt * STEP_EASE);
const moving = Math.hypot(m.target.x - f.position.x, m.target.z - f.position.z) > 0.03;
if (m.actor.setMoving) m.actor.setMoving(moving);
// waiting-punter fidget: slow weight shift + the odd glance, feet planted
f.rotation.z = Math.sin(t * 0.8 + ph) * 0.02;
f.rotation.y = m.base.ry + Math.sin(t * 0.4 + ph) * 0.05;
}
return this.members.length;
}
count() { return this.members.length; }
disposeAll() {
for (const m of this.members) {
if (m.actor.fig.parent) m.actor.fig.parent.remove(m.actor.fig);
m.actor.dispose && m.actor.dispose();
}
this.members.length = 0;
this._gen++;
this.t = 0;
}
}

View File

@ -100,6 +100,17 @@ const _bs = new THREE.Vector3();
export function buildFigure(rig, height = 1.75) {
const inner = skeletonClone(rig.scene);
inner.traverse(o => { if (o.isMesh) { o.frustumCulled = false; o.castShadow = false; } });
// FACING NORMALISE (R13 debt #4 — the RY audit). The imported GLB fleet's visual front is local +Z,
// but EVERYTHING downstream assumes -Z: the sim's heading math (sim.js atan2(-dx,-dz)), C's pose ry
// (counter.stand / bandPoses / watchPoints / browse points), the placeholder (toes point -Z on
// purpose, placeholder.js:61), and the impostor bake/pick (impostor.js -Z convention). So the GLB was
// the lone outlier: keepers faced the back wall, browsers faced away, streamed walkers moon-walked
// (anti-travel), and band.js carried a local RY_FLIP=π to patch just the gig. Rotate the rig's own
// mesh 180° here so its front becomes -Z like the rest of the system — one place, everyone correct,
// near↔mid stays seamless (the impostor bakes this same figure), and the flip is purely visual (a
// Y-rotation leaves every bone's world-Y untouched → head-height/feet-plant + the no-giants gate and
// all seeded identity/determinism are byte-unchanged). See LANE_D_NOTES R13 + LANE_C_PUB.md pose contract.
inner.rotation.y = Math.PI;
const fig = new THREE.Group();
fig.add(inner);
fig.updateWorldMatrix(true, true);

View File

@ -123,7 +123,7 @@ export class CitizenSim {
// R8 shop patronage (default-on for the streamed roster; ?patronage=0 off). No-op until setShops.
this.shopsByChunk = null; // chunkKey → [{ x, z, hours:[open,close], shopId }] door points
this.patronageOn = true;
this._gigVenue = null; // R12: venueShopId while a gig is on (F sets via setGig) → surge
this._gigVenues = new Set(); // R13: venueShopIds with a gig on (F sets per-venue via setGig) → multi-venue surge
this.weather = { state: 'clear', intensity: 0 }; // Lane B's PROCITY.weather contract (shell feeds it)
this._occupancy = new Map(); // R9 shopId → [{ seed, enteredAt, pedIndex }] — the interior-presence truth
this._dropKeys = []; // scratch: chunks to drop this frame
@ -333,10 +333,15 @@ export class CitizenSim {
// map: chunkKey → [{ x, z, hours:[open,close] }]. Without it, patronage is inert.
setShops(shopsByChunk) { this.shopsByChunk = shopsByChunk; }
setPatronage(on) { this.patronageOn = !!on; }
// R12 gig-night surge (?gigs=1): while a gig is on, F points patronage at the venue so the street
// Gig-night surge (?gigs=1): while a gig is on, F points patronage at the venue so the street
// converges on the pub — it pulls from further (GIG_RANGE) and ducks in harder (GIG_SURGE). Occupants
// drain at close per A's closing-time ruling (no special handling — _openAt + the dwell timer do it).
setGig(venueShopId, on = true) { this._gigVenue = on && venueShopId != null ? venueShopId : null; }
// R13 the district: per-venue, so several concurrent gigs each pull their own block. F calls this once
// per venue off its per-venue state; the alpha single-venue call (one id) is a subset — still works.
setGig(venueShopId, on = true) {
if (venueShopId == null) return;
if (on) this._gigVenues.add(venueShopId); else this._gigVenues.delete(venueShopId);
}
setWeather(w) { if (w && typeof w.state === 'string') this.weather = { state: w.state, intensity: +w.intensity || 0 }; }
_openAt(hours) { if (!hours) return true; const h = (this.timeOfDay % 1) * 24; return h >= hours[0] && h < hours[1]; }
@ -365,14 +370,14 @@ export class CitizenSim {
const list = this.shopsByChunk.get(this.chunkKeyAt(c.x, c.z));
if (!list || !list.length) return null;
let best = null, bd = PATRON_RANGE;
for (const s of list) {
let gigBest = null, gbd = GIG_RANGE; // R13: the nearest gig venue in range — it wins over ordinary
for (const s of list) { // shops (follow the sound), and across concurrent venues.
if (!this._openAt(s.hours)) continue;
const d = Math.hypot(s.x - c.x, s.z - c.z);
// gig night: the venue reaches further and wins over a nearer shop (follow the sound to the pub)
if (this._gigVenue != null && s.shopId === this._gigVenue && d < GIG_RANGE) return s;
if (this._gigVenues.has(s.shopId) && d < gbd) { gbd = d; gigBest = s; }
if (d < bd) { bd = d; best = s; }
}
return best;
return gigBest || best;
}
_beginVisit(c, shop) {
c._savedWalk = { edge: c.edge, forward: c.forward, s: c.s }; // resume the footpath walk on the way out
@ -519,7 +524,7 @@ export class CitizenSim {
if (c._patronDist >= PATRON_STRIDE) {
c._patronDist = 0;
const shop = this._nearestOpenShop(c);
const chance = (shop && this._gigVenue != null && shop.shopId === this._gigVenue)
const chance = (shop && this._gigVenues.has(shop.shopId))
? Math.max(this._patronChance(), GIG_SURGE) : this._patronChance();
if (shop && c.patronRng() < chance) this._beginVisit(c, shop);
}