Spaces:
Sleeping
Sleeping
Town Mode: close town view by default, build_district + place_road, bank/market/house, grow-one-town steering, behold-the-world reveal, tamed needle
b0d758d verified | // GODSEED — Genesis Log. Pure client-side replay: build the world at | |
| // features[0..K), then perform wish K's recorded thoughts + deltas again. | |
| // Live: /api/wishes + /api/wishes/{id} + /api/state. Dev: ?mock=1. | |
| // Deep link: book.html#w_000005 | |
| import { createPlanetApp } from "./planet/scene.js"; | |
| import { buildWishScript, ScriptRunner } from "./planet/replay.js"; | |
| import { latLonToDir, PLANET_R } from "./planet/util.js"; | |
| // Re-anchor + frame the town heart from the current world. | |
| function syncTown() { | |
| app.rig.setTownCenter(app.world.features); | |
| } | |
| const params = new URLSearchParams(location.search); | |
| const MOCK = params.has("mock"); | |
| const qs = MOCK ? "?mock=1" : ""; | |
| const els = { | |
| ledger: document.getElementById("ledger"), | |
| voiceDot: document.getElementById("voiceDot"), | |
| voiceWish: document.getElementById("voiceWish"), | |
| stream: document.getElementById("voiceStream"), | |
| epitaph: document.getElementById("epitaph"), | |
| epitaphText: document.getElementById("epitaphText"), | |
| status: document.getElementById("status"), | |
| hint: document.getElementById("replayHint"), | |
| }; | |
| const caret = document.createElement("span"); | |
| caret.className = "voice__caret"; | |
| function tickerPush(text, tone = "thought") { | |
| caret.remove(); | |
| let inner = els.stream.querySelector(".voice__innertext"); | |
| if (!inner) { | |
| inner = document.createElement("span"); | |
| inner.className = "voice__innertext"; | |
| els.stream.appendChild(inner); | |
| } | |
| const span = document.createElement("span"); | |
| span.className = `tone-${tone}`; | |
| span.textContent = text; | |
| inner.appendChild(span); | |
| inner.appendChild(caret); | |
| while (inner.children.length > 420) inner.firstChild.remove(); | |
| } | |
| let app; | |
| try { | |
| app = createPlanetApp(document.getElementById("scene"), { interactive: true }); | |
| } catch (err) { | |
| els.status.textContent = "this vessel cannot hold the world (WebGL unavailable)"; | |
| throw err; | |
| } | |
| app.start(); | |
| // left-side dark scrim so the log column reads over the bright planet rim + | |
| // gold trail (sits behind the canvas overlays, above the scene). | |
| if (!document.querySelector(".ledger-scrim")) { | |
| const scrim = document.createElement("div"); | |
| scrim.className = "ledger-scrim"; | |
| scrim.setAttribute("aria-hidden", "true"); | |
| document.body.insertBefore(scrim, document.querySelector(".vignette") || els.ledger); | |
| } | |
| // ---------------------------------------------------------------- data | |
| async function loadIndex() { | |
| if (MOCK) { | |
| const traces = await fetch("./mock/traces.json").then((r) => r.json()); | |
| const { FEED_WISH } = await import("./mock/feed.js"); | |
| const list = Object.values(traces).map((t, i) => ({ | |
| wish_id: t.wish_id, text: t.text, epitaph: t.epitaph, ts: t.submitted_at, | |
| epoch: i + 1, // grant order mirrors the API's enumerate(start=1) | |
| })); | |
| list.push({ | |
| wish_id: FEED_WISH.trace.wish_id, text: FEED_WISH.trace.text, | |
| epitaph: FEED_WISH.trace.epitaph, ts: 1718201100, epoch: list.length + 1, | |
| }); | |
| return { list, traces: { ...traces, [FEED_WISH.trace.wish_id]: FEED_WISH.trace }, feed: FEED_WISH }; | |
| } | |
| const list = await fetch("/api/wishes").then((r) => { | |
| if (!r.ok) throw new Error(`wishes ${r.status}`); | |
| return r.json(); | |
| }); | |
| return { list, traces: null, feed: null }; | |
| } | |
| async function loadState() { | |
| if (MOCK) { | |
| const state = await fetch("./mock/world.json").then((r) => r.json()); | |
| const { FEED_WISH } = await import("./mock/feed.js"); | |
| return { | |
| features: [...state.world.features, ...FEED_WISH.features], | |
| epoch: state.world.epoch + 1, | |
| }; | |
| } | |
| const state = await fetch("/api/state").then((r) => r.json()); | |
| return { features: state.world?.features ?? [], epoch: state.world?.epoch ?? 0 }; | |
| } | |
| async function loadTrace(ctx, wishId) { | |
| if (ctx.traces) return ctx.traces[wishId]; | |
| const r = await fetch(`/api/wishes/${encodeURIComponent(wishId)}`); | |
| if (!r.ok) throw new Error(`trace ${r.status}`); | |
| return r.json(); | |
| } | |
| // ---------------------------------------------------------------- replay | |
| const runner = new ScriptRunner({}); | |
| let phase = "reading"; | |
| let lastDescentDir = null; | |
| // The direction a feature landed at (descent target). Sky/weather have none. | |
| function featureDir(feature) { | |
| const a = feature?.args || {}; | |
| if (feature?.tool === "set_sky" || feature?.tool === "set_weather") return null; | |
| if (a.path && a.path.length) return latLonToDir(a.path[0][0], a.path[0][1]); | |
| if (a.lat != null && a.lon != null) return latLonToDir(a.lat, a.lon); | |
| return null; | |
| } | |
| function replayWish(trace, allFeatures) { | |
| runner.cancel(); | |
| els.stream.innerHTML = ""; | |
| els.epitaph.classList.remove("is-shown"); | |
| els.voiceWish.textContent = `“${trace.text}”`; | |
| els.voiceDot.classList.add("is-live"); | |
| phase = "reading"; | |
| lastDescentDir = null; | |
| // world at features[0..K) | |
| const mine = allFeatures.filter((f) => f.wish_id === trace.wish_id); | |
| const firstIdx = allFeatures.findIndex((f) => f.wish_id === trace.wish_id); | |
| const base = firstIdx === -1 ? allFeatures : allFeatures.slice(0, firstIdx); | |
| const features = mine.length ? mine : (trace.features ?? []); | |
| app.world.reset(base, app.simTime); | |
| app.rig.setMode("town"); // replays begin in the close town view | |
| syncTown(); | |
| runner.handlers = { | |
| started: () => {}, | |
| phase: () => {}, | |
| token: (e) => { | |
| const tone = e.text.startsWith("\n›") ? "obs" : phase === "reading" ? "reading" : "thought"; | |
| tickerPush(e.text, tone); | |
| }, | |
| call: (e) => { | |
| phase = "turn"; | |
| const a = e.call.args || {}; | |
| tickerPush(`\n⟡ ${e.call.tool}\n`, "call"); | |
| let target = null; | |
| if (a.path?.length) target = latLonToDir(a.path[0][0], a.path[0][1]).multiplyScalar(PLANET_R); | |
| else if (a.lat != null) target = latLonToDir(a.lat, a.lon).multiplyScalar(PLANET_R); | |
| app.rig.gaze(target, { hold: 4.5 }); | |
| }, | |
| delta: (e) => { | |
| const cue = app.world.applyFeature(e.feature, { animate: true, t: app.simTime }); | |
| if (e.feature.tool === "place_structure" || e.feature.tool === "build_district") { | |
| syncTown(); // the town heart follows each new building during the replay | |
| } | |
| if (cue.target) app.rig.gaze(cue.target.clone().multiplyScalar(PLANET_R), { hold: 4 }); | |
| else if (cue.wide) app.rig.gaze(null, { hold: 3.5 }); | |
| const dir = cue.target || featureDir(e.feature); | |
| if (dir) lastDescentDir = dir.clone().normalize(); | |
| }, | |
| granted: (e) => { | |
| els.epitaphText.textContent = e.epitaph; | |
| els.epitaph.classList.add("is-shown"); | |
| els.voiceDot.classList.remove("is-live"); | |
| app.world.sky.flash(0.3); | |
| // visit it: after the final delta, swoop down over the wish site, then rise | |
| if (lastDescentDir) { | |
| app.rig.setTerrain?.(app.world.terrain); | |
| app.rig.descend(lastDescentDir.clone().multiplyScalar(PLANET_R)); | |
| } else { | |
| app.rig.release(); | |
| } | |
| setTimeout(() => els.epitaph.classList.remove("is-shown"), 8500); | |
| }, | |
| }; | |
| runner.start(buildWishScript(trace, features), app.simTime); | |
| } | |
| app.onTick((t) => runner.update(t)); | |
| // ---------------------------------------------------------------- boot | |
| (async () => { | |
| try { | |
| const [ctx, state] = await Promise.all([loadIndex(), loadState()]); | |
| // newest first — a just-granted wish must appear at the TOP of the log. | |
| // epoch is the server's monotonic grant count (start=1); ts can be 0, so | |
| // epoch is the primary key, ts the tiebreak. | |
| const ord = (w, i) => (w.epoch ?? w.ts ?? i) * 1e6 + (w.ts ?? 0); | |
| const list = ctx.list | |
| .map((w, i) => ({ ...w, _ord: ord(w, i) })) | |
| .sort((a, b) => b._ord - a._ord); | |
| // world as it stands now (zero GPU, gorgeous by default). Count source is | |
| // unified with the landing page: epoch === number of granted wishes. | |
| app.world.reset(state.features, app.simTime); | |
| syncTown(); // open the log over the close town view (matches the landing page) | |
| const granted = state.epoch ?? list.length; | |
| els.status.textContent = `epoch ${granted} · ${granted} ${granted === 1 ? "wish" : "wishes"} granted`; | |
| if (!list.length) { | |
| const div = document.createElement("div"); | |
| div.className = "ledger__empty"; | |
| div.textContent = "No wishes yet. The god waits for the first voice."; | |
| els.ledger.appendChild(div); | |
| return; | |
| } | |
| const buttons = new Map(); | |
| list.forEach((w) => { | |
| const btn = document.createElement("button"); | |
| btn.className = "ledger__entry"; | |
| btn.innerHTML = ` | |
| <div class="ledger__epoch">epoch ${w.epoch ?? "—"}</div> | |
| <div class="ledger__wish"></div> | |
| <div class="ledger__epitaph"></div>`; | |
| btn.querySelector(".ledger__wish").textContent = `“${w.text}”`; | |
| btn.querySelector(".ledger__epitaph").textContent = w.epitaph ?? ""; | |
| btn.addEventListener("click", async () => { | |
| location.hash = w.wish_id; | |
| buttons.forEach((b) => b.classList.remove("is-active")); | |
| btn.classList.add("is-active"); | |
| els.hint.textContent = "replaying — the same words make the same world"; | |
| try { | |
| const trace = await loadTrace(ctx, w.wish_id); | |
| if (trace) replayWish(trace, state.features); | |
| } catch (err) { | |
| els.status.textContent = "that page of the log would not open"; | |
| console.error(err); | |
| } | |
| }); | |
| els.ledger.appendChild(btn); | |
| buttons.set(w.wish_id, btn); | |
| }); | |
| // deep link #w_000005 | |
| const target = location.hash.replace("#", ""); | |
| if (target && buttons.has(target)) buttons.get(target).click(); | |
| window.__godseed = { | |
| ready: true, app, | |
| tick: (s) => app.tick(s), | |
| seek: (t) => app.seek(t), | |
| pause: (v) => app.pause(v), | |
| stats: () => app.stats(), | |
| replay: (id) => buttons.get(id)?.click(), | |
| descend: (lat, lon, duration = 13) => { | |
| app.rig.setTerrain?.(app.world.terrain); | |
| app.rig.descend(latLonToDir(lat, lon).multiplyScalar(PLANET_R), { duration }); | |
| }, | |
| setCamera: (theta, phi, dist) => { | |
| Object.assign(app.rig, { | |
| theta, thetaT: theta, phi, phiT: phi, | |
| dist: dist ?? app.rig.dist, distT: dist ?? app.rig.distT, | |
| }); | |
| }, | |
| }; | |
| } catch (err) { | |
| els.status.textContent = "the log could not be opened"; | |
| console.error(err); | |
| } | |
| })(); | |