aboutsummaryrefslogtreecommitdiffstats
path: root/prototypes/live-rules/web
diff options
context:
space:
mode:
authoreunhhu <[email protected]>2026-09-24 17:14:54 +0100
committereunhhu <[email protected]>2026-09-24 17:14:54 +0100
commitb1ce1927022d374198441afafeb27199fc8a052a (patch)
tree6021f175f4c67ed03b44240eaa2f90d846f12f6f /prototypes/live-rules/web
parentd34f9426d5317a1948f12f229439946728bc342a (diff)
downloadpure-b1ce1927022d374198441afafeb27199fc8a052a.tar.gz
pure-b1ce1927022d374198441afafeb27199fc8a052a.zip
Establish native micro runtime checkpoint
Diffstat (limited to 'prototypes/live-rules/web')
-rw-r--r--prototypes/live-rules/web/app.js294
-rwxr-xr-xprototypes/live-rules/web/core.wasmbin0 -> 32544 bytes
-rw-r--r--prototypes/live-rules/web/index.html101
-rw-r--r--prototypes/live-rules/web/style.css1
4 files changed, 396 insertions, 0 deletions
diff --git a/prototypes/live-rules/web/app.js b/prototypes/live-rules/web/app.js
new file mode 100644
index 0000000..d8ca815
--- /dev/null
+++ b/prototypes/live-rules/web/app.js
@@ -0,0 +1,294 @@
+"use strict";
+
+(() => {
+ const $ = (id) => document.getElementById(id);
+ const field = Object.freeze({ tick: 0, y: 1, vy: 2, distance: 3, obstacleX: 4, passed: 5, hits: 6, jumps: 7, running: 8, revision: 9, reason: 10, source: 11, inputs: 12, accepted: 13, mode: 14, impulse: 15, enabled: 16, grounded: 17, obstacleHeight: 18 });
+ const reasonText = ["아직 입력 없음", "점프를 받아들였어요.", "점프 입력이 꺼져 있어요.", "땅에 닿아야 다시 뛸 수 있어요.", "공중에서 쓸 점프를 모두 썼어요.", "알 수 없는 입력이에요."];
+ const sourceText = ["키보드", "점프 버튼", "CLI"];
+ const frameMs = 1000 / 60;
+ let core = null;
+ let ready = false;
+ let applied = null;
+ let lastTime = null;
+ let accumulator = 0;
+ let droppedTicks = 0;
+ let renderCount = 0;
+ let historySignature = "";
+ let notice = "";
+ const nodes = [...document.querySelectorAll("[data-node]")];
+
+ function text(id, value) {
+ const element = $(id);
+ const next = String(value);
+ if (element.textContent !== next) element.textContent = next;
+ }
+
+ function get(name) { return core.get(field[name]); }
+ function counter(name) { return get(name) >>> 0; }
+ function say(message) { text("live-status", message); }
+ function formatBytes(value) { return `${(value >>> 0).toLocaleString("ko-KR")} B`; }
+ function draft() {
+ return { mode: Number(document.querySelector('input[name="mode"]:checked').value), impulse: Number($("impulse").value), enabled: $("enabled").checked ? 1 : 0 };
+ }
+ function isDirty() {
+ if (!applied) return false;
+ const next = draft();
+ return next.mode !== applied.mode || next.impulse !== applied.impulse || next.enabled !== applied.enabled;
+ }
+ function draftChanged() {
+ text("impulse-output", $("impulse").value);
+ const dirty = isDirty();
+ text("draft-status", dirty ? "아직 적용하지 않은 변경" : "현재 규칙과 같음");
+ $("draft-status").parentElement.classList.toggle("dirty", dirty);
+ $("apply-button").disabled = !ready || !dirty;
+ if (dirty) text("apply-feedback", "아직 이전 규칙으로 움직입니다. 적용하면 위치와 시간은 그대로 이어집니다.");
+ else if (ready) text("apply-feedback", "바꾼 뒤 적용을 눌러주세요. 위치와 진행 시간은 이어집니다.");
+ }
+ function syncApplied() {
+ applied = { mode: get("mode"), impulse: get("impulse"), enabled: get("enabled") };
+ document.querySelector(`input[name="mode"][value="${applied.mode}"]`).checked = true;
+ $("impulse").value = String(applied.impulse);
+ $("enabled").checked = Boolean(applied.enabled);
+ draftChanged();
+ }
+
+ function renderHistory() {
+ const entries = [];
+ for (let i = 0, count = Math.min(8, core.history_count()); i < count; i++) {
+ entries.push([0, 1, 2, 3].map((part) => core.history_get(i, part) >>> 0));
+ }
+ const signature = JSON.stringify(entries);
+ if (signature === historySignature) return;
+ historySignature = signature;
+ const list = $("history-list");
+ list.replaceChildren();
+ if (entries.length === 0) {
+ const empty = document.createElement("li");
+ empty.className = "empty-history";
+ empty.textContent = "점프하거나 규칙을 적용하면 이곳에 기록됩니다.";
+ list.append(empty);
+ return;
+ }
+ for (const [tick, kind, detail, revision] of entries) {
+ const row = document.createElement("li");
+ const time = document.createElement("span");
+ time.className = "history-time";
+ time.textContent = `${tick}t`;
+ const label = document.createElement("span");
+ label.className = "history-label";
+ const descriptions = { 1: "점프 허용", 2: reasonText[detail] || "점프 거절", 3: "규칙 적용", 4: "땅에 착지", 5: "장애물에 닿음", 6: "장애물을 지남" };
+ label.textContent = `${descriptions[kind] || "상태 변경"} · v${revision}`;
+ row.append(time, label);
+ list.append(row);
+ }
+ }
+
+ function renderDecision() {
+ const reason = get("reason");
+ const result = $("decision-result");
+ result.classList.toggle("accepted", reason === 1);
+ result.classList.toggle("rejected", reason > 1);
+ text("decision-result", reason === 0 ? "첫 점프를 기다리고 있어요." : reasonText[reason] || "입력을 확인해주세요.");
+ if (reason === 0) {
+ text("decision-explanation", "한 번 누르면 입력이 규칙을 통과하는 과정을 볼 수 있습니다.");
+ text("decision-meta", "아직 입력 없음");
+ $("stale-decision").hidden = true;
+ } else {
+ const preY = core.decision_meta(2);
+ const preJumps = core.decision_meta(3);
+ const revision = core.decision_meta(1) >>> 0;
+ text("decision-explanation", `누른 순간: 높이 ${(preY / 100).toFixed(1)} px · 사용한 점프 ${preJumps}회. ${get("running") ? "이후에도 세계는 움직입니다." : "멈춘 상태에서 판단을 살펴보고 있어요."}`);
+ text("decision-meta", `${sourceText[get("source")] || "알 수 없는 입력"} · ${core.decision_meta(0) >>> 0} tick · v${revision}`);
+ $("stale-decision").hidden = revision === counter("revision");
+ }
+ for (const node of nodes) {
+ const value = core.node_value(Number(node.dataset.node));
+ if (value < 0) delete node.dataset.result;
+ else node.dataset.result = String(Boolean(value));
+ const output = node.querySelector(".node-value");
+ const next = value < 0 ? "대기" : value ? "예" : "아니요";
+ if (output.textContent !== next) output.textContent = next;
+ }
+ }
+
+ function render() {
+ if (!ready) return;
+ renderCount++;
+ const y = get("y") / 100;
+ const velocity = get("vy") / 100;
+ const tick = counter("tick");
+ const running = Boolean(get("running"));
+ const obstacleHeight = get("obstacleHeight");
+ const cameraY = Math.min(0, 180 - y);
+ const viewWidth = $("stage").clientWidth < 500 ? 500 : 960;
+ $("world").setAttribute("viewBox", `0 ${cameraY} ${viewWidth} 350`);
+ $("dino").setAttribute("transform", `translate(120 ${230 - y})`);
+ $("dino-shadow").setAttribute("rx", String(Math.max(10, 27 - y / 12)));
+ $("dino-shadow").setAttribute("opacity", String(Math.max(0.05, 0.15 - y / 1600)));
+ $("obstacle").setAttribute("transform", `translate(${get("obstacleX")} ${278 - obstacleHeight})`);
+ $("obstacle-body").setAttribute("height", String(obstacleHeight));
+ $("obstacle-mark").setAttribute("d", `M11 10v${Math.max(0, obstacleHeight - 20)}m10-${Math.max(0, obstacleHeight - 27)}v${Math.max(0, obstacleHeight - 27)}`);
+ text("running-badge", running ? "실행 중" : "멈춰 있음");
+ $("running-badge").classList.toggle("running", running);
+ text("run-button", running ? "일시정지" : "실행");
+ $("run-button").setAttribute("aria-pressed", String(running));
+ $("step-button").disabled = running;
+ text("stage-hint", cameraY < 0 ? "높이에 맞춰 시점이 공룡을 따라갑니다." : running ? "Space 또는 점프 버튼 · 멈춰서 규칙을 바꿔도 좋아요." : "일시정지 상태에서도 규칙과 입력을 확인할 수 있어요.");
+ text("state-height", `${y.toFixed(1)} px`);
+ text("state-velocity", `${velocity > 0 ? "+" : ""}${velocity.toFixed(1)} px/틱`);
+ text("state-ground", get("grounded") ? "땅 위" : "공중");
+ text("state-jumps", `${counter("jumps")}회`);
+ text("state-distance", `${counter("distance").toLocaleString("ko-KR")} px`);
+ text("state-time", `${(tick / 60).toFixed(2)}초`);
+ text("state-tick", tick);
+ text("state-passed", counter("passed"));
+ text("state-hits", counter("hits"));
+ text("revision-badge", `v${counter("revision")}`);
+ text("applied-label", `적용 v${counter("revision")}`);
+ text("runtime-note", notice || (droppedTicks > 0 ? `화면 지연으로 ${droppedTicks.toLocaleString("ko-KR")}틱을 생략했습니다. 진행 시간은 실제 시간보다 느릴 수 있어요.` : "실행하면 시간이 흐릅니다. 멈춘 뒤 한 프레임씩 살펴볼 수도 있어요."));
+ text("scheduling-detail", `고정 60Hz · 한 화면 갱신당 최대 6틱 · 생략한 틱 ${droppedTicks.toLocaleString("ko-KR")}`);
+ text("bytes-linear", core.memory ? formatBytes(core.memory.buffer.byteLength) : "메모리 내보내기 없음");
+ renderDecision();
+ renderHistory();
+ }
+
+ function jump(source) {
+ if (!ready) return;
+ core.input(source);
+ render();
+ say(reasonText[get("reason")] || "입력 처리 완료");
+ }
+
+ function setRunning(next) {
+ core.set_running(next ? 1 : 0);
+ accumulator = 0;
+ lastTime = null;
+ notice = "";
+ render();
+ say(next ? "실행했습니다." : "멈췄습니다. 규칙을 바꾸거나 한 프레임씩 살펴볼 수 있어요.");
+ }
+
+ function frame(now) {
+ if (ready) {
+ if (get("running") && !document.hidden) {
+ if (lastTime !== null) {
+ accumulator += Math.max(0, now - lastTime);
+ const due = Math.floor(accumulator / frameMs);
+ if (due > 0) {
+ const ticks = Math.min(6, due);
+ droppedTicks += due - ticks;
+ accumulator -= due * frameMs;
+ core.advance(ticks);
+ render();
+ }
+ }
+ lastTime = now;
+ } else {
+ lastTime = null;
+ accumulator = 0;
+ }
+ }
+ window.requestAnimationFrame(frame);
+ }
+
+ $("rules-form").addEventListener("input", draftChanged);
+ $("rules-form").addEventListener("change", draftChanged);
+ $("rules-form").addEventListener("submit", (event) => {
+ event.preventDefault();
+ if (!ready || !isDirty()) return;
+ const next = draft();
+ if (core.apply_rules(next.mode, next.impulse, next.enabled) !== 0) {
+ text("apply-feedback", "적용하지 못했습니다. 점프 세기는 6~16이어야 합니다.");
+ say("규칙을 적용하지 못했습니다.");
+ return;
+ }
+ applied = next;
+ draftChanged();
+ const feedback = `v${counter("revision")} 적용 완료. 위치·속도·진행 시간은 그대로 이어집니다.`;
+ text("apply-feedback", feedback);
+ render();
+ say(feedback);
+ });
+ $("run-button").addEventListener("click", () => { if (ready) setRunning(!get("running")); });
+ $("step-button").addEventListener("click", () => {
+ if (!ready || get("running")) return;
+ core.step();
+ render();
+ say(`한 프레임 진행. 높이 ${(get("y") / 100).toFixed(1)} 픽셀.`);
+ });
+ $("jump-button").addEventListener("click", () => jump(1));
+ $("reset-button").addEventListener("click", () => {
+ if (!ready) return;
+ core.reset();
+ core.set_running(0);
+ accumulator = 0;
+ lastTime = null;
+ droppedTicks = 0;
+ notice = "처음 상태로 돌아왔습니다. 멈춘 상태에서 다시 시작할 수 있어요.";
+ syncApplied();
+ text("apply-feedback", "위치·진행 시간·규칙·기록을 초기 상태로 되돌렸습니다.");
+ render();
+ say("세계와 규칙을 초기 상태로 되돌렸습니다.");
+ });
+ document.addEventListener("keydown", (event) => {
+ if (!ready || (event.code !== "Space" && event.key !== " ") || event.isComposing || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
+ const target = event.target;
+ if (target instanceof Element && (target.isContentEditable || target.closest("input, textarea, select, button, a[href], summary, [contenteditable], [role=button]"))) return;
+ event.preventDefault();
+ if (event.repeat) return;
+ jump(0);
+ });
+ document.addEventListener("visibilitychange", () => {
+ if (!ready || !document.hidden) return;
+ const wasRunning = Boolean(get("running"));
+ core.set_running(0);
+ lastTime = null;
+ accumulator = 0;
+ if (wasRunning) {
+ notice = "다른 탭으로 이동해 자동으로 멈췄습니다. 실행을 누르면 이어집니다.";
+ render();
+ say(notice);
+ }
+ });
+ window.addEventListener("resize", render);
+
+ Object.defineProperty(window, "pureDemo", { value: Object.freeze({
+ snapshot() {
+ if (!ready) return Object.freeze({ ready: false });
+ return Object.freeze({ ready: true, ...Object.fromEntries(Object.keys(field).map((name) => [name, get(name)])), draftDirty: isDirty(), droppedTicks, renderCount, wasmMemoryBytes: core.memory?.buffer.byteLength ?? null, logicalBytes: core.bytes(6), decisionTick: core.decision_meta(0), decisionRevision: core.decision_meta(1), decisionNodes: Object.freeze(nodes.map((node) => core.node_value(Number(node.dataset.node)))) });
+ }
+ }), writable: false, configurable: false });
+
+ async function boot() {
+ try {
+ const response = await fetch("core.wasm", { cache: "no-store" });
+ if (!response.ok) throw new Error(`core.wasm HTTP ${response.status}`);
+ const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), {});
+ core = instance.exports;
+ for (const name of ["reset", "set_running", "advance", "step", "input", "apply_rules", "get", "node_value", "decision_meta", "history_count", "history_get", "bytes"]) {
+ if (typeof core[name] !== "function") throw new Error(`필요한 코어 함수가 없습니다: ${name}`);
+ }
+ core.reset();
+ core.set_running(0);
+ ready = true;
+ $("rule-controls").disabled = false;
+ for (const id of ["run-button", "step-button", "jump-button", "reset-button"]) $(id).disabled = false;
+ $("workspace").setAttribute("aria-busy", "false");
+ $("load-message").hidden = true;
+ syncApplied();
+ ["rules", "state", "decision", "history", "engine", "graph", "total"].forEach((name, i) => text(`bytes-${name}`, formatBytes(core.bytes(i))));
+ render();
+ say("준비되었습니다. 멈춘 상태입니다. 점프 버튼을 눌러보세요.");
+ window.requestAnimationFrame(frame);
+ } catch (error) {
+ ready = false;
+ $("workspace").setAttribute("aria-busy", "false");
+ $("load-message").hidden = true;
+ $("load-error").hidden = false;
+ text("running-badge", "실행기 없음");
+ text("error-detail", error instanceof Error ? error.message : String(error));
+ for (const control of document.querySelectorAll("button, #rule-controls")) control.disabled = true;
+ }
+ }
+ boot();
+})();
diff --git a/prototypes/live-rules/web/core.wasm b/prototypes/live-rules/web/core.wasm
new file mode 100755
index 0000000..8c79903
--- /dev/null
+++ b/prototypes/live-rules/web/core.wasm
Binary files differ
diff --git a/prototypes/live-rules/web/index.html b/prototypes/live-rules/web/index.html
new file mode 100644
index 0000000..45ff700
--- /dev/null
+++ b/prototypes/live-rules/web/index.html
@@ -0,0 +1,101 @@
+<!doctype html>
+<html lang="ko">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="theme-color" content="#f5f4ee">
+ <title>Pure — 규칙 실험실</title>
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%2327372b'/%3E%3Ctext x='8' y='23' font-family='Georgia' font-size='26' font-weight='bold' fill='%23f5f4ee'%3Ep%3C/text%3E%3C/svg%3E">
+ <link rel="stylesheet" href="style.css">
+ <script src="app.js" defer></script>
+</head>
+<body>
+ <header class="site-header">
+ <a class="brand" href="./" aria-label="Pure 규칙 실험실 홈"><span class="brand-mark" aria-hidden="true">p.</span><span>Pure <span class="brand-separator">/</span> <span class="brand-subtitle">규칙 실험실</span></span></a>
+ <span class="experiment-tag"><span class="status-dot" aria-hidden="true"></span>실험 002</span>
+ </header>
+
+ <main id="workspace" aria-busy="true">
+ <div class="intro">
+ <div><p class="eyebrow">작게 만들고, 직접 바꿔보기</p><h1>뛰는 도중에, 규칙을 바꿔보세요.</h1><p class="intro-copy">같은 공룡, 같은 순간. 점프 규칙만 바꾸면 무엇이 달라질까요?</p></div>
+ <p class="prototype-label">살아 있는 규칙<br><span>첫 번째 연습장</span></p>
+ </div>
+
+ <div id="load-message" class="notice" role="status">작은 실행기를 불러오고 있습니다…</div>
+ <section id="load-error" class="load-error" role="alert" hidden>
+ <h2>실행기를 불러오지 못했습니다.</h2>
+ <p>프로젝트 루트에서 아래 명령을 실행한 뒤 <a href="http://127.0.0.1:8765">http://127.0.0.1:8765</a>를 여세요. Zig 0.15.2가 필요하며, 파일을 직접 여는 <code>file://</code> 주소는 지원하지 않습니다.</p>
+ <pre><code>python3 prototypes/live-rules/manage.py build
+python3 prototypes/live-rules/manage.py serve --port 8765</code></pre>
+ <p class="error-detail" id="error-detail"></p>
+ </section>
+
+ <div class="workbench">
+ <section class="world-panel panel" aria-labelledby="world-heading">
+ <div class="panel-heading"><h2 id="world-heading"><span class="section-number">01</span>연습장</h2><span id="running-badge" class="badge">불러오는 중</span></div>
+ <div id="stage" class="stage" tabindex="0" role="group" aria-label="공룡 연습장. 스페이스 키로 점프합니다.">
+ <svg id="world" viewBox="0 0 960 350" role="img" aria-labelledby="scene-title scene-description">
+ <title id="scene-title">공룡 점프 연습장</title><desc id="scene-description">규칙을 적용하고 점프 버튼이나 스페이스 키로 움직여 보세요. 현재 상태는 연습장 아래에 표시됩니다.</desc>
+ <defs><pattern id="soil" width="48" height="24" patternUnits="userSpaceOnUse"><path d="M8 12h4M34 5h3" stroke="#c8cec0" stroke-width="2"/></pattern></defs>
+ <circle cx="765" cy="81" r="28" fill="#e7e4ce"/>
+ <path d="M556 277 673 195 744 245 813 201 935 277Z" fill="#e5e9dc"/>
+ <path d="M385 277 505 227 593 277M724 277 859 224 960 265V278" fill="#edf0e7"/>
+ <path d="M229 94h50m-35-10h17m140 53h37" stroke="#d9ded3" stroke-width="3" stroke-linecap="round"/>
+ <rect x="0" y="279" width="960" height="71" fill="#ebeee4"/><rect x="0" y="285" width="960" height="65" fill="url(#soil)"/>
+ <path d="M0 278H960" stroke="#b4bfaa" stroke-width="2"/>
+ <ellipse id="dino-shadow" cx="138" cy="281" rx="27" ry="5" fill="#3e5e36" opacity="0.15"/>
+ <g id="dino" transform="translate(120 230)">
+ <path d="M16 0H36V19H24V25H30V31H24V37H19V48H12V39H6V33H0V17H5V24H11V17H16Z" fill="#356548"/>
+ <path d="M27 5h4v4h-4z" fill="#f8fbf4"/><path d="M24 15h12" stroke="#21432c" stroke-width="2"/>
+ <path id="dino-foot" d="M18 46h6M9 46h6" stroke="#356548" stroke-width="4"/>
+ </g>
+ <g id="obstacle" transform="translate(800 230)"><rect id="obstacle-body" x="0" y="0" width="32" height="48" rx="5" fill="#87916c"/><path id="obstacle-mark" d="M11 10v28m10-21v21" stroke="#6a7957" stroke-width="2" stroke-linecap="round"/></g>
+ <text x="22" y="327" fill="#819075" font-size="12" font-family="ui-monospace, monospace" letter-spacing="2">PURE · RULE PLAYGROUND</text>
+ </svg>
+ <div class="stage-hint"><span class="tiny-dot" aria-hidden="true"></span><span id="stage-hint">일시정지 상태에서도 규칙과 입력을 확인할 수 있어요.</span></div>
+ </div>
+ <div class="transport">
+ <div class="transport-primary"><button id="run-button" class="button button-dark" disabled>실행</button><button id="step-button" class="button button-quiet" disabled>한 프레임</button></div>
+ <button id="jump-button" class="button button-jump" disabled>점프 <kbd>Space</kbd></button>
+ <button id="reset-button" class="button button-reset" disabled title="세계와 규칙을 초기 상태로 되돌립니다">처음부터</button>
+ </div>
+ <p id="runtime-note" class="runtime-note">실행하면 시간이 흐릅니다. 멈춘 뒤 한 프레임씩 살펴볼 수도 있어요.</p>
+ <div class="state-grid" aria-label="현재 상태">
+ <div><span>높이</span><strong id="state-height">—</strong></div><div><span>세로 속도</span><strong id="state-velocity">—</strong></div><div><span>발 디딤</span><strong id="state-ground">—</strong></div><div><span>사용한 점프</span><strong id="state-jumps">—</strong></div><div><span>진행 거리</span><strong id="state-distance">—</strong></div><div><span>진행 시간</span><strong id="state-time">—</strong></div>
+ </div>
+ <div class="world-summary"><span>지나간 장애물 <b id="state-passed">0</b></span><span>닿은 장애물 <b id="state-hits">0</b></span><span class="tick-label">tick <b id="state-tick">0</b></span></div>
+ </section>
+
+ <aside class="rules-panel panel" aria-labelledby="rules-heading">
+ <div class="panel-heading"><h2 id="rules-heading"><span class="section-number">02</span>점프 규칙</h2><span id="revision-badge" class="badge">v—</span></div>
+ <form id="rules-form">
+ <fieldset id="rule-controls" disabled>
+ <legend class="field-label">언제 다시 뛸 수 있나요?</legend>
+ <div class="mode-options"><label class="mode-option"><input type="radio" name="mode" value="0" checked><span><b>땅에서만</b><small>착지해야 다시 점프할 수 있어요.</small></span><span class="radio-mark" aria-hidden="true"></span></label><label class="mode-option"><input type="radio" name="mode" value="1"><span><b>두 번 점프</b><small>공중에서 한 번 더 뛸 수 있어요.</small></span><span class="radio-mark" aria-hidden="true"></span></label></div>
+ <div class="force-heading"><label class="field-label" for="impulse">점프 세기</label><output id="impulse-output" for="impulse">10</output></div>
+ <input type="range" id="impulse" name="impulse" min="6" max="16" step="1" value="10" aria-describedby="force-help"><div class="range-labels"><span>가볍게 · 6</span><span>높이 · 16</span></div><p id="force-help" class="field-help">다음에 받아들인 점프부터 적용됩니다.</p>
+ <label class="enabled-option"><span><b>점프 입력 받기</b><small>끄면 어떤 점프 요청도 받지 않아요.</small></span><input id="enabled" name="enabled" type="checkbox" checked><span class="switch" aria-hidden="true"></span></label>
+ </fieldset>
+ <div class="apply-area"><div class="draft-line"><span id="draft-status">실행기 준비 중</span><span id="applied-label">적용 v—</span></div><button id="apply-button" class="button button-apply" type="submit" disabled>규칙 적용</button><p id="apply-feedback">바꾼 뒤 적용을 눌러주세요. 위치와 진행 시간은 이어집니다.</p></div>
+ </form>
+ <div class="try-card"><span class="eyebrow">이렇게 해보세요</span><ol><li>점프한 뒤, 공중에서 다시 눌러보세요.</li><li>멈춘 상태에서 <b>두 번 점프</b>로 바꾸고 적용하세요.</li><li>위치가 그대로인지 보고 다시 점프해보세요.</li></ol><p>처음엔 멈춘 채로 점프 → 한 프레임을 눌러도 좋아요.</p></div>
+ </aside>
+ </div>
+
+ <section class="decision-panel panel" aria-labelledby="decision-heading">
+ <div class="panel-heading"><h2 id="decision-heading"><span class="section-number">03</span>왜 그렇게 움직였나요?</h2><span class="muted-small">마지막 점프 요청</span></div>
+ <div class="decision-summary"><div><p id="decision-result" class="decision-result">첫 점프를 기다리고 있어요.</p><p id="decision-explanation" class="decision-explanation">한 번 누르면 입력이 규칙을 통과하는 과정을 볼 수 있습니다.</p></div><div id="decision-meta" class="decision-meta">아직 입력 없음</div></div>
+ <p id="stale-decision" class="stale-note" hidden>이 기록은 이전 규칙의 판단입니다. 다시 점프하면 새 규칙으로 확인할 수 있어요.</p>
+ <ol id="decision-path" class="decision-path" aria-label="마지막 입력 판단 경로">
+ <li data-node="0"><span class="node-step">01</span><strong>요청 도착</strong><span class="node-value">대기</span></li><li data-node="1"><span class="node-step">02</span><strong>입력 켜짐</strong><span class="node-value">대기</span></li><li data-node="2"><span class="node-step">03</span><strong>땅에 있음</strong><span class="node-value">대기</span></li><li data-node="3"><span class="node-step">04</span><strong>점프 여유</strong><span class="node-value">대기</span></li><li data-node="4"><span class="node-step">05</span><strong>방식에 맞음</strong><span class="node-value">대기</span></li><li data-node="5"><span class="node-step">06</span><strong>점프 허용</strong><span class="node-value">대기</span></li>
+ </ol>
+ <p class="path-caption">요청과 입력 켜짐을 확인하고, 선택한 방식에 따라 ‘땅에 있음’ 또는 ‘점프 여유’를 판단합니다. 기록은 입력을 받은 순간의 상태입니다.</p>
+ <div class="history-section"><h3>최근 변화 <span>최대 8개 표시</span></h3><ol id="history-list" class="history-list"><li class="empty-history">점프하거나 규칙을 적용하면 이곳에 기록됩니다.</li></ol></div>
+ </section>
+
+ <details class="research-panel"><summary><span>실험의 범위와 메모리 보기</span><span class="summary-caption">기술적인 내용은 여기에</span></summary><div class="research-body"><div><h2>무엇을 확인하는 실험인가요?</h2><p>세계가 실행되는 동안 점프 규칙을 바꾸고, 상태를 유지하며, 입력 판단을 살펴보는 작은 시제품입니다. 물리 계산과 규칙 판단은 Zig로 작성한 동일한 WebAssembly 코어에서 처리합니다. 화면은 그 결과를 보여줍니다.</p><p>시간은 고정 60Hz 틱으로 진행합니다. 느린 화면에서는 한 번에 최대 6틱을 처리하며, 생략한 틱 수를 아래에 표시합니다. 실제 시간과 정확히 같다는 보장은 없습니다.</p><p>높이·속도·거리는 세계 좌표(px)로 표시합니다. 높은 점프에서는 시점이 공룡을 따라 위로 이동합니다. 화면 크기와 시점은 물리 계산을 바꾸지 않습니다.</p><p id="scheduling-detail" class="metric-note">아직 실행 전 · 생략한 틱 0</p></div><div><h2>코어의 자료 구조 크기</h2><table class="memory-table"><caption>코어가 보고한 논리 자료 크기 · 실제 프로세스 메모리와 다름</caption><tbody><tr><th scope="row">규칙</th><td id="bytes-rules">—</td></tr><tr><th scope="row">세계 상태</th><td id="bytes-state">—</td></tr><tr><th scope="row">마지막 판단</th><td id="bytes-decision">—</td></tr><tr><th scope="row">기록과 관리 정보</th><td id="bytes-history">—</td></tr><tr><th scope="row">엔진 전체 · 정렬 포함</th><td id="bytes-engine">—</td></tr><tr><th scope="row">고정 규칙 그래프</th><td id="bytes-graph">—</td></tr><tr class="total-row"><th scope="row">엔진 + 그래프 · 논리 합계</th><td id="bytes-total">—</td></tr><tr><th scope="row">WASM 선형 메모리 · 별도</th><td id="bytes-linear">—</td></tr></tbody></table><p class="metric-note">자료 구조 크기는 Zig 타입 배치 기준입니다. 고정 그래프는 컴파일 과정에서 코드에 통합될 수 있어, 이 수치를 실제 RAM 사용량으로 읽으면 안 됩니다. WASM 선형 메모리는 실제 할당된 별도 범위이며 위 합계와 더하지 않습니다. 브라우저·화면·코드·프로세스 전체 메모리(RSS)는 이 표에 포함되지 않습니다. M3 성능이나 최적화 우위를 입증하는 측정도 아닙니다.</p></div></div></details>
+ <p id="live-status" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></p>
+ </main>
+ <footer>Pure <span>·</span> 원하는 동작을 먼저, 근거는 남기면서.</footer>
+</body>
+</html>
diff --git a/prototypes/live-rules/web/style.css b/prototypes/live-rules/web/style.css
new file mode 100644
index 0000000..95207c8
--- /dev/null
+++ b/prototypes/live-rules/web/style.css
@@ -0,0 +1 @@
+:root{color-scheme:light;--page:#f5f4ee;--card:#fffefa;--ink:#27372b;--muted:#74806f;--line:#e3e6dc;--green:#356548;--green-light:#edf3e9;--amber:#a36a31;--amber-light:#faf0df;--radius:16px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:15px}*{box-sizing:border-box}body{margin:0;color:var(--ink);background:var(--page)}button,input{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.46}a{color:inherit}button,input,a,summary,[tabindex]{-webkit-tap-highlight-color:transparent}button:focus-visible,input:focus-visible,a:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:3px solid #b37c39;outline-offset:4px}button,input{accent-color:var(--green)}[hidden]{display:none!important}.site-header{height:94px;max-width:1256px;padding:0 36px;margin:auto;display:flex;align-items:center;justify-content:space-between}.brand{display:inline-flex;align-items:center;gap:13px;font-size:19px;font-weight:750;text-decoration:none;letter-spacing:-.5px}.brand-mark{display:grid;place-items:center;width:35px;height:35px;background:var(--ink);color:var(--page);border-radius:9px;font:700 27px Georgia,serif;padding-bottom:4px}.brand-separator{margin:0 10px;color:#b1b7a9;font-weight:400}.brand-subtitle{font-size:14px;font-weight:500;letter-spacing:-.3px}.experiment-tag{display:inline-flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.status-dot,.tiny-dot{display:inline-block;width:6px;height:6px;background:#82a06e;border-radius:50%}main{max-width:1256px;margin:0 auto;padding:28px 36px 0}.intro{display:flex;align-items:center;justify-content:space-between;gap:32px;margin-bottom:30px}.eyebrow{font-size:11px;letter-spacing:1.25px;font-weight:650;color:var(--muted);margin:0 0 11px}h1{font-size:clamp(25px,2.6vw,34px);font-weight:650;line-height:1.35;letter-spacing:-1.3px;margin:0 0 11px}.intro-copy{color:var(--muted);font-size:14px;line-height:1.7;margin:0}.prototype-label{flex-shrink:0;text-align:right;color:var(--green);font-size:12px;font-weight:600;line-height:1.85;margin:0;border-left:1px solid var(--line);padding-left:35px}.prototype-label span{color:var(--muted);font-size:11px;font-weight:400}.notice,.load-error{border:1px solid var(--line);padding:16px 20px;border-radius:10px;margin-bottom:20px;font-size:13px;line-height:1.7;background:var(--card)}.load-error{background:#fff0e8;border-color:#e9c6b7;color:#7b392b}.load-error h2{font-size:17px}.load-error pre{overflow:auto;padding:12px;background:#fff9f3;border-radius:7px;font-size:12px}.error-detail{font-family:ui-monospace,monospace;font-size:11px;overflow-wrap:anywhere}.workbench{display:grid;grid-template-columns:minmax(0,1fr) 322px;gap:20px;align-items:start}.panel{border:1px solid var(--line);border-radius:var(--radius);background:var(--card);overflow:hidden}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:22px 24px 19px}.panel-heading h2{font-size:14px;font-weight:650;margin:0;letter-spacing:-.3px;display:flex;align-items:center;gap:9px}.section-number{color:#9aa28f;font:11px ui-monospace,monospace;font-weight:400}.badge{font-size:10px;line-height:1;padding:6px 8px;background:#f0f1eb;color:#6e7967;border-radius:5px;font-variant-numeric:tabular-nums;white-space:nowrap}.badge.running{background:var(--green-light);color:var(--green)}.stage{position:relative;margin:0 14px;background:#f5f7f0;border:1px solid #e7ebdf;border-radius:10px;overflow:hidden;line-height:0}#world{display:block;width:100%;height:auto;min-height:230px}.stage-hint{position:absolute;top:16px;left:17px;right:15px;display:flex;align-items:center;gap:7px;color:#7a866f;font-size:10px;line-height:1.5}.tiny-dot{width:4px;height:4px;flex-shrink:0;background:#a5b496}.transport{display:flex;align-items:center;gap:9px;padding:20px 24px 0;flex-wrap:wrap}.transport-primary{display:flex;gap:7px}.button{border:1px solid transparent;border-radius:7px;line-height:1.25;font-size:12px;font-weight:550;padding:11px 15px;transition:background .15s,border-color .15s,transform .15s}.button:active:not(:disabled){transform:translateY(1px)}.button-dark{background:var(--ink);color:white;min-width:78px}.button-dark:hover:not(:disabled){background:#425544}.button-quiet{background:transparent;border-color:var(--line);color:var(--ink)}.button-quiet:hover:not(:disabled),.button-reset:hover:not(:disabled){background:#f2f3ec}.button-jump{margin-left:auto;background:var(--green-light);border-color:#dce6d5;color:var(--green);display:flex;align-items:center;gap:20px}.button-jump:hover:not(:disabled){background:#deead7}.button-jump kbd{font:9px ui-monospace,monospace;padding:2px 4px;border:1px solid #c7d5be;border-radius:3px;color:#6d865e}.button-reset{background:transparent;color:var(--muted);font-size:11px;padding-left:9px;padding-right:9px}.runtime-note{font-size:11px;color:var(--muted);line-height:1.7;margin:12px 24px 21px;min-height:19px}.state-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:23px 18px;padding:22px 24px;border-top:1px solid var(--line)}.state-grid>div{display:flex;flex-direction:column;gap:7px}.state-grid span{color:var(--muted);font-size:10px}.state-grid strong{font-size:16px;font-weight:550;font-variant-numeric:tabular-nums;letter-spacing:-.5px}.world-summary{display:flex;gap:22px;padding:14px 24px;border-top:1px solid var(--line);font-size:10px;color:var(--muted);background:#fcfcf7}.world-summary b{margin-left:5px;font-weight:550;color:#58694f;font-variant-numeric:tabular-nums}.tick-label{margin-left:auto;font-family:ui-monospace,monospace}.rules-panel .panel-heading{padding-bottom:22px}#rules-form{padding:0 23px}fieldset{border:0;padding:0;margin:0;min-width:0}.field-label{font-size:11px;font-weight:600;display:block;margin:0 0 12px}.mode-options{display:flex;flex-direction:column;gap:8px;margin-bottom:25px}.mode-option{position:relative;display:flex;align-items:center;gap:10px;padding:14px 12px;border:1px solid var(--line);border-radius:8px;cursor:pointer;background:var(--card);min-width:0}.mode-option:has(input:checked){border-color:#8fa783;background:#f1f5ec}.mode-option:has(input:focus-visible){outline:3px solid #b37c39;outline-offset:3px}.mode-option input{position:absolute;opacity:0;width:1px;height:1px}.mode-option>span:first-of-type{flex:1}.mode-option b,.enabled-option b{display:block;font-size:12px;font-weight:550}.mode-option small,.enabled-option small{display:block;font-size:10px;line-height:1.6;color:var(--muted);margin-top:5px;word-break:keep-all}.radio-mark{width:13px;height:13px;border:1px solid #c2cbb9;border-radius:50%;display:grid;place-items:center;flex-shrink:0}.mode-option input:checked~.radio-mark{border-color:var(--green)}.mode-option input:checked~.radio-mark:after{content:"";background:var(--green);width:5px;height:5px;border-radius:50%}.force-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.force-heading .field-label{margin:0}.force-heading output{background:#f1f3ec;font-size:12px;font-variant-numeric:tabular-nums;padding:4px 9px;border:1px solid var(--line);border-radius:5px;min-width:34px;text-align:center}input[type=range]{display:block;width:100%;margin:14px 0 9px;cursor:pointer;height:16px}.range-labels{display:flex;justify-content:space-between;font-size:9px;color:#8a937f}.field-help{font-size:10px;color:var(--muted);margin:10px 0 21px;line-height:1.6}.enabled-option{display:flex;align-items:center;position:relative;cursor:pointer;gap:15px;border-top:1px solid var(--line);padding-top:19px}.enabled-option>span:first-child{flex:1}.enabled-option input{position:absolute;right:4px;opacity:0;width:28px;height:20px}.switch{width:31px;height:19px;background:#ced4c7;border:1px solid #c5cdbe;border-radius:20px;padding:2px;flex-shrink:0;transition:background .15s}.switch:after{content:"";display:block;width:13px;height:13px;background:white;border-radius:50%;box-shadow:0 1px 2px #0002;transition:transform .15s}.enabled-option input:checked+.switch{background:var(--green);border-color:var(--green)}.enabled-option input:checked+.switch:after{transform:translateX(12px)}.enabled-option input:focus-visible+.switch{outline:3px solid #b37c39;outline-offset:4px}.apply-area{margin-top:23px}.draft-line{display:flex;justify-content:space-between;gap:10px;font-size:10px;color:var(--muted);margin-bottom:9px}.draft-line.dirty #draft-status{color:var(--amber);font-weight:600}.button-apply{display:block;width:100%;background:var(--green);color:white;font-size:12px;padding:12px}.button-apply:hover:not(:disabled){background:#284e36}#apply-feedback{font-size:10px;line-height:1.7;min-height:35px;margin:10px 0 22px;color:var(--muted)}.try-card{background:#f4f5ee;border-top:1px solid var(--line);padding:20px 23px}.try-card .eyebrow{font-size:10px;letter-spacing:.8px;display:block;margin-bottom:11px}.try-card ol{font-size:11px;line-height:1.8;padding-left:17px;margin:0;color:#627058}.try-card li+li{margin-top:5px}.try-card b{font-weight:600}.try-card p{font-size:10px;color:#859079;line-height:1.65;margin:12px 0 0}.decision-panel{margin-top:20px}.muted-small{font-size:10px;color:var(--muted)}.decision-summary{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:0 24px 19px}.decision-result{margin:0 0 7px;font-size:18px;letter-spacing:-.6px;font-weight:550}.decision-result.accepted{color:var(--green)}.decision-result.rejected{color:var(--amber)}.decision-explanation{font-size:11px;color:var(--muted);line-height:1.7;margin:0}.decision-meta{font-size:10px;color:var(--muted);text-align:right;line-height:1.9;white-space:nowrap;font-variant-numeric:tabular-nums}.stale-note{font-size:11px;color:var(--amber);background:var(--amber-light);margin:0 24px 17px;padding:10px 12px;border-radius:6px;line-height:1.7}.decision-path{list-style:none;display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:12px;margin:0;padding:0 24px}.decision-path li{position:relative;border:1px solid var(--line);background:#fafbf6;border-radius:8px;padding:12px;min-width:0}.decision-path li:not(:last-child):after{position:absolute;right:-10px;top:50%;content:"›";color:#a4af9a;font-size:12px;line-height:0}.decision-path li[data-result=true]{background:#f0f5eb;border-color:#d4e2ca}.decision-path li[data-result=false]{background:#fbf6ed;border-color:#ebdfc9}.node-step{display:block;font:9px ui-monospace,monospace;color:#a2aa97;margin-bottom:13px}.decision-path strong{display:block;font-size:11px;font-weight:550;white-space:nowrap}.node-value{display:block;font-size:10px;color:#8b9580;margin-top:6px}.decision-path li[data-result=true] .node-value{color:var(--green)}.decision-path li[data-result=false] .node-value{color:var(--amber)}.path-caption{font-size:10px;line-height:1.7;color:#85907b;padding:0 24px;margin:12px 0 21px}.history-section{border-top:1px solid var(--line);padding:19px 24px 20px;display:grid;grid-template-columns:145px minmax(0,1fr);gap:25px}.history-section h3{font-size:11px;font-weight:550;margin:3px 0;line-height:1.6}.history-section h3 span{display:block;font-size:9px;color:#939d88;font-weight:400;margin-top:4px}.history-list{list-style:none;padding:0;margin:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 25px}.history-list li{display:flex;gap:10px;align-items:baseline;font-size:10px;line-height:1.7;color:#69755e;min-width:0}.history-list .history-time{font:9px ui-monospace,monospace;color:#9aa38f;flex-shrink:0}.history-list .history-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.history-list .empty-history{grid-column:1/-1;color:#8d9781}.research-panel{margin-top:20px;border:1px solid var(--line);border-radius:12px;background:#f0f1e9;overflow:hidden}.research-panel summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:12px;padding:19px 23px;font-size:12px;font-weight:500}.research-panel summary::-webkit-details-marker{display:none}.research-panel summary:before{content:"+";font:16px ui-monospace,monospace;color:#8a947e}.research-panel[open] summary:before{content:"−"}.summary-caption{margin-left:auto;font-size:10px;font-weight:400;color:#8d9781}.research-body{border-top:1px solid var(--line);display:grid;grid-template-columns:1fr 1fr;gap:60px;padding:24px}.research-body h2{font-size:12px;margin:0 0 14px;font-weight:600}.research-body p{font-size:11px;line-height:1.85;color:#74806a;margin:0 0 13px}.memory-table{width:100%;border-collapse:collapse;font-size:10px;line-height:1.6;font-variant-numeric:tabular-nums}.memory-table caption{text-align:left;font-size:9px;color:#8c967f;padding-bottom:9px}.memory-table th{text-align:left;font-weight:400;padding:5px 0;color:#6b7762}.memory-table td{text-align:right;color:#4f6249;padding:5px 0;font-family:ui-monospace,monospace}.memory-table .total-row th,.memory-table .total-row td{border-top:1px solid #d6ddcd;font-weight:600;padding-top:9px}.research-body .metric-note{font-size:10px;margin-top:12px;color:#8a947f}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}footer{max-width:1256px;margin:0 auto;padding:30px 36px 35px;font-size:10px;color:#9ba48f;display:flex;align-items:center;gap:9px}footer span{color:#bdc4b4}@media(min-width:1100px){.state-grid{grid-template-columns:repeat(6,minmax(0,1fr));gap:10px}.state-grid strong{font-size:15px}.stage-hint{font-size:11px}}@media(max-width:900px){.site-header{height:77px;padding:0 24px}main{padding:22px 24px 0}.workbench{grid-template-columns:minmax(0,1fr) 286px;gap:15px}.panel-heading{padding:20px 19px 17px}#rules-form{padding:0 18px}.try-card{padding:18px}.transport{padding:17px 19px 0;gap:7px}.button{padding:10px 11px}.button-jump{gap:12px}.runtime-note{margin-left:19px;margin-right:19px}.state-grid{padding:19px}.world-summary{padding:13px 19px;gap:12px}.decision-path{gap:9px;padding:0 19px}.decision-path li{padding:10px 8px}.decision-path li:not(:last-child):after{right:-8px}.decision-path strong{font-size:10px}.decision-summary{padding-left:19px;padding-right:19px}.history-section{grid-template-columns:110px 1fr;padding-left:19px;padding-right:19px;gap:15px}.history-list{column-gap:14px}.intro{gap:20px}.prototype-label{display:none}.research-body{gap:32px}footer{padding-left:24px;padding-right:24px}}@media(max-width:700px){.site-header{height:70px;padding:0 18px}.brand{gap:9px;font-size:18px}.brand-separator{margin:0 6px}.brand-subtitle{font-size:12px}.experiment-tag{font-size:10px}main{padding:18px 16px 0}.intro{margin-bottom:24px}.eyebrow{font-size:10px}h1{font-size:25px;letter-spacing:-1px}.intro-copy{font-size:12px;word-break:keep-all}.workbench{grid-template-columns:1fr;gap:16px}.stage{margin:0 11px}#world{min-height:200px}.stage-hint{font-size:9px;left:11px;top:12px;max-width:280px}.transport{padding-left:17px;padding-right:17px;flex-wrap:nowrap;gap:6px}.button{font-size:11px;padding:11px 10px}.button-jump{gap:10px}.button-jump kbd{font-size:8px}.button-reset{font-size:10px;padding:10px 5px}.button-dark{min-width:69px}.state-grid{grid-template-columns:repeat(3,1fr);gap:20px}.state-grid strong{font-size:16px}.rules-panel .panel-heading{padding-bottom:19px}.mode-options{flex-direction:row;gap:9px;margin-bottom:21px}.mode-option{flex:1;padding:13px 11px;gap:7px}.mode-option b{font-size:11px}.mode-option small{font-size:9px}.radio-mark{width:12px;height:12px}.force-heading{margin-top:4px}.enabled-option{padding-top:15px}.apply-area{margin-top:21px}.try-card{padding:19px}.try-card li+li{margin-top:2px}.decision-panel{margin-top:16px}.decision-summary{align-items:flex-start;gap:15px}.decision-result{font-size:16px}.decision-explanation{font-size:10px}.decision-meta{font-size:9px}.decision-path{grid-template-columns:repeat(3,1fr);gap:9px}.decision-path li{padding:12px}.decision-path strong{font-size:11px}.decision-path li:nth-child(3):after{display:none}.node-step{margin-bottom:9px}.path-caption{padding:0 19px;font-size:9px}.history-section{display:block}.history-section h3{display:flex;justify-content:space-between;margin:0 0 13px}.history-section h3 span{margin:0}.history-list{grid-template-columns:1fr;gap:5px}.history-list li{font-size:10px}.stale-note{margin-left:19px;margin-right:19px;font-size:10px}.research-panel{margin-top:16px}.research-panel summary{padding:17px;font-size:11px}.summary-caption{font-size:9px}.research-body{grid-template-columns:1fr;gap:19px;padding:19px}.research-body>div+div{border-top:1px solid var(--line);padding-top:20px}footer{padding:24px 18px;font-size:9px}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{transition:none!important}}