From 3daf8df3ecf37208499863a3c8495f0edf39869a Mon Sep 17 00:00:00 2001 From: m3ultra Date: Wed, 15 Jul 2026 04:42:34 +1000 Subject: [PATCH] Lane D round-8: shop patronage v0 + weather reaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- D-progress.md | 20 ++++++++ docs/LANES/LANE_D_NOTES.md | 51 ++++++++++++++++++++ web/citizens_test.html | 30 +++++++++++- web/js/citizens/sim.js | 98 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 194 insertions(+), 5 deletions(-) diff --git a/D-progress.md b/D-progress.md index 664d22c..1364b75 100644 --- a/D-progress.md +++ b/D-progress.md @@ -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. diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md index b4a7f2d..2e86358 100644 --- a/docs/LANES/LANE_D_NOTES.md +++ b/docs/LANES/LANE_D_NOTES.md @@ -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 diff --git a/web/citizens_test.html b/web/citizens_test.html index c8d2315..2d285c7 100644 --- a/web/citizens_test.html +++ b/web/citizens_test.html @@ -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), diff --git a/web/js/citizens/sim.js b/web/js/citizens/sim.js index bb41326..2bcfe7e 100644 --- a/web/js/citizens/sim.js +++ b/web/js/citizens/sim.js @@ -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';