semif-api-rocm

SemIf HTTP API and rocm flake
Log | Files | Refs | README | LICENSE

self-game.js (22864B)


      1 /* No-rules platformer demo — the game loop for the "Platformer SL" tab.
      2  * Same physics and rendering as game.js, but the decision options carry no
      3  * rules, and three events trigger a /plan call whose output is injected into
      4  * every later observation as "Rules":
      5  *   - the model picks "insufficient" with p >= Planner.INSUFFICIENT_THRESHOLD
      6  *   - the player falls into a pit
      7  *   - three jump requests in a row with no jumps remaining
      8  * In all three the planner runs, then the level restarts with the new rules:
      9  * the planner writes full game rules, not a fix for one stuck situation, so
     10  * the model always applies them from a fresh start (the universal PLAN_SYSTEM
     11  * promises the environment resets after every plan).
     12  * Wrapped in an IIFE for the same reason as game.js. Requires game-rules.js,
     13  * planner.js and self-rules.js to have defined their globals first. */
     14 (() => {
     15 "use strict";
     16 
     17 /* ── level & physics (read from GameRules at use time, like game.js) ── */
     18 const { W, H, GROUND, GOAL, START_X, JUMP_VY, floorAt, QUESTION } = GameRules;
     19 const { OPTIONS } = SelfRules;
     20 const TICK_MS = 90;
     21 
     22 /* ── mutable game state ─────────────────────────────────────────────── */
     23 const player = { x: START_X, y: GROUND, vy: 0, onGround: true };
     24 let running = false;      // auto mode: player currently holds "run right"
     25 let mode = "idle";        // idle | auto | manual | won | lost
     26 let deciding = false;
     27 let planning = false;
     28 let stateMode = "prose";  // prose | runlength | ascii — bare formats only
     29 let jumpsLeft = GameRules.MAX_JUMPS;
     30 let deniedStreak = 0;     // consecutive jump-attempts with an empty budget
     31 let tickTimer = null;
     32 let decisionN = 0;
     33 let planN = 0;
     34 let runToken = 0;         // invalidates in-flight decisions/plans on reset
     35 let learnedRules = "";    // last /plan output; injected into every observation
     36 let transcript = Planner.fresh();  // completed actions + outcomes for /plan
     37 let pending = null;       // decision being executed, recorded once it resolves
     38 // Run stats for the completion summary: wall time from the Start click
     39 // (spanning auto-restarts after pit falls), failures, decisions, and the
     40 // total token spend of every planner call.
     41 const freshStats = () => ({ startedAt: null, failures: 0, decisions: 0, plans: 0,
     42   planCompletionTokens: 0, planReasoningTokens: 0, planPromptTokens: 0, planMs: 0 });
     43 let stats = freshStats();
     44 const count = (n) => n.toLocaleString("en-US");
     45 
     46 const $ = (id) => document.getElementById(id);
     47 const cv = $("self-cv"), ctx = cv.getContext("2d");
     48 const CELL = cv.width / W, ROWH = cv.height / H;
     49 function refreshLevelLabel() {
     50   $("self-level-label").textContent =
     51     `Level — pits at ${GameRules.PITS.map(([a, b]) => `${a}–${b + 1}`).join(", ")}; flag at ${GOAL}`;
     52 }
     53 refreshLevelLabel();
     54 
     55 // Same rule as game.js: shortcuts only while this panel is visible and focus
     56 // is not in an editable element.
     57 const gameActive = () => {
     58   const t = document.activeElement;
     59   if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return false;
     60   return !$("panel-self").hidden;
     61 };
     62 
     63 /* ── the ONE textual state: source for rendering prompts AND the API ── */
     64 // Bare observation: no learned rules. This is what the transcript records —
     65 // the planner already receives the current rules ONCE via Planner.context's
     66 // "Previous rules", so re-embedding them in all 24 history turns
     67 // would just bloat its prompt and blur which rules were current.
     68 function bareState() {
     69   return SelfRules.stateText(player, jumpsLeft, stateMode);
     70 }
     71 function stateText() {
     72   const base = bareState();
     73   // Learned rules lead, the current state follows. The freshest tokens
     74   // should be the immediate evidence the decision is made from, not the
     75   // standing rules — the decision pass reads once, and recent tokens weigh
     76   // most.
     77   return learnedRules ? `Rules:\n${learnedRules}\n\n${base}` : base;
     78 }
     79 
     80 /* ── rendering (same coordinates as game.js) ────────────────────────── */
     81 function render() {
     82   ctx.clearRect(0, 0, cv.width, cv.height);
     83   for (let c = 0; c < W; c++) {
     84     if (!floorAt(c)) {   // pit: dark shaft
     85       ctx.fillStyle = "#0a0c11";
     86       ctx.fillRect(c * CELL, GROUND * ROWH, CELL, cv.height - GROUND * ROWH);
     87       continue;
     88     }
     89     ctx.fillStyle = "#2a3140";
     90     ctx.fillRect(c * CELL, GROUND * ROWH, CELL, cv.height - GROUND * ROWH);
     91     ctx.fillStyle = "#3a4356";
     92     ctx.fillRect(c * CELL, GROUND * ROWH, CELL, 3);
     93   }
     94   const gx = GOAL * CELL;
     95   ctx.strokeStyle = "#56d364"; ctx.lineWidth = 2;
     96   ctx.beginPath(); ctx.moveTo(gx, GROUND * ROWH); ctx.lineTo(gx, GROUND * ROWH - 34); ctx.stroke();
     97   ctx.fillStyle = "#56d364";
     98   ctx.beginPath(); ctx.moveTo(gx, GROUND * ROWH - 34);
     99   ctx.lineTo(gx + 18, GROUND * ROWH - 27); ctx.lineTo(gx, GROUND * ROWH - 20); ctx.fill();
    100   ctx.fillStyle = player.onGround ? "#69c0ff" : "#e3a008";
    101   ctx.beginPath(); ctx.arc(player.x * CELL, player.y * ROWH - 8, 8, 0, Math.PI * 2); ctx.fill();
    102 }
    103 
    104 /* ── physics tick ───────────────────────────────────────────────────── */
    105 function tick() {
    106   if (mode !== "auto" && mode !== "manual") return;
    107   const before = { ...player };
    108   const next = GameRules.advance(player, mode === "auto" ? running : keyRun);
    109   Object.assign(player, next.player);
    110   if (next.fell) {
    111     recordOutcome("the player fell below the level.");
    112     lose("The player fell into a pit.");
    113     void replanAndRetry("fell");
    114     return;
    115   }
    116   if (player.x >= GOAL) { recordOutcome("the player reached the flag."); win(); return; }
    117   render();
    118   // Mirror the exact state text into the pane on every tick — in manual mode
    119   // this is the debugging view of what the decider would receive right now.
    120   $("self-state-view").textContent = stateText();
    121   // Running commits to one tick. A jump commits until landing.
    122   if (mode === "auto" && player.onGround) {
    123     stopTicks();
    124     const moved = Math.round(player.x - before.x);
    125     recordOutcome(pending && pending.choice === "jump"
    126       ? `the player jumped and landed ${moved} spaces to the right of the takeoff point.`
    127       : `the player moved ${moved} space${moved === 1 ? "" : "s"} to the right.`);
    128     void requestDecision();
    129   }
    130 }
    131 
    132 function jump() {
    133   if (!player.onGround || jumpsLeft <= 0 || mode === "won" || mode === "lost") return;
    134   jumpsLeft--; deniedStreak = 0;
    135   player.onGround = false; player.vy = JUMP_VY;
    136 }
    137 
    138 /* ── transcript: one turn per completed action, read by /plan ───────── */
    139 function recordOutcome(outcome) {
    140   if (!pending) return;   // manual play: no observation/choice to record
    141   Planner.record(transcript,
    142     `Observation:\n${pending.state}\nChosen action: ${pending.choice}\nOutcome: ${outcome}`);
    143   pending = null;
    144 }
    145 
    146 /* ── SemIf decision loop ────────────────────────────────────────────── */
    147 async function requestDecision() {
    148   if (deciding || planning || mode !== "auto") return;
    149   deciding = true;
    150   const token = runToken;
    151   stopTicks();                       // physics freezes while we think
    152   setStatus("thinking…", "");
    153   const text = stateText();
    154   const bare = bareState();   // transcript records this, rules live in /plan context
    155   $("self-state-view").textContent = text;
    156   const n = ++decisionN;
    157   let result, elapsedMs, err = null;
    158   try {
    159     const t0 = performance.now();
    160     const resp = await fetch("/decide", {
    161       method: "POST", headers: { "Content-Type": "application/json" },
    162       body: JSON.stringify({ id: `self-${n}`, state: text, question: QUESTION, options: OPTIONS }),
    163     });
    164     elapsedMs = performance.now() - t0;
    165     const payload = JSON.parse(await resp.text());
    166     if (!resp.ok) throw new Error(typeof payload.detail === "string" ? payload.detail : resp.statusText);
    167     result = payload;
    168   } catch (e) { err = e; }
    169   if (token !== runToken || mode !== "auto") return;   // reset while we waited
    170   deciding = false;
    171   if (err) {
    172     logError(n, err.message);
    173     setStatus(`decision failed: ${err.message} — retrying in 2 s`, "err");
    174     setTimeout(() => { if (token === runToken && mode === "auto") void requestDecision(); }, 2000);
    175     return;
    176   }
    177   stats.decisions++;
    178   const pairs = result.option_ids.map((id, i) => [id, result.probabilities[i]]);
    179   const [choice, p] = Planner.best(result);
    180   logDecision(n, [choice, p], pairs, elapsedMs, text);
    181 
    182   if (choice === Planner.INSUFFICIENT_ID) {
    183     if (Planner.triggered(result)) {
    184       // Confident "cannot decide": the planner writes full game rules, not a
    185       // way out of this exact spot — end the attempt and restart the level
    186       // so the model applies the rules from the start, same layout,
    187       // transcript intact. Same semantics as the room SL.
    188       pending = null;
    189       lose("The model cannot decide and asks for rules.");
    190       void replanAndRetry("insufficient");
    191       return;
    192     }
    193     // Weak insufficient loses the argmax to the best real action; keep playing
    194     // but make the override visible in the log and status.
    195     const real = pairs.filter(([id]) => id !== Planner.INSUFFICIENT_ID)
    196       .sort((a, b) => b[1] - a[1]);
    197     setStatus(`insufficient led at p=${p.toFixed(3)} (< 0.99 planning threshold) — acting on ${real[0][0]}`, "err");
    198     pending = { n, state: bare, choice: real[0][0] };
    199   } else {
    200     pending = { n, state: bare, choice };
    201   }
    202 
    203   setStatus(`playing… · ${jumpsLeft} jump${jumpsLeft === 1 ? "" : "s"} left`, "");
    204   running = pending.choice === "run";
    205   if (running) deniedStreak = 0;
    206   if (pending.choice === "jump") {
    207     if (jumpsLeft > 0) {
    208       jump();
    209     } else {
    210       deniedStreak++; running = false; pending = null;
    211       if (deniedStreak >= 3) {
    212         // Out of jumps and keeps trying: same learning loop as a pit fall —
    213         // plan from the transcript, then restart with the learned rules.
    214         deniedStreak = 0;
    215         lose("The model is out of jumps and keeps trying to jump.");
    216         void replanAndRetry("denied");
    217         return;
    218       }
    219       setStatus(`jump denied — 0 jumps left (asked ${deniedStreak}× in a row)`, "err");
    220       setTimeout(() => { if (token === runToken && mode === "auto") void requestDecision(); }, 400);
    221       return;
    222     }
    223   }
    224   if (mode !== "auto") return;
    225   startTicks();
    226   render();
    227 }
    228 
    229 /* ── /plan: derive rules from the transcript, then keep playing ─────── */
    230 async function replan(reason, token) {
    231   planning = true;
    232   const n = ++planN;
    233   setPlanningStatus(reason === "fell"
    234     ? "fell into a pit — planning rules from the transcript…"
    235     : reason === "denied"
    236       ? "out of jumps, keeps trying to jump — planning rules from the transcript…"
    237       : "insufficient evidence (p ≥ 0.99) — planning rules from the transcript…");
    238   const { e, choice, ms } = addPlanEntry(n, reason);
    239   let payload = null, err = null, elapsedMs = 0;
    240   try {
    241     const t0 = performance.now();
    242     // The endpoint is game-agnostic: Planner.context() wraps the simulation's
    243     // one-line goal with the trigger note and the rules currently in effect —
    244     // on repeat failures the planner can see and amend its own previous plan.
    245     const trigger = reason === "fell"
    246       ? "the actor fell below the level and the attempt ended"
    247       : reason === "denied"
    248         ? "the actor repeatedly chose an action the simulation rejected (jump with no jumps remaining) and the attempt stalled"
    249         : "the actor declared the evidence insufficient (p ≥ 0.99) and could not decide";
    250     payload = await Planner.request({
    251       id: `self-plan-${n}`,
    252       prompt: Planner.context(SelfRules.PLAN_GOAL, trigger, learnedRules),
    253       transcript,
    254     });
    255     elapsedMs = performance.now() - t0;
    256   } catch (e2) { err = e2; }
    257   if (token !== runToken) return false;   // reset while planning: discard result
    258   planning = false;
    259   if (err) {
    260     choice.textContent = "planning failed";
    261     const d = document.createElement("div"); d.className = "probs"; d.textContent = err.message;
    262     e.appendChild(d);
    263     setStatus(`planning failed: ${err.message} — press Reset or Start to continue`, "err");
    264     return false;
    265   }
    266   learnedRules = payload.rules;
    267   $("self-rules-view").value = learnedRules;
    268   const usage = payload.usage || {};
    269   stats.plans++;
    270   stats.planMs += elapsedMs;
    271   stats.planCompletionTokens += usage.completion_tokens || 0;
    272   stats.planPromptTokens += usage.prompt_tokens || 0;
    273   stats.planReasoningTokens += (usage.completion_tokens_details || {}).reasoning_tokens || 0;
    274   renderStats();
    275   choice.textContent = `→ rules learned (${count(usage.completion_tokens || 0)} tokens)`;
    276   ms.textContent = `${elapsedMs.toFixed(0)} ms`;
    277   // Planner output, expandable in place: the rules pane only ever shows the
    278   // latest set, so the log entry keeps every plan (rules + thinking trace)
    279   // inspectable. Click toggles between a preview and the full text.
    280   const detailParts = ["rules:\n" + payload.rules];
    281   if (payload.reasoning) detailParts.push("thinking:\n" + payload.reasoning);
    282   const detail = detailParts.join("\n\n");
    283   const d = document.createElement("div"); d.className = "probs expandable";
    284   let open = false;
    285   const paint = () => {
    286     d.textContent = (open ? detail : detail.slice(0, 240) + (detail.length > 240 ? "…" : "")) +
    287       (detail.length > 240 ? (open ? "  ▲" : "  ▼") : "");
    288   };
    289   paint();
    290   d.addEventListener("click", () => { open = !open; paint(); });
    291   e.appendChild(d);
    292   e.title = detail;   // hover for the full trace
    293   if (payload.truncated) {
    294     const warn = document.createElement("div"); warn.className = "probs";
    295     warn.textContent = "warning: reply hit the server's token limit and was truncated";
    296     e.appendChild(warn);
    297   }
    298   setStatus("rules updated", "");
    299   return true;
    300 }
    301 
    302 // Failed attempt (pit fall, impossible-action streak, or the model asked for
    303 // rules): plan, then restart the level with the learned rules and go again. This continues the SAME
    304 // run: stats and the Start-click timer keep going.
    305 async function replanAndRetry(reason) {
    306   const token = runToken;
    307   if (!await replan(reason, token)) return;
    308   if (mode !== "lost" || token !== runToken) return;   // user took over meanwhile
    309   resetLevel();
    310   setStatus("restarting with learned rules…", "");
    311   mode = "auto";
    312   $("self-start").disabled = true;
    313   void requestDecision();
    314 }
    315 
    316 /* ── decision log ───────────────────────────────────────────────────── */
    317 function clearLog() { const l = $("self-log"); while (l.firstChild) l.removeChild(l.firstChild); }
    318 function addEntry(n, cls) {
    319   const e = document.createElement("div"); e.className = `entry${cls ? " " + cls : ""}`;
    320   const head = document.createElement("div"); head.className = "head";
    321   const num = document.createElement("span"); num.className = "n";
    322   num.textContent = `#${n} · ${stateMode}`;
    323   const choice = document.createElement("span"); choice.className = "choice";
    324   const ms = document.createElement("span"); ms.className = "ms";
    325   head.append(num, choice, ms); e.appendChild(head);
    326   $("self-log").prepend(e);
    327   return { e, choice, ms };
    328 }
    329 function logDecision(n, best, pairs, elapsedMs, sentState) {
    330   const { e, choice, ms } = addEntry(n);
    331   choice.textContent = `→ ${best[0]} (p=${best[1].toFixed(3)})`;
    332   ms.textContent = `${elapsedMs.toFixed(0)} ms`;
    333   const probs = document.createElement("div"); probs.className = "probs";
    334   probs.textContent = pairs.map(([id, p]) => `${id} ${p.toFixed(3)}`).join("   ");
    335   e.appendChild(probs);
    336   const bar = document.createElement("div"); bar.className = "bar";
    337   const fill = document.createElement("span"); fill.style.width = `${(best[1] * 100).toFixed(1)}%`;
    338   bar.appendChild(fill); e.appendChild(bar);
    339   e.title = sentState;
    340 }
    341 function addPlanEntry(n, reason) {
    342   const entry = addEntry(n, "plan");
    343   entry.e.firstChild.firstChild.textContent = `#plan ${n} · ${reason} · ${stateMode}`;
    344   return entry;
    345 }
    346 function logError(n, msg) {
    347   const { e, choice } = addEntry(n, "error");
    348   choice.textContent = "request failed";
    349   const d = document.createElement("div"); d.className = "probs"; d.textContent = msg;
    350   e.appendChild(d);
    351 }
    352 
    353 /* ── status / win / lose / reset ────────────────────────────────────── */
    354 function setStatus(t, cls) {
    355   const s = $("self-status");
    356   s.className = `status${cls ? " " + cls : ""}`;
    357   s.textContent = t;
    358 }
    359 // Planning status: same text and size, but each letter becomes a span with a
    360 // staggered negative animation delay, so the CSS rainbow travels down the
    361 // text as a wave while /plan runs. Any later setStatus() restores plain text.
    362 function setPlanningStatus(t) {
    363   const s = $("self-status");
    364   s.className = "status planning";
    365   s.replaceChildren(...[...t].map((ch, i) => {
    366     const span = document.createElement("span");
    367     span.textContent = ch;
    368     span.style.setProperty("--i", i);
    369     return span;
    370   }));
    371 }
    372 function startTicks() { if (!tickTimer) tickTimer = setInterval(tick, TICK_MS); }
    373 function stopTicks() { if (tickTimer) { clearInterval(tickTimer); tickTimer = null; } }
    374 
    375 // Completion summary, refreshed after every plan and at the end of the run.
    376 // Wall time is measured from the Start click and spans auto-restarts.
    377 function renderStats(result) {
    378   const lines = [];
    379   if (result) lines.push(`result: ${result}`);
    380   if (stats.startedAt !== null) {
    381     lines.push(`wall time: ${((performance.now() - stats.startedAt) / 1000).toFixed(1)} s (from Start click)`);
    382   }
    383   lines.push(`decisions: ${stats.decisions}`);
    384   lines.push(`failures: ${stats.failures}`);
    385   lines.push(`plans: ${stats.plans}`);
    386   if (stats.plans > 0) {
    387     const reasoning = stats.planReasoningTokens ? ` (${count(stats.planReasoningTokens)} reasoning)` : "";
    388     lines.push(`planner tokens: ${count(stats.planCompletionTokens)} completion${reasoning} · ${count(stats.planPromptTokens)} prompt`);
    389     lines.push(`planning time: ${(stats.planMs / 1000).toFixed(1)} s`);
    390   }
    391   $("self-stats").textContent = lines.join("\n");
    392 }
    393 function win() {
    394   mode = "won"; stopTicks(); running = false;
    395   const seconds = stats.startedAt !== null ? `${((performance.now() - stats.startedAt) / 1000).toFixed(1)} s` : "—";
    396   const thinking = stats.plans > 0
    397     ? ` · ${stats.plans} plan${stats.plans === 1 ? "" : "s"}, ${count(stats.planCompletionTokens)} planner tokens`
    398     : "";
    399   setStatus(`🏁 level complete in ${seconds} · ${stats.decisions} decision(s) · ${stats.failures} failure(s)${thinking} · ${jumpsLeft} jump${jumpsLeft === 1 ? "" : "s"} to spare — press Reset to run it again`, "win");
    400   renderStats("success");
    401   render();
    402 }
    403 function lose(msg) {
    404   mode = "lost"; stopTicks(); running = false;
    405   stats.failures++;
    406   renderStats();
    407   setStatus(`✗ ${msg}`, "err");
    408   render();
    409 }
    410 // Level state only: keeps learning (rules, transcript) AND run stats, so the
    411 // auto-restart after a pit fall continues the same run. Full Reset below
    412 // clears everything.
    413 function resetLevel() {
    414   runToken++;
    415   stopTicks(); deciding = false; planning = false; running = false; keyRun = false;
    416   jumpsLeft = GameRules.MAX_JUMPS; deniedStreak = 0;
    417   player.x = START_X; player.y = GROUND; player.vy = 0; player.onGround = true;
    418   mode = "idle"; decisionN = 0; pending = null;
    419   render();
    420 }
    421 // Full reset: level + learned rules + transcript + run stats. The next Start
    422 // begins a fresh run from a blank slate.
    423 function reset() {
    424   resetLevel();
    425   learnedRules = "";
    426   transcript = Planner.fresh();
    427   stats = freshStats();
    428   clearLog();   // the decision/plan log is part of the run, not learning
    429   $("self-start").disabled = false;
    430   $("self-rules-view").value = "";
    431   $("self-stats").textContent = "(run not started)";
    432   setStatus("idle — press Start", "");
    433   $("self-state-view").textContent = stateText();   // idle preview: same text /decide would receive
    434 }
    435 function forgetRules() {
    436   learnedRules = "";
    437   transcript = Planner.fresh();
    438   $("self-rules-view").value = "";
    439   setStatus("learned rules forgotten", "");
    440 }
    441 
    442 // The rules pane is an editor: whatever is in it IS the rules, hand-typed or
    443 // pasted, so a good plan can be captured, tweaked, or replayed for a
    444 // reproducible run. Edits apply to the very next decision.
    445 $("self-rules-view").addEventListener("input", (e) => { learnedRules = e.target.value; });
    446 
    447 /* ── controls ───────────────────────────────────────────────────────── */
    448 const stateModes = ["prose", "runlength", "ascii"];
    449 const setStateMode = (m) => {
    450   stateMode = m;
    451   for (const name of stateModes) $("self-mode-" + name).className = name === m ? "on" : "";
    452 };
    453 for (const name of stateModes) $("self-mode-" + name).addEventListener("click", () => setStateMode(name));
    454 
    455 $("self-start").addEventListener("click", () => {
    456   if (mode === "auto") return;
    457   if (mode === "won" || mode === "lost") reset();
    458   stats = freshStats();               // a Start click begins a new timed run
    459   stats.startedAt = performance.now();
    460   renderStats();
    461   mode = "auto";
    462   $("self-start").disabled = true;
    463   void requestDecision();
    464 });
    465 $("self-reset").addEventListener("click", reset);
    466 $("self-forget").addEventListener("click", forgetRules);
    467 
    468 /* ── random pit layout (seeded, reproducible) ──────────────────── */
    469 const parseSeed = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n >>> 0 : null; };
    470 function applySeed(seedNum) {
    471   GameRules.setPits(GameRules.makePits(seedNum));
    472   refreshLevelLabel();
    473   reset();
    474 }
    475 $("self-randomize").addEventListener("click", () => {
    476   const s = (Math.random() * 0x100000000) >>> 0;
    477   $("self-seed").value = String(s);
    478   applySeed(s);
    479 });
    480 $("self-seed").addEventListener("change", () => {
    481   const s = parseSeed($("self-seed").value);
    482   if (s !== null) applySeed(s);
    483 });
    484 
    485 let keyRun = false;
    486 addEventListener("keydown", (e) => {
    487   if (!gameActive()) return;
    488   if (mode === "auto" || mode === "won" || mode === "lost") return;
    489   if (e.key === "ArrowRight" || e.key === "d") {
    490     keyRun = true;
    491     if (mode === "idle") { mode = "manual"; setStatus("manual mode", ""); startTicks(); }
    492   }
    493   if (e.key === " " || e.key === "ArrowUp" || e.key === "w") {
    494     e.preventDefault();
    495     if (mode === "idle") { mode = "manual"; setStatus("manual mode", ""); startTicks(); }
    496     jump();
    497   }
    498 });
    499 addEventListener("keyup", (e) => { if (e.key === "ArrowRight" || e.key === "d") keyRun = false; });
    500 
    501 reset();
    502 })();