diff options
Diffstat (limited to 'prototypes/live-rules')
| -rw-r--r-- | prototypes/live-rules/.gitignore | 1 | ||||
| -rw-r--r-- | prototypes/live-rules/README.md | 126 | ||||
| -rw-r--r-- | prototypes/live-rules/adapter.test.mjs | 96 | ||||
| -rw-r--r-- | prototypes/live-rules/cli.mjs | 64 | ||||
| -rw-r--r-- | prototypes/live-rules/core.zig | 504 | ||||
| -rw-r--r-- | prototypes/live-rules/manage.py | 88 | ||||
| -rw-r--r-- | prototypes/live-rules/measure.mjs | 48 | ||||
| -rw-r--r-- | prototypes/live-rules/qa-browser.js | 131 | ||||
| -rw-r--r-- | prototypes/live-rules/results/2026-09-22-linux-aarch64/browser-checks.json | 215 | ||||
| -rw-r--r-- | prototypes/live-rules/results/2026-09-22-linux-aarch64/desktop.png | bin | 0 -> 150506 bytes | |||
| -rw-r--r-- | prototypes/live-rules/results/2026-09-22-linux-aarch64/mobile.png | bin | 0 -> 123520 bytes | |||
| -rw-r--r-- | prototypes/live-rules/results/2026-09-22-linux-aarch64/storage-and-replay.json | 503 | ||||
| -rw-r--r-- | prototypes/live-rules/runtime.mjs | 100 | ||||
| -rw-r--r-- | prototypes/live-rules/web/app.js | 294 | ||||
| -rwxr-xr-x | prototypes/live-rules/web/core.wasm | bin | 0 -> 32544 bytes | |||
| -rw-r--r-- | prototypes/live-rules/web/index.html | 101 | ||||
| -rw-r--r-- | prototypes/live-rules/web/style.css | 1 |
17 files changed, 2272 insertions, 0 deletions
diff --git a/prototypes/live-rules/.gitignore b/prototypes/live-rules/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/prototypes/live-rules/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/prototypes/live-rules/README.md b/prototypes/live-rules/README.md new file mode 100644 index 0000000..a32d697 --- /dev/null +++ b/prototypes/live-rules/README.md @@ -0,0 +1,126 @@ +# Pure · 실행 중 규칙 바꾸기 + +공룡 연습 장면에서 점프 규칙을 바꾸고, 현재 상태와 입력이 허용·거절된 이유를 +확인하는 첫 사용 경험 실험이다. 부딪혀도 계속 진행되며, 규칙을 적용해도 위치·속도· +진행 상태는 유지된다. **처음부터**만 규칙과 상태를 함께 초기화한다. + +Zig 코어 하나를 WebAssembly로 빌드해 브라우저와 터미널에서 사용한다. +JavaScript에는 게임 물리나 별도의 규칙 실행기가 없다. 이 데모의 목적은 편집 의미와 +관찰 비용을 확인하는 것이다. 일반 런타임·전체 앱 편집기나 성능 우월성을 입증한 결과는 아니다. + +## 실행 + +저장소 루트에서 다음을 실행한다. 외부 JavaScript 패키지는 필요하지 않다. + +```sh +python3 prototypes/live-rules/manage.py serve --port 8765 +``` + +브라우저에서 <http://127.0.0.1:8765>를 연다. 포함된 `web/core.wasm`을 사용한다. +`file://`로 HTML을 직접 여는 대신 위 서버를 사용한다. 서버는 로컬 주소에만 바인딩한다. + +소스 수정 후에는 [Zig 0.15.2](https://ziglang.org/download/)로 다시 빌드한다. +새 버전과 구분해 재현하기 위해 이 버전으로 고정했다. 전체 재빌드에는 인터넷이 필요하지 않다. + +```sh +python3 prototypes/live-rules/manage.py build +# PATH에 없는 컴파일러를 지정할 때: +python3 prototypes/live-rules/manage.py build --zig /path/to/zig +``` + +빌드는 네이티브 테스트를 먼저 수행한다. 그 뒤 `wasm32-freestanding`, `ReleaseSafe`, +스택 예약 16 KiB, 초기·최대 선형 메모리 64 KiB로 빌드한다. 컴파일 캐시는 `build/`에 둔다. +버전·소스 해시·바이너리 해시·검사 출력은 `build/build.json`에 저장한다. +Wasm 파일 크기와 선형 메모리 용량은 서로 다른 값이다. + +## 직접 확인할 작업 + +1. 시작하고 **점프**를 누른다. 공중에서 다시 누르면 왜 거절됐는지 확인한다. +2. 공중에서 일시정지한다. 점프 조건을 **두 번 점프**로 바꾸고 **규칙 적용**을 누른다. +3. 위치와 진행 상태가 그대로인지 보고 다시 점프한다. 새 규칙의 판정이 표시되는지 확인한다. +4. 점프를 비활성화해 거절 이유를 확인한 뒤 다시 활성화한다. + +시간을 놓치면 정지 상태에서 점프하고 **한 프레임**씩 진행해도 된다. +정지 중 입력도 판정하지만, 실제 위치 이동은 프레임을 진행할 때 일어난다. +규칙을 바꾸기 전의 마지막 판정은 해당 규칙 버전의 기록으로 남는다. +입력과 규칙 변경은 한 프레임 처리 사이에서 순서대로 적용한다. + +## 같은 코어를 터미널에서 사용 + +Node.js 20 이상으로 실행한다. 검증 환경은 Node 24다. + +```sh +node prototypes/live-rules/cli.mjs +node prototypes/live-rules/cli.mjs --demo +node prototypes/live-rules/cli.mjs --demo --json +``` + +터미널에서는 시간이 저절로 흐르지 않는다. 아래 명령을 순서대로 입력하면 +공중에서의 거절과 규칙 수정 후 두 번째 점프를 정확히 같은 프레임에서 관찰할 수 있다. + +```text +jump +step 8 +jump +rules double 12 on +jump +inspect +memory +``` + +`help`는 명령 안내, `quit`은 종료다. 규칙 편집은 세 값을 한꺼번에 검증해 적용한다. +지원하지 않는 값이나 잘못된 명령은 이전 규칙과 상태를 보존한 채 거절한다. + +## 구조와 측정 범위 + +| 파일 | 역할 | +|---|---| +| `core.zig` | 실제 여섯 노드 판정 그래프, 고정 간격 물리, 규칙 변경, 상태·판정·기록 보관, 네이티브 테스트 | +| `web/` | 같은 코어에 연결하는 화면·키보드·버튼 어댑터 | +| `runtime.mjs`, `cli.mjs` | 같은 Wasm을 로드하는 터미널 어댑터 | +| `adapter.test.mjs` | 실제 Wasm과 입력 어댑터의 의미·메모리 한도 검증 | +| `measure.mjs` | 자료 구조 크기, 정확한 변경 기록, 소스·바이너리 해시 저장 | + +규칙은 점프 조건·힘·활성화 세 항목이다. 입력·활성 여부·접지 여부·남은 점프 수를 +읽고, 조건을 선택한 뒤 AND로 합류하는 실제 고정 그래프를 평가한다. +연산이나 연결을 자유롭게 추가하는 그래프 편집기는 아직 없다. + +자료 구조 크기는 Zig의 `@sizeOf`로 계산한다. 그래프 상수는 컴파일러가 코드로 +접을 수도 있으므로 이 합계를 물리 메모리 실측으로 해석하지 않는다. +Wasm 선형 메모리 64 KiB에는 코어 데이터와 스택 여유가 포함되며, 둘을 더하지 않는다. +브라우저/Node 엔진, DOM·렌더러, 코드와 프로세스 메모리는 별도이며 이번 수치에 포함하지 않는다. + +가장 최근 입력의 판정과 최근 사건 16개만 보관한다. 오래된 사건을 덮어쓰는 +이 기록은 완전한 실행 재생 로그가 아니다. 프레임 입력 큐를 만들어 몰래 버리는 방식도 아니다. +브라우저가 숨겨지면 일시정지하고, 프레임 처리가 밀린 경우의 시간 제한은 화면에 표시한다. +이 데모는 하드 실시간 동작을 보장하지 않는다. + +## 검증 및 기록 생성 + +```sh +python3 prototypes/live-rules/manage.py build +node prototypes/live-rules/adapter.test.mjs +node prototypes/live-rules/measure.mjs prototypes/live-rules/results/my-run +``` + +측정 출력 폴더의 상위 `results/`는 있어야 하며, 이미 있는 출력 폴더를 덮어쓰지 않는다. +측정 스크립트는 처리 속도·입력 지연을 측정하지 않는다. 브라우저를 통한 실제 조작 확인은 +이 자동 검사와 별도로 수행한다. 자세한 결과는 [연구 보고서 003](../../docs/research/003-live-rules.md)에 있다. + +브라우저 검증용 `qa-browser.js`는 Playwright CLI의 `run-code`에 전달하는 함수 표현식이다. +서버를 실행하고 CLI로 해당 페이지를 연 뒤 다음처럼 파일 내용을 인수로 전달할 수 있다. +검증에 사용한 CLI 버전은 0.1.21, 브라우저는 Chromium 151이며 런타임의 의존성은 아니다. +Playwright가 사용하는 브라우저의 설치 또는 실행 경로 설정은 별도로 필요하다. + +```sh +npx @playwright/cli -s=pure-live open http://127.0.0.1:8765 +python3 - <<'PY' +from pathlib import Path +import subprocess +Path('output/playwright').mkdir(parents=True, exist_ok=True) +script = Path('prototypes/live-rules/qa-browser.js').read_text() +subprocess.run(['npx', '@playwright/cli', '-s=pure-live', 'run-code', script], check=True) +PY +``` + +이 검사는 화면과 상태를 초기화하고 여러 시나리오를 조작한다. 개발용 페이지에서 실행한다. diff --git a/prototypes/live-rules/adapter.test.mjs b/prototypes/live-rules/adapter.test.mjs new file mode 100644 index 0000000..95af13b --- /dev/null +++ b/prototypes/live-rules/adapter.test.mjs @@ -0,0 +1,96 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { loadCore, snapshot, storage, parseCommand, execute } from './runtime.mjs'; + +test('the actual Wasm preserves airborne state through a rule change, then permits the second jump', async () => { + const c = await loadCore(); + execute(c, parseCommand('jump')); + execute(c, parseCommand('step 8')); + execute(c, parseCommand('jump')); + const before = snapshot(c); + assert.equal(before.state.lastReason, 3); + const after = execute(c, parseCommand('rules double 12 on')); + for (const field of ['tick', 'y', 'vy', 'distance', 'obstacleX', 'jumpsUsed', 'hits', 'passed']) { + assert.equal(after.state[field], before.state[field], field); + } + assert.deepEqual(after.decision, before.decision); + assert.equal(after.state.revision, 1); + execute(c, parseCommand('jump')); + assert.equal(c.get(10), 1); + assert.equal(c.get(2), 1200); + assert.equal(c.get(7), 2); + assert.equal(c.input(2), 0); + assert.equal(c.get(10), 4); +}); + +test('bad CLI edits and malformed numeric input cannot silently coerce into a different rule', async () => { + const c = await loadCore(); + const before = snapshot(c); + for (const command of ['rules double 8.5 on', 'rules double 4294967306 on', 'rules ground 5 on', + 'rules other 10 on', 'rules double 10 maybe', 'rules double 10 on extra', 'step 0', 'step 601', 'jump extra']) { + assert.throws(() => execute(c, parseCommand(command)), undefined, command); + assert.deepEqual(snapshot(c), before); + } + assert.equal(c.apply_rules(2, 10, 1), 1); + assert.deepEqual(snapshot(c), before); +}); + +test('keyboard, pointer and terminal requests give the same simulation result', async () => { + const states = []; + for (const source of [0, 1, 2]) { + const c = await loadCore(); + c.apply_rules(1, 12, 1); + c.input(source); + for (let i = 0; i < 8; i++) c.step(); + c.input(source); + const { lastSource, ...state } = snapshot(c).state; + assert.equal(lastSource, source); + states.push(state); + } + assert.deepEqual(states[0], states[1]); + assert.deepEqual(states[1], states[2]); +}); + +test('paused input has an inspectable decision; advance cannot move paused state', async () => { + const c = await loadCore(); + assert.equal(snapshot(c).decision, null); + assert.equal(c.input(0), 1); + assert.equal(c.get(1), 0); + assert.equal(c.get(17), 0); + const before = snapshot(c); + assert.equal(c.advance(60), 0); + assert.deepEqual(snapshot(c), before); + c.step(); + assert.equal(c.get(1), 945); + assert.equal(c.get(0), 1); +}); + +test('bounded history and fixed linear memory survive repeated play and edits', async () => { + const c = await loadCore(); + const before = storage(c); + c.set_running(1); + for (let i = 0; i < 5000; i++) { + c.apply_rules(i % 2, 6 + i % 11, 1); + c.input(i % 3); + c.advance(8); + } + assert.equal(c.history_count(), 16); + assert.equal(c.history_get(16, 0), -1); + assert.equal(c.get(0), 40000); + assert.deepEqual(storage(c), before); + assert.equal(before.wasmLinearMemoryBytes, 65536); + assert.throws(() => c.memory.grow(1), RangeError); + const b = before.layoutBytes; + assert.equal(b.logicalTotal, b.engine + b.graph); + assert.ok(b.engine >= b.rules + b.state + b.decision + b.history); +}); + +test('replaying the same CLI operations produces identical snapshots and journal', async () => { + const trace = ['jump', 'step 8', 'jump', 'rules double 10 on', 'jump', 'step 60', 'rules ground 8 off', 'jump']; + const snapshots = []; + for (let i = 0; i < 2; i++) { + const c = await loadCore(); + snapshots.push(trace.map((line) => execute(c, parseCommand(line)))); + } + assert.deepEqual(snapshots[0], snapshots[1]); +}); diff --git a/prototypes/live-rules/cli.mjs b/prototypes/live-rules/cli.mjs new file mode 100644 index 0000000..2c8a564 --- /dev/null +++ b/prototypes/live-rules/cli.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import { createInterface } from 'node:readline'; +import { loadCore, storage, parseCommand, execute } from './runtime.mjs'; + +const help = `Pure · 규칙 실험실 (브라우저와 같은 Zig 코어) + jump 점프 입력 + step [1..600] 지정한 프레임만 진행 (기본 1) + rules ground|double 6..16 on|off + 점프 조건·힘·활성화를 함께 적용 + status / inspect / memory 현재 상태 / 마지막 판정·기록 / 공간 + reset 규칙과 상태 모두 처음으로 + help / quit +예: jump → step 8 → jump → rules double 10 on → jump +시간은 step으로만 진행합니다. 실행 규칙 변경은 진행 상태를 보존합니다.`; + +const reason = ['아직 입력 없음', '점프 허용', '점프 비활성', '공중: 땅에서만 가능', '두 번 모두 사용', '지원하지 않는 입력']; + +async function main() { + const args = process.argv.slice(2); + if (args.includes('--help')) { console.log(help); return; } + if (args.some((x) => !['--demo', '--json'].includes(x))) throw new Error('사용법: node cli.mjs [--demo] [--json]'); + const json = args.includes('--json'); + const core = await loadCore(); + function show(line) { + const command = parseCommand(line); + if (command.op === 'help') { console.log(help); return true; } + if (command.op === 'quit') return false; + const result = execute(core, command); + if (json) { + console.log(JSON.stringify({ command: line, ...result, ...(command.op === 'memory' ? { storage: storage(core) } : {}) })); + } else if (command.op === 'memory') { + console.log(JSON.stringify(storage(core), null, 2)); + } else if (command.op === 'inspect') { + console.log(JSON.stringify(result, null, 2)); + } else { + const s = result.state; + console.log(`t=${s.tick} · 높이 ${(s.y / 100).toFixed(2)} · 속도 ${(s.vy / 100).toFixed(2)} · 점프 ${s.jumpsUsed}/2 · 규칙 r${s.revision} (${s.mode ? 'double' : 'ground'}, ${s.impulse}, ${s.enabled ? 'on' : 'off'}) · ${reason[s.lastReason]}`); + } + return true; + } + if (args.includes('--demo')) { + for (const line of ['status', 'jump', 'step 8', 'jump', 'rules double 10 on', 'jump', 'step 60', 'memory']) show(line); + return; + } + if (!json && process.stdin.isTTY) console.log(help); + const terminal = createInterface({ input: process.stdin, output: process.stdout, + terminal: Boolean(process.stdin.isTTY) }); + terminal.setPrompt('pure> '); + if (process.stdin.isTTY) terminal.prompt(); + for await (const line of terminal) { + try { + if (!show(line)) { terminal.close(); break; } + } catch (error) { + console.error(error.message); + if (!process.stdin.isTTY) process.exitCode = 1; + } + if (process.stdin.isTTY) terminal.prompt(); + } +} + +main().catch((error) => { + console.error(`${error.message}\n빌드: python3 prototypes/live-rules/manage.py build`); + process.exitCode = 1; +}); diff --git a/prototypes/live-rules/core.zig b/prototypes/live-rules/core.zig new file mode 100644 index 0000000..bd709df --- /dev/null +++ b/prototypes/live-rules/core.zig @@ -0,0 +1,504 @@ +//! A bounded, deterministic practice-game experiment, not a general runtime. +//! The same source is tested natively and compiled to freestanding WebAssembly. +//! All persistent storage is static; functions perform no allocation. + +const std = @import("std"); + +const Rules = struct { + mode: u32 = 0, + impulse: u32 = 10, + enabled: u32 = 1, + revision: u32 = 0, +}; + +const State = struct { + tick: u32 = 0, + y: i32 = 0, + vy: i32 = 0, + distance: u32 = 0, + obstacle_x: i32 = 800, + passed: u32 = 0, + hits: u32 = 0, + jumps_used: u32 = 0, + running: u32 = 0, + last_reason: u32 = 0, + last_source: u32 = std.math.maxInt(u32), + total_inputs: u32 = 0, + accepted_jumps: u32 = 0, + obstacle_height: u32 = 48, + rng: u32 = 0x50555245, + obstacle_touched: u32 = 0, + obstacle_passed: u32 = 0, +}; + +const Decision = struct { + tick: u32 = 0, + revision: u32 = 0, + y: i32 = 0, + jumps: u32 = 0, + values: [6]u8 = @splat(0), + valid: bool = false, +}; + +const Event = struct { + tick: u32 = 0, + kind: u32 = 0, + detail: u32 = 0, + revision: u32 = 0, +}; + +const history_capacity: u32 = 16; + +const History = struct { + entries: [history_capacity]Event = @splat(.{}), + next: u32 = 0, + count: u32 = 0, +}; + +const Engine = struct { + rules: Rules = .{}, + state: State = .{}, + decision: Decision = .{}, + history: History = .{}, +}; + +const Op = enum(u8) { request, enabled, grounded, extra, select, all }; +const Node = struct { op: Op, a: u8 = 0, b: u8 = 0, c: u8 = 0 }; + +// Node indices are topologically ordered. These descriptors drive evaluation; +// the display is observing this actual graph, not a separate diagram model. +const graph = [_]Node{ + .{ .op = .request }, + .{ .op = .enabled }, + .{ .op = .grounded }, + .{ .op = .extra }, + .{ .op = .select, .a = 2, .b = 3 }, + .{ .op = .all, .a = 0, .b = 1, .c = 4 }, +}; + +var engine: Engine = .{}; + +fn asPublic(value: u32) i32 { + return @bitCast(value); +} + +fn grounded() bool { + // A jump accepted while paused already consumes the ground jump even though + // position will not be integrated until the next tick. + return engine.state.y == 0 and engine.state.vy == 0; +} + +fn record(kind: u32, detail: u32) void { + const h = &engine.history; + h.entries[h.next] = .{ + .tick = engine.state.tick, + .kind = kind, + .detail = detail, + .revision = engine.rules.revision, + }; + h.next = (h.next + 1) % history_capacity; + h.count = @min(h.count + 1, history_capacity); +} + +fn evaluate(source: u32) void { + var decision: Decision = .{ + .tick = engine.state.tick, + .revision = engine.rules.revision, + .y = engine.state.y, + .jumps = engine.state.jumps_used, + .valid = true, + }; + for (graph, 0..) |node, i| { + decision.values[i] = switch (node.op) { + .request => @intFromBool(source <= 2), + .enabled => @intFromBool(engine.rules.enabled == 1), + .grounded => @intFromBool(grounded()), + .extra => @intFromBool(engine.state.jumps_used < 2), + .select => if (engine.rules.mode == 0) decision.values[node.a] else decision.values[node.b], + .all => decision.values[node.a] & decision.values[node.b] & decision.values[node.c], + }; + } + engine.decision = decision; +} + +export fn reset() void { + engine = .{}; +} + +export fn set_running(flag: u32) void { + engine.state.running = @intFromBool(flag != 0); +} + +export fn apply_rules(mode: u32, impulse: u32, enabled: u32) u32 { + // Validate the complete candidate before mutating any part of the engine. + if (mode > 1 or impulse < 6 or impulse > 16 or enabled > 1) return 1; + const revision = engine.rules.revision +% 1; + engine.rules = .{ .mode = mode, .impulse = impulse, .enabled = enabled, .revision = revision }; + record(3, mode); + return 0; +} + +export fn input(source: u32) u32 { + evaluate(source); + const state = &engine.state; + state.total_inputs +%= 1; + state.last_source = source; + if (engine.decision.values[5] != 0) { + state.last_reason = 1; + state.vy = @as(i32, @intCast(engine.rules.impulse)) * 100; + state.jumps_used +%= 1; + state.accepted_jumps +%= 1; + record(1, source); + return 1; + } + state.last_reason = if (source > 2) 5 else if (engine.rules.enabled == 0) 2 else if (engine.rules.mode == 0) 3 else 4; + record(2, state.last_reason); + return 0; +} + +fn nextRandom() u32 { + engine.state.rng = engine.state.rng *% 1664525 +% 1013904223; + return engine.state.rng; +} + +fn tickOnce() void { + const state = &engine.state; + state.tick +%= 1; + state.distance +%= 4; + + if (!grounded()) { + state.vy -= 55; + state.y += state.vy; + if (state.y <= 0) { + state.y = 0; + state.vy = 0; + state.jumps_used = 0; + record(4, 0); + } + } + + state.obstacle_x -= 4; + const horizontal_overlap = state.obstacle_x < 156 and state.obstacle_x + 32 > 120; + if (horizontal_overlap and state.y < @as(i32, @intCast(state.obstacle_height)) * 100 and state.obstacle_touched == 0) { + state.hits +%= 1; + state.obstacle_touched = 1; + record(5, state.hits); + } + if (state.obstacle_x + 32 <= 120 and state.obstacle_passed == 0) { + state.passed +%= 1; + state.obstacle_passed = 1; + record(6, state.passed); + } + if (state.obstacle_x + 32 < 0) { + state.obstacle_x = 500 + @as(i32, @intCast(nextRandom() % 301)); + state.obstacle_height = 32 + (nextRandom() % 4) * 8; + state.obstacle_touched = 0; + state.obstacle_passed = 0; + } +} + +export fn advance(ticks: u32) u32 { + if (ticks > 600 or engine.state.running == 0) return 0; + for (0..ticks) |_| tickOnce(); + return ticks; +} + +export fn step() void { + tickOnce(); +} + +export fn get(field: u32) i32 { + const s = &engine.state; + return switch (field) { + 0 => asPublic(s.tick), + 1 => s.y, + 2 => s.vy, + 3 => asPublic(s.distance), + 4 => s.obstacle_x, + 5 => asPublic(s.passed), + 6 => asPublic(s.hits), + 7 => asPublic(s.jumps_used), + 8 => asPublic(s.running), + 9 => asPublic(engine.rules.revision), + 10 => asPublic(s.last_reason), + 11 => asPublic(s.last_source), + 12 => asPublic(s.total_inputs), + 13 => asPublic(s.accepted_jumps), + 14 => asPublic(engine.rules.mode), + 15 => asPublic(engine.rules.impulse), + 16 => asPublic(engine.rules.enabled), + 17 => @intFromBool(grounded()), + 18 => asPublic(s.obstacle_height), + else => -1, + }; +} + +export fn node_value(id: u32) i32 { + if (!engine.decision.valid or id >= engine.decision.values.len) return -1; + return engine.decision.values[id]; +} + +export fn decision_meta(field: u32) i32 { + if (!engine.decision.valid) return -1; + return switch (field) { + 0 => asPublic(engine.decision.tick), + 1 => asPublic(engine.decision.revision), + 2 => engine.decision.y, + 3 => asPublic(engine.decision.jumps), + else => -1, + }; +} + +export fn history_count() u32 { + return engine.history.count; +} + +export fn history_get(newest_index: u32, field: u32) i32 { + const h = &engine.history; + if (newest_index >= h.count) return -1; + const index = (h.next + history_capacity - 1 - newest_index) % history_capacity; + const event = &h.entries[index]; + return switch (field) { + 0 => asPublic(event.tick), + 1 => asPublic(event.kind), + 2 => asPublic(event.detail), + 3 => asPublic(event.revision), + else => -1, + }; +} + +export fn bytes(kind: u32) u32 { + return switch (kind) { + 0 => @sizeOf(Rules), + 1 => @sizeOf(State), + 2 => @sizeOf(Decision), + 3 => @sizeOf(History), + 4 => @sizeOf(Engine), + 5 => @sizeOf(@TypeOf(graph)), + 6 => @sizeOf(Engine) + @sizeOf(@TypeOf(graph)), + else => 0, + }; +} + +test "edits preserve complete simulation state and latest observation" { + reset(); + set_running(1); + try std.testing.expectEqual(@as(u32, 1), input(0)); + try std.testing.expectEqual(@as(u32, 8), advance(8)); + const before_state = engine.state; + const before_decision = engine.decision; + try std.testing.expectEqual(@as(u32, 0), apply_rules(1, 16, 0)); + try std.testing.expectEqualDeep(before_state, engine.state); + try std.testing.expectEqualDeep(before_decision, engine.decision); + try std.testing.expectEqual(@as(i32, 1), get(9)); + try std.testing.expectEqual(@as(i32, 3), history_get(0, 1)); + try std.testing.expectEqual(@as(i32, 1), history_get(0, 3)); +} + +test "invalid edits and oversized advance are atomic" { + reset(); + set_running(1); + _ = input(2); + step(); + const before = engine; + const invalid = [_][3]u32{ + .{ 2, 10, 1 }, .{ 0, 5, 1 }, .{ 0, 17, 1 }, + .{ 0, 10, 2 }, .{ std.math.maxInt(u32), 10, 1 }, + }; + for (invalid) |candidate| { + try std.testing.expectEqual(@as(u32, 1), apply_rules(candidate[0], candidate[1], candidate[2])); + try std.testing.expectEqualDeep(before, engine); + } + try std.testing.expectEqual(@as(u32, 0), advance(601)); + try std.testing.expectEqual(@as(u32, 0), advance(std.math.maxInt(u32))); + try std.testing.expectEqualDeep(before, engine); +} + +test "pause, step, and ground-only jump handle input before integration" { + reset(); + try std.testing.expectEqual(@as(i32, -1), node_value(0)); + try std.testing.expectEqual(@as(i32, -1), decision_meta(0)); + try std.testing.expectEqual(@as(u32, 0), advance(600)); + try std.testing.expectEqual(@as(u32, 1), input(0)); + try std.testing.expectEqual(@as(i32, 0), get(1)); + try std.testing.expectEqual(@as(i32, 1000), get(2)); + try std.testing.expectEqual(@as(i32, 0), get(17)); + try std.testing.expectEqual(@as(u32, 0), input(0)); + try std.testing.expectEqual(@as(i32, 3), get(10)); + step(); + try std.testing.expectEqual(@as(i32, 1), get(0)); + try std.testing.expectEqual(@as(i32, 945), get(1)); + try std.testing.expectEqual(@as(i32, 0), get(8)); + for (0..100) |_| step(); + try std.testing.expectEqual(@as(i32, 1), get(17)); + try std.testing.expectEqual(@as(i32, 0), get(7)); + try std.testing.expectEqual(@as(u32, 1), input(0)); +} + +test "double jump, disable, mode changes, and impulse apply at input boundaries" { + reset(); + _ = input(0); + for (0..5) |_| step(); + const old_velocity = get(2); + try std.testing.expectEqual(@as(u32, 0), apply_rules(1, 16, 1)); + try std.testing.expectEqual(old_velocity, get(2)); + try std.testing.expectEqual(@as(u32, 1), input(1)); + try std.testing.expectEqual(@as(i32, 1600), get(2)); + try std.testing.expectEqual(@as(i32, 2), get(7)); + try std.testing.expectEqual(@as(u32, 0), input(2)); + try std.testing.expectEqual(@as(i32, 4), get(10)); + _ = apply_rules(0, 6, 1); + try std.testing.expectEqual(@as(u32, 0), input(0)); + try std.testing.expectEqual(@as(i32, 3), get(10)); + for (0..150) |_| step(); + _ = apply_rules(1, 6, 0); + try std.testing.expectEqual(@as(u32, 0), input(0)); + try std.testing.expectEqual(@as(i32, 2), get(10)); + _ = apply_rules(1, 6, 1); + try std.testing.expectEqual(@as(u32, 1), input(0)); + try std.testing.expectEqual(@as(i32, 600), get(2)); + try std.testing.expectEqual(@as(u32, 1), input(0)); + try std.testing.expectEqual(@as(u32, 0), input(0)); +} + +test "all input sources share transition behavior and retain provenance" { + var reference: State = undefined; + for (0..3) |source| { + reset(); + try std.testing.expectEqual(@as(u32, 1), input(@intCast(source))); + for (0..7) |_| step(); + try std.testing.expectEqual(@as(i32, @intCast(source)), get(11)); + try std.testing.expectEqual(@as(i32, @intCast(source)), history_get(0, 2)); + var normalized = engine.state; + normalized.last_source = 0; + if (source == 0) reference = normalized else try std.testing.expectEqualDeep(reference, normalized); + } + reset(); + try std.testing.expectEqual(@as(u32, 0), input(std.math.maxInt(u32))); + try std.testing.expectEqual(@as(i32, 5), get(10)); + try std.testing.expectEqual(@as(i32, 0), get(2)); + try std.testing.expectEqual(@as(i32, 0), node_value(0)); + try std.testing.expectEqual(@as(i32, 5), history_get(0, 2)); +} + +test "fixed history overwrites oldest entry and handles invalid access" { + reset(); + for (0..40) |i| { + _ = apply_rules(@intCast(i % 2), 10, 1); + step(); + } + try std.testing.expectEqual(@as(u32, 16), history_count()); + for (0..16) |i| { + try std.testing.expectEqual(@as(i32, @intCast(40 - i)), history_get(@intCast(i), 3)); + try std.testing.expectEqual(@as(i32, @intCast(39 - i)), history_get(@intCast(i), 0)); + } + try std.testing.expectEqual(@as(i32, -1), history_get(16, 0)); + try std.testing.expectEqual(@as(i32, -1), history_get(0, 4)); + try std.testing.expectEqual(@as(i32, -1), history_get(std.math.maxInt(u32), 0)); +} + +test "decision is pre-input state and remains at its original revision" { + reset(); + _ = apply_rules(1, 10, 1); + step(); + _ = input(2); + try std.testing.expectEqual(@as(i32, 1), decision_meta(0)); + try std.testing.expectEqual(@as(i32, 1), decision_meta(1)); + try std.testing.expectEqual(@as(i32, 0), decision_meta(2)); + try std.testing.expectEqual(@as(i32, 0), decision_meta(3)); + try std.testing.expectEqual(@as(i32, 1), get(7)); + try std.testing.expectEqual(@as(i32, 1), node_value(2)); + try std.testing.expectEqual(@as(i32, 1), node_value(5)); + const snapshot = engine.decision; + _ = apply_rules(0, 16, 0); + step(); + try std.testing.expectEqualDeep(snapshot, engine.decision); + try std.testing.expectEqual(@as(i32, -1), node_value(6)); + try std.testing.expectEqual(@as(i32, -1), decision_meta(4)); +} + +test "graph truth table agrees with a direct permission predicate" { + for (0..2) |mode| { + for (0..2) |enabled| { + for (0..2) |airborne| { + for (0..2) |pending_jump| { + for (0..3) |jumps| { + for (0..4) |source| { + reset(); + engine.rules.mode = @intCast(mode); + engine.rules.enabled = @intCast(enabled); + engine.state.y = if (airborne == 1) 500 else 0; + engine.state.vy = if (pending_jump == 1) 1000 else 0; + engine.state.jumps_used = @intCast(jumps); + const on_ground = airborne == 0 and pending_jump == 0; + const permission = if (mode == 0) on_ground else jumps < 2; + const expected = source < 3 and enabled == 1 and permission; + const accepted = input(@intCast(source)); + try std.testing.expectEqual(@as(u32, @intFromBool(expected)), accepted); + try std.testing.expectEqual(@as(i32, @intFromBool(on_ground)), node_value(2)); + try std.testing.expectEqual(@as(i32, @intFromBool(jumps < 2)), node_value(3)); + try std.testing.expectEqual(@as(i32, @intFromBool(permission)), node_value(4)); + try std.testing.expectEqual(@as(i32, @intFromBool(expected)), node_value(5)); + } + } + } + } + } + } +} + +fn replay() Engine { + reset(); + set_running(1); + for (0..3000) |i| { + if (i % 127 == 0) _ = apply_rules(@intCast((i / 127) % 2), @intCast(6 + i % 11), @intFromBool(i % 254 != 0)); + if (i % 29 == 0) _ = input(@intCast((i / 29) % 3)); + _ = advance(1); + } + return engine; +} + +test "replay includes respawns and produces identical complete engine state" { + const first = replay(); + const second = replay(); + try std.testing.expectEqualDeep(first, second); + try std.testing.expect(first.state.passed > 5); + try std.testing.expect(first.state.hits > 0); + try std.testing.expectEqual(@as(u32, 3000), first.state.tick); + try std.testing.expectEqual(@as(u32, 12000), first.state.distance); +} + +test "collisions do not reset progress and each obstacle is counted once" { + reset(); + set_running(1); + _ = advance(180); + try std.testing.expectEqual(@as(i32, 1), get(6)); + try std.testing.expectEqual(@as(i32, 1), get(5)); + try std.testing.expectEqual(@as(i32, 180), get(0)); + try std.testing.expectEqual(@as(i32, 720), get(3)); + _ = advance(10); + try std.testing.expectEqual(@as(i32, 1), get(6)); + try std.testing.expectEqual(@as(i32, 1), get(5)); + try std.testing.expectEqual(@as(i32, 1), get(8)); +} + +test "counters wrap explicitly and memory accounting has no overlapping total" { + reset(); + engine.state.tick = std.math.maxInt(u32); + engine.state.distance = std.math.maxInt(u32) - 1; + engine.state.total_inputs = std.math.maxInt(u32); + engine.state.accepted_jumps = std.math.maxInt(u32); + engine.rules.revision = std.math.maxInt(u32); + _ = input(0); + _ = apply_rules(1, 10, 1); + step(); + try std.testing.expectEqual(@as(i32, 0), get(0)); + try std.testing.expectEqual(@as(i32, 2), get(3)); + try std.testing.expectEqual(@as(i32, 0), get(9)); + try std.testing.expectEqual(@as(i32, 0), get(12)); + try std.testing.expectEqual(@as(i32, 0), get(13)); + try std.testing.expectEqual(bytes(4) + bytes(5), bytes(6)); + try std.testing.expect(bytes(4) >= bytes(0) + bytes(1) + bytes(2) + bytes(3)); + try std.testing.expectEqual(@as(u32, 0), bytes(7)); + try std.testing.expectEqual(@as(i32, -1), get(19)); +} diff --git a/prototypes/live-rules/manage.py b/prototypes/live-rules/manage.py new file mode 100644 index 0000000..fbe047c --- /dev/null +++ b/prototypes/live-rules/manage.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Build/test the shared Zig core or serve the local experiment. No Python dependencies.""" +import argparse +from datetime import datetime, timezone +from functools import partial +import hashlib +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +import shutil +import subprocess + +ROOT = Path(__file__).resolve().parent +VERSION = "0.15.2" + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def build(zig): + compiler = shutil.which(zig) + if compiler is None: + raise SystemExit("Zig 0.15.2가 필요합니다. ziglang.org/download 또는 --zig /path/to/zig를 사용하세요.") + version = subprocess.check_output([compiler, "version"], text=True).strip() + if version != VERSION: + raise SystemExit(f"이 실험은 Zig {VERSION}로 고정했습니다. 현재: {version}") + build_dir = ROOT / "build" + build_dir.mkdir(exist_ok=True) + cache = ["--cache-dir", str(build_dir / "cache"), + "--global-cache-dir", str(build_dir / "global-cache")] + source = ROOT / "core.zig" + native = [compiler, "test", str(source), *cache] + print("Checking native core…", flush=True) + checked = subprocess.run(native, capture_output=True, text=True) + print(checked.stdout + checked.stderr, end="", flush=True) + checked.check_returncode() + wasm = ROOT / "web" / "core.wasm" + wasm.parent.mkdir(exist_ok=True) + flags = ["-target", "wasm32-freestanding", "-O", "ReleaseSafe", "-fno-entry", + "-rdynamic", "--export-memory", "--stack", "16384", + "--initial-memory=65536", "--max-memory=65536"] + command = [compiler, "build-exe", str(source), *flags, f"-femit-bin={wasm}", *cache] + subprocess.run(command, check=True) + (build_dir / "build.json").write_text(json.dumps({ + "built_utc": datetime.now(timezone.utc).isoformat(), + "zig_version": version, "flags": flags, + "source_sha256": digest(source), "wasm_sha256": digest(wasm), + "wasm_file_bytes": wasm.stat().st_size, + "native_test_output": checked.stdout + checked.stderr, + }, ensure_ascii=False, indent=2) + "\n") + print(f"Built {wasm} ({wasm.stat().st_size:,} bytes)") + + +class Handler(SimpleHTTPRequestHandler): + extensions_map = {**SimpleHTTPRequestHandler.extensions_map, ".wasm": "application/wasm"} + + def end_headers(self): + self.send_header("Cache-Control", "no-store") + super().end_headers() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="action", required=True) + compile_cmd = sub.add_parser("build", help="native tests + WebAssembly build") + compile_cmd.add_argument("--zig", default="zig") + serve = sub.add_parser("serve", help="serve web/ on loopback only") + serve.add_argument("--port", type=int, default=8765) + args = parser.parse_args() + if args.action == "build": + build(args.zig) + return + if not 1 <= args.port <= 65535: + parser.error("port must be between 1 and 65535") + if not (ROOT / "web" / "core.wasm").is_file(): + parser.error("먼저 manage.py build를 실행하세요.") + handler = partial(Handler, directory=str(ROOT / "web")) + with ThreadingHTTPServer(("127.0.0.1", args.port), handler) as server: + print(f"Pure: http://127.0.0.1:{args.port} (Ctrl+C to stop)", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/prototypes/live-rules/measure.mjs b/prototypes/live-rules/measure.mjs new file mode 100644 index 0000000..2f12815 --- /dev/null +++ b/prototypes/live-rules/measure.mjs @@ -0,0 +1,48 @@ +// Reproducible storage ledger + exact state transitions, not a speed benchmark. +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { arch, platform, release } from 'node:os'; +import { loadCore, snapshot, storage, parseCommand, execute } from './runtime.mjs'; + +const root = fileURLToPath(new URL('.', import.meta.url)); +const destination = process.argv[2]; +if (!destination || process.argv.length !== 3) throw new Error('Usage: node measure.mjs NEW_OUTPUT_DIRECTORY'); +const core = await loadCore(); +const checked = spawnSync(process.execPath, [resolve(root, 'adapter.test.mjs')], { encoding: 'utf8' }); +if (checked.error || checked.status !== 0) throw new Error(checked.error?.message || checked.stdout + checked.stderr); +process.stdout.write(checked.stdout + checked.stderr); + +const trace = ['jump', 'step 8', 'jump', 'rules double 12 on', 'jump', 'step 60']; +const steps = trace.map((command) => ({ command, ...execute(core, parseCommand(command)) })); +const baseline = storage(core); +const imports = WebAssembly.Module.imports(await WebAssembly.compile(await readFile(resolve(root, 'web/core.wasm')))); +const hashes = {}; +for (const name of ['core.zig', 'web/core.wasm', 'runtime.mjs', 'cli.mjs', 'adapter.test.mjs', 'measure.mjs', 'manage.py', 'qa-browser.js', 'web/app.js', 'web/index.html', 'web/style.css']) { + const data = await readFile(resolve(root, name)); + hashes[name] = { bytes: data.length, sha256: createHash('sha256').update(data).digest('hex') }; +} +let build = null; +try { + build = JSON.parse(await readFile(resolve(root, 'build/build.json'), 'utf8')); + if (build.source_sha256 !== hashes['core.zig'].sha256 || build.wasm_sha256 !== hashes['web/core.wasm'].sha256) { + throw new Error('Build record and current source/binary differ. Rebuild first.'); + } +} catch (error) { + if (error.code !== 'ENOENT') throw error; +} + +const result = { + recordedUtc: new Date().toISOString(), + host: { platform: platform(), release: release(), arch: arch(), node: process.version }, + scope: 'Declared Zig type layouts and actual Wasm memory capacity; excludes JS/DOM/renderer, code pages and RSS. No latency or throughput measurement.', + imports, storage: baseline, trace: steps, + finalState: snapshot(core), build, files: hashes, + adapterTests: { command: 'node prototypes/live-rules/adapter.test.mjs', exitCode: checked.status, + output: checked.stdout + checked.stderr }, +}; +await mkdir(destination, { recursive: false }); +await writeFile(resolve(destination, 'storage-and-replay.json'), JSON.stringify(result, null, 2) + '\n'); +console.log(`Saved ${resolve(destination, 'storage-and-replay.json')}`); diff --git a/prototypes/live-rules/qa-browser.js b/prototypes/live-rules/qa-browser.js new file mode 100644 index 0000000..ee11c3a --- /dev/null +++ b/prototypes/live-rules/qa-browser.js @@ -0,0 +1,131 @@ +// Pass this expression to Playwright CLI's run-code after opening the local demo. +// Interactions use real UI controls; the read-only snapshot only observes results. +async (page) => { + const checks = []; + const errors = []; + const onError = (error) => errors.push(error.message); + const check = (condition, label) => { + if (!condition) throw new Error(label); + checks.push(label); + }; + const state = () => page.evaluate(() => window.pureDemo.snapshot()); + const button = (name) => page.getByRole('button', { name, exact: true }); + const step = async (count) => { for (let i = 0; i < count; i++) await button('한 프레임').click(); }; + page.on('pageerror', onError); + try { + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.reload(); + await page.waitForFunction(() => window.pureDemo?.snapshot().ready); + const initial = await state(); + check(initial.running === 0 && initial.tick === 0 && initial.revision === 0, 'initial state is paused'); + + await page.locator('#stage').focus(); + await page.keyboard.press('Space'); + let s = await state(); + check(s.accepted === 1 && s.inputs === 1 && s.source === 0 && s.y === 0, 'Space goes through keyboard input once while paused'); + await step(8); + await button('점프 Space').click(); + const before = await state(); + check(before.tick === 8 && before.y === 6020 && before.vy === 560 && before.reason === 3, 'ground rule rejects a second airborne jump'); + await page.getByRole('radio', { name: '두 번 점프 공중에서 한 번 더 뛸 수 있어요.', exact: true }).check(); + const draft = await state(); + check(draft.mode === 0 && draft.revision === 0 && draft.draftDirty, 'draft changes do not execute before Apply'); + await button('규칙 적용').click(); + const applied = await state(); + check(['tick', 'y', 'vy', 'distance', 'obstacleX', 'jumps'].every((key) => applied[key] === before[key]), 'applying rules preserves exact simulation progress'); + check(applied.mode === 1 && applied.revision === 1 && applied.decisionRevision === 0 && await page.locator('#stale-decision').isVisible(), 'old decision retains and displays its revision'); + await button('점프 Space').click(); + const second = await state(); + check(second.reason === 1 && second.jumps === 2 && second.source === 1 && second.decisionRevision === 1, 'pointer input uses new rule and accepts second jump'); + await button('점프 Space').click(); + check((await state()).reason === 4, 'third jump is rejected'); + + await page.getByRole('checkbox').uncheck(); + await button('규칙 적용').click(); + await button('점프 Space').click(); + check((await state()).reason === 2, 'disabled rule explains rejection'); + await button('처음부터').click(); + s = await state(); + check(s.tick === 0 && s.mode === 0 && s.impulse === 10 && s.enabled === 1 && s.inputs === 0 && s.running === 0, 'explicit reset restores rules and state'); + + await page.getByRole('slider', { name: '점프 세기' }).focus(); + await page.keyboard.press('Space'); + await page.getByRole('checkbox').focus(); + await page.keyboard.press('Space'); + check((await state()).inputs === 0, 'Space in form controls never dispatches a jump'); + await button('처음부터').click(); + await button('점프 Space').focus(); + await page.keyboard.press('Space'); + check((await state()).inputs === 1, 'Space on the jump button does not double dispatch'); + await button('처음부터').click(); + await page.locator('#stage').focus(); + await page.keyboard.down('Space'); + await page.keyboard.down('Space'); + await page.keyboard.up('Space'); + check((await state()).inputs === 1, 'keyboard repeat does not add a second request'); + + await button('처음부터').click(); + await button('실행').click(); + await page.waitForFunction(() => window.pureDemo.snapshot().tick >= 6); + await button('일시정지').click(); + const paused = await state(); + await page.waitForTimeout(120); + check(paused.tick >= 6 && paused.tick === (await state()).tick, 'running advances and pause freezes simulation'); + + await button('처음부터').click(); + await page.getByRole('radio', { name: '두 번 점프 공중에서 한 번 더 뛸 수 있어요.', exact: true }).check(); + await page.getByRole('slider', { name: '점프 세기' }).focus(); + await page.keyboard.press('End'); + await button('규칙 적용').click(); + await button('점프 Space').click(); + await step(28); + await button('점프 Space').click(); + await step(29); + const high = await state(); + const visible = await page.evaluate(() => { + const d = document.getElementById('dino').getBoundingClientRect(); + const w = document.getElementById('world').getBoundingClientRect(); + return d.top >= w.top && d.bottom <= w.bottom && d.left >= w.left && d.right <= w.right; + }); + check(high.y > 40000 && visible, 'maximum-height double jump remains inside the scene'); + + for (let i = 0; i < 25; i++) await button('점프 Space').click(); + check(await page.locator('#history-list li').count() === 8, 'visible history is bounded to eight rows'); + check((await state()).wasmMemoryBytes === 65536, 'browser instance retains fixed 64 KiB linear memory'); + await page.locator('.research-panel summary').click(); + check((await page.locator('#bytes-total').textContent()).includes('396'), 'research panel shows logical layout separately'); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.locator('.research-panel summary').click(); + await button('처음부터').click(); + const width = await page.evaluate(() => ({ viewport: innerWidth, content: document.documentElement.scrollWidth })); + check(width.content <= width.viewport, '390px viewport has no horizontal overflow'); + check(await page.locator('#dino').evaluate((el) => el.getBoundingClientRect().height) >= 28, 'narrow view keeps the dinosaur large enough to inspect'); + await button('점프 Space').click(); + await step(1); + check((await state()).y === 945, 'narrow viewport controls remain usable'); + await page.screenshot({ path: 'output/playwright/pure-live-mobile.png', fullPage: true }); + + await page.route('**/core.wasm', (route) => route.abort()); + await page.reload(); + await page.locator('#load-error').waitFor({ state: 'visible' }); + check(await button('실행').isDisabled(), 'missing Wasm has an actionable error and disabled controls'); + await page.unroute('**/core.wasm'); + await page.reload(); + await page.waitForFunction(() => window.pureDemo?.snapshot().ready); + await page.setViewportSize({ width: 1440, height: 1000 }); + await button('점프 Space').click(); + await step(8); + await button('점프 Space').click(); + await page.getByRole('radio', { name: '두 번 점프 공중에서 한 번 더 뛸 수 있어요.', exact: true }).check(); + await button('규칙 적용').click(); + await button('점프 Space').click(); + await page.screenshot({ path: 'output/playwright/pure-live-desktop.png', fullPage: true }); + check(errors.length === 0, 'no uncaught page errors'); + return { recordedUtc: new Date().toISOString(), browser: await page.evaluate(() => navigator.userAgent), + checks, before, applied, second, high, mobile: width, errors, + screenshots: ['output/playwright/pure-live-desktop.png', 'output/playwright/pure-live-mobile.png'] }; + } finally { + page.off('pageerror', onError); + } +} diff --git a/prototypes/live-rules/results/2026-09-22-linux-aarch64/browser-checks.json b/prototypes/live-rules/results/2026-09-22-linux-aarch64/browser-checks.json new file mode 100644 index 0000000..efb9ec7 --- /dev/null +++ b/prototypes/live-rules/results/2026-09-22-linux-aarch64/browser-checks.json @@ -0,0 +1,215 @@ +{ + "recordedUtc": "2026-09-22T10:20:31.347Z", + "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36", + "checks": [ + "initial state is paused", + "Space goes through keyboard input once while paused", + "ground rule rejects a second airborne jump", + "draft changes do not execute before Apply", + "applying rules preserves exact simulation progress", + "old decision retains and displays its revision", + "pointer input uses new rule and accepts second jump", + "third jump is rejected", + "disabled rule explains rejection", + "explicit reset restores rules and state", + "Space in form controls never dispatches a jump", + "Space on the jump button does not double dispatch", + "keyboard repeat does not add a second request", + "running advances and pause freezes simulation", + "maximum-height double jump remains inside the scene", + "visible history is bounded to eight rows", + "browser instance retains fixed 64 KiB linear memory", + "research panel shows logical layout separately", + "390px viewport has no horizontal overflow", + "narrow view keeps the dinosaur large enough to inspect", + "narrow viewport controls remain usable", + "missing Wasm has an actionable error and disabled controls", + "no uncaught page errors" + ], + "before": { + "ready": true, + "tick": 8, + "y": 6020, + "vy": 560, + "distance": 32, + "obstacleX": 768, + "passed": 0, + "hits": 0, + "jumps": 1, + "running": 0, + "revision": 0, + "reason": 3, + "source": 1, + "inputs": 2, + "accepted": 1, + "mode": 0, + "impulse": 10, + "enabled": 1, + "grounded": 0, + "obstacleHeight": 48, + "draftDirty": false, + "droppedTicks": 0, + "renderCount": 11, + "wasmMemoryBytes": 65536, + "logicalBytes": 396, + "decisionTick": 8, + "decisionRevision": 0, + "decisionNodes": [ + 1, + 1, + 0, + 1, + 0, + 0 + ] + }, + "applied": { + "ready": true, + "tick": 8, + "y": 6020, + "vy": 560, + "distance": 32, + "obstacleX": 768, + "passed": 0, + "hits": 0, + "jumps": 1, + "running": 0, + "revision": 1, + "reason": 3, + "source": 1, + "inputs": 2, + "accepted": 1, + "mode": 1, + "impulse": 10, + "enabled": 1, + "grounded": 0, + "obstacleHeight": 48, + "draftDirty": false, + "droppedTicks": 0, + "renderCount": 12, + "wasmMemoryBytes": 65536, + "logicalBytes": 396, + "decisionTick": 8, + "decisionRevision": 0, + "decisionNodes": [ + 1, + 1, + 0, + 1, + 0, + 0 + ] + }, + "second": { + "ready": true, + "tick": 8, + "y": 6020, + "vy": 1000, + "distance": 32, + "obstacleX": 768, + "passed": 0, + "hits": 0, + "jumps": 2, + "running": 0, + "revision": 1, + "reason": 1, + "source": 1, + "inputs": 3, + "accepted": 2, + "mode": 1, + "impulse": 10, + "enabled": 1, + "grounded": 0, + "obstacleHeight": 48, + "draftDirty": false, + "droppedTicks": 0, + "renderCount": 13, + "wasmMemoryBytes": 65536, + "logicalBytes": 396, + "decisionTick": 8, + "decisionRevision": 1, + "decisionNodes": [ + 1, + 1, + 0, + 1, + 1, + 1 + ] + }, + "high": { + "ready": true, + "tick": 57, + "y": 44945, + "vy": 5, + "distance": 228, + "obstacleX": 572, + "passed": 0, + "hits": 0, + "jumps": 2, + "running": 0, + "revision": 1, + "reason": 1, + "source": 1, + "inputs": 2, + "accepted": 2, + "mode": 1, + "impulse": 16, + "enabled": 1, + "grounded": 0, + "obstacleHeight": 48, + "draftDirty": false, + "droppedTicks": 0, + "renderCount": 92, + "wasmMemoryBytes": 65536, + "logicalBytes": 396, + "decisionTick": 28, + "decisionRevision": 1, + "decisionNodes": [ + 1, + 1, + 0, + 1, + 1, + 1 + ] + }, + "mobile": { + "viewport": 390, + "content": 390 + }, + "errors": [], + "screenshots": [ + { + "file": "desktop.png", + "sha256": "ae02e473b472816dccf147e1d7b0a45135ffbc10fbef97002a7f0697303a7fbb" + }, + { + "file": "mobile.png", + "sha256": "a3295e68fdaff2064d9076c0e136a80062abd0dd58ef770aa9b7c7dff356926d" + } + ], + "playwrightCliVersion": "0.1.21", + "files": { + "qa-browser.js": { + "sha256": "50d4293d41d5aa63b404c3e22fdc1349008765365fac8cdafe01dfdf858893da", + "bytes": 7554 + }, + "web/app.js": { + "sha256": "b3dcd10fa4e4ab56dff200e9238c640e6dbea5fa7b624328d7d421ee8744e778", + "bytes": 14358 + }, + "web/index.html": { + "sha256": "28630577d3faa81b61fec95723d421e1cac9a576452d909342cfd054d3dea385", + "bytes": 13396 + }, + "web/style.css": { + "sha256": "b399be728d8f2bfb1964903e11e69a9bccbc83c9d90ecfd6783110c7a0702923", + "bytes": 16606 + }, + "web/core.wasm": { + "sha256": "ea0789dcbb0cc2d6a17e049f4568374d242b44dafe7f996673538f3bd6dafed5", + "bytes": 32544 + } + } +} diff --git a/prototypes/live-rules/results/2026-09-22-linux-aarch64/desktop.png b/prototypes/live-rules/results/2026-09-22-linux-aarch64/desktop.png Binary files differnew file mode 100644 index 0000000..5318b55 --- /dev/null +++ b/prototypes/live-rules/results/2026-09-22-linux-aarch64/desktop.png 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 Binary files differnew file mode 100644 index 0000000..2f11c77 --- /dev/null +++ b/prototypes/live-rules/results/2026-09-22-linux-aarch64/mobile.png 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 Binary files differnew file mode 100755 index 0000000..8c79903 --- /dev/null +++ b/prototypes/live-rules/web/core.wasm diff --git a/prototypes/live-rules/web/index.html b/prototypes/live-rules/web/index.html new file mode 100644 index 0000000..45ff700 --- /dev/null +++ b/prototypes/live-rules/web/index.html @@ -0,0 +1,101 @@ +<!doctype html> +<html lang="ko"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="theme-color" content="#f5f4ee"> + <title>Pure — 규칙 실험실</title> + <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%2327372b'/%3E%3Ctext x='8' y='23' font-family='Georgia' font-size='26' font-weight='bold' fill='%23f5f4ee'%3Ep%3C/text%3E%3C/svg%3E"> + <link rel="stylesheet" href="style.css"> + <script src="app.js" defer></script> +</head> +<body> + <header class="site-header"> + <a class="brand" href="./" aria-label="Pure 규칙 실험실 홈"><span class="brand-mark" aria-hidden="true">p.</span><span>Pure <span class="brand-separator">/</span> <span class="brand-subtitle">규칙 실험실</span></span></a> + <span class="experiment-tag"><span class="status-dot" aria-hidden="true"></span>실험 002</span> + </header> + + <main id="workspace" aria-busy="true"> + <div class="intro"> + <div><p class="eyebrow">작게 만들고, 직접 바꿔보기</p><h1>뛰는 도중에, 규칙을 바꿔보세요.</h1><p class="intro-copy">같은 공룡, 같은 순간. 점프 규칙만 바꾸면 무엇이 달라질까요?</p></div> + <p class="prototype-label">살아 있는 규칙<br><span>첫 번째 연습장</span></p> + </div> + + <div id="load-message" class="notice" role="status">작은 실행기를 불러오고 있습니다…</div> + <section id="load-error" class="load-error" role="alert" hidden> + <h2>실행기를 불러오지 못했습니다.</h2> + <p>프로젝트 루트에서 아래 명령을 실행한 뒤 <a href="http://127.0.0.1:8765">http://127.0.0.1:8765</a>를 여세요. Zig 0.15.2가 필요하며, 파일을 직접 여는 <code>file://</code> 주소는 지원하지 않습니다.</p> + <pre><code>python3 prototypes/live-rules/manage.py build +python3 prototypes/live-rules/manage.py serve --port 8765</code></pre> + <p class="error-detail" id="error-detail"></p> + </section> + + <div class="workbench"> + <section class="world-panel panel" aria-labelledby="world-heading"> + <div class="panel-heading"><h2 id="world-heading"><span class="section-number">01</span>연습장</h2><span id="running-badge" class="badge">불러오는 중</span></div> + <div id="stage" class="stage" tabindex="0" role="group" aria-label="공룡 연습장. 스페이스 키로 점프합니다."> + <svg id="world" viewBox="0 0 960 350" role="img" aria-labelledby="scene-title scene-description"> + <title id="scene-title">공룡 점프 연습장</title><desc id="scene-description">규칙을 적용하고 점프 버튼이나 스페이스 키로 움직여 보세요. 현재 상태는 연습장 아래에 표시됩니다.</desc> + <defs><pattern id="soil" width="48" height="24" patternUnits="userSpaceOnUse"><path d="M8 12h4M34 5h3" stroke="#c8cec0" stroke-width="2"/></pattern></defs> + <circle cx="765" cy="81" r="28" fill="#e7e4ce"/> + <path d="M556 277 673 195 744 245 813 201 935 277Z" fill="#e5e9dc"/> + <path d="M385 277 505 227 593 277M724 277 859 224 960 265V278" fill="#edf0e7"/> + <path d="M229 94h50m-35-10h17m140 53h37" stroke="#d9ded3" stroke-width="3" stroke-linecap="round"/> + <rect x="0" y="279" width="960" height="71" fill="#ebeee4"/><rect x="0" y="285" width="960" height="65" fill="url(#soil)"/> + <path d="M0 278H960" stroke="#b4bfaa" stroke-width="2"/> + <ellipse id="dino-shadow" cx="138" cy="281" rx="27" ry="5" fill="#3e5e36" opacity="0.15"/> + <g id="dino" transform="translate(120 230)"> + <path d="M16 0H36V19H24V25H30V31H24V37H19V48H12V39H6V33H0V17H5V24H11V17H16Z" fill="#356548"/> + <path d="M27 5h4v4h-4z" fill="#f8fbf4"/><path d="M24 15h12" stroke="#21432c" stroke-width="2"/> + <path id="dino-foot" d="M18 46h6M9 46h6" stroke="#356548" stroke-width="4"/> + </g> + <g id="obstacle" transform="translate(800 230)"><rect id="obstacle-body" x="0" y="0" width="32" height="48" rx="5" fill="#87916c"/><path id="obstacle-mark" d="M11 10v28m10-21v21" stroke="#6a7957" stroke-width="2" stroke-linecap="round"/></g> + <text x="22" y="327" fill="#819075" font-size="12" font-family="ui-monospace, monospace" letter-spacing="2">PURE · RULE PLAYGROUND</text> + </svg> + <div class="stage-hint"><span class="tiny-dot" aria-hidden="true"></span><span id="stage-hint">일시정지 상태에서도 규칙과 입력을 확인할 수 있어요.</span></div> + </div> + <div class="transport"> + <div class="transport-primary"><button id="run-button" class="button button-dark" disabled>실행</button><button id="step-button" class="button button-quiet" disabled>한 프레임</button></div> + <button id="jump-button" class="button button-jump" disabled>점프 <kbd>Space</kbd></button> + <button id="reset-button" class="button button-reset" disabled title="세계와 규칙을 초기 상태로 되돌립니다">처음부터</button> + </div> + <p id="runtime-note" class="runtime-note">실행하면 시간이 흐릅니다. 멈춘 뒤 한 프레임씩 살펴볼 수도 있어요.</p> + <div class="state-grid" aria-label="현재 상태"> + <div><span>높이</span><strong id="state-height">—</strong></div><div><span>세로 속도</span><strong id="state-velocity">—</strong></div><div><span>발 디딤</span><strong id="state-ground">—</strong></div><div><span>사용한 점프</span><strong id="state-jumps">—</strong></div><div><span>진행 거리</span><strong id="state-distance">—</strong></div><div><span>진행 시간</span><strong id="state-time">—</strong></div> + </div> + <div class="world-summary"><span>지나간 장애물 <b id="state-passed">0</b></span><span>닿은 장애물 <b id="state-hits">0</b></span><span class="tick-label">tick <b id="state-tick">0</b></span></div> + </section> + + <aside class="rules-panel panel" aria-labelledby="rules-heading"> + <div class="panel-heading"><h2 id="rules-heading"><span class="section-number">02</span>점프 규칙</h2><span id="revision-badge" class="badge">v—</span></div> + <form id="rules-form"> + <fieldset id="rule-controls" disabled> + <legend class="field-label">언제 다시 뛸 수 있나요?</legend> + <div class="mode-options"><label class="mode-option"><input type="radio" name="mode" value="0" checked><span><b>땅에서만</b><small>착지해야 다시 점프할 수 있어요.</small></span><span class="radio-mark" aria-hidden="true"></span></label><label class="mode-option"><input type="radio" name="mode" value="1"><span><b>두 번 점프</b><small>공중에서 한 번 더 뛸 수 있어요.</small></span><span class="radio-mark" aria-hidden="true"></span></label></div> + <div class="force-heading"><label class="field-label" for="impulse">점프 세기</label><output id="impulse-output" for="impulse">10</output></div> + <input type="range" id="impulse" name="impulse" min="6" max="16" step="1" value="10" aria-describedby="force-help"><div class="range-labels"><span>가볍게 · 6</span><span>높이 · 16</span></div><p id="force-help" class="field-help">다음에 받아들인 점프부터 적용됩니다.</p> + <label class="enabled-option"><span><b>점프 입력 받기</b><small>끄면 어떤 점프 요청도 받지 않아요.</small></span><input id="enabled" name="enabled" type="checkbox" checked><span class="switch" aria-hidden="true"></span></label> + </fieldset> + <div class="apply-area"><div class="draft-line"><span id="draft-status">실행기 준비 중</span><span id="applied-label">적용 v—</span></div><button id="apply-button" class="button button-apply" type="submit" disabled>규칙 적용</button><p id="apply-feedback">바꾼 뒤 적용을 눌러주세요. 위치와 진행 시간은 이어집니다.</p></div> + </form> + <div class="try-card"><span class="eyebrow">이렇게 해보세요</span><ol><li>점프한 뒤, 공중에서 다시 눌러보세요.</li><li>멈춘 상태에서 <b>두 번 점프</b>로 바꾸고 적용하세요.</li><li>위치가 그대로인지 보고 다시 점프해보세요.</li></ol><p>처음엔 멈춘 채로 점프 → 한 프레임을 눌러도 좋아요.</p></div> + </aside> + </div> + + <section class="decision-panel panel" aria-labelledby="decision-heading"> + <div class="panel-heading"><h2 id="decision-heading"><span class="section-number">03</span>왜 그렇게 움직였나요?</h2><span class="muted-small">마지막 점프 요청</span></div> + <div class="decision-summary"><div><p id="decision-result" class="decision-result">첫 점프를 기다리고 있어요.</p><p id="decision-explanation" class="decision-explanation">한 번 누르면 입력이 규칙을 통과하는 과정을 볼 수 있습니다.</p></div><div id="decision-meta" class="decision-meta">아직 입력 없음</div></div> + <p id="stale-decision" class="stale-note" hidden>이 기록은 이전 규칙의 판단입니다. 다시 점프하면 새 규칙으로 확인할 수 있어요.</p> + <ol id="decision-path" class="decision-path" aria-label="마지막 입력 판단 경로"> + <li data-node="0"><span class="node-step">01</span><strong>요청 도착</strong><span class="node-value">대기</span></li><li data-node="1"><span class="node-step">02</span><strong>입력 켜짐</strong><span class="node-value">대기</span></li><li data-node="2"><span class="node-step">03</span><strong>땅에 있음</strong><span class="node-value">대기</span></li><li data-node="3"><span class="node-step">04</span><strong>점프 여유</strong><span class="node-value">대기</span></li><li data-node="4"><span class="node-step">05</span><strong>방식에 맞음</strong><span class="node-value">대기</span></li><li data-node="5"><span class="node-step">06</span><strong>점프 허용</strong><span class="node-value">대기</span></li> + </ol> + <p class="path-caption">요청과 입력 켜짐을 확인하고, 선택한 방식에 따라 ‘땅에 있음’ 또는 ‘점프 여유’를 판단합니다. 기록은 입력을 받은 순간의 상태입니다.</p> + <div class="history-section"><h3>최근 변화 <span>최대 8개 표시</span></h3><ol id="history-list" class="history-list"><li class="empty-history">점프하거나 규칙을 적용하면 이곳에 기록됩니다.</li></ol></div> + </section> + + <details class="research-panel"><summary><span>실험의 범위와 메모리 보기</span><span class="summary-caption">기술적인 내용은 여기에</span></summary><div class="research-body"><div><h2>무엇을 확인하는 실험인가요?</h2><p>세계가 실행되는 동안 점프 규칙을 바꾸고, 상태를 유지하며, 입력 판단을 살펴보는 작은 시제품입니다. 물리 계산과 규칙 판단은 Zig로 작성한 동일한 WebAssembly 코어에서 처리합니다. 화면은 그 결과를 보여줍니다.</p><p>시간은 고정 60Hz 틱으로 진행합니다. 느린 화면에서는 한 번에 최대 6틱을 처리하며, 생략한 틱 수를 아래에 표시합니다. 실제 시간과 정확히 같다는 보장은 없습니다.</p><p>높이·속도·거리는 세계 좌표(px)로 표시합니다. 높은 점프에서는 시점이 공룡을 따라 위로 이동합니다. 화면 크기와 시점은 물리 계산을 바꾸지 않습니다.</p><p id="scheduling-detail" class="metric-note">아직 실행 전 · 생략한 틱 0</p></div><div><h2>코어의 자료 구조 크기</h2><table class="memory-table"><caption>코어가 보고한 논리 자료 크기 · 실제 프로세스 메모리와 다름</caption><tbody><tr><th scope="row">규칙</th><td id="bytes-rules">—</td></tr><tr><th scope="row">세계 상태</th><td id="bytes-state">—</td></tr><tr><th scope="row">마지막 판단</th><td id="bytes-decision">—</td></tr><tr><th scope="row">기록과 관리 정보</th><td id="bytes-history">—</td></tr><tr><th scope="row">엔진 전체 · 정렬 포함</th><td id="bytes-engine">—</td></tr><tr><th scope="row">고정 규칙 그래프</th><td id="bytes-graph">—</td></tr><tr class="total-row"><th scope="row">엔진 + 그래프 · 논리 합계</th><td id="bytes-total">—</td></tr><tr><th scope="row">WASM 선형 메모리 · 별도</th><td id="bytes-linear">—</td></tr></tbody></table><p class="metric-note">자료 구조 크기는 Zig 타입 배치 기준입니다. 고정 그래프는 컴파일 과정에서 코드에 통합될 수 있어, 이 수치를 실제 RAM 사용량으로 읽으면 안 됩니다. WASM 선형 메모리는 실제 할당된 별도 범위이며 위 합계와 더하지 않습니다. 브라우저·화면·코드·프로세스 전체 메모리(RSS)는 이 표에 포함되지 않습니다. M3 성능이나 최적화 우위를 입증하는 측정도 아닙니다.</p></div></div></details> + <p id="live-status" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></p> + </main> + <footer>Pure <span>·</span> 원하는 동작을 먼저, 근거는 남기면서.</footer> +</body> +</html> diff --git a/prototypes/live-rules/web/style.css b/prototypes/live-rules/web/style.css new file mode 100644 index 0000000..95207c8 --- /dev/null +++ b/prototypes/live-rules/web/style.css @@ -0,0 +1 @@ +:root{color-scheme:light;--page:#f5f4ee;--card:#fffefa;--ink:#27372b;--muted:#74806f;--line:#e3e6dc;--green:#356548;--green-light:#edf3e9;--amber:#a36a31;--amber-light:#faf0df;--radius:16px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:15px}*{box-sizing:border-box}body{margin:0;color:var(--ink);background:var(--page)}button,input{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.46}a{color:inherit}button,input,a,summary,[tabindex]{-webkit-tap-highlight-color:transparent}button:focus-visible,input:focus-visible,a:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:3px solid #b37c39;outline-offset:4px}button,input{accent-color:var(--green)}[hidden]{display:none!important}.site-header{height:94px;max-width:1256px;padding:0 36px;margin:auto;display:flex;align-items:center;justify-content:space-between}.brand{display:inline-flex;align-items:center;gap:13px;font-size:19px;font-weight:750;text-decoration:none;letter-spacing:-.5px}.brand-mark{display:grid;place-items:center;width:35px;height:35px;background:var(--ink);color:var(--page);border-radius:9px;font:700 27px Georgia,serif;padding-bottom:4px}.brand-separator{margin:0 10px;color:#b1b7a9;font-weight:400}.brand-subtitle{font-size:14px;font-weight:500;letter-spacing:-.3px}.experiment-tag{display:inline-flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.status-dot,.tiny-dot{display:inline-block;width:6px;height:6px;background:#82a06e;border-radius:50%}main{max-width:1256px;margin:0 auto;padding:28px 36px 0}.intro{display:flex;align-items:center;justify-content:space-between;gap:32px;margin-bottom:30px}.eyebrow{font-size:11px;letter-spacing:1.25px;font-weight:650;color:var(--muted);margin:0 0 11px}h1{font-size:clamp(25px,2.6vw,34px);font-weight:650;line-height:1.35;letter-spacing:-1.3px;margin:0 0 11px}.intro-copy{color:var(--muted);font-size:14px;line-height:1.7;margin:0}.prototype-label{flex-shrink:0;text-align:right;color:var(--green);font-size:12px;font-weight:600;line-height:1.85;margin:0;border-left:1px solid var(--line);padding-left:35px}.prototype-label span{color:var(--muted);font-size:11px;font-weight:400}.notice,.load-error{border:1px solid var(--line);padding:16px 20px;border-radius:10px;margin-bottom:20px;font-size:13px;line-height:1.7;background:var(--card)}.load-error{background:#fff0e8;border-color:#e9c6b7;color:#7b392b}.load-error h2{font-size:17px}.load-error pre{overflow:auto;padding:12px;background:#fff9f3;border-radius:7px;font-size:12px}.error-detail{font-family:ui-monospace,monospace;font-size:11px;overflow-wrap:anywhere}.workbench{display:grid;grid-template-columns:minmax(0,1fr) 322px;gap:20px;align-items:start}.panel{border:1px solid var(--line);border-radius:var(--radius);background:var(--card);overflow:hidden}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:22px 24px 19px}.panel-heading h2{font-size:14px;font-weight:650;margin:0;letter-spacing:-.3px;display:flex;align-items:center;gap:9px}.section-number{color:#9aa28f;font:11px ui-monospace,monospace;font-weight:400}.badge{font-size:10px;line-height:1;padding:6px 8px;background:#f0f1eb;color:#6e7967;border-radius:5px;font-variant-numeric:tabular-nums;white-space:nowrap}.badge.running{background:var(--green-light);color:var(--green)}.stage{position:relative;margin:0 14px;background:#f5f7f0;border:1px solid #e7ebdf;border-radius:10px;overflow:hidden;line-height:0}#world{display:block;width:100%;height:auto;min-height:230px}.stage-hint{position:absolute;top:16px;left:17px;right:15px;display:flex;align-items:center;gap:7px;color:#7a866f;font-size:10px;line-height:1.5}.tiny-dot{width:4px;height:4px;flex-shrink:0;background:#a5b496}.transport{display:flex;align-items:center;gap:9px;padding:20px 24px 0;flex-wrap:wrap}.transport-primary{display:flex;gap:7px}.button{border:1px solid transparent;border-radius:7px;line-height:1.25;font-size:12px;font-weight:550;padding:11px 15px;transition:background .15s,border-color .15s,transform .15s}.button:active:not(:disabled){transform:translateY(1px)}.button-dark{background:var(--ink);color:white;min-width:78px}.button-dark:hover:not(:disabled){background:#425544}.button-quiet{background:transparent;border-color:var(--line);color:var(--ink)}.button-quiet:hover:not(:disabled),.button-reset:hover:not(:disabled){background:#f2f3ec}.button-jump{margin-left:auto;background:var(--green-light);border-color:#dce6d5;color:var(--green);display:flex;align-items:center;gap:20px}.button-jump:hover:not(:disabled){background:#deead7}.button-jump kbd{font:9px ui-monospace,monospace;padding:2px 4px;border:1px solid #c7d5be;border-radius:3px;color:#6d865e}.button-reset{background:transparent;color:var(--muted);font-size:11px;padding-left:9px;padding-right:9px}.runtime-note{font-size:11px;color:var(--muted);line-height:1.7;margin:12px 24px 21px;min-height:19px}.state-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:23px 18px;padding:22px 24px;border-top:1px solid var(--line)}.state-grid>div{display:flex;flex-direction:column;gap:7px}.state-grid span{color:var(--muted);font-size:10px}.state-grid strong{font-size:16px;font-weight:550;font-variant-numeric:tabular-nums;letter-spacing:-.5px}.world-summary{display:flex;gap:22px;padding:14px 24px;border-top:1px solid var(--line);font-size:10px;color:var(--muted);background:#fcfcf7}.world-summary b{margin-left:5px;font-weight:550;color:#58694f;font-variant-numeric:tabular-nums}.tick-label{margin-left:auto;font-family:ui-monospace,monospace}.rules-panel .panel-heading{padding-bottom:22px}#rules-form{padding:0 23px}fieldset{border:0;padding:0;margin:0;min-width:0}.field-label{font-size:11px;font-weight:600;display:block;margin:0 0 12px}.mode-options{display:flex;flex-direction:column;gap:8px;margin-bottom:25px}.mode-option{position:relative;display:flex;align-items:center;gap:10px;padding:14px 12px;border:1px solid var(--line);border-radius:8px;cursor:pointer;background:var(--card);min-width:0}.mode-option:has(input:checked){border-color:#8fa783;background:#f1f5ec}.mode-option:has(input:focus-visible){outline:3px solid #b37c39;outline-offset:3px}.mode-option input{position:absolute;opacity:0;width:1px;height:1px}.mode-option>span:first-of-type{flex:1}.mode-option b,.enabled-option b{display:block;font-size:12px;font-weight:550}.mode-option small,.enabled-option small{display:block;font-size:10px;line-height:1.6;color:var(--muted);margin-top:5px;word-break:keep-all}.radio-mark{width:13px;height:13px;border:1px solid #c2cbb9;border-radius:50%;display:grid;place-items:center;flex-shrink:0}.mode-option input:checked~.radio-mark{border-color:var(--green)}.mode-option input:checked~.radio-mark:after{content:"";background:var(--green);width:5px;height:5px;border-radius:50%}.force-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.force-heading .field-label{margin:0}.force-heading output{background:#f1f3ec;font-size:12px;font-variant-numeric:tabular-nums;padding:4px 9px;border:1px solid var(--line);border-radius:5px;min-width:34px;text-align:center}input[type=range]{display:block;width:100%;margin:14px 0 9px;cursor:pointer;height:16px}.range-labels{display:flex;justify-content:space-between;font-size:9px;color:#8a937f}.field-help{font-size:10px;color:var(--muted);margin:10px 0 21px;line-height:1.6}.enabled-option{display:flex;align-items:center;position:relative;cursor:pointer;gap:15px;border-top:1px solid var(--line);padding-top:19px}.enabled-option>span:first-child{flex:1}.enabled-option input{position:absolute;right:4px;opacity:0;width:28px;height:20px}.switch{width:31px;height:19px;background:#ced4c7;border:1px solid #c5cdbe;border-radius:20px;padding:2px;flex-shrink:0;transition:background .15s}.switch:after{content:"";display:block;width:13px;height:13px;background:white;border-radius:50%;box-shadow:0 1px 2px #0002;transition:transform .15s}.enabled-option input:checked+.switch{background:var(--green);border-color:var(--green)}.enabled-option input:checked+.switch:after{transform:translateX(12px)}.enabled-option input:focus-visible+.switch{outline:3px solid #b37c39;outline-offset:4px}.apply-area{margin-top:23px}.draft-line{display:flex;justify-content:space-between;gap:10px;font-size:10px;color:var(--muted);margin-bottom:9px}.draft-line.dirty #draft-status{color:var(--amber);font-weight:600}.button-apply{display:block;width:100%;background:var(--green);color:white;font-size:12px;padding:12px}.button-apply:hover:not(:disabled){background:#284e36}#apply-feedback{font-size:10px;line-height:1.7;min-height:35px;margin:10px 0 22px;color:var(--muted)}.try-card{background:#f4f5ee;border-top:1px solid var(--line);padding:20px 23px}.try-card .eyebrow{font-size:10px;letter-spacing:.8px;display:block;margin-bottom:11px}.try-card ol{font-size:11px;line-height:1.8;padding-left:17px;margin:0;color:#627058}.try-card li+li{margin-top:5px}.try-card b{font-weight:600}.try-card p{font-size:10px;color:#859079;line-height:1.65;margin:12px 0 0}.decision-panel{margin-top:20px}.muted-small{font-size:10px;color:var(--muted)}.decision-summary{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:0 24px 19px}.decision-result{margin:0 0 7px;font-size:18px;letter-spacing:-.6px;font-weight:550}.decision-result.accepted{color:var(--green)}.decision-result.rejected{color:var(--amber)}.decision-explanation{font-size:11px;color:var(--muted);line-height:1.7;margin:0}.decision-meta{font-size:10px;color:var(--muted);text-align:right;line-height:1.9;white-space:nowrap;font-variant-numeric:tabular-nums}.stale-note{font-size:11px;color:var(--amber);background:var(--amber-light);margin:0 24px 17px;padding:10px 12px;border-radius:6px;line-height:1.7}.decision-path{list-style:none;display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:12px;margin:0;padding:0 24px}.decision-path li{position:relative;border:1px solid var(--line);background:#fafbf6;border-radius:8px;padding:12px;min-width:0}.decision-path li:not(:last-child):after{position:absolute;right:-10px;top:50%;content:"›";color:#a4af9a;font-size:12px;line-height:0}.decision-path li[data-result=true]{background:#f0f5eb;border-color:#d4e2ca}.decision-path li[data-result=false]{background:#fbf6ed;border-color:#ebdfc9}.node-step{display:block;font:9px ui-monospace,monospace;color:#a2aa97;margin-bottom:13px}.decision-path strong{display:block;font-size:11px;font-weight:550;white-space:nowrap}.node-value{display:block;font-size:10px;color:#8b9580;margin-top:6px}.decision-path li[data-result=true] .node-value{color:var(--green)}.decision-path li[data-result=false] .node-value{color:var(--amber)}.path-caption{font-size:10px;line-height:1.7;color:#85907b;padding:0 24px;margin:12px 0 21px}.history-section{border-top:1px solid var(--line);padding:19px 24px 20px;display:grid;grid-template-columns:145px minmax(0,1fr);gap:25px}.history-section h3{font-size:11px;font-weight:550;margin:3px 0;line-height:1.6}.history-section h3 span{display:block;font-size:9px;color:#939d88;font-weight:400;margin-top:4px}.history-list{list-style:none;padding:0;margin:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 25px}.history-list li{display:flex;gap:10px;align-items:baseline;font-size:10px;line-height:1.7;color:#69755e;min-width:0}.history-list .history-time{font:9px ui-monospace,monospace;color:#9aa38f;flex-shrink:0}.history-list .history-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.history-list .empty-history{grid-column:1/-1;color:#8d9781}.research-panel{margin-top:20px;border:1px solid var(--line);border-radius:12px;background:#f0f1e9;overflow:hidden}.research-panel summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:12px;padding:19px 23px;font-size:12px;font-weight:500}.research-panel summary::-webkit-details-marker{display:none}.research-panel summary:before{content:"+";font:16px ui-monospace,monospace;color:#8a947e}.research-panel[open] summary:before{content:"−"}.summary-caption{margin-left:auto;font-size:10px;font-weight:400;color:#8d9781}.research-body{border-top:1px solid var(--line);display:grid;grid-template-columns:1fr 1fr;gap:60px;padding:24px}.research-body h2{font-size:12px;margin:0 0 14px;font-weight:600}.research-body p{font-size:11px;line-height:1.85;color:#74806a;margin:0 0 13px}.memory-table{width:100%;border-collapse:collapse;font-size:10px;line-height:1.6;font-variant-numeric:tabular-nums}.memory-table caption{text-align:left;font-size:9px;color:#8c967f;padding-bottom:9px}.memory-table th{text-align:left;font-weight:400;padding:5px 0;color:#6b7762}.memory-table td{text-align:right;color:#4f6249;padding:5px 0;font-family:ui-monospace,monospace}.memory-table .total-row th,.memory-table .total-row td{border-top:1px solid #d6ddcd;font-weight:600;padding-top:9px}.research-body .metric-note{font-size:10px;margin-top:12px;color:#8a947f}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}footer{max-width:1256px;margin:0 auto;padding:30px 36px 35px;font-size:10px;color:#9ba48f;display:flex;align-items:center;gap:9px}footer span{color:#bdc4b4}@media(min-width:1100px){.state-grid{grid-template-columns:repeat(6,minmax(0,1fr));gap:10px}.state-grid strong{font-size:15px}.stage-hint{font-size:11px}}@media(max-width:900px){.site-header{height:77px;padding:0 24px}main{padding:22px 24px 0}.workbench{grid-template-columns:minmax(0,1fr) 286px;gap:15px}.panel-heading{padding:20px 19px 17px}#rules-form{padding:0 18px}.try-card{padding:18px}.transport{padding:17px 19px 0;gap:7px}.button{padding:10px 11px}.button-jump{gap:12px}.runtime-note{margin-left:19px;margin-right:19px}.state-grid{padding:19px}.world-summary{padding:13px 19px;gap:12px}.decision-path{gap:9px;padding:0 19px}.decision-path li{padding:10px 8px}.decision-path li:not(:last-child):after{right:-8px}.decision-path strong{font-size:10px}.decision-summary{padding-left:19px;padding-right:19px}.history-section{grid-template-columns:110px 1fr;padding-left:19px;padding-right:19px;gap:15px}.history-list{column-gap:14px}.intro{gap:20px}.prototype-label{display:none}.research-body{gap:32px}footer{padding-left:24px;padding-right:24px}}@media(max-width:700px){.site-header{height:70px;padding:0 18px}.brand{gap:9px;font-size:18px}.brand-separator{margin:0 6px}.brand-subtitle{font-size:12px}.experiment-tag{font-size:10px}main{padding:18px 16px 0}.intro{margin-bottom:24px}.eyebrow{font-size:10px}h1{font-size:25px;letter-spacing:-1px}.intro-copy{font-size:12px;word-break:keep-all}.workbench{grid-template-columns:1fr;gap:16px}.stage{margin:0 11px}#world{min-height:200px}.stage-hint{font-size:9px;left:11px;top:12px;max-width:280px}.transport{padding-left:17px;padding-right:17px;flex-wrap:nowrap;gap:6px}.button{font-size:11px;padding:11px 10px}.button-jump{gap:10px}.button-jump kbd{font-size:8px}.button-reset{font-size:10px;padding:10px 5px}.button-dark{min-width:69px}.state-grid{grid-template-columns:repeat(3,1fr);gap:20px}.state-grid strong{font-size:16px}.rules-panel .panel-heading{padding-bottom:19px}.mode-options{flex-direction:row;gap:9px;margin-bottom:21px}.mode-option{flex:1;padding:13px 11px;gap:7px}.mode-option b{font-size:11px}.mode-option small{font-size:9px}.radio-mark{width:12px;height:12px}.force-heading{margin-top:4px}.enabled-option{padding-top:15px}.apply-area{margin-top:21px}.try-card{padding:19px}.try-card li+li{margin-top:2px}.decision-panel{margin-top:16px}.decision-summary{align-items:flex-start;gap:15px}.decision-result{font-size:16px}.decision-explanation{font-size:10px}.decision-meta{font-size:9px}.decision-path{grid-template-columns:repeat(3,1fr);gap:9px}.decision-path li{padding:12px}.decision-path strong{font-size:11px}.decision-path li:nth-child(3):after{display:none}.node-step{margin-bottom:9px}.path-caption{padding:0 19px;font-size:9px}.history-section{display:block}.history-section h3{display:flex;justify-content:space-between;margin:0 0 13px}.history-section h3 span{margin:0}.history-list{grid-template-columns:1fr;gap:5px}.history-list li{font-size:10px}.stale-note{margin-left:19px;margin-right:19px;font-size:10px}.research-panel{margin-top:16px}.research-panel summary{padding:17px;font-size:11px}.summary-caption{font-size:9px}.research-body{grid-template-columns:1fr;gap:19px;padding:19px}.research-body>div+div{border-top:1px solid var(--line);padding-top:20px}footer{padding:24px 18px;font-size:9px}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{transition:none!important}} |
