Add web game (plan2.md) on the independent runtime, merge-blocking gates #1
@@ -50,6 +50,14 @@ jobs:
|
||||
- name: Unit tests (incl. negative controls)
|
||||
run: cargo test --release --workspace
|
||||
|
||||
# plan2.md Phase H: the protocol + socket + replay/visibility gates are
|
||||
# merge-blocking. These run the full HTTP/WebSocket/protocol/runtime path
|
||||
# headlessly over real sockets (1k matches, 10k fuzz, 100 e2e, leak +
|
||||
# resilience). The rendered-browser layer is NOT gated here — see
|
||||
# web-gates.yml (advisory, blocked on CI infrastructure).
|
||||
- name: Web protocol + socket gates (merge-blocking)
|
||||
run: cargo test --release -p protocol -p game_runtime -p server -p web_assets -p web_client -p web_tests
|
||||
|
||||
- name: Full merge-blocking acceptance gates
|
||||
env:
|
||||
MAGICKA_PROFILE: merge
|
||||
|
||||
@@ -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
|
||||
@@ -2,3 +2,8 @@
|
||||
/ci_out
|
||||
/ci_out_merge
|
||||
Cargo.lock
|
||||
# Playwright / Node artifacts for the rendered-browser E2E
|
||||
crates/web_tests/e2e/node_modules
|
||||
crates/web_tests/e2e/package-lock.json
|
||||
crates/web_tests/e2e/test-results
|
||||
crates/web_tests/e2e/playwright-report
|
||||
|
||||
@@ -11,6 +11,12 @@ members = [
|
||||
"crates/semantic_mutation",
|
||||
"crates/replay_corpus",
|
||||
"crates/ci_reports",
|
||||
"crates/protocol",
|
||||
"crates/game_runtime",
|
||||
"crates/web_assets",
|
||||
"crates/web_client",
|
||||
"crates/server",
|
||||
"crates/web_tests",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -134,3 +134,93 @@ cargo run --release -p replay_corpus --bin freeze -- 10000
|
||||
Everything is seed-derived and integer-only (SplitMix64 RNG, FNV-1a content
|
||||
hashing, wrapping/guarded arithmetic). No floating point enters a canonical
|
||||
hash, so replay is bit-exact across machines and runs. No external crates.
|
||||
|
||||
## The web game (plan2.md)
|
||||
|
||||
A browser game is built **around** the existing runtime — it is a playable
|
||||
window into the Rust universe, never a second simulation. The browser sends only
|
||||
*intent*; the server is the sole authority; every rune program executes through
|
||||
the **independent** interpreter (`runtime_under_test::native_resolve`) against
|
||||
the shared world. The game deliberately does **not** call the reference engine —
|
||||
the interpreter it uses is the one the runtime-equivalence gate proves correct
|
||||
(with a negative control proving that gate can fail). Same constraints as the
|
||||
rest of the repo: pure `std`, no external crates (the WebSocket server
|
||||
hand-rolls SHA-1, base64, and RFC 6455 framing; JSON is hand-rolled with a total
|
||||
parser).
|
||||
|
||||
> Audit note: the hand-rolled SHA-1 / base64 / RFC-6455 framing and JSON parser
|
||||
> are checked against published test vectors (RFC 6455 §1.3 accept key, SHA-1
|
||||
> "abc", base64 length cases) and a fuzz gate, but they are bespoke
|
||||
> cryptographic/parsing code and carry audit risk relative to a reviewed
|
||||
> library. They exist to honor the repo's no-external-crates rule; a future
|
||||
> hardening pass could swap in vetted implementations behind the same interface.
|
||||
|
||||
```
|
||||
Rust runtime → game_runtime (authority) → protocol (WS messages) → server → browser
|
||||
```
|
||||
|
||||
| Crate | Role |
|
||||
|-------|------|
|
||||
| `protocol` | Versioned, hashable, **total-decode** client/server messages + JSON value/parser. A malformed packet yields `Err`, never a panic. |
|
||||
| `game_runtime` | Authoritative match state. Resolves turns through the **independent interpreter** (`runtime_under_test`, not the reference engine), filters visibility/knowledge, records + regenerates replays. A match is a pure function of `(seed, roster, ordered inputs)`. |
|
||||
| `web_assets` | The embedded browser client (HTML/CSS/JS): arena, rune editor, domain/knowledge panels, replay viewer. |
|
||||
| `web_client` | Static-asset HTTP delivery (keeps raw assets separate from framing). |
|
||||
| `server` | `std::net` HTTP + WebSocket server: turn timer, action collection, disconnect handling, panic-proof dispatch. |
|
||||
| `web_tests` | A dependency-free WebSocket test client + the Phase H gates. |
|
||||
|
||||
### Running it
|
||||
|
||||
```bash
|
||||
cargo run --release -p server --bin magicka-server # serve on 127.0.0.1:8080
|
||||
# then open http://127.0.0.1:8080 in a browser
|
||||
MAGICKA_ADDR=0.0.0.0:9000 MAGICKA_TURN_MS=8000 cargo run --release -p server --bin magicka-server
|
||||
```
|
||||
|
||||
Join is immediate (1 player + a training dummy). A duel shares a match by id:
|
||||
two browsers that `JoinMatch` the same `match_id` take slots 1 and 2.
|
||||
|
||||
### Web CI gates (Phase H)
|
||||
|
||||
These gates are **merge-blocking**: they run inside the merge-required job in
|
||||
`.github/workflows/merge-gates.yml` (and as fast PR feedback in
|
||||
`web-gates.yml`). They are the Rust suite in `crates/web_tests`, run with
|
||||
`cargo test -p web_tests`:
|
||||
|
||||
| Gate | Test | Minimum | Status |
|
||||
|------|------|---------|--------|
|
||||
| Replay determinism | `determinism.rs` | 1,000 simulated matches, **0 hash mismatches** | merge-blocking |
|
||||
| Protocol fuzz | `fuzz.rs` | 10,000 fuzz cases, **0 panics** (+ a live server survives a malformed-packet burst) | merge-blocking |
|
||||
| End-to-end matches | `e2e.rs` | **100** full matches over real sockets; recorded replay reproduces every live per-turn hash | merge-blocking |
|
||||
| Hidden-state leaks | `visibility.rs` | **0 leaks** — no client-bound frame carries a hidden key; redaction counts every withheld value | merge-blocking |
|
||||
| Disconnect / timer edges | `resilience.rs` | mid-match disconnect does not corrupt the match; wrong-turn / late submits are rejected deterministically | merge-blocking |
|
||||
| Rendered-browser E2E | `e2e/specs/play.spec.js` | a real browser joins, casts, and replays a match | **external-blocked (advisory only)** |
|
||||
|
||||
Scope honesty — two distinct things, not conflated:
|
||||
|
||||
- The "100 E2E matches" merge-blocking gate drives the full
|
||||
HTTP→WebSocket→protocol→runtime path **headlessly over real sockets**. This is
|
||||
protocol-level coverage. It is **not** rendered-browser coverage and is not
|
||||
claimed as such.
|
||||
- Rendered-browser coverage is **blocked on CI infrastructure**: this CI has no
|
||||
real browser, so the Playwright suite under `crates/web_tests/e2e/` cannot be
|
||||
merge-blocking yet. It runs **advisory-only** (`continue-on-error`) in the
|
||||
`rendered-browser-e2e` job and uploads its report as an artifact. Until a CI
|
||||
runner with a browser exists, rendered-browser E2E is treated as
|
||||
**unsatisfied**, not green. Run it locally with:
|
||||
|
||||
```bash
|
||||
cd crates/web_tests/e2e && npm install && npx playwright install chromium && npm test
|
||||
```
|
||||
|
||||
### Acceptance criteria mapping (plan2.md)
|
||||
|
||||
| Criterion | Where it holds |
|
||||
|-----------|----------------|
|
||||
| A player can join a browser match | `server` join + `web_assets` client; `e2e.rs::single_match_full_playthrough` |
|
||||
| A turn timer runs | `server` timer thread; client header countdown |
|
||||
| Inspect / move / attack / cast | `Action` in `protocol`; `game_runtime::apply_action` |
|
||||
| Rune programs execute only on the server | `game_runtime` is the only caller of the interpreter (`runtime_under_test::native_resolve`); client never imports `EngineConfig` (asserted in `web_assets`) |
|
||||
| Results return as filtered observations | `VisibleWorldSnapshot`; `visibility.rs` |
|
||||
| Replay can reproduce the match | `game_runtime::replay`; `determinism.rs`, `e2e.rs` |
|
||||
| Browser cannot alter hidden truth | intent-only protocol; `visibility.rs` leak gate |
|
||||
| CI proves protocol, replay, visibility, authority | merge-blocking gates in `merge-gates.yml` (+ `web-gates.yml`); rendered-browser E2E remains external-blocked |
|
||||
|
||||
@@ -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" }
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
[package]
|
||||
name = "web_assets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "web_client"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
web_assets = { path = "../web_assets" }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 { .. }));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user