Lane D round-8: shop patronage v0 + weather reaction

The crowd comes alive. Default-on for the streamed roster (behind ?patronage=0); v1 path
(?roster=v1) untouched — no patron fields, golden identity holds. qa --strict GREEN.

Patronage (D1): per-ped state machine walking -> going (steer to a nearby open shop's door,
seeded ~every 10m within 18m) -> inside (hidden, 5-20s seeded dwell) -> emerge (resumes walk).
Hours-aware: noon = all types (up to ~38 inside, 7 types); night = video-only (the openLate shop
draws the crowd, roll ramps up as streets empty). Deterministic (150 chunk-keyed ids), leak-free
(0 GPU delta over the churn), budget-neutral (inside peds hidden -> draws -1).

Weather (D2): reads Lane B's PROCITY.weather contract (no import). setWeather({state,intensity}).
Rain -> density -56% (40-60% target), walk speed +14%, more sheltering (inside 6->25); overcast
-10%; clear = v1.

Also this session: R7 post-flip shell verification (no-flag boot streams, determinism, leak-free,
?roster=v1 restores golden roster) and pushed main + v1.0/v1.1/v2.0-alpha/v2.0-beta to origin.

F wiring (setShops door-point recipe, setWeather, ?patronage=0) documented in LANE_D_NOTES.
Only web/js/citizens/sim.js + web/citizens_test.html changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-15 04:42:34 +10:00
parent fbc8ffa1c8
commit 3daf8df3ec
4 changed files with 194 additions and 5 deletions

View File

@ -1,5 +1,25 @@
# PROCITY-D — progress (Lane D · Citizens)
## Round 8 (the crowd comes alive) — shop patronage v0 + weather reaction. qa GREEN.
Also did the R7 post-flip shell verification (F's flip landed) + pushed the tags to origin (main
1a20501→900b274; v1.0/v1.1/v2.0-alpha/v2.0-beta all on origin).
- **Post-flip verify** ✅ (real shell): no-flag boot → streamMode true, determinism (188 ids), leak-free
(0 delta), setNightLivelyChunks wired; `?roster=v1` → fixed roster, golden identity byte-identical.
- **Shop patronage v0 (D1)** ✅: streamed peds duck into open shops they pass (going→inside→emerge, hidden
while inside), hours-aware — noon variety (up to ~38 inside, 7 types), **night video-only** (the
open-late shop draws the crowd). Deterministic (150 chunk-keyed ids), leak-free (0 GPU delta over the
churn), **budget-neutral** (draws 1, inside peds hidden). Behind `?patronage=0`, default-on for stream.
- **Weather reaction (D2)** ✅: reads B's `PROCITY.weather` (no import). Rain → density **56%** (4060%
target), walk speed +~14%, more sheltering (inside 6→25); overcast 10%; clear = v1.
- v1 path untouched (no patron fields, golden identity). Full numbers + F's `setShops`/`setWeather`/
`?patronage=0` wiring in `LANE_D_NOTES.md`.
Only `web/js/citizens/sim.js` + `web/citizens_test.html` (synthetic-shop demo + flags) changed.
---
## Round 7 (the flip) — MERGE VALIDATED, GO given to F, defaults + escape hatch ready. qa GREEN.
Validated Lane E's ped-merge (the gate my R6 memo named) and handed F the **GO** for the roster flip.

View File

@ -6,6 +6,57 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
---
## ROUND 8 — shop patronage v0 + weather reaction (→ Lane F wiring below)
**The crowd comes alive: streamed peds duck into open shops they pass and re-emerge; at night only the
open-late video shop draws visitors; rain thins + shelters the crowd.** Default-on for the streamed
roster (post-flip), behind `?patronage=0` off-switch. Verified in-shell (real plan shops) + test page.
`qa.sh --strict` GREEN. v1 (`?roster=v1`) path untouched (no patron fields, golden identity holds).
### Patronage (D1) ✅
State machine per streamed ped: walking → (every ~10m, if a nearby OPEN shop is within 18m, a seeded
roll) → **going** (steer to the door) → **inside** (hidden, seeded 520s dwell) → **emerge** (resumes
its footpath walk). Hours-aware via the shop's `hours`.
- **Day-long**: peds visibly enter/leave shops of all types (noon: up to ~38 inside near a shopfront,
7 types visited).
- **Night**: at 22:48 only the video shop (`hours[1]≥22`) is open → patronage is **video-only**
(`onlyVideo:true`), and its block stays lively (the R6 night floor). The patronage roll ramps up as
the streets empty so the few peds out concentrate at the one open shop.
- **Deterministic** (chunk-keyed identities unchanged: 150 re-derive from seed), **leak-free** (0 GPU
delta over a 2-walk patronage churn — enter/emerge reuses the existing actor lifecycle), and
**budget-neutral / positive** (inside peds are hidden → draws 1 vs patronage-off at the same spot).
### Weather reaction (D2) ✅ — reads Lane B's `PROCITY.weather` contract (does NOT import weather.js)
`sim.setWeather({state,intensity})` each frame. **Rain** (intensity 0.8): density **56%** (in the
4060% target), walk speed +~14%, and patronage chance up → more peds shelter in shops (measured
inside 6→25). **Overcast**: ~10%. **Clear**: v1 behaviour. Seed 20261990 rolls `clear` (v1-identical);
use `?weather=rain` to force it.
### → Lane F: wiring in `web/index.html` (F owns the shell)
1. **Shop door points** — build once from the plan, feed the sim:
```js
const CH = 64, shopsByChunk = new Map();
for (const s of plan.shops) {
const l = plan.lots.find(x => x.id === s.lot); if (!l) continue;
const ry = l.ry || 0, fx = -Math.sin(ry), fz = -Math.cos(ry); // facade normal → the street
const x = l.x + fx * (l.d / 2 + 0.6), z = l.z + fz * (l.d / 2 + 0.6); // door at the shopfront
const k = citizens.chunkKeyAt(x, z);
(shopsByChunk.get(k) || shopsByChunk.set(k, []).get(k)).push({ x, z, hours: s.hours });
}
if (!rosterV1) citizens.setShops(shopsByChunk); // patronage needs door points; inert without
if (params.get('patronage') === '0') citizens.setPatronage(false); // off-switch
```
(PATRON_RANGE 18m tolerates door-point imprecision; if a door reads wrong, nudge the `+0.6`.)
2. **Weather** — in the street branch each frame: `citizens.setWeather(window.PROCITY.weather);`
(B's contract is always a valid `{state,intensity}`; F sets `{clear,0}` when `?weather` is off.)
3. `setNightLivelyChunks` (openLate block) — already wired in F1; unchanged.
**Baseline note (flip protocol):** patronage default-on may move the flags-off draw baseline a hair —
in the direction of *fewer* draws (inside peds hidden), and the default spawn view has ~0 near-rigs so
it's near-nil. If F1's regression trips, re-pin per the flip protocol (it's an improvement, not a leak).
---
## ROUND 7 — MERGE VALIDATED · **GO for the roster flip** (→ Lane F: flip on this) · escape hatch ready
**Verdict: GO. Lane E's R7 ped-merge closed the one gap the R6 memo named. Full-density streamed

View File

@ -86,6 +86,8 @@ const NOASSETS = qs.get('noassets') === '1';
// R7 flip: the chunk-streamed roster is now the DEFAULT. `?roster=v1` is the escape hatch back to the
// fixed v1 roster; `?roster=stream` is still accepted (explicit). Mirrors the shell wiring F lands in F1.
const STREAM = qs.get('roster') !== 'v1';
const PATRONAGE = qs.get('patronage') !== '0'; // R8: peds duck into open shops (default-on for stream)
const WEATHER = qs.get('weather'); // R8 demo: ?weather=rain|overcast (thins + redirects the crowd)
// ---------- renderer / scene ----------
const app = document.getElementById('app');
@ -175,7 +177,33 @@ for (const n of nodes) {
// ---------- fleet + sim ----------
const fleet = loadPedFleet(NOASSETS ? 'models/peds/__none__/' : 'models/peds/');
const sim = new CitizenSim({ renderer, scene, camera, citySeed: SEED, graph, fleet,
chunkStream: STREAM ? { radius: 2 } : null }); // perChunk default 10 (street draw budget) — see sim.js
chunkStream: STREAM ? { radius: 2 } : null }); // perChunk default 16 (full density post-merge) — see sim.js
// ---------- R8 patronage demo (synthetic shops on the fixture; the shell feeds REAL plan shops — see
// LANE_D_NOTES for F's setShops door-point recipe). Peds duck into open shops they pass; one openLate. ----
if (STREAM && PATRONAGE) {
const shopsByChunk = new Map();
const cell = (x, z) => { const k = sim.chunkKeyAt(x, z); let a = shopsByChunk.get(k); if (!a) shopsByChunk.set(k, a = []); return a; };
let vid = null;
edges.forEach((e, i) => {
const A = nodeById(e.a), B = nodeById(e.b), mx = (A.x + B.x) / 2, mz = (A.z + B.z) / 2;
const dx = B.x - A.x, dz = B.z - A.z, len = Math.hypot(dx, dz) || 1, px = dz / len, pz = -dx / len;
[1, -1].forEach((side) => {
const x = mx + px * side * 7, z = mz + pz * side * 7;
const openLate = i === 0 && side === 1; // one openLate "video" shop
const s = { x, z, hours: openLate ? [11, 23] : [9, 17], type: openLate ? 'video' : 'shop' };
cell(x, z).push(s); if (openLate) vid = s;
const box = new THREE.Mesh(new THREE.BoxGeometry(3.4, 3, 3.4),
new THREE.MeshStandardMaterial({ color: openLate ? 0x3a5c8c : 0x54493c, roughness: 0.9 }));
box.position.set(x, 1.5, z); scene.add(box);
});
});
sim.setShops(shopsByChunk);
if (vid) { const [cx, cz] = sim.chunkKeyAt(vid.x, vid.z).split(',').map(Number); const lv = new Set();
for (let a = -1; a <= 1; a++) for (let b = -1; b <= 1; b++) lv.add(`${cx + a},${cz + b}`); sim.setNightLivelyChunks(lv); }
}
if (STREAM && WEATHER === 'rain') sim.setWeather({ state: 'rain', intensity: 0.8 });
else if (STREAM && WEATHER === 'overcast') sim.setWeather({ state: 'overcast', intensity: 0.5 });
// ---------- keeper demo (mock counter, since Lane C hasn't landed) ----------
const counter = new THREE.Mesh(new THREE.BoxGeometry(3, 1.05, 1),

View File

@ -30,6 +30,8 @@ const PLACEHOLDER_VARIANTS = 8; // impostor atlas subjects in asset
const FOOTPATH_MARGIN = 0.9; // m outside the carriageway edge
const IMPOSTOR_MAX = 220; // instanced billboards ceiling
const NIGHT_LIVELY_FLOOR = 0.5; // stream: "lively" chunks (open-late block) keep ≥this density at night
const PATRON_RANGE = 18; // m — a ped ducks into a shop it's passing within this
const PATRON_STRIDE = 10; // m walked between patronage checks (framerate-independent)
// time-of-day density curve: t01 in [0,1) over a day → crowd multiplier (CITY_SPEC: lunch rush,
// near-empty at night). Sampled at 8 control points, linearly interpolated.
@ -116,6 +118,10 @@ export class CitizenSim {
this._hookDriven = false; // Lane B's onChunkBuilt/Disposed drive us → stop polling
this._livelyChunks = new Set(); // chunkKeys that resist night thinning (e.g. the open-late block)
this._encountered = new Set(); // cumulative identity signatures seen (determinism proof)
// 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] }] door points
this.patronageOn = true;
this.weather = { state: 'clear', intensity: 0 }; // Lane B's PROCITY.weather contract (shell feeds it)
this._dropKeys = []; // scratch: chunks to drop this frame
this._activeList = []; // this frame's active citizens (both modes) — test page reads it
this.timeOfDay = 0.5; // noon
@ -240,6 +246,7 @@ export class CitizenSim {
edge, forward: idn.forward, s: idn.sFrac * this.edges[edge].len, loiter: 0,
x: 0, z: 0, facing: 0, tier: 'far', actor: null, actorKind: null, _acc: 0,
turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id),
patron: null, patronTarget: null, patronTimer: 0, patronRng: rng(this.citySeed, 'patron', id),
};
if (this.mode === 'rig' && this.fleet.ready) { const pk = pickRig(this.fleet, c.pedRoll); if (pk) { c.pedIndex = pk.index; c.subject = pk.index; } }
this._placeOnLane(c);
@ -316,6 +323,62 @@ export class CitizenSim {
this.debugTiers = !!on;
if (this.impostor) this.impostor.setTint(on ? tierColor.mid : 0xffffff);
}
// ---- R8 patronage + weather ----
// shell feeds door points + hours per chunk (computed from the plan — sim stays graph-only).
// map: chunkKey → [{ x, z, hours:[open,close] }]. Without it, patronage is inert.
setShops(shopsByChunk) { this.shopsByChunk = shopsByChunk; }
setPatronage(on) { this.patronageOn = !!on; }
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]; }
// rain thins the crowd (~4060%), overcast a little; clear = v1. Applied to each chunk's active count.
_weatherDensityMult() {
const w = this.weather;
if (w.state === 'rain') return 0.6 - 0.2 * w.intensity; // 0.40.6
if (w.state === 'overcast') return 1 - 0.15 * w.intensity; // ~0.9
return 1;
}
_speedMult() { return this.weather.state === 'rain' ? 1.0 + 0.18 * this.weather.intensity : 1; }
// chance (per in-range check) a ped ducks into an open shop. Rain → shelter-seeking. As the streets
// empty (night), bump it so the few peds out concentrate at the one open shop (the video shop draws
// the night crowd instead of a dead street with a lone visitor).
_patronChance() {
let base = this.weather.state === 'rain' ? 0.22 + 0.25 * this.weather.intensity : 0.16;
const day = densityAt(this.timeOfDay);
if (day < 0.35) base = Math.min(0.6, base + (0.35 - day) * 1.3);
return base;
}
// nearest OPEN shop the ped is currently passing (current chunk, ≤PATRON_RANGE), else null.
// Hours-aware — at night only the openLate video shop qualifies, so its block draws the night crowd.
_nearestOpenShop(c) {
if (!this.shopsByChunk) return null;
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) {
if (!this._openAt(s.hours)) continue;
const d = Math.hypot(s.x - c.x, s.z - c.z);
if (d < bd) { bd = d; best = s; }
}
return best;
}
_beginVisit(c, shop) {
c._savedWalk = { edge: c.edge, forward: c.forward, s: c.s }; // resume the footpath walk on the way out
c.patronTarget = shop; c.patron = 'going';
}
_enter(c) {
c.patron = 'inside';
c.patronTimer = 5 + c.patronRng() * 15; // seeded dwell 520s
c.x = c.patronTarget.x; c.z = c.patronTarget.z; // parked at the door while inside (hidden)
}
_emerge(c) {
const w = c._savedWalk;
if (w) { c.edge = w.edge; c.forward = w.forward; c.s = w.s; }
c.patron = null; c.patronTarget = null; c._savedWalk = null;
this._placeOnLane(c); // back on the footpath, resumes the walk
}
// stable identity signature of the active set — immutable spawn identity, NOT live position, so it
// holds while citizens walk (the determinism gate: same seed → same crowd, twice). Both modes.
identitySignature() {
@ -388,11 +451,23 @@ export class CitizenSim {
}
_advance(c, dt) {
// ---- R8 patronage state machine (streamed citizens only; c.patron is undefined for v1) ----
if (c.patron === 'inside') { c.patronTimer -= dt; if (c.patronTimer <= 0) this._emerge(c); return; }
if (c.patron === 'going') { // steer straight to the shop door
const dx = c.patronTarget.x - c.x, dz = c.patronTarget.z - c.z;
const dist = Math.hypot(dx, dz);
if (dist < 1.8) { this._enter(c); return; }
const step = c.speed * this._speedMult() * dt;
c.x += (dx / dist) * step; c.z += (dz / dist) * step;
c.facing = Math.atan2(-dx / dist, -dz / dist);
return;
}
if (c.loiter > 0) { c.loiter -= dt; return; }
const e = this.edges[c.edge];
c.s += c.speed * dt;
const adv = c.speed * this._speedMult() * dt;
c.s += adv;
if (c.s >= e.len) {
// arrived at the far node — pick the next edge (seeded), maybe loiter
// arrived at the far node — pick the next edge (seeded), maybe a window-shop loiter
const node = c.forward > 0 ? e.b : e.a;
const inc = this.adj.get(node);
let choices = inc.filter(i => i !== c.edge);
@ -405,6 +480,17 @@ export class CitizenSim {
if (c.loit() < 0.10 + c.loiterTend * 0.28) c.loiter = 1.4 + c.loit() * 3.2; // window-shop stop
}
this._placeOnLane(c);
// patronage: every ~PATRON_STRIDE walked, IF a nearby open shop is in range, a seeded chance to
// duck in (proximity-gated so the roll isn't wasted mid-block — this is what makes the sparse
// night crowd reliably cluster at the open-late video shop).
if (this.patronageOn && this.shopsByChunk && c.patronRng) {
c._patronDist = (c._patronDist || 0) + adv;
if (c._patronDist >= PATRON_STRIDE) {
c._patronDist = 0;
const shop = this._nearestOpenShop(c);
if (shop && c.patronRng() < this._patronChance()) this._beginVisit(c, shop);
}
}
}
// ---- actor lifecycle ----
@ -445,10 +531,12 @@ export class CitizenSim {
const density = densityAt(this.timeOfDay);
if (this.streamMode) {
const haveLively = this._livelyChunks.size > 0;
const wm = this._weatherDensityMult(); // rain/overcast thins the crowd on top of the day curve
for (const [key, arr] of this.chunkRosters) {
// hours-aware: every chunk thins by the day curve, but "lively" chunks (the open-late block)
// keep a night floor so that street never goes dead while its shop is still open.
const d = (haveLively && this._livelyChunks.has(key)) ? Math.max(density, NIGHT_LIVELY_FLOOR) : density;
const base = (haveLively && this._livelyChunks.has(key)) ? Math.max(density, NIGHT_LIVELY_FLOOR) : density;
const d = base * wm;
const activeCount = Math.round(arr.length * d);
for (let i = 0; i < arr.length; i++) {
if (i < activeCount) list.push(arr[i]);
@ -491,6 +579,7 @@ export class CitizenSim {
// choose the near set: hysteresis eligibility, then nearest-first up to the cap
const eligible = [];
for (const c of cand) {
if (c.patron === 'inside') continue; // ducked into a shop → not rendered
const wasNear = c.tier === 'near';
if (c._d < (wasNear ? NEAR_EXIT : NEAR_ENTER)) eligible.push(c);
}
@ -502,7 +591,8 @@ export class CitizenSim {
let newRigs = 0;
for (const c of cand) {
let want;
if (nearSet.has(c)) want = 'near';
if (c.patron === 'inside') want = 'far'; // inside a shop → hidden (actor released below)
else if (nearSet.has(c)) want = 'near';
else if (c._d < (c.tier === 'far' ? MID_ENTER : MID_EXIT)) want = 'mid';
else want = 'far';