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 — main page. Boot the planet, wire the HUD, then either: | |
| // live : GET /api/state → EventSource /api/stream (SSE protocol) | |
| // mock : ?mock=1 → web/mock/world.json + scripted looping wish feed | |
| // The deterministic engine owns the facts; this file only performs them. | |
| import { createPlanetApp } from "./planet/scene.js"; | |
| import { buildWishScript, ScriptRunner } from "./planet/replay.js"; | |
| import { latLonToDir, PLANET_R, ordinal, clamp, townCenter } from "./planet/util.js"; | |
| const params = new URLSearchParams(location.search); | |
| const MOCK = params.has("mock"); | |
| const STATIC = params.has("static"); | |
| const UPTO = params.has("upto") ? Math.max(0, parseInt(params.get("upto"), 10) || 0) : null; | |
| // ---------------------------------------------------------------- HUD | |
| const els = { | |
| epoch: document.getElementById("epoch"), | |
| voiceDot: document.getElementById("voiceDot"), | |
| voiceWish: document.getElementById("voiceWish"), | |
| stream: document.getElementById("voiceStream"), | |
| epitaph: document.getElementById("epitaph"), | |
| epitaphText: document.getElementById("epitaphText"), | |
| epitaphLabel: document.getElementById("epitaphLabel"), | |
| epitaphDescend: document.getElementById("epitaphDescend"), | |
| queueChip: document.getElementById("queueChip"), | |
| form: document.getElementById("wishForm"), | |
| input: document.getElementById("wishInput"), | |
| send: document.getElementById("wishSend"), | |
| hint: document.getElementById("altarHint"), | |
| status: document.getElementById("status"), | |
| behold: document.getElementById("beholdToggle"), | |
| }; | |
| const HINT_DEFAULT = "the god grants one wish at a time"; | |
| let hintTimer = null; | |
| function hint(text, { warn = false, revert = 6000 } = {}) { | |
| els.hint.textContent = text; | |
| els.hint.classList.toggle("is-warn", warn); | |
| clearTimeout(hintTimer); | |
| if (revert) { | |
| hintTimer = setTimeout(() => { | |
| els.hint.textContent = HINT_DEFAULT; | |
| els.hint.classList.remove("is-warn"); | |
| }, revert); | |
| } | |
| } | |
| const caret = document.createElement("span"); | |
| caret.className = "voice__caret"; | |
| function tickerClear() { | |
| els.stream.innerHTML = ""; | |
| } | |
| 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(); | |
| } | |
| function tickerIdle() { | |
| caret.remove(); | |
| els.voiceDot.classList.remove("is-live"); | |
| } | |
| function tickerLive(wishText) { | |
| els.voiceDot.classList.add("is-live"); | |
| els.voiceWish.textContent = wishText ? `“${wishText}”` : ""; | |
| } | |
| let epitaphTimer = null; | |
| function showEpitaph(text, label = "the wish is granted") { | |
| els.epitaphText.textContent = text; | |
| els.epitaphLabel.textContent = label; | |
| els.epitaph.classList.add("is-shown"); | |
| clearTimeout(epitaphTimer); | |
| // hold long enough for the eye to find the "descend ↓" affordance | |
| epitaphTimer = setTimeout(() => { | |
| els.epitaph.classList.remove("is-shown"); | |
| if (els.epitaphDescend) els.epitaphDescend.hidden = true; | |
| }, 9000); | |
| } | |
| function setEpoch(epoch, granted = epoch) { | |
| els.epoch.textContent = `Epoch ${epoch} · ${granted} ${granted === 1 ? "wish" : "wishes"} granted`; | |
| } | |
| function setQueue(length, mine = null) { | |
| if (mine != null) { | |
| els.queueChip.hidden = false; | |
| els.queueChip.textContent = `your wish is ${ordinal(mine)} in line`; | |
| } else if (length > 0) { | |
| els.queueChip.hidden = false; | |
| els.queueChip.textContent = `${length} ${length === 1 ? "wish waits" : "wishes wait"}`; | |
| } else { | |
| els.queueChip.hidden = true; | |
| } | |
| } | |
| function fmtCall(call) { | |
| const a = call.args || {}; | |
| const bits = []; | |
| if (a.kind) bits.push(a.kind); | |
| if (a.palette) bits.push(a.palette); | |
| if (a.lat != null) bits.push(`${(+a.lat).toFixed(0)}°, ${(+a.lon).toFixed(0)}°`); | |
| if (a.path) bits.push(`${a.path.length} waypoints`); | |
| if (a.radius_deg != null) bits.push(`r ${(+a.radius_deg).toFixed(0)}°`); | |
| if (a.height != null) bits.push(`h ${a.height}`); | |
| if (a.depth != null) bits.push(`d ${a.depth}`); | |
| if (a.intensity != null) bits.push(`i ${a.intensity}`); | |
| if (a.moons != null) bits.push(`${a.moons} moon${a.moons === 1 ? "" : "s"}`); | |
| if (a.style) bits.push(a.style); | |
| return `\n⟡ ${call.tool} · ${bits.join(" · ")}\n`; | |
| } | |
| function callTarget(call) { | |
| const a = call.args || {}; | |
| if (call.tool === "set_sky" || call.tool === "set_weather") return null; | |
| if (call.tool === "inscribe_wish") return null; // delta will refine (stone seats by seed) | |
| if (a.path && a.path.length) return latLonToDir(a.path[0][0], a.path[0][1]).multiplyScalar(PLANET_R); | |
| if (a.lat != null && a.lon != null) return latLonToDir(a.lat, a.lon).multiplyScalar(PLANET_R); | |
| return null; | |
| } | |
| // The direction on the surface a feature landed at (for the descent target). | |
| // Sky/weather have no place, so they don't count toward "where to visit". | |
| 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; | |
| } | |
| // "descend ↓" affordance on the epitaph card → swoop down over the wish site. | |
| function armDescent(dir) { | |
| if (!els.epitaphDescend) return; | |
| if (dir) { | |
| lastDescentDir = dir.clone(); | |
| els.epitaphDescend.hidden = false; | |
| } else if (!lastDescentDir) { | |
| els.epitaphDescend.hidden = true; | |
| } | |
| } | |
| // ---------------------------------------------------------------- boot | |
| let app; | |
| try { | |
| app = createPlanetApp(document.getElementById("scene"), { interactive: true }); | |
| } catch (err) { | |
| els.status.textContent = "this vessel cannot hold the world (WebGL unavailable)"; | |
| console.error(err); | |
| throw err; | |
| } | |
| // last surface direction a wish landed at — the descent target. | |
| let lastDescentDir = null; | |
| if (els.epitaphDescend) { | |
| els.epitaphDescend.addEventListener("click", () => { | |
| if (!lastDescentDir) return; | |
| app.rig.setTerrain?.(app.world.terrain); // clear live terrain analytically | |
| app.rig.descend(lastDescentDir.clone().multiplyScalar(PLANET_R)); | |
| }); | |
| } | |
| // Re-anchor the town heart from the current world (camera follows it as it | |
| // densifies). Called on every state load + world_delta. | |
| function syncTown() { | |
| app.rig.setTownCenter(app.world.features); | |
| } | |
| // "behold the world ↕" — pull back to the full planet; "↻ return to the town" — | |
| // drop back into the close town view. Reflects the rig's current mode. | |
| function updateBeholdLabel() { | |
| if (!els.behold) return; | |
| const world = app.rig.mode === "world"; | |
| els.behold.classList.toggle("is-world", world); | |
| els.behold.setAttribute("aria-pressed", world ? "true" : "false"); | |
| els.behold.querySelector(".behold__label").textContent = | |
| world ? "return to the town" : "behold the world"; | |
| } | |
| if (els.behold) { | |
| els.behold.addEventListener("click", () => { | |
| app.rig.beholdWorld(); | |
| updateBeholdLabel(); | |
| }); | |
| updateBeholdLabel(); | |
| } | |
| const featuresByWish = (features, upto) => { | |
| if (upto == null) return features; | |
| const order = []; | |
| const seen = new Set(); | |
| for (const f of features) { | |
| if (!seen.has(f.wish_id)) { seen.add(f.wish_id); order.push(f.wish_id); } | |
| } | |
| const keep = new Set(order.slice(0, upto + 1)); // genesis is index 0 | |
| return features.filter((f) => keep.has(f.wish_id)); | |
| }; | |
| function applyDevOverrides() { | |
| if (params.has("sky")) { | |
| app.world.sky.set(params.get("sky"), parseFloat(params.get("stars") ?? "0.8"), | |
| parseInt(params.get("moons") ?? "1", 10), { animate: false }); | |
| } | |
| if (params.has("weather")) { | |
| app.world.weather.set(params.get("weather"), parseFloat(params.get("wi") ?? "0.6"), { animate: false }); | |
| } | |
| } | |
| 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; | |
| } | |
| // ---------------------------------------------------------------- live mode | |
| async function bootLive() { | |
| let stateVersion = null; | |
| let readingPhase = 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; | |
| app.world.reset(state.world?.features ?? [], app.simTime); | |
| syncTown(); // anchor the town heart, then frame it (the default close view) | |
| const epoch = state.world?.epoch ?? countWishes(state.world?.features ?? []); | |
| setEpoch(epoch, epoch); | |
| setQueue(state.queue?.length ?? 0); | |
| return state; | |
| } | |
| try { | |
| await loadState(); | |
| els.status.textContent = "live · the god is listening"; | |
| } catch (err) { | |
| console.warn("no live backend:", err); | |
| els.status.textContent = "the god is dreaming · demo vision"; | |
| return bootMock(); // graceful fall-back to the mock world | |
| } | |
| // Browser-invoked granting (ZeroGPU): the GPU rite must run inside OUR | |
| // gradio request so the HF auth headers carry the quota. When the SSE says | |
| // it's our wish's turn, call the named gradio endpoint with the one-time token. | |
| const myWishes = new Set(); | |
| // your_turn can arrive BEFORE the POST response registers our wish_id (an | |
| // empty queue turns to a wish instantly) — buffer unmatched turns and replay | |
| // them the moment the wish registers. | |
| const pendingTurns = new Set(); | |
| let gradioClient = null; | |
| async function invokeGrant(wishId) { | |
| els.status.textContent = "the god turns to your wish…"; | |
| try { | |
| // Fetch our secret token from the cookie-authenticated route — it is | |
| // never broadcast over SSE, so only the wish's owner can begin the rite. | |
| const tr = await fetch(`/api/turn?wish_id=${encodeURIComponent(wishId)}`, { | |
| credentials: "same-origin", | |
| }); | |
| if (!tr.ok) { | |
| if (tr.status === 403) return; // not our turn (a stranger's wish) | |
| 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) hint(out.reason, { warn: true }); | |
| } catch (err) { | |
| console.error("grant invocation failed", err); | |
| hint("the rite could not begin — sign in to hugging face, then wish again", { warn: true }); | |
| } | |
| } | |
| const es = new EventSource("/api/stream"); | |
| es.onmessage = (e) => { | |
| let m; | |
| try { m = JSON.parse(e.data); } catch { return; } | |
| switch (m.type) { | |
| case "your_turn": | |
| if (myWishes.has(m.wish_id)) invokeGrant(m.wish_id); | |
| else pendingTurns.add(m.wish_id); | |
| break; | |
| case "hello": | |
| if (stateVersion != null && m.world_version !== stateVersion) loadState().catch(() => {}); | |
| if (m.epoch != null) setEpoch(m.epoch, m.epoch); | |
| break; | |
| case "queue": | |
| setQueue(m.length ?? 0); | |
| if (m.current?.text_preview) tickerLive(m.current.text_preview); | |
| break; | |
| case "wish_started": | |
| tickerClear(); | |
| readingPhase = true; | |
| tickerLive(m.text); | |
| lastDescentDir = null; | |
| if (els.epitaphDescend) els.epitaphDescend.hidden = true; | |
| break; | |
| case "thought_token": | |
| tickerPush(m.text, readingPhase ? "reading" : "thought"); | |
| break; | |
| case "tool_call": { | |
| readingPhase = false; | |
| tickerPush(fmtCall(m), "call"); | |
| app.rig.gaze(callTarget(m), { hold: 5 }); | |
| break; | |
| } | |
| case "world_delta": { | |
| if (m.feature) { | |
| stateVersion = stateVersion == null ? null : stateVersion + 1; | |
| const cue = app.world.applyFeature(m.feature, { animate: true, t: app.simTime }); | |
| // a new building shifts the town's heart — re-anchor before framing so | |
| // the camera follows the town as it densifies (no-op for sky/weather). | |
| if (m.feature.tool === "place_structure" || m.feature.tool === "build_district") { | |
| syncTown(); | |
| } | |
| // gaze toward where the world is actually changing — the cue target is | |
| // the resolved surface point (refines inscriptions/structures by seed). | |
| // In TOWN mode gaze() keeps this WITHIN the town zoom (never the far limb). | |
| if (cue.target) app.rig.gaze(cue.target.clone().multiplyScalar(PLANET_R), { hold: 5.5 }); | |
| else if (cue.wide) app.rig.gaze(null, { hold: 4 }); | |
| const dir = cue.target || featureDir(m.feature); | |
| if (dir) lastDescentDir = dir.clone().normalize(); | |
| } | |
| break; | |
| } | |
| case "wish_granted": | |
| showEpitaph(m.epitaph || "it is made"); | |
| if (m.epoch != null) setEpoch(m.epoch, m.epoch); | |
| app.world.sky.flash(0.35); | |
| armDescent(lastDescentDir); | |
| // hold a beat on the wish site, THEN release to the wide shot | |
| if (lastDescentDir && !app.rig.descending) { | |
| app.rig.gaze(lastDescentDir.clone().multiplyScalar(PLANET_R), { hold: 3.6 }); | |
| } else { | |
| app.rig.release(); | |
| } | |
| tickerIdle(); | |
| els.status.textContent = "live · the god is listening"; | |
| break; | |
| case "wish_rejected": | |
| hint(m.reason || "the god declines this wish.", { warn: true }); | |
| tickerIdle(); | |
| break; | |
| case "heartbeat": | |
| break; | |
| } | |
| }; | |
| es.onerror = () => { | |
| els.status.textContent = "the link to the god flickers · reconnecting"; | |
| }; | |
| es.addEventListener("open", () => { | |
| els.status.textContent = "live · the god is listening"; | |
| }); | |
| // wish submission | |
| els.form.addEventListener("submit", async (e) => { | |
| e.preventDefault(); | |
| const text = els.input.value.trim(); | |
| if (!text) return; | |
| 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); | |
| if (pendingTurns.has(body.wish_id)) { | |
| pendingTurns.delete(body.wish_id); | |
| invokeGrant(body.wish_id); | |
| } | |
| } | |
| setQueue(body.position ?? 1, body.position ?? 1); | |
| hint("the god has heard you — stay; your presence completes the rite", { revert: 6000 }); | |
| } else { | |
| hint(body.reason || body.detail || "the god declines this wish.", { warn: true }); | |
| } | |
| } catch { | |
| hint("the offering was lost — try again", { warn: true }); | |
| } finally { | |
| els.send.disabled = false; | |
| } | |
| }); | |
| } | |
| // ---------------------------------------------------------------- mock mode | |
| let mockApi = {}; | |
| async function bootMock() { | |
| const [state, { FEED_WISH }] = await Promise.all([ | |
| fetch("./mock/world.json").then((r) => r.json()), | |
| import("./mock/feed.js"), | |
| ]); | |
| const baseFeatures = featuresByWish(state.world.features, UPTO); | |
| const baseEpoch = countWishes(baseFeatures); | |
| app.world.reset(baseFeatures, app.simTime); | |
| syncTown(); // anchor + frame the town heart (the default close view) | |
| applyDevOverrides(); | |
| setEpoch(baseEpoch, baseEpoch); | |
| els.status.textContent = MOCK ? "demo vision · ?mock=1" : "the god is dreaming · demo vision"; | |
| const looping = !STATIC && UPTO == null; | |
| // -- looping scripted feed: one full wish, replayed forever ------------ | |
| const runner = new ScriptRunner({ | |
| started: (e) => { tickerClear(); tickerLive(e.text); }, | |
| phase: () => {}, | |
| token: (e) => { | |
| const tone = e.text.startsWith("\n›") ? "obs" : phase === "reading" ? "reading" : "thought"; | |
| tickerPush(e.text, tone); | |
| }, | |
| call: (e) => { | |
| phase = "turn"; | |
| tickerPush(fmtCall(e.call), "call"); | |
| app.rig.gaze(callTarget(e.call), { 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 | |
| } | |
| if (cue.target) app.rig.gaze(cue.target.clone().multiplyScalar(PLANET_R), { hold: 4.5 }); | |
| 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) => { | |
| showEpitaph(e.epitaph); | |
| setEpoch(baseEpoch + 1, baseEpoch + 1); | |
| app.world.sky.flash(0.35); | |
| armDescent(lastDescentDir); | |
| if (lastDescentDir && !app.rig.descending) { | |
| app.rig.gaze(lastDescentDir.clone().multiplyScalar(PLANET_R), { hold: 3.6 }); | |
| } else { | |
| app.rig.release(); | |
| } | |
| tickerIdle(); | |
| cooldown = 12; // dwell on the granted world (descend affordance) before reset | |
| }, | |
| }); | |
| let phase = "reading"; | |
| let cooldown = 7; // contemplative pause before the first / between loops | |
| let pendingWishText = null; | |
| function startFeed(customText) { | |
| phase = "reading"; | |
| lastDescentDir = null; | |
| if (els.epitaphDescend) els.epitaphDescend.hidden = true; | |
| const trace = customText ? { ...FEED_WISH.trace, text: customText } : FEED_WISH.trace; | |
| runner.start(buildWishScript(trace, FEED_WISH.features), app.simTime); | |
| } | |
| app.onTick((t, dt) => { | |
| runner.update(t); | |
| if (!looping || runner.active) return; | |
| cooldown -= dt; | |
| if (cooldown <= 0) { | |
| // reset to the base world, then perform the wish again — determinism on display | |
| if (app.world.features.length > baseFeatures.length) { | |
| app.world.reset(baseFeatures, t); | |
| syncTown(); // back to the base town heart before the wish runs again | |
| setEpoch(baseEpoch, baseEpoch); | |
| tickerClear(); | |
| tickerIdle(); | |
| els.voiceWish.textContent = ""; | |
| } | |
| startFeed(pendingWishText); | |
| if (pendingWishText) { setQueue(0); pendingWishText = null; } | |
| cooldown = 10; | |
| } | |
| }); | |
| if (looping) { | |
| els.form.addEventListener("submit", (e) => { | |
| e.preventDefault(); | |
| const text = els.input.value.trim(); | |
| if (!text) return; | |
| pendingWishText = text.slice(0, 140); | |
| els.input.value = ""; | |
| setQueue(0, 1); | |
| hint("demo vision — the god will dream your words", { revert: 7000 }); | |
| }); | |
| } | |
| mockApi.runFeedOnce = startFeed; | |
| mockApi.resetTo = (upto) => { | |
| runner.cancel(); | |
| const feats = featuresByWish(state.world.features, upto ?? null); | |
| app.world.reset(feats, app.simTime); | |
| setEpoch(countWishes(feats), countWishes(feats)); | |
| }; | |
| } | |
| // ---------------------------------------------------------------- start | |
| const ready = (MOCK ? bootMock() : bootLive()) | |
| .then(() => { window.__godseed.ready = true; }) | |
| .catch((err) => { | |
| els.status.textContent = "the vision failed to load"; | |
| console.error(err); | |
| }); | |
| app.start(); | |
| // status heartbeat (epoch + sky, quiet diagnostics) | |
| setInterval(() => { | |
| const s = app.stats(); | |
| // base = everything that isn't a previous "<palette> sky" suffix (the status | |
| // text varies between 1 and 2 segments; a fixed slice duplicated the suffix) | |
| const base = els.status.textContent | |
| .split(" · ") | |
| .filter((p) => !p.trim().endsWith(" sky")) | |
| .join(" · "); | |
| els.status.textContent = `${base} · ${s.sky} sky`; | |
| }, 4000); | |
| // ---------------------------------------------------------------- harness API | |
| window.__godseed = { | |
| ready: false, | |
| app, | |
| get world() { return app.world; }, | |
| get rig() { return app.rig; }, | |
| tick: (seconds) => app.tick(seconds), | |
| seek: (t) => app.seek(t), | |
| pause: (v) => app.pause(v), | |
| stats: () => app.stats(), | |
| gazeLatLon: (lat, lon, hold = 30) => { | |
| app.rig.userHold = 0; | |
| app.rig.gaze(latLonToDir(lat, lon).multiplyScalar(PLANET_R), { hold }); | |
| }, | |
| gazeWide: () => { app.rig.setMode("world"); updateBeholdLabel(); }, | |
| setMode: (mode) => { app.rig.setMode(mode); updateBeholdLabel(); }, | |
| beholdWorld: () => { const m = app.rig.beholdWorld(); updateBeholdLabel(); return m; }, | |
| townCenter: () => townCenter(app.world.features), | |
| descend: (lat, lon, duration = 13) => { | |
| app.rig.setTerrain?.(app.world.terrain); | |
| app.rig.descend(latLonToDir(lat, lon).multiplyScalar(PLANET_R), { duration }); | |
| }, | |
| descendLast: (duration = 13) => { | |
| if (!lastDescentDir) return false; | |
| app.rig.setTerrain?.(app.world.terrain); | |
| app.rig.descend(lastDescentDir.clone().multiplyScalar(PLANET_R), { duration }); | |
| return true; | |
| }, | |
| setSky: (p, stars = 0.8, moons = 1) => app.world.sky.set(p, stars, moons, { animate: false }), | |
| setWeather: (k, i = 0.6) => app.world.weather.set(k, i, { animate: false }), | |
| setCamera: (theta, phi, dist) => { | |
| Object.assign(app.rig, { | |
| theta, thetaT: theta, phi, phiT: phi, | |
| dist: dist ?? app.rig.dist, distT: dist ?? app.rig.distT, | |
| }); | |
| }, | |
| runFeedOnce: (text) => mockApi.runFeedOnce?.(text), | |
| resetTo: (n) => mockApi.resetTo?.(n), | |
| }; | |