From b1ce1927022d374198441afafeb27199fc8a052a Mon Sep 17 00:00:00 2001 From: eunhhu Date: Thu, 24 Sep 2026 17:14:54 +0100 Subject: Establish native micro runtime checkpoint --- prototypes/live-rules/.gitignore | 1 + prototypes/live-rules/README.md | 126 ++++++ prototypes/live-rules/adapter.test.mjs | 96 ++++ prototypes/live-rules/cli.mjs | 64 +++ prototypes/live-rules/core.zig | 504 +++++++++++++++++++++ prototypes/live-rules/manage.py | 88 ++++ prototypes/live-rules/measure.mjs | 48 ++ prototypes/live-rules/qa-browser.js | 131 ++++++ .../2026-09-22-linux-aarch64/browser-checks.json | 215 +++++++++ .../results/2026-09-22-linux-aarch64/desktop.png | Bin 0 -> 150506 bytes .../results/2026-09-22-linux-aarch64/mobile.png | Bin 0 -> 123520 bytes .../storage-and-replay.json | 503 ++++++++++++++++++++ prototypes/live-rules/runtime.mjs | 100 ++++ prototypes/live-rules/web/app.js | 294 ++++++++++++ prototypes/live-rules/web/core.wasm | Bin 0 -> 32544 bytes prototypes/live-rules/web/index.html | 101 +++++ prototypes/live-rules/web/style.css | 1 + 17 files changed, 2272 insertions(+) create mode 100644 prototypes/live-rules/.gitignore create mode 100644 prototypes/live-rules/README.md create mode 100644 prototypes/live-rules/adapter.test.mjs create mode 100644 prototypes/live-rules/cli.mjs create mode 100644 prototypes/live-rules/core.zig create mode 100644 prototypes/live-rules/manage.py create mode 100644 prototypes/live-rules/measure.mjs create mode 100644 prototypes/live-rules/qa-browser.js create mode 100644 prototypes/live-rules/results/2026-09-22-linux-aarch64/browser-checks.json create mode 100644 prototypes/live-rules/results/2026-09-22-linux-aarch64/desktop.png create mode 100644 prototypes/live-rules/results/2026-09-22-linux-aarch64/mobile.png create mode 100644 prototypes/live-rules/results/2026-09-22-linux-aarch64/storage-and-replay.json create mode 100644 prototypes/live-rules/runtime.mjs create mode 100644 prototypes/live-rules/web/app.js create mode 100755 prototypes/live-rules/web/core.wasm create mode 100644 prototypes/live-rules/web/index.html create mode 100644 prototypes/live-rules/web/style.css (limited to 'prototypes/live-rules') 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 +``` + +브라우저에서 를 연다. 포함된 `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 Binary files /dev/null and b/prototypes/live-rules/results/2026-09-22-linux-aarch64/desktop.png 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 Binary files /dev/null and b/prototypes/live-rules/results/2026-09-22-linux-aarch64/mobile.png 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 Binary files /dev/null and b/prototypes/live-rules/web/core.wasm 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 @@ + + + + + + + Pure — 규칙 실험실 + + + + + + + +
+
+

작게 만들고, 직접 바꿔보기

뛰는 도중에, 규칙을 바꿔보세요.

같은 공룡, 같은 순간. 점프 규칙만 바꾸면 무엇이 달라질까요?

+

살아 있는 규칙
첫 번째 연습장

+
+ +
작은 실행기를 불러오고 있습니다…
+ + +
+
+

01연습장

불러오는 중
+
+ + 공룡 점프 연습장규칙을 적용하고 점프 버튼이나 스페이스 키로 움직여 보세요. 현재 상태는 연습장 아래에 표시됩니다. + + + + + + + + + + + + + + + PURE · RULE PLAYGROUND + +
일시정지 상태에서도 규칙과 입력을 확인할 수 있어요.
+
+
+
+ + +
+

실행하면 시간이 흐릅니다. 멈춘 뒤 한 프레임씩 살펴볼 수도 있어요.

+
+
높이—
세로 속도—
발 디딤—
사용한 점프—
진행 거리—
진행 시간—
+
+
지나간 장애물 0닿은 장애물 0tick 0
+
+ + +
+ +
+

03왜 그렇게 움직였나요?

마지막 점프 요청
+

첫 점프를 기다리고 있어요.

한 번 누르면 입력이 규칙을 통과하는 과정을 볼 수 있습니다.

아직 입력 없음
+ +
    +
  1. 01요청 도착대기
  2. 02입력 켜짐대기
  3. 03땅에 있음대기
  4. 04점프 여유대기
  5. 05방식에 맞음대기
  6. 06점프 허용대기
  7. +
+

요청과 입력 켜짐을 확인하고, 선택한 방식에 따라 ‘땅에 있음’ 또는 ‘점프 여유’를 판단합니다. 기록은 입력을 받은 순간의 상태입니다.

+

최근 변화 최대 8개 표시

  1. 점프하거나 규칙을 적용하면 이곳에 기록됩니다.
+
+ +
실험의 범위와 메모리 보기기술적인 내용은 여기에

무엇을 확인하는 실험인가요?

세계가 실행되는 동안 점프 규칙을 바꾸고, 상태를 유지하며, 입력 판단을 살펴보는 작은 시제품입니다. 물리 계산과 규칙 판단은 Zig로 작성한 동일한 WebAssembly 코어에서 처리합니다. 화면은 그 결과를 보여줍니다.

시간은 고정 60Hz 틱으로 진행합니다. 느린 화면에서는 한 번에 최대 6틱을 처리하며, 생략한 틱 수를 아래에 표시합니다. 실제 시간과 정확히 같다는 보장은 없습니다.

높이·속도·거리는 세계 좌표(px)로 표시합니다. 높은 점프에서는 시점이 공룡을 따라 위로 이동합니다. 화면 크기와 시점은 물리 계산을 바꾸지 않습니다.

아직 실행 전 · 생략한 틱 0

코어의 자료 구조 크기

코어가 보고한 논리 자료 크기 · 실제 프로세스 메모리와 다름
규칙—
세계 상태—
마지막 판단—
기록과 관리 정보—
엔진 전체 · 정렬 포함—
고정 규칙 그래프—
엔진 + 그래프 · 논리 합계—
WASM 선형 메모리 · 별도—

자료 구조 크기는 Zig 타입 배치 기준입니다. 고정 그래프는 컴파일 과정에서 코드에 통합될 수 있어, 이 수치를 실제 RAM 사용량으로 읽으면 안 됩니다. WASM 선형 메모리는 실제 할당된 별도 범위이며 위 합계와 더하지 않습니다. 브라우저·화면·코드·프로세스 전체 메모리(RSS)는 이 표에 포함되지 않습니다. M3 성능이나 최적화 우위를 입증하는 측정도 아닙니다.

+

+
+ + + 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}} -- cgit v1.2.3