PROCITY/web/citizens_test.html
m3ultra 3daf8df3ec 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>
2026-07-15 04:42:34 +10:00

352 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PROCITY · Lane D — Citizens test</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; background: #12100e;
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; color: #e8e2d6; }
#app { position: fixed; inset: 0; }
#hud { position: fixed; top: 10px; left: 10px; width: 264px; padding: 12px 14px;
background: rgba(20,17,14,.82); border: 1px solid #3a3128; border-radius: 8px;
backdrop-filter: blur(6px); user-select: none; }
#hud h1 { margin: 0 0 8px; font-size: 12px; letter-spacing: .08em; color: #d8b23a; text-transform: uppercase; }
.row { display: flex; justify-content: space-between; gap: 8px; margin: 3px 0; }
.row .k { color: #9a8f7d; }
.row .v { color: #f0e8d0; font-variant-numeric: tabular-nums; }
.ctl { margin: 10px 0 4px; }
.ctl label { display: flex; justify-content: space-between; color: #c7bca8; margin-bottom: 3px; }
input[type=range] { width: 100%; accent-color: #d8b23a; }
.btns { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
button { flex: 1; min-width: 84px; padding: 6px 8px; cursor: pointer; color: #efe7d6;
background: #2c2620; border: 1px solid #4a3f31; border-radius: 6px; font: inherit; }
button:hover { background: #38302666; border-color: #6a5a44; }
button.on { background: #4a3d1f; border-color: #d8b23a; color: #ffe9a8; }
#det { margin-top: 8px; min-height: 15px; color: #8fd88f; }
#warn { color: #e0a24a; margin-top: 6px; }
hr { border: 0; border-top: 1px solid #3a3128; margin: 10px 0 6px; }
#help { position: fixed; bottom: 8px; left: 10px; color: #6f665a; font-size: 11px; }
.barwrap { height: 6px; background: #2a241d; border-radius: 3px; overflow: hidden; margin-top: 2px; }
.bar { height: 100%; background: linear-gradient(90deg,#6db36d,#d8b23a,#c8102e); width: 0%; }
</style>
<script type="importmap">
{ "imports": {
"three": "./vendor/three.module.js",
"three/addons/": "./vendor/addons/"
} }
</script>
</head>
<body>
<div id="app"></div>
<div id="hud">
<h1>Lane D · Citizens</h1>
<div class="row"><span class="k">fps</span><span class="v" id="fps"></span></div>
<div class="barwrap"><div class="bar" id="fpsbar"></div></div>
<div class="row"><span class="k">rigged (near)</span><span class="v" id="s-rig">0</span></div>
<div class="row"><span class="k">impostor (mid)</span><span class="v" id="s-mid">0</span></div>
<div class="row"><span class="k">culled (far)</span><span class="v" id="s-far">0</span></div>
<div class="row"><span class="k">active</span><span class="v" id="s-active">0</span></div>
<hr>
<div class="row"><span class="k">mixer ms</span><span class="v" id="s-mixer">0</span></div>
<div class="row"><span class="k">draw calls</span><span class="v" id="s-draws">0</span></div>
<div class="row"><span class="k">triangles</span><span class="v" id="s-tris">0</span></div>
<div class="row"><span class="k">rig pool</span><span class="v" id="s-pool">0</span></div>
<div class="row"><span class="k">mode</span><span class="v" id="s-mode"></span></div>
<div class="ctl">
<label>population <span class="v" id="l-pop">120</span></label>
<input type="range" id="pop" min="0" max="200" value="120">
</div>
<div class="ctl">
<label>time of day <span class="v" id="l-tod">12:00</span></label>
<input type="range" id="tod" min="0" max="1000" value="500">
</div>
<div class="btns">
<button id="btn-tiers">Tier colours</button>
<button id="btn-det">Determinism ✓</button>
</div>
<div id="det"></div>
<div id="warn"></div>
</div>
<div id="help">drag = look · wheel = zoom · WASD = move focus · Q/E = up/down</div>
<script type="module">
import * as THREE from 'three';
import { loadPedFleet } from './js/citizens/rigs.js';
import { CitizenSim, identityOf, signatureOf } from './js/citizens/sim.js';
import { pickRig } from './js/citizens/rigs.js';
import { KeeperManager } from './js/citizens/keepers.js';
const qs = new URLSearchParams(location.search);
const SEED = (parseInt(qs.get('seed'), 10) || 20261990) >>> 0;
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');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
app.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xbcC6cf);
scene.fog = new THREE.Fog(0xbcC6cf, 90, 220);
const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.1, 600);
// warm cinematic key + cool sky fill (the POLY lock)
const sun = new THREE.DirectionalLight(0xfff0d8, 2.2); sun.position.set(40, 70, 25); scene.add(sun);
scene.add(new THREE.HemisphereLight(0xbcd0f0, 0x4a4034, 1.15));
scene.add(new THREE.AmbientLight(0x2a2620, 0.4));
// neutral PMREM environment — the peds are metallic-PBR (metalness≈0.5) and render dark with no
// environment to reflect. Lane B's shell will provide the real sky env; the test scene makes its own.
// (Set BEFORE the sim is created so the rig-atlas bake reads scene.environment too.)
function makeEnv() {
const c = document.createElement('canvas'); c.width = 16; c.height = 64;
const x = c.getContext('2d'), g = x.createLinearGradient(0, 0, 0, 64);
g.addColorStop(0, '#e6eef7'); g.addColorStop(0.5, '#c2cad0'); g.addColorStop(1, '#726552');
x.fillStyle = g; x.fillRect(0, 0, 16, 64);
const tex = new THREE.CanvasTexture(c);
tex.mapping = THREE.EquirectangularReflectionMapping; tex.colorSpace = THREE.SRGBColorSpace;
const pm = new THREE.PMREMGenerator(renderer); const env = pm.fromEquirectangular(tex).texture;
tex.dispose(); pm.dispose(); return env;
}
scene.environment = makeEnv();
// ---------- fixture street graph (LOCAL — no Lane A/B imports) ----------
// a GRID×GRID lattice (≈±GRID·G/2 m) → a big walkable loop network with turns at every junction.
// Deliberately large so ?roster=stream can be walked far from spawn: v1's fixed roster thins out over
// the whole grid, chunk-streaming keeps constant density around the camera. Centre cross = main streets.
const GRID = 9, G = 52, MID = (GRID - 1) / 2;
const nodes = [];
for (let r = 0; r < GRID; r++) for (let c = 0; c < GRID; c++)
nodes.push({ id: `n${r}_${c}`, x: (c - MID) * G, z: (r - MID) * G });
const nid = (r, c) => `n${r}_${c}`;
const edges = [];
let eid = 0;
for (let r = 0; r < GRID; r++) for (let c = 0; c < GRID; c++) {
if (c < GRID - 1) edges.push({ id: `e${eid++}`, a: nid(r, c), b: nid(r, c + 1), width: r === MID ? 9 : 6, kind: r === MID ? 'main' : 'side' });
if (r < GRID - 1) edges.push({ id: `e${eid++}`, a: nid(r, c), b: nid(r + 1, c), width: c === MID ? 9 : 6, kind: c === MID ? 'main' : 'side' });
}
const graph = { nodes, edges };
const GRID_SPAN = GRID * G; // used to size the ground plane + fog
// ground + road/footpath strips so the streets read
const groundMat = new THREE.MeshStandardMaterial({ color: 0x6f7a52, roughness: 1 });
const ground = new THREE.Mesh(new THREE.PlaneGeometry(GRID_SPAN + 200, GRID_SPAN + 200), groundMat);
ground.rotation.x = -Math.PI / 2; ground.position.y = -0.02; scene.add(ground);
function addStrip(width, len, cx, cz, beta, color, y) {
const m = new THREE.Mesh(new THREE.PlaneGeometry(width, len),
new THREE.MeshStandardMaterial({ color, roughness: 1 }));
m.rotation.x = -Math.PI / 2; m.rotation.z = -beta; // z-rotation in the flattened plane aligns length
m.position.set(cx, y, cz);
scene.add(m);
return m;
}
const nodeById = id => nodes.find(n => n.id === id);
for (const e of edges) {
const A = nodeById(e.a), B = nodeById(e.b);
const dx = B.x - A.x, dz = B.z - A.z, len = Math.hypot(dx, dz);
const beta = Math.atan2(dx, dz);
const cx = (A.x + B.x) / 2, cz = (A.z + B.z) / 2;
addStrip(e.width, len, cx, cz, beta, 0x2f2c29, 0.0); // asphalt
const foot = e.width / 2 + 0.9, px = Math.cos(beta), pz = -Math.sin(beta);
addStrip(1.8, len, cx + px * foot, cz + pz * foot, beta, 0x8b8375, 0.01); // footpath +side
addStrip(1.8, len, cx - px * foot, cz - pz * foot, beta, 0x8b8375, 0.01); // footpath -side
}
// junction pads
for (const n of nodes) {
const pad = new THREE.Mesh(new THREE.CircleGeometry(5.5, 20),
new THREE.MeshStandardMaterial({ color: 0x323029, roughness: 1 }));
pad.rotation.x = -Math.PI / 2; pad.position.set(n.x, 0.005, n.z); scene.add(pad);
}
// ---------- 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 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),
new THREE.MeshStandardMaterial({ color: 0x6b4a2e, roughness: 0.8 }));
counter.position.set(6, 0.525, 10); scene.add(counter);
const keepers = new KeeperManager({ camera, citySeed: SEED, fleet });
let keeper = null;
fleet.whenReady.then(() => { keeper = keepers.spawn(scene, { x: 6, z: 11.2, ry: 0, shopId: 'demo', type: 'record' }); });
if (NOASSETS) keeper = keepers.spawn(scene, { x: 6, z: 11.2, ry: 0, shopId: 'demo', type: 'record' });
// ---------- debug tier markers ----------
const TIER_COL = { near: new THREE.Color(0x66dd88), mid: new THREE.Color(0xffcc44), far: new THREE.Color(0x884466) };
const ring = new THREE.RingGeometry(0.4, 0.62, 18); ring.rotateX(-Math.PI / 2);
const markers = new THREE.InstancedMesh(ring,
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0.85 }), 512);
markers.frustumCulled = false; markers.visible = false; markers.count = 0; scene.add(markers);
const _mm = new THREE.Matrix4(), _mp = new THREE.Vector3(), _mq = new THREE.Quaternion(), _ms = new THREE.Vector3(1, 1, 1);
function paintTierMarkers() {
const act = sim.activeCitizens(); let n = 0;
for (let i = 0; i < act.length && n < 512; i++) {
const c = act[i];
_mp.set(c.x, 0.06, c.z); _mm.compose(_mp, _mq, _ms);
markers.setMatrixAt(n, _mm); markers.setColorAt(n, TIER_COL[c.tier] || TIER_COL.far); n++;
}
markers.count = n; markers.instanceMatrix.needsUpdate = true;
if (markers.instanceColor) markers.instanceColor.needsUpdate = true;
}
// ---------- minimal orbit camera ----------
const cam = { target: new THREE.Vector3(0, 1, 0), theta: 0.6, phi: 1.15, radius: 55 };
function applyCam() {
const r = cam.radius, sp = Math.sin(cam.phi), cp = Math.cos(cam.phi);
camera.position.set(
cam.target.x + r * sp * Math.sin(cam.theta),
cam.target.y + r * cp,
cam.target.z + r * sp * Math.cos(cam.theta));
camera.lookAt(cam.target);
}
let drag = false, lx = 0, ly = 0;
renderer.domElement.addEventListener('pointerdown', e => { drag = true; lx = e.clientX; ly = e.clientY; });
addEventListener('pointerup', () => drag = false);
addEventListener('pointermove', e => {
if (!drag) return;
cam.theta -= (e.clientX - lx) * 0.005; cam.phi = Math.max(0.15, Math.min(1.5, cam.phi - (e.clientY - ly) * 0.005));
lx = e.clientX; ly = e.clientY;
});
addEventListener('wheel', e => { cam.radius = Math.max(4, Math.min(240, cam.radius * (1 + Math.sign(e.deltaY) * 0.08))); }, { passive: true });
const keys = {};
addEventListener('keydown', e => keys[e.key.toLowerCase()] = true);
addEventListener('keyup', e => keys[e.key.toLowerCase()] = false);
function moveTarget(dt) {
const sp = 26 * dt, fx = Math.sin(cam.theta), fz = Math.cos(cam.theta);
if (keys['w']) { cam.target.x -= fx * sp; cam.target.z -= fz * sp; }
if (keys['s']) { cam.target.x += fx * sp; cam.target.z += fz * sp; }
if (keys['a']) { cam.target.x -= fz * sp; cam.target.z += fx * sp; }
if (keys['d']) { cam.target.x += fz * sp; cam.target.z -= fx * sp; }
if (keys['q']) cam.target.y = Math.max(0.2, cam.target.y - sp * 0.5);
if (keys['e']) cam.target.y += sp * 0.5;
}
// ---------- UI ----------
const $ = id => document.getElementById(id);
let population = 120, debugTiers = false;
sim.setPopulation(population);
$('pop').oninput = e => { population = +e.target.value; $('l-pop').textContent = population; sim.setPopulation(population); };
function fmtTod(t) { const h = Math.floor(t * 24), m = Math.floor((t * 24 % 1) * 60); return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; }
$('tod').oninput = e => { const t = +e.target.value / 1000; $('l-tod').textContent = fmtTod(t); sim.setTimeOfDay(t); };
sim.setTimeOfDay(0.5);
$('btn-tiers').onclick = () => {
debugTiers = !debugTiers; sim.setDebugTiers(debugTiers);
markers.visible = debugTiers; $('btn-tiers').classList.toggle('on', debugTiers);
};
function freshPed(idn) { if (fleet.ready) { const pk = pickRig(fleet, idn.pedRoll); if (pk) return pk.index; } return -1; }
$('btn-det').onclick = () => {
let ok, msg;
if (STREAM) {
// stream: every live chunk's residents are a pure function of (seed, chunkKey, i) — re-derive + compare
let checked = 0, bad = null;
for (const [key, arr] of sim.chunkRosters) {
const el = sim.chunkEdges.get(key) || [];
for (let i = 0; i < arr.length; i++) {
const idn = identityOf(SEED, el.length, `${key}#${i}`);
const expect = signatureOf(`${key}#${i}`, idn, freshPed(idn));
checked++;
if (expect !== sim._sig(arr[i])) { bad = `${key}#${i}`; break; }
}
if (bad) break;
}
ok = !bad;
msg = ok ? `✓ deterministic — ${checked} chunk-keyed identities match seed ${SEED} (${sim.chunkRosters.size} chunks)`
: `✗ MISMATCH at ${bad}`;
} else {
const active = sim.stats.active, live = sim.identitySignature(), fresh = [];
for (let i = 0; i < active; i++) { const idn = identityOf(SEED, edges.length, i); fresh.push(signatureOf(i, idn, freshPed(idn))); }
ok = live.length === fresh.length && live.every((s, i) => s === fresh[i]);
msg = ok ? `✓ deterministic — ${active} identities match seed ${SEED}` : `✗ MISMATCH at ${live.findIndex((s, i) => s !== fresh[i])}`;
}
$('det').style.color = ok ? '#8fd88f' : '#e06a5a';
$('det').textContent = msg;
};
if (NOASSETS) $('warn').textContent = '?noassets — placeholder figures only';
// ---------- loop ----------
let last = performance.now(), fpsSmooth = 60;
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000); last = now;
moveTarget(dt); applyCam();
renderer.info.reset();
const st = sim.update(dt);
keepers.update(dt);
if (debugTiers) paintTierMarkers();
renderer.render(scene, camera);
const fps = 1 / Math.max(1e-3, dt); fpsSmooth += (fps - fpsSmooth) * 0.1;
$('fps').textContent = fpsSmooth.toFixed(0);
$('fpsbar').style.width = Math.min(100, fpsSmooth / 60 * 100) + '%';
$('s-rig').textContent = st.rigged;
$('s-mid').textContent = st.mid;
$('s-far').textContent = st.far;
$('s-active').textContent = st.active;
$('s-mixer').textContent = st.mixerMs.toFixed(2);
$('s-draws').textContent = renderer.info.render.calls;
$('s-tris').textContent = renderer.info.render.triangles.toLocaleString();
$('s-pool').textContent = st.poolTotal;
$('s-mode').textContent = st.mode + (STREAM ? ` · stream ${st.chunks}ch` : '') + (NOASSETS ? ' (noassets)' : '');
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// pause mixers only on a real visibility change (never fires in the preview pane, which is
// permanently "hidden" yet visible to us) — satisfies "all mixers pause when tab hidden".
document.addEventListener('visibilitychange', () => sim.setPaused(document.hidden));
window._sim = sim; // dev hook
// dev harness: drive N sim+render frames deterministically (the preview pane throttles rAF)
window._dbg = { renderer, scene, camera, cam, keepers, applyCam, paintTierMarkers,
step(n = 1, dt = 0.016) { for (let i = 0; i < n; i++) { applyCam(); const st = sim.update(dt); keepers.update(dt); if (debugTiers) paintTierMarkers(); renderer.render(scene, camera); } },
look(tx, ty, tz, radius, theta = cam.theta, phi = cam.phi) { cam.target.set(tx, ty, tz); cam.radius = radius; cam.theta = theta; cam.phi = phi; applyCam(); } };
</script>
</body>
</html>