From 9d9d5ce41cf99b1b1f5a1d4f8a86dae98e1b5277 Mon Sep 17 00:00:00 2001 From: Drakeor Date: Sun, 21 Jun 2026 20:20:46 -0700 Subject: [PATCH] Add web game (plan2.md) on the independent runtime, merge-blocking gates Builds the browser game around the existing Rust runtime: a window into the universe, not a second simulation. Pure std, no external crates. New crates: - protocol: versioned, hashable client/server messages + hand-rolled JSON value and total parser (malformed packet -> Err, never panic). - game_runtime: authoritative match state. Resolves turns through the INDEPENDENT interpreter (runtime_under_test::native_resolve), not the reference engine; filters visibility/knowledge; records and regenerates replays. A match is a pure function of (seed, roster, ordered inputs). - web_assets/web_client: embedded browser client (arena, rune editor, knowledge panels, replay viewer) + static HTTP delivery. - server: std::net HTTP + WebSocket (hand-rolled SHA-1/base64/RFC-6455 framing), turn timer, disconnect handling, panic-proof dispatch, poison-tolerant lock. - web_tests: dependency-free WebSocket test client + Phase H gates. Trust hardening per review: - game_runtime no longer delegates to reference_runtime::execute; it runs the independent interpreter that the runtime-equivalence gate proves correct. - Protocol/socket/replay/visibility/resilience gates are merge-blocking (added to the merge_group-required job in merge-gates.yml): 1k matches/0 drift, 10k fuzz/0 panics, 100 headless socket E2E, 0 hidden-state leaks. - Rendered-browser E2E is marked EXTERNAL-BLOCKED: Playwright runs advisory-only (continue-on-error, artifacts) until CI infrastructure with a browser exists; it is treated as unsatisfied, not green. The headless 100-match gate is labeled protocol-level coverage, not rendered-browser coverage. - README documents the hand-rolled crypto/parser audit risk explicitly. Fixes an integer-overflow panic in observed-volatility inference (i64 sum / abs near i64::MIN) that could poison the server mutex. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/merge-gates.yml | 8 + .github/workflows/web-gates.yml | 72 ++ .gitignore | 5 + Cargo.toml | 6 + README.md | 90 +++ crates/game_runtime/Cargo.toml | 17 + crates/game_runtime/src/lib.rs | 757 +++++++++++++++++++ crates/protocol/Cargo.toml | 11 + crates/protocol/src/json.rs | 531 +++++++++++++ crates/protocol/src/lib.rs | 858 ++++++++++++++++++++++ crates/server/Cargo.toml | 15 + crates/server/src/http.rs | 113 +++ crates/server/src/lib.rs | 606 +++++++++++++++ crates/server/src/main.rs | 8 + crates/server/src/ws.rs | 342 +++++++++ crates/web_assets/Cargo.toml | 5 + crates/web_assets/assets/app.js | 337 +++++++++ crates/web_assets/assets/index.html | 82 +++ crates/web_assets/assets/style.css | 108 +++ crates/web_assets/src/lib.rs | 44 ++ crates/web_client/Cargo.toml | 8 + crates/web_client/src/lib.rs | 54 ++ crates/web_tests/Cargo.toml | 12 + crates/web_tests/e2e/package.json | 12 + crates/web_tests/e2e/playwright.config.js | 28 + crates/web_tests/e2e/specs/play.spec.js | 43 ++ crates/web_tests/src/lib.rs | 170 +++++ crates/web_tests/tests/determinism.rs | 64 ++ crates/web_tests/tests/e2e.rs | 145 ++++ crates/web_tests/tests/fuzz.rs | 84 +++ crates/web_tests/tests/resilience.rs | 94 +++ crates/web_tests/tests/visibility.rs | 74 ++ plan2.md | 262 +++++++ 33 files changed, 5065 insertions(+) create mode 100644 .github/workflows/web-gates.yml create mode 100644 crates/game_runtime/Cargo.toml create mode 100644 crates/game_runtime/src/lib.rs create mode 100644 crates/protocol/Cargo.toml create mode 100644 crates/protocol/src/json.rs create mode 100644 crates/protocol/src/lib.rs create mode 100644 crates/server/Cargo.toml create mode 100644 crates/server/src/http.rs create mode 100644 crates/server/src/lib.rs create mode 100644 crates/server/src/main.rs create mode 100644 crates/server/src/ws.rs create mode 100644 crates/web_assets/Cargo.toml create mode 100644 crates/web_assets/assets/app.js create mode 100644 crates/web_assets/assets/index.html create mode 100644 crates/web_assets/assets/style.css create mode 100644 crates/web_assets/src/lib.rs create mode 100644 crates/web_client/Cargo.toml create mode 100644 crates/web_client/src/lib.rs create mode 100644 crates/web_tests/Cargo.toml create mode 100644 crates/web_tests/e2e/package.json create mode 100644 crates/web_tests/e2e/playwright.config.js create mode 100644 crates/web_tests/e2e/specs/play.spec.js create mode 100644 crates/web_tests/src/lib.rs create mode 100644 crates/web_tests/tests/determinism.rs create mode 100644 crates/web_tests/tests/e2e.rs create mode 100644 crates/web_tests/tests/fuzz.rs create mode 100644 crates/web_tests/tests/resilience.rs create mode 100644 crates/web_tests/tests/visibility.rs create mode 100644 plan2.md diff --git a/.github/workflows/merge-gates.yml b/.github/workflows/merge-gates.yml index 7bfb2ac..6b47823 100644 --- a/.github/workflows/merge-gates.yml +++ b/.github/workflows/merge-gates.yml @@ -50,6 +50,14 @@ jobs: - name: Unit tests (incl. negative controls) run: cargo test --release --workspace + # plan2.md Phase H: the protocol + socket + replay/visibility gates are + # merge-blocking. These run the full HTTP/WebSocket/protocol/runtime path + # headlessly over real sockets (1k matches, 10k fuzz, 100 e2e, leak + + # resilience). The rendered-browser layer is NOT gated here — see + # web-gates.yml (advisory, blocked on CI infrastructure). + - name: Web protocol + socket gates (merge-blocking) + run: cargo test --release -p protocol -p game_runtime -p server -p web_assets -p web_client -p web_tests + - name: Full merge-blocking acceptance gates env: MAGICKA_PROFILE: merge diff --git a/.github/workflows/web-gates.yml b/.github/workflows/web-gates.yml new file mode 100644 index 0000000..fdba4e0 --- /dev/null +++ b/.github/workflows/web-gates.yml @@ -0,0 +1,72 @@ +name: magicka-web-gates + +# Phase H of plan2.md — the web CI gates. +# +# MERGE-BLOCKING gates live in merge-gates.yml (the merge_group-required job), +# which runs the protocol + socket + replay/visibility/resilience suite. This +# workflow provides the same Rust gates as fast PR/push feedback, plus the +# rendered-browser layer. +# +# The Rust gates (web-rust-gates) enforce, deterministically and without a +# browser: +# * 1,000 simulated matches, 0 replay hash mismatches (determinism.rs) +# * 10,000 protocol fuzz cases, 0 server panics (fuzz.rs) +# * 100 end-to-end matches over real sockets (e2e.rs, protocol-level) +# * 0 hidden-state leaks (visibility.rs) +# * disconnect/reconnect + timer edges (resilience.rs) +# +# The rendered-browser layer (rendered-browser-e2e) is EXTERNAL-BLOCKED: it +# cannot be merge-blocking until CI infrastructure with a real browser exists. +# Until then it runs advisory-only (continue-on-error) and uploads Playwright +# artifacts. It is NOT counted as satisfied coverage. + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +jobs: + web-rust-gates: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Protocol / server / game-runtime unit tests + run: cargo test --release -p protocol -p game_runtime -p server -p web_assets -p web_client + - name: Web CI gates (1k matches, 10k fuzz, 100 e2e, leak + resilience) + run: cargo test --release -p web_tests + + # EXTERNAL-BLOCKED: rendered-browser end-to-end. A real browser is not + # available in this CI, so this job is advisory only and never blocks merge. + # It produces Playwright artifacts as evidence; it does not satisfy the + # "rendered browser" coverage claim until CI infrastructure exists. + rendered-browser-e2e: + name: rendered-browser-e2e (ADVISORY — blocked on CI infra) + runs-on: ubuntu-latest + timeout-minutes: 30 + continue-on-error: true + needs: web-rust-gates + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Install Playwright (Chromium) + working-directory: crates/web_tests/e2e + continue-on-error: true + run: | + npm install + npx playwright install --with-deps chromium + - name: Rendered-browser E2E (advisory) + working-directory: crates/web_tests/e2e + continue-on-error: true + run: npm test + - name: Upload advisory Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-advisory + path: crates/web_tests/e2e/playwright-report + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 059f02b..2d06dcd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ /ci_out /ci_out_merge Cargo.lock +# Playwright / Node artifacts for the rendered-browser E2E +crates/web_tests/e2e/node_modules +crates/web_tests/e2e/package-lock.json +crates/web_tests/e2e/test-results +crates/web_tests/e2e/playwright-report diff --git a/Cargo.toml b/Cargo.toml index 62f331f..670bac7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,12 @@ members = [ "crates/semantic_mutation", "crates/replay_corpus", "crates/ci_reports", + "crates/protocol", + "crates/game_runtime", + "crates/web_assets", + "crates/web_client", + "crates/server", + "crates/web_tests", ] [workspace.package] diff --git a/README.md b/README.md index 4f02722..3326145 100644 --- a/README.md +++ b/README.md @@ -134,3 +134,93 @@ cargo run --release -p replay_corpus --bin freeze -- 10000 Everything is seed-derived and integer-only (SplitMix64 RNG, FNV-1a content hashing, wrapping/guarded arithmetic). No floating point enters a canonical hash, so replay is bit-exact across machines and runs. No external crates. + +## The web game (plan2.md) + +A browser game is built **around** the existing runtime — it is a playable +window into the Rust universe, never a second simulation. The browser sends only +*intent*; the server is the sole authority; every rune program executes through +the **independent** interpreter (`runtime_under_test::native_resolve`) against +the shared world. The game deliberately does **not** call the reference engine — +the interpreter it uses is the one the runtime-equivalence gate proves correct +(with a negative control proving that gate can fail). Same constraints as the +rest of the repo: pure `std`, no external crates (the WebSocket server +hand-rolls SHA-1, base64, and RFC 6455 framing; JSON is hand-rolled with a total +parser). + +> Audit note: the hand-rolled SHA-1 / base64 / RFC-6455 framing and JSON parser +> are checked against published test vectors (RFC 6455 §1.3 accept key, SHA-1 +> "abc", base64 length cases) and a fuzz gate, but they are bespoke +> cryptographic/parsing code and carry audit risk relative to a reviewed +> library. They exist to honor the repo's no-external-crates rule; a future +> hardening pass could swap in vetted implementations behind the same interface. + +``` +Rust runtime → game_runtime (authority) → protocol (WS messages) → server → browser +``` + +| Crate | Role | +|-------|------| +| `protocol` | Versioned, hashable, **total-decode** client/server messages + JSON value/parser. A malformed packet yields `Err`, never a panic. | +| `game_runtime` | Authoritative match state. Resolves turns through the **independent interpreter** (`runtime_under_test`, not the reference engine), filters visibility/knowledge, records + regenerates replays. A match is a pure function of `(seed, roster, ordered inputs)`. | +| `web_assets` | The embedded browser client (HTML/CSS/JS): arena, rune editor, domain/knowledge panels, replay viewer. | +| `web_client` | Static-asset HTTP delivery (keeps raw assets separate from framing). | +| `server` | `std::net` HTTP + WebSocket server: turn timer, action collection, disconnect handling, panic-proof dispatch. | +| `web_tests` | A dependency-free WebSocket test client + the Phase H gates. | + +### Running it + +```bash +cargo run --release -p server --bin magicka-server # serve on 127.0.0.1:8080 +# then open http://127.0.0.1:8080 in a browser +MAGICKA_ADDR=0.0.0.0:9000 MAGICKA_TURN_MS=8000 cargo run --release -p server --bin magicka-server +``` + +Join is immediate (1 player + a training dummy). A duel shares a match by id: +two browsers that `JoinMatch` the same `match_id` take slots 1 and 2. + +### Web CI gates (Phase H) + +These gates are **merge-blocking**: they run inside the merge-required job in +`.github/workflows/merge-gates.yml` (and as fast PR feedback in +`web-gates.yml`). They are the Rust suite in `crates/web_tests`, run with +`cargo test -p web_tests`: + +| Gate | Test | Minimum | Status | +|------|------|---------|--------| +| Replay determinism | `determinism.rs` | 1,000 simulated matches, **0 hash mismatches** | merge-blocking | +| Protocol fuzz | `fuzz.rs` | 10,000 fuzz cases, **0 panics** (+ a live server survives a malformed-packet burst) | merge-blocking | +| End-to-end matches | `e2e.rs` | **100** full matches over real sockets; recorded replay reproduces every live per-turn hash | merge-blocking | +| Hidden-state leaks | `visibility.rs` | **0 leaks** — no client-bound frame carries a hidden key; redaction counts every withheld value | merge-blocking | +| Disconnect / timer edges | `resilience.rs` | mid-match disconnect does not corrupt the match; wrong-turn / late submits are rejected deterministically | merge-blocking | +| Rendered-browser E2E | `e2e/specs/play.spec.js` | a real browser joins, casts, and replays a match | **external-blocked (advisory only)** | + +Scope honesty — two distinct things, not conflated: + +- The "100 E2E matches" merge-blocking gate drives the full + HTTP→WebSocket→protocol→runtime path **headlessly over real sockets**. This is + protocol-level coverage. It is **not** rendered-browser coverage and is not + claimed as such. +- Rendered-browser coverage is **blocked on CI infrastructure**: this CI has no + real browser, so the Playwright suite under `crates/web_tests/e2e/` cannot be + merge-blocking yet. It runs **advisory-only** (`continue-on-error`) in the + `rendered-browser-e2e` job and uploads its report as an artifact. Until a CI + runner with a browser exists, rendered-browser E2E is treated as + **unsatisfied**, not green. Run it locally with: + +```bash +cd crates/web_tests/e2e && npm install && npx playwright install chromium && npm test +``` + +### Acceptance criteria mapping (plan2.md) + +| Criterion | Where it holds | +|-----------|----------------| +| A player can join a browser match | `server` join + `web_assets` client; `e2e.rs::single_match_full_playthrough` | +| A turn timer runs | `server` timer thread; client header countdown | +| Inspect / move / attack / cast | `Action` in `protocol`; `game_runtime::apply_action` | +| Rune programs execute only on the server | `game_runtime` is the only caller of the interpreter (`runtime_under_test::native_resolve`); client never imports `EngineConfig` (asserted in `web_assets`) | +| Results return as filtered observations | `VisibleWorldSnapshot`; `visibility.rs` | +| Replay can reproduce the match | `game_runtime::replay`; `determinism.rs`, `e2e.rs` | +| Browser cannot alter hidden truth | intent-only protocol; `visibility.rs` leak gate | +| CI proves protocol, replay, visibility, authority | merge-blocking gates in `merge-gates.yml` (+ `web-gates.yml`); rendered-browser E2E remains external-blocked | diff --git a/crates/game_runtime/Cargo.toml b/crates/game_runtime/Cargo.toml new file mode 100644 index 0000000..0575006 --- /dev/null +++ b/crates/game_runtime/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "game_runtime" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +world_model = { path = "../world_model" } +rune_ir = { path = "../rune_ir" } +trace_model = { path = "../trace_model" } +# Used only for the shared engine I/O *contract* types (ResolutionInput / +# ResolutionResult) and the canonical view. Execution is driven through the +# independent interpreter in `runtime_under_test`, never the reference engine. +reference_runtime = { path = "../reference_runtime" } +runtime_under_test = { path = "../runtime_under_test" } +generators = { path = "../generators" } +protocol = { path = "../protocol" } diff --git a/crates/game_runtime/src/lib.rs b/crates/game_runtime/src/lib.rs new file mode 100644 index 0000000..276133c --- /dev/null +++ b/crates/game_runtime/src/lib.rs @@ -0,0 +1,757 @@ +//! `game_runtime` — the authoritative match layer (Phase B of `plan2.md`). +//! +//! This crate is the *only* place game truth is decided. The browser sends +//! intent; this crate resolves it. Every rune program executes through the +//! **independent** interpreter [`runtime_under_test::native_resolve`] against +//! the shared [`WorldSnapshot`] — the web layer is a window into the Rust +//! universe and never a second simulation. The game does not call the reference +//! engine; correctness of the interpreter it does use is established separately +//! by the runtime-equivalence gate (which compares that interpreter against the +//! reference over a large sweep, with a negative control proving the gate can +//! fail). +//! +//! Two properties are essential and tested: +//! * **Determinism** — a match is a pure function of `(seed, roster, ordered +//! inputs)`. [`replay`] reconstructs any match and produces an identical +//! final hash. No wall clock, no ambient RNG; the turn *timer* lives in the +//! server, never here. +//! * **Authority + visibility** — players receive a [`VisibleWorldSnapshot`] +//! that redacts all hidden lanes and every non-observable observed lane. The +//! hidden ground truth is never placed in any client-bound structure. + +use protocol::{ + Action, Knowledge, MatchId, RuneDiagnostics, RuneTokenWire, VisibleDomain, + VisibleEntity, VisibleWorldSnapshot, +}; +use reference_runtime::{canonical, ResolutionInput, ResolutionResult}; +use runtime_under_test::native_resolve; +use rune_ir::{Op, RuneProgram, RuneToken}; +use world_model::{ + standard_executors, DomainKind, ExecutionContext, Hash, Hasher, ProgramId, Rng, WorldSnapshot, + HIDDEN_LANES, LANES, NUM_DOMAINS, +}; + +pub const ARENA_W: i32 = 8; +pub const ARENA_H: i32 = 8; +pub const MAX_HP: i32 = 30; +/// Basic stick attack damage. +pub const ATTACK_DAMAGE: i32 = 4; +/// Range (Manhattan) within which a cast's consequence reaches enemies. +pub const CAST_RANGE: i32 = 3; + +/// One combatant on the arena. A "player" entity is driven by a connection; a +/// "dummy" is a deterministic stationary target for the 1-player slice. +#[derive(Clone, Debug)] +pub struct Entity { + pub id: u32, + pub name: String, + pub x: i32, + pub y: i32, + pub hp: i32, + pub is_dummy: bool, + /// The player's current editable rune program (Phase D editor state). + pub program: RuneProgram, +} + +impl Entity { + pub fn alive(&self) -> bool { + self.hp > 0 + } +} + +/// A roster entry needed to reconstruct a match for replay. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RosterEntry { + pub id: u32, + pub name: String, + pub is_dummy: bool, +} + +/// One resolved turn's authoritative input, sufficient to replay it exactly. +#[derive(Clone, Debug, PartialEq)] +pub struct TurnInput { + pub player: u32, + pub action: Action, + /// The exact program used, captured iff `action` is `Cast`. Recording the + /// program here (rather than replaying editor edits) makes replay a pure + /// function of this input stream. + pub program: Option>, +} + +/// One recorded turn (Phase G). +#[derive(Clone, Debug)] +pub struct RecordedTurn { + pub turn: u64, + pub inputs: Vec, + pub turn_hash: Hash, + pub events: Vec, +} + +/// The full replay log for a match. +#[derive(Clone, Debug)] +pub struct ReplayLog { + pub seed: u64, + pub roster: Vec, + pub turns: Vec, + pub final_hash: Hash, +} + +/// The authoritative match state. +#[derive(Clone, Debug)] +pub struct Match { + pub id: MatchId, + pub seed: u64, + pub turn: u64, + pub world: WorldSnapshot, + pub contexts: Vec, + pub entities: Vec, + pub history: Vec, + pub replay: ReplayLog, + pub finished: bool, + /// The most recent per-domain observed change, used to tag freshly-observed + /// lanes without storing per-player memory (keeps resolution stateless). + last_observed_delta: [[i64; LANES]; NUM_DOMAINS], +} + +/// A blank default program (a single benign token) so every entity always has +/// something to cast. +fn default_program(seed: u64) -> RuneProgram { + let mut rng = Rng::derive(seed, "default-program"); + let tokens = (0..8) + .map(|i| RuneToken { + op: Op::from_u8((i as u8).wrapping_add(rng.next_u64() as u8)), + a: rng.next_u64() as u8, + b: rng.next_u64() as u8, + c: rng.next_u64() as u8, + imm: rng.range_i64(-1000, 1000), + }) + .collect(); + RuneProgram { id: ProgramId(seed), tokens, seed } +} + +impl Match { + /// Create a match deterministically from a seed and a roster. + pub fn new(id: MatchId, seed: u64, roster: Vec) -> Match { + let world = generators::generate_world(seed); + let contexts = standard_executors(seed, 3); + let mut placer = Rng::derive(seed, "arena-placement"); + let mut taken: Vec<(i32, i32)> = Vec::new(); + let mut entities = Vec::with_capacity(roster.len()); + for entry in &roster { + // Deterministic distinct placement. + let (x, y) = loop { + let x = placer.below(ARENA_W as usize) as i32; + let y = placer.below(ARENA_H as usize) as i32; + if !taken.contains(&(x, y)) { + break (x, y); + } + }; + taken.push((x, y)); + entities.push(Entity { + id: entry.id, + name: entry.name.clone(), + x, + y, + hp: MAX_HP, + is_dummy: entry.is_dummy, + program: default_program(seed ^ (entry.id as u64).wrapping_mul(0x9e3779b97f4a7c15)), + }); + } + Match { + id, + seed, + turn: 0, + world, + contexts, + entities, + history: Vec::new(), + replay: ReplayLog { seed, roster, turns: Vec::new(), final_hash: Hash(0) }, + finished: false, + last_observed_delta: [[0; LANES]; NUM_DOMAINS], + } + } + + pub fn entity(&self, id: u32) -> Option<&Entity> { + self.entities.iter().find(|e| e.id == id) + } + pub fn entity_mut(&mut self, id: u32) -> Option<&mut Entity> { + self.entities.iter_mut().find(|e| e.id == id) + } + + /// Replace a player's editable program (Phase D / `EditRuneProgram`). Length + /// is bounded by the protocol decoder; this just stores it. + pub fn set_program(&mut self, player: u32, tokens: Vec) { + let seed = self.seed; + if let Some(e) = self.entity_mut(player) { + e.program = RuneProgram { + id: ProgramId(player as u64), + seed: seed ^ player as u64, + tokens: tokens.iter().map(|t| t.into_token()).collect(), + }; + } + } + + fn resolution_input(&self, program: &RuneProgram) -> ResolutionInput { + ResolutionInput { + world: self.world.clone(), + program: program.clone(), + contexts: self.contexts.clone(), + contract_seed: self.seed, + perturbation_seed: self.seed ^ self.turn, + } + } + + /// Resolve one turn from a set of `(player, action)` submissions. Missing + /// players default to `Wait`. Returns the per-turn event list. This is the + /// authoritative state transition and is fully deterministic. + pub fn resolve_turn(&mut self, submissions: &[(u32, Action)]) -> Vec { + // Build a canonical, complete, sorted input set: one action per entity. + let mut inputs: Vec = Vec::new(); + let mut ids: Vec = self.entities.iter().map(|e| e.id).collect(); + ids.sort_unstable(); + for id in ids { + let action = submissions + .iter() + .find(|(pid, _)| *pid == id) + .map(|(_, a)| a.clone()) + .unwrap_or(Action::Wait); + let program = if matches!(action, Action::Cast) { + self.entity(id) + .map(|e| e.program.tokens.iter().map(RuneTokenWire::from_token).collect()) + } else { + None + }; + inputs.push(TurnInput { player: id, action, program }); + } + + let before = self.world.clone(); + let mut events = Vec::new(); + + for input in &inputs { + self.apply_action(input, &mut events); + } + + // Knowledge bookkeeping: which observed lanes changed this turn. + for d in 0..NUM_DOMAINS { + for l in 0..LANES { + self.last_observed_delta[d][l] = + self.world.domains[d].observed[l].wrapping_sub(before.domains[d].observed[l]); + } + } + + self.turn = self.turn.wrapping_add(1); + + // Per-turn hash binds every effect: world state + entity state + inputs. + let turn_hash = self.turn_hash(&inputs); + for e in &events { + self.history.push(format!("turn {}: {}", self.turn, e)); + } + + // End condition: in a multi-player match, finish when at most one + // non-dummy combatant is still standing. + let players = self.entities.iter().filter(|e| !e.is_dummy).count(); + let living_players = self.entities.iter().filter(|e| !e.is_dummy && e.alive()).count(); + if players >= 2 && living_players <= 1 { + self.finished = true; + } + + self.replay.turns.push(RecordedTurn { + turn: self.turn, + inputs, + turn_hash, + events: events.clone(), + }); + self.recompute_final_hash(); + events + } + + fn apply_action(&mut self, input: &TurnInput, events: &mut Vec) { + // Skip dead entities entirely. + let alive = self.entity(input.player).map(|e| e.alive()).unwrap_or(false); + if !alive { + return; + } + match &input.action { + Action::Wait => {} + Action::Move { dx, dy } => { + let (nx, ny) = { + let e = self.entity(input.player).unwrap(); + ( + (e.x + dx.clamp(&-1, &1)).clamp(0, ARENA_W - 1), + (e.y + dy.clamp(&-1, &1)).clamp(0, ARENA_H - 1), + ) + }; + let occupied = self + .entities + .iter() + .any(|o| o.id != input.player && o.alive() && o.x == nx && o.y == ny); + if !occupied { + let name = self.entity(input.player).unwrap().name.clone(); + let e = self.entity_mut(input.player).unwrap(); + e.x = nx; + e.y = ny; + events.push(format!("{name} moved to ({nx},{ny})")); + } + } + Action::Attack { target } => { + let attacker = self.entity(input.player).unwrap().clone(); + if let Some(t) = self.entity(*target) { + let adjacent = (t.x - attacker.x).abs() <= 1 && (t.y - attacker.y).abs() <= 1; + if adjacent && t.alive() && *target != input.player { + let tname = t.name.clone(); + let te = self.entity_mut(*target).unwrap(); + te.hp = (te.hp - ATTACK_DAMAGE).max(0); + let hp = te.hp; + events.push(format!( + "{} struck {} for {ATTACK_DAMAGE} ({} hp left)", + attacker.name, tname, hp + )); + if hp == 0 { + events.push(format!("{tname} fell")); + } + } + } + } + Action::Inspect { target } => { + if let Some(t) = self.entity(*target) { + events.push(format!( + "{} inspected {}", + self.entity(input.player).unwrap().name, + t.name + )); + } + } + Action::Cast => { + let program = match &input.program { + Some(toks) => RuneProgram { + id: ProgramId(input.player as u64), + seed: self.seed ^ input.player as u64, + tokens: toks.iter().map(|t| t.into_token()).collect(), + }, + None => self.entity(input.player).unwrap().program.clone(), + }; + let res = native_resolve(&self.resolution_input(&program)); + self.apply_resolution(&res); + let power = cast_power(&res); + let caster = self.entity(input.player).unwrap().clone(); + events.push(format!("{} cast a rune program (power {power})", caster.name)); + // Consequence: enemies within range take `power` damage. + let targets: Vec = self + .entities + .iter() + .filter(|o| { + o.id != input.player + && o.alive() + && (o.x - caster.x).abs() + (o.y - caster.y).abs() <= CAST_RANGE + }) + .map(|o| o.id) + .collect(); + for tid in targets { + let tname = self.entity(tid).unwrap().name.clone(); + let te = self.entity_mut(tid).unwrap(); + te.hp = (te.hp - power).max(0); + let hp = te.hp; + events.push(format!("{tname} took {power} from the working ({hp} hp left)")); + if hp == 0 { + events.push(format!("{tname} fell")); + } + } + } + } + } + + /// Apply a resolution's world delta to the shared world (observed + hidden). + fn apply_resolution(&mut self, res: &ResolutionResult) { + for dd in &res.delta.domain_deltas { + let d = dd.domain.0 as usize; + if d >= NUM_DOMAINS { + continue; + } + for l in 0..LANES { + self.world.domains[d].observed[l] = + self.world.domains[d].observed[l].wrapping_add(dd.observed[l]); + } + for l in 0..HIDDEN_LANES { + self.world.domains[d].hidden[l] = + self.world.domains[d].hidden[l].wrapping_add(dd.hidden[l]); + } + } + } + + fn turn_hash(&self, inputs: &[TurnInput]) -> Hash { + let mut h = Hasher::new(); + h.write_tag("game-turn"); + h.write_u64(self.turn); + h.write_u64(self.world.content_hash().0); + for e in &self.entities { + h.write_u64(e.id as u64); + h.write_i64(e.x as i64); + h.write_i64(e.y as i64); + h.write_i64(e.hp as i64); + } + for input in inputs { + h.write_u64(input.player as u64); + hash_action(&mut h, &input.action); + if let Some(prog) = &input.program { + h.write_usize(prog.len()); + for t in prog { + h.write_u8(t.op); + h.write_u8(t.a); + h.write_u8(t.b); + h.write_u8(t.c); + h.write_i64(t.imm); + } + } + } + h.finish() + } + + fn recompute_final_hash(&mut self) { + let mut h = Hasher::new(); + h.write_tag("game-replay-final"); + h.write_u64(self.seed); + for entry in &self.replay.roster { + h.write_u64(entry.id as u64); + h.write_bytes(entry.name.as_bytes()); + h.write_u8(entry.is_dummy as u8); + } + for t in &self.replay.turns { + h.write_u64(t.turn_hash.0); + } + self.replay.final_hash = h.finish(); + } + + /// Hex string of the most recent turn's hash (Phase G `runtime_hash`). + pub fn last_turn_hash_hex(&self) -> String { + format!("{}", self.replay.turns.last().map(|t| t.turn_hash).unwrap_or(Hash(0))) + } + + pub fn final_hash_hex(&self) -> String { + format!("{}", self.replay.final_hash) + } + + // -- Visibility / knowledge (Phase F) ----------------------------------- + + /// Build the filtered snapshot for one player. Hidden lanes and non-visible + /// observed lanes are redacted; only their *count* is reported. + pub fn visible_for(&self, player: u32) -> VisibleWorldSnapshot { + let projection = self.world.observed_projection(); + let mut observed_domains = Vec::with_capacity(NUM_DOMAINS); + let mut redactions: u32 = 0; + for d in 0..NUM_DOMAINS { + let mut observed = Vec::with_capacity(LANES); + let mut knowledge = Vec::with_capacity(LANES); + for l in 0..LANES { + if self.world.observation_state.visible[d][l] { + observed.push(Some(projection[d * LANES + l])); + knowledge.push(if self.last_observed_delta[d][l] != 0 { + Knowledge::NewlyObserved + } else { + Knowledge::Known + }); + } else { + observed.push(None); + knowledge.push(Knowledge::Unknown); + redactions += 1; + } + } + observed_domains.push(VisibleDomain { + index: d as u8, + name: DomainKind::from_index(d).name().to_string(), + observed, + knowledge, + }); + } + // All hidden lanes are always withheld. + redactions += (NUM_DOMAINS * HIDDEN_LANES) as u32; + + let observed_entities = self + .entities + .iter() + .map(|e| VisibleEntity { + id: e.id, + name: e.name.clone(), + x: e.x, + y: e.y, + hp: e.hp, + is_self: e.id == player, + alive: e.alive(), + }) + .collect(); + + // Inference from *observed* volatility only — never from hidden state. + // Use a presence test (any lane changed) rather than summing magnitudes, + // which avoids overflow on wrapping deltas near i64::MIN. + let mut inferred_markers = Vec::new(); + for d in 0..NUM_DOMAINS { + let shifted = (0..LANES).any(|l| self.last_observed_delta[d][l] != 0); + if shifted && self.world.observation_state.visible[d].iter().any(|&v| v) { + inferred_markers.push(format!( + "{} shifted recently — likely volatile", + DomainKind::from_index(d).name() + )); + } + } + + let known_history: Vec = self.history.iter().rev().take(8).rev().cloned().collect(); + + VisibleWorldSnapshot { + turn: self.turn, + arena_w: ARENA_W, + arena_h: ARENA_H, + observed_domains, + observed_entities, + observed_environment: vec![ + format!("arena {ARENA_W}x{ARENA_H}"), + format!("turn {}", self.turn), + ], + known_history, + inferred_markers, + hidden_state_redactions: redactions, + } + } + + /// Player-facing diagnostics for a candidate program (Phase D). A *dry run* + /// against a clone of the world — it mutates nothing and never reports + /// hidden values, only domain names, counts, and observed fault risks. + pub fn diagnostics_for(&self, program: &RuneProgram) -> RuneDiagnostics { + let res = native_resolve(&self.resolution_input(program)); + let visible_domain = |d: usize| self.world.observation_state.visible[d].iter().any(|&v| v); + + let mut known_reads = Vec::new(); + for d in res.trace.read_graph.touched() { + if visible_domain(d) { + known_reads.push(DomainKind::from_index(d).name().to_string()); + } + } + known_reads.sort(); + known_reads.dedup(); + + let mut known_writes = Vec::new(); + let mut unknown_listeners = 0u32; + for d in res.trace.write_graph.touched() { + if visible_domain(d) { + known_writes.push(DomainKind::from_index(d).name().to_string()); + } else { + unknown_listeners += 1; + } + } + known_writes.sort(); + known_writes.dedup(); + + let mut observed_risks = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + for f in &res.faults.faults { + if seen.insert(f.code.name()) { + observed_risks.push(format!("possible {}", f.code.name())); + } + } + + let matching: Vec = self + .history + .iter() + .filter(|h| h.contains("cast") || h.contains("working")) + .cloned() + .collect(); + let start = matching.len().saturating_sub(4); + let previous_outcomes: Vec = matching[start..].to_vec(); + + RuneDiagnostics { + known_reads, + known_writes, + observed_risks, + unknown_listeners, + previous_outcomes, + } + } + + /// Diagnostics for a player's currently-stored program. + pub fn diagnostics_for_player(&self, player: u32) -> RuneDiagnostics { + match self.entity(player) { + Some(e) => self.diagnostics_for(&e.program), + None => RuneDiagnostics::default(), + } + } +} + +/// Damage power derived from the runtime trace — the rune program's effect on +/// the game is a function of the structure the Rust engine actually produced. +fn cast_power(res: &ResolutionResult) -> i32 { + let rank = res.trace.causal_rank() as i32; + let touched = res.trace.touched_domain_count() as i32; + (1 + rank + touched / 2).clamp(1, 10) +} + +fn hash_action(h: &mut Hasher, a: &Action) { + match a { + Action::Wait => h.write_u8(0), + Action::Move { dx, dy } => { + h.write_u8(1); + h.write_i64(*dx as i64); + h.write_i64(*dy as i64); + } + Action::Inspect { target } => { + h.write_u8(2); + h.write_u64(*target as u64); + } + Action::Cast => h.write_u8(3), + Action::Attack { target } => { + h.write_u8(4); + h.write_u64(*target as u64); + } + } +} + +/// Build a standard 1-player + dummy roster. +pub fn solo_roster(player_name: &str) -> Vec { + vec![ + RosterEntry { id: 1, name: player_name.to_string(), is_dummy: false }, + RosterEntry { id: 2, name: "training dummy".to_string(), is_dummy: true }, + ] +} + +/// Build a 2-player roster. +pub fn duel_roster(a: &str, b: &str) -> Vec { + vec![ + RosterEntry { id: 1, name: a.to_string(), is_dummy: false }, + RosterEntry { id: 2, name: b.to_string(), is_dummy: false }, + ] +} + +/// Re-run a match from its seed, roster, and the exact recorded inputs, and +/// return the reconstructed log. Determinism gate: this must reproduce the +/// original `final_hash` bit-for-bit. +pub fn replay(seed: u64, roster: &[RosterEntry], recorded: &[RecordedTurn]) -> ReplayLog { + let mut m = Match::new(MatchId(0), seed, roster.to_vec()); + for rt in recorded { + // Restore each casting player's program from the record, then apply the + // same actions in the same order. + for input in &rt.inputs { + if let (Action::Cast, Some(prog)) = (&input.action, &input.program) { + m.set_program(input.player, prog.clone()); + } + } + let subs: Vec<(u32, Action)> = + rt.inputs.iter().map(|i| (i.player, i.action.clone())).collect(); + m.resolve_turn(&subs); + } + m.replay +} + +/// Convenience: run a scripted match end-to-end and return its log. Used by the +/// determinism tests and the headless E2E harness. +pub fn run_scripted( + seed: u64, + roster: &[RosterEntry], + scripts: &[Vec<(u32, Action)>], +) -> (Match, ReplayLog) { + let mut m = Match::new(MatchId(seed), seed, roster.to_vec()); + for turn_subs in scripts { + m.resolve_turn(turn_subs); + } + let log = m.replay.clone(); + (m, log) +} + +/// Canonical fingerprint of a single resolution (used by integration tests to +/// assert the game layer truly drove the independent interpreter). +pub fn resolution_fingerprint(world: &WorldSnapshot, program: &RuneProgram, seed: u64) -> Hash { + let input = ResolutionInput { + world: world.clone(), + program: program.clone(), + contexts: standard_executors(seed, 3), + contract_seed: seed, + perturbation_seed: seed, + }; + let c = canonical(&native_resolve(&input)); + let mut h = Hasher::new(); + h.write_tag("resolution-fp"); + h.write_u64(c.delta_hash.0); + h.write_u64(c.trace_hash.0); + h.write_u64(c.replay_hash.0); + h.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rune(op: u8, a: u8, b: u8, c: u8, imm: i64) -> RuneTokenWire { + RuneTokenWire { op, a, b, c, imm } + } + + fn scripted_match() -> Vec> { + vec![ + vec![(1, Action::Move { dx: 1, dy: 0 })], + vec![(1, Action::Cast)], + vec![(1, Action::Attack { target: 2 })], + vec![(1, Action::Wait), (2, Action::Wait)], + vec![(1, Action::Cast)], + ] + } + + #[test] + fn match_resolves_through_independent_interpreter() { + let mut m = Match::new(MatchId(1), 7, solo_roster("dev")); + m.set_program(1, vec![rune(0, 1, 2, 3, 4), rune(5, 2, 1, 0, -3)]); + let before = m.world.content_hash(); + m.resolve_turn(&[(1, Action::Cast)]); + // A cast changed the shared world via the independent interpreter. + assert_ne!(before, m.world.content_hash()); + assert_eq!(m.turn, 1); + } + + #[test] + fn replay_reproduces_final_hash() { + let seed = 12345; + let roster = solo_roster("dev"); + let mut m = Match::new(MatchId(seed), seed, roster.clone()); + m.set_program(1, vec![rune(2, 3, 4, 5, 6), rune(8, 1, 1, 1, 1), rune(0, 7, 7, 7, 7)]); + for subs in scripted_match() { + m.resolve_turn(&subs); + } + let original = m.replay.final_hash; + // Replay from the recorded inputs alone. + let reconstructed = replay(seed, &roster, &m.replay.turns); + assert_eq!(original, reconstructed.final_hash, "replay drifted"); + } + + #[test] + fn many_matches_are_deterministic() { + for seed in 0..200u64 { + let roster = solo_roster("p"); + let (m, log) = run_scripted(seed, &roster, &scripted_match()); + let again = replay(seed, &roster, &log.turns); + assert_eq!(m.replay.final_hash, again.final_hash, "seed {seed} not deterministic"); + } + } + + #[test] + fn hidden_state_never_appears_in_visible_snapshot() { + let mut m = Match::new(MatchId(1), 999, solo_roster("dev")); + // Mask some observed lanes so redaction is non-trivial. + for d in 0..NUM_DOMAINS { + m.world.observation_state.visible[d][1] = false; + } + m.set_program(1, vec![rune(10, 1, 2, 3, 4)]); + m.resolve_turn(&[(1, Action::Cast)]); + let snap = m.visible_for(1); + for vd in &snap.observed_domains { + for (l, o) in vd.observed.iter().enumerate() { + if !m.world.observation_state.visible[vd.index as usize][l] { + assert!(o.is_none(), "masked lane leaked a value"); + } + } + } + assert!(snap.hidden_state_redactions >= (NUM_DOMAINS * HIDDEN_LANES) as u32); + } + + #[test] + fn diagnostics_are_names_and_counts_only() { + let m = Match::new(MatchId(1), 5, solo_roster("dev")); + let diag = m.diagnostics_for_player(1); + for s in diag.known_reads.iter().chain(diag.known_writes.iter()) { + assert!(s.chars().any(|c| c.is_alphabetic()), "diagnostic should be a domain name"); + } + } +} diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml new file mode 100644 index 0000000..fbbae6a --- /dev/null +++ b/crates/protocol/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "protocol" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +world_model = { path = "../world_model" } +rune_ir = { path = "../rune_ir" } +trace_model = { path = "../trace_model" } +reference_runtime = { path = "../reference_runtime" } diff --git a/crates/protocol/src/json.rs b/crates/protocol/src/json.rs new file mode 100644 index 0000000..493c90b --- /dev/null +++ b/crates/protocol/src/json.rs @@ -0,0 +1,531 @@ +//! A complete hand-rolled JSON value, serializer, and parser (no external +//! crates). The orchestrator's `ci_reports::json` is write-only; the protocol +//! needs to *parse* untrusted client packets too, and parsing must be **total** +//! — any byte sequence yields `Ok` or `Err`, never a panic. That totality is +//! what lets the server treat a malformed packet as a deterministic +//! `ValidationReport` rather than a crash. + +use std::collections::BTreeMap; +use std::fmt; + +/// A parsed JSON value. Objects use a `BTreeMap` so key order is canonical, +/// which keeps re-serialization stable and hashable. +#[derive(Clone, PartialEq, Debug)] +pub enum Json { + Null, + Bool(bool), + /// All numbers are carried as `f64`; integer accessors round-trip exact + /// values within the safe integer range, which is all the protocol uses. + Num(f64), + Str(String), + Arr(Vec), + Obj(BTreeMap), +} + +impl Json { + pub fn s(v: impl Into) -> Json { + Json::Str(v.into()) + } + pub fn i(v: i64) -> Json { + Json::Num(v as f64) + } + pub fn u(v: u64) -> Json { + Json::Num(v as f64) + } + pub fn obj(fields: Vec<(&str, Json)>) -> Json { + let mut m = BTreeMap::new(); + for (k, v) in fields { + m.insert(k.to_string(), v); + } + Json::Obj(m) + } + + // ---- typed accessors (all fallible, none panic) ---- + + pub fn get(&self, key: &str) -> Option<&Json> { + match self { + Json::Obj(m) => m.get(key), + _ => None, + } + } + pub fn as_str(&self) -> Option<&str> { + match self { + Json::Str(s) => Some(s), + _ => None, + } + } + pub fn as_f64(&self) -> Option { + match self { + Json::Num(n) => Some(*n), + _ => None, + } + } + pub fn as_i64(&self) -> Option { + match self { + Json::Num(n) if n.is_finite() => Some(*n as i64), + _ => None, + } + } + pub fn as_u64(&self) -> Option { + match self { + Json::Num(n) if n.is_finite() && *n >= 0.0 => Some(*n as u64), + _ => None, + } + } + pub fn as_u8(&self) -> Option { + self.as_u64().and_then(|v| u8::try_from(v).ok()) + } + pub fn as_bool(&self) -> Option { + match self { + Json::Bool(b) => Some(*b), + _ => None, + } + } + pub fn as_arr(&self) -> Option<&[Json]> { + match self { + Json::Arr(a) => Some(a), + _ => None, + } + } + + /// Convenience: required field accessors that produce a descriptive error. + pub fn field<'a>(&'a self, key: &str) -> Result<&'a Json, JsonError> { + self.get(key).ok_or_else(|| JsonError::Field(key.to_string())) + } + pub fn str_field(&self, key: &str) -> Result { + self.field(key)? + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| JsonError::Type(key.to_string(), "string")) + } + pub fn u64_field(&self, key: &str) -> Result { + self.field(key)? + .as_u64() + .ok_or_else(|| JsonError::Type(key.to_string(), "u64")) + } + pub fn i64_field(&self, key: &str) -> Result { + self.field(key)? + .as_i64() + .ok_or_else(|| JsonError::Type(key.to_string(), "i64")) + } + pub fn arr_field<'a>(&'a self, key: &str) -> Result<&'a [Json], JsonError> { + self.field(key)? + .as_arr() + .ok_or_else(|| JsonError::Type(key.to_string(), "array")) + } + + // ---- serialization ---- + + /// Compact canonical serialization (no whitespace). Deterministic because + /// object keys are stored sorted. + pub fn to_compact(&self) -> String { + let mut out = String::new(); + self.write(&mut out); + out + } + + fn write(&self, out: &mut String) { + match self { + Json::Null => out.push_str("null"), + Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Json::Num(n) => { + if !n.is_finite() { + out.push_str("null"); + } else if *n == n.trunc() && n.abs() < 9_007_199_254_740_992.0 { + // Exact integer: print without a decimal point. + out.push_str(&(*n as i64).to_string()); + } else { + out.push_str(&format!("{}", n)); + } + } + Json::Str(s) => write_str(out, s), + Json::Arr(items) => { + out.push('['); + for (i, it) in items.iter().enumerate() { + if i > 0 { + out.push(','); + } + it.write(out); + } + out.push(']'); + } + Json::Obj(m) => { + out.push('{'); + for (i, (k, v)) in m.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_str(out, k); + out.push(':'); + v.write(out); + } + out.push('}'); + } + } + } +} + +fn write_str(out: &mut String, s: &str) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); +} + +/// A JSON parse / shape error. Carrying a message keeps decode failures +/// diagnosable without ever unwinding. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum JsonError { + Parse(String), + Field(String), + Type(String, &'static str), +} + +impl fmt::Display for JsonError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + JsonError::Parse(m) => write!(f, "json parse error: {m}"), + JsonError::Field(k) => write!(f, "missing field: {k}"), + JsonError::Type(k, t) => write!(f, "field {k} is not a {t}"), + } + } +} + +impl std::error::Error for JsonError {} + +/// Parse a JSON document. Total: never panics on any input. +pub fn parse(input: &str) -> Result { + let bytes = input.as_bytes(); + let mut p = Parser { bytes, pos: 0, depth: 0 }; + p.skip_ws(); + let v = p.value()?; + p.skip_ws(); + if p.pos != bytes.len() { + return Err(JsonError::Parse("trailing characters".into())); + } + Ok(v) +} + +/// Maximum nesting depth. Bounds recursion so a deeply-nested adversarial +/// packet returns `Err` instead of overflowing the stack. +const MAX_DEPTH: usize = 64; + +struct Parser<'a> { + bytes: &'a [u8], + pos: usize, + depth: usize, +} + +impl<'a> Parser<'a> { + fn peek(&self) -> Option { + self.bytes.get(self.pos).copied() + } + + fn skip_ws(&mut self) { + while let Some(b) = self.peek() { + if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' { + self.pos += 1; + } else { + break; + } + } + } + + fn value(&mut self) -> Result { + self.depth += 1; + if self.depth > MAX_DEPTH { + return Err(JsonError::Parse("max depth exceeded".into())); + } + let r = match self.peek() { + Some(b'{') => self.object(), + Some(b'[') => self.array(), + Some(b'"') => Ok(Json::Str(self.string()?)), + Some(b't') | Some(b'f') => self.boolean(), + Some(b'n') => self.null(), + Some(b'-') | Some(b'0'..=b'9') => self.number(), + Some(c) => Err(JsonError::Parse(format!("unexpected byte '{}'", c as char))), + None => Err(JsonError::Parse("unexpected end".into())), + }; + self.depth -= 1; + r + } + + fn expect(&mut self, b: u8) -> Result<(), JsonError> { + if self.peek() == Some(b) { + self.pos += 1; + Ok(()) + } else { + Err(JsonError::Parse(format!("expected '{}'", b as char))) + } + } + + fn object(&mut self) -> Result { + self.expect(b'{')?; + let mut m = BTreeMap::new(); + self.skip_ws(); + if self.peek() == Some(b'}') { + self.pos += 1; + return Ok(Json::Obj(m)); + } + loop { + self.skip_ws(); + let key = self.string()?; + self.skip_ws(); + self.expect(b':')?; + self.skip_ws(); + let val = self.value()?; + m.insert(key, val); + self.skip_ws(); + match self.peek() { + Some(b',') => { + self.pos += 1; + continue; + } + Some(b'}') => { + self.pos += 1; + break; + } + _ => return Err(JsonError::Parse("expected ',' or '}'".into())), + } + } + Ok(Json::Obj(m)) + } + + fn array(&mut self) -> Result { + self.expect(b'[')?; + let mut a = Vec::new(); + self.skip_ws(); + if self.peek() == Some(b']') { + self.pos += 1; + return Ok(Json::Arr(a)); + } + loop { + self.skip_ws(); + a.push(self.value()?); + self.skip_ws(); + match self.peek() { + Some(b',') => { + self.pos += 1; + continue; + } + Some(b']') => { + self.pos += 1; + break; + } + _ => return Err(JsonError::Parse("expected ',' or ']'".into())), + } + } + Ok(Json::Arr(a)) + } + + fn string(&mut self) -> Result { + self.expect(b'"')?; + let mut s = String::new(); + loop { + match self.peek() { + None => return Err(JsonError::Parse("unterminated string".into())), + Some(b'"') => { + self.pos += 1; + break; + } + Some(b'\\') => { + self.pos += 1; + match self.peek() { + Some(b'"') => s.push('"'), + Some(b'\\') => s.push('\\'), + Some(b'/') => s.push('/'), + Some(b'n') => s.push('\n'), + Some(b'r') => s.push('\r'), + Some(b't') => s.push('\t'), + Some(b'b') => s.push('\u{0008}'), + Some(b'f') => s.push('\u{000c}'), + Some(b'u') => { + let cp = self.hex4()?; + // Handle surrogate pairs. + if (0xD800..=0xDBFF).contains(&cp) { + if self.peek() == Some(b'\\') { + self.pos += 1; + if self.peek() == Some(b'u') { + let lo = self.hex4()?; + if (0xDC00..=0xDFFF).contains(&lo) { + let c = 0x10000 + + ((cp - 0xD800) << 10) + + (lo - 0xDC00); + if let Some(ch) = char::from_u32(c) { + s.push(ch); + } else { + s.push('\u{FFFD}'); + } + continue; + } + } + } + s.push('\u{FFFD}'); + } else if let Some(ch) = char::from_u32(cp) { + s.push(ch); + } else { + s.push('\u{FFFD}'); + } + continue; + } + _ => return Err(JsonError::Parse("bad escape".into())), + } + self.pos += 1; + } + Some(_) => { + // Copy one UTF-8 codepoint from the source. + let start = self.pos; + let len = utf8_len(self.bytes[start]); + if start + len > self.bytes.len() { + return Err(JsonError::Parse("bad utf8".into())); + } + match std::str::from_utf8(&self.bytes[start..start + len]) { + Ok(chunk) => s.push_str(chunk), + Err(_) => return Err(JsonError::Parse("bad utf8".into())), + } + self.pos += len; + } + } + } + Ok(s) + } + + fn hex4(&mut self) -> Result { + // assumes the 'u' has been consumed + self.pos += 1; + let mut v: u32 = 0; + for _ in 0..4 { + let d = self + .peek() + .and_then(|b| (b as char).to_digit(16)) + .ok_or_else(|| JsonError::Parse("bad \\u".into()))?; + v = v * 16 + d; + self.pos += 1; + } + Ok(v) + } + + fn boolean(&mut self) -> Result { + if self.bytes[self.pos..].starts_with(b"true") { + self.pos += 4; + Ok(Json::Bool(true)) + } else if self.bytes[self.pos..].starts_with(b"false") { + self.pos += 5; + Ok(Json::Bool(false)) + } else { + Err(JsonError::Parse("bad literal".into())) + } + } + + fn null(&mut self) -> Result { + if self.bytes[self.pos..].starts_with(b"null") { + self.pos += 4; + Ok(Json::Null) + } else { + Err(JsonError::Parse("bad literal".into())) + } + } + + fn number(&mut self) -> Result { + let start = self.pos; + if self.peek() == Some(b'-') { + self.pos += 1; + } + while let Some(b'0'..=b'9') = self.peek() { + self.pos += 1; + } + if self.peek() == Some(b'.') { + self.pos += 1; + while let Some(b'0'..=b'9') = self.peek() { + self.pos += 1; + } + } + if let Some(b'e') | Some(b'E') = self.peek() { + self.pos += 1; + if let Some(b'+') | Some(b'-') = self.peek() { + self.pos += 1; + } + while let Some(b'0'..=b'9') = self.peek() { + self.pos += 1; + } + } + let slice = std::str::from_utf8(&self.bytes[start..self.pos]) + .map_err(|_| JsonError::Parse("bad number".into()))?; + slice + .parse::() + .map(Json::Num) + .map_err(|_| JsonError::Parse("bad number".into())) + } +} + +fn utf8_len(b: u8) -> usize { + if b < 0x80 { + 1 + } else if b >> 5 == 0b110 { + 2 + } else if b >> 4 == 0b1110 { + 3 + } else if b >> 3 == 0b11110 { + 4 + } else { + 1 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_basic() { + let v = Json::obj(vec![ + ("a", Json::i(42)), + ("b", Json::Arr(vec![Json::Bool(true), Json::Null, Json::s("x")])), + ("c", Json::Num(1.5)), + ]); + let s = v.to_compact(); + let back = parse(&s).unwrap(); + assert_eq!(v, back); + } + + #[test] + fn parse_never_panics_on_garbage() { + let deep = "{".repeat(1000); + let cases = [ + "", "{", "[", "\"", "nul", "{\"a\":}", "[1,2,", "tru", "12.3.4", + "{\"a\"1}", "\\", "\"\\u00\"", deep.as_str(), + ]; + for c in cases { + // Must return without panicking; value is irrelevant. + let _ = parse(c); + } + } + + #[test] + fn deep_nesting_is_rejected_not_overflowed() { + let deep = "[".repeat(10_000); + assert!(parse(&deep).is_err()); + } + + #[test] + fn integers_roundtrip_exact() { + let v = Json::i(-1234567890123); + assert_eq!(parse(&v.to_compact()).unwrap().as_i64(), Some(-1234567890123)); + } + + #[test] + fn escapes_roundtrip() { + let v = Json::s("line\ntab\tquote\"slash\\end"); + let s = v.to_compact(); + assert_eq!(parse(&s).unwrap(), v); + } +} diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs new file mode 100644 index 0000000..f1b8bc0 --- /dev/null +++ b/crates/protocol/src/lib.rs @@ -0,0 +1,858 @@ +//! `protocol` — the versioned, serializable, hashable client/server message +//! contract (Phase A of `plan2.md`). Defined **before** any UI. +//! +//! Invariants enforced here: +//! * Every message carries a protocol version (`PROTOCOL_VERSION`); a decoder +//! rejects mismatched versions deterministically. +//! * Decoding is **total**: any byte string yields `Ok(msg)` or `Err(..)`, +//! never a panic. The server relies on this to turn a malformed client +//! packet into a `ValidationReport`/`ErrorEvent` instead of crashing. +//! * Every server output is **hashable** ([`ServerMessage::content_hash`]) over +//! a canonical (sorted-key, whitespace-free) serialization, so replays and +//! the browser can verify byte-for-byte agreement with the server. +//! * No game truth lives client-side: client messages carry only *intent* +//! (movement choice, rune program, slot selection, inspection request). + +pub mod json; + +pub use json::{parse, Json, JsonError}; +use world_model::{Hash, Hasher}; + +/// Protocol version. Bumped on any wire-incompatible change. Both peers check +/// it on every message. +pub const PROTOCOL_VERSION: u32 = 1; + +/// Stable identifier for a connected player within a match. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub struct PlayerId(pub u32); + +/// Stable identifier for a match. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub struct MatchId(pub u64); + +// --------------------------------------------------------------------------- +// Rune token wire form (mirror of `rune_ir::RuneToken`, kept independent so the +// wire format does not silently change when the IR changes). +// --------------------------------------------------------------------------- + +/// One rune token as it crosses the wire. `op` is the opcode index +/// (`rune_ir::Op::to_u8`); every field is interpreted modulo its range by the +/// runtime, so no token value is ever rejected. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct RuneTokenWire { + pub op: u8, + pub a: u8, + pub b: u8, + pub c: u8, + pub imm: i64, +} + +impl RuneTokenWire { + pub fn to_json(self) -> Json { + Json::obj(vec![ + ("op", Json::u(self.op as u64)), + ("a", Json::u(self.a as u64)), + ("b", Json::u(self.b as u64)), + ("c", Json::u(self.c as u64)), + ("imm", Json::i(self.imm)), + ]) + } + pub fn from_json(j: &Json) -> Result { + Ok(RuneTokenWire { + op: j.field("op")?.as_u8().ok_or(JsonError::Type("op".into(), "u8"))?, + a: j.field("a")?.as_u8().ok_or(JsonError::Type("a".into(), "u8"))?, + b: j.field("b")?.as_u8().ok_or(JsonError::Type("b".into(), "u8"))?, + c: j.field("c")?.as_u8().ok_or(JsonError::Type("c".into(), "u8"))?, + imm: j.i64_field("imm")?, + }) + } + pub fn into_token(self) -> rune_ir::RuneToken { + rune_ir::RuneToken { + op: rune_ir::Op::from_u8(self.op), + a: self.a, + b: self.b, + c: self.c, + imm: self.imm, + } + } + pub fn from_token(t: &rune_ir::RuneToken) -> Self { + RuneTokenWire { op: t.op.to_u8(), a: t.a, b: t.b, c: t.c, imm: t.imm } + } +} + +// --------------------------------------------------------------------------- +// Player intent / actions (client -> server only). +// --------------------------------------------------------------------------- + +/// A single per-turn action chosen by a player. The browser only ever sends +/// *intent*; the server is the sole authority on the outcome. +#[derive(Clone, PartialEq, Debug)] +pub enum Action { + /// Step one cell on the arena grid (`dx`,`dy` in {-1,0,1}). + Move { dx: i32, dy: i32 }, + /// Inspect a target entity (request diagnostics about it). + Inspect { target: u32 }, + /// Cast the player's currently-edited rune program. + Cast, + /// Basic stick/melee attack against a target entity. + Attack { target: u32 }, + /// Pass the turn. + Wait, +} + +impl Action { + pub fn to_json(&self) -> Json { + match self { + Action::Move { dx, dy } => Json::obj(vec![ + ("kind", Json::s("move")), + ("dx", Json::i(*dx as i64)), + ("dy", Json::i(*dy as i64)), + ]), + Action::Inspect { target } => Json::obj(vec![ + ("kind", Json::s("inspect")), + ("target", Json::u(*target as u64)), + ]), + Action::Cast => Json::obj(vec![("kind", Json::s("cast"))]), + Action::Attack { target } => Json::obj(vec![ + ("kind", Json::s("attack")), + ("target", Json::u(*target as u64)), + ]), + Action::Wait => Json::obj(vec![("kind", Json::s("wait"))]), + } + } + pub fn from_json(j: &Json) -> Result { + match j.str_field("kind")?.as_str() { + "move" => { + let dx = j.i64_field("dx")? as i32; + let dy = j.i64_field("dy")? as i32; + // Clamp to legal step range so a hostile client cannot teleport. + Ok(Action::Move { dx: dx.clamp(-1, 1), dy: dy.clamp(-1, 1) }) + } + "inspect" => Ok(Action::Inspect { target: j.u64_field("target")? as u32 }), + "cast" => Ok(Action::Cast), + "attack" => Ok(Action::Attack { target: j.u64_field("target")? as u32 }), + "wait" => Ok(Action::Wait), + other => Err(JsonError::Parse(format!("unknown action kind '{other}'"))), + } + } +} + +// --------------------------------------------------------------------------- +// ClientMessage. +// --------------------------------------------------------------------------- + +/// Everything a browser may send. Intent only — never game truth. +#[derive(Clone, PartialEq, Debug)] +pub enum ClientMessage { + /// Request to join (or create) a match. `name` is a dev/anonymous label. + JoinMatch { name: String, match_id: Option }, + /// Submit this turn's action for the current turn number. + SubmitTurn { turn: u64, action: Action }, + /// Replace the player's editable rune program (library/editor state). + EditRuneProgram { tokens: Vec }, + /// Ask for diagnostics about a target entity. + InspectTarget { target: u32 }, + /// Ask the server to stream the recorded replay for a match. + RequestReplay { match_id: MatchId }, + /// Liveness ping. + Ping { nonce: u64 }, +} + +impl ClientMessage { + fn type_tag(&self) -> &'static str { + match self { + ClientMessage::JoinMatch { .. } => "JoinMatch", + ClientMessage::SubmitTurn { .. } => "SubmitTurn", + ClientMessage::EditRuneProgram { .. } => "EditRuneProgram", + ClientMessage::InspectTarget { .. } => "InspectTarget", + ClientMessage::RequestReplay { .. } => "RequestReplay", + ClientMessage::Ping { .. } => "Ping", + } + } + + fn body(&self) -> Json { + match self { + ClientMessage::JoinMatch { name, match_id } => Json::obj(vec![ + ("name", Json::s(name.clone())), + ( + "match_id", + match match_id { + Some(m) => Json::u(m.0), + None => Json::Null, + }, + ), + ]), + ClientMessage::SubmitTurn { turn, action } => Json::obj(vec![ + ("turn", Json::u(*turn)), + ("action", action.to_json()), + ]), + ClientMessage::EditRuneProgram { tokens } => Json::obj(vec![( + "tokens", + Json::Arr(tokens.iter().map(|t| t.to_json()).collect()), + )]), + ClientMessage::InspectTarget { target } => { + Json::obj(vec![("target", Json::u(*target as u64))]) + } + ClientMessage::RequestReplay { match_id } => { + Json::obj(vec![("match_id", Json::u(match_id.0))]) + } + ClientMessage::Ping { nonce } => Json::obj(vec![("nonce", Json::u(*nonce))]), + } + } + + /// Canonical envelope: `{v, type, body}`. + pub fn to_json(&self) -> Json { + Json::obj(vec![ + ("v", Json::u(PROTOCOL_VERSION as u64)), + ("type", Json::s(self.type_tag())), + ("body", self.body()), + ]) + } + + pub fn encode(&self) -> String { + self.to_json().to_compact() + } + + /// Decode a wire string. Total: never panics. Rejects version mismatch. + pub fn decode(raw: &str) -> Result { + let j = parse(raw)?; + Self::from_json(&j) + } + + pub fn from_json(j: &Json) -> Result { + let v = j.u64_field("v")?; + if v != PROTOCOL_VERSION as u64 { + return Err(JsonError::Parse(format!( + "protocol version mismatch: got {v}, expected {PROTOCOL_VERSION}" + ))); + } + let ty = j.str_field("type")?; + let body = j.field("body")?; + match ty.as_str() { + "JoinMatch" => { + let name = body.str_field("name")?; + let match_id = match body.get("match_id") { + Some(Json::Null) | None => None, + Some(other) => other.as_u64().map(MatchId), + }; + Ok(ClientMessage::JoinMatch { name, match_id }) + } + "SubmitTurn" => { + let turn = body.u64_field("turn")?; + let action = Action::from_json(body.field("action")?)?; + Ok(ClientMessage::SubmitTurn { turn, action }) + } + "EditRuneProgram" => { + let arr = body.arr_field("tokens")?; + // Bound the program length defensively. + if arr.len() > MAX_PROGRAM_TOKENS { + return Err(JsonError::Parse("program too long".into())); + } + let mut tokens = Vec::with_capacity(arr.len()); + for t in arr { + tokens.push(RuneTokenWire::from_json(t)?); + } + Ok(ClientMessage::EditRuneProgram { tokens }) + } + "InspectTarget" => Ok(ClientMessage::InspectTarget { + target: body.u64_field("target")? as u32, + }), + "RequestReplay" => Ok(ClientMessage::RequestReplay { + match_id: MatchId(body.u64_field("match_id")?), + }), + "Ping" => Ok(ClientMessage::Ping { nonce: body.u64_field("nonce")? }), + other => Err(JsonError::Parse(format!("unknown client message '{other}'"))), + } + } +} + +/// Hard upper bound on a submitted rune program, enforced at decode. +pub const MAX_PROGRAM_TOKENS: usize = 256; + +// --------------------------------------------------------------------------- +// Visibility / knowledge layer (Phase F). This is filtered *game state*, not UI +// notes: the client renders exactly what the server says is observable, and the +// hidden ground truth never crosses the wire. +// --------------------------------------------------------------------------- + +/// How well a piece of state is known to the observing player. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Knowledge { + Known, + Unknown, + Suspected, + Contradicted, + NewlyObserved, +} + +impl Knowledge { + pub fn name(self) -> &'static str { + match self { + Knowledge::Known => "known", + Knowledge::Unknown => "unknown", + Knowledge::Suspected => "suspected", + Knowledge::Contradicted => "contradicted", + Knowledge::NewlyObserved => "newly_observed", + } + } + pub fn from_str(s: &str) -> Option { + Some(match s { + "known" => Knowledge::Known, + "unknown" => Knowledge::Unknown, + "suspected" => Knowledge::Suspected, + "contradicted" => Knowledge::Contradicted, + "newly_observed" => Knowledge::NewlyObserved, + _ => return None, + }) + } +} + +/// One domain as the player observes it. Only *visible observed* lanes carry a +/// value; non-visible observed lanes and **all hidden lanes** are redacted. +#[derive(Clone, PartialEq, Debug)] +pub struct VisibleDomain { + pub index: u8, + pub name: String, + /// `Some(v)` for a visible observed lane, `None` for a redacted lane. + pub observed: Vec>, + /// Per-lane knowledge tag. + pub knowledge: Vec, +} + +impl VisibleDomain { + fn to_json(&self) -> Json { + Json::obj(vec![ + ("index", Json::u(self.index as u64)), + ("name", Json::s(self.name.clone())), + ( + "observed", + Json::Arr( + self.observed + .iter() + .map(|o| match o { + Some(v) => Json::i(*v), + None => Json::Null, + }) + .collect(), + ), + ), + ( + "knowledge", + Json::Arr(self.knowledge.iter().map(|k| Json::s(k.name())).collect()), + ), + ]) + } + fn from_json(j: &Json) -> Result { + let observed = j + .arr_field("observed")? + .iter() + .map(|v| match v { + Json::Null => None, + other => other.as_i64(), + }) + .collect(); + let knowledge = j + .arr_field("knowledge")? + .iter() + .filter_map(|v| v.as_str().and_then(Knowledge::from_str)) + .collect(); + Ok(VisibleDomain { + index: j.field("index")?.as_u8().ok_or(JsonError::Type("index".into(), "u8"))?, + name: j.str_field("name")?, + observed, + knowledge, + }) + } +} + +/// An entity (player/dummy) as seen on the arena. +#[derive(Clone, PartialEq, Debug)] +pub struct VisibleEntity { + pub id: u32, + pub name: String, + pub x: i32, + pub y: i32, + pub hp: i32, + pub is_self: bool, + pub alive: bool, +} + +impl VisibleEntity { + fn to_json(&self) -> Json { + Json::obj(vec![ + ("id", Json::u(self.id as u64)), + ("name", Json::s(self.name.clone())), + ("x", Json::i(self.x as i64)), + ("y", Json::i(self.y as i64)), + ("hp", Json::i(self.hp as i64)), + ("is_self", Json::Bool(self.is_self)), + ("alive", Json::Bool(self.alive)), + ]) + } + fn from_json(j: &Json) -> Result { + Ok(VisibleEntity { + id: j.u64_field("id")? as u32, + name: j.str_field("name")?, + x: j.i64_field("x")? as i32, + y: j.i64_field("y")? as i32, + hp: j.i64_field("hp")? as i32, + is_self: j.field("is_self")?.as_bool().unwrap_or(false), + alive: j.field("alive")?.as_bool().unwrap_or(true), + }) + } +} + +/// The server's filtered view of the world for one player (Phase F). +#[derive(Clone, PartialEq, Debug)] +pub struct VisibleWorldSnapshot { + pub turn: u64, + pub arena_w: i32, + pub arena_h: i32, + pub observed_domains: Vec, + pub observed_entities: Vec, + /// Short human-readable environment descriptors (arena conditions). + pub observed_environment: Vec, + /// Prior-turn outcome summaries the player has already witnessed. + pub known_history: Vec, + /// Inferred (suspected) markers, e.g. "domain 3 likely volatile". + pub inferred_markers: Vec, + /// Count of state values deliberately withheld (hidden lanes + masked + /// observed lanes). Proof that hidden state exists and is *not* sent. + pub hidden_state_redactions: u32, +} + +impl VisibleWorldSnapshot { + pub fn to_json(&self) -> Json { + Json::obj(vec![ + ("turn", Json::u(self.turn)), + ("arena_w", Json::i(self.arena_w as i64)), + ("arena_h", Json::i(self.arena_h as i64)), + ( + "observed_domains", + Json::Arr(self.observed_domains.iter().map(|d| d.to_json()).collect()), + ), + ( + "observed_entities", + Json::Arr(self.observed_entities.iter().map(|e| e.to_json()).collect()), + ), + ( + "observed_environment", + Json::Arr(self.observed_environment.iter().map(|s| Json::s(s.clone())).collect()), + ), + ( + "known_history", + Json::Arr(self.known_history.iter().map(|s| Json::s(s.clone())).collect()), + ), + ( + "inferred_markers", + Json::Arr(self.inferred_markers.iter().map(|s| Json::s(s.clone())).collect()), + ), + ("hidden_state_redactions", Json::u(self.hidden_state_redactions as u64)), + ]) + } + pub fn from_json(j: &Json) -> Result { + let observed_domains = j + .arr_field("observed_domains")? + .iter() + .map(VisibleDomain::from_json) + .collect::>()?; + let observed_entities = j + .arr_field("observed_entities")? + .iter() + .map(VisibleEntity::from_json) + .collect::>()?; + let strs = |key| -> Result, JsonError> { + Ok(j.arr_field(key)? + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect()) + }; + Ok(VisibleWorldSnapshot { + turn: j.u64_field("turn")?, + arena_w: j.i64_field("arena_w")? as i32, + arena_h: j.i64_field("arena_h")? as i32, + observed_domains, + observed_entities, + observed_environment: strs("observed_environment")?, + known_history: strs("known_history")?, + inferred_markers: strs("inferred_markers")?, + hidden_state_redactions: j.u64_field("hidden_state_redactions")? as u32, + }) + } +} + +/// Player-facing diagnostics for a rune program (Phase D). Strictly *observed* +/// claims — never "guaranteed damage" or full hidden state. +#[derive(Clone, PartialEq, Debug, Default)] +pub struct RuneDiagnostics { + pub known_reads: Vec, + pub known_writes: Vec, + pub observed_risks: Vec, + pub unknown_listeners: u32, + pub previous_outcomes: Vec, +} + +impl RuneDiagnostics { + fn to_json(&self) -> Json { + Json::obj(vec![ + ("known_reads", Json::Arr(self.known_reads.iter().map(|s| Json::s(s.clone())).collect())), + ("known_writes", Json::Arr(self.known_writes.iter().map(|s| Json::s(s.clone())).collect())), + ("observed_risks", Json::Arr(self.observed_risks.iter().map(|s| Json::s(s.clone())).collect())), + ("unknown_listeners", Json::u(self.unknown_listeners as u64)), + ("previous_outcomes", Json::Arr(self.previous_outcomes.iter().map(|s| Json::s(s.clone())).collect())), + ]) + } + fn from_json(j: &Json) -> Result { + let strs = |key| -> Vec { + j.get(key) + .and_then(|v| v.as_arr()) + .map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default() + }; + Ok(RuneDiagnostics { + known_reads: strs("known_reads"), + known_writes: strs("known_writes"), + observed_risks: strs("observed_risks"), + unknown_listeners: j.get("unknown_listeners").and_then(|v| v.as_u64()).unwrap_or(0) as u32, + previous_outcomes: strs("previous_outcomes"), + }) + } +} + +/// One recorded turn in a replay stream (Phase G). +#[derive(Clone, PartialEq, Debug)] +pub struct ReplayTurn { + pub turn: u64, + /// `(player_id, action)` pairs applied this turn, in canonical order. + pub inputs: Vec<(u32, Action)>, + /// The runtime canonical replay hash produced this turn. + pub runtime_hash: String, +} + +impl ReplayTurn { + fn to_json(&self) -> Json { + Json::obj(vec![ + ("turn", Json::u(self.turn)), + ( + "inputs", + Json::Arr( + self.inputs + .iter() + .map(|(pid, a)| { + Json::obj(vec![("player", Json::u(*pid as u64)), ("action", a.to_json())]) + }) + .collect(), + ), + ), + ("runtime_hash", Json::s(self.runtime_hash.clone())), + ]) + } + fn from_json(j: &Json) -> Result { + let inputs = j + .arr_field("inputs")? + .iter() + .map(|e| { + let pid = e.u64_field("player")? as u32; + let a = Action::from_json(e.field("action")?)?; + Ok((pid, a)) + }) + .collect::>()?; + Ok(ReplayTurn { + turn: j.u64_field("turn")?, + inputs, + runtime_hash: j.str_field("runtime_hash")?, + }) + } +} + +// --------------------------------------------------------------------------- +// ServerMessage. +// --------------------------------------------------------------------------- + +/// Everything the server may send. Every variant is hashable; the browser can +/// verify it matches a recorded replay. +#[derive(Clone, PartialEq, Debug)] +pub enum ServerMessage { + /// Assigned identity + match parameters on join. + MatchState { + match_id: MatchId, + player_id: PlayerId, + turn: u64, + snapshot: VisibleWorldSnapshot, + }, + /// A new turn has begun; `deadline_ms` is the wall-clock budget. + TurnStarted { turn: u64, deadline_ms: u64 }, + /// A turn resolved authoritatively. Carries the runtime replay hash so the + /// client can verify determinism. + TurnResolved { + turn: u64, + snapshot: VisibleWorldSnapshot, + runtime_hash: String, + events: Vec, + }, + /// Result of an inspection request (filtered observations of a target). + ObservationResult { target: u32, diagnostics: RuneDiagnostics }, + /// Validation feedback for a client packet (accepted/rejected + why). + ValidationReport { accepted: bool, detail: String, diagnostics: RuneDiagnostics }, + /// One chunk of a replay stream. + ReplayChunk { + match_id: MatchId, + seed: u64, + index: u32, + total: u32, + turns: Vec, + final_hash: String, + }, + /// A protocol/transport error that is not tied to a specific submission. + ErrorEvent { code: String, detail: String }, +} + +impl ServerMessage { + fn type_tag(&self) -> &'static str { + match self { + ServerMessage::MatchState { .. } => "MatchState", + ServerMessage::TurnStarted { .. } => "TurnStarted", + ServerMessage::TurnResolved { .. } => "TurnResolved", + ServerMessage::ObservationResult { .. } => "ObservationResult", + ServerMessage::ValidationReport { .. } => "ValidationReport", + ServerMessage::ReplayChunk { .. } => "ReplayChunk", + ServerMessage::ErrorEvent { .. } => "ErrorEvent", + } + } + + fn body(&self) -> Json { + match self { + ServerMessage::MatchState { match_id, player_id, turn, snapshot } => Json::obj(vec![ + ("match_id", Json::u(match_id.0)), + ("player_id", Json::u(player_id.0 as u64)), + ("turn", Json::u(*turn)), + ("snapshot", snapshot.to_json()), + ]), + ServerMessage::TurnStarted { turn, deadline_ms } => Json::obj(vec![ + ("turn", Json::u(*turn)), + ("deadline_ms", Json::u(*deadline_ms)), + ]), + ServerMessage::TurnResolved { turn, snapshot, runtime_hash, events } => Json::obj(vec![ + ("turn", Json::u(*turn)), + ("snapshot", snapshot.to_json()), + ("runtime_hash", Json::s(runtime_hash.clone())), + ("events", Json::Arr(events.iter().map(|s| Json::s(s.clone())).collect())), + ]), + ServerMessage::ObservationResult { target, diagnostics } => Json::obj(vec![ + ("target", Json::u(*target as u64)), + ("diagnostics", diagnostics.to_json()), + ]), + ServerMessage::ValidationReport { accepted, detail, diagnostics } => Json::obj(vec![ + ("accepted", Json::Bool(*accepted)), + ("detail", Json::s(detail.clone())), + ("diagnostics", diagnostics.to_json()), + ]), + ServerMessage::ReplayChunk { match_id, seed, index, total, turns, final_hash } => { + Json::obj(vec![ + ("match_id", Json::u(match_id.0)), + ("seed", Json::u(*seed)), + ("index", Json::u(*index as u64)), + ("total", Json::u(*total as u64)), + ("turns", Json::Arr(turns.iter().map(|t| t.to_json()).collect())), + ("final_hash", Json::s(final_hash.clone())), + ]) + } + ServerMessage::ErrorEvent { code, detail } => Json::obj(vec![ + ("code", Json::s(code.clone())), + ("detail", Json::s(detail.clone())), + ]), + } + } + + pub fn to_json(&self) -> Json { + Json::obj(vec![ + ("v", Json::u(PROTOCOL_VERSION as u64)), + ("type", Json::s(self.type_tag())), + ("body", self.body()), + ]) + } + + pub fn encode(&self) -> String { + self.to_json().to_compact() + } + + /// Stable content hash over the canonical serialization. Because object + /// keys are sorted and there is no whitespace, identical messages hash + /// identically across machines — this is how replays are verified. + pub fn content_hash(&self) -> Hash { + let mut h = Hasher::new(); + h.write_tag("server-message"); + h.write_bytes(self.encode().as_bytes()); + h.finish() + } + + pub fn decode(raw: &str) -> Result { + let j = parse(raw)?; + Self::from_json(&j) + } + + pub fn from_json(j: &Json) -> Result { + let v = j.u64_field("v")?; + if v != PROTOCOL_VERSION as u64 { + return Err(JsonError::Parse(format!( + "protocol version mismatch: got {v}, expected {PROTOCOL_VERSION}" + ))); + } + let ty = j.str_field("type")?; + let body = j.field("body")?; + match ty.as_str() { + "MatchState" => Ok(ServerMessage::MatchState { + match_id: MatchId(body.u64_field("match_id")?), + player_id: PlayerId(body.u64_field("player_id")? as u32), + turn: body.u64_field("turn")?, + snapshot: VisibleWorldSnapshot::from_json(body.field("snapshot")?)?, + }), + "TurnStarted" => Ok(ServerMessage::TurnStarted { + turn: body.u64_field("turn")?, + deadline_ms: body.u64_field("deadline_ms")?, + }), + "TurnResolved" => Ok(ServerMessage::TurnResolved { + turn: body.u64_field("turn")?, + snapshot: VisibleWorldSnapshot::from_json(body.field("snapshot")?)?, + runtime_hash: body.str_field("runtime_hash")?, + events: body + .arr_field("events")? + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(), + }), + "ObservationResult" => Ok(ServerMessage::ObservationResult { + target: body.u64_field("target")? as u32, + diagnostics: RuneDiagnostics::from_json(body.field("diagnostics")?)?, + }), + "ValidationReport" => Ok(ServerMessage::ValidationReport { + accepted: body.field("accepted")?.as_bool().unwrap_or(false), + detail: body.str_field("detail")?, + diagnostics: RuneDiagnostics::from_json(body.field("diagnostics")?)?, + }), + "ReplayChunk" => Ok(ServerMessage::ReplayChunk { + match_id: MatchId(body.u64_field("match_id")?), + seed: body.u64_field("seed")?, + index: body.u64_field("index")? as u32, + total: body.u64_field("total")? as u32, + turns: body + .arr_field("turns")? + .iter() + .map(ReplayTurn::from_json) + .collect::>()?, + final_hash: body.str_field("final_hash")?, + }), + "ErrorEvent" => Ok(ServerMessage::ErrorEvent { + code: body.str_field("code")?, + detail: body.str_field("detail")?, + }), + other => Err(JsonError::Parse(format!("unknown server message '{other}'"))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_snapshot() -> VisibleWorldSnapshot { + VisibleWorldSnapshot { + turn: 3, + arena_w: 8, + arena_h: 8, + observed_domains: vec![VisibleDomain { + index: 0, + name: "aether".into(), + observed: vec![Some(1), None, Some(-4), None], + knowledge: vec![ + Knowledge::Known, + Knowledge::Unknown, + Knowledge::NewlyObserved, + Knowledge::Unknown, + ], + }], + observed_entities: vec![VisibleEntity { + id: 1, + name: "you".into(), + x: 2, + y: 3, + hp: 30, + is_self: true, + alive: true, + }], + observed_environment: vec!["calm".into()], + known_history: vec!["turn 2: you moved".into()], + inferred_markers: vec!["domain 4 likely volatile".into()], + hidden_state_redactions: 18, + } + } + + #[test] + fn client_messages_roundtrip() { + let msgs = vec![ + ClientMessage::JoinMatch { name: "dev".into(), match_id: None }, + ClientMessage::JoinMatch { name: "dev".into(), match_id: Some(MatchId(9)) }, + ClientMessage::SubmitTurn { turn: 4, action: Action::Move { dx: 1, dy: -1 } }, + ClientMessage::SubmitTurn { turn: 4, action: Action::Cast }, + ClientMessage::SubmitTurn { turn: 4, action: Action::Attack { target: 2 } }, + ClientMessage::EditRuneProgram { + tokens: vec![RuneTokenWire { op: 0, a: 1, b: 2, c: 3, imm: -7 }], + }, + ClientMessage::InspectTarget { target: 5 }, + ClientMessage::RequestReplay { match_id: MatchId(42) }, + ClientMessage::Ping { nonce: 123 }, + ]; + for m in msgs { + let s = m.encode(); + assert_eq!(ClientMessage::decode(&s).unwrap(), m, "roundtrip failed for {m:?}"); + } + } + + #[test] + fn server_messages_roundtrip_and_hash_is_stable() { + let msgs = vec![ + ServerMessage::MatchState { + match_id: MatchId(1), + player_id: PlayerId(1), + turn: 0, + snapshot: sample_snapshot(), + }, + ServerMessage::TurnStarted { turn: 1, deadline_ms: 5000 }, + ServerMessage::TurnResolved { + turn: 1, + snapshot: sample_snapshot(), + runtime_hash: "deadbeefcafef00d".into(), + events: vec!["you cast".into(), "dummy took 4".into()], + }, + ServerMessage::ValidationReport { + accepted: false, + detail: "late".into(), + diagnostics: RuneDiagnostics::default(), + }, + ]; + for m in msgs { + let s = m.encode(); + let back = ServerMessage::decode(&s).unwrap(); + assert_eq!(back, m); + // Hash is a pure function of the canonical bytes. + assert_eq!(m.content_hash(), back.content_hash()); + } + } + + #[test] + fn version_mismatch_is_rejected() { + let mut j = ClientMessage::Ping { nonce: 1 }.to_json(); + if let Json::Obj(ref mut m) = j { + m.insert("v".into(), Json::u(999)); + } + assert!(ClientMessage::from_json(&j).is_err()); + } + + #[test] + fn decode_is_total_on_garbage() { + for raw in ["", "{}", "null", "{\"v\":1}", "{\"v\":1,\"type\":\"Nope\",\"body\":{}}"] { + // Must be Err, never a panic. + assert!(ClientMessage::decode(raw).is_err()); + } + } +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml new file mode 100644 index 0000000..17d06d4 --- /dev/null +++ b/crates/server/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "server" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "magicka-server" +path = "src/main.rs" + +[dependencies] +protocol = { path = "../protocol" } +game_runtime = { path = "../game_runtime" } +world_model = { path = "../world_model" } +web_client = { path = "../web_client" } diff --git a/crates/server/src/http.rs b/crates/server/src/http.rs new file mode 100644 index 0000000..5c02da4 --- /dev/null +++ b/crates/server/src/http.rs @@ -0,0 +1,113 @@ +//! Minimal HTTP/1.1 request parsing — only enough to tell a static GET from a +//! WebSocket upgrade and to read the upgrade key. Tolerant and total: a +//! malformed request yields `None`, never a panic. + +use std::collections::BTreeMap; +use std::io::{self, BufRead}; + +/// A parsed request head. +pub struct Request { + pub method: String, + pub path: String, + pub headers: BTreeMap, +} + +impl Request { + pub fn header(&self, name: &str) -> Option<&str> { + self.headers.get(&name.to_ascii_lowercase()).map(|s| s.as_str()) + } + + /// True if this is a WebSocket upgrade request. + pub fn is_websocket_upgrade(&self) -> bool { + self.header("upgrade") + .map(|v| v.eq_ignore_ascii_case("websocket")) + .unwrap_or(false) + && self + .header("connection") + .map(|v| v.to_ascii_lowercase().contains("upgrade")) + .unwrap_or(false) + } + + pub fn websocket_key(&self) -> Option<&str> { + self.header("sec-websocket-key") + } +} + +/// Read and parse the request head from a buffered reader. Returns `Ok(None)` +/// on a clean EOF before any bytes. +pub fn read_request(r: &mut R) -> io::Result> { + let mut line = String::new(); + let n = r.read_line(&mut line)?; + if n == 0 { + return Ok(None); + } + let mut parts = line.trim_end().split_whitespace(); + let method = match parts.next() { + Some(m) => m.to_string(), + None => return Ok(None), + }; + let path = parts.next().unwrap_or("/").to_string(); + + let mut headers = BTreeMap::new(); + loop { + let mut h = String::new(); + let hn = r.read_line(&mut h)?; + if hn == 0 { + break; + } + let trimmed = h.trim_end(); + if trimmed.is_empty() { + break; + } + if let Some((k, v)) = trimmed.split_once(':') { + headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string()); + } + // Bound header count defensively. + if headers.len() > 100 { + break; + } + } + Ok(Some(Request { method, path, headers })) +} + +/// Build the 101 Switching Protocols handshake response. +pub fn handshake_response(accept: &str) -> String { + format!( + "HTTP/1.1 101 Switching Protocols\r\n\ + Upgrade: websocket\r\n\ + Connection: Upgrade\r\n\ + Sec-WebSocket-Accept: {accept}\r\n\r\n" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::BufReader; + + #[test] + fn parses_websocket_upgrade() { + let raw = "GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: abc\r\n\r\n"; + let mut r = BufReader::new(raw.as_bytes()); + let req = read_request(&mut r).unwrap().unwrap(); + assert_eq!(req.method, "GET"); + assert_eq!(req.path, "/ws"); + assert!(req.is_websocket_upgrade()); + assert_eq!(req.websocket_key(), Some("abc")); + } + + #[test] + fn parses_plain_get() { + let raw = "GET /app.js HTTP/1.1\r\nHost: x\r\n\r\n"; + let mut r = BufReader::new(raw.as_bytes()); + let req = read_request(&mut r).unwrap().unwrap(); + assert!(!req.is_websocket_upgrade()); + assert_eq!(req.path, "/app.js"); + } + + #[test] + fn empty_input_is_none() { + let mut r = BufReader::new("".as_bytes()); + assert!(read_request(&mut r).unwrap().is_none()); + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs new file mode 100644 index 0000000..a7bab62 --- /dev/null +++ b/crates/server/src/lib.rs @@ -0,0 +1,606 @@ +//! `server` — the authoritative match server (Phase B of `plan2.md`), built on +//! `std::net` with no external crates. +//! +//! Responsibilities the server owns: match state, the turn timer, collecting +//! submitted actions, driving resolution through [`game_runtime`], visibility +//! filtering, replay recording, and disconnect handling. The browser is served +//! the embedded client and then speaks the `protocol` over a WebSocket. +//! +//! Authority guarantees enforced here and covered by tests: +//! * **No panic on bad input** — every client packet is decoded with the total +//! `protocol` decoder; a failure becomes a `ValidationReport`, never a crash. +//! * **Late input rejected deterministically** — a `SubmitTurn` for any turn +//! other than the live one, or after the deadline, is rejected with a stable +//! reason. +//! * **Disconnect cannot corrupt a match** — a dropped connection simply stops +//! submitting; that player's turns default to `Wait` and the match continues. +//! * **Client cannot mutate hidden state** — only intent is accepted, and the +//! hidden ground truth is never serialized to a client. + +pub mod http; +pub mod ws; + +use std::collections::BTreeMap; +use std::io::{BufReader, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::mpsc::{self, Sender}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use game_runtime::{duel_roster, solo_roster, Match}; +use protocol::{ + Action, ClientMessage, MatchId, PlayerId, ReplayTurn, RuneDiagnostics, ServerMessage, +}; + +/// How long the timer thread sleeps between ticks. +const TICK: Duration = Duration::from_millis(40); +/// Replay turns per `ReplayChunk`. +const REPLAY_CHUNK: usize = 16; + +/// An outbound item for a single connection's writer thread. Routing every +/// write through one thread keeps frames from interleaving. +enum Out { + Text(String), + Pong(Vec), + Close, +} + +/// One live match plus its scheduling and connection state. +struct Session { + m: Match, + turn_len: Duration, + deadline: Instant, + pending: BTreeMap, + conns: BTreeMap>, + /// Entity ids that are human-controlled (vs. a dummy). + human_slots: Vec, +} + +impl Session { + fn deadline_ms(&self, now: Instant) -> u64 { + self.deadline.saturating_duration_since(now).as_millis() as u64 + } + + fn snapshot_msg(&self, player: u32) -> ServerMessage { + ServerMessage::MatchState { + match_id: self.m.id, + player_id: PlayerId(player), + turn: self.m.turn, + snapshot: self.m.visible_for(player), + } + } +} + +/// The shared manager: all matches, behind one mutex. +pub struct Manager { + sessions: BTreeMap, + next_auto_id: u64, + turn_ms: u64, +} + +/// Result of a successful join. +struct JoinOk { + match_id: MatchId, + player_id: u32, + initial: ServerMessage, + turn_started: ServerMessage, +} + +impl Manager { + fn new(turn_ms: u64) -> Manager { + Manager { sessions: BTreeMap::new(), next_auto_id: 1, turn_ms } + } + + fn turn_len(&self) -> Duration { + Duration::from_millis(self.turn_ms) + } + + /// Join (or create) a match. `requested = None` creates a fresh solo match + /// (player + dummy). `requested = Some(id)` joins an existing duel by id, or + /// creates that duel and takes the first human slot. + fn join( + &mut self, + name: &str, + requested: Option, + tx: Sender, + ) -> Result { + let now = Instant::now(); + let turn_len = self.turn_len(); + let key = match requested { + Some(m) => m.0, + None => { + let id = self.next_auto_id; + self.next_auto_id += 1; + id + } + }; + + // Create the session if absent. + if !self.sessions.contains_key(&key) { + let (roster, human_slots) = if requested.is_some() { + (duel_roster(name, "opponent"), vec![1u32, 2]) + } else { + (solo_roster(name), vec![1u32]) + }; + let m = Match::new(MatchId(key), key, roster); + self.sessions.insert( + key, + Session { + m, + turn_len, + deadline: now + turn_len, + pending: BTreeMap::new(), + conns: BTreeMap::new(), + human_slots, + }, + ); + } + + let session = self.sessions.get_mut(&key).unwrap(); + // Find the first human slot without a live connection. + let slot = session + .human_slots + .iter() + .copied() + .find(|s| !session.conns.contains_key(s)) + .ok_or_else(|| "match is full".to_string())?; + + // Adopt the player's chosen name on their entity. + if let Some(e) = session.m.entity_mut(slot) { + e.name = name.to_string(); + } + session.conns.insert(slot, tx); + + Ok(JoinOk { + match_id: MatchId(key), + player_id: slot, + initial: session.snapshot_msg(slot), + turn_started: ServerMessage::TurnStarted { + turn: session.m.turn, + deadline_ms: session.deadline_ms(now), + }, + }) + } + + /// Queue a turn submission. Rejects late / wrong-turn submissions + /// deterministically. + fn submit( + &mut self, + match_key: u64, + player: u32, + turn: u64, + action: Action, + ) -> Result<(), String> { + let now = Instant::now(); + let session = self + .sessions + .get_mut(&match_key) + .ok_or_else(|| "no such match".to_string())?; + if session.m.finished { + return Err("match has ended".to_string()); + } + if turn != session.m.turn { + return Err(format!( + "wrong turn: submitted {}, live turn is {}", + turn, session.m.turn + )); + } + if now > session.deadline { + return Err("late: turn deadline has passed".to_string()); + } + session.pending.insert(player, action); + Ok(()) + } + + fn set_program( + &mut self, + match_key: u64, + player: u32, + tokens: Vec, + ) -> Result { + let session = self + .sessions + .get_mut(&match_key) + .ok_or_else(|| "no such match".to_string())?; + session.m.set_program(player, tokens); + Ok(session.m.diagnostics_for_player(player)) + } + + fn inspect(&self, match_key: u64, target: u32) -> Result { + let session = self + .sessions + .get(&match_key) + .ok_or_else(|| "no such match".to_string())?; + let diagnostics = session.m.diagnostics_for_player(target); + Ok(ServerMessage::ObservationResult { target, diagnostics }) + } + + fn replay_chunks(&self, match_key: u64) -> Result, String> { + let session = self + .sessions + .get(&match_key) + .ok_or_else(|| "no such match".to_string())?; + let m = &session.m; + let turns: Vec = m + .replay + .turns + .iter() + .map(|rt| ReplayTurn { + turn: rt.turn, + inputs: rt + .inputs + .iter() + .map(|i| (i.player, i.action.clone())) + .collect(), + runtime_hash: format!("{}", rt.turn_hash), + }) + .collect(); + let final_hash = m.final_hash_hex(); + let chunks: Vec<&[ReplayTurn]> = if turns.is_empty() { + vec![&[]] + } else { + turns.chunks(REPLAY_CHUNK).collect() + }; + let total = chunks.len() as u32; + Ok(chunks + .into_iter() + .enumerate() + .map(|(i, c)| ServerMessage::ReplayChunk { + match_id: m.id, + seed: m.seed, + index: i as u32, + total, + turns: c.to_vec(), + final_hash: final_hash.clone(), + }) + .collect()) + } + + /// Advance any match whose deadline has elapsed. Runs under the lock; sends + /// are non-blocking on unbounded channels. + fn tick(&mut self, now: Instant) { + let mut empty: Vec = Vec::new(); + for (key, session) in self.sessions.iter_mut() { + if session.conns.is_empty() { + empty.push(*key); + continue; + } + if session.m.finished || now < session.deadline { + continue; + } + // Resolve the turn from queued submissions. + let subs: Vec<(u32, Action)> = + session.pending.iter().map(|(p, a)| (*p, a.clone())).collect(); + let events = session.m.resolve_turn(&subs); + session.pending.clear(); + let runtime_hash = session.m.last_turn_hash_hex(); + let turn = session.m.turn; + // Broadcast the resolved state, filtered per player. + for (pid, tx) in session.conns.iter() { + let msg = ServerMessage::TurnResolved { + turn, + snapshot: session.m.visible_for(*pid), + runtime_hash: runtime_hash.clone(), + events: events.clone(), + }; + let _ = tx.send(Out::Text(msg.encode())); + } + // Open the next turn unless the match just ended. + if !session.m.finished { + session.deadline = now + session.turn_len; + let ts = ServerMessage::TurnStarted { + turn, + deadline_ms: session.turn_len.as_millis() as u64, + }; + for tx in session.conns.values() { + let _ = tx.send(Out::Text(ts.encode())); + } + } + } + // Drop sessions nobody is connected to (replay no longer reachable). + for key in empty { + self.sessions.remove(&key); + } + } + + fn disconnect(&mut self, match_key: u64, player: u32) { + if let Some(session) = self.sessions.get_mut(&match_key) { + session.conns.remove(&player); + if session.conns.is_empty() { + self.sessions.remove(&match_key); + } + } + } +} + +/// Configuration. +pub struct Config { + pub turn_ms: u64, +} + +impl Config { + pub fn from_env() -> Config { + let turn_ms = std::env::var("MAGICKA_TURN_MS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5000); + Config { turn_ms } + } +} + +/// Start the server on `addr`. Returns the bound address (useful when binding +/// to port 0 in tests). Spawns the accept loop and the turn-timer thread as +/// detached background threads. +pub fn serve(addr: &str, cfg: Config) -> std::io::Result { + let listener = TcpListener::bind(addr)?; + let local = listener.local_addr()?; + let manager = Arc::new(Mutex::new(Manager::new(cfg.turn_ms))); + + // Turn timer. + { + let mgr = Arc::clone(&manager); + thread::spawn(move || loop { + thread::sleep(TICK); + let now = Instant::now(); + lock(&mgr).tick(now); + }); + } + + // Accept loop. + { + let mgr = Arc::clone(&manager); + thread::spawn(move || { + for stream in listener.incoming() { + if let Ok(stream) = stream { + let mgr = Arc::clone(&mgr); + thread::spawn(move || { + let _ = handle_conn(stream, mgr); + }); + } + } + }); + } + + Ok(local) +} + +/// Blocking entry point for the binary. +pub fn run(addr: &str) -> std::io::Result<()> { + let local = serve(addr, Config::from_env())?; + eprintln!("magicka-server listening on http://{local} (open it in a browser)"); + loop { + thread::sleep(Duration::from_secs(3600)); + } +} + +fn handle_conn(stream: TcpStream, mgr: Arc>) -> std::io::Result<()> { + stream.set_nodelay(true).ok(); + let mut head_reader = BufReader::new(stream.try_clone()?); + let req = match http::read_request(&mut head_reader)? { + Some(r) => r, + None => return Ok(()), + }; + + if !req.is_websocket_upgrade() { + // Static asset. + let mut s = stream; + let resp = web_client::http_response(&req.path).unwrap_or_else(web_client::not_found); + s.write_all(&resp)?; + return Ok(()); + } + + // Complete the WebSocket handshake. + let key = match req.websocket_key() { + Some(k) => k, + None => return Ok(()), + }; + let accept = ws::accept_key(key); + { + let mut s = stream.try_clone()?; + s.write_all(http::handshake_response(&accept).as_bytes())?; + s.flush()?; + } + + // Writer thread: the only thing that ever writes to this socket. + let (tx, rx) = mpsc::channel::(); + let mut write_stream = stream.try_clone()?; + let writer = thread::spawn(move || { + for out in rx { + let r = match out { + Out::Text(s) => ws::write_text(&mut write_stream, &s), + Out::Pong(p) => ws::write_pong(&mut write_stream, &p), + Out::Close => { + let _ = ws::write_close(&mut write_stream); + break; + } + }; + if r.is_err() { + break; + } + } + }); + + // Reader loop. + let mut read_stream = stream; + let mut match_key: Option = None; + let mut player_id: Option = None; + + loop { + match ws::read_message(&mut read_stream) { + Ok(Some(ws::Message::Text(raw))) => { + dispatch(&mgr, &tx, &raw, &mut match_key, &mut player_id); + } + Ok(Some(ws::Message::Ping(p))) => { + let _ = tx.send(Out::Pong(p)); + } + Ok(Some(ws::Message::Pong)) => {} + Ok(Some(ws::Message::Close)) | Ok(None) => break, + Err(_) => break, + } + } + + // Disconnect: deregister so the match continues without corruption. + if let (Some(k), Some(p)) = (match_key, player_id) { + lock(&mgr).disconnect(k, p); + } + let _ = tx.send(Out::Close); + drop(tx); + let _ = writer.join(); + Ok(()) +} + +/// Dispatch one decoded client message. Never panics: a decode failure or any +/// rejected operation becomes a `ValidationReport`/`ErrorEvent`. +fn dispatch( + mgr: &Arc>, + tx: &Sender, + raw: &str, + match_key: &mut Option, + player_id: &mut Option, +) { + let msg = match ClientMessage::decode(raw) { + Ok(m) => m, + Err(e) => { + send(tx, ServerMessage::ValidationReport { + accepted: false, + detail: format!("malformed packet: {e}"), + diagnostics: RuneDiagnostics::default(), + }); + return; + } + }; + + match msg { + ClientMessage::JoinMatch { name, match_id } => { + if player_id.is_some() { + send(tx, err_event("already_joined", "this connection already joined a match")); + return; + } + let mut m = lock(mgr); + match m.join(&name, match_id, tx.clone()) { + Ok(ok) => { + *match_key = Some(ok.match_id.0); + *player_id = Some(ok.player_id); + send(tx, ok.initial); + send(tx, ok.turn_started); + } + Err(detail) => send(tx, err_event("join_failed", &detail)), + } + } + ClientMessage::SubmitTurn { turn, action } => { + let (Some(k), Some(p)) = (*match_key, *player_id) else { + send(tx, err_event("not_joined", "join a match first")); + return; + }; + let res = lock(mgr).submit(k, p, turn, action); + match res { + Ok(()) => send(tx, ServerMessage::ValidationReport { + accepted: true, + detail: format!("action queued for turn {turn}"), + diagnostics: RuneDiagnostics::default(), + }), + Err(detail) => send(tx, ServerMessage::ValidationReport { + accepted: false, + detail, + diagnostics: RuneDiagnostics::default(), + }), + } + } + ClientMessage::EditRuneProgram { tokens } => { + let (Some(k), Some(p)) = (*match_key, *player_id) else { + send(tx, err_event("not_joined", "join a match first")); + return; + }; + match lock(mgr).set_program(k, p, tokens) { + Ok(diagnostics) => send(tx, ServerMessage::ValidationReport { + accepted: true, + detail: "program updated".to_string(), + diagnostics, + }), + Err(detail) => send(tx, err_event("edit_failed", &detail)), + } + } + ClientMessage::InspectTarget { target } => { + let Some(k) = *match_key else { + send(tx, err_event("not_joined", "join a match first")); + return; + }; + match lock(mgr).inspect(k, target) { + Ok(m) => send(tx, m), + Err(detail) => send(tx, err_event("inspect_failed", &detail)), + } + } + ClientMessage::RequestReplay { match_id } => { + match lock(mgr).replay_chunks(match_id.0) { + Ok(chunks) => { + for c in chunks { + send(tx, c); + } + } + Err(detail) => send(tx, err_event("replay_failed", &detail)), + } + } + ClientMessage::Ping { .. } => { + // Liveness only; the WebSocket layer already handles control pings. + } + } +} + +fn send(tx: &Sender, msg: ServerMessage) { + let _ = tx.send(Out::Text(msg.encode())); +} + +/// Acquire the manager lock, recovering a poisoned guard. A panic in any single +/// connection or tick must not permanently brick the server for everyone else. +fn lock(mgr: &Arc>) -> std::sync::MutexGuard<'_, Manager> { + mgr.lock().unwrap_or_else(|p| p.into_inner()) +} + +fn err_event(code: &str, detail: &str) -> ServerMessage { + ServerMessage::ErrorEvent { code: code.to_string(), detail: detail.to_string() } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manager_join_and_resolve_is_authoritative() { + let mut m = Manager::new(10); + let (tx, _rx) = mpsc::channel(); + let ok = m.join("dev", None, tx).unwrap(); + assert_eq!(ok.player_id, 1); + let key = ok.match_id.0; + // Submit a cast for the live turn. + assert!(m.submit(key, 1, 0, Action::Cast).is_ok()); + // Wrong turn is rejected deterministically. + let e = m.submit(key, 1, 99, Action::Cast).unwrap_err(); + assert!(e.contains("wrong turn"), "{e}"); + } + + #[test] + fn disconnect_drops_session_when_last_leaves() { + let mut m = Manager::new(10); + let (tx, _rx) = mpsc::channel(); + let ok = m.join("dev", None, tx).unwrap(); + let key = ok.match_id.0; + assert!(m.sessions.contains_key(&key)); + m.disconnect(key, 1); + assert!(!m.sessions.contains_key(&key)); + } + + #[test] + fn duel_assigns_two_human_slots() { + let mut m = Manager::new(10); + let (tx1, _r1) = mpsc::channel(); + let (tx2, _r2) = mpsc::channel(); + let a = m.join("a", Some(MatchId(42)), tx1).unwrap(); + let b = m.join("b", Some(MatchId(42)), tx2).unwrap(); + assert_eq!(a.player_id, 1); + assert_eq!(b.player_id, 2); + // Third join to a full duel is rejected. + let (tx3, _r3) = mpsc::channel(); + assert!(m.join("c", Some(MatchId(42)), tx3).is_err()); + } +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs new file mode 100644 index 0000000..e7d1eff --- /dev/null +++ b/crates/server/src/main.rs @@ -0,0 +1,8 @@ +//! The Magicka VM web server binary. Serves the embedded browser client and the +//! authoritative WebSocket protocol. Bind address via `MAGICKA_ADDR` +//! (default `127.0.0.1:8080`); turn length via `MAGICKA_TURN_MS`. + +fn main() -> std::io::Result<()> { + let addr = std::env::var("MAGICKA_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".to_string()); + server::run(&addr) +} diff --git a/crates/server/src/ws.rs b/crates/server/src/ws.rs new file mode 100644 index 0000000..cc131e4 --- /dev/null +++ b/crates/server/src/ws.rs @@ -0,0 +1,342 @@ +//! Minimal WebSocket (RFC 6455) support over `std::net`, no external crates. +//! Implements just what the game needs: the upgrade handshake (SHA1 + base64), +//! masked client-frame reading with fragment reassembly, and unmasked +//! server-frame writing. All reads are length-checked so a hostile frame +//! returns an `Err`, never a panic or unbounded allocation. + +use std::io::{self, Read, Write}; + +const WS_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +/// Reject any single message larger than this (defensive bound). +pub const MAX_MESSAGE: usize = 1 << 20; // 1 MiB + +// --------------------------------------------------------------------------- +// SHA-1 (FIPS 180-1). Used only for the handshake accept key. +// --------------------------------------------------------------------------- + +fn sha1(data: &[u8]) -> [u8; 20] { + let mut h: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0]; + let ml = (data.len() as u64) * 8; + let mut msg = data.to_vec(); + msg.push(0x80); + while msg.len() % 64 != 56 { + msg.push(0); + } + msg.extend_from_slice(&ml.to_be_bytes()); + + for chunk in msg.chunks_exact(64) { + let mut w = [0u32; 80]; + for (i, wi) in w.iter_mut().enumerate().take(16) { + *wi = u32::from_be_bytes([ + chunk[i * 4], + chunk[i * 4 + 1], + chunk[i * 4 + 2], + chunk[i * 4 + 3], + ]); + } + for i in 16..80 { + w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1); + } + let (mut a, mut b, mut c, mut d, mut e) = (h[0], h[1], h[2], h[3], h[4]); + for (i, &wi) in w.iter().enumerate() { + let (f, k) = match i { + 0..=19 => ((b & c) | ((!b) & d), 0x5A827999u32), + 20..=39 => (b ^ c ^ d, 0x6ED9EBA1), + 40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC), + _ => (b ^ c ^ d, 0xCA62C1D6), + }; + let tmp = a + .rotate_left(5) + .wrapping_add(f) + .wrapping_add(e) + .wrapping_add(k) + .wrapping_add(wi); + e = d; + d = c; + c = b.rotate_left(30); + b = a; + a = tmp; + } + h[0] = h[0].wrapping_add(a); + h[1] = h[1].wrapping_add(b); + h[2] = h[2].wrapping_add(c); + h[3] = h[3].wrapping_add(d); + h[4] = h[4].wrapping_add(e); + } + + let mut out = [0u8; 20]; + for (i, hi) in h.iter().enumerate() { + out[i * 4..i * 4 + 4].copy_from_slice(&hi.to_be_bytes()); + } + out +} + +// --------------------------------------------------------------------------- +// base64 (standard alphabet). +// --------------------------------------------------------------------------- + +fn base64(data: &[u8]) -> String { + const ALPHABET: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for chunk in data.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32); + out.push(ALPHABET[((n >> 18) & 63) as usize] as char); + out.push(ALPHABET[((n >> 12) & 63) as usize] as char); + if chunk.len() > 1 { + out.push(ALPHABET[((n >> 6) & 63) as usize] as char); + } else { + out.push('='); + } + if chunk.len() > 2 { + out.push(ALPHABET[(n & 63) as usize] as char); + } else { + out.push('='); + } + } + out +} + +/// Compute the `Sec-WebSocket-Accept` value for a client key. +pub fn accept_key(client_key: &str) -> String { + let mut concat = client_key.to_string(); + concat.push_str(WS_GUID); + base64(&sha1(concat.as_bytes())) +} + +// --------------------------------------------------------------------------- +// Frames. +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Opcode { + Continuation, + Text, + Binary, + Close, + Ping, + Pong, +} + +impl Opcode { + fn from_u8(v: u8) -> Option { + Some(match v { + 0x0 => Opcode::Continuation, + 0x1 => Opcode::Text, + 0x2 => Opcode::Binary, + 0x8 => Opcode::Close, + 0x9 => Opcode::Ping, + 0xA => Opcode::Pong, + _ => return None, + }) + } +} + +struct Frame { + fin: bool, + opcode: Opcode, + payload: Vec, +} + +fn read_frame(r: &mut R) -> io::Result { + let mut hdr = [0u8; 2]; + r.read_exact(&mut hdr)?; + let fin = hdr[0] & 0x80 != 0; + let opcode = Opcode::from_u8(hdr[0] & 0x0f) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "bad opcode"))?; + let masked = hdr[1] & 0x80 != 0; + let len7 = (hdr[1] & 0x7f) as usize; + let len = match len7 { + 126 => { + let mut b = [0u8; 2]; + r.read_exact(&mut b)?; + u16::from_be_bytes(b) as usize + } + 127 => { + let mut b = [0u8; 8]; + r.read_exact(&mut b)?; + u64::from_be_bytes(b) as usize + } + n => n, + }; + if len > MAX_MESSAGE { + return Err(io::Error::new(io::ErrorKind::InvalidData, "frame too large")); + } + // Per RFC, client frames MUST be masked. + let mask = if masked { + let mut m = [0u8; 4]; + r.read_exact(&mut m)?; + Some(m) + } else { + None + }; + let mut payload = vec![0u8; len]; + r.read_exact(&mut payload)?; + if let Some(m) = mask { + for (i, b) in payload.iter_mut().enumerate() { + *b ^= m[i % 4]; + } + } + Ok(Frame { fin, opcode, payload }) +} + +/// A complete application message read from the socket. Control frames are +/// surfaced rather than answered inline so that *all* socket writes can be +/// funneled through a single writer (avoiding interleaved frames when a server +/// is both broadcasting and answering pings). +pub enum Message { + Text(String), + /// A ping with its payload; the caller must reply with a pong. + Ping(Vec), + /// A pong (informational). + Pong, + /// The peer requested close. + Close, +} + +/// Read one full WebSocket message, reassembling fragments. Returns `Ok(None)` +/// on a clean EOF. Reads only — never writes to the socket. +pub fn read_message(stream: &mut R) -> io::Result> { + let mut buf: Vec = Vec::new(); + let mut msg_op: Option = None; + loop { + let frame = match read_frame(stream) { + Ok(f) => f, + Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), + Err(e) => return Err(e), + }; + match frame.opcode { + Opcode::Close => return Ok(Some(Message::Close)), + Opcode::Ping => return Ok(Some(Message::Ping(frame.payload))), + Opcode::Pong => return Ok(Some(Message::Pong)), + Opcode::Text | Opcode::Binary => { + if msg_op.is_some() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "interleaved frame")); + } + msg_op = Some(frame.opcode); + buf.extend_from_slice(&frame.payload); + } + Opcode::Continuation => { + if msg_op.is_none() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "stray continuation")); + } + buf.extend_from_slice(&frame.payload); + } + } + if buf.len() > MAX_MESSAGE { + return Err(io::Error::new(io::ErrorKind::InvalidData, "message too large")); + } + if frame.fin { + // We only surface text to the application; binary is decoded lossily. + let s = String::from_utf8_lossy(&buf).into_owned(); + return Ok(Some(Message::Text(s))); + } + } +} + +fn write_frame(w: &mut W, opcode: Opcode, payload: &[u8]) -> io::Result<()> { + let op = match opcode { + Opcode::Continuation => 0x0, + Opcode::Text => 0x1, + Opcode::Binary => 0x2, + Opcode::Close => 0x8, + Opcode::Ping => 0x9, + Opcode::Pong => 0xA, + }; + let mut frame = vec![0x80 | op]; + let len = payload.len(); + if len < 126 { + frame.push(len as u8); + } else if len < 65536 { + frame.push(126); + frame.extend_from_slice(&(len as u16).to_be_bytes()); + } else { + frame.push(127); + frame.extend_from_slice(&(len as u64).to_be_bytes()); + } + frame.extend_from_slice(payload); + w.write_all(&frame)?; + w.flush() +} + +/// Send a text message (server frames are never masked). +pub fn write_text(w: &mut W, text: &str) -> io::Result<()> { + write_frame(w, Opcode::Text, text.as_bytes()) +} + +/// Send a pong frame echoing a ping payload. +pub fn write_pong(w: &mut W, payload: &[u8]) -> io::Result<()> { + write_frame(w, Opcode::Pong, payload) +} + +/// Send a close frame. +pub fn write_close(w: &mut W) -> io::Result<()> { + write_frame(w, Opcode::Close, &[]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rfc_example_accept_key() { + // The canonical example from RFC 6455 section 1.3. + assert_eq!( + accept_key("dGhlIHNhbXBsZSBub25jZQ=="), + "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" + ); + } + + #[test] + fn sha1_known_vector() { + // "abc" -> a9993e364706816aba3e25717850c26c9cd0d89d + let d = sha1(b"abc"); + let hex: String = d.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(hex, "a9993e364706816aba3e25717850c26c9cd0d89d"); + } + + #[test] + fn base64_roundtrip_lengths() { + assert_eq!(base64(b""), ""); + assert_eq!(base64(b"f"), "Zg=="); + assert_eq!(base64(b"fo"), "Zm8="); + assert_eq!(base64(b"foo"), "Zm9v"); + assert_eq!(base64(b"foobar"), "Zm9vYmFy"); + } + + #[test] + fn masked_text_frame_roundtrips_through_reader() { + use std::io::Cursor; + // Build a masked client text frame for "hi". + let payload = b"hi"; + let mask = [0x01, 0x02, 0x03, 0x04]; + let mut frame = vec![0x81, 0x80 | payload.len() as u8]; + frame.extend_from_slice(&mask); + for (i, &b) in payload.iter().enumerate() { + frame.push(b ^ mask[i % 4]); + } + // Cursor implements Read+Write (write goes nowhere useful but pong path + // is not exercised here). + let mut cur = Cursor::new(frame); + match read_message(&mut cur).unwrap() { + Some(Message::Text(s)) => assert_eq!(s, "hi"), + _ => panic!("expected text"), + } + } + + #[test] + fn oversized_frame_is_rejected() { + use std::io::Cursor; + // Declares a 127-length (8-byte) payload of u64::MAX — must error, not OOM. + let mut frame = vec![0x81, 0x80 | 127]; + frame.extend_from_slice(&u64::MAX.to_be_bytes()); + frame.extend_from_slice(&[0, 0, 0, 0]); // partial mask + let mut cur = Cursor::new(frame); + assert!(read_message(&mut cur).is_err()); + } +} diff --git a/crates/web_assets/Cargo.toml b/crates/web_assets/Cargo.toml new file mode 100644 index 0000000..0843057 --- /dev/null +++ b/crates/web_assets/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "web_assets" +version.workspace = true +edition.workspace = true +license.workspace = true diff --git a/crates/web_assets/assets/app.js b/crates/web_assets/assets/app.js new file mode 100644 index 0000000..5a0e483 --- /dev/null +++ b/crates/web_assets/assets/app.js @@ -0,0 +1,337 @@ +"use strict"; +// Browser client for Magicka VM. The browser only ever sends INTENT; the server +// is the sole authority. This file mirrors the `protocol` crate's wire format +// (version 1, envelope {v,type,body}). It never simulates the world — it renders +// exactly what the server says is observable. + +const PROTOCOL_VERSION = 1; +const OPS = ["mix","channel","branch","schedule","resonate","observe", + "collapse","invert","diffuse","anchor","echoback","imprint"]; + +const state = { + ws: null, + playerId: null, + matchId: null, + turn: 0, + deadline: 0, + locked: false, + snapshot: null, + selectedTarget: null, + program: [], + slots: [[], [], []], + activeSlot: 0, + liveHashes: {}, // turn -> runtime_hash seen live + replay: null, // { turns: [...], final_hash, cursor } +}; + +// ---- wire helpers ------------------------------------------------------- +function send(type, body) { + if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return; + state.ws.send(JSON.stringify({ v: PROTOCOL_VERSION, type, body })); +} + +function connect() { + const proto = location.protocol === "https:" ? "wss" : "ws"; + const ws = new WebSocket(`${proto}://${location.host}/ws`); + state.ws = ws; + ws.onopen = () => { + setConn(true); + send("JoinMatch", { name: "dev-" + Math.floor(Math.random() * 1000), match_id: null }); + }; + ws.onclose = () => { setConn(false); setTimeout(connect, 1000); }; + ws.onerror = () => ws.close(); + ws.onmessage = (ev) => { + let msg; + try { msg = JSON.parse(ev.data); } catch (_) { return; } + if (!msg || msg.v !== PROTOCOL_VERSION) return; + handle(msg.type, msg.body || {}); + }; +} + +function setConn(on) { + const el = document.getElementById("conn"); + el.textContent = on ? "connected" : "disconnected"; + el.className = "badge " + (on ? "on" : "off"); +} + +// ---- server message handling ------------------------------------------- +function handle(type, body) { + switch (type) { + case "MatchState": + state.playerId = body.player_id; + state.matchId = body.match_id; + state.turn = body.turn; + state.snapshot = body.snapshot; + render(); + break; + case "TurnStarted": + state.turn = body.turn; + state.deadline = Date.now() + (body.deadline_ms || 0); + state.locked = false; + renderTimer(); + break; + case "TurnResolved": + state.turn = body.turn; + state.snapshot = body.snapshot; + state.liveHashes[body.turn] = body.runtime_hash; + (body.events || []).forEach((e) => log(`t${body.turn}: ${e}`)); + showHashes(); + render(); + break; + case "ObservationResult": + renderDiagnostics(body.diagnostics, `target ${body.target}`); + break; + case "ValidationReport": + log((body.accepted ? "✓ " : "✗ ") + body.detail); + if (body.diagnostics) renderDiagnostics(body.diagnostics, "program"); + break; + case "ReplayChunk": + loadReplay(body); + break; + case "ErrorEvent": + log(`! ${body.code}: ${body.detail}`); + break; + } +} + +// ---- rendering ---------------------------------------------------------- +function render() { + if (!state.snapshot) return; + renderArena(); + renderDomains(); + renderLogHistory(); + renderTimer(); +} + +function renderArena() { + const s = state.snapshot; + const arena = document.getElementById("arena"); + arena.style.gridTemplateColumns = `repeat(${s.arena_w}, 34px)`; + arena.innerHTML = ""; + const at = {}; + (s.observed_entities || []).forEach((e) => { at[`${e.x},${e.y}`] = e; }); + for (let y = 0; y < s.arena_h; y++) { + for (let x = 0; x < s.arena_w; x++) { + const cell = document.createElement("div"); + cell.className = "cell"; + const e = at[`${x},${y}`]; + if (e) { + cell.textContent = e.is_self ? "@" : (e.is_dummy ? "▣" : "&"); + if (e.is_self) cell.classList.add("self"); + if (state.selectedTarget === e.id) cell.classList.add("target"); + if (!e.alive) cell.classList.add("dead"); + const hp = document.createElement("span"); + hp.className = "hp"; hp.textContent = e.hp; + cell.appendChild(hp); + cell.title = `${e.name} (#${e.id}) hp ${e.hp}`; + cell.onclick = () => { state.selectedTarget = e.id; renderArena(); }; + } + arena.appendChild(cell); + } + } +} + +function renderDomains() { + const s = state.snapshot; + const wrap = document.getElementById("domains"); + wrap.innerHTML = ""; + (s.observed_domains || []).forEach((d) => { + const el = document.createElement("div"); + el.className = "domain"; + const name = document.createElement("div"); + name.className = "name"; name.textContent = `${d.index}: ${d.name}`; + el.appendChild(name); + d.observed.forEach((v, i) => { + const lane = document.createElement("span"); + const k = (d.knowledge && d.knowledge[i]) || "unknown"; + lane.className = "lane " + k; + lane.textContent = v === null ? "▒" : v; + lane.title = k; + el.appendChild(lane); + }); + wrap.appendChild(el); + }); + document.getElementById("redactions").textContent = + `${s.hidden_state_redactions} hidden state values withheld (hidden lanes + masked observations)`; + const inf = document.getElementById("inferred"); + inf.innerHTML = ""; + (s.inferred_markers || []).forEach((m) => { + const li = document.createElement("li"); li.textContent = m; inf.appendChild(li); + }); +} + +function renderLogHistory() { + // History from the snapshot is authoritative; live events are appended too. + const known = state.snapshot.known_history || []; + const log = document.getElementById("log"); + if (log.dataset.lastTurn !== String(state.turn)) { + log.dataset.lastTurn = String(state.turn); + } +} + +function renderTimer() { + const t = document.getElementById("timer"); + const remain = Math.max(0, Math.ceil((state.deadline - Date.now()) / 1000)); + t.textContent = `turn ${state.turn} — ${remain}s ${state.locked ? "(locked)" : ""}`; +} +setInterval(() => { + if (state.deadline) { + if (Date.now() > state.deadline) state.locked = true; + renderTimer(); + } +}, 250); + +function renderDiagnostics(d, label) { + const body = document.getElementById("diag-body"); + body.innerHTML = ""; + const row = (k, v) => { + const div = document.createElement("div"); + div.className = "diag-row"; + div.innerHTML = `${k}: ${v}`; + body.appendChild(div); + }; + row("for", label); + row("known reads", (d.known_reads || []).join(", ") || "—"); + row("known writes", (d.known_writes || []).join(", ") || "—"); + row("observed risks", (d.observed_risks || []).join(", ") || "none observed"); + row("unknown listeners", `${d.unknown_listeners || 0} (writes you cannot observe)`); + row("previous outcomes", (d.previous_outcomes || []).join(" | ") || "—"); +} + +// ---- rune editor -------------------------------------------------------- +function renderTokens() { + const wrap = document.getElementById("tokens"); + wrap.innerHTML = ""; + state.program.forEach((t, i) => { + const el = document.createElement("span"); + el.className = "token"; + el.textContent = `${OPS[t.op % OPS.length]} ${t.a},${t.b},${t.c}#${t.imm}`; + el.title = "click to remove"; + el.onclick = () => { state.program.splice(i, 1); renderTokens(); }; + wrap.appendChild(el); + }); +} + +function renderLibrary() { + const wrap = document.getElementById("library"); + wrap.innerHTML = ""; + state.slots.forEach((slot, i) => { + const el = document.createElement("div"); + el.className = "slot" + (i === state.activeSlot ? " active" : ""); + el.textContent = `slot ${i + 1} (${slot.length})`; + el.onclick = () => { + state.slots[state.activeSlot] = state.program.slice(); + state.activeSlot = i; + state.program = state.slots[i].slice(); + renderTokens(); renderLibrary(); + }; + wrap.appendChild(el); + }); +} + +function initEditor() { + const sel = document.getElementById("op-select"); + OPS.forEach((op, i) => { + const o = document.createElement("option"); + o.value = i; o.textContent = op; sel.appendChild(o); + }); + document.getElementById("btn-add").onclick = () => { + state.program.push({ + op: parseInt(sel.value, 10), + a: clampByte("tok-a"), b: clampByte("tok-b"), c: clampByte("tok-c"), + imm: parseInt(document.getElementById("tok-imm").value, 10) || 0, + }); + renderTokens(); + }; + document.getElementById("btn-clear").onclick = () => { state.program = []; renderTokens(); }; + document.getElementById("btn-save").onclick = () => { + send("EditRuneProgram", { tokens: state.program }); + log("saved program (" + state.program.length + " runes)"); + }; + renderTokens(); renderLibrary(); +} + +function clampByte(id) { + let v = parseInt(document.getElementById(id).value, 10) || 0; + return Math.max(0, Math.min(255, v)); +} + +// ---- actions ------------------------------------------------------------ +function submit(action) { + if (state.locked) { log("turn locked — submission rejected client-side"); return; } + send("SubmitTurn", { turn: state.turn, action }); +} + +function initActions() { + document.querySelectorAll("[data-move]").forEach((b) => { + b.onclick = () => { + const [dx, dy] = b.dataset.move.split(",").map((n) => parseInt(n, 10)); + submit({ kind: "move", dx, dy }); + }; + }); + document.getElementById("btn-cast").onclick = () => submit({ kind: "cast" }); + document.getElementById("btn-wait").onclick = () => submit({ kind: "wait" }); + document.getElementById("btn-attack").onclick = () => { + if (state.selectedTarget === null) { log("select a target first"); return; } + submit({ kind: "attack", target: state.selectedTarget }); + }; + document.getElementById("btn-inspect").onclick = () => { + if (state.selectedTarget === null) { log("select a target first"); return; } + send("InspectTarget", { target: state.selectedTarget }); + }; +} + +// ---- replay ------------------------------------------------------------- +function initReplay() { + document.getElementById("btn-replay").onclick = () => { + if (state.matchId === null) return; + state.replay = null; + send("RequestReplay", { match_id: state.matchId }); + }; + document.getElementById("btn-replay-step").onclick = stepReplay; +} + +function loadReplay(chunk) { + if (!state.replay) state.replay = { turns: [], final_hash: chunk.final_hash, cursor: 0, seed: chunk.seed }; + state.replay.turns = state.replay.turns.concat(chunk.turns || []); + state.replay.final_hash = chunk.final_hash; + document.getElementById("replay-status").textContent = + `replay loaded: ${state.replay.turns.length} turns (seed ${chunk.seed})`; +} + +function stepReplay() { + if (!state.replay || state.replay.cursor >= state.replay.turns.length) { + document.getElementById("replay-status").textContent = "replay complete"; + return; + } + const rt = state.replay.turns[state.replay.cursor++]; + // Verify browser replay event order/hash matches what we saw live (Phase G). + const live = state.liveHashes[rt.turn]; + const ok = live === undefined || live === rt.runtime_hash; + log(`replay t${rt.turn}: hash ${rt.runtime_hash} ${ok ? "✓ matches live" : "✗ MISMATCH"}`); + const hd = document.getElementById("hashes"); + hd.innerHTML += `
t${rt.turn} ${rt.runtime_hash}
`; + document.getElementById("replay-status").textContent = + `replay turn ${rt.turn} / ${state.replay.turns.length}`; +} + +function showHashes() { + const hd = document.getElementById("hashes"); + hd.innerHTML = `
live final-turn hash: ${state.liveHashes[state.turn] || "—"}
`; +} + +// ---- misc --------------------------------------------------------------- +function log(msg) { + const el = document.getElementById("log"); + const li = document.createElement("li"); + li.textContent = msg; + el.appendChild(li); + el.scrollTop = el.scrollHeight; +} + +window.addEventListener("DOMContentLoaded", () => { + initEditor(); + initActions(); + initReplay(); + connect(); +}); diff --git a/crates/web_assets/assets/index.html b/crates/web_assets/assets/index.html new file mode 100644 index 0000000..6804d1c --- /dev/null +++ b/crates/web_assets/assets/index.html @@ -0,0 +1,82 @@ + + + + + + Magicka VM — playable window + + + +
+

Magicka VM

+
disconnected
+
turn —
+
+ +
+
+

Arena

+
+
+
+ +
+ + + +
+
+
+ + + + +
+
+

Select a target entity (click it), then Attack/Inspect. Cast uses your current program.

+
+ +
+

Rune editor

+
+
+
+ + + + + + + + +
+
+

Observed diagnostics

+
cast or save a program to preview observed diagnostics
+
+
+ +
+

Domains (observed)

+
+

+

Inferred

+
    +
    + +
    +

    Turn log

    +
      +

      Replay

      +
      + + + no replay loaded +
      +
      +
      +
      + + + + diff --git a/crates/web_assets/assets/style.css b/crates/web_assets/assets/style.css new file mode 100644 index 0000000..ed658db --- /dev/null +++ b/crates/web_assets/assets/style.css @@ -0,0 +1,108 @@ +:root { + --bg: #0e1014; + --panel: #171a21; + --ink: #d7dce5; + --dim: #828b9c; + --accent: #6ad0ff; + --warn: #ffb454; + --bad: #ff6a6a; + --good: #7be08a; + --grid: #2a2f3a; +} +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font: 14px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} +header { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + background: #11131a; + border-bottom: 1px solid var(--grid); +} +h1 { font-size: 18px; margin: 0; color: var(--accent); } +h2 { font-size: 14px; margin: 0 0 8px; color: var(--accent); } +h3 { font-size: 12px; margin: 12px 0 6px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.06em; } +.badge { padding: 2px 8px; border-radius: 10px; font-size: 12px; } +.badge.off { background: #3a1f23; color: var(--bad); } +.badge.on { background: #1f3a26; color: var(--good); } +.timer { margin-left: auto; color: var(--warn); } +main { + display: grid; + grid-template-columns: 1.2fr 1.2fr 1fr; + grid-template-rows: auto auto; + gap: 12px; + padding: 12px; +} +.panel { background: var(--panel); border: 1px solid var(--grid); border-radius: 8px; padding: 12px; } +#arena-panel { grid-row: span 2; } +#log-panel { grid-row: span 2; } + +.arena { + display: grid; + gap: 2px; + background: var(--grid); + border: 1px solid var(--grid); + width: max-content; +} +.cell { + width: 34px; height: 34px; + background: #10131a; + display: flex; align-items: center; justify-content: center; + font-size: 16px; cursor: pointer; position: relative; +} +.cell.self { outline: 2px solid var(--accent); } +.cell.target { outline: 2px solid var(--warn); } +.cell.dead { opacity: 0.35; } +.cell .hp { position: absolute; bottom: 0; right: 2px; font-size: 9px; color: var(--dim); } + +.actions { display: flex; gap: 24px; margin-top: 12px; align-items: center; } +.dpad { display: flex; flex-direction: column; align-items: center; gap: 2px; } +.dpad-row { display: flex; gap: 2px; } +button { + background: #222733; color: var(--ink); border: 1px solid var(--grid); + border-radius: 5px; padding: 6px 10px; cursor: pointer; font: inherit; +} +button:hover { border-color: var(--accent); } +button:disabled { opacity: 0.4; cursor: not-allowed; } +.action-buttons { display: flex; flex-wrap: wrap; gap: 6px; } +.hint { color: var(--dim); font-size: 12px; } + +.library { display: flex; gap: 6px; margin-bottom: 8px; } +.slot { border: 1px dashed var(--grid); border-radius: 5px; padding: 4px 8px; cursor: pointer; color: var(--dim); } +.slot.active { border-color: var(--accent); color: var(--accent); } +.tokens { display: flex; flex-wrap: wrap; gap: 4px; min-height: 30px; padding: 6px; background: #10131a; border-radius: 5px; } +.token { background: #232a36; border: 1px solid var(--grid); border-radius: 4px; padding: 2px 6px; font-size: 12px; cursor: pointer; } +.token:hover { border-color: var(--bad); } +.editor-controls { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 8px; } +.editor-controls label { display: flex; flex-direction: column; font-size: 10px; color: var(--dim); } +.editor-controls input { width: 64px; background: #10131a; border: 1px solid var(--grid); color: var(--ink); border-radius: 4px; padding: 3px; } +.editor-controls select { background: #10131a; border: 1px solid var(--grid); color: var(--ink); border-radius: 4px; padding: 4px; } + +.diagnostics { margin-top: 12px; background: #10131a; border-radius: 5px; padding: 8px; } +.diag-row { margin: 2px 0; } +.diag-key { color: var(--dim); } +.warn { color: var(--warn); } + +.domains { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } +.domain { background: #10131a; border: 1px solid var(--grid); border-radius: 5px; padding: 6px; } +.domain .name { color: var(--accent); font-size: 12px; } +.lane { display: inline-block; min-width: 40px; text-align: right; padding: 1px 4px; margin: 1px; border-radius: 3px; font-size: 11px; } +.lane.known { background: #15251a; color: var(--good); } +.lane.newly_observed { background: #2a2410; color: var(--warn); } +.lane.unknown { background: #25151a; color: var(--dim); } +.lane.suspected { background: #1a1a2a; color: #9aa0ff; } +.lane.contradicted { background: #2a1525; color: #ff9ae0; } +.redactions { color: var(--bad); font-size: 12px; } + +.log { list-style: none; margin: 0; padding: 0; max-height: 320px; overflow-y: auto; } +.log li { padding: 2px 0; border-bottom: 1px solid #1c2029; font-size: 12px; } +.replay-controls { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } +.hashes { margin-top: 8px; font-size: 11px; color: var(--dim); word-break: break-all; } +.hash-ok { color: var(--good); } +.hash-bad { color: var(--bad); } +ul#inferred { margin: 0; padding-left: 16px; color: var(--dim); font-size: 12px; } diff --git a/crates/web_assets/src/lib.rs b/crates/web_assets/src/lib.rs new file mode 100644 index 0000000..56c3885 --- /dev/null +++ b/crates/web_assets/src/lib.rs @@ -0,0 +1,44 @@ +//! `web_assets` — the browser client's static files, embedded at compile time +//! so the server ships as a single binary with no runtime filesystem +//! dependency. The actual HTML/CSS/JS live under `assets/`. + +pub const INDEX_HTML: &str = include_str!("../assets/index.html"); +pub const STYLE_CSS: &str = include_str!("../assets/style.css"); +pub const APP_JS: &str = include_str!("../assets/app.js"); + +/// A served asset: its bytes and MIME type. +pub struct Asset { + pub body: &'static str, + pub content_type: &'static str, +} + +/// Resolve a request path to a static asset. `/` maps to the client shell. +pub fn resolve(path: &str) -> Option { + match path { + "/" | "/index.html" => Some(Asset { body: INDEX_HTML, content_type: "text/html; charset=utf-8" }), + "/style.css" => Some(Asset { body: STYLE_CSS, content_type: "text/css; charset=utf-8" }), + "/app.js" => Some(Asset { body: APP_JS, content_type: "application/javascript; charset=utf-8" }), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shell_and_assets_resolve() { + assert!(resolve("/").is_some()); + assert!(resolve("/app.js").is_some()); + assert!(resolve("/style.css").is_some()); + assert!(resolve("/nope").is_none()); + } + + #[test] + fn client_only_sends_intent() { + // Guard against the client ever embedding a second simulation: the + // browser code must not reference the reference engine internals. + assert!(!APP_JS.contains("EngineConfig")); + assert!(APP_JS.contains("only ever sends INTENT")); + } +} diff --git a/crates/web_client/Cargo.toml b/crates/web_client/Cargo.toml new file mode 100644 index 0000000..93d1054 --- /dev/null +++ b/crates/web_client/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "web_client" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +web_assets = { path = "../web_assets" } diff --git a/crates/web_client/src/lib.rs b/crates/web_client/src/lib.rs new file mode 100644 index 0000000..258cda5 --- /dev/null +++ b/crates/web_client/src/lib.rs @@ -0,0 +1,54 @@ +//! `web_client` — the client delivery layer. It owns *how* the embedded +//! [`web_assets`] reach the browser (the HTTP response framing), keeping the +//! raw asset bytes (`web_assets`) separate from delivery concerns. The server +//! depends on this crate, not on `web_assets` directly. + +pub use web_assets::{resolve, Asset}; + +/// Build a complete HTTP/1.1 response for a static GET path. Returns `None` +/// for unknown paths so the caller can emit a 404. +pub fn http_response(path: &str) -> Option> { + let asset = resolve(path)?; + let body = asset.body.as_bytes(); + let mut out = Vec::with_capacity(body.len() + 128); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n", + asset.content_type, + body.len() + ); + out.extend_from_slice(header.as_bytes()); + out.extend_from_slice(body); + Some(out) +} + +/// The canonical 404 response. +pub fn not_found() -> Vec { + let body = b"404 not found"; + let mut out = Vec::new(); + let header = format!( + "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + out.extend_from_slice(header.as_bytes()); + out.extend_from_slice(body); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serves_shell() { + let r = http_response("/").unwrap(); + let s = String::from_utf8_lossy(&r); + assert!(s.starts_with("HTTP/1.1 200 OK")); + assert!(s.contains("text/html")); + assert!(s.contains("Magicka VM")); + } + + #[test] + fn unknown_path_is_none() { + assert!(http_response("/secret").is_none()); + } +} diff --git a/crates/web_tests/Cargo.toml b/crates/web_tests/Cargo.toml new file mode 100644 index 0000000..8ae8cbd --- /dev/null +++ b/crates/web_tests/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "web_tests" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +protocol = { path = "../protocol" } +game_runtime = { path = "../game_runtime" } +world_model = { path = "../world_model" } +rune_ir = { path = "../rune_ir" } +server = { path = "../server" } diff --git a/crates/web_tests/e2e/package.json b/crates/web_tests/e2e/package.json new file mode 100644 index 0000000..c863136 --- /dev/null +++ b/crates/web_tests/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "magicka-web-e2e", + "version": "0.1.0", + "private": true, + "description": "Playwright rendered-browser E2E for the Magicka VM web game.", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.40.0" + } +} diff --git a/crates/web_tests/e2e/playwright.config.js b/crates/web_tests/e2e/playwright.config.js new file mode 100644 index 0000000..2c76fbf --- /dev/null +++ b/crates/web_tests/e2e/playwright.config.js @@ -0,0 +1,28 @@ +// Playwright config for the rendered-browser E2E layer. This complements the +// headless Rust protocol E2E in `crates/web_tests/tests/e2e.rs`: here a real +// Chromium drives the actual DOM client. It boots the real server (short turns) +// via `webServer` so `npm test` is self-contained. +// +// Requires Node + `npx playwright install chromium`. The Rust CI gates do not +// depend on this; it is the optional rendered-browser proof. + +const { defineConfig } = require("@playwright/test"); + +module.exports = defineConfig({ + testDir: "./specs", + timeout: 30000, + expect: { timeout: 10000 }, + use: { + baseURL: "http://127.0.0.1:8099", + headless: true, + }, + webServer: { + // Build once, then run the server with fast turns for E2E. + command: "cargo run --release -p server --bin magicka-server", + cwd: "../../..", + env: { MAGICKA_ADDR: "127.0.0.1:8099", MAGICKA_TURN_MS: "1500" }, + url: "http://127.0.0.1:8099/", + reuseExistingServer: true, + timeout: 120000, + }, +}); diff --git a/crates/web_tests/e2e/specs/play.spec.js b/crates/web_tests/e2e/specs/play.spec.js new file mode 100644 index 0000000..2c7078c --- /dev/null +++ b/crates/web_tests/e2e/specs/play.spec.js @@ -0,0 +1,43 @@ +// Rendered-browser E2E: a real Chromium joins a match, the turn timer runs, the +// player edits + saves a rune program, casts, sees filtered results, and replays +// the match — verifying the recorded replay hashes match what was seen live. +// +// This is the browser layer of plan2.md Phase H ("Playwright end-to-end tests"). + +const { test, expect } = require("@playwright/test"); + +test("a player can join, cast, and replay a browser match", async ({ page }) => { + await page.goto("/"); + + // Connects to the authoritative server. + await expect(page.locator("#conn")).toHaveText("connected", { timeout: 10000 }); + + // The turn timer is running (header shows a turn + countdown). + await expect(page.locator("#timer")).toContainText("turn", { timeout: 10000 }); + + // The arena rendered with the player's marker. + await expect(page.locator(".cell.self")).toHaveCount(1, { timeout: 10000 }); + + // Domains panel shows the hidden-state redaction notice (visibility layer). + await expect(page.locator("#redactions")).toContainText("withheld", { timeout: 10000 }); + + // Edit a rune program: add a couple of runes and save. + await page.fill("#tok-a", "1"); + await page.fill("#tok-b", "2"); + await page.click("#btn-add"); + await page.click("#btn-add"); + await page.click("#btn-save"); + // Observed diagnostics appear (names/counts only). + await expect(page.locator("#diag-body")).toContainText("known reads", { timeout: 10000 }); + + // Cast and wait for a resolved-turn log line. + await page.click("#btn-cast"); + await expect(page.locator("#log")).toContainText("cast a rune program", { timeout: 15000 }); + + // Request and step the replay; the client verifies hashes vs. what it saw live. + await page.click("#btn-replay"); + await expect(page.locator("#replay-status")).toContainText("replay loaded", { timeout: 10000 }); + await page.click("#btn-replay-step"); + // A matching hash line is shown (no MISMATCH). + await expect(page.locator(".hash-bad")).toHaveCount(0); +}); diff --git a/crates/web_tests/src/lib.rs b/crates/web_tests/src/lib.rs new file mode 100644 index 0000000..5a9304a --- /dev/null +++ b/crates/web_tests/src/lib.rs @@ -0,0 +1,170 @@ +//! `web_tests` — a tiny, dependency-free WebSocket *client* used to drive the +//! real server over a real socket in integration tests. It performs the HTTP +//! upgrade, masks client frames (as RFC 6455 requires), and reads server +//! frames. This is the harness behind the Phase H web CI gates. + +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::time::Duration; + +use protocol::{ClientMessage, ServerMessage}; + +/// A blocking WebSocket client connection to the test server. +pub struct WsClient { + stream: TcpStream, + reader: BufReader, +} + +impl WsClient { + /// Connect, upgrade to WebSocket, and verify the handshake. + pub fn connect(addr: &str) -> io::Result { + let stream = TcpStream::connect(addr)?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_nodelay(true).ok(); + let mut reader = BufReader::new(stream.try_clone()?); + + // A fixed client key keeps the handshake assertion deterministic. + let key = "dGhlIHNhbXBsZSBub25jZQ=="; + let mut s = stream.try_clone()?; + let req = format!( + "GET /ws HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\n\ + Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n\ + Sec-WebSocket-Version: 13\r\n\r\n" + ); + s.write_all(req.as_bytes())?; + s.flush()?; + + // Read the response head. + let mut status = String::new(); + reader.read_line(&mut status)?; + if !status.contains("101") { + return Err(io::Error::new(io::ErrorKind::Other, format!("no upgrade: {status:?}"))); + } + let mut saw_accept = false; + loop { + let mut line = String::new(); + let n = reader.read_line(&mut line)?; + if n == 0 || line.trim_end().is_empty() { + break; + } + if line.to_ascii_lowercase().starts_with("sec-websocket-accept:") { + let got = line.split(':').nth(1).unwrap_or("").trim(); + // Expected accept for the canonical key above. + if got == "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" { + saw_accept = true; + } + } + } + if !saw_accept { + return Err(io::Error::new(io::ErrorKind::Other, "bad Sec-WebSocket-Accept")); + } + Ok(WsClient { stream, reader }) + } + + /// Send a typed client message. + pub fn send(&mut self, msg: &ClientMessage) -> io::Result<()> { + self.send_raw_text(&msg.encode()) + } + + /// Send arbitrary text as a masked frame (used by fuzz tests). + pub fn send_raw_text(&mut self, text: &str) -> io::Result<()> { + self.write_masked(0x1, text.as_bytes()) + } + + /// Send arbitrary bytes as a masked binary frame (fuzz transport). + pub fn send_raw_bytes(&mut self, bytes: &[u8]) -> io::Result<()> { + self.write_masked(0x2, bytes) + } + + fn write_masked(&mut self, opcode: u8, payload: &[u8]) -> io::Result<()> { + let mask = [0x12u8, 0x34, 0x56, 0x78]; + let mut frame = vec![0x80 | opcode]; + let len = payload.len(); + if len < 126 { + frame.push(0x80 | len as u8); + } else if len < 65536 { + frame.push(0x80 | 126); + frame.extend_from_slice(&(len as u16).to_be_bytes()); + } else { + frame.push(0x80 | 127); + frame.extend_from_slice(&(len as u64).to_be_bytes()); + } + frame.extend_from_slice(&mask); + for (i, &b) in payload.iter().enumerate() { + frame.push(b ^ mask[i % 4]); + } + self.stream.write_all(&frame)?; + self.stream.flush() + } + + /// Read one server text frame and decode it. Skips control frames. + pub fn recv(&mut self) -> io::Result { + let text = self.recv_text()?; + ServerMessage::decode(&text) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("decode: {e}"))) + } + + /// Read one server text frame (raw). + pub fn recv_text(&mut self) -> io::Result { + loop { + let mut hdr = [0u8; 2]; + self.reader.read_exact(&mut hdr)?; + let opcode = hdr[0] & 0x0f; + let masked = hdr[1] & 0x80 != 0; + let len7 = (hdr[1] & 0x7f) as usize; + let len = match len7 { + 126 => { + let mut b = [0u8; 2]; + self.reader.read_exact(&mut b)?; + u16::from_be_bytes(b) as usize + } + 127 => { + let mut b = [0u8; 8]; + self.reader.read_exact(&mut b)?; + u64::from_be_bytes(b) as usize + } + n => n, + }; + // Server frames are not masked, but tolerate it. + let mask = if masked { + let mut m = [0u8; 4]; + self.reader.read_exact(&mut m)?; + Some(m) + } else { + None + }; + let mut payload = vec![0u8; len]; + self.reader.read_exact(&mut payload)?; + if let Some(m) = mask { + for (i, b) in payload.iter_mut().enumerate() { + *b ^= m[i % 4]; + } + } + match opcode { + 0x1 | 0x2 => return Ok(String::from_utf8_lossy(&payload).into_owned()), + 0x8 => return Err(io::Error::new(io::ErrorKind::ConnectionAborted, "closed")), + _ => continue, // ping/pong/continuation + } + } + } + + /// Receive until a predicate matches, returning that message. Bounded so a + /// test never hangs. + pub fn recv_until bool>(&mut self, pred: F) -> io::Result { + for _ in 0..256 { + let m = self.recv()?; + if pred(&m) { + return Ok(m); + } + } + Err(io::Error::new(io::ErrorKind::Other, "predicate never matched")) + } +} + +/// Start a fresh server instance on an ephemeral port for a test, returning the +/// address string. Each call binds a new port so tests are isolated. +pub fn spawn_test_server(turn_ms: u64) -> String { + let addr = server::serve("127.0.0.1:0", server::Config { turn_ms }) + .expect("bind test server"); + format!("127.0.0.1:{}", addr.port()) +} diff --git a/crates/web_tests/tests/determinism.rs b/crates/web_tests/tests/determinism.rs new file mode 100644 index 0000000..849cbdd --- /dev/null +++ b/crates/web_tests/tests/determinism.rs @@ -0,0 +1,64 @@ +//! Phase H gate: replay determinism. The web CI minimum is "1,000 simulated +//! matches" with "0 replay hash mismatches". A match is a pure function of its +//! seed, roster, and ordered inputs, so re-running the recorded inputs must +//! reproduce the final hash exactly. + +use game_runtime::{replay, run_scripted, solo_roster, duel_roster}; +use protocol::Action; +use world_model::Rng; + +/// Build a varied but deterministic script for a given seed. +fn script(seed: u64) -> Vec> { + let mut rng = Rng::derive(seed, "script"); + let mut turns = Vec::new(); + for _ in 0..6 { + let mut subs = Vec::new(); + for pid in 1..=2u32 { + let a = match rng.below(5) { + 0 => Action::Move { dx: rng.range_i64(-1, 1) as i32, dy: rng.range_i64(-1, 1) as i32 }, + 1 => Action::Cast, + 2 => Action::Attack { target: if pid == 1 { 2 } else { 1 } }, + 3 => Action::Inspect { target: if pid == 1 { 2 } else { 1 } }, + _ => Action::Wait, + }; + subs.push((pid, a)); + } + turns.push(subs); + } + turns +} + +#[test] +fn one_thousand_matches_replay_with_zero_drift() { + let mut mismatches = 0u32; + for seed in 0..1000u64 { + let roster = if seed % 2 == 0 { + solo_roster("p") + } else { + duel_roster("a", "b") + }; + let scripts = script(seed); + let (m, log) = run_scripted(seed, &roster, &scripts); + let again = replay(seed, &roster, &log.turns); + if m.replay.final_hash != again.final_hash { + mismatches += 1; + } + // Per-turn hashes must also agree. + for (a, b) in m.replay.turns.iter().zip(again.turns.iter()) { + if a.turn_hash != b.turn_hash { + mismatches += 1; + } + } + } + assert_eq!(mismatches, 0, "replay hash mismatches across 1000 matches"); +} + +#[test] +fn re_executing_same_seed_twice_is_identical() { + for seed in [1u64, 7, 99, 12345, 0xdead_beef] { + let roster = solo_roster("p"); + let (a, _) = run_scripted(seed, &roster, &script(seed)); + let (b, _) = run_scripted(seed, &roster, &script(seed)); + assert_eq!(a.replay.final_hash, b.replay.final_hash, "seed {seed}"); + } +} diff --git a/crates/web_tests/tests/e2e.rs b/crates/web_tests/tests/e2e.rs new file mode 100644 index 0000000..8b83658 --- /dev/null +++ b/crates/web_tests/tests/e2e.rs @@ -0,0 +1,145 @@ +//! Phase H gate: end-to-end matches over the real server, real sockets, real +//! protocol. The web CI minimum names "100 browser E2E matches"; a headless +//! browser is not available in this CI, so this drives the full +//! HTTP+WebSocket+protocol+runtime path *headlessly* (it is protocol-level +//! E2E, not a rendered browser — the Playwright harness under `e2e/` covers the +//! rendered browser when Node is present). It asserts: a player can join, the +//! turn timer drives resolution, casts resolve through the runtime, results +//! return as filtered observations, and the recorded replay reproduces the live +//! per-turn hashes (0 replay hash mismatches). + +use protocol::{Action, ClientMessage, MatchId, RuneTokenWire, ServerMessage}; +use web_tests::{spawn_test_server, WsClient}; + +struct Played { + match_id: MatchId, + live_hashes: Vec<(u64, String)>, + replay_hashes: Vec<(u64, String)>, + final_hash: String, + saw_filtered_snapshot: bool, +} + +/// Play a solo match for `n_turns`, then pull the recorded replay back. +fn play_solo(addr: &str, n_turns: u64, program: &[RuneTokenWire]) -> std::io::Result { + let mut c = WsClient::connect(addr)?; + c.send(&ClientMessage::JoinMatch { name: "e2e".into(), match_id: None })?; + let (match_id, _player) = match c.recv_until(|m| matches!(m, ServerMessage::MatchState { .. }))? { + ServerMessage::MatchState { match_id, player_id, snapshot, .. } => { + // Filtered snapshot sanity: hidden lanes are redacted. + assert!(snapshot.hidden_state_redactions > 0, "no redactions in snapshot"); + (match_id, player_id) + } + _ => unreachable!(), + }; + + if !program.is_empty() { + c.send(&ClientMessage::EditRuneProgram { tokens: program.to_vec() })?; + } + + let mut live_hashes = Vec::new(); + let mut saw_filtered = false; + let mut live_turn = 0u64; + for _ in 0..n_turns { + c.send(&ClientMessage::SubmitTurn { turn: live_turn, action: Action::Cast })?; + let expected = live_turn + 1; + let resolved = c.recv_until(|m| { + matches!(m, ServerMessage::TurnResolved { turn, .. } if *turn == expected) + })?; + if let ServerMessage::TurnResolved { turn, runtime_hash, snapshot, .. } = resolved { + // Results come back as filtered observations. + if snapshot.hidden_state_redactions > 0 { + saw_filtered = true; + } + live_hashes.push((turn, runtime_hash)); + live_turn = turn; + } + } + + // Pull the replay back and collect its per-turn hashes. + c.send(&ClientMessage::RequestReplay { match_id })?; + let mut replay_hashes = Vec::new(); + let mut final_hash = String::new(); + let mut got = 0u32; + let mut total = 1u32; + while got < total { + match c.recv_until(|m| matches!(m, ServerMessage::ReplayChunk { .. }))? { + ServerMessage::ReplayChunk { total: t, turns, final_hash: fh, .. } => { + total = t.max(1); + got += 1; + final_hash = fh; + for rt in turns { + replay_hashes.push((rt.turn, rt.runtime_hash)); + } + } + _ => break, + } + } + + Ok(Played { match_id, live_hashes, replay_hashes, final_hash, saw_filtered_snapshot: saw_filtered }) +} + +#[test] +fn single_match_full_playthrough() { + let addr = spawn_test_server(15); + let program = vec![ + RuneTokenWire { op: 0, a: 1, b: 2, c: 3, imm: 5 }, + RuneTokenWire { op: 5, a: 2, b: 4, c: 1, imm: -3 }, + RuneTokenWire { op: 8, a: 0, b: 6, c: 2, imm: 11 }, + ]; + let played = play_solo(&addr, 4, &program).expect("play"); + assert_ne!(played.match_id.0, 0, "a match id was assigned"); + assert_eq!(played.live_hashes.len(), 4, "all turns resolved"); + assert!(played.saw_filtered_snapshot, "results returned as filtered observations"); + assert!(!played.final_hash.is_empty()); + + // Replay must reproduce every live per-turn hash (0 mismatches). + for (turn, live) in &played.live_hashes { + let found = played.replay_hashes.iter().find(|(t, _)| t == turn); + assert!(found.is_some(), "replay missing turn {turn}"); + assert_eq!(&found.unwrap().1, live, "replay hash mismatch at turn {turn}"); + } +} + +#[test] +fn inspection_returns_observed_diagnostics_only() { + let addr = spawn_test_server(50); + let mut c = WsClient::connect(&addr).unwrap(); + c.send(&ClientMessage::JoinMatch { name: "inspector".into(), match_id: None }).unwrap(); + c.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap(); + // Inspect the dummy (entity id 2). + c.send(&ClientMessage::InspectTarget { target: 2 }).unwrap(); + let obs = c.recv_until(|m| matches!(m, ServerMessage::ObservationResult { .. })).unwrap(); + if let ServerMessage::ObservationResult { diagnostics, .. } = obs { + // Diagnostics are observed names/counts — never guaranteed truth. + // unknown_listeners is a count; reads/writes are domain names. + for s in diagnostics.known_reads.iter().chain(diagnostics.known_writes.iter()) { + assert!(s.chars().any(|ch| ch.is_alphabetic())); + } + } else { + panic!("expected ObservationResult"); + } +} + +#[test] +fn one_hundred_e2e_matches_zero_hash_mismatches() { + // Single short-turn server; 100 independent matches over real sockets. + let addr = spawn_test_server(8); + let program = vec![ + RuneTokenWire { op: 1, a: 3, b: 1, c: 2, imm: 7 }, + RuneTokenWire { op: 6, a: 0, b: 5, c: 3, imm: -9 }, + ]; + let mut mismatches = 0u32; + let mut completed = 0u32; + for _ in 0..100 { + let played = play_solo(&addr, 3, &program).expect("e2e match"); + completed += 1; + for (turn, live) in &played.live_hashes { + match played.replay_hashes.iter().find(|(t, _)| t == turn) { + Some((_, rh)) if rh == live => {} + _ => mismatches += 1, + } + } + } + assert_eq!(completed, 100, "all 100 matches completed"); + assert_eq!(mismatches, 0, "replay hash mismatches across 100 e2e matches"); +} diff --git a/crates/web_tests/tests/fuzz.rs b/crates/web_tests/tests/fuzz.rs new file mode 100644 index 0000000..6a5ffe6 --- /dev/null +++ b/crates/web_tests/tests/fuzz.rs @@ -0,0 +1,84 @@ +//! Phase H gate: protocol fuzzing. The web CI minimum is "10,000 protocol fuzz +//! cases" with "0 server panics". Decoding is total, so every byte string must +//! yield `Ok` or `Err` — never an unwind. A sample is also fired at a live +//! server to prove a malformed packet cannot bring it down. + +use protocol::{json, ClientMessage}; +use web_tests::{spawn_test_server, WsClient}; +use world_model::Rng; + +/// Generate a pseudo-random byte string from a seed, with a bias toward +/// JSON-ish characters so the parser's deeper paths are exercised. +fn fuzz_bytes(seed: u64) -> Vec { + let mut rng = Rng::derive(seed, "fuzz"); + let alphabet = b"{}[]\":,0123456789tfnuelavabcdef.- \\/\n\t"; + let len = rng.below(80); + (0..len) + .map(|_| { + if rng.chance(0.85) { + alphabet[rng.below(alphabet.len())] + } else { + rng.next_u64() as u8 + } + }) + .collect() +} + +#[test] +fn ten_thousand_fuzz_cases_never_panic() { + let mut ok = 0u64; + let mut err = 0u64; + for seed in 0..10_000u64 { + let bytes = fuzz_bytes(seed); + let text = String::from_utf8_lossy(&bytes); + // Raw JSON parse must be total. + let _ = json::parse(&text); + // Full client-message decode must be total. + match ClientMessage::decode(&text) { + Ok(_) => ok += 1, + Err(_) => err += 1, + } + } + // The point is the absence of a panic; both counters are just evidence the + // loop ran to completion. + assert_eq!(ok + err, 10_000); +} + +#[test] +fn structured_but_invalid_messages_are_rejected_not_panicked() { + let cases = [ + "{}", + "{\"v\":1}", + "{\"v\":2,\"type\":\"Ping\",\"body\":{}}", // wrong version + "{\"v\":1,\"type\":\"Nope\",\"body\":{}}", // unknown type + "{\"v\":1,\"type\":\"SubmitTurn\",\"body\":{}}", // missing fields + "{\"v\":1,\"type\":\"JoinMatch\",\"body\":{\"name\":5}}", // wrong type + ]; + for c in cases { + assert!(ClientMessage::decode(c).is_err(), "should reject: {c}"); + } +} + +#[test] +fn live_server_survives_malformed_packets() { + let addr = spawn_test_server(30); + let mut c = WsClient::connect(&addr).expect("connect"); + // Fire a burst of garbage frames (kept modest so the join reply is not + // starved behind a flood of rejection reports; decode breadth is covered by + // the 10k case test above). + for seed in 0..40u64 { + let bytes = fuzz_bytes(seed); + let _ = c.send_raw_bytes(&bytes); + } + // Also send raw garbage text. + for s in ["", "{", "garbage", "{\"v\":1,\"type\":\"X\",\"body\":1}"] { + let _ = c.send_raw_text(s); + } + // The server must still be alive and respond to a valid join. + c.send(&ClientMessage::JoinMatch { name: "after-fuzz".into(), match_id: None }) + .expect("send join"); + let m = c + .recv_until(|m| matches!(m, protocol::ServerMessage::MatchState { .. })) + .expect("server still serving after fuzz"); + assert!(matches!(m, protocol::ServerMessage::MatchState { .. })); +} diff --git a/crates/web_tests/tests/resilience.rs b/crates/web_tests/tests/resilience.rs new file mode 100644 index 0000000..d7661e0 --- /dev/null +++ b/crates/web_tests/tests/resilience.rs @@ -0,0 +1,94 @@ +//! Phase H gates: disconnect/reconnect and timer edges. A dropped connection +//! must not corrupt a match, and a late or wrong-turn submission must be +//! rejected deterministically. + +use protocol::{Action, ClientMessage, ServerMessage}; +use web_tests::{spawn_test_server, WsClient}; + +#[test] +fn wrong_turn_submission_is_rejected_deterministically() { + let addr = spawn_test_server(2000); // long turn so we control timing + let mut c = WsClient::connect(&addr).unwrap(); + c.send(&ClientMessage::JoinMatch { name: "timer".into(), match_id: None }).unwrap(); + c.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap(); + + // Submit for a turn that is not live. + c.send(&ClientMessage::SubmitTurn { turn: 999, action: Action::Wait }).unwrap(); + let report = c.recv_until(|m| matches!(m, ServerMessage::ValidationReport { .. })).unwrap(); + match report { + ServerMessage::ValidationReport { accepted, detail, .. } => { + assert!(!accepted, "wrong-turn submission should be rejected"); + assert!(detail.contains("wrong turn"), "reason: {detail}"); + } + _ => unreachable!(), + } + + // A correct, in-time submission is accepted. + c.send(&ClientMessage::SubmitTurn { turn: 0, action: Action::Wait }).unwrap(); + let ok = c.recv_until(|m| matches!(m, ServerMessage::ValidationReport { .. })).unwrap(); + match ok { + ServerMessage::ValidationReport { accepted, .. } => assert!(accepted), + _ => unreachable!(), + } +} + +#[test] +fn disconnect_does_not_corrupt_an_ongoing_duel() { + let addr = spawn_test_server(10); + // Two players share a duel match by id. + let mut a = WsClient::connect(&addr).unwrap(); + a.send(&ClientMessage::JoinMatch { name: "a".into(), match_id: Some(protocol::MatchId(7)) }) + .unwrap(); + let _ = a.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap(); + + let mut b = WsClient::connect(&addr).unwrap(); + b.send(&ClientMessage::JoinMatch { name: "b".into(), match_id: Some(protocol::MatchId(7)) }) + .unwrap(); + let _ = b.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap(); + + // Player A plays a couple of turns. + let mut live = 0u64; + for _ in 0..2 { + a.send(&ClientMessage::SubmitTurn { turn: live, action: Action::Cast }).unwrap(); + let r = a + .recv_until(|m| matches!(m, ServerMessage::TurnResolved { turn, .. } if *turn == live + 1)) + .unwrap(); + if let ServerMessage::TurnResolved { turn, .. } = r { + live = turn; + } + } + + // Player B disconnects abruptly. + drop(b); + + // The match continues for A without corruption: more turns still resolve. + for _ in 0..2 { + a.send(&ClientMessage::SubmitTurn { turn: live, action: Action::Cast }).unwrap(); + let r = a + .recv_until(|m| matches!(m, ServerMessage::TurnResolved { turn, .. } if *turn == live + 1)) + .unwrap(); + if let ServerMessage::TurnResolved { turn, runtime_hash, .. } = r { + assert!(!runtime_hash.is_empty()); + live = turn; + } + } + assert!(live >= 4, "match advanced past a mid-match disconnect"); +} + +#[test] +fn reconnect_into_open_duel_slot_succeeds() { + let addr = spawn_test_server(50); + let mid = protocol::MatchId(21); + + let mut a = WsClient::connect(&addr).unwrap(); + a.send(&ClientMessage::JoinMatch { name: "host".into(), match_id: Some(mid) }).unwrap(); + a.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap(); + + // A second player takes the open slot. + let mut b = WsClient::connect(&addr).unwrap(); + b.send(&ClientMessage::JoinMatch { name: "guest".into(), match_id: Some(mid) }).unwrap(); + let ms = b.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap(); + if let ServerMessage::MatchState { player_id, .. } = ms { + assert_eq!(player_id.0, 2, "second human should take slot 2"); + } +} diff --git a/crates/web_tests/tests/visibility.rs b/crates/web_tests/tests/visibility.rs new file mode 100644 index 0000000..500d80b --- /dev/null +++ b/crates/web_tests/tests/visibility.rs @@ -0,0 +1,74 @@ +//! Phase H gate: "0 hidden-state leaks". The hidden ground truth must never +//! cross the wire. These tests check the invariant both in-process (the +//! authoritative match) and over the live socket (the serialized bytes). + +use game_runtime::{solo_roster, Match}; +use protocol::{Action, ClientMessage, MatchId, ServerMessage}; +use web_tests::{spawn_test_server, WsClient}; +use world_model::{HIDDEN_LANES, LANES, NUM_DOMAINS}; + +#[test] +fn visible_snapshot_redacts_all_hidden_lanes() { + let mut m = Match::new(MatchId(1), 4242, solo_roster("dev")); + // Mask half of the observed lanes so redaction is non-trivial too. + for d in 0..NUM_DOMAINS { + for l in 0..LANES { + if (d + l) % 2 == 0 { + m.world.observation_state.visible[d][l] = false; + } + } + } + // The generator may already mask lanes; count actual non-visible lanes. + let masked: u32 = (0..NUM_DOMAINS) + .flat_map(|d| (0..LANES).map(move |l| (d, l))) + .filter(|&(d, l)| !m.world.observation_state.visible[d][l]) + .count() as u32; + m.set_program(1, vec![protocol::RuneTokenWire { op: 0, a: 1, b: 2, c: 3, imm: 1 }]); + m.resolve_turn(&[(1, Action::Cast)]); + + let snap = m.visible_for(1); + // Every masked observed lane is None. + for vd in &snap.observed_domains { + assert_eq!(vd.observed.len(), LANES); + for (l, o) in vd.observed.iter().enumerate() { + let visible = m.world.observation_state.visible[vd.index as usize][l]; + assert_eq!(o.is_some(), visible, "lane visibility/value mismatch"); + } + } + // Redaction count accounts for every masked observed lane and every hidden + // lane in the world. + let expected = masked + (NUM_DOMAINS * HIDDEN_LANES) as u32; + assert_eq!(snap.hidden_state_redactions, expected); + + // The serialized snapshot must not carry a "hidden" key at all. + let wire = snap.to_json().to_compact(); + assert!(!wire.contains("\"hidden\""), "serialized snapshot mentions hidden state"); +} + +#[test] +fn no_server_message_over_the_wire_carries_hidden_keys() { + let addr = spawn_test_server(12); + let mut c = WsClient::connect(&addr).unwrap(); + c.send(&ClientMessage::JoinMatch { name: "leak-check".into(), match_id: None }).unwrap(); + + let mut checked = 0; + // Drive a few turns and scan every raw frame the server emits. + let mut live_turn = 0u64; + for _ in 0..6 { + c.send(&ClientMessage::SubmitTurn { turn: live_turn, action: Action::Cast }).unwrap(); + // Read several frames; scan each. + for _ in 0..4 { + let raw = match c.recv_text() { + Ok(r) => r, + Err(_) => break, + }; + checked += 1; + assert!(!raw.contains("\"hidden\""), "raw frame leaked hidden key: {raw}"); + // Track turn progression from resolved frames. + if let Ok(ServerMessage::TurnResolved { turn, .. }) = ServerMessage::decode(&raw) { + live_turn = turn; + } + } + } + assert!(checked > 0, "no frames scanned"); +} diff --git a/plan2.md b/plan2.md new file mode 100644 index 0000000..75145fc --- /dev/null +++ b/plan2.md @@ -0,0 +1,262 @@ + +Web Game Implementation Plan +Prime Directive +Build the browser game around the already-defined Rust simulation/testing system. + +The web game must be: + +Browser-first +Server-authoritative +Replayable +Deterministic +Test-gated +Playable before pretty +No web feature may bypass the Rust runtime contract. + +Architecture +Rust simulation core + ↓ +Authoritative server + ↓ +WebSocket protocol + ↓ +Browser client + ↓ +UI / arena / rune editor +Browser never decides truth. + +Browser only sends: + +intent +movement choice +rune program +library slot selection +inspection request +Server returns: + +world snapshot +visible observations +turn result +trace excerpts +replay hash +legal player-facing diagnostics +Crates +crates/ + world_model + rune_ir + trace_model + reference_runtime + game_runtime + replay_corpus + protocol + server + web_client + web_assets + web_tests +Phase A — Protocol First +Define all client/server messages before UI. + +ClientMessage + JoinMatch + SubmitTurn + EditRuneProgram + InspectTarget + RequestReplay + Ping + +ServerMessage + MatchState + TurnStarted + TurnResolved + ObservationResult + ValidationReport + ReplayChunk + ErrorEvent +Rules: + +All messages versioned +All messages serializable +All messages replay-testable +All server outputs hashable +No client-only game truth +Phase B — Authoritative Match Server +Server owns: + +match state +turn timer +submitted actions +rune execution +visibility filtering +knowledge filtering +replay recording +disconnect handling +Server loop: + +Create match +Send visible snapshot +Start turn timer +Collect actions +Resolve through runtime +Persist replay event +Send filtered results +Advance turn +Hard gates: + +same inputs produce same replay hash +late input rejected deterministically +disconnect does not corrupt match +invalid client packet cannot panic server +client cannot mutate hidden state +Phase C — Browser Client Shell +Client responsibilities: + +connect +authenticate anonymously/dev +join match +render visible arena +show entities +show turn timer +edit rune program +submit action +display results +display observations +play replay events +Do not implement complex art yet. + +Use debug visuals: + +grid +tokens +panels +logs +timers +entity markers +domain indicators +Phase D — Rune Editor +The rune editor is the core UI. + +Required: + +keyboard-bound rune input +token grid / sequence view +library slot panel +syntax-neutral execution preview +visible cost/risk diagnostics +submission lock on timer expiry +Important: + +The editor must not pretend to know full truth. +It can show observed diagnostics only. +Player-facing diagnostics should say: + +known reads +known writes +observed risks +unknown listeners +previous outcomes +Not: + +guaranteed damage +guaranteed success +full hidden state +Phase E — Arena Interaction +Each turn, player can: + +move +inspect +cast rune program +use stick/basic attack +wait +All actions become server commands. + +Client-side previews are advisory only. + +Phase F — Visibility / Knowledge Layer +Server sends filtered state: + +VisibleWorldSnapshot { + observed_domains, + observed_entities, + observed_environment, + known_history, + inferred_markers, + hidden_state_redactions, +} +Knowledge must be game state, not UI notes. + +Client displays: + +known +unknown +suspected +contradicted +newly observed +Phase G — Replay System +Every match produces: + +initial seed +player inputs +turn boundaries +runtime hashes +visible outputs +trace excerpts +final hash +Browser replay consumes the same protocol stream. + +CI gate: + +recorded replay equals regenerated replay +browser replay event order matches server order +Phase H — Web Testing +Required test layers: + +Rust protocol tests +server integration tests +browser protocol tests +Playwright end-to-end tests +replay determinism tests +fuzzed packet tests +disconnect/reconnect tests +timer edge tests +Minimum web CI gates: + +1,000 simulated matches +10,000 protocol fuzz cases +100 browser E2E matches +0 server panics +0 replay hash mismatches +0 hidden-state leaks +Phase I — Vertical Slice +First playable slice: + +2 players or 1 player + dummy opponent +small arena +turn timer +movement +inspection +basic attack +rune submission +multicast execution +visible consequences +replay viewer +No progression. +No accounts. +No cosmetics. +No marketplace. +No complex content. + +Acceptance Criteria +Web phase is accepted only when: + +A player can join a browser match. +A turn timer runs. +The player can inspect, move, attack, or cast. +Rune programs execute only on the server. +Results return as filtered observations. +Replay can reproduce the match. +Browser cannot alter hidden truth. +CI proves protocol, replay, visibility, and server authority. +Core rule: + +The web game is just a playable window into the Rust universe. +It must not become a second simulation. \ No newline at end of file