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) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 20:20:46 -07:00
co-authored by Claude Opus 4.8
parent 659544f0b2
commit 9d9d5ce41c
33 changed files with 5065 additions and 0 deletions
+8
View File
@@ -50,6 +50,14 @@ jobs:
- name: Unit tests (incl. negative controls) - name: Unit tests (incl. negative controls)
run: cargo test --release --workspace 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 - name: Full merge-blocking acceptance gates
env: env:
MAGICKA_PROFILE: merge MAGICKA_PROFILE: merge
+72
View File
@@ -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
+5
View File
@@ -2,3 +2,8 @@
/ci_out /ci_out
/ci_out_merge /ci_out_merge
Cargo.lock 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
+6
View File
@@ -11,6 +11,12 @@ members = [
"crates/semantic_mutation", "crates/semantic_mutation",
"crates/replay_corpus", "crates/replay_corpus",
"crates/ci_reports", "crates/ci_reports",
"crates/protocol",
"crates/game_runtime",
"crates/web_assets",
"crates/web_client",
"crates/server",
"crates/web_tests",
] ]
[workspace.package] [workspace.package]
+90
View File
@@ -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 Everything is seed-derived and integer-only (SplitMix64 RNG, FNV-1a content
hashing, wrapping/guarded arithmetic). No floating point enters a canonical hashing, wrapping/guarded arithmetic). No floating point enters a canonical
hash, so replay is bit-exact across machines and runs. No external crates. 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 |
+17
View File
@@ -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" }
+757
View File
@@ -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<Vec<RuneTokenWire>>,
}
/// One recorded turn (Phase G).
#[derive(Clone, Debug)]
pub struct RecordedTurn {
pub turn: u64,
pub inputs: Vec<TurnInput>,
pub turn_hash: Hash,
pub events: Vec<String>,
}
/// The full replay log for a match.
#[derive(Clone, Debug)]
pub struct ReplayLog {
pub seed: u64,
pub roster: Vec<RosterEntry>,
pub turns: Vec<RecordedTurn>,
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<ExecutionContext>,
pub entities: Vec<Entity>,
pub history: Vec<String>,
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<RosterEntry>) -> 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<RuneTokenWire>) {
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<String> {
// Build a canonical, complete, sorted input set: one action per entity.
let mut inputs: Vec<TurnInput> = Vec::new();
let mut ids: Vec<u32> = 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<String>) {
// 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<u32> = 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<String> = 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<String> = self
.history
.iter()
.filter(|h| h.contains("cast") || h.contains("working"))
.cloned()
.collect();
let start = matching.len().saturating_sub(4);
let previous_outcomes: Vec<String> = 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<RosterEntry> {
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<RosterEntry> {
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<(u32, Action)>> {
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");
}
}
}
+11
View File
@@ -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" }
+531
View File
@@ -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<Json>),
Obj(BTreeMap<String, Json>),
}
impl Json {
pub fn s(v: impl Into<String>) -> 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<f64> {
match self {
Json::Num(n) => Some(*n),
_ => None,
}
}
pub fn as_i64(&self) -> Option<i64> {
match self {
Json::Num(n) if n.is_finite() => Some(*n as i64),
_ => None,
}
}
pub fn as_u64(&self) -> Option<u64> {
match self {
Json::Num(n) if n.is_finite() && *n >= 0.0 => Some(*n as u64),
_ => None,
}
}
pub fn as_u8(&self) -> Option<u8> {
self.as_u64().and_then(|v| u8::try_from(v).ok())
}
pub fn as_bool(&self) -> Option<bool> {
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<String, JsonError> {
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<u64, JsonError> {
self.field(key)?
.as_u64()
.ok_or_else(|| JsonError::Type(key.to_string(), "u64"))
}
pub fn i64_field(&self, key: &str) -> Result<i64, JsonError> {
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<Json, JsonError> {
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<u8> {
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<Json, JsonError> {
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<Json, JsonError> {
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<Json, JsonError> {
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<String, JsonError> {
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<u32, JsonError> {
// 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<Json, JsonError> {
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<Json, JsonError> {
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<Json, JsonError> {
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::<f64>()
.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);
}
}
+858
View File
@@ -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<Self, JsonError> {
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<Self, JsonError> {
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<MatchId> },
/// 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<RuneTokenWire> },
/// 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<ClientMessage, JsonError> {
let j = parse(raw)?;
Self::from_json(&j)
}
pub fn from_json(j: &Json) -> Result<ClientMessage, JsonError> {
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<Knowledge> {
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<Option<i64>>,
/// Per-lane knowledge tag.
pub knowledge: Vec<Knowledge>,
}
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<Self, JsonError> {
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<Self, JsonError> {
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<VisibleDomain>,
pub observed_entities: Vec<VisibleEntity>,
/// Short human-readable environment descriptors (arena conditions).
pub observed_environment: Vec<String>,
/// Prior-turn outcome summaries the player has already witnessed.
pub known_history: Vec<String>,
/// Inferred (suspected) markers, e.g. "domain 3 likely volatile".
pub inferred_markers: Vec<String>,
/// 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<Self, JsonError> {
let observed_domains = j
.arr_field("observed_domains")?
.iter()
.map(VisibleDomain::from_json)
.collect::<Result<_, _>>()?;
let observed_entities = j
.arr_field("observed_entities")?
.iter()
.map(VisibleEntity::from_json)
.collect::<Result<_, _>>()?;
let strs = |key| -> Result<Vec<String>, 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<String>,
pub known_writes: Vec<String>,
pub observed_risks: Vec<String>,
pub unknown_listeners: u32,
pub previous_outcomes: Vec<String>,
}
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<Self, JsonError> {
let strs = |key| -> Vec<String> {
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<Self, JsonError> {
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::<Result<_, JsonError>>()?;
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<String>,
},
/// 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<ReplayTurn>,
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<ServerMessage, JsonError> {
let j = parse(raw)?;
Self::from_json(&j)
}
pub fn from_json(j: &Json) -> Result<ServerMessage, JsonError> {
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::<Result<_, _>>()?,
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());
}
}
}
+15
View File
@@ -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" }
+113
View File
@@ -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<String, String>,
}
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: BufRead>(r: &mut R) -> io::Result<Option<Request>> {
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());
}
}
+606
View File
@@ -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<u8>),
Close,
}
/// One live match plus its scheduling and connection state.
struct Session {
m: Match,
turn_len: Duration,
deadline: Instant,
pending: BTreeMap<u32, Action>,
conns: BTreeMap<u32, Sender<Out>>,
/// Entity ids that are human-controlled (vs. a dummy).
human_slots: Vec<u32>,
}
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<u64, Session>,
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<MatchId>,
tx: Sender<Out>,
) -> Result<JoinOk, String> {
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<protocol::RuneTokenWire>,
) -> Result<RuneDiagnostics, String> {
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<ServerMessage, String> {
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<Vec<ServerMessage>, String> {
let session = self
.sessions
.get(&match_key)
.ok_or_else(|| "no such match".to_string())?;
let m = &session.m;
let turns: Vec<ReplayTurn> = 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<u64> = 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<SocketAddr> {
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<Mutex<Manager>>) -> 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::<Out>();
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<u64> = None;
let mut player_id: Option<u32> = 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<Mutex<Manager>>,
tx: &Sender<Out>,
raw: &str,
match_key: &mut Option<u64>,
player_id: &mut Option<u32>,
) {
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<Out>, 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<Mutex<Manager>>) -> 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());
}
}
+8
View File
@@ -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)
}
+342
View File
@@ -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<Opcode> {
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<u8>,
}
fn read_frame<R: Read>(r: &mut R) -> io::Result<Frame> {
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<u8>),
/// 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<R: Read>(stream: &mut R) -> io::Result<Option<Message>> {
let mut buf: Vec<u8> = Vec::new();
let mut msg_op: Option<Opcode> = 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: Write>(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: Write>(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: Write>(w: &mut W, payload: &[u8]) -> io::Result<()> {
write_frame(w, Opcode::Pong, payload)
}
/// Send a close frame.
pub fn write_close<W: Write>(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());
}
}
+5
View File
@@ -0,0 +1,5 @@
[package]
name = "web_assets"
version.workspace = true
edition.workspace = true
license.workspace = true
+337
View File
@@ -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 = `<span class="diag-key">${k}:</span> ${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", `<span class="warn">${d.unknown_listeners || 0}</span> (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 += `<div class="${ok ? "hash-ok" : "hash-bad"}">t${rt.turn} ${rt.runtime_hash}</div>`;
document.getElementById("replay-status").textContent =
`replay turn ${rt.turn} / ${state.replay.turns.length}`;
}
function showHashes() {
const hd = document.getElementById("hashes");
hd.innerHTML = `<div>live final-turn hash: ${state.liveHashes[state.turn] || "—"}</div>`;
}
// ---- 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();
});
+82
View File
@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Magicka VM — playable window</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<header>
<h1>Magicka VM</h1>
<div id="conn" class="badge off">disconnected</div>
<div id="timer" class="timer">turn —</div>
</header>
<main>
<section id="arena-panel" class="panel">
<h2>Arena</h2>
<div id="arena" class="arena" aria-label="arena grid"></div>
<div class="actions">
<div class="dpad">
<button data-move="0,-1"></button>
<div class="dpad-row">
<button data-move="-1,0"></button>
<button data-move="0,1"></button>
<button data-move="1,0"></button>
</div>
</div>
<div class="action-buttons">
<button id="btn-cast">Cast</button>
<button id="btn-attack">Attack</button>
<button id="btn-inspect">Inspect</button>
<button id="btn-wait">Wait</button>
</div>
</div>
<p class="hint">Select a target entity (click it), then Attack/Inspect. Cast uses your current program.</p>
</section>
<section id="editor-panel" class="panel">
<h2>Rune editor</h2>
<div id="library" class="library"></div>
<div id="tokens" class="tokens"></div>
<div class="editor-controls">
<select id="op-select"></select>
<label>a<input id="tok-a" type="number" value="0" min="0" max="255" /></label>
<label>b<input id="tok-b" type="number" value="0" min="0" max="255" /></label>
<label>c<input id="tok-c" type="number" value="0" min="0" max="255" /></label>
<label>imm<input id="tok-imm" type="number" value="0" /></label>
<button id="btn-add">Add rune</button>
<button id="btn-clear">Clear</button>
<button id="btn-save">Save program</button>
</div>
<div id="diagnostics" class="diagnostics">
<h3>Observed diagnostics</h3>
<div id="diag-body">cast or save a program to preview observed diagnostics</div>
</div>
</section>
<section id="domains-panel" class="panel">
<h2>Domains (observed)</h2>
<div id="domains" class="domains"></div>
<p id="redactions" class="redactions"></p>
<h3>Inferred</h3>
<ul id="inferred"></ul>
</section>
<section id="log-panel" class="panel">
<h2>Turn log</h2>
<ul id="log" class="log"></ul>
<h3>Replay</h3>
<div class="replay-controls">
<button id="btn-replay">Request replay</button>
<button id="btn-replay-step">Step ▶</button>
<span id="replay-status">no replay loaded</span>
</div>
<div id="hashes" class="hashes"></div>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>
+108
View File
@@ -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; }
+44
View File
@@ -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<Asset> {
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"));
}
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "web_client"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
web_assets = { path = "../web_assets" }
+54
View File
@@ -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<Vec<u8>> {
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<u8> {
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());
}
}
+12
View File
@@ -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" }
+12
View File
@@ -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"
}
}
+28
View File
@@ -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,
},
});
+43
View File
@@ -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);
});
+170
View File
@@ -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<TcpStream>,
}
impl WsClient {
/// Connect, upgrade to WebSocket, and verify the handshake.
pub fn connect(addr: &str) -> io::Result<WsClient> {
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<ServerMessage> {
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<String> {
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<F: Fn(&ServerMessage) -> bool>(&mut self, pred: F) -> io::Result<ServerMessage> {
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())
}
+64
View File
@@ -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<Vec<(u32, Action)>> {
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}");
}
}
+145
View File
@@ -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<Played> {
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");
}
+84
View File
@@ -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<u8> {
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 { .. }));
}
+94
View File
@@ -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");
}
}
+74
View File
@@ -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");
}
+262
View File
@@ -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.