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 })();