fix construction stuck-at-dirt: snap the shared clock on large drift + tab refocus (was a 0.02s/s crawl, useless after tab throttling)
09ce0b4 verified | // NEMOCITY — page shell. Boot the city, wire the HUD placards, then either: | |
| // live : GET /api/state -> EventSource /api/stream (SSE protocol) | |
| // mock : ?mock=1 -> web/mock/world.json (or inline genesis) + scripted feed | |
| // The deterministic engine owns all facts; this file only performs them. | |
| // The ONLY wall-clock reads on the sim path are the clock anchors below | |
| // (server_now in live mode, one Date.now in mock boot) — all motion, mock | |
| // scheduling, and construction stamping run on simTime. | |
| import * as THREE from "three"; | |
| const params = new URLSearchParams(location.search); | |
| const MOCK = params.has("mock"); | |
| const STATIC = params.has("static"); | |
| // ---------------------------------------------------------------- HUD els | |
| const $ = (id) => document.getElementById(id); | |
| const els = { | |
| pop: $("hud-pop"), | |
| buildings: $("hud-buildings"), | |
| traffic: $("hud-traffic"), | |
| trafficChip: $("chip-traffic"), | |
| clock: $("hud-clock"), | |
| visitors: $("hud-visitors"), | |
| queueChip: $("queue-chip"), | |
| lifecycle: $("lifecycle"), | |
| form: $("petition-form"), | |
| input: $("petition-input"), | |
| send: $("petition-send"), | |
| toast: $("toast"), | |
| tickerInner: $("ticker-inner"), | |
| alert: $("traffic-alert"), | |
| alertText: $("traffic-alert-text"), | |
| btnFix: $("btn-fix"), | |
| btnWalk: $("btn-walk"), | |
| btnPhoto: $("btn-photo"), | |
| btnSound: $("btn-sound"), | |
| btnHeatmap: $("btn-heatmap"), | |
| paperwork: $("paperwork"), | |
| paperworkToggle: $("paperwork-toggle"), | |
| paperworkBody: $("paperwork-body"), | |
| fpvUi: $("fpv-ui"), | |
| status: $("status"), | |
| }; | |
| function setStatus(text) { els.status.textContent = text; } | |
| setStatus("waking the city…"); | |
| // ---------------------------------------------------------------- constants | |
| // hand-seeded ./city/constants.js may land after this file in the parallel | |
| // build; the contract values double as fallbacks. | |
| const C = { CITY_EPOCH_S: 1781222400, DAY_S: 240, CELL: 4 }; | |
| let BUILDINGS = null; | |
| try { | |
| const m = await import("./city/constants.js"); | |
| for (const k of Object.keys(C)) if (m[k] != null) C[k] = m[k]; | |
| BUILDINGS = m.BUILDINGS ?? null; | |
| } catch { /* contract fallbacks above */ } | |
| // residents/duration mirror of the contract table — HUD fallbacks for when | |
| // world.stats()/constants.js aren't wired yet. | |
| const RES = { house: 4, townhouse: 6, apartments: 16 }; | |
| const DUR = { | |
| house: 20, townhouse: 20, apartments: 45, cafe: 20, shop: 20, market: 30, | |
| bank: 30, office: 30, tower: 45, school: 30, hospital: 45, fire_station: 30, | |
| warehouse: 30, factory: 45, park: 20, plaza: 20, stadium: 45, church: 30, town_hall: 45, | |
| }; | |
| function kindMeta(kind) { | |
| const row = BUILDINGS?.[kind]; | |
| return { | |
| residents: row?.residents ?? RES[kind] ?? 0, | |
| duration: row?.duration_s ?? row?.duration ?? DUR[kind] ?? 30, | |
| }; | |
| } | |
| // ---------------------------------------------------------------- boot scene | |
| let createCityApp = null; | |
| try { | |
| const sceneMod = await import("./city/scene.js"); | |
| createCityApp = sceneMod.createCityApp ?? sceneMod.createPlanetApp; // tolerate the unadapted copy | |
| } catch (err) { | |
| setStatus("the city renderer failed to load"); | |
| console.error(err); | |
| throw err; | |
| } | |
| let app; | |
| try { | |
| app = createCityApp($("scene"), { interactive: true }); | |
| } catch (err) { | |
| setStatus("WebGL unavailable — the city cannot render here"); | |
| console.error(err); | |
| throw err; | |
| } | |
| // ------------------------------------------------------- rig + fpv + post wiring | |
| const { CameraRig } = await import("./city/camera.js"); | |
| const rig = new CameraRig(app.camera, app.renderer.domElement); | |
| app.setRig(rig); | |
| rig.landing(); // T0-T10 choreography; any pointer/wheel input cancels it | |
| app.world.citizens?.setCamera?.(app.camera); | |
| let alertLive = null; // {name, until} — live traffic telemetry (traffic.onAlert) | |
| if (app.world.traffic) { | |
| app.world.traffic.onAlert = ({ streetName }) => { | |
| alertLive = { name: streetName || "Old Bridge", until: app.simTime + 40 }; | |
| }; | |
| } | |
| app.onTick(() => { | |
| app.post.setDaylight(app.world.daylightValue ?? 1); | |
| if (!app.fpv) { // FPV holds tilt-shift at 0 (hard requirement) | |
| app.post.setFocusY(rig.focusScreenY()); | |
| app.post.setTiltShift(rig.tiltShiftAmount()); | |
| } | |
| }); | |
| try { | |
| const { FPV, isTouchDevice } = await import("./city/fpv.js"); | |
| if (isTouchDevice()) { | |
| els.btnWalk.hidden = true; // v0: no mobile FPV | |
| } else { | |
| const fpv = new FPV(app.camera, app.renderer.domElement, app.world.ground, | |
| (pos) => rig.resume(pos)); | |
| fpv.bindKeys(app, rig); // owns the F-key toggle | |
| fpv.setRoadLookup(app.world.roadCells); | |
| // body.fpv + #fpv-ui follow the nemocity:fpv event; wrap enter/exit to emit it | |
| const baseEnter = fpv.enter.bind(fpv); | |
| const baseExit = fpv.exit.bind(fpv); | |
| fpv.enter = (p) => { | |
| baseEnter(p ?? rig.lookPoint()); | |
| if (fpv.active) window.dispatchEvent(new CustomEvent("nemocity:fpv", { detail: { active: true } })); | |
| }; | |
| fpv.exit = () => { | |
| const was = fpv.active; | |
| baseExit(); | |
| if (was) window.dispatchEvent(new CustomEvent("nemocity:fpv", { detail: { active: false } })); | |
| }; | |
| window.__fpv = fpv; | |
| } | |
| } catch (err) { | |
| console.warn("fpv unavailable:", err); | |
| } | |
| // ---------------------------------------------------------------- shared clock | |
| let anchorServer = null; | |
| let anchorPerf = null; | |
| function anchorClock(serverNow) { | |
| anchorServer = serverNow; | |
| anchorPerf = performance.now() / 1000; | |
| } | |
| function serverNowEst() { return anchorServer + performance.now() / 1000 - anchorPerf; } | |
| let warnedNoSetSim = false; | |
| function setSim(t, { quiet = false } = {}) { | |
| if (typeof app.setSimTime === "function") { app.setSimTime(t); return true; } | |
| if (t >= app.simTime && t - app.simTime < 900) { app.seek(t); app.pause(false); return true; } | |
| if (!warnedNoSetSim && !quiet) { | |
| warnedNoSetSim = true; | |
| console.warn("scene.js exposes no setSimTime(t) — shared clock left unsynced (integration hook)"); | |
| } | |
| return false; | |
| } | |
| // Keep the sim clock locked to the server clock. serverNowEst() is wall-clock | |
| // based (accurate even when rAF is throttled); app.simTime is advanced by the | |
| // render loop and FALLS BEHIND when the tab is backgrounded or the frame rate | |
| // dips (the dt cap). If it lags, a freshly-built building reads as "not started" | |
| // and sits at 0% dirt until the clock catches up (June 15 bug). So: gentle slew | |
| // for tiny drift (cars/day-night don't jump), but SNAP on large drift (after a | |
| // throttle gap) and whenever the tab regains focus. | |
| function syncClock() { | |
| if (!LIVE || anchorPerf == null) return; | |
| const target = serverNowEst() - C.CITY_EPOCH_S; | |
| const drift = target - app.simTime; | |
| if (Math.abs(drift) <= 1.0) return; | |
| if (Math.abs(drift) > 3) setSim(target, { quiet: true }); // snap (post-throttle) | |
| else setSim(app.simTime + Math.sign(drift) * 0.25, { quiet: true }); // gentle catch-up | |
| } | |
| setInterval(syncClock, 1000); | |
| document.addEventListener("visibilitychange", () => { if (!document.hidden) syncClock(); }); | |
| // ---------------------------------------------------------------- helpers | |
| const truncate = (s, n) => (s && s.length > n ? `${s.slice(0, n - 1)}…` : s ?? ""); | |
| function ordinal(n) { | |
| const v = n % 100; | |
| if (v >= 11 && v <= 13) return `${n}th`; | |
| return `${n}${["th", "st", "nd", "rd"][n % 10] ?? "th"}`; | |
| } | |
| function countWishes(features) { | |
| const s = new Set(); | |
| for (const f of features) if (f.wish_id !== "genesis") s.add(f.wish_id); | |
| return s.size; | |
| } | |
| const cellCenter = (cx, cz) => | |
| new THREE.Vector3(cx * C.CELL + C.CELL / 2, 0, cz * C.CELL + C.CELL / 2); | |
| // where a feature lands, from its args alone (the contract's grid formulas) | |
| function featureTarget(f) { | |
| const a = f?.args ?? {}; | |
| if (f?.tool === "place_building" && a.cx != null) { | |
| return new THREE.Vector3((a.cx + (a.w ?? 1) / 2) * C.CELL, 0, (a.cz + (a.d ?? 1) / 2) * C.CELL); | |
| } | |
| if (a.cells?.length) { | |
| const [cx, cz] = a.cells[a.cells.length >> 1]; | |
| return cellCenter(cx, cz); | |
| } | |
| return null; | |
| } | |
| function gaze(target, hold = 5) { | |
| if (!target) return; | |
| try { app.rig?.gaze?.(target.clone(), { hold }); } catch { /* rig not wired yet */ } | |
| } | |
| function fmtCall(tool, a) { | |
| if (tool === "place_building") { | |
| return `place_building · ${a.kind}${a.name ? ` "${a.name}"` : ""} @ (${a.cx},${a.cz}) ` + | |
| `${a.w ?? 1}x${a.d ?? 1}${a.floors ? ` · ${a.floors} fl` : ""}`; | |
| } | |
| if (tool === "lay_road") return `lay_road · ${a.klass}${a.name ? ` "${a.name}"` : ""} · ${a.cells?.length ?? 0} cells`; | |
| if (tool === "apply_fix") return `apply_fix · ${a.action}${a.name ? ` "${a.name}"` : ""} · ${a.cells?.length ?? 0} cells`; | |
| if (tool === "note") return `note · ${a.text ?? ""}`; | |
| return tool; | |
| } | |
| // ---------------------------------------------------------------- ticker | |
| const headlines = ["NEMOCITY IS LISTENING — ASK FOR A BUILDING"]; | |
| function renderTicker() { | |
| const joined = headlines.join(" +++ ") + " +++ "; | |
| els.tickerInner.textContent = ""; | |
| for (let i = 0; i < 2; i++) { | |
| const span = document.createElement("span"); | |
| span.className = "ticker__copy"; | |
| span.textContent = joined; | |
| els.tickerInner.appendChild(span); | |
| } | |
| els.tickerInner.style.animationDuration = `${Math.max(18, Math.min(90, joined.length * 0.22))}s`; | |
| } | |
| function headline(text) { | |
| if (!text) return; | |
| headlines.unshift(String(text).toUpperCase()); | |
| if (headlines.length > 6) headlines.pop(); | |
| renderTicker(); | |
| } | |
| renderTicker(); | |
| // ---------------------------------------------------------------- toast + queue | |
| let toastTimer = null; | |
| function toast(text, { warn = false, ms = 6000 } = {}) { | |
| els.toast.textContent = text; | |
| els.toast.classList.toggle("is-warn", warn); | |
| els.toast.hidden = false; | |
| clearTimeout(toastTimer); | |
| toastTimer = setTimeout(() => { els.toast.hidden = true; }, ms); | |
| } | |
| let queueLen = 0; | |
| function setQueue(length, minePos = null) { | |
| if (minePos != null) { | |
| els.queueChip.hidden = false; | |
| els.queueChip.textContent = `your petition is ${ordinal(minePos)} in line`; | |
| } else if (length > 0) { | |
| els.queueChip.hidden = false; | |
| els.queueChip.textContent = `${length} petition${length === 1 ? "" : "s"} in line`; | |
| } else { | |
| els.queueChip.hidden = true; | |
| } | |
| } | |
| // ---------------------------------------------------------------- odometer | |
| function setOdometer(el, value) { | |
| const str = String(Math.max(0, Math.floor(value))); | |
| if (el.dataset.v === str) return; | |
| el.dataset.v = str; | |
| while (el.children.length > str.length) el.firstChild.remove(); | |
| while (el.children.length < str.length) { | |
| const col = document.createElement("span"); | |
| col.className = "odo-digit"; | |
| const roll = document.createElement("span"); | |
| roll.className = "odo-roll"; | |
| for (let i = 0; i < 10; i++) { | |
| const d = document.createElement("span"); | |
| d.textContent = String(i); | |
| roll.appendChild(d); | |
| } | |
| col.appendChild(roll); | |
| el.insertBefore(col, el.firstChild); | |
| } | |
| [...el.children].forEach((col, i) => { | |
| col.firstChild.style.transform = `translateY(${-(+str[i]) * 10}%)`; | |
| }); | |
| } | |
| // ---------------------------------------------------------------- lifecycle chips | |
| const myWishes = new Set(); | |
| const chips = new Map(); // wish_id -> {el, label, state, timer} | |
| const lastTargets = new Map(); // wish_id -> Vector3 (jump-camera link) | |
| const lastNames = new Map(); // wish_id -> building name | |
| const wishTexts = new Map(); | |
| function chipRemove(id) { | |
| const c = chips.get(id); | |
| if (!c) return; | |
| clearTimeout(c.timer); | |
| c.el.remove(); | |
| chips.delete(id); | |
| } | |
| function chipFor(wishId) { | |
| let c = chips.get(wishId); | |
| if (c) return c; | |
| if (chips.size >= 3) { | |
| const order = [...chips.keys()]; | |
| const victim = order.find((id) => ["built", "rejected"].includes(chips.get(id).state)) ?? order[0]; | |
| chipRemove(victim); | |
| } | |
| const el = document.createElement("span"); | |
| el.className = "lc-chip"; | |
| const label = document.createElement("span"); | |
| label.className = "lc-chip__label"; | |
| el.appendChild(label); | |
| els.lifecycle.appendChild(el); | |
| c = { el, label, state: null, timer: null }; | |
| chips.set(wishId, c); | |
| return c; | |
| } | |
| function chipSet(wishId, state, extra = {}) { | |
| if (!wishId) return; | |
| const c = chipFor(wishId); | |
| c.state = state; | |
| c.el.className = `lc-chip is-${state}${myWishes.has(wishId) ? " is-mine" : ""}`; | |
| const texts = { | |
| queued: "queued", | |
| reading: "city hall is reading", | |
| approved: "permit approved", | |
| building: extra.name ? `under construction — ${extra.name}` : "under construction", | |
| built: extra.name ? `built — ${extra.name}` : "built", | |
| rejected: "declined", | |
| }; | |
| c.label.textContent = texts[state] ?? state; | |
| c.el.querySelector(".lc-chip__view")?.remove(); | |
| const target = lastTargets.get(wishId); | |
| if (target && (state === "building" || state === "built")) { | |
| const btn = document.createElement("button"); | |
| btn.type = "button"; | |
| btn.className = "lc-chip__view"; | |
| btn.textContent = "view"; | |
| btn.addEventListener("click", () => gaze(target, 8)); | |
| c.el.appendChild(btn); | |
| } | |
| clearTimeout(c.timer); | |
| c.timer = setTimeout(() => chipRemove(wishId), | |
| state === "built" || state === "rejected" ? 14000 : 150000); | |
| } | |
| // ---------------------------------------------------------------- paperwork | |
| const permits = new Map(); // wish_id -> <article> | |
| function paperworkAdd(wishId, plan, text) { | |
| const art = document.createElement("article"); | |
| art.className = "permit"; | |
| const head = document.createElement("header"); | |
| head.textContent = `${wishId}${text ? ` · "${truncate(text, 48)}"` : ""}`; | |
| const pre = document.createElement("pre"); | |
| pre.textContent = JSON.stringify(plan, null, 1); | |
| art.append(head, pre); | |
| els.paperworkBody.prepend(art); | |
| permits.set(wishId, art); | |
| while (els.paperworkBody.children.length > 8) els.paperworkBody.lastChild.remove(); | |
| if (!els.paperwork.classList.contains("is-open")) els.paperworkToggle.classList.add("has-new"); | |
| } | |
| function paperworkLine(wishId, line) { | |
| const art = permits.get(wishId); | |
| if (!art) return; | |
| const div = document.createElement("div"); | |
| div.className = "permit__line"; | |
| div.textContent = line; | |
| art.appendChild(div); | |
| } | |
| els.paperworkToggle.addEventListener("click", () => { | |
| const open = els.paperwork.classList.toggle("is-open"); | |
| els.paperworkToggle.setAttribute("aria-expanded", String(open)); | |
| els.paperworkToggle.classList.remove("has-new"); | |
| }); | |
| // ---------------------------------------------------------------- traffic alert | |
| let alertMock = null; // {name, until} — scripted alerts (mock feed) | |
| let alertFix = null; // {text, until} — "78 -> 41" banner after apply_fix | |
| function showAlert(text, { fix = false } = {}) { | |
| els.alert.hidden = false; | |
| els.alertText.textContent = text; | |
| els.alert.classList.toggle("is-fixed", fix); | |
| els.btnFix.hidden = fix; | |
| } | |
| function hideAlert() { els.alert.hidden = true; } | |
| function renderAlert(t) { | |
| if (alertFix) { | |
| if (t < alertFix.until) return showAlert(alertFix.text, { fix: true }); | |
| alertFix = null; | |
| } | |
| let name = null; | |
| try { | |
| const tr = app.world?.traffic; | |
| const al = typeof tr?.alert === "function" ? tr.alert() : null; | |
| if (al) name = al.name ?? String(al); | |
| } catch { /* traffic not wired yet */ } | |
| if (!name && alertMock && t < alertMock.until) name = alertMock.name; | |
| if (name) showAlert(`Traffic alert: ${name}`); | |
| else hideAlert(); | |
| } | |
| // ---------------------------------------------------------------- sound (synth, OFF by default) | |
| let audio = null; | |
| let soundOn = false; | |
| function makeAudio() { | |
| const ctx = new (window.AudioContext || window.webkitAudioContext)(); | |
| const master = ctx.createGain(); | |
| master.gain.value = 0; | |
| master.connect(ctx.destination); | |
| // traffic hum: looped seeded-noise through a lowpass (no Math.random) | |
| const len = ctx.sampleRate * 2; | |
| const buf = ctx.createBuffer(1, len, ctx.sampleRate); | |
| const data = buf.getChannelData(0); | |
| let s = 1234567; | |
| for (let i = 0; i < len; i++) { | |
| s = (s * 1664525 + 1013904223) >>> 0; | |
| data[i] = (s / 2 ** 32) * 2 - 1; | |
| } | |
| const src = ctx.createBufferSource(); | |
| src.buffer = buf; | |
| src.loop = true; | |
| const lp = ctx.createBiquadFilter(); | |
| lp.type = "lowpass"; | |
| lp.frequency.value = 220; | |
| lp.Q.value = 0.4; | |
| const humGain = ctx.createGain(); | |
| humGain.gain.value = 0.05; | |
| src.connect(lp).connect(humGain).connect(master); | |
| src.start(); | |
| return { | |
| ctx, master, | |
| chime() { // marimba-ish triad on a grant | |
| const t0 = ctx.currentTime; | |
| for (const [f, dt, g] of [[523.25, 0, 0.16], [784, 0.09, 0.11], [1046.5, 0.18, 0.07]]) { | |
| const o = ctx.createOscillator(); | |
| o.type = "sine"; | |
| o.frequency.value = f; | |
| const og = ctx.createGain(); | |
| og.gain.setValueAtTime(0, t0 + dt); | |
| og.gain.linearRampToValueAtTime(g, t0 + dt + 0.012); | |
| og.gain.exponentialRampToValueAtTime(0.0001, t0 + dt + 0.7); | |
| o.connect(og).connect(master); | |
| o.start(t0 + dt); | |
| o.stop(t0 + dt + 0.8); | |
| } | |
| }, | |
| }; | |
| } | |
| els.btnSound.addEventListener("click", () => { | |
| soundOn = !soundOn; | |
| els.btnSound.setAttribute("aria-pressed", String(soundOn)); | |
| audio ??= makeAudio(); | |
| audio.ctx.resume(); | |
| audio.master.gain.linearRampToValueAtTime(soundOn ? 1 : 0, audio.ctx.currentTime + 0.4); | |
| }); | |
| // ---------------------------------------------------------------- modes | |
| function setPhoto(v) { | |
| const on = v ?? !document.body.classList.contains("photo"); | |
| document.body.classList.toggle("photo", on); | |
| } | |
| els.btnPhoto.addEventListener("click", () => setPhoto()); | |
| function enterWalk() { | |
| // integration wires fpv.js to this event (and may also expose window.__fpv) | |
| window.dispatchEvent(new CustomEvent("nemocity:walk")); | |
| window.__fpv?.enter?.(); | |
| } | |
| els.btnWalk.addEventListener("click", enterWalk); | |
| window.addEventListener("nemocity:fpv", (e) => { | |
| const active = !!e.detail?.active; | |
| document.body.classList.toggle("fpv", active); | |
| els.fpvUi.hidden = !active; | |
| }); | |
| const heat = { desired: false, applied: false }; | |
| els.btnHeatmap.addEventListener("click", () => { | |
| heat.desired = !heat.desired; | |
| els.btnHeatmap.setAttribute("aria-pressed", String(heat.desired)); | |
| if (heat.desired && !app.world?.traffic) { | |
| toast("traffic telemetry is still warming up — the overlay appears once cars commute"); | |
| } | |
| }); | |
| window.addEventListener("keydown", (e) => { | |
| if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; | |
| if (e.key === "f" || e.key === "F") enterWalk(); | |
| else if (e.key === "p" || e.key === "P") setPhoto(); | |
| else if (e.key === "Escape" && document.body.classList.contains("photo")) setPhoto(false); | |
| }); | |
| // ---------------------------------------------------------------- placeholders | |
| const PLACEHOLDERS = [ | |
| "a ramen shop near the park…", | |
| "a fire station downtown…", | |
| "a glass tower by the river…", | |
| "homes for forty new families…", | |
| "a stadium on the east bank…", | |
| "a little chapel on Elm St…", | |
| "a school for the new quarter…", | |
| ]; | |
| let phIdx = 0; | |
| setInterval(() => { | |
| if (document.activeElement === els.input || els.input.value) return; | |
| phIdx = (phIdx + 1) % PLACEHOLDERS.length; | |
| els.input.placeholder = PLACEHOLDERS[phIdx]; | |
| }, 4200); | |
| setTimeout(() => { | |
| els.input.classList.add("pulse"); | |
| setTimeout(() => els.input.classList.remove("pulse"), 2400); | |
| }, 10000); | |
| // ---------------------------------------------------------------- event applier | |
| let LIVE = false; | |
| let stateVersion = null; | |
| let epochCount = 0; | |
| let sawDelta = false; | |
| function setEpoch(n) { epochCount = n ?? epochCount; } | |
| function applyDelta(feature) { | |
| if (!feature) return; | |
| sawDelta = true; | |
| if (LIVE && stateVersion != null) stateVersion += 1; | |
| try { | |
| app.world.applyFeature(feature, { animate: true, t: app.simTime }); | |
| } catch (err) { | |
| console.warn("applyFeature failed:", feature.tool, err); | |
| } | |
| const a = feature.args ?? {}; | |
| const target = featureTarget(feature); | |
| if (target) { | |
| lastTargets.set(feature.wish_id, target); | |
| gaze(target, 5.5); | |
| } | |
| if (feature.tool === "place_building") { | |
| if (a.name) lastNames.set(feature.wish_id, a.name); | |
| chipSet(feature.wish_id, "building", { name: a.name }); | |
| } else if (feature.tool === "apply_fix") { | |
| headline(a.diagnosis || "the City Engineer applied a fix"); | |
| const b = a.metrics_before?.traffic_index; | |
| const p = a.metrics_predicted?.traffic_index; | |
| alertFix = { | |
| text: b != null && p != null ? `City Engineer: fix applied — index ${b} -> ${p}` : "City Engineer: fix applied", | |
| until: app.simTime + 9, | |
| }; | |
| alertMock = null; | |
| } else if (feature.tool === "note" && a.text) { | |
| headline(a.text); | |
| } | |
| } | |
| function handleEvent(m) { | |
| switch (m.type) { | |
| case "your_turn": // ZeroGPU dance: only the petitioner's browser may begin the grant | |
| if (!LIVE) break; | |
| if (myWishes.has(m.wish_id)) invokeGrant(m.wish_id); | |
| else pendingTurns.add(m.wish_id); | |
| break; | |
| case "hello": | |
| if (m.server_now != null) anchorClock(m.server_now); | |
| if (LIVE && stateVersion != null && m.world_version !== stateVersion) loadState().catch(() => {}); | |
| if (m.epoch != null) setEpoch(m.epoch); | |
| break; | |
| case "queue": | |
| queueLen = m.length ?? 0; | |
| setQueue(queueLen); | |
| break; | |
| case "wish_started": | |
| wishTexts.set(m.wish_id, m.text ?? ""); | |
| chipSet(m.wish_id, "reading"); | |
| setStatus(`city hall is reading — "${truncate(m.text, 56)}"`); | |
| break; | |
| case "plan": // City Hall paperwork: the raw validated Nemotron JSON | |
| paperworkAdd(m.wish_id, m.plan, wishTexts.get(m.wish_id)); | |
| chipSet(m.wish_id, "approved"); | |
| break; | |
| case "tool_call": { | |
| paperworkLine(m.wish_id, fmtCall(m.tool, m.args ?? {})); | |
| const tgt = featureTarget({ tool: m.tool, args: m.args }); | |
| if (tgt) gaze(tgt, 5); | |
| break; | |
| } | |
| case "world_delta": | |
| applyDelta(m.feature); | |
| break; | |
| case "wish_granted": | |
| setEpoch(m.epoch != null ? m.epoch : epochCount + 1); | |
| headline(m.epitaph); | |
| chipSet(m.wish_id, "built", { name: lastNames.get(m.wish_id) }); | |
| if (myWishes.has(m.wish_id)) toast("granted — watch the crane"); | |
| if (soundOn) audio?.chime(); | |
| setStatus(LIVE ? "live · city hall is listening" : "demo city · scripted petitions"); | |
| break; | |
| case "wish_rejected": | |
| chipSet(m.wish_id, "rejected"); | |
| if (!m.wish_id || myWishes.has(m.wish_id)) { | |
| toast(m.reason || "city hall declines this petition.", { warn: true }); | |
| } | |
| break; | |
| case "heartbeat": | |
| if (m.server_now != null) anchorClock(m.server_now); | |
| break; | |
| case "traffic_alert": // mock feed only — live alerts come from traffic telemetry | |
| alertMock = { name: m.name || "Old Bridge", until: app.simTime + 40 }; | |
| break; | |
| } | |
| } | |
| // ---------------------------------------------------------------- script runner | |
| // Generic performer for feed.js scripts — used by ?mock=1 AND by the live | |
| // "welcome build" (a crane is ALWAYS rising when a judge arrives). | |
| let feedMod = null; | |
| const played = { active: null, appliedIds: new Set() }; | |
| const mockState = { enabled: false, nextAt: Infinity, idx: 0, pendingText: null, fixDone: false }; | |
| function playScript(script) { | |
| played.active = { script, start: app.simTime, i: 0 }; | |
| } | |
| function emitScript(ev, script) { | |
| if (ev.type === "world_delta") { | |
| const f = ev.feature; | |
| if (!f || played.appliedIds.has(f.id)) return; // replays skip placement — cells already taken | |
| played.appliedIds.add(f.id); | |
| // stamp t now so construction progress starts at this shared moment | |
| handleEvent({ ...ev, feature: { ...f, t: C.CITY_EPOCH_S + app.simTime } }); | |
| return; | |
| } | |
| if (ev.type === "wish_started" && script.textOverride) { | |
| handleEvent({ ...ev, text: script.textOverride }); | |
| return; | |
| } | |
| handleEvent(ev); | |
| } | |
| app.onTick((t) => { | |
| const a = played.active; | |
| if (a) { | |
| const evs = a.script.events; | |
| while (a.i < evs.length && t - a.start >= evs[a.i].at) { | |
| emitScript(evs[a.i].ev, a.script); | |
| a.i += 1; | |
| } | |
| if (a.i >= evs.length) played.active = null; | |
| } else if (mockState.enabled && t >= mockState.nextAt) { | |
| startNextScript(); | |
| } | |
| }); | |
| function pickScript(text) { | |
| const all = [...feedMod.SCRIPTS, feedMod.FIX_SCRIPT]; | |
| return all.find((s) => s.match && new RegExp(s.match, "i").test(text)) ?? feedMod.SCRIPTS[0]; | |
| } | |
| function startNextScript() { | |
| if (!feedMod) return; | |
| let sc = null; | |
| if (mockState.pendingText) { | |
| sc = { ...pickScript(mockState.pendingText), textOverride: mockState.pendingText }; | |
| myWishes.add(sc.wish_id); | |
| mockState.pendingText = null; | |
| setQueue(0); | |
| } else { | |
| const rotation = [...feedMod.SCRIPTS, feedMod.FIX_SCRIPT]; | |
| sc = rotation[mockState.idx % rotation.length]; | |
| mockState.idx += 1; | |
| if (sc === feedMod.FIX_SCRIPT && mockState.fixDone) { | |
| sc = rotation[mockState.idx % rotation.length]; | |
| mockState.idx += 1; | |
| } | |
| } | |
| if (sc === feedMod.FIX_SCRIPT || sc.wish_id === feedMod.FIX_SCRIPT.wish_id) mockState.fixDone = true; | |
| playScript(sc); | |
| mockState.nextAt = app.simTime + 45; // a scripted petition every ~45 s keeps the demo city performing | |
| } | |
| // relocate the welcome cafe to a cell the live city hasn't claimed | |
| function patchWelcome(welcome) { | |
| const occ = new Set(); | |
| for (const f of app.world?.features ?? []) { | |
| const a = f.args ?? {}; | |
| if (f.tool === "place_building" && a.cx != null) { | |
| for (let x = 0; x < (a.w ?? 1); x++) { | |
| for (let z = 0; z < (a.d ?? 1); z++) occ.add(`${a.cx + x},${a.cz + z}`); | |
| } | |
| } | |
| for (const [cx, cz] of a.cells ?? []) occ.add(`${cx},${cz}`); | |
| } | |
| const water = (cx, cz) => (cz <= -5 ? [7, 8] : cz <= 3 ? [8, 9] : [9, 10]).includes(cx); | |
| const spot = [[2, 1], [3, 1], [4, 1], [1, -4], [-3, -1], [2, -7]] | |
| .find(([cx, cz]) => !occ.has(`${cx},${cz}`) && !water(cx, cz)); | |
| if (!spot) return null; | |
| const sc = structuredClone(welcome); | |
| sc.wish_id = `${welcome.wish_id}_live`; | |
| for (const e of sc.events) { | |
| if (e.ev.wish_id) e.ev.wish_id = sc.wish_id; | |
| const f = e.ev.feature; | |
| if (f) { | |
| f.id = `${f.id}_live`; | |
| f.wish_id = sc.wish_id; | |
| } | |
| const a = e.ev.args ?? f?.args; | |
| if (a && a.cx != null) { a.cx = spot[0]; a.cz = spot[1]; } | |
| } | |
| return sc; | |
| } | |
| // ---------------------------------------------------------------- live mode | |
| const pendingTurns = new Set(); | |
| let gradioClient = null; | |
| // Browser-invoked granting (ZeroGPU): the GPU call must run inside OUR gradio | |
| // request so the HF auth headers carry the visitor's quota. When the SSE says | |
| // it's our petition's turn, call the named endpoint with the one-time token. | |
| async function invokeGrant(wishId) { | |
| setStatus("city hall turns to your petition…"); | |
| try { | |
| // the token route is cookie-bound — never broadcast over SSE | |
| const tr = await fetch(`/api/turn?wish_id=${encodeURIComponent(wishId)}`, { | |
| credentials: "same-origin", | |
| }); | |
| if (!tr.ok) { | |
| if (tr.status === 403) return; // not our petition (a stranger's turn) | |
| throw new Error(`turn ${tr.status}`); | |
| } | |
| const { token } = await tr.json(); | |
| if (!gradioClient) { | |
| const { Client } = await import("https://cdn.jsdelivr.net/npm/@gradio/client/+esm"); | |
| gradioClient = await Client.connect(window.location.origin); | |
| } | |
| const r = await gradioClient.predict("/grant", { wish_id: wishId, token }); | |
| const out = Array.isArray(r?.data) ? r.data[0] : r?.data; | |
| if (out && out.ok === false && out.reason) toast(out.reason, { warn: true }); | |
| } catch (err) { | |
| console.error("grant invocation failed", err); | |
| toast("the permit office stalled — sign in to Hugging Face, then petition again", { warn: true }); | |
| } | |
| } | |
| async function loadState() { | |
| const res = await fetch("/api/state"); | |
| if (!res.ok) throw new Error(`state ${res.status}`); | |
| const state = await res.json(); | |
| stateVersion = state.world?.version ?? null; | |
| if (state.server_now != null) { | |
| anchorClock(state.server_now); | |
| setSim(serverNowEst() - C.CITY_EPOCH_S); | |
| } | |
| const features = state.world?.features ?? []; | |
| app.world.reset(features, app.simTime); | |
| setEpoch(state.world?.epoch ?? countWishes(features)); | |
| queueLen = state.queue?.length ?? 0; | |
| setQueue(queueLen); | |
| return state; | |
| } | |
| async function bootLive() { | |
| try { | |
| await loadState(); | |
| } catch (err) { | |
| console.warn("no live backend:", err); | |
| setStatus("demo city · no backend"); | |
| return bootMock(); | |
| } | |
| LIVE = true; | |
| feedMod = await import("./mock/feed.js").catch(() => null); | |
| const es = new EventSource("/api/stream"); | |
| es.onmessage = (e) => { | |
| let m; | |
| try { m = JSON.parse(e.data); } catch { return; } | |
| handleEvent(m); | |
| }; | |
| es.onerror = () => setStatus("the line to city hall flickers · reconnecting"); | |
| es.addEventListener("open", () => setStatus("live · city hall is listening")); | |
| setStatus("live · city hall is listening"); | |
| // welcome build: if the queue is idle, a scripted crane rises client-side | |
| // (visual only — never written to the server) within the first 10 s | |
| setTimeout(() => { | |
| if (sawDelta || queueLen > 0 || played.active || !feedMod?.WELCOME) return; | |
| const sc = patchWelcome(feedMod.WELCOME); | |
| if (sc) playScript(sc); | |
| }, 5500); | |
| } | |
| // ---------------------------------------------------------------- mock mode | |
| async function bootMock() { | |
| LIVE = false; | |
| feedMod = await import("./mock/feed.js"); | |
| let features = null; | |
| try { | |
| const r = await fetch("./mock/world.json"); | |
| if (r.ok) { | |
| const j = await r.json(); | |
| features = j.world?.features ?? j.features ?? null; | |
| } | |
| } catch { /* world.json is generated later by tools/gen_web.py */ } | |
| features ??= feedMod.GENESIS_FEATURES; | |
| const t0 = params.has("t") | |
| ? Math.max(0, parseFloat(params.get("t")) || 0) | |
| : Math.max(0, Date.now() / 1000 - C.CITY_EPOCH_S); // the mock clock anchor (sole wall read) | |
| setSim(t0); | |
| app.world.reset(features, app.simTime); | |
| setEpoch(countWishes(features)); | |
| setStatus(MOCK ? "demo city · ?mock=1" : "demo city · scripted petitions"); | |
| if (!STATIC) { | |
| mockState.enabled = true; | |
| mockState.nextAt = app.simTime + 4; // welcome build inside the first 10 s | |
| } | |
| } | |
| function mockPetition(text) { | |
| els.input.value = ""; | |
| mockState.pendingText = text.slice(0, 160); | |
| mockState.nextAt = Math.min(mockState.nextAt, app.simTime + 3); | |
| setQueue(0, 1); | |
| toast("demo city — city hall will read your words"); | |
| } | |
| // ---------------------------------------------------------------- petition form | |
| els.form.addEventListener("submit", async (e) => { | |
| e.preventDefault(); | |
| const text = els.input.value.trim(); | |
| if (!text) return; | |
| if (!LIVE) return mockPetition(text); | |
| els.send.disabled = true; | |
| try { | |
| const res = await fetch("/api/wish", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ text }), | |
| }); | |
| const body = await res.json().catch(() => ({})); | |
| if (res.ok) { | |
| els.input.value = ""; | |
| if (body.wish_id) { | |
| myWishes.add(body.wish_id); | |
| chipSet(body.wish_id, "queued"); | |
| if (pendingTurns.has(body.wish_id)) { | |
| pendingTurns.delete(body.wish_id); | |
| invokeGrant(body.wish_id); | |
| } | |
| } | |
| setQueue(queueLen, body.position ?? 1); | |
| toast("city hall has your petition — stay on the page; your visit powers the build"); | |
| } else if (res.status === 429) { | |
| toast(body.detail || body.reason || "the queue is full right now — try again in a little while", { warn: true }); | |
| } else { | |
| toast(body.detail || body.reason || "city hall declines this petition.", { warn: true }); | |
| } | |
| } catch { | |
| toast("the petition was lost in the mail — try again", { warn: true }); | |
| } finally { | |
| els.send.disabled = false; | |
| } | |
| }); | |
| // ---------------------------------------------------------------- fix button | |
| els.btnFix.addEventListener("click", async () => { | |
| if (!LIVE) { | |
| if (mockState.fixDone) return toast("the Engineer already widened the crossing — traffic is settling"); | |
| if (played.active) return toast("the Engineer is mid-shift — one moment"); | |
| mockState.fixDone = true; | |
| playScript(feedMod.FIX_SCRIPT); | |
| return; | |
| } | |
| els.btnFix.disabled = true; | |
| try { | |
| const res = await fetch("/api/fix", { method: "POST" }); | |
| const body = await res.json().catch(() => ({})); | |
| if (res.ok) { | |
| if (body.wish_id) { | |
| myWishes.add(body.wish_id); | |
| if (pendingTurns.has(body.wish_id)) { | |
| pendingTurns.delete(body.wish_id); | |
| invokeGrant(body.wish_id); | |
| } | |
| } | |
| toast("the City Engineer is reading the telemetry…"); | |
| } else if (res.status === 409) { | |
| toast(body.detail || "Traffic is flowing smoothly — no fix needed."); | |
| } else { | |
| toast(body.detail || "the Engineer is busy — try again soon", { warn: true }); | |
| } | |
| } catch { | |
| toast("could not reach the City Engineer — try again", { warn: true }); | |
| } finally { | |
| els.btnFix.disabled = false; | |
| } | |
| }); | |
| // ---------------------------------------------------------------- HUD loop | |
| function hudTick() { | |
| const t = app.simTime; | |
| const dayH = ((((t % C.DAY_S) + C.DAY_S) % C.DAY_S) / C.DAY_S) * 24; | |
| els.clock.textContent = | |
| `${String(Math.floor(dayH)).padStart(2, "0")}:${String(Math.floor((dayH % 1) * 60)).padStart(2, "0")}`; | |
| const w = app.world; | |
| let s = null; | |
| try { s = w?.stats?.(); } catch { /* not wired yet */ } | |
| const feats = w?.features ?? []; | |
| const blds = feats.filter((f) => f.tool === "place_building"); | |
| let pop = s?.population; | |
| if (pop == null) { | |
| pop = 0; | |
| for (const f of blds) { | |
| const meta = kindMeta(f.args?.kind); | |
| if (!meta.residents) continue; | |
| const p = f.t ? Math.min(1, Math.max(0, (C.CITY_EPOCH_S + t - f.t) / meta.duration)) : 1; | |
| pop += meta.residents * p; | |
| } | |
| } | |
| setOdometer(els.pop, pop); | |
| els.buildings.textContent = String(s?.buildings ?? blds.length); | |
| els.visitors.textContent = `built by ${epochCount} visitor${epochCount === 1 ? "" : "s"}`; | |
| const tr = w?.traffic; // may be absent until integration — every read is guarded | |
| let idx = null; | |
| try { | |
| if (typeof tr?.index === "function") idx = tr.index(); | |
| else if (typeof tr?.index === "number") idx = tr.index; | |
| else if (typeof tr?.trafficIndex === "number") idx = tr.trafficIndex; | |
| } catch { /* not wired yet */ } | |
| els.traffic.textContent = idx == null ? "–" : String(Math.round(idx)); | |
| els.trafficChip.classList.toggle("is-jam", idx != null && idx >= 70); | |
| els.trafficChip.classList.toggle("is-warn", idx != null && idx >= 40 && idx < 70); | |
| if (tr?.setHeatmap && heat.applied !== heat.desired) { | |
| try { tr.setHeatmap(heat.desired); heat.applied = heat.desired; } catch { /* retry next loop */ } | |
| } | |
| renderAlert(t); | |
| } | |
| setInterval(hudTick, 200); | |
| // ---------------------------------------------------------------- harness API | |
| window.__app = { | |
| ready: false, | |
| app, | |
| get world() { return app.world; }, | |
| tick: (s) => app.tick(s), | |
| seek: (t) => app.seek(t), | |
| pause: (v = true) => app.pause(v), | |
| setSim, | |
| stats: () => { | |
| let s = {}; | |
| try { s = app.stats?.() ?? {}; } catch { /* scene stats not wired */ } | |
| return { ...s, simTime: +app.simTime.toFixed(2), epoch: epochCount, headlines: headlines.length }; | |
| }, | |
| setPhoto, | |
| setHeatmap: (v) => { | |
| heat.desired = !!v; | |
| els.btnHeatmap.setAttribute("aria-pressed", String(!!v)); | |
| }, | |
| gazeCell: (cx, cz, hold = 30) => gaze(cellCenter(cx, cz), hold), | |
| runScript: (i = 0) => { if (feedMod) playScript(feedMod.SCRIPTS[i] ?? feedMod.SCRIPTS[0]); }, | |
| runFix: () => { if (feedMod) { mockState.fixDone = true; playScript(feedMod.FIX_SCRIPT); } }, | |
| enterWalk, | |
| }; | |
| // ---------------------------------------------------------------- start | |
| (MOCK ? bootMock() : bootLive()) | |
| .then(() => { window.__app.ready = true; }) | |
| .catch((err) => { | |
| setStatus("the city failed to load"); | |
| console.error(err); | |
| }); | |
| app.start(); | |