PROCITY/web/js/citizens/queue.js
m3ultra 9a65f51a88 Lane D R14 (v3.0): identity continuity — the ped you followed is in the crowd
The last charter promise. A per-venue tonight roster (sim.js _venueRoster) of
who entered a gig venue tonight — surge occupants self-record in _enter, queue
admits relay via citizens.recordVenueEntry — is consumed by GigCrew.spawn: the
crowd's front slots BECOME the roster identities (same pedIndex, same height),
seeded strangers fill the rest.

- No golden move: both goldens are plan-gen hashes; each crowd slot is an
  independently-keyed rng stream, so a roster override touches only that slot and
  an EMPTY roster is byte-identical to R13 (verified). Roster heights 1.55-1.95m
  stay inside the no-giants [1.4,2.0] gate. Pure bookkeeping: 0 rng, 0 GPU.
- Roster persists across interior exit/re-enter, bounded, auto-clears on the
  setGig(id,false) transition (per-night). Deduped by stable key.
- queue.js: admitOne() returns {pedIndex,height,key} + fires an onAdmit callback
  (auto-drain AND explicit) so F relays every walked-in punter to the roster;
  _spawnAt stashes the identity. band.js: _make returns pedIndex; members carry
  it for F's continuity smoke; crew.spawn is now null-gig-safe.
- Verified: surge 8/8 + queue 4/4 real identities land in the crowd; empty-roster
  == R13; 7-night x 3-venue soak leak geo0/tex0, roster bounded, no identity
  churn; interior 64 draws <=350 (0 new rigs); ?noassets placeholders, 0 fetch.
- F seam in LANE_D_NOTES: crew.spawn({roster}) + queue onAdmit relay + the
  tonightRoster(venueShopId) accessor.
- Debt #2 (sit clip): investigated, deferred to v3.1 — the clip converts+binds
  (tailnet Sitting_Idle.fbx -> 394KB sit.glb) but a real seated pose is ~1.2m
  stature (fails F's no-giants floor) and needs a _rotWithHips loader + replant
  (shared rig-stack) + an F smoke exemption; not a freeze-week change. SEAT_DROP
  hack kept (reads seated from the money-shot front).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:04:06 +10:00

166 lines
8.7 KiB
JavaScript

// 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).
// onAdmit (R14): fired with {pedIndex,height,key} on EVERY admit (auto-drain or explicit), so F can relay
// the punter who walked in into the sim's tonight roster (citizens.recordVenueEntry) → they join the crowd.
spawn(parent, { door = null, queueZone = null, gigId = 0, count = null, onAdmit = null } = {}) {
this.disposeAll();
this._gen++;
this.gid = gigId | 0;
this.parent = parent;
this.onAdmit = onAdmit;
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, pedIndex = null;
if (this.fleet && this.fleet.ready) {
const pk = pickRig(this.fleet, r());
pedIndex = pk ? pk.index : null; // R14: remember the ped so admitOne can hand it to the crowd
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, pedIndex, height: h, key, // key = `${gid}:${slot}` — stable per punter (slot shuffles, key doesn't)
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.
// R14: returns the admitted punter's identity {pedIndex, height, key} (or null when empty) and fires
// onAdmit(identity) — F relays it to citizens.recordVenueEntry so the punter joins the interior crowd.
admitOne() {
if (!this.members.length) return null;
// 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();
const identity = { pedIndex: head.pedIndex, height: head.height, key: head.key };
// 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();
if (this.onAdmit) this.onAdmit(identity); // relay the walked-in punter → the crowd roster (auto or explicit)
return identity;
}
// 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;
}
}