aboutsummaryrefslogtreecommitdiffstats
path: root/prototypes/live-rules
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
parentd34f9426d5317a1948f12f229439946728bc342a (diff)
downloadpure-b1ce1927022d374198441afafeb27199fc8a052a.tar.gz
pure-b1ce1927022d374198441afafeb27199fc8a052a.zip
Establish native micro runtime checkpoint
Diffstat (limited to 'prototypes/live-rules')
-rw-r--r--prototypes/live-rules/.gitignore1
-rw-r--r--prototypes/live-rules/README.md126
-rw-r--r--prototypes/live-rules/adapter.test.mjs96
-rw-r--r--prototypes/live-rules/cli.mjs64
-rw-r--r--prototypes/live-rules/core.zig504
-rw-r--r--prototypes/live-rules/manage.py88
-rw-r--r--prototypes/live-rules/measure.mjs48
-rw-r--r--prototypes/live-rules/qa-browser.js131
-rw-r--r--prototypes/live-rules/results/2026-09-22-linux-aarch64/browser-checks.json215
-rw-r--r--prototypes/live-rules/results/2026-09-22-linux-aarch64/desktop.pngbin0 -> 150506 bytes
-rw-r--r--prototypes/live-rules/results/2026-09-22-linux-aarch64/mobile.pngbin0 -> 123520 bytes
-rw-r--r--prototypes/live-rules/results/2026-09-22-linux-aarch64/storage-and-replay.json503
-rw-r--r--prototypes/live-rules/runtime.mjs100
-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
17 files changed, 2272 insertions, 0 deletions
diff --git a/prototypes/live-rules/.gitignore b/prototypes/live-rules/.gitignore
new file mode 100644
index 0000000..84c048a
--- /dev/null
+++ b/prototypes/live-rules/.gitignore
@@ -0,0 +1 @@
+/build/
diff --git a/prototypes/live-rules/README.md b/prototypes/live-rules/README.md
new file mode 100644
index 0000000..a32d697
--- /dev/null
+++ b/prototypes/live-rules/README.md
@@ -0,0 +1,126 @@
+# Pure · 실행 중 규칙 바꾸기
+
+공룡 연습 장면에서 점프 규칙을 바꾸고, 현재 상태와 입력이 허용·거절된 이유를
+확인하는 첫 사용 경험 실험이다. 부딪혀도 계속 진행되며, 규칙을 적용해도 위치·속도·
+진행 상태는 유지된다. **처음부터**만 규칙과 상태를 함께 초기화한다.
+
+Zig 코어 하나를 WebAssembly로 빌드해 브라우저와 터미널에서 사용한다.
+JavaScript에는 게임 물리나 별도의 규칙 실행기가 없다. 이 데모의 목적은 편집 의미와
+관찰 비용을 확인하는 것이다. 일반 런타임·전체 앱 편집기나 성능 우월성을 입증한 결과는 아니다.
+
+## 실행
+
+저장소 루트에서 다음을 실행한다. 외부 JavaScript 패키지는 필요하지 않다.
+
+```sh
+python3 prototypes/live-rules/manage.py serve --port 8765
+```
+
+브라우저에서 <http://127.0.0.1:8765>를 연다. 포함된 `web/core.wasm`을 사용한다.
+`file://`로 HTML을 직접 여는 대신 위 서버를 사용한다. 서버는 로컬 주소에만 바인딩한다.
+
+소스 수정 후에는 [Zig 0.15.2](https://ziglang.org/download/)로 다시 빌드한다.
+새 버전과 구분해 재현하기 위해 이 버전으로 고정했다. 전체 재빌드에는 인터넷이 필요하지 않다.
+
+```sh
+python3 prototypes/live-rules/manage.py build
+# PATH에 없는 컴파일러를 지정할 때:
+python3 prototypes/live-rules/manage.py build --zig /path/to/zig
+```
+
+빌드는 네이티브 테스트를 먼저 수행한다. 그 뒤 `wasm32-freestanding`, `ReleaseSafe`,
+스택 예약 16 KiB, 초기·최대 선형 메모리 64 KiB로 빌드한다. 컴파일 캐시는 `build/`에 둔다.
+버전·소스 해시·바이너리 해시·검사 출력은 `build/build.json`에 저장한다.
+Wasm 파일 크기와 선형 메모리 용량은 서로 다른 값이다.
+
+## 직접 확인할 작업
+
+1. 시작하고 **점프**를 누른다. 공중에서 다시 누르면 왜 거절됐는지 확인한다.
+2. 공중에서 일시정지한다. 점프 조건을 **두 번 점프**로 바꾸고 **규칙 적용**을 누른다.
+3. 위치와 진행 상태가 그대로인지 보고 다시 점프한다. 새 규칙의 판정이 표시되는지 확인한다.
+4. 점프를 비활성화해 거절 이유를 확인한 뒤 다시 활성화한다.
+
+시간을 놓치면 정지 상태에서 점프하고 **한 프레임**씩 진행해도 된다.
+정지 중 입력도 판정하지만, 실제 위치 이동은 프레임을 진행할 때 일어난다.
+규칙을 바꾸기 전의 마지막 판정은 해당 규칙 버전의 기록으로 남는다.
+입력과 규칙 변경은 한 프레임 처리 사이에서 순서대로 적용한다.
+
+## 같은 코어를 터미널에서 사용
+
+Node.js 20 이상으로 실행한다. 검증 환경은 Node 24다.
+
+```sh
+node prototypes/live-rules/cli.mjs
+node prototypes/live-rules/cli.mjs --demo
+node prototypes/live-rules/cli.mjs --demo --json
+```
+
+터미널에서는 시간이 저절로 흐르지 않는다. 아래 명령을 순서대로 입력하면
+공중에서의 거절과 규칙 수정 후 두 번째 점프를 정확히 같은 프레임에서 관찰할 수 있다.
+
+```text
+jump
+step 8
+jump
+rules double 12 on
+jump
+inspect
+memory
+```
+
+`help`는 명령 안내, `quit`은 종료다. 규칙 편집은 세 값을 한꺼번에 검증해 적용한다.
+지원하지 않는 값이나 잘못된 명령은 이전 규칙과 상태를 보존한 채 거절한다.
+
+## 구조와 측정 범위
+
+| 파일 | 역할 |
+|---|---|
+| `core.zig` | 실제 여섯 노드 판정 그래프, 고정 간격 물리, 규칙 변경, 상태·판정·기록 보관, 네이티브 테스트 |
+| `web/` | 같은 코어에 연결하는 화면·키보드·버튼 어댑터 |
+| `runtime.mjs`, `cli.mjs` | 같은 Wasm을 로드하는 터미널 어댑터 |
+| `adapter.test.mjs` | 실제 Wasm과 입력 어댑터의 의미·메모리 한도 검증 |
+| `measure.mjs` | 자료 구조 크기, 정확한 변경 기록, 소스·바이너리 해시 저장 |
+
+규칙은 점프 조건·힘·활성화 세 항목이다. 입력·활성 여부·접지 여부·남은 점프 수를
+읽고, 조건을 선택한 뒤 AND로 합류하는 실제 고정 그래프를 평가한다.
+연산이나 연결을 자유롭게 추가하는 그래프 편집기는 아직 없다.
+
+자료 구조 크기는 Zig의 `@sizeOf`로 계산한다. 그래프 상수는 컴파일러가 코드로
+접을 수도 있으므로 이 합계를 물리 메모리 실측으로 해석하지 않는다.
+Wasm 선형 메모리 64 KiB에는 코어 데이터와 스택 여유가 포함되며, 둘을 더하지 않는다.
+브라우저/Node 엔진, DOM·렌더러, 코드와 프로세스 메모리는 별도이며 이번 수치에 포함하지 않는다.
+
+가장 최근 입력의 판정과 최근 사건 16개만 보관한다. 오래된 사건을 덮어쓰는
+이 기록은 완전한 실행 재생 로그가 아니다. 프레임 입력 큐를 만들어 몰래 버리는 방식도 아니다.
+브라우저가 숨겨지면 일시정지하고, 프레임 처리가 밀린 경우의 시간 제한은 화면에 표시한다.
+이 데모는 하드 실시간 동작을 보장하지 않는다.
+
+## 검증 및 기록 생성
+
+```sh
+python3 prototypes/live-rules/manage.py build
+node prototypes/live-rules/adapter.test.mjs
+node prototypes/live-rules/measure.mjs prototypes/live-rules/results/my-run
+```
+
+측정 출력 폴더의 상위 `results/`는 있어야 하며, 이미 있는 출력 폴더를 덮어쓰지 않는다.
+측정 스크립트는 처리 속도·입력 지연을 측정하지 않는다. 브라우저를 통한 실제 조작 확인은
+이 자동 검사와 별도로 수행한다. 자세한 결과는 [연구 보고서 003](../../docs/research/003-live-rules.md)에 있다.
+
+브라우저 검증용 `qa-browser.js`는 Playwright CLI의 `run-code`에 전달하는 함수 표현식이다.
+서버를 실행하고 CLI로 해당 페이지를 연 뒤 다음처럼 파일 내용을 인수로 전달할 수 있다.
+검증에 사용한 CLI 버전은 0.1.21, 브라우저는 Chromium 151이며 런타임의 의존성은 아니다.
+Playwright가 사용하는 브라우저의 설치 또는 실행 경로 설정은 별도로 필요하다.
+
+```sh
+npx @playwright/cli -s=pure-live open http://127.0.0.1:8765
+python3 - <<'PY'
+from pathlib import Path
+import subprocess
+Path('output/playwright').mkdir(parents=True, exist_ok=True)
+script = Path('prototypes/live-rules/qa-browser.js').read_text()
+subprocess.run(['npx', '@playwright/cli', '-s=pure-live', 'run-code', script], check=True)
+PY
+```
+
+이 검사는 화면과 상태를 초기화하고 여러 시나리오를 조작한다. 개발용 페이지에서 실행한다.
diff --git a/prototypes/live-rules/adapter.test.mjs b/prototypes/live-rules/adapter.test.mjs
new file mode 100644
index 0000000..95af13b
--- /dev/null
+++ b/prototypes/live-rules/adapter.test.mjs
@@ -0,0 +1,96 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { loadCore, snapshot, storage, parseCommand, execute } from './runtime.mjs';
+
+test('the actual Wasm preserves airborne state through a rule change, then permits the second jump', async () => {
+ const c = await loadCore();
+ execute(c, parseCommand('jump'));
+ execute(c, parseCommand('step 8'));
+ execute(c, parseCommand('jump'));
+ const before = snapshot(c);
+ assert.equal(before.state.lastReason, 3);
+ const after = execute(c, parseCommand('rules double 12 on'));
+ for (const field of ['tick', 'y', 'vy', 'distance', 'obstacleX', 'jumpsUsed', 'hits', 'passed']) {
+ assert.equal(after.state[field], before.state[field], field);
+ }
+ assert.deepEqual(after.decision, before.decision);
+ assert.equal(after.state.revision, 1);
+ execute(c, parseCommand('jump'));
+ assert.equal(c.get(10), 1);
+ assert.equal(c.get(2), 1200);
+ assert.equal(c.get(7), 2);
+ assert.equal(c.input(2), 0);
+ assert.equal(c.get(10), 4);
+});
+
+test('bad CLI edits and malformed numeric input cannot silently coerce into a different rule', async () => {
+ const c = await loadCore();
+ const before = snapshot(c);
+ for (const command of ['rules double 8.5 on', 'rules double 4294967306 on', 'rules ground 5 on',
+ 'rules other 10 on', 'rules double 10 maybe', 'rules double 10 on extra', 'step 0', 'step 601', 'jump extra']) {
+ assert.throws(() => execute(c, parseCommand(command)), undefined, command);
+ assert.deepEqual(snapshot(c), before);
+ }
+ assert.equal(c.apply_rules(2, 10, 1), 1);
+ assert.deepEqual(snapshot(c), before);
+});
+
+test('keyboard, pointer and terminal requests give the same simulation result', async () => {
+ const states = [];
+ for (const source of [0, 1, 2]) {
+ const c = await loadCore();
+ c.apply_rules(1, 12, 1);
+ c.input(source);
+ for (let i = 0; i < 8; i++) c.step();
+ c.input(source);
+ const { lastSource, ...state } = snapshot(c).state;
+ assert.equal(lastSource, source);
+ states.push(state);
+ }
+ assert.deepEqual(states[0], states[1]);
+ assert.deepEqual(states[1], states[2]);
+});
+
+test('paused input has an inspectable decision; advance cannot move paused state', async () => {
+ const c = await loadCore();
+ assert.equal(snapshot(c).decision, null);
+ assert.equal(c.input(0), 1);
+ assert.equal(c.get(1), 0);
+ assert.equal(c.get(17), 0);
+ const before = snapshot(c);
+ assert.equal(c.advance(60), 0);
+ assert.deepEqual(snapshot(c), before);
+ c.step();
+ assert.equal(c.get(1), 945);
+ assert.equal(c.get(0), 1);
+});
+
+test('bounded history and fixed linear memory survive repeated play and edits', async () => {
+ const c = await loadCore();
+ const before = storage(c);
+ c.set_running(1);
+ for (let i = 0; i < 5000; i++) {
+ c.apply_rules(i % 2, 6 + i % 11, 1);
+ c.input(i % 3);
+ c.advance(8);
+ }
+ assert.equal(c.history_count(), 16);
+ assert.equal(c.history_get(16, 0), -1);
+ assert.equal(c.get(0), 40000);
+ assert.deepEqual(storage(c), before);
+ assert.equal(before.wasmLinearMemoryBytes, 65536);
+ assert.throws(() => c.memory.grow(1), RangeError);
+ const b = before.layoutBytes;
+ assert.equal(b.logicalTotal, b.engine + b.graph);
+ assert.ok(b.engine >= b.rules + b.state + b.decision + b.history);
+});
+
+test('replaying the same CLI operations produces identical snapshots and journal', async () => {
+ const trace = ['jump', 'step 8', 'jump', 'rules double 10 on', 'jump', 'step 60', 'rules ground 8 off', 'jump'];
+ const snapshots = [];
+ for (let i = 0; i < 2; i++) {
+ const c = await loadCore();
+ snapshots.push(trace.map((line) => execute(c, parseCommand(line))));
+ }
+ assert.deepEqual(snapshots[0], snapshots[1]);
+});
diff --git a/prototypes/live-rules/cli.mjs b/prototypes/live-rules/cli.mjs
new file mode 100644
index 0000000..2c8a564
--- /dev/null
+++ b/prototypes/live-rules/cli.mjs
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+import { createInterface } from 'node:readline';
+import { loadCore, storage, parseCommand, execute } from './runtime.mjs';
+
+const help = `Pure · 규칙 실험실 (브라우저와 같은 Zig 코어)
+ jump 점프 입력
+ step [1..600] 지정한 프레임만 진행 (기본 1)
+ rules ground|double 6..16 on|off
+ 점프 조건·힘·활성화를 함께 적용
+ status / inspect / memory 현재 상태 / 마지막 판정·기록 / 공간
+ reset 규칙과 상태 모두 처음으로
+ help / quit
+예: jump → step 8 → jump → rules double 10 on → jump
+시간은 step으로만 진행합니다. 실행 규칙 변경은 진행 상태를 보존합니다.`;
+
+const reason = ['아직 입력 없음', '점프 허용', '점프 비활성', '공중: 땅에서만 가능', '두 번 모두 사용', '지원하지 않는 입력'];
+
+async function main() {
+ const args = process.argv.slice(2);
+ if (args.includes('--help')) { console.log(help); return; }
+ if (args.some((x) => !['--demo', '--json'].includes(x))) throw new Error('사용법: node cli.mjs [--demo] [--json]');
+ const json = args.includes('--json');
+ const core = await loadCore();
+ function show(line) {
+ const command = parseCommand(line);
+ if (command.op === 'help') { console.log(help); return true; }
+ if (command.op === 'quit') return false;
+ const result = execute(core, command);
+ if (json) {
+ console.log(JSON.stringify({ command: line, ...result, ...(command.op === 'memory' ? { storage: storage(core) } : {}) }));
+ } else if (command.op === 'memory') {
+ console.log(JSON.stringify(storage(core), null, 2));
+ } else if (command.op === 'inspect') {
+ console.log(JSON.stringify(result, null, 2));
+ } else {
+ const s = result.state;
+ console.log(`t=${s.tick} · 높이 ${(s.y / 100).toFixed(2)} · 속도 ${(s.vy / 100).toFixed(2)} · 점프 ${s.jumpsUsed}/2 · 규칙 r${s.revision} (${s.mode ? 'double' : 'ground'}, ${s.impulse}, ${s.enabled ? 'on' : 'off'}) · ${reason[s.lastReason]}`);
+ }
+ return true;
+ }
+ if (args.includes('--demo')) {
+ for (const line of ['status', 'jump', 'step 8', 'jump', 'rules double 10 on', 'jump', 'step 60', 'memory']) show(line);
+ return;
+ }
+ if (!json && process.stdin.isTTY) console.log(help);
+ const terminal = createInterface({ input: process.stdin, output: process.stdout,
+ terminal: Boolean(process.stdin.isTTY) });
+ terminal.setPrompt('pure> ');
+ if (process.stdin.isTTY) terminal.prompt();
+ for await (const line of terminal) {
+ try {
+ if (!show(line)) { terminal.close(); break; }
+ } catch (error) {
+ console.error(error.message);
+ if (!process.stdin.isTTY) process.exitCode = 1;
+ }
+ if (process.stdin.isTTY) terminal.prompt();
+ }
+}
+
+main().catch((error) => {
+ console.error(`${error.message}\n빌드: python3 prototypes/live-rules/manage.py build`);
+ process.exitCode = 1;
+});
diff --git a/prototypes/live-rules/core.zig b/prototypes/live-rules/core.zig
new file mode 100644
index 0000000..bd709df
--- /dev/null
+++ b/prototypes/live-rules/core.zig
@@ -0,0 +1,504 @@
+//! A bounded, deterministic practice-game experiment, not a general runtime.
+//! The same source is tested natively and compiled to freestanding WebAssembly.
+//! All persistent storage is static; functions perform no allocation.
+
+const std = @import("std");
+
+const Rules = struct {
+ mode: u32 = 0,
+ impulse: u32 = 10,
+ enabled: u32 = 1,
+ revision: u32 = 0,
+};
+
+const State = struct {
+ tick: u32 = 0,
+ y: i32 = 0,
+ vy: i32 = 0,
+ distance: u32 = 0,
+ obstacle_x: i32 = 800,
+ passed: u32 = 0,
+ hits: u32 = 0,
+ jumps_used: u32 = 0,
+ running: u32 = 0,
+ last_reason: u32 = 0,
+ last_source: u32 = std.math.maxInt(u32),
+ total_inputs: u32 = 0,
+ accepted_jumps: u32 = 0,
+ obstacle_height: u32 = 48,
+ rng: u32 = 0x50555245,
+ obstacle_touched: u32 = 0,
+ obstacle_passed: u32 = 0,
+};
+
+const Decision = struct {
+ tick: u32 = 0,
+ revision: u32 = 0,
+ y: i32 = 0,
+ jumps: u32 = 0,
+ values: [6]u8 = @splat(0),
+ valid: bool = false,
+};
+
+const Event = struct {
+ tick: u32 = 0,
+ kind: u32 = 0,
+ detail: u32 = 0,
+ revision: u32 = 0,
+};
+
+const history_capacity: u32 = 16;
+
+const History = struct {
+ entries: [history_capacity]Event = @splat(.{}),
+ next: u32 = 0,
+ count: u32 = 0,
+};
+
+const Engine = struct {
+ rules: Rules = .{},
+ state: State = .{},
+ decision: Decision = .{},
+ history: History = .{},
+};
+
+const Op = enum(u8) { request, enabled, grounded, extra, select, all };
+const Node = struct { op: Op, a: u8 = 0, b: u8 = 0, c: u8 = 0 };
+
+// Node indices are topologically ordered. These descriptors drive evaluation;
+// the display is observing this actual graph, not a separate diagram model.
+const graph = [_]Node{
+ .{ .op = .request },
+ .{ .op = .enabled },
+ .{ .op = .grounded },
+ .{ .op = .extra },
+ .{ .op = .select, .a = 2, .b = 3 },
+ .{ .op = .all, .a = 0, .b = 1, .c = 4 },
+};
+
+var engine: Engine = .{};
+
+fn asPublic(value: u32) i32 {
+ return @bitCast(value);
+}
+
+fn grounded() bool {
+ // A jump accepted while paused already consumes the ground jump even though
+ // position will not be integrated until the next tick.
+ return engine.state.y == 0 and engine.state.vy == 0;
+}
+
+fn record(kind: u32, detail: u32) void {
+ const h = &engine.history;
+ h.entries[h.next] = .{
+ .tick = engine.state.tick,
+ .kind = kind,
+ .detail = detail,
+ .revision = engine.rules.revision,
+ };
+ h.next = (h.next + 1) % history_capacity;
+ h.count = @min(h.count + 1, history_capacity);
+}
+
+fn evaluate(source: u32) void {
+ var decision: Decision = .{
+ .tick = engine.state.tick,
+ .revision = engine.rules.revision,
+ .y = engine.state.y,
+ .jumps = engine.state.jumps_used,
+ .valid = true,
+ };
+ for (graph, 0..) |node, i| {
+ decision.values[i] = switch (node.op) {
+ .request => @intFromBool(source <= 2),
+ .enabled => @intFromBool(engine.rules.enabled == 1),
+ .grounded => @intFromBool(grounded()),
+ .extra => @intFromBool(engine.state.jumps_used < 2),
+ .select => if (engine.rules.mode == 0) decision.values[node.a] else decision.values[node.b],
+ .all => decision.values[node.a] & decision.values[node.b] & decision.values[node.c],
+ };
+ }
+ engine.decision = decision;
+}
+
+export fn reset() void {
+ engine = .{};
+}
+
+export fn set_running(flag: u32) void {
+ engine.state.running = @intFromBool(flag != 0);
+}
+
+export fn apply_rules(mode: u32, impulse: u32, enabled: u32) u32 {
+ // Validate the complete candidate before mutating any part of the engine.
+ if (mode > 1 or impulse < 6 or impulse > 16 or enabled > 1) return 1;
+ const revision = engine.rules.revision +% 1;
+ engine.rules = .{ .mode = mode, .impulse = impulse, .enabled = enabled, .revision = revision };
+ record(3, mode);
+ return 0;
+}
+
+export fn input(source: u32) u32 {
+ evaluate(source);
+ const state = &engine.state;
+ state.total_inputs +%= 1;
+ state.last_source = source;
+ if (engine.decision.values[5] != 0) {
+ state.last_reason = 1;
+ state.vy = @as(i32, @intCast(engine.rules.impulse)) * 100;
+ state.jumps_used +%= 1;
+ state.accepted_jumps +%= 1;
+ record(1, source);
+ return 1;
+ }
+ state.last_reason = if (source > 2) 5 else if (engine.rules.enabled == 0) 2 else if (engine.rules.mode == 0) 3 else 4;
+ record(2, state.last_reason);
+ return 0;
+}
+
+fn nextRandom() u32 {
+ engine.state.rng = engine.state.rng *% 1664525 +% 1013904223;
+ return engine.state.rng;
+}
+
+fn tickOnce() void {
+ const state = &engine.state;
+ state.tick +%= 1;
+ state.distance +%= 4;
+
+ if (!grounded()) {
+ state.vy -= 55;
+ state.y += state.vy;
+ if (state.y <= 0) {
+ state.y = 0;
+ state.vy = 0;
+ state.jumps_used = 0;
+ record(4, 0);
+ }
+ }
+
+ state.obstacle_x -= 4;
+ const horizontal_overlap = state.obstacle_x < 156 and state.obstacle_x + 32 > 120;
+ if (horizontal_overlap and state.y < @as(i32, @intCast(state.obstacle_height)) * 100 and state.obstacle_touched == 0) {
+ state.hits +%= 1;
+ state.obstacle_touched = 1;
+ record(5, state.hits);
+ }
+ if (state.obstacle_x + 32 <= 120 and state.obstacle_passed == 0) {
+ state.passed +%= 1;
+ state.obstacle_passed = 1;
+ record(6, state.passed);
+ }
+ if (state.obstacle_x + 32 < 0) {
+ state.obstacle_x = 500 + @as(i32, @intCast(nextRandom() % 301));
+ state.obstacle_height = 32 + (nextRandom() % 4) * 8;
+ state.obstacle_touched = 0;
+ state.obstacle_passed = 0;
+ }
+}
+
+export fn advance(ticks: u32) u32 {
+ if (ticks > 600 or engine.state.running == 0) return 0;
+ for (0..ticks) |_| tickOnce();
+ return ticks;
+}
+
+export fn step() void {
+ tickOnce();
+}
+
+export fn get(field: u32) i32 {
+ const s = &engine.state;
+ return switch (field) {
+ 0 => asPublic(s.tick),
+ 1 => s.y,
+ 2 => s.vy,
+ 3 => asPublic(s.distance),
+ 4 => s.obstacle_x,
+ 5 => asPublic(s.passed),
+ 6 => asPublic(s.hits),
+ 7 => asPublic(s.jumps_used),
+ 8 => asPublic(s.running),
+ 9 => asPublic(engine.rules.revision),
+ 10 => asPublic(s.last_reason),
+ 11 => asPublic(s.last_source),
+ 12 => asPublic(s.total_inputs),
+ 13 => asPublic(s.accepted_jumps),
+ 14 => asPublic(engine.rules.mode),
+ 15 => asPublic(engine.rules.impulse),
+ 16 => asPublic(engine.rules.enabled),
+ 17 => @intFromBool(grounded()),
+ 18 => asPublic(s.obstacle_height),
+ else => -1,
+ };
+}
+
+export fn node_value(id: u32) i32 {
+ if (!engine.decision.valid or id >= engine.decision.values.len) return -1;
+ return engine.decision.values[id];
+}
+
+export fn decision_meta(field: u32) i32 {
+ if (!engine.decision.valid) return -1;
+ return switch (field) {
+ 0 => asPublic(engine.decision.tick),
+ 1 => asPublic(engine.decision.revision),
+ 2 => engine.decision.y,
+ 3 => asPublic(engine.decision.jumps),
+ else => -1,
+ };
+}
+
+export fn history_count() u32 {
+ return engine.history.count;
+}
+
+export fn history_get(newest_index: u32, field: u32) i32 {
+ const h = &engine.history;
+ if (newest_index >= h.count) return -1;
+ const index = (h.next + history_capacity - 1 - newest_index) % history_capacity;
+ const event = &h.entries[index];
+ return switch (field) {
+ 0 => asPublic(event.tick),
+ 1 => asPublic(event.kind),
+ 2 => asPublic(event.detail),
+ 3 => asPublic(event.revision),
+ else => -1,
+ };
+}
+
+export fn bytes(kind: u32) u32 {
+ return switch (kind) {
+ 0 => @sizeOf(Rules),
+ 1 => @sizeOf(State),
+ 2 => @sizeOf(Decision),
+ 3 => @sizeOf(History),
+ 4 => @sizeOf(Engine),
+ 5 => @sizeOf(@TypeOf(graph)),
+ 6 => @sizeOf(Engine) + @sizeOf(@TypeOf(graph)),
+ else => 0,
+ };
+}
+
+test "edits preserve complete simulation state and latest observation" {
+ reset();
+ set_running(1);
+ try std.testing.expectEqual(@as(u32, 1), input(0));
+ try std.testing.expectEqual(@as(u32, 8), advance(8));
+ const before_state = engine.state;
+ const before_decision = engine.decision;
+ try std.testing.expectEqual(@as(u32, 0), apply_rules(1, 16, 0));
+ try std.testing.expectEqualDeep(before_state, engine.state);
+ try std.testing.expectEqualDeep(before_decision, engine.decision);
+ try std.testing.expectEqual(@as(i32, 1), get(9));
+ try std.testing.expectEqual(@as(i32, 3), history_get(0, 1));
+ try std.testing.expectEqual(@as(i32, 1), history_get(0, 3));
+}
+
+test "invalid edits and oversized advance are atomic" {
+ reset();
+ set_running(1);
+ _ = input(2);
+ step();
+ const before = engine;
+ const invalid = [_][3]u32{
+ .{ 2, 10, 1 }, .{ 0, 5, 1 }, .{ 0, 17, 1 },
+ .{ 0, 10, 2 }, .{ std.math.maxInt(u32), 10, 1 },
+ };
+ for (invalid) |candidate| {
+ try std.testing.expectEqual(@as(u32, 1), apply_rules(candidate[0], candidate[1], candidate[2]));
+ try std.testing.expectEqualDeep(before, engine);
+ }
+ try std.testing.expectEqual(@as(u32, 0), advance(601));
+ try std.testing.expectEqual(@as(u32, 0), advance(std.math.maxInt(u32)));
+ try std.testing.expectEqualDeep(before, engine);
+}
+
+test "pause, step, and ground-only jump handle input before integration" {
+ reset();
+ try std.testing.expectEqual(@as(i32, -1), node_value(0));
+ try std.testing.expectEqual(@as(i32, -1), decision_meta(0));
+ try std.testing.expectEqual(@as(u32, 0), advance(600));
+ try std.testing.expectEqual(@as(u32, 1), input(0));
+ try std.testing.expectEqual(@as(i32, 0), get(1));
+ try std.testing.expectEqual(@as(i32, 1000), get(2));
+ try std.testing.expectEqual(@as(i32, 0), get(17));
+ try std.testing.expectEqual(@as(u32, 0), input(0));
+ try std.testing.expectEqual(@as(i32, 3), get(10));
+ step();
+ try std.testing.expectEqual(@as(i32, 1), get(0));
+ try std.testing.expectEqual(@as(i32, 945), get(1));
+ try std.testing.expectEqual(@as(i32, 0), get(8));
+ for (0..100) |_| step();
+ try std.testing.expectEqual(@as(i32, 1), get(17));
+ try std.testing.expectEqual(@as(i32, 0), get(7));
+ try std.testing.expectEqual(@as(u32, 1), input(0));
+}
+
+test "double jump, disable, mode changes, and impulse apply at input boundaries" {
+ reset();
+ _ = input(0);
+ for (0..5) |_| step();
+ const old_velocity = get(2);
+ try std.testing.expectEqual(@as(u32, 0), apply_rules(1, 16, 1));
+ try std.testing.expectEqual(old_velocity, get(2));
+ try std.testing.expectEqual(@as(u32, 1), input(1));
+ try std.testing.expectEqual(@as(i32, 1600), get(2));
+ try std.testing.expectEqual(@as(i32, 2), get(7));
+ try std.testing.expectEqual(@as(u32, 0), input(2));
+ try std.testing.expectEqual(@as(i32, 4), get(10));
+ _ = apply_rules(0, 6, 1);
+ try std.testing.expectEqual(@as(u32, 0), input(0));
+ try std.testing.expectEqual(@as(i32, 3), get(10));
+ for (0..150) |_| step();
+ _ = apply_rules(1, 6, 0);
+ try std.testing.expectEqual(@as(u32, 0), input(0));
+ try std.testing.expectEqual(@as(i32, 2), get(10));
+ _ = apply_rules(1, 6, 1);
+ try std.testing.expectEqual(@as(u32, 1), input(0));
+ try std.testing.expectEqual(@as(i32, 600), get(2));
+ try std.testing.expectEqual(@as(u32, 1), input(0));
+ try std.testing.expectEqual(@as(u32, 0), input(0));
+}
+
+test "all input sources share transition behavior and retain provenance" {
+ var reference: State = undefined;
+ for (0..3) |source| {
+ reset();
+ try std.testing.expectEqual(@as(u32, 1), input(@intCast(source)));
+ for (0..7) |_| step();
+ try std.testing.expectEqual(@as(i32, @intCast(source)), get(11));
+ try std.testing.expectEqual(@as(i32, @intCast(source)), history_get(0, 2));
+ var normalized = engine.state;
+ normalized.last_source = 0;
+ if (source == 0) reference = normalized else try std.testing.expectEqualDeep(reference, normalized);
+ }
+ reset();
+ try std.testing.expectEqual(@as(u32, 0), input(std.math.maxInt(u32)));
+ try std.testing.expectEqual(@as(i32, 5), get(10));
+ try std.testing.expectEqual(@as(i32, 0), get(2));
+ try std.testing.expectEqual(@as(i32, 0), node_value(0));
+ try std.testing.expectEqual(@as(i32, 5), history_get(0, 2));
+}
+
+test "fixed history overwrites oldest entry and handles invalid access" {
+ reset();
+ for (0..40) |i| {
+ _ = apply_rules(@intCast(i % 2), 10, 1);
+ step();
+ }
+ try std.testing.expectEqual(@as(u32, 16), history_count());
+ for (0..16) |i| {
+ try std.testing.expectEqual(@as(i32, @intCast(40 - i)), history_get(@intCast(i), 3));
+ try std.testing.expectEqual(@as(i32, @intCast(39 - i)), history_get(@intCast(i), 0));
+ }
+ try std.testing.expectEqual(@as(i32, -1), history_get(16, 0));
+ try std.testing.expectEqual(@as(i32, -1), history_get(0, 4));
+ try std.testing.expectEqual(@as(i32, -1), history_get(std.math.maxInt(u32), 0));
+}
+
+test "decision is pre-input state and remains at its original revision" {
+ reset();
+ _ = apply_rules(1, 10, 1);
+ step();
+ _ = input(2);
+ try std.testing.expectEqual(@as(i32, 1), decision_meta(0));
+ try std.testing.expectEqual(@as(i32, 1), decision_meta(1));
+ try std.testing.expectEqual(@as(i32, 0), decision_meta(2));
+ try std.testing.expectEqual(@as(i32, 0), decision_meta(3));
+ try std.testing.expectEqual(@as(i32, 1), get(7));
+ try std.testing.expectEqual(@as(i32, 1), node_value(2));
+ try std.testing.expectEqual(@as(i32, 1), node_value(5));
+ const snapshot = engine.decision;
+ _ = apply_rules(0, 16, 0);
+ step();
+ try std.testing.expectEqualDeep(snapshot, engine.decision);
+ try std.testing.expectEqual(@as(i32, -1), node_value(6));
+ try std.testing.expectEqual(@as(i32, -1), decision_meta(4));
+}
+
+test "graph truth table agrees with a direct permission predicate" {
+ for (0..2) |mode| {
+ for (0..2) |enabled| {
+ for (0..2) |airborne| {
+ for (0..2) |pending_jump| {
+ for (0..3) |jumps| {
+ for (0..4) |source| {
+ reset();
+ engine.rules.mode = @intCast(mode);
+ engine.rules.enabled = @intCast(enabled);
+ engine.state.y = if (airborne == 1) 500 else 0;
+ engine.state.vy = if (pending_jump == 1) 1000 else 0;
+ engine.state.jumps_used = @intCast(jumps);
+ const on_ground = airborne == 0 and pending_jump == 0;
+ const permission = if (mode == 0) on_ground else jumps < 2;
+ const expected = source < 3 and enabled == 1 and permission;
+ const accepted = input(@intCast(source));
+ try std.testing.expectEqual(@as(u32, @intFromBool(expected)), accepted);
+ try std.testing.expectEqual(@as(i32, @intFromBool(on_ground)), node_value(2));
+ try std.testing.expectEqual(@as(i32, @intFromBool(jumps < 2)), node_value(3));
+ try std.testing.expectEqual(@as(i32, @intFromBool(permission)), node_value(4));
+ try std.testing.expectEqual(@as(i32, @intFromBool(expected)), node_value(5));
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+fn replay() Engine {
+ reset();
+ set_running(1);
+ for (0..3000) |i| {
+ if (i % 127 == 0) _ = apply_rules(@intCast((i / 127) % 2), @intCast(6 + i % 11), @intFromBool(i % 254 != 0));
+ if (i % 29 == 0) _ = input(@intCast((i / 29) % 3));
+ _ = advance(1);
+ }
+ return engine;
+}
+
+test "replay includes respawns and produces identical complete engine state" {
+ const first = replay();
+ const second = replay();
+ try std.testing.expectEqualDeep(first, second);
+ try std.testing.expect(first.state.passed > 5);
+ try std.testing.expect(first.state.hits > 0);
+ try std.testing.expectEqual(@as(u32, 3000), first.state.tick);
+ try std.testing.expectEqual(@as(u32, 12000), first.state.distance);
+}
+
+test "collisions do not reset progress and each obstacle is counted once" {
+ reset();
+ set_running(1);
+ _ = advance(180);
+ try std.testing.expectEqual(@as(i32, 1), get(6));
+ try std.testing.expectEqual(@as(i32, 1), get(5));
+ try std.testing.expectEqual(@as(i32, 180), get(0));
+ try std.testing.expectEqual(@as(i32, 720), get(3));
+ _ = advance(10);
+ try std.testing.expectEqual(@as(i32, 1), get(6));
+ try std.testing.expectEqual(@as(i32, 1), get(5));
+ try std.testing.expectEqual(@as(i32, 1), get(8));
+}
+
+test "counters wrap explicitly and memory accounting has no overlapping total" {
+ reset();
+ engine.state.tick = std.math.maxInt(u32);
+ engine.state.distance = std.math.maxInt(u32) - 1;
+ engine.state.total_inputs = std.math.maxInt(u32);
+ engine.state.accepted_jumps = std.math.maxInt(u32);
+ engine.rules.revision = std.math.maxInt(u32);
+ _ = input(0);
+ _ = apply_rules(1, 10, 1);
+ step();
+ try std.testing.expectEqual(@as(i32, 0), get(0));
+ try std.testing.expectEqual(@as(i32, 2), get(3));
+ try std.testing.expectEqual(@as(i32, 0), get(9));
+ try std.testing.expectEqual(@as(i32, 0), get(12));
+ try std.testing.expectEqual(@as(i32, 0), get(13));
+ try std.testing.expectEqual(bytes(4) + bytes(5), bytes(6));
+ try std.testing.expect(bytes(4) >= bytes(0) + bytes(1) + bytes(2) + bytes(3));
+ try std.testing.expectEqual(@as(u32, 0), bytes(7));
+ try std.testing.expectEqual(@as(i32, -1), get(19));
+}
diff --git a/prototypes/live-rules/manage.py b/prototypes/live-rules/manage.py
new file mode 100644
index 0000000..fbe047c
--- /dev/null
+++ b/prototypes/live-rules/manage.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+"""Build/test the shared Zig core or serve the local experiment. No Python dependencies."""
+import argparse
+from datetime import datetime, timezone
+from functools import partial
+import hashlib
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+import json
+from pathlib import Path
+import shutil
+import subprocess
+
+ROOT = Path(__file__).resolve().parent
+VERSION = "0.15.2"
+
+
+def digest(path):
+ return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
+def build(zig):
+ compiler = shutil.which(zig)
+ if compiler is None:
+ raise SystemExit("Zig 0.15.2가 필요합니다. ziglang.org/download 또는 --zig /path/to/zig를 사용하세요.")
+ version = subprocess.check_output([compiler, "version"], text=True).strip()
+ if version != VERSION:
+ raise SystemExit(f"이 실험은 Zig {VERSION}로 고정했습니다. 현재: {version}")
+ build_dir = ROOT / "build"
+ build_dir.mkdir(exist_ok=True)
+ cache = ["--cache-dir", str(build_dir / "cache"),
+ "--global-cache-dir", str(build_dir / "global-cache")]
+ source = ROOT / "core.zig"
+ native = [compiler, "test", str(source), *cache]
+ print("Checking native core…", flush=True)
+ checked = subprocess.run(native, capture_output=True, text=True)
+ print(checked.stdout + checked.stderr, end="", flush=True)
+ checked.check_returncode()
+ wasm = ROOT / "web" / "core.wasm"
+ wasm.parent.mkdir(exist_ok=True)
+ flags = ["-target", "wasm32-freestanding", "-O", "ReleaseSafe", "-fno-entry",
+ "-rdynamic", "--export-memory", "--stack", "16384",
+ "--initial-memory=65536", "--max-memory=65536"]
+ command = [compiler, "build-exe", str(source), *flags, f"-femit-bin={wasm}", *cache]
+ subprocess.run(command, check=True)
+ (build_dir / "build.json").write_text(json.dumps({
+ "built_utc": datetime.now(timezone.utc).isoformat(),
+ "zig_version": version, "flags": flags,
+ "source_sha256": digest(source), "wasm_sha256": digest(wasm),
+ "wasm_file_bytes": wasm.stat().st_size,
+ "native_test_output": checked.stdout + checked.stderr,
+ }, ensure_ascii=False, indent=2) + "\n")
+ print(f"Built {wasm} ({wasm.stat().st_size:,} bytes)")
+
+
+class Handler(SimpleHTTPRequestHandler):
+ extensions_map = {**SimpleHTTPRequestHandler.extensions_map, ".wasm": "application/wasm"}
+
+ def end_headers(self):
+ self.send_header("Cache-Control", "no-store")
+ super().end_headers()
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ sub = parser.add_subparsers(dest="action", required=True)
+ compile_cmd = sub.add_parser("build", help="native tests + WebAssembly build")
+ compile_cmd.add_argument("--zig", default="zig")
+ serve = sub.add_parser("serve", help="serve web/ on loopback only")
+ serve.add_argument("--port", type=int, default=8765)
+ args = parser.parse_args()
+ if args.action == "build":
+ build(args.zig)
+ return
+ if not 1 <= args.port <= 65535:
+ parser.error("port must be between 1 and 65535")
+ if not (ROOT / "web" / "core.wasm").is_file():
+ parser.error("먼저 manage.py build를 실행하세요.")
+ handler = partial(Handler, directory=str(ROOT / "web"))
+ with ThreadingHTTPServer(("127.0.0.1", args.port), handler) as server:
+ print(f"Pure: http://127.0.0.1:{args.port} (Ctrl+C to stop)", flush=True)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+
+
+if __name__ == "__main__":
+ main()
diff --git a/prototypes/live-rules/measure.mjs b/prototypes/live-rules/measure.mjs
new file mode 100644
index 0000000..2f12815
--- /dev/null
+++ b/prototypes/live-rules/measure.mjs
@@ -0,0 +1,48 @@
+// Reproducible storage ledger + exact state transitions, not a speed benchmark.
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import { createHash } from 'node:crypto';
+import { resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { spawnSync } from 'node:child_process';
+import { arch, platform, release } from 'node:os';
+import { loadCore, snapshot, storage, parseCommand, execute } from './runtime.mjs';
+
+const root = fileURLToPath(new URL('.', import.meta.url));
+const destination = process.argv[2];
+if (!destination || process.argv.length !== 3) throw new Error('Usage: node measure.mjs NEW_OUTPUT_DIRECTORY');
+const core = await loadCore();
+const checked = spawnSync(process.execPath, [resolve(root, 'adapter.test.mjs')], { encoding: 'utf8' });
+if (checked.error || checked.status !== 0) throw new Error(checked.error?.message || checked.stdout + checked.stderr);
+process.stdout.write(checked.stdout + checked.stderr);
+
+const trace = ['jump', 'step 8', 'jump', 'rules double 12 on', 'jump', 'step 60'];
+const steps = trace.map((command) => ({ command, ...execute(core, parseCommand(command)) }));
+const baseline = storage(core);
+const imports = WebAssembly.Module.imports(await WebAssembly.compile(await readFile(resolve(root, 'web/core.wasm'))));
+const hashes = {};
+for (const name of ['core.zig', 'web/core.wasm', 'runtime.mjs', 'cli.mjs', 'adapter.test.mjs', 'measure.mjs', 'manage.py', 'qa-browser.js', 'web/app.js', 'web/index.html', 'web/style.css']) {
+ const data = await readFile(resolve(root, name));
+ hashes[name] = { bytes: data.length, sha256: createHash('sha256').update(data).digest('hex') };
+}
+let build = null;
+try {
+ build = JSON.parse(await readFile(resolve(root, 'build/build.json'), 'utf8'));
+ if (build.source_sha256 !== hashes['core.zig'].sha256 || build.wasm_sha256 !== hashes['web/core.wasm'].sha256) {
+ throw new Error('Build record and current source/binary differ. Rebuild first.');
+ }
+} catch (error) {
+ if (error.code !== 'ENOENT') throw error;
+}
+
+const result = {
+ recordedUtc: new Date().toISOString(),
+ host: { platform: platform(), release: release(), arch: arch(), node: process.version },
+ scope: 'Declared Zig type layouts and actual Wasm memory capacity; excludes JS/DOM/renderer, code pages and RSS. No latency or throughput measurement.',
+ imports, storage: baseline, trace: steps,
+ finalState: snapshot(core), build, files: hashes,
+ adapterTests: { command: 'node prototypes/live-rules/adapter.test.mjs', exitCode: checked.status,
+ output: checked.stdout + checked.stderr },
+};
+await mkdir(destination, { recursive: false });
+await writeFile(resolve(destination, 'storage-and-replay.json'), JSON.stringify(result, null, 2) + '\n');
+console.log(`Saved ${resolve(destination, 'storage-and-replay.json')}`);
diff --git a/prototypes/live-rules/qa-browser.js b/prototypes/live-rules/qa-browser.js
new file mode 100644
index 0000000..ee11c3a
--- /dev/null
+++ b/prototypes/live-rules/qa-browser.js
@@ -0,0 +1,131 @@
+// Pass this expression to Playwright CLI's run-code after opening the local demo.
+// Interactions use real UI controls; the read-only snapshot only observes results.
+async (page) => {
+ const checks = [];
+ const errors = [];
+ const onError = (error) => errors.push(error.message);
+ const check = (condition, label) => {
+ if (!condition) throw new Error(label);
+ checks.push(label);
+ };
+ const state = () => page.evaluate(() => window.pureDemo.snapshot());
+ const button = (name) => page.getByRole('button', { name, exact: true });
+ const step = async (count) => { for (let i = 0; i < count; i++) await button('한 프레임').click(); };
+ page.on('pageerror', onError);
+ try {
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.reload();
+ await page.waitForFunction(() => window.pureDemo?.snapshot().ready);
+ const initial = await state();
+ check(initial.running === 0 && initial.tick === 0 && initial.revision === 0, 'initial state is paused');
+
+ await page.locator('#stage').focus();
+ await page.keyboard.press('Space');
+ let s = await state();
+ check(s.accepted === 1 && s.inputs === 1 && s.source === 0 && s.y === 0, 'Space goes through keyboard input once while paused');
+ await step(8);
+ await button('점프 Space').click();
+ const before = await state();
+ check(before.tick === 8 && before.y === 6020 && before.vy === 560 && before.reason === 3, 'ground rule rejects a second airborne jump');
+ await page.getByRole('radio', { name: '두 번 점프 공중에서 한 번 더 뛸 수 있어요.', exact: true }).check();
+ const draft = await state();
+ check(draft.mode === 0 && draft.revision === 0 && draft.draftDirty, 'draft changes do not execute before Apply');
+ await button('규칙 적용').click();
+ const applied = await state();
+ check(['tick', 'y', 'vy', 'distance', 'obstacleX', 'jumps'].every((key) => applied[key] === before[key]), 'applying rules preserves exact simulation progress');
+ check(applied.mode === 1 && applied.revision === 1 && applied.decisionRevision === 0 && await page.locator('#stale-decision').isVisible(), 'old decision retains and displays its revision');
+ await button('점프 Space').click();
+ const second = await state();
+ check(second.reason === 1 && second.jumps === 2 && second.source === 1 && second.decisionRevision === 1, 'pointer input uses new rule and accepts second jump');
+ await button('점프 Space').click();
+ check((await state()).reason === 4, 'third jump is rejected');
+
+ await page.getByRole('checkbox').uncheck();
+ await button('규칙 적용').click();
+ await button('점프 Space').click();
+ check((await state()).reason === 2, 'disabled rule explains rejection');
+ await button('처음부터').click();
+ s = await state();
+ check(s.tick === 0 && s.mode === 0 && s.impulse === 10 && s.enabled === 1 && s.inputs === 0 && s.running === 0, 'explicit reset restores rules and state');
+
+ await page.getByRole('slider', { name: '점프 세기' }).focus();
+ await page.keyboard.press('Space');
+ await page.getByRole('checkbox').focus();
+ await page.keyboard.press('Space');
+ check((await state()).inputs === 0, 'Space in form controls never dispatches a jump');
+ await button('처음부터').click();
+ await button('점프 Space').focus();
+ await page.keyboard.press('Space');
+ check((await state()).inputs === 1, 'Space on the jump button does not double dispatch');
+ await button('처음부터').click();
+ await page.locator('#stage').focus();
+ await page.keyboard.down('Space');
+ await page.keyboard.down('Space');
+ await page.keyboard.up('Space');
+ check((await state()).inputs === 1, 'keyboard repeat does not add a second request');
+
+ await button('처음부터').click();
+ await button('실행').click();
+ await page.waitForFunction(() => window.pureDemo.snapshot().tick >= 6);
+ await button('일시정지').click();
+ const paused = await state();
+ await page.waitForTimeout(120);
+ check(paused.tick >= 6 && paused.tick === (await state()).tick, 'running advances and pause freezes simulation');
+
+ await button('처음부터').click();
+ await page.getByRole('radio', { name: '두 번 점프 공중에서 한 번 더 뛸 수 있어요.', exact: true }).check();
+ await page.getByRole('slider', { name: '점프 세기' }).focus();
+ await page.keyboard.press('End');
+ await button('규칙 적용').click();
+ await button('점프 Space').click();
+ await step(28);
+ await button('점프 Space').click();
+ await step(29);
+ const high = await state();
+ const visible = await page.evaluate(() => {
+ const d = document.getElementById('dino').getBoundingClientRect();
+ const w = document.getElementById('world').getBoundingClientRect();
+ return d.top >= w.top && d.bottom <= w.bottom && d.left >= w.left && d.right <= w.right;
+ });
+ check(high.y > 40000 && visible, 'maximum-height double jump remains inside the scene');
+
+ for (let i = 0; i < 25; i++) await button('점프 Space').click();
+ check(await page.locator('#history-list li').count() === 8, 'visible history is bounded to eight rows');
+ check((await state()).wasmMemoryBytes === 65536, 'browser instance retains fixed 64 KiB linear memory');
+ await page.locator('.research-panel summary').click();
+ check((await page.locator('#bytes-total').textContent()).includes('396'), 'research panel shows logical layout separately');
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.locator('.research-panel summary').click();
+ await button('처음부터').click();
+ const width = await page.evaluate(() => ({ viewport: innerWidth, content: document.documentElement.scrollWidth }));
+ check(width.content <= width.viewport, '390px viewport has no horizontal overflow');
+ check(await page.locator('#dino').evaluate((el) => el.getBoundingClientRect().height) >= 28, 'narrow view keeps the dinosaur large enough to inspect');
+ await button('점프 Space').click();
+ await step(1);
+ check((await state()).y === 945, 'narrow viewport controls remain usable');
+ await page.screenshot({ path: 'output/playwright/pure-live-mobile.png', fullPage: true });
+
+ await page.route('**/core.wasm', (route) => route.abort());
+ await page.reload();
+ await page.locator('#load-error').waitFor({ state: 'visible' });
+ check(await button('실행').isDisabled(), 'missing Wasm has an actionable error and disabled controls');
+ await page.unroute('**/core.wasm');
+ await page.reload();
+ await page.waitForFunction(() => window.pureDemo?.snapshot().ready);
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await button('점프 Space').click();
+ await step(8);
+ await button('점프 Space').click();
+ await page.getByRole('radio', { name: '두 번 점프 공중에서 한 번 더 뛸 수 있어요.', exact: true }).check();
+ await button('규칙 적용').click();
+ await button('점프 Space').click();
+ await page.screenshot({ path: 'output/playwright/pure-live-desktop.png', fullPage: true });
+ check(errors.length === 0, 'no uncaught page errors');
+ return { recordedUtc: new Date().toISOString(), browser: await page.evaluate(() => navigator.userAgent),
+ checks, before, applied, second, high, mobile: width, errors,
+ screenshots: ['output/playwright/pure-live-desktop.png', 'output/playwright/pure-live-mobile.png'] };
+ } finally {
+ page.off('pageerror', onError);
+ }
+}
diff --git a/prototypes/live-rules/results/2026-09-22-linux-aarch64/browser-checks.json b/prototypes/live-rules/results/2026-09-22-linux-aarch64/browser-checks.json
new file mode 100644
index 0000000..efb9ec7
--- /dev/null
+++ b/prototypes/live-rules/results/2026-09-22-linux-aarch64/browser-checks.json
@@ -0,0 +1,215 @@
+{
+ "recordedUtc": "2026-09-22T10:20:31.347Z",
+ "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36",
+ "checks": [
+ "initial state is paused",
+ "Space goes through keyboard input once while paused",
+ "ground rule rejects a second airborne jump",
+ "draft changes do not execute before Apply",
+ "applying rules preserves exact simulation progress",
+ "old decision retains and displays its revision",
+ "pointer input uses new rule and accepts second jump",
+ "third jump is rejected",
+ "disabled rule explains rejection",
+ "explicit reset restores rules and state",
+ "Space in form controls never dispatches a jump",
+ "Space on the jump button does not double dispatch",
+ "keyboard repeat does not add a second request",
+ "running advances and pause freezes simulation",
+ "maximum-height double jump remains inside the scene",
+ "visible history is bounded to eight rows",
+ "browser instance retains fixed 64 KiB linear memory",
+ "research panel shows logical layout separately",
+ "390px viewport has no horizontal overflow",
+ "narrow view keeps the dinosaur large enough to inspect",
+ "narrow viewport controls remain usable",
+ "missing Wasm has an actionable error and disabled controls",
+ "no uncaught page errors"
+ ],
+ "before": {
+ "ready": true,
+ "tick": 8,
+ "y": 6020,
+ "vy": 560,
+ "distance": 32,
+ "obstacleX": 768,
+ "passed": 0,
+ "hits": 0,
+ "jumps": 1,
+ "running": 0,
+ "revision": 0,
+ "reason": 3,
+ "source": 1,
+ "inputs": 2,
+ "accepted": 1,
+ "mode": 0,
+ "impulse": 10,
+ "enabled": 1,
+ "grounded": 0,
+ "obstacleHeight": 48,
+ "draftDirty": false,
+ "droppedTicks": 0,
+ "renderCount": 11,
+ "wasmMemoryBytes": 65536,
+ "logicalBytes": 396,
+ "decisionTick": 8,
+ "decisionRevision": 0,
+ "decisionNodes": [
+ 1,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0
+ ]
+ },
+ "applied": {
+ "ready": true,
+ "tick": 8,
+ "y": 6020,
+ "vy": 560,
+ "distance": 32,
+ "obstacleX": 768,
+ "passed": 0,
+ "hits": 0,
+ "jumps": 1,
+ "running": 0,
+ "revision": 1,
+ "reason": 3,
+ "source": 1,
+ "inputs": 2,
+ "accepted": 1,
+ "mode": 1,
+ "impulse": 10,
+ "enabled": 1,
+ "grounded": 0,
+ "obstacleHeight": 48,
+ "draftDirty": false,
+ "droppedTicks": 0,
+ "renderCount": 12,
+ "wasmMemoryBytes": 65536,
+ "logicalBytes": 396,
+ "decisionTick": 8,
+ "decisionRevision": 0,
+ "decisionNodes": [
+ 1,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0
+ ]
+ },
+ "second": {
+ "ready": true,
+ "tick": 8,
+ "y": 6020,
+ "vy": 1000,
+ "distance": 32,
+ "obstacleX": 768,
+ "passed": 0,
+ "hits": 0,
+ "jumps": 2,
+ "running": 0,
+ "revision": 1,
+ "reason": 1,
+ "source": 1,
+ "inputs": 3,
+ "accepted": 2,
+ "mode": 1,
+ "impulse": 10,
+ "enabled": 1,
+ "grounded": 0,
+ "obstacleHeight": 48,
+ "draftDirty": false,
+ "droppedTicks": 0,
+ "renderCount": 13,
+ "wasmMemoryBytes": 65536,
+ "logicalBytes": 396,
+ "decisionTick": 8,
+ "decisionRevision": 1,
+ "decisionNodes": [
+ 1,
+ 1,
+ 0,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ "high": {
+ "ready": true,
+ "tick": 57,
+ "y": 44945,
+ "vy": 5,
+ "distance": 228,
+ "obstacleX": 572,
+ "passed": 0,
+ "hits": 0,
+ "jumps": 2,
+ "running": 0,
+ "revision": 1,
+ "reason": 1,
+ "source": 1,
+ "inputs": 2,
+ "accepted": 2,
+ "mode": 1,
+ "impulse": 16,
+ "enabled": 1,
+ "grounded": 0,
+ "obstacleHeight": 48,
+ "draftDirty": false,
+ "droppedTicks": 0,
+ "renderCount": 92,
+ "wasmMemoryBytes": 65536,
+ "logicalBytes": 396,
+ "decisionTick": 28,
+ "decisionRevision": 1,
+ "decisionNodes": [
+ 1,
+ 1,
+ 0,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ "mobile": {
+ "viewport": 390,
+ "content": 390
+ },
+ "errors": [],
+ "screenshots": [
+ {
+ "file": "desktop.png",
+ "sha256": "ae02e473b472816dccf147e1d7b0a45135ffbc10fbef97002a7f0697303a7fbb"
+ },
+ {
+ "file": "mobile.png",
+ "sha256": "a3295e68fdaff2064d9076c0e136a80062abd0dd58ef770aa9b7c7dff356926d"
+ }
+ ],
+ "playwrightCliVersion": "0.1.21",
+ "files": {
+ "qa-browser.js": {
+ "sha256": "50d4293d41d5aa63b404c3e22fdc1349008765365fac8cdafe01dfdf858893da",
+ "bytes": 7554
+ },
+ "web/app.js": {
+ "sha256": "b3dcd10fa4e4ab56dff200e9238c640e6dbea5fa7b624328d7d421ee8744e778",
+ "bytes": 14358
+ },
+ "web/index.html": {
+ "sha256": "28630577d3faa81b61fec95723d421e1cac9a576452d909342cfd054d3dea385",
+ "bytes": 13396
+ },
+ "web/style.css": {
+ "sha256": "b399be728d8f2bfb1964903e11e69a9bccbc83c9d90ecfd6783110c7a0702923",
+ "bytes": 16606
+ },
+ "web/core.wasm": {
+ "sha256": "ea0789dcbb0cc2d6a17e049f4568374d242b44dafe7f996673538f3bd6dafed5",
+ "bytes": 32544
+ }
+ }
+}
diff --git a/prototypes/live-rules/results/2026-09-22-linux-aarch64/desktop.png b/prototypes/live-rules/results/2026-09-22-linux-aarch64/desktop.png
new file mode 100644
index 0000000..5318b55
--- /dev/null
+++ b/prototypes/live-rules/results/2026-09-22-linux-aarch64/desktop.png
Binary files differ
diff --git a/prototypes/live-rules/results/2026-09-22-linux-aarch64/mobile.png b/prototypes/live-rules/results/2026-09-22-linux-aarch64/mobile.png
new file mode 100644
index 0000000..2f11c77
--- /dev/null
+++ b/prototypes/live-rules/results/2026-09-22-linux-aarch64/mobile.png
Binary files differ
diff --git a/prototypes/live-rules/results/2026-09-22-linux-aarch64/storage-and-replay.json b/prototypes/live-rules/results/2026-09-22-linux-aarch64/storage-and-replay.json
new file mode 100644
index 0000000..aab0d2a
--- /dev/null
+++ b/prototypes/live-rules/results/2026-09-22-linux-aarch64/storage-and-replay.json
@@ -0,0 +1,503 @@
+{
+ "recordedUtc": "2026-09-22T10:21:30.811Z",
+ "host": {
+ "platform": "linux",
+ "release": "6.18.39+rpt-rpi-2712",
+ "arch": "arm64",
+ "node": "v24.20.0"
+ },
+ "scope": "Declared Zig type layouts and actual Wasm memory capacity; excludes JS/DOM/renderer, code pages and RSS. No latency or throughput measurement.",
+ "imports": [],
+ "storage": {
+ "layoutBytes": {
+ "rules": 16,
+ "state": 68,
+ "decision": 24,
+ "history": 264,
+ "engine": 372,
+ "graph": 24,
+ "logicalTotal": 396
+ },
+ "wasmLinearMemoryBytes": 65536,
+ "note": "Type-layout sizes; constant graph may be compiled into code. Not browser heap or RSS."
+ },
+ "trace": [
+ {
+ "command": "jump",
+ "state": {
+ "tick": 0,
+ "y": 0,
+ "vy": 1000,
+ "distance": 0,
+ "obstacleX": 800,
+ "passed": 0,
+ "hits": 0,
+ "jumpsUsed": 1,
+ "running": 0,
+ "revision": 0,
+ "lastReason": 1,
+ "lastSource": 2,
+ "totalInputs": 1,
+ "acceptedJumps": 1,
+ "mode": 0,
+ "impulse": 10,
+ "enabled": 1,
+ "grounded": 0,
+ "obstacleHeight": 48
+ },
+ "decision": {
+ "tick": 0,
+ "revision": 0,
+ "y": 0,
+ "jumps": 0,
+ "nodes": [
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ "history": [
+ {
+ "tick": 0,
+ "kind": 1,
+ "detail": 2,
+ "revision": 0
+ }
+ ]
+ },
+ {
+ "command": "step 8",
+ "state": {
+ "tick": 8,
+ "y": 6020,
+ "vy": 560,
+ "distance": 32,
+ "obstacleX": 768,
+ "passed": 0,
+ "hits": 0,
+ "jumpsUsed": 1,
+ "running": 0,
+ "revision": 0,
+ "lastReason": 1,
+ "lastSource": 2,
+ "totalInputs": 1,
+ "acceptedJumps": 1,
+ "mode": 0,
+ "impulse": 10,
+ "enabled": 1,
+ "grounded": 0,
+ "obstacleHeight": 48
+ },
+ "decision": {
+ "tick": 0,
+ "revision": 0,
+ "y": 0,
+ "jumps": 0,
+ "nodes": [
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ "history": [
+ {
+ "tick": 0,
+ "kind": 1,
+ "detail": 2,
+ "revision": 0
+ }
+ ]
+ },
+ {
+ "command": "jump",
+ "state": {
+ "tick": 8,
+ "y": 6020,
+ "vy": 560,
+ "distance": 32,
+ "obstacleX": 768,
+ "passed": 0,
+ "hits": 0,
+ "jumpsUsed": 1,
+ "running": 0,
+ "revision": 0,
+ "lastReason": 3,
+ "lastSource": 2,
+ "totalInputs": 2,
+ "acceptedJumps": 1,
+ "mode": 0,
+ "impulse": 10,
+ "enabled": 1,
+ "grounded": 0,
+ "obstacleHeight": 48
+ },
+ "decision": {
+ "tick": 8,
+ "revision": 0,
+ "y": 6020,
+ "jumps": 1,
+ "nodes": [
+ 1,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0
+ ]
+ },
+ "history": [
+ {
+ "tick": 8,
+ "kind": 2,
+ "detail": 3,
+ "revision": 0
+ },
+ {
+ "tick": 0,
+ "kind": 1,
+ "detail": 2,
+ "revision": 0
+ }
+ ]
+ },
+ {
+ "command": "rules double 12 on",
+ "state": {
+ "tick": 8,
+ "y": 6020,
+ "vy": 560,
+ "distance": 32,
+ "obstacleX": 768,
+ "passed": 0,
+ "hits": 0,
+ "jumpsUsed": 1,
+ "running": 0,
+ "revision": 1,
+ "lastReason": 3,
+ "lastSource": 2,
+ "totalInputs": 2,
+ "acceptedJumps": 1,
+ "mode": 1,
+ "impulse": 12,
+ "enabled": 1,
+ "grounded": 0,
+ "obstacleHeight": 48
+ },
+ "decision": {
+ "tick": 8,
+ "revision": 0,
+ "y": 6020,
+ "jumps": 1,
+ "nodes": [
+ 1,
+ 1,
+ 0,
+ 1,
+ 0,
+ 0
+ ]
+ },
+ "history": [
+ {
+ "tick": 8,
+ "kind": 3,
+ "detail": 1,
+ "revision": 1
+ },
+ {
+ "tick": 8,
+ "kind": 2,
+ "detail": 3,
+ "revision": 0
+ },
+ {
+ "tick": 0,
+ "kind": 1,
+ "detail": 2,
+ "revision": 0
+ }
+ ]
+ },
+ {
+ "command": "jump",
+ "state": {
+ "tick": 8,
+ "y": 6020,
+ "vy": 1200,
+ "distance": 32,
+ "obstacleX": 768,
+ "passed": 0,
+ "hits": 0,
+ "jumpsUsed": 2,
+ "running": 0,
+ "revision": 1,
+ "lastReason": 1,
+ "lastSource": 2,
+ "totalInputs": 3,
+ "acceptedJumps": 2,
+ "mode": 1,
+ "impulse": 12,
+ "enabled": 1,
+ "grounded": 0,
+ "obstacleHeight": 48
+ },
+ "decision": {
+ "tick": 8,
+ "revision": 1,
+ "y": 6020,
+ "jumps": 1,
+ "nodes": [
+ 1,
+ 1,
+ 0,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ "history": [
+ {
+ "tick": 8,
+ "kind": 1,
+ "detail": 2,
+ "revision": 1
+ },
+ {
+ "tick": 8,
+ "kind": 3,
+ "detail": 1,
+ "revision": 1
+ },
+ {
+ "tick": 8,
+ "kind": 2,
+ "detail": 3,
+ "revision": 0
+ },
+ {
+ "tick": 0,
+ "kind": 1,
+ "detail": 2,
+ "revision": 0
+ }
+ ]
+ },
+ {
+ "command": "step 60",
+ "state": {
+ "tick": 68,
+ "y": 0,
+ "vy": 0,
+ "distance": 272,
+ "obstacleX": 528,
+ "passed": 0,
+ "hits": 0,
+ "jumpsUsed": 0,
+ "running": 0,
+ "revision": 1,
+ "lastReason": 1,
+ "lastSource": 2,
+ "totalInputs": 3,
+ "acceptedJumps": 2,
+ "mode": 1,
+ "impulse": 12,
+ "enabled": 1,
+ "grounded": 1,
+ "obstacleHeight": 48
+ },
+ "decision": {
+ "tick": 8,
+ "revision": 1,
+ "y": 6020,
+ "jumps": 1,
+ "nodes": [
+ 1,
+ 1,
+ 0,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ "history": [
+ {
+ "tick": 56,
+ "kind": 4,
+ "detail": 0,
+ "revision": 1
+ },
+ {
+ "tick": 8,
+ "kind": 1,
+ "detail": 2,
+ "revision": 1
+ },
+ {
+ "tick": 8,
+ "kind": 3,
+ "detail": 1,
+ "revision": 1
+ },
+ {
+ "tick": 8,
+ "kind": 2,
+ "detail": 3,
+ "revision": 0
+ },
+ {
+ "tick": 0,
+ "kind": 1,
+ "detail": 2,
+ "revision": 0
+ }
+ ]
+ }
+ ],
+ "finalState": {
+ "state": {
+ "tick": 68,
+ "y": 0,
+ "vy": 0,
+ "distance": 272,
+ "obstacleX": 528,
+ "passed": 0,
+ "hits": 0,
+ "jumpsUsed": 0,
+ "running": 0,
+ "revision": 1,
+ "lastReason": 1,
+ "lastSource": 2,
+ "totalInputs": 3,
+ "acceptedJumps": 2,
+ "mode": 1,
+ "impulse": 12,
+ "enabled": 1,
+ "grounded": 1,
+ "obstacleHeight": 48
+ },
+ "decision": {
+ "tick": 8,
+ "revision": 1,
+ "y": 6020,
+ "jumps": 1,
+ "nodes": [
+ 1,
+ 1,
+ 0,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ "history": [
+ {
+ "tick": 56,
+ "kind": 4,
+ "detail": 0,
+ "revision": 1
+ },
+ {
+ "tick": 8,
+ "kind": 1,
+ "detail": 2,
+ "revision": 1
+ },
+ {
+ "tick": 8,
+ "kind": 3,
+ "detail": 1,
+ "revision": 1
+ },
+ {
+ "tick": 8,
+ "kind": 2,
+ "detail": 3,
+ "revision": 0
+ },
+ {
+ "tick": 0,
+ "kind": 1,
+ "detail": 2,
+ "revision": 0
+ }
+ ]
+ },
+ "build": {
+ "built_utc": "2026-09-22T10:01:20.063882+00:00",
+ "zig_version": "0.15.2",
+ "flags": [
+ "-target",
+ "wasm32-freestanding",
+ "-O",
+ "ReleaseSafe",
+ "-fno-entry",
+ "-rdynamic",
+ "--export-memory",
+ "--stack",
+ "16384",
+ "--initial-memory=65536",
+ "--max-memory=65536"
+ ],
+ "source_sha256": "d9fdba86e700605b053586d95ccdfb47cbe87070717182fb1c2ab7b45221ad2b",
+ "wasm_sha256": "ea0789dcbb0cc2d6a17e049f4568374d242b44dafe7f996673538f3bd6dafed5",
+ "wasm_file_bytes": 32544,
+ "native_test_output": "1/11 core.test.edits preserve complete simulation state and latest observation...OK\n2/11 core.test.invalid edits and oversized advance are atomic...OK\n3/11 core.test.pause, step, and ground-only jump handle input before integration...OK\n4/11 core.test.double jump, disable, mode changes, and impulse apply at input boundaries...OK\n5/11 core.test.all input sources share transition behavior and retain provenance...OK\n6/11 core.test.fixed history overwrites oldest entry and handles invalid access...OK\n7/11 core.test.decision is pre-input state and remains at its original revision...OK\n8/11 core.test.graph truth table agrees with a direct permission predicate...OK\n9/11 core.test.replay includes respawns and produces identical complete engine state...OK\n10/11 core.test.collisions do not reset progress and each obstacle is counted once...OK\n11/11 core.test.counters wrap explicitly and memory accounting has no overlapping total...OK\nAll 11 tests passed.\n"
+ },
+ "files": {
+ "core.zig": {
+ "bytes": 17914,
+ "sha256": "d9fdba86e700605b053586d95ccdfb47cbe87070717182fb1c2ab7b45221ad2b"
+ },
+ "web/core.wasm": {
+ "bytes": 32544,
+ "sha256": "ea0789dcbb0cc2d6a17e049f4568374d242b44dafe7f996673538f3bd6dafed5"
+ },
+ "runtime.mjs": {
+ "bytes": 3939,
+ "sha256": "9d01626245d1e0c6261a28a9ecacb04c56c67dfcb901958526d3ccb234bef159"
+ },
+ "cli.mjs": {
+ "bytes": 2998,
+ "sha256": "6b39eebef35b5c19db01dee4d254ad667e42a5633c840b29d84607f37e98ef8f"
+ },
+ "adapter.test.mjs": {
+ "bytes": 3701,
+ "sha256": "6f81dc74cd2e2b38390e7bcca4c4757e8a50b58ef65194eab871192c4e86cf66"
+ },
+ "measure.mjs": {
+ "bytes": 2810,
+ "sha256": "4125f3e98d3d941bd1a61c389a91d3a1e0cd7e1bc14c6fedd87094ae6f0948dd"
+ },
+ "manage.py": {
+ "bytes": 3559,
+ "sha256": "d9f1b13e635e15dff526a2f082144570e13bc18e73d9d85da1c1804de82a3e32"
+ },
+ "qa-browser.js": {
+ "bytes": 7554,
+ "sha256": "50d4293d41d5aa63b404c3e22fdc1349008765365fac8cdafe01dfdf858893da"
+ },
+ "web/app.js": {
+ "bytes": 14358,
+ "sha256": "b3dcd10fa4e4ab56dff200e9238c640e6dbea5fa7b624328d7d421ee8744e778"
+ },
+ "web/index.html": {
+ "bytes": 13396,
+ "sha256": "28630577d3faa81b61fec95723d421e1cac9a576452d909342cfd054d3dea385"
+ },
+ "web/style.css": {
+ "bytes": 16606,
+ "sha256": "b399be728d8f2bfb1964903e11e69a9bccbc83c9d90ecfd6783110c7a0702923"
+ }
+ },
+ "adapterTests": {
+ "command": "node prototypes/live-rules/adapter.test.mjs",
+ "exitCode": 0,
+ "output": "✔ the actual Wasm preserves airborne state through a rule change, then permits the second jump (9.168858ms)\n✔ bad CLI edits and malformed numeric input cannot silently coerce into a different rule (1.968798ms)\n✔ keyboard, pointer and terminal requests give the same simulation result (2.355835ms)\n✔ paused input has an inspectable decision; advance cannot move paused state (2.338743ms)\n✔ bounded history and fixed linear memory survive repeated play and edits (4.602115ms)\n✔ replaying the same CLI operations produces identical snapshots and journal (3.481058ms)\nℹ tests 6\nℹ suites 0\nℹ pass 6\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 32.277966\n"
+ }
+}
diff --git a/prototypes/live-rules/runtime.mjs b/prototypes/live-rules/runtime.mjs
new file mode 100644
index 0000000..9c854ea
--- /dev/null
+++ b/prototypes/live-rules/runtime.mjs
@@ -0,0 +1,100 @@
+// Node adapter only. All rule evaluation, physics and persistent state live in Zig.
+import { readFile } from 'node:fs/promises';
+
+export const fields = [
+ 'tick', 'y', 'vy', 'distance', 'obstacleX', 'passed', 'hits', 'jumpsUsed',
+ 'running', 'revision', 'lastReason', 'lastSource', 'totalInputs',
+ 'acceptedJumps', 'mode', 'impulse', 'enabled', 'grounded', 'obstacleHeight',
+];
+const signed = new Set(['y', 'vy', 'obstacleX']);
+
+export async function loadCore() {
+ const binary = await readFile(new URL('./web/core.wasm', import.meta.url));
+ const module = await WebAssembly.compile(binary);
+ if (WebAssembly.Module.imports(module).length !== 0) throw new Error('Core must not import a host runtime.');
+ const instance = await WebAssembly.instantiate(module, {});
+ instance.exports.reset();
+ return instance.exports;
+}
+
+export function snapshot(core) {
+ const state = Object.fromEntries(fields.map((name, id) => {
+ const value = core.get(id);
+ return [name, signed.has(name) ? value : value >>> 0];
+ }));
+ return {
+ state,
+ decision: core.node_value(0) === -1 ? null : {
+ tick: core.decision_meta(0) >>> 0,
+ revision: core.decision_meta(1) >>> 0,
+ y: core.decision_meta(2),
+ jumps: core.decision_meta(3) >>> 0,
+ nodes: Array.from({ length: 6 }, (_, i) => core.node_value(i)),
+ },
+ history: Array.from({ length: core.history_count() }, (_, i) => ({
+ tick: core.history_get(i, 0) >>> 0,
+ kind: core.history_get(i, 1),
+ detail: core.history_get(i, 2) >>> 0,
+ revision: core.history_get(i, 3) >>> 0,
+ })),
+ };
+}
+
+export function storage(core) {
+ const keys = ['rules', 'state', 'decision', 'history', 'engine', 'graph', 'logicalTotal'];
+ return {
+ layoutBytes: Object.fromEntries(keys.map((key, i) => [key, core.bytes(i)])),
+ wasmLinearMemoryBytes: core.memory.buffer.byteLength,
+ note: 'Type-layout sizes; constant graph may be compiled into code. Not browser heap or RSS.',
+ };
+}
+
+export function parseCommand(line) {
+ const tokens = line.trim().split(/\s+/);
+ const name = tokens[0];
+ if (!name) return { op: 'status' };
+ if (['status', 'inspect', 'memory', 'reset', 'jump', 'help', 'quit'].includes(name)) {
+ if (tokens.length !== 1) throw new Error(`${name}: 인수가 필요하지 않습니다.`);
+ return { op: name };
+ }
+ if (name === 'step') {
+ if (tokens.length > 2) throw new Error('사용법: step [1..600]');
+ const count = tokens.length === 1 ? 1 : integer(tokens[1], 1, 600);
+ return { op: 'step', count };
+ }
+ if (name === 'rules') {
+ if (tokens.length !== 4 || !['ground', 'double'].includes(tokens[1]) || !['on', 'off'].includes(tokens[3])) {
+ throw new Error('사용법: rules ground|double 6..16 on|off');
+ }
+ return { op: 'rules', mode: tokens[1] === 'double' ? 1 : 0,
+ impulse: integer(tokens[2], 6, 16), enabled: tokens[3] === 'on' ? 1 : 0 };
+ }
+ throw new Error('알 수 없는 명령입니다. help를 입력하세요.');
+}
+
+function integer(text, min, max) {
+ if (!/^\d+$/.test(text)) throw new Error(`${min}..${max} 범위의 정수를 입력하세요.`);
+ const value = Number(text);
+ if (!Number.isSafeInteger(value) || value < min || value > max) {
+ throw new Error(`${min}..${max} 범위의 정수를 입력하세요.`);
+ }
+ return value;
+}
+
+export function execute(core, command) {
+ switch (command.op) {
+ case 'jump': core.input(2); break;
+ case 'step':
+ for (let n = 0; n < command.count; n += 1) core.step();
+ break;
+ case 'rules':
+ if (core.apply_rules(command.mode, command.impulse, command.enabled) !== 0) {
+ throw new Error('규칙 전체를 거절했습니다. 이전 규칙과 상태를 유지합니다.');
+ }
+ break;
+ case 'reset': core.reset(); break;
+ case 'status': case 'inspect': case 'memory': break;
+ default: throw new Error('실행할 수 없는 명령입니다.');
+ }
+ return snapshot(core);
+}
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}}