PROCITY/web/citizens_test.html
m3ultra 34145e24a7 Lane D round-9: interior presence — occupancy truth + browser rigs (C->D->F seam)
Follow a ped into a shop and find them browsing. D owns occupancy truth; C owns browse points;
F wires the handoff. Validated end-to-end in-shell (?stock=real). qa --strict GREEN; v1 untouched.

Occupancy (D1): patronage records who's inside which shop by shopId. New
citizens.occupancyOf(shopId) -> {count, occupants:[{seed,enteredAt,pedIndex}]} (pedIndex = the
exact ped who ducked in, for identity continuity). Cleaned on emerge/chunk-drop/stream-toggle.
Deterministic: 17 shops + occupants byte-equal across two runs (fixed a stale-occupancy leak on
stream toggle).

Browsers: keepers.js gained browse:true (faces the shelf via ry, no player-greet) + pedIndex +
seedKey. Each = a merged ped ~1 draw. 12 enter/exit cycles: count == min(occupancy,3) 12/12,
worst 61 draws <=350 (C's headroom holds), leak-free (0 GPU delta after warmup), disposed every
exit via keepers.disposeAll().

Consistency (D2): inside peds hidden on the street (0 rendered), re-emerge at the door.

F handoff verified by running F's exact interior_mode wiring (enter -> occupancyOf ->
browsePoints.slice(0,min(count,3)) -> keepers.spawn). index.html setShops door points need
shopId:s.id added. Closing-time handled by the dwell model (pending A's explicit ruling).

Evidence: docs/shots/laneD/r9_browsers_in_shop.jpg. Changed sim.js + keepers.js + test page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:21:10 +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, shopId: `e${i}s${side}`, 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>