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:
parent
fbc8ffa1c8
commit
3daf8df3ec
@ -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%** (40–60%
|
||||
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.
|
||||
|
||||
@ -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 5–20s 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
|
||||
40–60% 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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 (~40–60%), 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.4–0.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 5–20s
|
||||
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';
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user