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