Add web game (plan2.md) on the independent runtime, merge-blocking gates

Builds the browser game around the existing Rust runtime: a window into the
universe, not a second simulation. Pure std, no external crates.

New crates:
- protocol: versioned, hashable client/server messages + hand-rolled JSON value
  and total parser (malformed packet -> Err, never panic).
- game_runtime: authoritative match state. Resolves turns through the
  INDEPENDENT interpreter (runtime_under_test::native_resolve), not the
  reference engine; filters visibility/knowledge; records and regenerates
  replays. A match is a pure function of (seed, roster, ordered inputs).
- web_assets/web_client: embedded browser client (arena, rune editor,
  knowledge panels, replay viewer) + static HTTP delivery.
- server: std::net HTTP + WebSocket (hand-rolled SHA-1/base64/RFC-6455 framing),
  turn timer, disconnect handling, panic-proof dispatch, poison-tolerant lock.
- web_tests: dependency-free WebSocket test client + Phase H gates.

Trust hardening per review:
- game_runtime no longer delegates to reference_runtime::execute; it runs the
  independent interpreter that the runtime-equivalence gate proves correct.
- Protocol/socket/replay/visibility/resilience gates are merge-blocking
  (added to the merge_group-required job in merge-gates.yml): 1k matches/0
  drift, 10k fuzz/0 panics, 100 headless socket E2E, 0 hidden-state leaks.
- Rendered-browser E2E is marked EXTERNAL-BLOCKED: Playwright runs advisory-only
  (continue-on-error, artifacts) until CI infrastructure with a browser exists;
  it is treated as unsatisfied, not green. The headless 100-match gate is
  labeled protocol-level coverage, not rendered-browser coverage.
- README documents the hand-rolled crypto/parser audit risk explicitly.

Fixes an integer-overflow panic in observed-volatility inference (i64 sum /
abs near i64::MIN) that could poison the server mutex.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 20:20:46 -07:00
co-authored by Claude Opus 4.8
parent 659544f0b2
commit 9d9d5ce41c
33 changed files with 5065 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "game_runtime"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
world_model = { path = "../world_model" }
rune_ir = { path = "../rune_ir" }
trace_model = { path = "../trace_model" }
# Used only for the shared engine I/O *contract* types (ResolutionInput /
# ResolutionResult) and the canonical view. Execution is driven through the
# independent interpreter in `runtime_under_test`, never the reference engine.
reference_runtime = { path = "../reference_runtime" }
runtime_under_test = { path = "../runtime_under_test" }
generators = { path = "../generators" }
protocol = { path = "../protocol" }
+757
View File
@@ -0,0 +1,757 @@
//! `game_runtime` — the authoritative match layer (Phase B of `plan2.md`).
//!
//! This crate is the *only* place game truth is decided. The browser sends
//! intent; this crate resolves it. Every rune program executes through the
//! **independent** interpreter [`runtime_under_test::native_resolve`] against
//! the shared [`WorldSnapshot`] — the web layer is a window into the Rust
//! universe and never a second simulation. The game does not call the reference
//! engine; correctness of the interpreter it does use is established separately
//! by the runtime-equivalence gate (which compares that interpreter against the
//! reference over a large sweep, with a negative control proving the gate can
//! fail).
//!
//! Two properties are essential and tested:
//! * **Determinism** — a match is a pure function of `(seed, roster, ordered
//! inputs)`. [`replay`] reconstructs any match and produces an identical
//! final hash. No wall clock, no ambient RNG; the turn *timer* lives in the
//! server, never here.
//! * **Authority + visibility** — players receive a [`VisibleWorldSnapshot`]
//! that redacts all hidden lanes and every non-observable observed lane. The
//! hidden ground truth is never placed in any client-bound structure.
use protocol::{
Action, Knowledge, MatchId, RuneDiagnostics, RuneTokenWire, VisibleDomain,
VisibleEntity, VisibleWorldSnapshot,
};
use reference_runtime::{canonical, ResolutionInput, ResolutionResult};
use runtime_under_test::native_resolve;
use rune_ir::{Op, RuneProgram, RuneToken};
use world_model::{
standard_executors, DomainKind, ExecutionContext, Hash, Hasher, ProgramId, Rng, WorldSnapshot,
HIDDEN_LANES, LANES, NUM_DOMAINS,
};
pub const ARENA_W: i32 = 8;
pub const ARENA_H: i32 = 8;
pub const MAX_HP: i32 = 30;
/// Basic stick attack damage.
pub const ATTACK_DAMAGE: i32 = 4;
/// Range (Manhattan) within which a cast's consequence reaches enemies.
pub const CAST_RANGE: i32 = 3;
/// One combatant on the arena. A "player" entity is driven by a connection; a
/// "dummy" is a deterministic stationary target for the 1-player slice.
#[derive(Clone, Debug)]
pub struct Entity {
pub id: u32,
pub name: String,
pub x: i32,
pub y: i32,
pub hp: i32,
pub is_dummy: bool,
/// The player's current editable rune program (Phase D editor state).
pub program: RuneProgram,
}
impl Entity {
pub fn alive(&self) -> bool {
self.hp > 0
}
}
/// A roster entry needed to reconstruct a match for replay.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RosterEntry {
pub id: u32,
pub name: String,
pub is_dummy: bool,
}
/// One resolved turn's authoritative input, sufficient to replay it exactly.
#[derive(Clone, Debug, PartialEq)]
pub struct TurnInput {
pub player: u32,
pub action: Action,
/// The exact program used, captured iff `action` is `Cast`. Recording the
/// program here (rather than replaying editor edits) makes replay a pure
/// function of this input stream.
pub program: Option<Vec<RuneTokenWire>>,
}
/// One recorded turn (Phase G).
#[derive(Clone, Debug)]
pub struct RecordedTurn {
pub turn: u64,
pub inputs: Vec<TurnInput>,
pub turn_hash: Hash,
pub events: Vec<String>,
}
/// The full replay log for a match.
#[derive(Clone, Debug)]
pub struct ReplayLog {
pub seed: u64,
pub roster: Vec<RosterEntry>,
pub turns: Vec<RecordedTurn>,
pub final_hash: Hash,
}
/// The authoritative match state.
#[derive(Clone, Debug)]
pub struct Match {
pub id: MatchId,
pub seed: u64,
pub turn: u64,
pub world: WorldSnapshot,
pub contexts: Vec<ExecutionContext>,
pub entities: Vec<Entity>,
pub history: Vec<String>,
pub replay: ReplayLog,
pub finished: bool,
/// The most recent per-domain observed change, used to tag freshly-observed
/// lanes without storing per-player memory (keeps resolution stateless).
last_observed_delta: [[i64; LANES]; NUM_DOMAINS],
}
/// A blank default program (a single benign token) so every entity always has
/// something to cast.
fn default_program(seed: u64) -> RuneProgram {
let mut rng = Rng::derive(seed, "default-program");
let tokens = (0..8)
.map(|i| RuneToken {
op: Op::from_u8((i as u8).wrapping_add(rng.next_u64() as u8)),
a: rng.next_u64() as u8,
b: rng.next_u64() as u8,
c: rng.next_u64() as u8,
imm: rng.range_i64(-1000, 1000),
})
.collect();
RuneProgram { id: ProgramId(seed), tokens, seed }
}
impl Match {
/// Create a match deterministically from a seed and a roster.
pub fn new(id: MatchId, seed: u64, roster: Vec<RosterEntry>) -> Match {
let world = generators::generate_world(seed);
let contexts = standard_executors(seed, 3);
let mut placer = Rng::derive(seed, "arena-placement");
let mut taken: Vec<(i32, i32)> = Vec::new();
let mut entities = Vec::with_capacity(roster.len());
for entry in &roster {
// Deterministic distinct placement.
let (x, y) = loop {
let x = placer.below(ARENA_W as usize) as i32;
let y = placer.below(ARENA_H as usize) as i32;
if !taken.contains(&(x, y)) {
break (x, y);
}
};
taken.push((x, y));
entities.push(Entity {
id: entry.id,
name: entry.name.clone(),
x,
y,
hp: MAX_HP,
is_dummy: entry.is_dummy,
program: default_program(seed ^ (entry.id as u64).wrapping_mul(0x9e3779b97f4a7c15)),
});
}
Match {
id,
seed,
turn: 0,
world,
contexts,
entities,
history: Vec::new(),
replay: ReplayLog { seed, roster, turns: Vec::new(), final_hash: Hash(0) },
finished: false,
last_observed_delta: [[0; LANES]; NUM_DOMAINS],
}
}
pub fn entity(&self, id: u32) -> Option<&Entity> {
self.entities.iter().find(|e| e.id == id)
}
pub fn entity_mut(&mut self, id: u32) -> Option<&mut Entity> {
self.entities.iter_mut().find(|e| e.id == id)
}
/// Replace a player's editable program (Phase D / `EditRuneProgram`). Length
/// is bounded by the protocol decoder; this just stores it.
pub fn set_program(&mut self, player: u32, tokens: Vec<RuneTokenWire>) {
let seed = self.seed;
if let Some(e) = self.entity_mut(player) {
e.program = RuneProgram {
id: ProgramId(player as u64),
seed: seed ^ player as u64,
tokens: tokens.iter().map(|t| t.into_token()).collect(),
};
}
}
fn resolution_input(&self, program: &RuneProgram) -> ResolutionInput {
ResolutionInput {
world: self.world.clone(),
program: program.clone(),
contexts: self.contexts.clone(),
contract_seed: self.seed,
perturbation_seed: self.seed ^ self.turn,
}
}
/// Resolve one turn from a set of `(player, action)` submissions. Missing
/// players default to `Wait`. Returns the per-turn event list. This is the
/// authoritative state transition and is fully deterministic.
pub fn resolve_turn(&mut self, submissions: &[(u32, Action)]) -> Vec<String> {
// Build a canonical, complete, sorted input set: one action per entity.
let mut inputs: Vec<TurnInput> = Vec::new();
let mut ids: Vec<u32> = self.entities.iter().map(|e| e.id).collect();
ids.sort_unstable();
for id in ids {
let action = submissions
.iter()
.find(|(pid, _)| *pid == id)
.map(|(_, a)| a.clone())
.unwrap_or(Action::Wait);
let program = if matches!(action, Action::Cast) {
self.entity(id)
.map(|e| e.program.tokens.iter().map(RuneTokenWire::from_token).collect())
} else {
None
};
inputs.push(TurnInput { player: id, action, program });
}
let before = self.world.clone();
let mut events = Vec::new();
for input in &inputs {
self.apply_action(input, &mut events);
}
// Knowledge bookkeeping: which observed lanes changed this turn.
for d in 0..NUM_DOMAINS {
for l in 0..LANES {
self.last_observed_delta[d][l] =
self.world.domains[d].observed[l].wrapping_sub(before.domains[d].observed[l]);
}
}
self.turn = self.turn.wrapping_add(1);
// Per-turn hash binds every effect: world state + entity state + inputs.
let turn_hash = self.turn_hash(&inputs);
for e in &events {
self.history.push(format!("turn {}: {}", self.turn, e));
}
// End condition: in a multi-player match, finish when at most one
// non-dummy combatant is still standing.
let players = self.entities.iter().filter(|e| !e.is_dummy).count();
let living_players = self.entities.iter().filter(|e| !e.is_dummy && e.alive()).count();
if players >= 2 && living_players <= 1 {
self.finished = true;
}
self.replay.turns.push(RecordedTurn {
turn: self.turn,
inputs,
turn_hash,
events: events.clone(),
});
self.recompute_final_hash();
events
}
fn apply_action(&mut self, input: &TurnInput, events: &mut Vec<String>) {
// Skip dead entities entirely.
let alive = self.entity(input.player).map(|e| e.alive()).unwrap_or(false);
if !alive {
return;
}
match &input.action {
Action::Wait => {}
Action::Move { dx, dy } => {
let (nx, ny) = {
let e = self.entity(input.player).unwrap();
(
(e.x + dx.clamp(&-1, &1)).clamp(0, ARENA_W - 1),
(e.y + dy.clamp(&-1, &1)).clamp(0, ARENA_H - 1),
)
};
let occupied = self
.entities
.iter()
.any(|o| o.id != input.player && o.alive() && o.x == nx && o.y == ny);
if !occupied {
let name = self.entity(input.player).unwrap().name.clone();
let e = self.entity_mut(input.player).unwrap();
e.x = nx;
e.y = ny;
events.push(format!("{name} moved to ({nx},{ny})"));
}
}
Action::Attack { target } => {
let attacker = self.entity(input.player).unwrap().clone();
if let Some(t) = self.entity(*target) {
let adjacent = (t.x - attacker.x).abs() <= 1 && (t.y - attacker.y).abs() <= 1;
if adjacent && t.alive() && *target != input.player {
let tname = t.name.clone();
let te = self.entity_mut(*target).unwrap();
te.hp = (te.hp - ATTACK_DAMAGE).max(0);
let hp = te.hp;
events.push(format!(
"{} struck {} for {ATTACK_DAMAGE} ({} hp left)",
attacker.name, tname, hp
));
if hp == 0 {
events.push(format!("{tname} fell"));
}
}
}
}
Action::Inspect { target } => {
if let Some(t) = self.entity(*target) {
events.push(format!(
"{} inspected {}",
self.entity(input.player).unwrap().name,
t.name
));
}
}
Action::Cast => {
let program = match &input.program {
Some(toks) => RuneProgram {
id: ProgramId(input.player as u64),
seed: self.seed ^ input.player as u64,
tokens: toks.iter().map(|t| t.into_token()).collect(),
},
None => self.entity(input.player).unwrap().program.clone(),
};
let res = native_resolve(&self.resolution_input(&program));
self.apply_resolution(&res);
let power = cast_power(&res);
let caster = self.entity(input.player).unwrap().clone();
events.push(format!("{} cast a rune program (power {power})", caster.name));
// Consequence: enemies within range take `power` damage.
let targets: Vec<u32> = self
.entities
.iter()
.filter(|o| {
o.id != input.player
&& o.alive()
&& (o.x - caster.x).abs() + (o.y - caster.y).abs() <= CAST_RANGE
})
.map(|o| o.id)
.collect();
for tid in targets {
let tname = self.entity(tid).unwrap().name.clone();
let te = self.entity_mut(tid).unwrap();
te.hp = (te.hp - power).max(0);
let hp = te.hp;
events.push(format!("{tname} took {power} from the working ({hp} hp left)"));
if hp == 0 {
events.push(format!("{tname} fell"));
}
}
}
}
}
/// Apply a resolution's world delta to the shared world (observed + hidden).
fn apply_resolution(&mut self, res: &ResolutionResult) {
for dd in &res.delta.domain_deltas {
let d = dd.domain.0 as usize;
if d >= NUM_DOMAINS {
continue;
}
for l in 0..LANES {
self.world.domains[d].observed[l] =
self.world.domains[d].observed[l].wrapping_add(dd.observed[l]);
}
for l in 0..HIDDEN_LANES {
self.world.domains[d].hidden[l] =
self.world.domains[d].hidden[l].wrapping_add(dd.hidden[l]);
}
}
}
fn turn_hash(&self, inputs: &[TurnInput]) -> Hash {
let mut h = Hasher::new();
h.write_tag("game-turn");
h.write_u64(self.turn);
h.write_u64(self.world.content_hash().0);
for e in &self.entities {
h.write_u64(e.id as u64);
h.write_i64(e.x as i64);
h.write_i64(e.y as i64);
h.write_i64(e.hp as i64);
}
for input in inputs {
h.write_u64(input.player as u64);
hash_action(&mut h, &input.action);
if let Some(prog) = &input.program {
h.write_usize(prog.len());
for t in prog {
h.write_u8(t.op);
h.write_u8(t.a);
h.write_u8(t.b);
h.write_u8(t.c);
h.write_i64(t.imm);
}
}
}
h.finish()
}
fn recompute_final_hash(&mut self) {
let mut h = Hasher::new();
h.write_tag("game-replay-final");
h.write_u64(self.seed);
for entry in &self.replay.roster {
h.write_u64(entry.id as u64);
h.write_bytes(entry.name.as_bytes());
h.write_u8(entry.is_dummy as u8);
}
for t in &self.replay.turns {
h.write_u64(t.turn_hash.0);
}
self.replay.final_hash = h.finish();
}
/// Hex string of the most recent turn's hash (Phase G `runtime_hash`).
pub fn last_turn_hash_hex(&self) -> String {
format!("{}", self.replay.turns.last().map(|t| t.turn_hash).unwrap_or(Hash(0)))
}
pub fn final_hash_hex(&self) -> String {
format!("{}", self.replay.final_hash)
}
// -- Visibility / knowledge (Phase F) -----------------------------------
/// Build the filtered snapshot for one player. Hidden lanes and non-visible
/// observed lanes are redacted; only their *count* is reported.
pub fn visible_for(&self, player: u32) -> VisibleWorldSnapshot {
let projection = self.world.observed_projection();
let mut observed_domains = Vec::with_capacity(NUM_DOMAINS);
let mut redactions: u32 = 0;
for d in 0..NUM_DOMAINS {
let mut observed = Vec::with_capacity(LANES);
let mut knowledge = Vec::with_capacity(LANES);
for l in 0..LANES {
if self.world.observation_state.visible[d][l] {
observed.push(Some(projection[d * LANES + l]));
knowledge.push(if self.last_observed_delta[d][l] != 0 {
Knowledge::NewlyObserved
} else {
Knowledge::Known
});
} else {
observed.push(None);
knowledge.push(Knowledge::Unknown);
redactions += 1;
}
}
observed_domains.push(VisibleDomain {
index: d as u8,
name: DomainKind::from_index(d).name().to_string(),
observed,
knowledge,
});
}
// All hidden lanes are always withheld.
redactions += (NUM_DOMAINS * HIDDEN_LANES) as u32;
let observed_entities = self
.entities
.iter()
.map(|e| VisibleEntity {
id: e.id,
name: e.name.clone(),
x: e.x,
y: e.y,
hp: e.hp,
is_self: e.id == player,
alive: e.alive(),
})
.collect();
// Inference from *observed* volatility only — never from hidden state.
// Use a presence test (any lane changed) rather than summing magnitudes,
// which avoids overflow on wrapping deltas near i64::MIN.
let mut inferred_markers = Vec::new();
for d in 0..NUM_DOMAINS {
let shifted = (0..LANES).any(|l| self.last_observed_delta[d][l] != 0);
if shifted && self.world.observation_state.visible[d].iter().any(|&v| v) {
inferred_markers.push(format!(
"{} shifted recently — likely volatile",
DomainKind::from_index(d).name()
));
}
}
let known_history: Vec<String> = self.history.iter().rev().take(8).rev().cloned().collect();
VisibleWorldSnapshot {
turn: self.turn,
arena_w: ARENA_W,
arena_h: ARENA_H,
observed_domains,
observed_entities,
observed_environment: vec![
format!("arena {ARENA_W}x{ARENA_H}"),
format!("turn {}", self.turn),
],
known_history,
inferred_markers,
hidden_state_redactions: redactions,
}
}
/// Player-facing diagnostics for a candidate program (Phase D). A *dry run*
/// against a clone of the world — it mutates nothing and never reports
/// hidden values, only domain names, counts, and observed fault risks.
pub fn diagnostics_for(&self, program: &RuneProgram) -> RuneDiagnostics {
let res = native_resolve(&self.resolution_input(program));
let visible_domain = |d: usize| self.world.observation_state.visible[d].iter().any(|&v| v);
let mut known_reads = Vec::new();
for d in res.trace.read_graph.touched() {
if visible_domain(d) {
known_reads.push(DomainKind::from_index(d).name().to_string());
}
}
known_reads.sort();
known_reads.dedup();
let mut known_writes = Vec::new();
let mut unknown_listeners = 0u32;
for d in res.trace.write_graph.touched() {
if visible_domain(d) {
known_writes.push(DomainKind::from_index(d).name().to_string());
} else {
unknown_listeners += 1;
}
}
known_writes.sort();
known_writes.dedup();
let mut observed_risks = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for f in &res.faults.faults {
if seen.insert(f.code.name()) {
observed_risks.push(format!("possible {}", f.code.name()));
}
}
let matching: Vec<String> = self
.history
.iter()
.filter(|h| h.contains("cast") || h.contains("working"))
.cloned()
.collect();
let start = matching.len().saturating_sub(4);
let previous_outcomes: Vec<String> = matching[start..].to_vec();
RuneDiagnostics {
known_reads,
known_writes,
observed_risks,
unknown_listeners,
previous_outcomes,
}
}
/// Diagnostics for a player's currently-stored program.
pub fn diagnostics_for_player(&self, player: u32) -> RuneDiagnostics {
match self.entity(player) {
Some(e) => self.diagnostics_for(&e.program),
None => RuneDiagnostics::default(),
}
}
}
/// Damage power derived from the runtime trace — the rune program's effect on
/// the game is a function of the structure the Rust engine actually produced.
fn cast_power(res: &ResolutionResult) -> i32 {
let rank = res.trace.causal_rank() as i32;
let touched = res.trace.touched_domain_count() as i32;
(1 + rank + touched / 2).clamp(1, 10)
}
fn hash_action(h: &mut Hasher, a: &Action) {
match a {
Action::Wait => h.write_u8(0),
Action::Move { dx, dy } => {
h.write_u8(1);
h.write_i64(*dx as i64);
h.write_i64(*dy as i64);
}
Action::Inspect { target } => {
h.write_u8(2);
h.write_u64(*target as u64);
}
Action::Cast => h.write_u8(3),
Action::Attack { target } => {
h.write_u8(4);
h.write_u64(*target as u64);
}
}
}
/// Build a standard 1-player + dummy roster.
pub fn solo_roster(player_name: &str) -> Vec<RosterEntry> {
vec![
RosterEntry { id: 1, name: player_name.to_string(), is_dummy: false },
RosterEntry { id: 2, name: "training dummy".to_string(), is_dummy: true },
]
}
/// Build a 2-player roster.
pub fn duel_roster(a: &str, b: &str) -> Vec<RosterEntry> {
vec![
RosterEntry { id: 1, name: a.to_string(), is_dummy: false },
RosterEntry { id: 2, name: b.to_string(), is_dummy: false },
]
}
/// Re-run a match from its seed, roster, and the exact recorded inputs, and
/// return the reconstructed log. Determinism gate: this must reproduce the
/// original `final_hash` bit-for-bit.
pub fn replay(seed: u64, roster: &[RosterEntry], recorded: &[RecordedTurn]) -> ReplayLog {
let mut m = Match::new(MatchId(0), seed, roster.to_vec());
for rt in recorded {
// Restore each casting player's program from the record, then apply the
// same actions in the same order.
for input in &rt.inputs {
if let (Action::Cast, Some(prog)) = (&input.action, &input.program) {
m.set_program(input.player, prog.clone());
}
}
let subs: Vec<(u32, Action)> =
rt.inputs.iter().map(|i| (i.player, i.action.clone())).collect();
m.resolve_turn(&subs);
}
m.replay
}
/// Convenience: run a scripted match end-to-end and return its log. Used by the
/// determinism tests and the headless E2E harness.
pub fn run_scripted(
seed: u64,
roster: &[RosterEntry],
scripts: &[Vec<(u32, Action)>],
) -> (Match, ReplayLog) {
let mut m = Match::new(MatchId(seed), seed, roster.to_vec());
for turn_subs in scripts {
m.resolve_turn(turn_subs);
}
let log = m.replay.clone();
(m, log)
}
/// Canonical fingerprint of a single resolution (used by integration tests to
/// assert the game layer truly drove the independent interpreter).
pub fn resolution_fingerprint(world: &WorldSnapshot, program: &RuneProgram, seed: u64) -> Hash {
let input = ResolutionInput {
world: world.clone(),
program: program.clone(),
contexts: standard_executors(seed, 3),
contract_seed: seed,
perturbation_seed: seed,
};
let c = canonical(&native_resolve(&input));
let mut h = Hasher::new();
h.write_tag("resolution-fp");
h.write_u64(c.delta_hash.0);
h.write_u64(c.trace_hash.0);
h.write_u64(c.replay_hash.0);
h.finish()
}
#[cfg(test)]
mod tests {
use super::*;
fn rune(op: u8, a: u8, b: u8, c: u8, imm: i64) -> RuneTokenWire {
RuneTokenWire { op, a, b, c, imm }
}
fn scripted_match() -> Vec<Vec<(u32, Action)>> {
vec![
vec![(1, Action::Move { dx: 1, dy: 0 })],
vec![(1, Action::Cast)],
vec![(1, Action::Attack { target: 2 })],
vec![(1, Action::Wait), (2, Action::Wait)],
vec![(1, Action::Cast)],
]
}
#[test]
fn match_resolves_through_independent_interpreter() {
let mut m = Match::new(MatchId(1), 7, solo_roster("dev"));
m.set_program(1, vec![rune(0, 1, 2, 3, 4), rune(5, 2, 1, 0, -3)]);
let before = m.world.content_hash();
m.resolve_turn(&[(1, Action::Cast)]);
// A cast changed the shared world via the independent interpreter.
assert_ne!(before, m.world.content_hash());
assert_eq!(m.turn, 1);
}
#[test]
fn replay_reproduces_final_hash() {
let seed = 12345;
let roster = solo_roster("dev");
let mut m = Match::new(MatchId(seed), seed, roster.clone());
m.set_program(1, vec![rune(2, 3, 4, 5, 6), rune(8, 1, 1, 1, 1), rune(0, 7, 7, 7, 7)]);
for subs in scripted_match() {
m.resolve_turn(&subs);
}
let original = m.replay.final_hash;
// Replay from the recorded inputs alone.
let reconstructed = replay(seed, &roster, &m.replay.turns);
assert_eq!(original, reconstructed.final_hash, "replay drifted");
}
#[test]
fn many_matches_are_deterministic() {
for seed in 0..200u64 {
let roster = solo_roster("p");
let (m, log) = run_scripted(seed, &roster, &scripted_match());
let again = replay(seed, &roster, &log.turns);
assert_eq!(m.replay.final_hash, again.final_hash, "seed {seed} not deterministic");
}
}
#[test]
fn hidden_state_never_appears_in_visible_snapshot() {
let mut m = Match::new(MatchId(1), 999, solo_roster("dev"));
// Mask some observed lanes so redaction is non-trivial.
for d in 0..NUM_DOMAINS {
m.world.observation_state.visible[d][1] = false;
}
m.set_program(1, vec![rune(10, 1, 2, 3, 4)]);
m.resolve_turn(&[(1, Action::Cast)]);
let snap = m.visible_for(1);
for vd in &snap.observed_domains {
for (l, o) in vd.observed.iter().enumerate() {
if !m.world.observation_state.visible[vd.index as usize][l] {
assert!(o.is_none(), "masked lane leaked a value");
}
}
}
assert!(snap.hidden_state_redactions >= (NUM_DOMAINS * HIDDEN_LANES) as u32);
}
#[test]
fn diagnostics_are_names_and_counts_only() {
let m = Match::new(MatchId(1), 5, solo_roster("dev"));
let diag = m.diagnostics_for_player(1);
for s in diag.known_reads.iter().chain(diag.known_writes.iter()) {
assert!(s.chars().any(|c| c.is_alphabetic()), "diagnostic should be a domain name");
}
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "protocol"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
world_model = { path = "../world_model" }
rune_ir = { path = "../rune_ir" }
trace_model = { path = "../trace_model" }
reference_runtime = { path = "../reference_runtime" }
+531
View File
@@ -0,0 +1,531 @@
//! A complete hand-rolled JSON value, serializer, and parser (no external
//! crates). The orchestrator's `ci_reports::json` is write-only; the protocol
//! needs to *parse* untrusted client packets too, and parsing must be **total**
//! — any byte sequence yields `Ok` or `Err`, never a panic. That totality is
//! what lets the server treat a malformed packet as a deterministic
//! `ValidationReport` rather than a crash.
use std::collections::BTreeMap;
use std::fmt;
/// A parsed JSON value. Objects use a `BTreeMap` so key order is canonical,
/// which keeps re-serialization stable and hashable.
#[derive(Clone, PartialEq, Debug)]
pub enum Json {
Null,
Bool(bool),
/// All numbers are carried as `f64`; integer accessors round-trip exact
/// values within the safe integer range, which is all the protocol uses.
Num(f64),
Str(String),
Arr(Vec<Json>),
Obj(BTreeMap<String, Json>),
}
impl Json {
pub fn s(v: impl Into<String>) -> Json {
Json::Str(v.into())
}
pub fn i(v: i64) -> Json {
Json::Num(v as f64)
}
pub fn u(v: u64) -> Json {
Json::Num(v as f64)
}
pub fn obj(fields: Vec<(&str, Json)>) -> Json {
let mut m = BTreeMap::new();
for (k, v) in fields {
m.insert(k.to_string(), v);
}
Json::Obj(m)
}
// ---- typed accessors (all fallible, none panic) ----
pub fn get(&self, key: &str) -> Option<&Json> {
match self {
Json::Obj(m) => m.get(key),
_ => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
Json::Str(s) => Some(s),
_ => None,
}
}
pub fn as_f64(&self) -> Option<f64> {
match self {
Json::Num(n) => Some(*n),
_ => None,
}
}
pub fn as_i64(&self) -> Option<i64> {
match self {
Json::Num(n) if n.is_finite() => Some(*n as i64),
_ => None,
}
}
pub fn as_u64(&self) -> Option<u64> {
match self {
Json::Num(n) if n.is_finite() && *n >= 0.0 => Some(*n as u64),
_ => None,
}
}
pub fn as_u8(&self) -> Option<u8> {
self.as_u64().and_then(|v| u8::try_from(v).ok())
}
pub fn as_bool(&self) -> Option<bool> {
match self {
Json::Bool(b) => Some(*b),
_ => None,
}
}
pub fn as_arr(&self) -> Option<&[Json]> {
match self {
Json::Arr(a) => Some(a),
_ => None,
}
}
/// Convenience: required field accessors that produce a descriptive error.
pub fn field<'a>(&'a self, key: &str) -> Result<&'a Json, JsonError> {
self.get(key).ok_or_else(|| JsonError::Field(key.to_string()))
}
pub fn str_field(&self, key: &str) -> Result<String, JsonError> {
self.field(key)?
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| JsonError::Type(key.to_string(), "string"))
}
pub fn u64_field(&self, key: &str) -> Result<u64, JsonError> {
self.field(key)?
.as_u64()
.ok_or_else(|| JsonError::Type(key.to_string(), "u64"))
}
pub fn i64_field(&self, key: &str) -> Result<i64, JsonError> {
self.field(key)?
.as_i64()
.ok_or_else(|| JsonError::Type(key.to_string(), "i64"))
}
pub fn arr_field<'a>(&'a self, key: &str) -> Result<&'a [Json], JsonError> {
self.field(key)?
.as_arr()
.ok_or_else(|| JsonError::Type(key.to_string(), "array"))
}
// ---- serialization ----
/// Compact canonical serialization (no whitespace). Deterministic because
/// object keys are stored sorted.
pub fn to_compact(&self) -> String {
let mut out = String::new();
self.write(&mut out);
out
}
fn write(&self, out: &mut String) {
match self {
Json::Null => out.push_str("null"),
Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
Json::Num(n) => {
if !n.is_finite() {
out.push_str("null");
} else if *n == n.trunc() && n.abs() < 9_007_199_254_740_992.0 {
// Exact integer: print without a decimal point.
out.push_str(&(*n as i64).to_string());
} else {
out.push_str(&format!("{}", n));
}
}
Json::Str(s) => write_str(out, s),
Json::Arr(items) => {
out.push('[');
for (i, it) in items.iter().enumerate() {
if i > 0 {
out.push(',');
}
it.write(out);
}
out.push(']');
}
Json::Obj(m) => {
out.push('{');
for (i, (k, v)) in m.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_str(out, k);
out.push(':');
v.write(out);
}
out.push('}');
}
}
}
}
fn write_str(out: &mut String, s: &str) {
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
}
/// A JSON parse / shape error. Carrying a message keeps decode failures
/// diagnosable without ever unwinding.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum JsonError {
Parse(String),
Field(String),
Type(String, &'static str),
}
impl fmt::Display for JsonError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
JsonError::Parse(m) => write!(f, "json parse error: {m}"),
JsonError::Field(k) => write!(f, "missing field: {k}"),
JsonError::Type(k, t) => write!(f, "field {k} is not a {t}"),
}
}
}
impl std::error::Error for JsonError {}
/// Parse a JSON document. Total: never panics on any input.
pub fn parse(input: &str) -> Result<Json, JsonError> {
let bytes = input.as_bytes();
let mut p = Parser { bytes, pos: 0, depth: 0 };
p.skip_ws();
let v = p.value()?;
p.skip_ws();
if p.pos != bytes.len() {
return Err(JsonError::Parse("trailing characters".into()));
}
Ok(v)
}
/// Maximum nesting depth. Bounds recursion so a deeply-nested adversarial
/// packet returns `Err` instead of overflowing the stack.
const MAX_DEPTH: usize = 64;
struct Parser<'a> {
bytes: &'a [u8],
pos: usize,
depth: usize,
}
impl<'a> Parser<'a> {
fn peek(&self) -> Option<u8> {
self.bytes.get(self.pos).copied()
}
fn skip_ws(&mut self) {
while let Some(b) = self.peek() {
if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' {
self.pos += 1;
} else {
break;
}
}
}
fn value(&mut self) -> Result<Json, JsonError> {
self.depth += 1;
if self.depth > MAX_DEPTH {
return Err(JsonError::Parse("max depth exceeded".into()));
}
let r = match self.peek() {
Some(b'{') => self.object(),
Some(b'[') => self.array(),
Some(b'"') => Ok(Json::Str(self.string()?)),
Some(b't') | Some(b'f') => self.boolean(),
Some(b'n') => self.null(),
Some(b'-') | Some(b'0'..=b'9') => self.number(),
Some(c) => Err(JsonError::Parse(format!("unexpected byte '{}'", c as char))),
None => Err(JsonError::Parse("unexpected end".into())),
};
self.depth -= 1;
r
}
fn expect(&mut self, b: u8) -> Result<(), JsonError> {
if self.peek() == Some(b) {
self.pos += 1;
Ok(())
} else {
Err(JsonError::Parse(format!("expected '{}'", b as char)))
}
}
fn object(&mut self) -> Result<Json, JsonError> {
self.expect(b'{')?;
let mut m = BTreeMap::new();
self.skip_ws();
if self.peek() == Some(b'}') {
self.pos += 1;
return Ok(Json::Obj(m));
}
loop {
self.skip_ws();
let key = self.string()?;
self.skip_ws();
self.expect(b':')?;
self.skip_ws();
let val = self.value()?;
m.insert(key, val);
self.skip_ws();
match self.peek() {
Some(b',') => {
self.pos += 1;
continue;
}
Some(b'}') => {
self.pos += 1;
break;
}
_ => return Err(JsonError::Parse("expected ',' or '}'".into())),
}
}
Ok(Json::Obj(m))
}
fn array(&mut self) -> Result<Json, JsonError> {
self.expect(b'[')?;
let mut a = Vec::new();
self.skip_ws();
if self.peek() == Some(b']') {
self.pos += 1;
return Ok(Json::Arr(a));
}
loop {
self.skip_ws();
a.push(self.value()?);
self.skip_ws();
match self.peek() {
Some(b',') => {
self.pos += 1;
continue;
}
Some(b']') => {
self.pos += 1;
break;
}
_ => return Err(JsonError::Parse("expected ',' or ']'".into())),
}
}
Ok(Json::Arr(a))
}
fn string(&mut self) -> Result<String, JsonError> {
self.expect(b'"')?;
let mut s = String::new();
loop {
match self.peek() {
None => return Err(JsonError::Parse("unterminated string".into())),
Some(b'"') => {
self.pos += 1;
break;
}
Some(b'\\') => {
self.pos += 1;
match self.peek() {
Some(b'"') => s.push('"'),
Some(b'\\') => s.push('\\'),
Some(b'/') => s.push('/'),
Some(b'n') => s.push('\n'),
Some(b'r') => s.push('\r'),
Some(b't') => s.push('\t'),
Some(b'b') => s.push('\u{0008}'),
Some(b'f') => s.push('\u{000c}'),
Some(b'u') => {
let cp = self.hex4()?;
// Handle surrogate pairs.
if (0xD800..=0xDBFF).contains(&cp) {
if self.peek() == Some(b'\\') {
self.pos += 1;
if self.peek() == Some(b'u') {
let lo = self.hex4()?;
if (0xDC00..=0xDFFF).contains(&lo) {
let c = 0x10000
+ ((cp - 0xD800) << 10)
+ (lo - 0xDC00);
if let Some(ch) = char::from_u32(c) {
s.push(ch);
} else {
s.push('\u{FFFD}');
}
continue;
}
}
}
s.push('\u{FFFD}');
} else if let Some(ch) = char::from_u32(cp) {
s.push(ch);
} else {
s.push('\u{FFFD}');
}
continue;
}
_ => return Err(JsonError::Parse("bad escape".into())),
}
self.pos += 1;
}
Some(_) => {
// Copy one UTF-8 codepoint from the source.
let start = self.pos;
let len = utf8_len(self.bytes[start]);
if start + len > self.bytes.len() {
return Err(JsonError::Parse("bad utf8".into()));
}
match std::str::from_utf8(&self.bytes[start..start + len]) {
Ok(chunk) => s.push_str(chunk),
Err(_) => return Err(JsonError::Parse("bad utf8".into())),
}
self.pos += len;
}
}
}
Ok(s)
}
fn hex4(&mut self) -> Result<u32, JsonError> {
// assumes the 'u' has been consumed
self.pos += 1;
let mut v: u32 = 0;
for _ in 0..4 {
let d = self
.peek()
.and_then(|b| (b as char).to_digit(16))
.ok_or_else(|| JsonError::Parse("bad \\u".into()))?;
v = v * 16 + d;
self.pos += 1;
}
Ok(v)
}
fn boolean(&mut self) -> Result<Json, JsonError> {
if self.bytes[self.pos..].starts_with(b"true") {
self.pos += 4;
Ok(Json::Bool(true))
} else if self.bytes[self.pos..].starts_with(b"false") {
self.pos += 5;
Ok(Json::Bool(false))
} else {
Err(JsonError::Parse("bad literal".into()))
}
}
fn null(&mut self) -> Result<Json, JsonError> {
if self.bytes[self.pos..].starts_with(b"null") {
self.pos += 4;
Ok(Json::Null)
} else {
Err(JsonError::Parse("bad literal".into()))
}
}
fn number(&mut self) -> Result<Json, JsonError> {
let start = self.pos;
if self.peek() == Some(b'-') {
self.pos += 1;
}
while let Some(b'0'..=b'9') = self.peek() {
self.pos += 1;
}
if self.peek() == Some(b'.') {
self.pos += 1;
while let Some(b'0'..=b'9') = self.peek() {
self.pos += 1;
}
}
if let Some(b'e') | Some(b'E') = self.peek() {
self.pos += 1;
if let Some(b'+') | Some(b'-') = self.peek() {
self.pos += 1;
}
while let Some(b'0'..=b'9') = self.peek() {
self.pos += 1;
}
}
let slice = std::str::from_utf8(&self.bytes[start..self.pos])
.map_err(|_| JsonError::Parse("bad number".into()))?;
slice
.parse::<f64>()
.map(Json::Num)
.map_err(|_| JsonError::Parse("bad number".into()))
}
}
fn utf8_len(b: u8) -> usize {
if b < 0x80 {
1
} else if b >> 5 == 0b110 {
2
} else if b >> 4 == 0b1110 {
3
} else if b >> 3 == 0b11110 {
4
} else {
1
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_basic() {
let v = Json::obj(vec![
("a", Json::i(42)),
("b", Json::Arr(vec![Json::Bool(true), Json::Null, Json::s("x")])),
("c", Json::Num(1.5)),
]);
let s = v.to_compact();
let back = parse(&s).unwrap();
assert_eq!(v, back);
}
#[test]
fn parse_never_panics_on_garbage() {
let deep = "{".repeat(1000);
let cases = [
"", "{", "[", "\"", "nul", "{\"a\":}", "[1,2,", "tru", "12.3.4",
"{\"a\"1}", "\\", "\"\\u00\"", deep.as_str(),
];
for c in cases {
// Must return without panicking; value is irrelevant.
let _ = parse(c);
}
}
#[test]
fn deep_nesting_is_rejected_not_overflowed() {
let deep = "[".repeat(10_000);
assert!(parse(&deep).is_err());
}
#[test]
fn integers_roundtrip_exact() {
let v = Json::i(-1234567890123);
assert_eq!(parse(&v.to_compact()).unwrap().as_i64(), Some(-1234567890123));
}
#[test]
fn escapes_roundtrip() {
let v = Json::s("line\ntab\tquote\"slash\\end");
let s = v.to_compact();
assert_eq!(parse(&s).unwrap(), v);
}
}
+858
View File
@@ -0,0 +1,858 @@
//! `protocol` — the versioned, serializable, hashable client/server message
//! contract (Phase A of `plan2.md`). Defined **before** any UI.
//!
//! Invariants enforced here:
//! * Every message carries a protocol version (`PROTOCOL_VERSION`); a decoder
//! rejects mismatched versions deterministically.
//! * Decoding is **total**: any byte string yields `Ok(msg)` or `Err(..)`,
//! never a panic. The server relies on this to turn a malformed client
//! packet into a `ValidationReport`/`ErrorEvent` instead of crashing.
//! * Every server output is **hashable** ([`ServerMessage::content_hash`]) over
//! a canonical (sorted-key, whitespace-free) serialization, so replays and
//! the browser can verify byte-for-byte agreement with the server.
//! * No game truth lives client-side: client messages carry only *intent*
//! (movement choice, rune program, slot selection, inspection request).
pub mod json;
pub use json::{parse, Json, JsonError};
use world_model::{Hash, Hasher};
/// Protocol version. Bumped on any wire-incompatible change. Both peers check
/// it on every message.
pub const PROTOCOL_VERSION: u32 = 1;
/// Stable identifier for a connected player within a match.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct PlayerId(pub u32);
/// Stable identifier for a match.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct MatchId(pub u64);
// ---------------------------------------------------------------------------
// Rune token wire form (mirror of `rune_ir::RuneToken`, kept independent so the
// wire format does not silently change when the IR changes).
// ---------------------------------------------------------------------------
/// One rune token as it crosses the wire. `op` is the opcode index
/// (`rune_ir::Op::to_u8`); every field is interpreted modulo its range by the
/// runtime, so no token value is ever rejected.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct RuneTokenWire {
pub op: u8,
pub a: u8,
pub b: u8,
pub c: u8,
pub imm: i64,
}
impl RuneTokenWire {
pub fn to_json(self) -> Json {
Json::obj(vec![
("op", Json::u(self.op as u64)),
("a", Json::u(self.a as u64)),
("b", Json::u(self.b as u64)),
("c", Json::u(self.c as u64)),
("imm", Json::i(self.imm)),
])
}
pub fn from_json(j: &Json) -> Result<Self, JsonError> {
Ok(RuneTokenWire {
op: j.field("op")?.as_u8().ok_or(JsonError::Type("op".into(), "u8"))?,
a: j.field("a")?.as_u8().ok_or(JsonError::Type("a".into(), "u8"))?,
b: j.field("b")?.as_u8().ok_or(JsonError::Type("b".into(), "u8"))?,
c: j.field("c")?.as_u8().ok_or(JsonError::Type("c".into(), "u8"))?,
imm: j.i64_field("imm")?,
})
}
pub fn into_token(self) -> rune_ir::RuneToken {
rune_ir::RuneToken {
op: rune_ir::Op::from_u8(self.op),
a: self.a,
b: self.b,
c: self.c,
imm: self.imm,
}
}
pub fn from_token(t: &rune_ir::RuneToken) -> Self {
RuneTokenWire { op: t.op.to_u8(), a: t.a, b: t.b, c: t.c, imm: t.imm }
}
}
// ---------------------------------------------------------------------------
// Player intent / actions (client -> server only).
// ---------------------------------------------------------------------------
/// A single per-turn action chosen by a player. The browser only ever sends
/// *intent*; the server is the sole authority on the outcome.
#[derive(Clone, PartialEq, Debug)]
pub enum Action {
/// Step one cell on the arena grid (`dx`,`dy` in {-1,0,1}).
Move { dx: i32, dy: i32 },
/// Inspect a target entity (request diagnostics about it).
Inspect { target: u32 },
/// Cast the player's currently-edited rune program.
Cast,
/// Basic stick/melee attack against a target entity.
Attack { target: u32 },
/// Pass the turn.
Wait,
}
impl Action {
pub fn to_json(&self) -> Json {
match self {
Action::Move { dx, dy } => Json::obj(vec![
("kind", Json::s("move")),
("dx", Json::i(*dx as i64)),
("dy", Json::i(*dy as i64)),
]),
Action::Inspect { target } => Json::obj(vec![
("kind", Json::s("inspect")),
("target", Json::u(*target as u64)),
]),
Action::Cast => Json::obj(vec![("kind", Json::s("cast"))]),
Action::Attack { target } => Json::obj(vec![
("kind", Json::s("attack")),
("target", Json::u(*target as u64)),
]),
Action::Wait => Json::obj(vec![("kind", Json::s("wait"))]),
}
}
pub fn from_json(j: &Json) -> Result<Self, JsonError> {
match j.str_field("kind")?.as_str() {
"move" => {
let dx = j.i64_field("dx")? as i32;
let dy = j.i64_field("dy")? as i32;
// Clamp to legal step range so a hostile client cannot teleport.
Ok(Action::Move { dx: dx.clamp(-1, 1), dy: dy.clamp(-1, 1) })
}
"inspect" => Ok(Action::Inspect { target: j.u64_field("target")? as u32 }),
"cast" => Ok(Action::Cast),
"attack" => Ok(Action::Attack { target: j.u64_field("target")? as u32 }),
"wait" => Ok(Action::Wait),
other => Err(JsonError::Parse(format!("unknown action kind '{other}'"))),
}
}
}
// ---------------------------------------------------------------------------
// ClientMessage.
// ---------------------------------------------------------------------------
/// Everything a browser may send. Intent only — never game truth.
#[derive(Clone, PartialEq, Debug)]
pub enum ClientMessage {
/// Request to join (or create) a match. `name` is a dev/anonymous label.
JoinMatch { name: String, match_id: Option<MatchId> },
/// Submit this turn's action for the current turn number.
SubmitTurn { turn: u64, action: Action },
/// Replace the player's editable rune program (library/editor state).
EditRuneProgram { tokens: Vec<RuneTokenWire> },
/// Ask for diagnostics about a target entity.
InspectTarget { target: u32 },
/// Ask the server to stream the recorded replay for a match.
RequestReplay { match_id: MatchId },
/// Liveness ping.
Ping { nonce: u64 },
}
impl ClientMessage {
fn type_tag(&self) -> &'static str {
match self {
ClientMessage::JoinMatch { .. } => "JoinMatch",
ClientMessage::SubmitTurn { .. } => "SubmitTurn",
ClientMessage::EditRuneProgram { .. } => "EditRuneProgram",
ClientMessage::InspectTarget { .. } => "InspectTarget",
ClientMessage::RequestReplay { .. } => "RequestReplay",
ClientMessage::Ping { .. } => "Ping",
}
}
fn body(&self) -> Json {
match self {
ClientMessage::JoinMatch { name, match_id } => Json::obj(vec![
("name", Json::s(name.clone())),
(
"match_id",
match match_id {
Some(m) => Json::u(m.0),
None => Json::Null,
},
),
]),
ClientMessage::SubmitTurn { turn, action } => Json::obj(vec![
("turn", Json::u(*turn)),
("action", action.to_json()),
]),
ClientMessage::EditRuneProgram { tokens } => Json::obj(vec![(
"tokens",
Json::Arr(tokens.iter().map(|t| t.to_json()).collect()),
)]),
ClientMessage::InspectTarget { target } => {
Json::obj(vec![("target", Json::u(*target as u64))])
}
ClientMessage::RequestReplay { match_id } => {
Json::obj(vec![("match_id", Json::u(match_id.0))])
}
ClientMessage::Ping { nonce } => Json::obj(vec![("nonce", Json::u(*nonce))]),
}
}
/// Canonical envelope: `{v, type, body}`.
pub fn to_json(&self) -> Json {
Json::obj(vec![
("v", Json::u(PROTOCOL_VERSION as u64)),
("type", Json::s(self.type_tag())),
("body", self.body()),
])
}
pub fn encode(&self) -> String {
self.to_json().to_compact()
}
/// Decode a wire string. Total: never panics. Rejects version mismatch.
pub fn decode(raw: &str) -> Result<ClientMessage, JsonError> {
let j = parse(raw)?;
Self::from_json(&j)
}
pub fn from_json(j: &Json) -> Result<ClientMessage, JsonError> {
let v = j.u64_field("v")?;
if v != PROTOCOL_VERSION as u64 {
return Err(JsonError::Parse(format!(
"protocol version mismatch: got {v}, expected {PROTOCOL_VERSION}"
)));
}
let ty = j.str_field("type")?;
let body = j.field("body")?;
match ty.as_str() {
"JoinMatch" => {
let name = body.str_field("name")?;
let match_id = match body.get("match_id") {
Some(Json::Null) | None => None,
Some(other) => other.as_u64().map(MatchId),
};
Ok(ClientMessage::JoinMatch { name, match_id })
}
"SubmitTurn" => {
let turn = body.u64_field("turn")?;
let action = Action::from_json(body.field("action")?)?;
Ok(ClientMessage::SubmitTurn { turn, action })
}
"EditRuneProgram" => {
let arr = body.arr_field("tokens")?;
// Bound the program length defensively.
if arr.len() > MAX_PROGRAM_TOKENS {
return Err(JsonError::Parse("program too long".into()));
}
let mut tokens = Vec::with_capacity(arr.len());
for t in arr {
tokens.push(RuneTokenWire::from_json(t)?);
}
Ok(ClientMessage::EditRuneProgram { tokens })
}
"InspectTarget" => Ok(ClientMessage::InspectTarget {
target: body.u64_field("target")? as u32,
}),
"RequestReplay" => Ok(ClientMessage::RequestReplay {
match_id: MatchId(body.u64_field("match_id")?),
}),
"Ping" => Ok(ClientMessage::Ping { nonce: body.u64_field("nonce")? }),
other => Err(JsonError::Parse(format!("unknown client message '{other}'"))),
}
}
}
/// Hard upper bound on a submitted rune program, enforced at decode.
pub const MAX_PROGRAM_TOKENS: usize = 256;
// ---------------------------------------------------------------------------
// Visibility / knowledge layer (Phase F). This is filtered *game state*, not UI
// notes: the client renders exactly what the server says is observable, and the
// hidden ground truth never crosses the wire.
// ---------------------------------------------------------------------------
/// How well a piece of state is known to the observing player.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Knowledge {
Known,
Unknown,
Suspected,
Contradicted,
NewlyObserved,
}
impl Knowledge {
pub fn name(self) -> &'static str {
match self {
Knowledge::Known => "known",
Knowledge::Unknown => "unknown",
Knowledge::Suspected => "suspected",
Knowledge::Contradicted => "contradicted",
Knowledge::NewlyObserved => "newly_observed",
}
}
pub fn from_str(s: &str) -> Option<Knowledge> {
Some(match s {
"known" => Knowledge::Known,
"unknown" => Knowledge::Unknown,
"suspected" => Knowledge::Suspected,
"contradicted" => Knowledge::Contradicted,
"newly_observed" => Knowledge::NewlyObserved,
_ => return None,
})
}
}
/// One domain as the player observes it. Only *visible observed* lanes carry a
/// value; non-visible observed lanes and **all hidden lanes** are redacted.
#[derive(Clone, PartialEq, Debug)]
pub struct VisibleDomain {
pub index: u8,
pub name: String,
/// `Some(v)` for a visible observed lane, `None` for a redacted lane.
pub observed: Vec<Option<i64>>,
/// Per-lane knowledge tag.
pub knowledge: Vec<Knowledge>,
}
impl VisibleDomain {
fn to_json(&self) -> Json {
Json::obj(vec![
("index", Json::u(self.index as u64)),
("name", Json::s(self.name.clone())),
(
"observed",
Json::Arr(
self.observed
.iter()
.map(|o| match o {
Some(v) => Json::i(*v),
None => Json::Null,
})
.collect(),
),
),
(
"knowledge",
Json::Arr(self.knowledge.iter().map(|k| Json::s(k.name())).collect()),
),
])
}
fn from_json(j: &Json) -> Result<Self, JsonError> {
let observed = j
.arr_field("observed")?
.iter()
.map(|v| match v {
Json::Null => None,
other => other.as_i64(),
})
.collect();
let knowledge = j
.arr_field("knowledge")?
.iter()
.filter_map(|v| v.as_str().and_then(Knowledge::from_str))
.collect();
Ok(VisibleDomain {
index: j.field("index")?.as_u8().ok_or(JsonError::Type("index".into(), "u8"))?,
name: j.str_field("name")?,
observed,
knowledge,
})
}
}
/// An entity (player/dummy) as seen on the arena.
#[derive(Clone, PartialEq, Debug)]
pub struct VisibleEntity {
pub id: u32,
pub name: String,
pub x: i32,
pub y: i32,
pub hp: i32,
pub is_self: bool,
pub alive: bool,
}
impl VisibleEntity {
fn to_json(&self) -> Json {
Json::obj(vec![
("id", Json::u(self.id as u64)),
("name", Json::s(self.name.clone())),
("x", Json::i(self.x as i64)),
("y", Json::i(self.y as i64)),
("hp", Json::i(self.hp as i64)),
("is_self", Json::Bool(self.is_self)),
("alive", Json::Bool(self.alive)),
])
}
fn from_json(j: &Json) -> Result<Self, JsonError> {
Ok(VisibleEntity {
id: j.u64_field("id")? as u32,
name: j.str_field("name")?,
x: j.i64_field("x")? as i32,
y: j.i64_field("y")? as i32,
hp: j.i64_field("hp")? as i32,
is_self: j.field("is_self")?.as_bool().unwrap_or(false),
alive: j.field("alive")?.as_bool().unwrap_or(true),
})
}
}
/// The server's filtered view of the world for one player (Phase F).
#[derive(Clone, PartialEq, Debug)]
pub struct VisibleWorldSnapshot {
pub turn: u64,
pub arena_w: i32,
pub arena_h: i32,
pub observed_domains: Vec<VisibleDomain>,
pub observed_entities: Vec<VisibleEntity>,
/// Short human-readable environment descriptors (arena conditions).
pub observed_environment: Vec<String>,
/// Prior-turn outcome summaries the player has already witnessed.
pub known_history: Vec<String>,
/// Inferred (suspected) markers, e.g. "domain 3 likely volatile".
pub inferred_markers: Vec<String>,
/// Count of state values deliberately withheld (hidden lanes + masked
/// observed lanes). Proof that hidden state exists and is *not* sent.
pub hidden_state_redactions: u32,
}
impl VisibleWorldSnapshot {
pub fn to_json(&self) -> Json {
Json::obj(vec![
("turn", Json::u(self.turn)),
("arena_w", Json::i(self.arena_w as i64)),
("arena_h", Json::i(self.arena_h as i64)),
(
"observed_domains",
Json::Arr(self.observed_domains.iter().map(|d| d.to_json()).collect()),
),
(
"observed_entities",
Json::Arr(self.observed_entities.iter().map(|e| e.to_json()).collect()),
),
(
"observed_environment",
Json::Arr(self.observed_environment.iter().map(|s| Json::s(s.clone())).collect()),
),
(
"known_history",
Json::Arr(self.known_history.iter().map(|s| Json::s(s.clone())).collect()),
),
(
"inferred_markers",
Json::Arr(self.inferred_markers.iter().map(|s| Json::s(s.clone())).collect()),
),
("hidden_state_redactions", Json::u(self.hidden_state_redactions as u64)),
])
}
pub fn from_json(j: &Json) -> Result<Self, JsonError> {
let observed_domains = j
.arr_field("observed_domains")?
.iter()
.map(VisibleDomain::from_json)
.collect::<Result<_, _>>()?;
let observed_entities = j
.arr_field("observed_entities")?
.iter()
.map(VisibleEntity::from_json)
.collect::<Result<_, _>>()?;
let strs = |key| -> Result<Vec<String>, JsonError> {
Ok(j.arr_field(key)?
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect())
};
Ok(VisibleWorldSnapshot {
turn: j.u64_field("turn")?,
arena_w: j.i64_field("arena_w")? as i32,
arena_h: j.i64_field("arena_h")? as i32,
observed_domains,
observed_entities,
observed_environment: strs("observed_environment")?,
known_history: strs("known_history")?,
inferred_markers: strs("inferred_markers")?,
hidden_state_redactions: j.u64_field("hidden_state_redactions")? as u32,
})
}
}
/// Player-facing diagnostics for a rune program (Phase D). Strictly *observed*
/// claims — never "guaranteed damage" or full hidden state.
#[derive(Clone, PartialEq, Debug, Default)]
pub struct RuneDiagnostics {
pub known_reads: Vec<String>,
pub known_writes: Vec<String>,
pub observed_risks: Vec<String>,
pub unknown_listeners: u32,
pub previous_outcomes: Vec<String>,
}
impl RuneDiagnostics {
fn to_json(&self) -> Json {
Json::obj(vec![
("known_reads", Json::Arr(self.known_reads.iter().map(|s| Json::s(s.clone())).collect())),
("known_writes", Json::Arr(self.known_writes.iter().map(|s| Json::s(s.clone())).collect())),
("observed_risks", Json::Arr(self.observed_risks.iter().map(|s| Json::s(s.clone())).collect())),
("unknown_listeners", Json::u(self.unknown_listeners as u64)),
("previous_outcomes", Json::Arr(self.previous_outcomes.iter().map(|s| Json::s(s.clone())).collect())),
])
}
fn from_json(j: &Json) -> Result<Self, JsonError> {
let strs = |key| -> Vec<String> {
j.get(key)
.and_then(|v| v.as_arr())
.map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default()
};
Ok(RuneDiagnostics {
known_reads: strs("known_reads"),
known_writes: strs("known_writes"),
observed_risks: strs("observed_risks"),
unknown_listeners: j.get("unknown_listeners").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
previous_outcomes: strs("previous_outcomes"),
})
}
}
/// One recorded turn in a replay stream (Phase G).
#[derive(Clone, PartialEq, Debug)]
pub struct ReplayTurn {
pub turn: u64,
/// `(player_id, action)` pairs applied this turn, in canonical order.
pub inputs: Vec<(u32, Action)>,
/// The runtime canonical replay hash produced this turn.
pub runtime_hash: String,
}
impl ReplayTurn {
fn to_json(&self) -> Json {
Json::obj(vec![
("turn", Json::u(self.turn)),
(
"inputs",
Json::Arr(
self.inputs
.iter()
.map(|(pid, a)| {
Json::obj(vec![("player", Json::u(*pid as u64)), ("action", a.to_json())])
})
.collect(),
),
),
("runtime_hash", Json::s(self.runtime_hash.clone())),
])
}
fn from_json(j: &Json) -> Result<Self, JsonError> {
let inputs = j
.arr_field("inputs")?
.iter()
.map(|e| {
let pid = e.u64_field("player")? as u32;
let a = Action::from_json(e.field("action")?)?;
Ok((pid, a))
})
.collect::<Result<_, JsonError>>()?;
Ok(ReplayTurn {
turn: j.u64_field("turn")?,
inputs,
runtime_hash: j.str_field("runtime_hash")?,
})
}
}
// ---------------------------------------------------------------------------
// ServerMessage.
// ---------------------------------------------------------------------------
/// Everything the server may send. Every variant is hashable; the browser can
/// verify it matches a recorded replay.
#[derive(Clone, PartialEq, Debug)]
pub enum ServerMessage {
/// Assigned identity + match parameters on join.
MatchState {
match_id: MatchId,
player_id: PlayerId,
turn: u64,
snapshot: VisibleWorldSnapshot,
},
/// A new turn has begun; `deadline_ms` is the wall-clock budget.
TurnStarted { turn: u64, deadline_ms: u64 },
/// A turn resolved authoritatively. Carries the runtime replay hash so the
/// client can verify determinism.
TurnResolved {
turn: u64,
snapshot: VisibleWorldSnapshot,
runtime_hash: String,
events: Vec<String>,
},
/// Result of an inspection request (filtered observations of a target).
ObservationResult { target: u32, diagnostics: RuneDiagnostics },
/// Validation feedback for a client packet (accepted/rejected + why).
ValidationReport { accepted: bool, detail: String, diagnostics: RuneDiagnostics },
/// One chunk of a replay stream.
ReplayChunk {
match_id: MatchId,
seed: u64,
index: u32,
total: u32,
turns: Vec<ReplayTurn>,
final_hash: String,
},
/// A protocol/transport error that is not tied to a specific submission.
ErrorEvent { code: String, detail: String },
}
impl ServerMessage {
fn type_tag(&self) -> &'static str {
match self {
ServerMessage::MatchState { .. } => "MatchState",
ServerMessage::TurnStarted { .. } => "TurnStarted",
ServerMessage::TurnResolved { .. } => "TurnResolved",
ServerMessage::ObservationResult { .. } => "ObservationResult",
ServerMessage::ValidationReport { .. } => "ValidationReport",
ServerMessage::ReplayChunk { .. } => "ReplayChunk",
ServerMessage::ErrorEvent { .. } => "ErrorEvent",
}
}
fn body(&self) -> Json {
match self {
ServerMessage::MatchState { match_id, player_id, turn, snapshot } => Json::obj(vec![
("match_id", Json::u(match_id.0)),
("player_id", Json::u(player_id.0 as u64)),
("turn", Json::u(*turn)),
("snapshot", snapshot.to_json()),
]),
ServerMessage::TurnStarted { turn, deadline_ms } => Json::obj(vec![
("turn", Json::u(*turn)),
("deadline_ms", Json::u(*deadline_ms)),
]),
ServerMessage::TurnResolved { turn, snapshot, runtime_hash, events } => Json::obj(vec![
("turn", Json::u(*turn)),
("snapshot", snapshot.to_json()),
("runtime_hash", Json::s(runtime_hash.clone())),
("events", Json::Arr(events.iter().map(|s| Json::s(s.clone())).collect())),
]),
ServerMessage::ObservationResult { target, diagnostics } => Json::obj(vec![
("target", Json::u(*target as u64)),
("diagnostics", diagnostics.to_json()),
]),
ServerMessage::ValidationReport { accepted, detail, diagnostics } => Json::obj(vec![
("accepted", Json::Bool(*accepted)),
("detail", Json::s(detail.clone())),
("diagnostics", diagnostics.to_json()),
]),
ServerMessage::ReplayChunk { match_id, seed, index, total, turns, final_hash } => {
Json::obj(vec![
("match_id", Json::u(match_id.0)),
("seed", Json::u(*seed)),
("index", Json::u(*index as u64)),
("total", Json::u(*total as u64)),
("turns", Json::Arr(turns.iter().map(|t| t.to_json()).collect())),
("final_hash", Json::s(final_hash.clone())),
])
}
ServerMessage::ErrorEvent { code, detail } => Json::obj(vec![
("code", Json::s(code.clone())),
("detail", Json::s(detail.clone())),
]),
}
}
pub fn to_json(&self) -> Json {
Json::obj(vec![
("v", Json::u(PROTOCOL_VERSION as u64)),
("type", Json::s(self.type_tag())),
("body", self.body()),
])
}
pub fn encode(&self) -> String {
self.to_json().to_compact()
}
/// Stable content hash over the canonical serialization. Because object
/// keys are sorted and there is no whitespace, identical messages hash
/// identically across machines — this is how replays are verified.
pub fn content_hash(&self) -> Hash {
let mut h = Hasher::new();
h.write_tag("server-message");
h.write_bytes(self.encode().as_bytes());
h.finish()
}
pub fn decode(raw: &str) -> Result<ServerMessage, JsonError> {
let j = parse(raw)?;
Self::from_json(&j)
}
pub fn from_json(j: &Json) -> Result<ServerMessage, JsonError> {
let v = j.u64_field("v")?;
if v != PROTOCOL_VERSION as u64 {
return Err(JsonError::Parse(format!(
"protocol version mismatch: got {v}, expected {PROTOCOL_VERSION}"
)));
}
let ty = j.str_field("type")?;
let body = j.field("body")?;
match ty.as_str() {
"MatchState" => Ok(ServerMessage::MatchState {
match_id: MatchId(body.u64_field("match_id")?),
player_id: PlayerId(body.u64_field("player_id")? as u32),
turn: body.u64_field("turn")?,
snapshot: VisibleWorldSnapshot::from_json(body.field("snapshot")?)?,
}),
"TurnStarted" => Ok(ServerMessage::TurnStarted {
turn: body.u64_field("turn")?,
deadline_ms: body.u64_field("deadline_ms")?,
}),
"TurnResolved" => Ok(ServerMessage::TurnResolved {
turn: body.u64_field("turn")?,
snapshot: VisibleWorldSnapshot::from_json(body.field("snapshot")?)?,
runtime_hash: body.str_field("runtime_hash")?,
events: body
.arr_field("events")?
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
}),
"ObservationResult" => Ok(ServerMessage::ObservationResult {
target: body.u64_field("target")? as u32,
diagnostics: RuneDiagnostics::from_json(body.field("diagnostics")?)?,
}),
"ValidationReport" => Ok(ServerMessage::ValidationReport {
accepted: body.field("accepted")?.as_bool().unwrap_or(false),
detail: body.str_field("detail")?,
diagnostics: RuneDiagnostics::from_json(body.field("diagnostics")?)?,
}),
"ReplayChunk" => Ok(ServerMessage::ReplayChunk {
match_id: MatchId(body.u64_field("match_id")?),
seed: body.u64_field("seed")?,
index: body.u64_field("index")? as u32,
total: body.u64_field("total")? as u32,
turns: body
.arr_field("turns")?
.iter()
.map(ReplayTurn::from_json)
.collect::<Result<_, _>>()?,
final_hash: body.str_field("final_hash")?,
}),
"ErrorEvent" => Ok(ServerMessage::ErrorEvent {
code: body.str_field("code")?,
detail: body.str_field("detail")?,
}),
other => Err(JsonError::Parse(format!("unknown server message '{other}'"))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_snapshot() -> VisibleWorldSnapshot {
VisibleWorldSnapshot {
turn: 3,
arena_w: 8,
arena_h: 8,
observed_domains: vec![VisibleDomain {
index: 0,
name: "aether".into(),
observed: vec![Some(1), None, Some(-4), None],
knowledge: vec![
Knowledge::Known,
Knowledge::Unknown,
Knowledge::NewlyObserved,
Knowledge::Unknown,
],
}],
observed_entities: vec![VisibleEntity {
id: 1,
name: "you".into(),
x: 2,
y: 3,
hp: 30,
is_self: true,
alive: true,
}],
observed_environment: vec!["calm".into()],
known_history: vec!["turn 2: you moved".into()],
inferred_markers: vec!["domain 4 likely volatile".into()],
hidden_state_redactions: 18,
}
}
#[test]
fn client_messages_roundtrip() {
let msgs = vec![
ClientMessage::JoinMatch { name: "dev".into(), match_id: None },
ClientMessage::JoinMatch { name: "dev".into(), match_id: Some(MatchId(9)) },
ClientMessage::SubmitTurn { turn: 4, action: Action::Move { dx: 1, dy: -1 } },
ClientMessage::SubmitTurn { turn: 4, action: Action::Cast },
ClientMessage::SubmitTurn { turn: 4, action: Action::Attack { target: 2 } },
ClientMessage::EditRuneProgram {
tokens: vec![RuneTokenWire { op: 0, a: 1, b: 2, c: 3, imm: -7 }],
},
ClientMessage::InspectTarget { target: 5 },
ClientMessage::RequestReplay { match_id: MatchId(42) },
ClientMessage::Ping { nonce: 123 },
];
for m in msgs {
let s = m.encode();
assert_eq!(ClientMessage::decode(&s).unwrap(), m, "roundtrip failed for {m:?}");
}
}
#[test]
fn server_messages_roundtrip_and_hash_is_stable() {
let msgs = vec![
ServerMessage::MatchState {
match_id: MatchId(1),
player_id: PlayerId(1),
turn: 0,
snapshot: sample_snapshot(),
},
ServerMessage::TurnStarted { turn: 1, deadline_ms: 5000 },
ServerMessage::TurnResolved {
turn: 1,
snapshot: sample_snapshot(),
runtime_hash: "deadbeefcafef00d".into(),
events: vec!["you cast".into(), "dummy took 4".into()],
},
ServerMessage::ValidationReport {
accepted: false,
detail: "late".into(),
diagnostics: RuneDiagnostics::default(),
},
];
for m in msgs {
let s = m.encode();
let back = ServerMessage::decode(&s).unwrap();
assert_eq!(back, m);
// Hash is a pure function of the canonical bytes.
assert_eq!(m.content_hash(), back.content_hash());
}
}
#[test]
fn version_mismatch_is_rejected() {
let mut j = ClientMessage::Ping { nonce: 1 }.to_json();
if let Json::Obj(ref mut m) = j {
m.insert("v".into(), Json::u(999));
}
assert!(ClientMessage::from_json(&j).is_err());
}
#[test]
fn decode_is_total_on_garbage() {
for raw in ["", "{}", "null", "{\"v\":1}", "{\"v\":1,\"type\":\"Nope\",\"body\":{}}"] {
// Must be Err, never a panic.
assert!(ClientMessage::decode(raw).is_err());
}
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "server"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "magicka-server"
path = "src/main.rs"
[dependencies]
protocol = { path = "../protocol" }
game_runtime = { path = "../game_runtime" }
world_model = { path = "../world_model" }
web_client = { path = "../web_client" }
+113
View File
@@ -0,0 +1,113 @@
//! Minimal HTTP/1.1 request parsing — only enough to tell a static GET from a
//! WebSocket upgrade and to read the upgrade key. Tolerant and total: a
//! malformed request yields `None`, never a panic.
use std::collections::BTreeMap;
use std::io::{self, BufRead};
/// A parsed request head.
pub struct Request {
pub method: String,
pub path: String,
pub headers: BTreeMap<String, String>,
}
impl Request {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.get(&name.to_ascii_lowercase()).map(|s| s.as_str())
}
/// True if this is a WebSocket upgrade request.
pub fn is_websocket_upgrade(&self) -> bool {
self.header("upgrade")
.map(|v| v.eq_ignore_ascii_case("websocket"))
.unwrap_or(false)
&& self
.header("connection")
.map(|v| v.to_ascii_lowercase().contains("upgrade"))
.unwrap_or(false)
}
pub fn websocket_key(&self) -> Option<&str> {
self.header("sec-websocket-key")
}
}
/// Read and parse the request head from a buffered reader. Returns `Ok(None)`
/// on a clean EOF before any bytes.
pub fn read_request<R: BufRead>(r: &mut R) -> io::Result<Option<Request>> {
let mut line = String::new();
let n = r.read_line(&mut line)?;
if n == 0 {
return Ok(None);
}
let mut parts = line.trim_end().split_whitespace();
let method = match parts.next() {
Some(m) => m.to_string(),
None => return Ok(None),
};
let path = parts.next().unwrap_or("/").to_string();
let mut headers = BTreeMap::new();
loop {
let mut h = String::new();
let hn = r.read_line(&mut h)?;
if hn == 0 {
break;
}
let trimmed = h.trim_end();
if trimmed.is_empty() {
break;
}
if let Some((k, v)) = trimmed.split_once(':') {
headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string());
}
// Bound header count defensively.
if headers.len() > 100 {
break;
}
}
Ok(Some(Request { method, path, headers }))
}
/// Build the 101 Switching Protocols handshake response.
pub fn handshake_response(accept: &str) -> String {
format!(
"HTTP/1.1 101 Switching Protocols\r\n\
Upgrade: websocket\r\n\
Connection: Upgrade\r\n\
Sec-WebSocket-Accept: {accept}\r\n\r\n"
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::BufReader;
#[test]
fn parses_websocket_upgrade() {
let raw = "GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: abc\r\n\r\n";
let mut r = BufReader::new(raw.as_bytes());
let req = read_request(&mut r).unwrap().unwrap();
assert_eq!(req.method, "GET");
assert_eq!(req.path, "/ws");
assert!(req.is_websocket_upgrade());
assert_eq!(req.websocket_key(), Some("abc"));
}
#[test]
fn parses_plain_get() {
let raw = "GET /app.js HTTP/1.1\r\nHost: x\r\n\r\n";
let mut r = BufReader::new(raw.as_bytes());
let req = read_request(&mut r).unwrap().unwrap();
assert!(!req.is_websocket_upgrade());
assert_eq!(req.path, "/app.js");
}
#[test]
fn empty_input_is_none() {
let mut r = BufReader::new("".as_bytes());
assert!(read_request(&mut r).unwrap().is_none());
}
}
+606
View File
@@ -0,0 +1,606 @@
//! `server` — the authoritative match server (Phase B of `plan2.md`), built on
//! `std::net` with no external crates.
//!
//! Responsibilities the server owns: match state, the turn timer, collecting
//! submitted actions, driving resolution through [`game_runtime`], visibility
//! filtering, replay recording, and disconnect handling. The browser is served
//! the embedded client and then speaks the `protocol` over a WebSocket.
//!
//! Authority guarantees enforced here and covered by tests:
//! * **No panic on bad input** — every client packet is decoded with the total
//! `protocol` decoder; a failure becomes a `ValidationReport`, never a crash.
//! * **Late input rejected deterministically** — a `SubmitTurn` for any turn
//! other than the live one, or after the deadline, is rejected with a stable
//! reason.
//! * **Disconnect cannot corrupt a match** — a dropped connection simply stops
//! submitting; that player's turns default to `Wait` and the match continues.
//! * **Client cannot mutate hidden state** — only intent is accepted, and the
//! hidden ground truth is never serialized to a client.
pub mod http;
pub mod ws;
use std::collections::BTreeMap;
use std::io::{BufReader, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::mpsc::{self, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use game_runtime::{duel_roster, solo_roster, Match};
use protocol::{
Action, ClientMessage, MatchId, PlayerId, ReplayTurn, RuneDiagnostics, ServerMessage,
};
/// How long the timer thread sleeps between ticks.
const TICK: Duration = Duration::from_millis(40);
/// Replay turns per `ReplayChunk`.
const REPLAY_CHUNK: usize = 16;
/// An outbound item for a single connection's writer thread. Routing every
/// write through one thread keeps frames from interleaving.
enum Out {
Text(String),
Pong(Vec<u8>),
Close,
}
/// One live match plus its scheduling and connection state.
struct Session {
m: Match,
turn_len: Duration,
deadline: Instant,
pending: BTreeMap<u32, Action>,
conns: BTreeMap<u32, Sender<Out>>,
/// Entity ids that are human-controlled (vs. a dummy).
human_slots: Vec<u32>,
}
impl Session {
fn deadline_ms(&self, now: Instant) -> u64 {
self.deadline.saturating_duration_since(now).as_millis() as u64
}
fn snapshot_msg(&self, player: u32) -> ServerMessage {
ServerMessage::MatchState {
match_id: self.m.id,
player_id: PlayerId(player),
turn: self.m.turn,
snapshot: self.m.visible_for(player),
}
}
}
/// The shared manager: all matches, behind one mutex.
pub struct Manager {
sessions: BTreeMap<u64, Session>,
next_auto_id: u64,
turn_ms: u64,
}
/// Result of a successful join.
struct JoinOk {
match_id: MatchId,
player_id: u32,
initial: ServerMessage,
turn_started: ServerMessage,
}
impl Manager {
fn new(turn_ms: u64) -> Manager {
Manager { sessions: BTreeMap::new(), next_auto_id: 1, turn_ms }
}
fn turn_len(&self) -> Duration {
Duration::from_millis(self.turn_ms)
}
/// Join (or create) a match. `requested = None` creates a fresh solo match
/// (player + dummy). `requested = Some(id)` joins an existing duel by id, or
/// creates that duel and takes the first human slot.
fn join(
&mut self,
name: &str,
requested: Option<MatchId>,
tx: Sender<Out>,
) -> Result<JoinOk, String> {
let now = Instant::now();
let turn_len = self.turn_len();
let key = match requested {
Some(m) => m.0,
None => {
let id = self.next_auto_id;
self.next_auto_id += 1;
id
}
};
// Create the session if absent.
if !self.sessions.contains_key(&key) {
let (roster, human_slots) = if requested.is_some() {
(duel_roster(name, "opponent"), vec![1u32, 2])
} else {
(solo_roster(name), vec![1u32])
};
let m = Match::new(MatchId(key), key, roster);
self.sessions.insert(
key,
Session {
m,
turn_len,
deadline: now + turn_len,
pending: BTreeMap::new(),
conns: BTreeMap::new(),
human_slots,
},
);
}
let session = self.sessions.get_mut(&key).unwrap();
// Find the first human slot without a live connection.
let slot = session
.human_slots
.iter()
.copied()
.find(|s| !session.conns.contains_key(s))
.ok_or_else(|| "match is full".to_string())?;
// Adopt the player's chosen name on their entity.
if let Some(e) = session.m.entity_mut(slot) {
e.name = name.to_string();
}
session.conns.insert(slot, tx);
Ok(JoinOk {
match_id: MatchId(key),
player_id: slot,
initial: session.snapshot_msg(slot),
turn_started: ServerMessage::TurnStarted {
turn: session.m.turn,
deadline_ms: session.deadline_ms(now),
},
})
}
/// Queue a turn submission. Rejects late / wrong-turn submissions
/// deterministically.
fn submit(
&mut self,
match_key: u64,
player: u32,
turn: u64,
action: Action,
) -> Result<(), String> {
let now = Instant::now();
let session = self
.sessions
.get_mut(&match_key)
.ok_or_else(|| "no such match".to_string())?;
if session.m.finished {
return Err("match has ended".to_string());
}
if turn != session.m.turn {
return Err(format!(
"wrong turn: submitted {}, live turn is {}",
turn, session.m.turn
));
}
if now > session.deadline {
return Err("late: turn deadline has passed".to_string());
}
session.pending.insert(player, action);
Ok(())
}
fn set_program(
&mut self,
match_key: u64,
player: u32,
tokens: Vec<protocol::RuneTokenWire>,
) -> Result<RuneDiagnostics, String> {
let session = self
.sessions
.get_mut(&match_key)
.ok_or_else(|| "no such match".to_string())?;
session.m.set_program(player, tokens);
Ok(session.m.diagnostics_for_player(player))
}
fn inspect(&self, match_key: u64, target: u32) -> Result<ServerMessage, String> {
let session = self
.sessions
.get(&match_key)
.ok_or_else(|| "no such match".to_string())?;
let diagnostics = session.m.diagnostics_for_player(target);
Ok(ServerMessage::ObservationResult { target, diagnostics })
}
fn replay_chunks(&self, match_key: u64) -> Result<Vec<ServerMessage>, String> {
let session = self
.sessions
.get(&match_key)
.ok_or_else(|| "no such match".to_string())?;
let m = &session.m;
let turns: Vec<ReplayTurn> = m
.replay
.turns
.iter()
.map(|rt| ReplayTurn {
turn: rt.turn,
inputs: rt
.inputs
.iter()
.map(|i| (i.player, i.action.clone()))
.collect(),
runtime_hash: format!("{}", rt.turn_hash),
})
.collect();
let final_hash = m.final_hash_hex();
let chunks: Vec<&[ReplayTurn]> = if turns.is_empty() {
vec![&[]]
} else {
turns.chunks(REPLAY_CHUNK).collect()
};
let total = chunks.len() as u32;
Ok(chunks
.into_iter()
.enumerate()
.map(|(i, c)| ServerMessage::ReplayChunk {
match_id: m.id,
seed: m.seed,
index: i as u32,
total,
turns: c.to_vec(),
final_hash: final_hash.clone(),
})
.collect())
}
/// Advance any match whose deadline has elapsed. Runs under the lock; sends
/// are non-blocking on unbounded channels.
fn tick(&mut self, now: Instant) {
let mut empty: Vec<u64> = Vec::new();
for (key, session) in self.sessions.iter_mut() {
if session.conns.is_empty() {
empty.push(*key);
continue;
}
if session.m.finished || now < session.deadline {
continue;
}
// Resolve the turn from queued submissions.
let subs: Vec<(u32, Action)> =
session.pending.iter().map(|(p, a)| (*p, a.clone())).collect();
let events = session.m.resolve_turn(&subs);
session.pending.clear();
let runtime_hash = session.m.last_turn_hash_hex();
let turn = session.m.turn;
// Broadcast the resolved state, filtered per player.
for (pid, tx) in session.conns.iter() {
let msg = ServerMessage::TurnResolved {
turn,
snapshot: session.m.visible_for(*pid),
runtime_hash: runtime_hash.clone(),
events: events.clone(),
};
let _ = tx.send(Out::Text(msg.encode()));
}
// Open the next turn unless the match just ended.
if !session.m.finished {
session.deadline = now + session.turn_len;
let ts = ServerMessage::TurnStarted {
turn,
deadline_ms: session.turn_len.as_millis() as u64,
};
for tx in session.conns.values() {
let _ = tx.send(Out::Text(ts.encode()));
}
}
}
// Drop sessions nobody is connected to (replay no longer reachable).
for key in empty {
self.sessions.remove(&key);
}
}
fn disconnect(&mut self, match_key: u64, player: u32) {
if let Some(session) = self.sessions.get_mut(&match_key) {
session.conns.remove(&player);
if session.conns.is_empty() {
self.sessions.remove(&match_key);
}
}
}
}
/// Configuration.
pub struct Config {
pub turn_ms: u64,
}
impl Config {
pub fn from_env() -> Config {
let turn_ms = std::env::var("MAGICKA_TURN_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5000);
Config { turn_ms }
}
}
/// Start the server on `addr`. Returns the bound address (useful when binding
/// to port 0 in tests). Spawns the accept loop and the turn-timer thread as
/// detached background threads.
pub fn serve(addr: &str, cfg: Config) -> std::io::Result<SocketAddr> {
let listener = TcpListener::bind(addr)?;
let local = listener.local_addr()?;
let manager = Arc::new(Mutex::new(Manager::new(cfg.turn_ms)));
// Turn timer.
{
let mgr = Arc::clone(&manager);
thread::spawn(move || loop {
thread::sleep(TICK);
let now = Instant::now();
lock(&mgr).tick(now);
});
}
// Accept loop.
{
let mgr = Arc::clone(&manager);
thread::spawn(move || {
for stream in listener.incoming() {
if let Ok(stream) = stream {
let mgr = Arc::clone(&mgr);
thread::spawn(move || {
let _ = handle_conn(stream, mgr);
});
}
}
});
}
Ok(local)
}
/// Blocking entry point for the binary.
pub fn run(addr: &str) -> std::io::Result<()> {
let local = serve(addr, Config::from_env())?;
eprintln!("magicka-server listening on http://{local} (open it in a browser)");
loop {
thread::sleep(Duration::from_secs(3600));
}
}
fn handle_conn(stream: TcpStream, mgr: Arc<Mutex<Manager>>) -> std::io::Result<()> {
stream.set_nodelay(true).ok();
let mut head_reader = BufReader::new(stream.try_clone()?);
let req = match http::read_request(&mut head_reader)? {
Some(r) => r,
None => return Ok(()),
};
if !req.is_websocket_upgrade() {
// Static asset.
let mut s = stream;
let resp = web_client::http_response(&req.path).unwrap_or_else(web_client::not_found);
s.write_all(&resp)?;
return Ok(());
}
// Complete the WebSocket handshake.
let key = match req.websocket_key() {
Some(k) => k,
None => return Ok(()),
};
let accept = ws::accept_key(key);
{
let mut s = stream.try_clone()?;
s.write_all(http::handshake_response(&accept).as_bytes())?;
s.flush()?;
}
// Writer thread: the only thing that ever writes to this socket.
let (tx, rx) = mpsc::channel::<Out>();
let mut write_stream = stream.try_clone()?;
let writer = thread::spawn(move || {
for out in rx {
let r = match out {
Out::Text(s) => ws::write_text(&mut write_stream, &s),
Out::Pong(p) => ws::write_pong(&mut write_stream, &p),
Out::Close => {
let _ = ws::write_close(&mut write_stream);
break;
}
};
if r.is_err() {
break;
}
}
});
// Reader loop.
let mut read_stream = stream;
let mut match_key: Option<u64> = None;
let mut player_id: Option<u32> = None;
loop {
match ws::read_message(&mut read_stream) {
Ok(Some(ws::Message::Text(raw))) => {
dispatch(&mgr, &tx, &raw, &mut match_key, &mut player_id);
}
Ok(Some(ws::Message::Ping(p))) => {
let _ = tx.send(Out::Pong(p));
}
Ok(Some(ws::Message::Pong)) => {}
Ok(Some(ws::Message::Close)) | Ok(None) => break,
Err(_) => break,
}
}
// Disconnect: deregister so the match continues without corruption.
if let (Some(k), Some(p)) = (match_key, player_id) {
lock(&mgr).disconnect(k, p);
}
let _ = tx.send(Out::Close);
drop(tx);
let _ = writer.join();
Ok(())
}
/// Dispatch one decoded client message. Never panics: a decode failure or any
/// rejected operation becomes a `ValidationReport`/`ErrorEvent`.
fn dispatch(
mgr: &Arc<Mutex<Manager>>,
tx: &Sender<Out>,
raw: &str,
match_key: &mut Option<u64>,
player_id: &mut Option<u32>,
) {
let msg = match ClientMessage::decode(raw) {
Ok(m) => m,
Err(e) => {
send(tx, ServerMessage::ValidationReport {
accepted: false,
detail: format!("malformed packet: {e}"),
diagnostics: RuneDiagnostics::default(),
});
return;
}
};
match msg {
ClientMessage::JoinMatch { name, match_id } => {
if player_id.is_some() {
send(tx, err_event("already_joined", "this connection already joined a match"));
return;
}
let mut m = lock(mgr);
match m.join(&name, match_id, tx.clone()) {
Ok(ok) => {
*match_key = Some(ok.match_id.0);
*player_id = Some(ok.player_id);
send(tx, ok.initial);
send(tx, ok.turn_started);
}
Err(detail) => send(tx, err_event("join_failed", &detail)),
}
}
ClientMessage::SubmitTurn { turn, action } => {
let (Some(k), Some(p)) = (*match_key, *player_id) else {
send(tx, err_event("not_joined", "join a match first"));
return;
};
let res = lock(mgr).submit(k, p, turn, action);
match res {
Ok(()) => send(tx, ServerMessage::ValidationReport {
accepted: true,
detail: format!("action queued for turn {turn}"),
diagnostics: RuneDiagnostics::default(),
}),
Err(detail) => send(tx, ServerMessage::ValidationReport {
accepted: false,
detail,
diagnostics: RuneDiagnostics::default(),
}),
}
}
ClientMessage::EditRuneProgram { tokens } => {
let (Some(k), Some(p)) = (*match_key, *player_id) else {
send(tx, err_event("not_joined", "join a match first"));
return;
};
match lock(mgr).set_program(k, p, tokens) {
Ok(diagnostics) => send(tx, ServerMessage::ValidationReport {
accepted: true,
detail: "program updated".to_string(),
diagnostics,
}),
Err(detail) => send(tx, err_event("edit_failed", &detail)),
}
}
ClientMessage::InspectTarget { target } => {
let Some(k) = *match_key else {
send(tx, err_event("not_joined", "join a match first"));
return;
};
match lock(mgr).inspect(k, target) {
Ok(m) => send(tx, m),
Err(detail) => send(tx, err_event("inspect_failed", &detail)),
}
}
ClientMessage::RequestReplay { match_id } => {
match lock(mgr).replay_chunks(match_id.0) {
Ok(chunks) => {
for c in chunks {
send(tx, c);
}
}
Err(detail) => send(tx, err_event("replay_failed", &detail)),
}
}
ClientMessage::Ping { .. } => {
// Liveness only; the WebSocket layer already handles control pings.
}
}
}
fn send(tx: &Sender<Out>, msg: ServerMessage) {
let _ = tx.send(Out::Text(msg.encode()));
}
/// Acquire the manager lock, recovering a poisoned guard. A panic in any single
/// connection or tick must not permanently brick the server for everyone else.
fn lock(mgr: &Arc<Mutex<Manager>>) -> std::sync::MutexGuard<'_, Manager> {
mgr.lock().unwrap_or_else(|p| p.into_inner())
}
fn err_event(code: &str, detail: &str) -> ServerMessage {
ServerMessage::ErrorEvent { code: code.to_string(), detail: detail.to_string() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manager_join_and_resolve_is_authoritative() {
let mut m = Manager::new(10);
let (tx, _rx) = mpsc::channel();
let ok = m.join("dev", None, tx).unwrap();
assert_eq!(ok.player_id, 1);
let key = ok.match_id.0;
// Submit a cast for the live turn.
assert!(m.submit(key, 1, 0, Action::Cast).is_ok());
// Wrong turn is rejected deterministically.
let e = m.submit(key, 1, 99, Action::Cast).unwrap_err();
assert!(e.contains("wrong turn"), "{e}");
}
#[test]
fn disconnect_drops_session_when_last_leaves() {
let mut m = Manager::new(10);
let (tx, _rx) = mpsc::channel();
let ok = m.join("dev", None, tx).unwrap();
let key = ok.match_id.0;
assert!(m.sessions.contains_key(&key));
m.disconnect(key, 1);
assert!(!m.sessions.contains_key(&key));
}
#[test]
fn duel_assigns_two_human_slots() {
let mut m = Manager::new(10);
let (tx1, _r1) = mpsc::channel();
let (tx2, _r2) = mpsc::channel();
let a = m.join("a", Some(MatchId(42)), tx1).unwrap();
let b = m.join("b", Some(MatchId(42)), tx2).unwrap();
assert_eq!(a.player_id, 1);
assert_eq!(b.player_id, 2);
// Third join to a full duel is rejected.
let (tx3, _r3) = mpsc::channel();
assert!(m.join("c", Some(MatchId(42)), tx3).is_err());
}
}
+8
View File
@@ -0,0 +1,8 @@
//! The Magicka VM web server binary. Serves the embedded browser client and the
//! authoritative WebSocket protocol. Bind address via `MAGICKA_ADDR`
//! (default `127.0.0.1:8080`); turn length via `MAGICKA_TURN_MS`.
fn main() -> std::io::Result<()> {
let addr = std::env::var("MAGICKA_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".to_string());
server::run(&addr)
}
+342
View File
@@ -0,0 +1,342 @@
//! Minimal WebSocket (RFC 6455) support over `std::net`, no external crates.
//! Implements just what the game needs: the upgrade handshake (SHA1 + base64),
//! masked client-frame reading with fragment reassembly, and unmasked
//! server-frame writing. All reads are length-checked so a hostile frame
//! returns an `Err`, never a panic or unbounded allocation.
use std::io::{self, Read, Write};
const WS_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
/// Reject any single message larger than this (defensive bound).
pub const MAX_MESSAGE: usize = 1 << 20; // 1 MiB
// ---------------------------------------------------------------------------
// SHA-1 (FIPS 180-1). Used only for the handshake accept key.
// ---------------------------------------------------------------------------
fn sha1(data: &[u8]) -> [u8; 20] {
let mut h: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];
let ml = (data.len() as u64) * 8;
let mut msg = data.to_vec();
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0);
}
msg.extend_from_slice(&ml.to_be_bytes());
for chunk in msg.chunks_exact(64) {
let mut w = [0u32; 80];
for (i, wi) in w.iter_mut().enumerate().take(16) {
*wi = u32::from_be_bytes([
chunk[i * 4],
chunk[i * 4 + 1],
chunk[i * 4 + 2],
chunk[i * 4 + 3],
]);
}
for i in 16..80 {
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
}
let (mut a, mut b, mut c, mut d, mut e) = (h[0], h[1], h[2], h[3], h[4]);
for (i, &wi) in w.iter().enumerate() {
let (f, k) = match i {
0..=19 => ((b & c) | ((!b) & d), 0x5A827999u32),
20..=39 => (b ^ c ^ d, 0x6ED9EBA1),
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC),
_ => (b ^ c ^ d, 0xCA62C1D6),
};
let tmp = a
.rotate_left(5)
.wrapping_add(f)
.wrapping_add(e)
.wrapping_add(k)
.wrapping_add(wi);
e = d;
d = c;
c = b.rotate_left(30);
b = a;
a = tmp;
}
h[0] = h[0].wrapping_add(a);
h[1] = h[1].wrapping_add(b);
h[2] = h[2].wrapping_add(c);
h[3] = h[3].wrapping_add(d);
h[4] = h[4].wrapping_add(e);
}
let mut out = [0u8; 20];
for (i, hi) in h.iter().enumerate() {
out[i * 4..i * 4 + 4].copy_from_slice(&hi.to_be_bytes());
}
out
}
// ---------------------------------------------------------------------------
// base64 (standard alphabet).
// ---------------------------------------------------------------------------
fn base64(data: &[u8]) -> String {
const ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in data.chunks(3) {
let b = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
out.push(ALPHABET[((n >> 18) & 63) as usize] as char);
out.push(ALPHABET[((n >> 12) & 63) as usize] as char);
if chunk.len() > 1 {
out.push(ALPHABET[((n >> 6) & 63) as usize] as char);
} else {
out.push('=');
}
if chunk.len() > 2 {
out.push(ALPHABET[(n & 63) as usize] as char);
} else {
out.push('=');
}
}
out
}
/// Compute the `Sec-WebSocket-Accept` value for a client key.
pub fn accept_key(client_key: &str) -> String {
let mut concat = client_key.to_string();
concat.push_str(WS_GUID);
base64(&sha1(concat.as_bytes()))
}
// ---------------------------------------------------------------------------
// Frames.
// ---------------------------------------------------------------------------
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Opcode {
Continuation,
Text,
Binary,
Close,
Ping,
Pong,
}
impl Opcode {
fn from_u8(v: u8) -> Option<Opcode> {
Some(match v {
0x0 => Opcode::Continuation,
0x1 => Opcode::Text,
0x2 => Opcode::Binary,
0x8 => Opcode::Close,
0x9 => Opcode::Ping,
0xA => Opcode::Pong,
_ => return None,
})
}
}
struct Frame {
fin: bool,
opcode: Opcode,
payload: Vec<u8>,
}
fn read_frame<R: Read>(r: &mut R) -> io::Result<Frame> {
let mut hdr = [0u8; 2];
r.read_exact(&mut hdr)?;
let fin = hdr[0] & 0x80 != 0;
let opcode = Opcode::from_u8(hdr[0] & 0x0f)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "bad opcode"))?;
let masked = hdr[1] & 0x80 != 0;
let len7 = (hdr[1] & 0x7f) as usize;
let len = match len7 {
126 => {
let mut b = [0u8; 2];
r.read_exact(&mut b)?;
u16::from_be_bytes(b) as usize
}
127 => {
let mut b = [0u8; 8];
r.read_exact(&mut b)?;
u64::from_be_bytes(b) as usize
}
n => n,
};
if len > MAX_MESSAGE {
return Err(io::Error::new(io::ErrorKind::InvalidData, "frame too large"));
}
// Per RFC, client frames MUST be masked.
let mask = if masked {
let mut m = [0u8; 4];
r.read_exact(&mut m)?;
Some(m)
} else {
None
};
let mut payload = vec![0u8; len];
r.read_exact(&mut payload)?;
if let Some(m) = mask {
for (i, b) in payload.iter_mut().enumerate() {
*b ^= m[i % 4];
}
}
Ok(Frame { fin, opcode, payload })
}
/// A complete application message read from the socket. Control frames are
/// surfaced rather than answered inline so that *all* socket writes can be
/// funneled through a single writer (avoiding interleaved frames when a server
/// is both broadcasting and answering pings).
pub enum Message {
Text(String),
/// A ping with its payload; the caller must reply with a pong.
Ping(Vec<u8>),
/// A pong (informational).
Pong,
/// The peer requested close.
Close,
}
/// Read one full WebSocket message, reassembling fragments. Returns `Ok(None)`
/// on a clean EOF. Reads only — never writes to the socket.
pub fn read_message<R: Read>(stream: &mut R) -> io::Result<Option<Message>> {
let mut buf: Vec<u8> = Vec::new();
let mut msg_op: Option<Opcode> = None;
loop {
let frame = match read_frame(stream) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e),
};
match frame.opcode {
Opcode::Close => return Ok(Some(Message::Close)),
Opcode::Ping => return Ok(Some(Message::Ping(frame.payload))),
Opcode::Pong => return Ok(Some(Message::Pong)),
Opcode::Text | Opcode::Binary => {
if msg_op.is_some() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "interleaved frame"));
}
msg_op = Some(frame.opcode);
buf.extend_from_slice(&frame.payload);
}
Opcode::Continuation => {
if msg_op.is_none() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "stray continuation"));
}
buf.extend_from_slice(&frame.payload);
}
}
if buf.len() > MAX_MESSAGE {
return Err(io::Error::new(io::ErrorKind::InvalidData, "message too large"));
}
if frame.fin {
// We only surface text to the application; binary is decoded lossily.
let s = String::from_utf8_lossy(&buf).into_owned();
return Ok(Some(Message::Text(s)));
}
}
}
fn write_frame<W: Write>(w: &mut W, opcode: Opcode, payload: &[u8]) -> io::Result<()> {
let op = match opcode {
Opcode::Continuation => 0x0,
Opcode::Text => 0x1,
Opcode::Binary => 0x2,
Opcode::Close => 0x8,
Opcode::Ping => 0x9,
Opcode::Pong => 0xA,
};
let mut frame = vec![0x80 | op];
let len = payload.len();
if len < 126 {
frame.push(len as u8);
} else if len < 65536 {
frame.push(126);
frame.extend_from_slice(&(len as u16).to_be_bytes());
} else {
frame.push(127);
frame.extend_from_slice(&(len as u64).to_be_bytes());
}
frame.extend_from_slice(payload);
w.write_all(&frame)?;
w.flush()
}
/// Send a text message (server frames are never masked).
pub fn write_text<W: Write>(w: &mut W, text: &str) -> io::Result<()> {
write_frame(w, Opcode::Text, text.as_bytes())
}
/// Send a pong frame echoing a ping payload.
pub fn write_pong<W: Write>(w: &mut W, payload: &[u8]) -> io::Result<()> {
write_frame(w, Opcode::Pong, payload)
}
/// Send a close frame.
pub fn write_close<W: Write>(w: &mut W) -> io::Result<()> {
write_frame(w, Opcode::Close, &[])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rfc_example_accept_key() {
// The canonical example from RFC 6455 section 1.3.
assert_eq!(
accept_key("dGhlIHNhbXBsZSBub25jZQ=="),
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
);
}
#[test]
fn sha1_known_vector() {
// "abc" -> a9993e364706816aba3e25717850c26c9cd0d89d
let d = sha1(b"abc");
let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(hex, "a9993e364706816aba3e25717850c26c9cd0d89d");
}
#[test]
fn base64_roundtrip_lengths() {
assert_eq!(base64(b""), "");
assert_eq!(base64(b"f"), "Zg==");
assert_eq!(base64(b"fo"), "Zm8=");
assert_eq!(base64(b"foo"), "Zm9v");
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
}
#[test]
fn masked_text_frame_roundtrips_through_reader() {
use std::io::Cursor;
// Build a masked client text frame for "hi".
let payload = b"hi";
let mask = [0x01, 0x02, 0x03, 0x04];
let mut frame = vec![0x81, 0x80 | payload.len() as u8];
frame.extend_from_slice(&mask);
for (i, &b) in payload.iter().enumerate() {
frame.push(b ^ mask[i % 4]);
}
// Cursor implements Read+Write (write goes nowhere useful but pong path
// is not exercised here).
let mut cur = Cursor::new(frame);
match read_message(&mut cur).unwrap() {
Some(Message::Text(s)) => assert_eq!(s, "hi"),
_ => panic!("expected text"),
}
}
#[test]
fn oversized_frame_is_rejected() {
use std::io::Cursor;
// Declares a 127-length (8-byte) payload of u64::MAX — must error, not OOM.
let mut frame = vec![0x81, 0x80 | 127];
frame.extend_from_slice(&u64::MAX.to_be_bytes());
frame.extend_from_slice(&[0, 0, 0, 0]); // partial mask
let mut cur = Cursor::new(frame);
assert!(read_message(&mut cur).is_err());
}
}
+5
View File
@@ -0,0 +1,5 @@
[package]
name = "web_assets"
version.workspace = true
edition.workspace = true
license.workspace = true
+337
View File
@@ -0,0 +1,337 @@
"use strict";
// Browser client for Magicka VM. The browser only ever sends INTENT; the server
// is the sole authority. This file mirrors the `protocol` crate's wire format
// (version 1, envelope {v,type,body}). It never simulates the world — it renders
// exactly what the server says is observable.
const PROTOCOL_VERSION = 1;
const OPS = ["mix","channel","branch","schedule","resonate","observe",
"collapse","invert","diffuse","anchor","echoback","imprint"];
const state = {
ws: null,
playerId: null,
matchId: null,
turn: 0,
deadline: 0,
locked: false,
snapshot: null,
selectedTarget: null,
program: [],
slots: [[], [], []],
activeSlot: 0,
liveHashes: {}, // turn -> runtime_hash seen live
replay: null, // { turns: [...], final_hash, cursor }
};
// ---- wire helpers -------------------------------------------------------
function send(type, body) {
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
state.ws.send(JSON.stringify({ v: PROTOCOL_VERSION, type, body }));
}
function connect() {
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/ws`);
state.ws = ws;
ws.onopen = () => {
setConn(true);
send("JoinMatch", { name: "dev-" + Math.floor(Math.random() * 1000), match_id: null });
};
ws.onclose = () => { setConn(false); setTimeout(connect, 1000); };
ws.onerror = () => ws.close();
ws.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch (_) { return; }
if (!msg || msg.v !== PROTOCOL_VERSION) return;
handle(msg.type, msg.body || {});
};
}
function setConn(on) {
const el = document.getElementById("conn");
el.textContent = on ? "connected" : "disconnected";
el.className = "badge " + (on ? "on" : "off");
}
// ---- server message handling -------------------------------------------
function handle(type, body) {
switch (type) {
case "MatchState":
state.playerId = body.player_id;
state.matchId = body.match_id;
state.turn = body.turn;
state.snapshot = body.snapshot;
render();
break;
case "TurnStarted":
state.turn = body.turn;
state.deadline = Date.now() + (body.deadline_ms || 0);
state.locked = false;
renderTimer();
break;
case "TurnResolved":
state.turn = body.turn;
state.snapshot = body.snapshot;
state.liveHashes[body.turn] = body.runtime_hash;
(body.events || []).forEach((e) => log(`t${body.turn}: ${e}`));
showHashes();
render();
break;
case "ObservationResult":
renderDiagnostics(body.diagnostics, `target ${body.target}`);
break;
case "ValidationReport":
log((body.accepted ? "✓ " : "✗ ") + body.detail);
if (body.diagnostics) renderDiagnostics(body.diagnostics, "program");
break;
case "ReplayChunk":
loadReplay(body);
break;
case "ErrorEvent":
log(`! ${body.code}: ${body.detail}`);
break;
}
}
// ---- rendering ----------------------------------------------------------
function render() {
if (!state.snapshot) return;
renderArena();
renderDomains();
renderLogHistory();
renderTimer();
}
function renderArena() {
const s = state.snapshot;
const arena = document.getElementById("arena");
arena.style.gridTemplateColumns = `repeat(${s.arena_w}, 34px)`;
arena.innerHTML = "";
const at = {};
(s.observed_entities || []).forEach((e) => { at[`${e.x},${e.y}`] = e; });
for (let y = 0; y < s.arena_h; y++) {
for (let x = 0; x < s.arena_w; x++) {
const cell = document.createElement("div");
cell.className = "cell";
const e = at[`${x},${y}`];
if (e) {
cell.textContent = e.is_self ? "@" : (e.is_dummy ? "▣" : "&");
if (e.is_self) cell.classList.add("self");
if (state.selectedTarget === e.id) cell.classList.add("target");
if (!e.alive) cell.classList.add("dead");
const hp = document.createElement("span");
hp.className = "hp"; hp.textContent = e.hp;
cell.appendChild(hp);
cell.title = `${e.name} (#${e.id}) hp ${e.hp}`;
cell.onclick = () => { state.selectedTarget = e.id; renderArena(); };
}
arena.appendChild(cell);
}
}
}
function renderDomains() {
const s = state.snapshot;
const wrap = document.getElementById("domains");
wrap.innerHTML = "";
(s.observed_domains || []).forEach((d) => {
const el = document.createElement("div");
el.className = "domain";
const name = document.createElement("div");
name.className = "name"; name.textContent = `${d.index}: ${d.name}`;
el.appendChild(name);
d.observed.forEach((v, i) => {
const lane = document.createElement("span");
const k = (d.knowledge && d.knowledge[i]) || "unknown";
lane.className = "lane " + k;
lane.textContent = v === null ? "▒" : v;
lane.title = k;
el.appendChild(lane);
});
wrap.appendChild(el);
});
document.getElementById("redactions").textContent =
`${s.hidden_state_redactions} hidden state values withheld (hidden lanes + masked observations)`;
const inf = document.getElementById("inferred");
inf.innerHTML = "";
(s.inferred_markers || []).forEach((m) => {
const li = document.createElement("li"); li.textContent = m; inf.appendChild(li);
});
}
function renderLogHistory() {
// History from the snapshot is authoritative; live events are appended too.
const known = state.snapshot.known_history || [];
const log = document.getElementById("log");
if (log.dataset.lastTurn !== String(state.turn)) {
log.dataset.lastTurn = String(state.turn);
}
}
function renderTimer() {
const t = document.getElementById("timer");
const remain = Math.max(0, Math.ceil((state.deadline - Date.now()) / 1000));
t.textContent = `turn ${state.turn}${remain}s ${state.locked ? "(locked)" : ""}`;
}
setInterval(() => {
if (state.deadline) {
if (Date.now() > state.deadline) state.locked = true;
renderTimer();
}
}, 250);
function renderDiagnostics(d, label) {
const body = document.getElementById("diag-body");
body.innerHTML = "";
const row = (k, v) => {
const div = document.createElement("div");
div.className = "diag-row";
div.innerHTML = `<span class="diag-key">${k}:</span> ${v}`;
body.appendChild(div);
};
row("for", label);
row("known reads", (d.known_reads || []).join(", ") || "—");
row("known writes", (d.known_writes || []).join(", ") || "—");
row("observed risks", (d.observed_risks || []).join(", ") || "none observed");
row("unknown listeners", `<span class="warn">${d.unknown_listeners || 0}</span> (writes you cannot observe)`);
row("previous outcomes", (d.previous_outcomes || []).join(" | ") || "—");
}
// ---- rune editor --------------------------------------------------------
function renderTokens() {
const wrap = document.getElementById("tokens");
wrap.innerHTML = "";
state.program.forEach((t, i) => {
const el = document.createElement("span");
el.className = "token";
el.textContent = `${OPS[t.op % OPS.length]} ${t.a},${t.b},${t.c}#${t.imm}`;
el.title = "click to remove";
el.onclick = () => { state.program.splice(i, 1); renderTokens(); };
wrap.appendChild(el);
});
}
function renderLibrary() {
const wrap = document.getElementById("library");
wrap.innerHTML = "";
state.slots.forEach((slot, i) => {
const el = document.createElement("div");
el.className = "slot" + (i === state.activeSlot ? " active" : "");
el.textContent = `slot ${i + 1} (${slot.length})`;
el.onclick = () => {
state.slots[state.activeSlot] = state.program.slice();
state.activeSlot = i;
state.program = state.slots[i].slice();
renderTokens(); renderLibrary();
};
wrap.appendChild(el);
});
}
function initEditor() {
const sel = document.getElementById("op-select");
OPS.forEach((op, i) => {
const o = document.createElement("option");
o.value = i; o.textContent = op; sel.appendChild(o);
});
document.getElementById("btn-add").onclick = () => {
state.program.push({
op: parseInt(sel.value, 10),
a: clampByte("tok-a"), b: clampByte("tok-b"), c: clampByte("tok-c"),
imm: parseInt(document.getElementById("tok-imm").value, 10) || 0,
});
renderTokens();
};
document.getElementById("btn-clear").onclick = () => { state.program = []; renderTokens(); };
document.getElementById("btn-save").onclick = () => {
send("EditRuneProgram", { tokens: state.program });
log("saved program (" + state.program.length + " runes)");
};
renderTokens(); renderLibrary();
}
function clampByte(id) {
let v = parseInt(document.getElementById(id).value, 10) || 0;
return Math.max(0, Math.min(255, v));
}
// ---- actions ------------------------------------------------------------
function submit(action) {
if (state.locked) { log("turn locked — submission rejected client-side"); return; }
send("SubmitTurn", { turn: state.turn, action });
}
function initActions() {
document.querySelectorAll("[data-move]").forEach((b) => {
b.onclick = () => {
const [dx, dy] = b.dataset.move.split(",").map((n) => parseInt(n, 10));
submit({ kind: "move", dx, dy });
};
});
document.getElementById("btn-cast").onclick = () => submit({ kind: "cast" });
document.getElementById("btn-wait").onclick = () => submit({ kind: "wait" });
document.getElementById("btn-attack").onclick = () => {
if (state.selectedTarget === null) { log("select a target first"); return; }
submit({ kind: "attack", target: state.selectedTarget });
};
document.getElementById("btn-inspect").onclick = () => {
if (state.selectedTarget === null) { log("select a target first"); return; }
send("InspectTarget", { target: state.selectedTarget });
};
}
// ---- replay -------------------------------------------------------------
function initReplay() {
document.getElementById("btn-replay").onclick = () => {
if (state.matchId === null) return;
state.replay = null;
send("RequestReplay", { match_id: state.matchId });
};
document.getElementById("btn-replay-step").onclick = stepReplay;
}
function loadReplay(chunk) {
if (!state.replay) state.replay = { turns: [], final_hash: chunk.final_hash, cursor: 0, seed: chunk.seed };
state.replay.turns = state.replay.turns.concat(chunk.turns || []);
state.replay.final_hash = chunk.final_hash;
document.getElementById("replay-status").textContent =
`replay loaded: ${state.replay.turns.length} turns (seed ${chunk.seed})`;
}
function stepReplay() {
if (!state.replay || state.replay.cursor >= state.replay.turns.length) {
document.getElementById("replay-status").textContent = "replay complete";
return;
}
const rt = state.replay.turns[state.replay.cursor++];
// Verify browser replay event order/hash matches what we saw live (Phase G).
const live = state.liveHashes[rt.turn];
const ok = live === undefined || live === rt.runtime_hash;
log(`replay t${rt.turn}: hash ${rt.runtime_hash} ${ok ? "✓ matches live" : "✗ MISMATCH"}`);
const hd = document.getElementById("hashes");
hd.innerHTML += `<div class="${ok ? "hash-ok" : "hash-bad"}">t${rt.turn} ${rt.runtime_hash}</div>`;
document.getElementById("replay-status").textContent =
`replay turn ${rt.turn} / ${state.replay.turns.length}`;
}
function showHashes() {
const hd = document.getElementById("hashes");
hd.innerHTML = `<div>live final-turn hash: ${state.liveHashes[state.turn] || "—"}</div>`;
}
// ---- misc ---------------------------------------------------------------
function log(msg) {
const el = document.getElementById("log");
const li = document.createElement("li");
li.textContent = msg;
el.appendChild(li);
el.scrollTop = el.scrollHeight;
}
window.addEventListener("DOMContentLoaded", () => {
initEditor();
initActions();
initReplay();
connect();
});
+82
View File
@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Magicka VM — playable window</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<header>
<h1>Magicka VM</h1>
<div id="conn" class="badge off">disconnected</div>
<div id="timer" class="timer">turn —</div>
</header>
<main>
<section id="arena-panel" class="panel">
<h2>Arena</h2>
<div id="arena" class="arena" aria-label="arena grid"></div>
<div class="actions">
<div class="dpad">
<button data-move="0,-1"></button>
<div class="dpad-row">
<button data-move="-1,0"></button>
<button data-move="0,1"></button>
<button data-move="1,0"></button>
</div>
</div>
<div class="action-buttons">
<button id="btn-cast">Cast</button>
<button id="btn-attack">Attack</button>
<button id="btn-inspect">Inspect</button>
<button id="btn-wait">Wait</button>
</div>
</div>
<p class="hint">Select a target entity (click it), then Attack/Inspect. Cast uses your current program.</p>
</section>
<section id="editor-panel" class="panel">
<h2>Rune editor</h2>
<div id="library" class="library"></div>
<div id="tokens" class="tokens"></div>
<div class="editor-controls">
<select id="op-select"></select>
<label>a<input id="tok-a" type="number" value="0" min="0" max="255" /></label>
<label>b<input id="tok-b" type="number" value="0" min="0" max="255" /></label>
<label>c<input id="tok-c" type="number" value="0" min="0" max="255" /></label>
<label>imm<input id="tok-imm" type="number" value="0" /></label>
<button id="btn-add">Add rune</button>
<button id="btn-clear">Clear</button>
<button id="btn-save">Save program</button>
</div>
<div id="diagnostics" class="diagnostics">
<h3>Observed diagnostics</h3>
<div id="diag-body">cast or save a program to preview observed diagnostics</div>
</div>
</section>
<section id="domains-panel" class="panel">
<h2>Domains (observed)</h2>
<div id="domains" class="domains"></div>
<p id="redactions" class="redactions"></p>
<h3>Inferred</h3>
<ul id="inferred"></ul>
</section>
<section id="log-panel" class="panel">
<h2>Turn log</h2>
<ul id="log" class="log"></ul>
<h3>Replay</h3>
<div class="replay-controls">
<button id="btn-replay">Request replay</button>
<button id="btn-replay-step">Step ▶</button>
<span id="replay-status">no replay loaded</span>
</div>
<div id="hashes" class="hashes"></div>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>
+108
View File
@@ -0,0 +1,108 @@
:root {
--bg: #0e1014;
--panel: #171a21;
--ink: #d7dce5;
--dim: #828b9c;
--accent: #6ad0ff;
--warn: #ffb454;
--bad: #ff6a6a;
--good: #7be08a;
--grid: #2a2f3a;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 14px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
header {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 16px;
background: #11131a;
border-bottom: 1px solid var(--grid);
}
h1 { font-size: 18px; margin: 0; color: var(--accent); }
h2 { font-size: 14px; margin: 0 0 8px; color: var(--accent); }
h3 { font-size: 12px; margin: 12px 0 6px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.06em; }
.badge { padding: 2px 8px; border-radius: 10px; font-size: 12px; }
.badge.off { background: #3a1f23; color: var(--bad); }
.badge.on { background: #1f3a26; color: var(--good); }
.timer { margin-left: auto; color: var(--warn); }
main {
display: grid;
grid-template-columns: 1.2fr 1.2fr 1fr;
grid-template-rows: auto auto;
gap: 12px;
padding: 12px;
}
.panel { background: var(--panel); border: 1px solid var(--grid); border-radius: 8px; padding: 12px; }
#arena-panel { grid-row: span 2; }
#log-panel { grid-row: span 2; }
.arena {
display: grid;
gap: 2px;
background: var(--grid);
border: 1px solid var(--grid);
width: max-content;
}
.cell {
width: 34px; height: 34px;
background: #10131a;
display: flex; align-items: center; justify-content: center;
font-size: 16px; cursor: pointer; position: relative;
}
.cell.self { outline: 2px solid var(--accent); }
.cell.target { outline: 2px solid var(--warn); }
.cell.dead { opacity: 0.35; }
.cell .hp { position: absolute; bottom: 0; right: 2px; font-size: 9px; color: var(--dim); }
.actions { display: flex; gap: 24px; margin-top: 12px; align-items: center; }
.dpad { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.dpad-row { display: flex; gap: 2px; }
button {
background: #222733; color: var(--ink); border: 1px solid var(--grid);
border-radius: 5px; padding: 6px 10px; cursor: pointer; font: inherit;
}
button:hover { border-color: var(--accent); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
.action-buttons { display: flex; flex-wrap: wrap; gap: 6px; }
.hint { color: var(--dim); font-size: 12px; }
.library { display: flex; gap: 6px; margin-bottom: 8px; }
.slot { border: 1px dashed var(--grid); border-radius: 5px; padding: 4px 8px; cursor: pointer; color: var(--dim); }
.slot.active { border-color: var(--accent); color: var(--accent); }
.tokens { display: flex; flex-wrap: wrap; gap: 4px; min-height: 30px; padding: 6px; background: #10131a; border-radius: 5px; }
.token { background: #232a36; border: 1px solid var(--grid); border-radius: 4px; padding: 2px 6px; font-size: 12px; cursor: pointer; }
.token:hover { border-color: var(--bad); }
.editor-controls { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 8px; }
.editor-controls label { display: flex; flex-direction: column; font-size: 10px; color: var(--dim); }
.editor-controls input { width: 64px; background: #10131a; border: 1px solid var(--grid); color: var(--ink); border-radius: 4px; padding: 3px; }
.editor-controls select { background: #10131a; border: 1px solid var(--grid); color: var(--ink); border-radius: 4px; padding: 4px; }
.diagnostics { margin-top: 12px; background: #10131a; border-radius: 5px; padding: 8px; }
.diag-row { margin: 2px 0; }
.diag-key { color: var(--dim); }
.warn { color: var(--warn); }
.domains { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.domain { background: #10131a; border: 1px solid var(--grid); border-radius: 5px; padding: 6px; }
.domain .name { color: var(--accent); font-size: 12px; }
.lane { display: inline-block; min-width: 40px; text-align: right; padding: 1px 4px; margin: 1px; border-radius: 3px; font-size: 11px; }
.lane.known { background: #15251a; color: var(--good); }
.lane.newly_observed { background: #2a2410; color: var(--warn); }
.lane.unknown { background: #25151a; color: var(--dim); }
.lane.suspected { background: #1a1a2a; color: #9aa0ff; }
.lane.contradicted { background: #2a1525; color: #ff9ae0; }
.redactions { color: var(--bad); font-size: 12px; }
.log { list-style: none; margin: 0; padding: 0; max-height: 320px; overflow-y: auto; }
.log li { padding: 2px 0; border-bottom: 1px solid #1c2029; font-size: 12px; }
.replay-controls { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.hashes { margin-top: 8px; font-size: 11px; color: var(--dim); word-break: break-all; }
.hash-ok { color: var(--good); }
.hash-bad { color: var(--bad); }
ul#inferred { margin: 0; padding-left: 16px; color: var(--dim); font-size: 12px; }
+44
View File
@@ -0,0 +1,44 @@
//! `web_assets` — the browser client's static files, embedded at compile time
//! so the server ships as a single binary with no runtime filesystem
//! dependency. The actual HTML/CSS/JS live under `assets/`.
pub const INDEX_HTML: &str = include_str!("../assets/index.html");
pub const STYLE_CSS: &str = include_str!("../assets/style.css");
pub const APP_JS: &str = include_str!("../assets/app.js");
/// A served asset: its bytes and MIME type.
pub struct Asset {
pub body: &'static str,
pub content_type: &'static str,
}
/// Resolve a request path to a static asset. `/` maps to the client shell.
pub fn resolve(path: &str) -> Option<Asset> {
match path {
"/" | "/index.html" => Some(Asset { body: INDEX_HTML, content_type: "text/html; charset=utf-8" }),
"/style.css" => Some(Asset { body: STYLE_CSS, content_type: "text/css; charset=utf-8" }),
"/app.js" => Some(Asset { body: APP_JS, content_type: "application/javascript; charset=utf-8" }),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_and_assets_resolve() {
assert!(resolve("/").is_some());
assert!(resolve("/app.js").is_some());
assert!(resolve("/style.css").is_some());
assert!(resolve("/nope").is_none());
}
#[test]
fn client_only_sends_intent() {
// Guard against the client ever embedding a second simulation: the
// browser code must not reference the reference engine internals.
assert!(!APP_JS.contains("EngineConfig"));
assert!(APP_JS.contains("only ever sends INTENT"));
}
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "web_client"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
web_assets = { path = "../web_assets" }
+54
View File
@@ -0,0 +1,54 @@
//! `web_client` — the client delivery layer. It owns *how* the embedded
//! [`web_assets`] reach the browser (the HTTP response framing), keeping the
//! raw asset bytes (`web_assets`) separate from delivery concerns. The server
//! depends on this crate, not on `web_assets` directly.
pub use web_assets::{resolve, Asset};
/// Build a complete HTTP/1.1 response for a static GET path. Returns `None`
/// for unknown paths so the caller can emit a 404.
pub fn http_response(path: &str) -> Option<Vec<u8>> {
let asset = resolve(path)?;
let body = asset.body.as_bytes();
let mut out = Vec::with_capacity(body.len() + 128);
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n",
asset.content_type,
body.len()
);
out.extend_from_slice(header.as_bytes());
out.extend_from_slice(body);
Some(out)
}
/// The canonical 404 response.
pub fn not_found() -> Vec<u8> {
let body = b"404 not found";
let mut out = Vec::new();
let header = format!(
"HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
out.extend_from_slice(header.as_bytes());
out.extend_from_slice(body);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serves_shell() {
let r = http_response("/").unwrap();
let s = String::from_utf8_lossy(&r);
assert!(s.starts_with("HTTP/1.1 200 OK"));
assert!(s.contains("text/html"));
assert!(s.contains("Magicka VM"));
}
#[test]
fn unknown_path_is_none() {
assert!(http_response("/secret").is_none());
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "web_tests"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
protocol = { path = "../protocol" }
game_runtime = { path = "../game_runtime" }
world_model = { path = "../world_model" }
rune_ir = { path = "../rune_ir" }
server = { path = "../server" }
+12
View File
@@ -0,0 +1,12 @@
{
"name": "magicka-web-e2e",
"version": "0.1.0",
"private": true,
"description": "Playwright rendered-browser E2E for the Magicka VM web game.",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.40.0"
}
}
+28
View File
@@ -0,0 +1,28 @@
// Playwright config for the rendered-browser E2E layer. This complements the
// headless Rust protocol E2E in `crates/web_tests/tests/e2e.rs`: here a real
// Chromium drives the actual DOM client. It boots the real server (short turns)
// via `webServer` so `npm test` is self-contained.
//
// Requires Node + `npx playwright install chromium`. The Rust CI gates do not
// depend on this; it is the optional rendered-browser proof.
const { defineConfig } = require("@playwright/test");
module.exports = defineConfig({
testDir: "./specs",
timeout: 30000,
expect: { timeout: 10000 },
use: {
baseURL: "http://127.0.0.1:8099",
headless: true,
},
webServer: {
// Build once, then run the server with fast turns for E2E.
command: "cargo run --release -p server --bin magicka-server",
cwd: "../../..",
env: { MAGICKA_ADDR: "127.0.0.1:8099", MAGICKA_TURN_MS: "1500" },
url: "http://127.0.0.1:8099/",
reuseExistingServer: true,
timeout: 120000,
},
});
+43
View File
@@ -0,0 +1,43 @@
// Rendered-browser E2E: a real Chromium joins a match, the turn timer runs, the
// player edits + saves a rune program, casts, sees filtered results, and replays
// the match — verifying the recorded replay hashes match what was seen live.
//
// This is the browser layer of plan2.md Phase H ("Playwright end-to-end tests").
const { test, expect } = require("@playwright/test");
test("a player can join, cast, and replay a browser match", async ({ page }) => {
await page.goto("/");
// Connects to the authoritative server.
await expect(page.locator("#conn")).toHaveText("connected", { timeout: 10000 });
// The turn timer is running (header shows a turn + countdown).
await expect(page.locator("#timer")).toContainText("turn", { timeout: 10000 });
// The arena rendered with the player's marker.
await expect(page.locator(".cell.self")).toHaveCount(1, { timeout: 10000 });
// Domains panel shows the hidden-state redaction notice (visibility layer).
await expect(page.locator("#redactions")).toContainText("withheld", { timeout: 10000 });
// Edit a rune program: add a couple of runes and save.
await page.fill("#tok-a", "1");
await page.fill("#tok-b", "2");
await page.click("#btn-add");
await page.click("#btn-add");
await page.click("#btn-save");
// Observed diagnostics appear (names/counts only).
await expect(page.locator("#diag-body")).toContainText("known reads", { timeout: 10000 });
// Cast and wait for a resolved-turn log line.
await page.click("#btn-cast");
await expect(page.locator("#log")).toContainText("cast a rune program", { timeout: 15000 });
// Request and step the replay; the client verifies hashes vs. what it saw live.
await page.click("#btn-replay");
await expect(page.locator("#replay-status")).toContainText("replay loaded", { timeout: 10000 });
await page.click("#btn-replay-step");
// A matching hash line is shown (no MISMATCH).
await expect(page.locator(".hash-bad")).toHaveCount(0);
});
+170
View File
@@ -0,0 +1,170 @@
//! `web_tests` — a tiny, dependency-free WebSocket *client* used to drive the
//! real server over a real socket in integration tests. It performs the HTTP
//! upgrade, masks client frames (as RFC 6455 requires), and reads server
//! frames. This is the harness behind the Phase H web CI gates.
use std::io::{self, BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::time::Duration;
use protocol::{ClientMessage, ServerMessage};
/// A blocking WebSocket client connection to the test server.
pub struct WsClient {
stream: TcpStream,
reader: BufReader<TcpStream>,
}
impl WsClient {
/// Connect, upgrade to WebSocket, and verify the handshake.
pub fn connect(addr: &str) -> io::Result<WsClient> {
let stream = TcpStream::connect(addr)?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_nodelay(true).ok();
let mut reader = BufReader::new(stream.try_clone()?);
// A fixed client key keeps the handshake assertion deterministic.
let key = "dGhlIHNhbXBsZSBub25jZQ==";
let mut s = stream.try_clone()?;
let req = format!(
"GET /ws HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\n\
Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n\
Sec-WebSocket-Version: 13\r\n\r\n"
);
s.write_all(req.as_bytes())?;
s.flush()?;
// Read the response head.
let mut status = String::new();
reader.read_line(&mut status)?;
if !status.contains("101") {
return Err(io::Error::new(io::ErrorKind::Other, format!("no upgrade: {status:?}")));
}
let mut saw_accept = false;
loop {
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 || line.trim_end().is_empty() {
break;
}
if line.to_ascii_lowercase().starts_with("sec-websocket-accept:") {
let got = line.split(':').nth(1).unwrap_or("").trim();
// Expected accept for the canonical key above.
if got == "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" {
saw_accept = true;
}
}
}
if !saw_accept {
return Err(io::Error::new(io::ErrorKind::Other, "bad Sec-WebSocket-Accept"));
}
Ok(WsClient { stream, reader })
}
/// Send a typed client message.
pub fn send(&mut self, msg: &ClientMessage) -> io::Result<()> {
self.send_raw_text(&msg.encode())
}
/// Send arbitrary text as a masked frame (used by fuzz tests).
pub fn send_raw_text(&mut self, text: &str) -> io::Result<()> {
self.write_masked(0x1, text.as_bytes())
}
/// Send arbitrary bytes as a masked binary frame (fuzz transport).
pub fn send_raw_bytes(&mut self, bytes: &[u8]) -> io::Result<()> {
self.write_masked(0x2, bytes)
}
fn write_masked(&mut self, opcode: u8, payload: &[u8]) -> io::Result<()> {
let mask = [0x12u8, 0x34, 0x56, 0x78];
let mut frame = vec![0x80 | opcode];
let len = payload.len();
if len < 126 {
frame.push(0x80 | len as u8);
} else if len < 65536 {
frame.push(0x80 | 126);
frame.extend_from_slice(&(len as u16).to_be_bytes());
} else {
frame.push(0x80 | 127);
frame.extend_from_slice(&(len as u64).to_be_bytes());
}
frame.extend_from_slice(&mask);
for (i, &b) in payload.iter().enumerate() {
frame.push(b ^ mask[i % 4]);
}
self.stream.write_all(&frame)?;
self.stream.flush()
}
/// Read one server text frame and decode it. Skips control frames.
pub fn recv(&mut self) -> io::Result<ServerMessage> {
let text = self.recv_text()?;
ServerMessage::decode(&text)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("decode: {e}")))
}
/// Read one server text frame (raw).
pub fn recv_text(&mut self) -> io::Result<String> {
loop {
let mut hdr = [0u8; 2];
self.reader.read_exact(&mut hdr)?;
let opcode = hdr[0] & 0x0f;
let masked = hdr[1] & 0x80 != 0;
let len7 = (hdr[1] & 0x7f) as usize;
let len = match len7 {
126 => {
let mut b = [0u8; 2];
self.reader.read_exact(&mut b)?;
u16::from_be_bytes(b) as usize
}
127 => {
let mut b = [0u8; 8];
self.reader.read_exact(&mut b)?;
u64::from_be_bytes(b) as usize
}
n => n,
};
// Server frames are not masked, but tolerate it.
let mask = if masked {
let mut m = [0u8; 4];
self.reader.read_exact(&mut m)?;
Some(m)
} else {
None
};
let mut payload = vec![0u8; len];
self.reader.read_exact(&mut payload)?;
if let Some(m) = mask {
for (i, b) in payload.iter_mut().enumerate() {
*b ^= m[i % 4];
}
}
match opcode {
0x1 | 0x2 => return Ok(String::from_utf8_lossy(&payload).into_owned()),
0x8 => return Err(io::Error::new(io::ErrorKind::ConnectionAborted, "closed")),
_ => continue, // ping/pong/continuation
}
}
}
/// Receive until a predicate matches, returning that message. Bounded so a
/// test never hangs.
pub fn recv_until<F: Fn(&ServerMessage) -> bool>(&mut self, pred: F) -> io::Result<ServerMessage> {
for _ in 0..256 {
let m = self.recv()?;
if pred(&m) {
return Ok(m);
}
}
Err(io::Error::new(io::ErrorKind::Other, "predicate never matched"))
}
}
/// Start a fresh server instance on an ephemeral port for a test, returning the
/// address string. Each call binds a new port so tests are isolated.
pub fn spawn_test_server(turn_ms: u64) -> String {
let addr = server::serve("127.0.0.1:0", server::Config { turn_ms })
.expect("bind test server");
format!("127.0.0.1:{}", addr.port())
}
+64
View File
@@ -0,0 +1,64 @@
//! Phase H gate: replay determinism. The web CI minimum is "1,000 simulated
//! matches" with "0 replay hash mismatches". A match is a pure function of its
//! seed, roster, and ordered inputs, so re-running the recorded inputs must
//! reproduce the final hash exactly.
use game_runtime::{replay, run_scripted, solo_roster, duel_roster};
use protocol::Action;
use world_model::Rng;
/// Build a varied but deterministic script for a given seed.
fn script(seed: u64) -> Vec<Vec<(u32, Action)>> {
let mut rng = Rng::derive(seed, "script");
let mut turns = Vec::new();
for _ in 0..6 {
let mut subs = Vec::new();
for pid in 1..=2u32 {
let a = match rng.below(5) {
0 => Action::Move { dx: rng.range_i64(-1, 1) as i32, dy: rng.range_i64(-1, 1) as i32 },
1 => Action::Cast,
2 => Action::Attack { target: if pid == 1 { 2 } else { 1 } },
3 => Action::Inspect { target: if pid == 1 { 2 } else { 1 } },
_ => Action::Wait,
};
subs.push((pid, a));
}
turns.push(subs);
}
turns
}
#[test]
fn one_thousand_matches_replay_with_zero_drift() {
let mut mismatches = 0u32;
for seed in 0..1000u64 {
let roster = if seed % 2 == 0 {
solo_roster("p")
} else {
duel_roster("a", "b")
};
let scripts = script(seed);
let (m, log) = run_scripted(seed, &roster, &scripts);
let again = replay(seed, &roster, &log.turns);
if m.replay.final_hash != again.final_hash {
mismatches += 1;
}
// Per-turn hashes must also agree.
for (a, b) in m.replay.turns.iter().zip(again.turns.iter()) {
if a.turn_hash != b.turn_hash {
mismatches += 1;
}
}
}
assert_eq!(mismatches, 0, "replay hash mismatches across 1000 matches");
}
#[test]
fn re_executing_same_seed_twice_is_identical() {
for seed in [1u64, 7, 99, 12345, 0xdead_beef] {
let roster = solo_roster("p");
let (a, _) = run_scripted(seed, &roster, &script(seed));
let (b, _) = run_scripted(seed, &roster, &script(seed));
assert_eq!(a.replay.final_hash, b.replay.final_hash, "seed {seed}");
}
}
+145
View File
@@ -0,0 +1,145 @@
//! Phase H gate: end-to-end matches over the real server, real sockets, real
//! protocol. The web CI minimum names "100 browser E2E matches"; a headless
//! browser is not available in this CI, so this drives the full
//! HTTP+WebSocket+protocol+runtime path *headlessly* (it is protocol-level
//! E2E, not a rendered browser — the Playwright harness under `e2e/` covers the
//! rendered browser when Node is present). It asserts: a player can join, the
//! turn timer drives resolution, casts resolve through the runtime, results
//! return as filtered observations, and the recorded replay reproduces the live
//! per-turn hashes (0 replay hash mismatches).
use protocol::{Action, ClientMessage, MatchId, RuneTokenWire, ServerMessage};
use web_tests::{spawn_test_server, WsClient};
struct Played {
match_id: MatchId,
live_hashes: Vec<(u64, String)>,
replay_hashes: Vec<(u64, String)>,
final_hash: String,
saw_filtered_snapshot: bool,
}
/// Play a solo match for `n_turns`, then pull the recorded replay back.
fn play_solo(addr: &str, n_turns: u64, program: &[RuneTokenWire]) -> std::io::Result<Played> {
let mut c = WsClient::connect(addr)?;
c.send(&ClientMessage::JoinMatch { name: "e2e".into(), match_id: None })?;
let (match_id, _player) = match c.recv_until(|m| matches!(m, ServerMessage::MatchState { .. }))? {
ServerMessage::MatchState { match_id, player_id, snapshot, .. } => {
// Filtered snapshot sanity: hidden lanes are redacted.
assert!(snapshot.hidden_state_redactions > 0, "no redactions in snapshot");
(match_id, player_id)
}
_ => unreachable!(),
};
if !program.is_empty() {
c.send(&ClientMessage::EditRuneProgram { tokens: program.to_vec() })?;
}
let mut live_hashes = Vec::new();
let mut saw_filtered = false;
let mut live_turn = 0u64;
for _ in 0..n_turns {
c.send(&ClientMessage::SubmitTurn { turn: live_turn, action: Action::Cast })?;
let expected = live_turn + 1;
let resolved = c.recv_until(|m| {
matches!(m, ServerMessage::TurnResolved { turn, .. } if *turn == expected)
})?;
if let ServerMessage::TurnResolved { turn, runtime_hash, snapshot, .. } = resolved {
// Results come back as filtered observations.
if snapshot.hidden_state_redactions > 0 {
saw_filtered = true;
}
live_hashes.push((turn, runtime_hash));
live_turn = turn;
}
}
// Pull the replay back and collect its per-turn hashes.
c.send(&ClientMessage::RequestReplay { match_id })?;
let mut replay_hashes = Vec::new();
let mut final_hash = String::new();
let mut got = 0u32;
let mut total = 1u32;
while got < total {
match c.recv_until(|m| matches!(m, ServerMessage::ReplayChunk { .. }))? {
ServerMessage::ReplayChunk { total: t, turns, final_hash: fh, .. } => {
total = t.max(1);
got += 1;
final_hash = fh;
for rt in turns {
replay_hashes.push((rt.turn, rt.runtime_hash));
}
}
_ => break,
}
}
Ok(Played { match_id, live_hashes, replay_hashes, final_hash, saw_filtered_snapshot: saw_filtered })
}
#[test]
fn single_match_full_playthrough() {
let addr = spawn_test_server(15);
let program = vec![
RuneTokenWire { op: 0, a: 1, b: 2, c: 3, imm: 5 },
RuneTokenWire { op: 5, a: 2, b: 4, c: 1, imm: -3 },
RuneTokenWire { op: 8, a: 0, b: 6, c: 2, imm: 11 },
];
let played = play_solo(&addr, 4, &program).expect("play");
assert_ne!(played.match_id.0, 0, "a match id was assigned");
assert_eq!(played.live_hashes.len(), 4, "all turns resolved");
assert!(played.saw_filtered_snapshot, "results returned as filtered observations");
assert!(!played.final_hash.is_empty());
// Replay must reproduce every live per-turn hash (0 mismatches).
for (turn, live) in &played.live_hashes {
let found = played.replay_hashes.iter().find(|(t, _)| t == turn);
assert!(found.is_some(), "replay missing turn {turn}");
assert_eq!(&found.unwrap().1, live, "replay hash mismatch at turn {turn}");
}
}
#[test]
fn inspection_returns_observed_diagnostics_only() {
let addr = spawn_test_server(50);
let mut c = WsClient::connect(&addr).unwrap();
c.send(&ClientMessage::JoinMatch { name: "inspector".into(), match_id: None }).unwrap();
c.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
// Inspect the dummy (entity id 2).
c.send(&ClientMessage::InspectTarget { target: 2 }).unwrap();
let obs = c.recv_until(|m| matches!(m, ServerMessage::ObservationResult { .. })).unwrap();
if let ServerMessage::ObservationResult { diagnostics, .. } = obs {
// Diagnostics are observed names/counts — never guaranteed truth.
// unknown_listeners is a count; reads/writes are domain names.
for s in diagnostics.known_reads.iter().chain(diagnostics.known_writes.iter()) {
assert!(s.chars().any(|ch| ch.is_alphabetic()));
}
} else {
panic!("expected ObservationResult");
}
}
#[test]
fn one_hundred_e2e_matches_zero_hash_mismatches() {
// Single short-turn server; 100 independent matches over real sockets.
let addr = spawn_test_server(8);
let program = vec![
RuneTokenWire { op: 1, a: 3, b: 1, c: 2, imm: 7 },
RuneTokenWire { op: 6, a: 0, b: 5, c: 3, imm: -9 },
];
let mut mismatches = 0u32;
let mut completed = 0u32;
for _ in 0..100 {
let played = play_solo(&addr, 3, &program).expect("e2e match");
completed += 1;
for (turn, live) in &played.live_hashes {
match played.replay_hashes.iter().find(|(t, _)| t == turn) {
Some((_, rh)) if rh == live => {}
_ => mismatches += 1,
}
}
}
assert_eq!(completed, 100, "all 100 matches completed");
assert_eq!(mismatches, 0, "replay hash mismatches across 100 e2e matches");
}
+84
View File
@@ -0,0 +1,84 @@
//! Phase H gate: protocol fuzzing. The web CI minimum is "10,000 protocol fuzz
//! cases" with "0 server panics". Decoding is total, so every byte string must
//! yield `Ok` or `Err` — never an unwind. A sample is also fired at a live
//! server to prove a malformed packet cannot bring it down.
use protocol::{json, ClientMessage};
use web_tests::{spawn_test_server, WsClient};
use world_model::Rng;
/// Generate a pseudo-random byte string from a seed, with a bias toward
/// JSON-ish characters so the parser's deeper paths are exercised.
fn fuzz_bytes(seed: u64) -> Vec<u8> {
let mut rng = Rng::derive(seed, "fuzz");
let alphabet = b"{}[]\":,0123456789tfnuelavabcdef.- \\/\n\t";
let len = rng.below(80);
(0..len)
.map(|_| {
if rng.chance(0.85) {
alphabet[rng.below(alphabet.len())]
} else {
rng.next_u64() as u8
}
})
.collect()
}
#[test]
fn ten_thousand_fuzz_cases_never_panic() {
let mut ok = 0u64;
let mut err = 0u64;
for seed in 0..10_000u64 {
let bytes = fuzz_bytes(seed);
let text = String::from_utf8_lossy(&bytes);
// Raw JSON parse must be total.
let _ = json::parse(&text);
// Full client-message decode must be total.
match ClientMessage::decode(&text) {
Ok(_) => ok += 1,
Err(_) => err += 1,
}
}
// The point is the absence of a panic; both counters are just evidence the
// loop ran to completion.
assert_eq!(ok + err, 10_000);
}
#[test]
fn structured_but_invalid_messages_are_rejected_not_panicked() {
let cases = [
"{}",
"{\"v\":1}",
"{\"v\":2,\"type\":\"Ping\",\"body\":{}}", // wrong version
"{\"v\":1,\"type\":\"Nope\",\"body\":{}}", // unknown type
"{\"v\":1,\"type\":\"SubmitTurn\",\"body\":{}}", // missing fields
"{\"v\":1,\"type\":\"JoinMatch\",\"body\":{\"name\":5}}", // wrong type
];
for c in cases {
assert!(ClientMessage::decode(c).is_err(), "should reject: {c}");
}
}
#[test]
fn live_server_survives_malformed_packets() {
let addr = spawn_test_server(30);
let mut c = WsClient::connect(&addr).expect("connect");
// Fire a burst of garbage frames (kept modest so the join reply is not
// starved behind a flood of rejection reports; decode breadth is covered by
// the 10k case test above).
for seed in 0..40u64 {
let bytes = fuzz_bytes(seed);
let _ = c.send_raw_bytes(&bytes);
}
// Also send raw garbage text.
for s in ["", "{", "garbage", "{\"v\":1,\"type\":\"X\",\"body\":1}"] {
let _ = c.send_raw_text(s);
}
// The server must still be alive and respond to a valid join.
c.send(&ClientMessage::JoinMatch { name: "after-fuzz".into(), match_id: None })
.expect("send join");
let m = c
.recv_until(|m| matches!(m, protocol::ServerMessage::MatchState { .. }))
.expect("server still serving after fuzz");
assert!(matches!(m, protocol::ServerMessage::MatchState { .. }));
}
+94
View File
@@ -0,0 +1,94 @@
//! Phase H gates: disconnect/reconnect and timer edges. A dropped connection
//! must not corrupt a match, and a late or wrong-turn submission must be
//! rejected deterministically.
use protocol::{Action, ClientMessage, ServerMessage};
use web_tests::{spawn_test_server, WsClient};
#[test]
fn wrong_turn_submission_is_rejected_deterministically() {
let addr = spawn_test_server(2000); // long turn so we control timing
let mut c = WsClient::connect(&addr).unwrap();
c.send(&ClientMessage::JoinMatch { name: "timer".into(), match_id: None }).unwrap();
c.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
// Submit for a turn that is not live.
c.send(&ClientMessage::SubmitTurn { turn: 999, action: Action::Wait }).unwrap();
let report = c.recv_until(|m| matches!(m, ServerMessage::ValidationReport { .. })).unwrap();
match report {
ServerMessage::ValidationReport { accepted, detail, .. } => {
assert!(!accepted, "wrong-turn submission should be rejected");
assert!(detail.contains("wrong turn"), "reason: {detail}");
}
_ => unreachable!(),
}
// A correct, in-time submission is accepted.
c.send(&ClientMessage::SubmitTurn { turn: 0, action: Action::Wait }).unwrap();
let ok = c.recv_until(|m| matches!(m, ServerMessage::ValidationReport { .. })).unwrap();
match ok {
ServerMessage::ValidationReport { accepted, .. } => assert!(accepted),
_ => unreachable!(),
}
}
#[test]
fn disconnect_does_not_corrupt_an_ongoing_duel() {
let addr = spawn_test_server(10);
// Two players share a duel match by id.
let mut a = WsClient::connect(&addr).unwrap();
a.send(&ClientMessage::JoinMatch { name: "a".into(), match_id: Some(protocol::MatchId(7)) })
.unwrap();
let _ = a.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
let mut b = WsClient::connect(&addr).unwrap();
b.send(&ClientMessage::JoinMatch { name: "b".into(), match_id: Some(protocol::MatchId(7)) })
.unwrap();
let _ = b.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
// Player A plays a couple of turns.
let mut live = 0u64;
for _ in 0..2 {
a.send(&ClientMessage::SubmitTurn { turn: live, action: Action::Cast }).unwrap();
let r = a
.recv_until(|m| matches!(m, ServerMessage::TurnResolved { turn, .. } if *turn == live + 1))
.unwrap();
if let ServerMessage::TurnResolved { turn, .. } = r {
live = turn;
}
}
// Player B disconnects abruptly.
drop(b);
// The match continues for A without corruption: more turns still resolve.
for _ in 0..2 {
a.send(&ClientMessage::SubmitTurn { turn: live, action: Action::Cast }).unwrap();
let r = a
.recv_until(|m| matches!(m, ServerMessage::TurnResolved { turn, .. } if *turn == live + 1))
.unwrap();
if let ServerMessage::TurnResolved { turn, runtime_hash, .. } = r {
assert!(!runtime_hash.is_empty());
live = turn;
}
}
assert!(live >= 4, "match advanced past a mid-match disconnect");
}
#[test]
fn reconnect_into_open_duel_slot_succeeds() {
let addr = spawn_test_server(50);
let mid = protocol::MatchId(21);
let mut a = WsClient::connect(&addr).unwrap();
a.send(&ClientMessage::JoinMatch { name: "host".into(), match_id: Some(mid) }).unwrap();
a.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
// A second player takes the open slot.
let mut b = WsClient::connect(&addr).unwrap();
b.send(&ClientMessage::JoinMatch { name: "guest".into(), match_id: Some(mid) }).unwrap();
let ms = b.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
if let ServerMessage::MatchState { player_id, .. } = ms {
assert_eq!(player_id.0, 2, "second human should take slot 2");
}
}
+74
View File
@@ -0,0 +1,74 @@
//! Phase H gate: "0 hidden-state leaks". The hidden ground truth must never
//! cross the wire. These tests check the invariant both in-process (the
//! authoritative match) and over the live socket (the serialized bytes).
use game_runtime::{solo_roster, Match};
use protocol::{Action, ClientMessage, MatchId, ServerMessage};
use web_tests::{spawn_test_server, WsClient};
use world_model::{HIDDEN_LANES, LANES, NUM_DOMAINS};
#[test]
fn visible_snapshot_redacts_all_hidden_lanes() {
let mut m = Match::new(MatchId(1), 4242, solo_roster("dev"));
// Mask half of the observed lanes so redaction is non-trivial too.
for d in 0..NUM_DOMAINS {
for l in 0..LANES {
if (d + l) % 2 == 0 {
m.world.observation_state.visible[d][l] = false;
}
}
}
// The generator may already mask lanes; count actual non-visible lanes.
let masked: u32 = (0..NUM_DOMAINS)
.flat_map(|d| (0..LANES).map(move |l| (d, l)))
.filter(|&(d, l)| !m.world.observation_state.visible[d][l])
.count() as u32;
m.set_program(1, vec![protocol::RuneTokenWire { op: 0, a: 1, b: 2, c: 3, imm: 1 }]);
m.resolve_turn(&[(1, Action::Cast)]);
let snap = m.visible_for(1);
// Every masked observed lane is None.
for vd in &snap.observed_domains {
assert_eq!(vd.observed.len(), LANES);
for (l, o) in vd.observed.iter().enumerate() {
let visible = m.world.observation_state.visible[vd.index as usize][l];
assert_eq!(o.is_some(), visible, "lane visibility/value mismatch");
}
}
// Redaction count accounts for every masked observed lane and every hidden
// lane in the world.
let expected = masked + (NUM_DOMAINS * HIDDEN_LANES) as u32;
assert_eq!(snap.hidden_state_redactions, expected);
// The serialized snapshot must not carry a "hidden" key at all.
let wire = snap.to_json().to_compact();
assert!(!wire.contains("\"hidden\""), "serialized snapshot mentions hidden state");
}
#[test]
fn no_server_message_over_the_wire_carries_hidden_keys() {
let addr = spawn_test_server(12);
let mut c = WsClient::connect(&addr).unwrap();
c.send(&ClientMessage::JoinMatch { name: "leak-check".into(), match_id: None }).unwrap();
let mut checked = 0;
// Drive a few turns and scan every raw frame the server emits.
let mut live_turn = 0u64;
for _ in 0..6 {
c.send(&ClientMessage::SubmitTurn { turn: live_turn, action: Action::Cast }).unwrap();
// Read several frames; scan each.
for _ in 0..4 {
let raw = match c.recv_text() {
Ok(r) => r,
Err(_) => break,
};
checked += 1;
assert!(!raw.contains("\"hidden\""), "raw frame leaked hidden key: {raw}");
// Track turn progression from resolved frames.
if let Ok(ServerMessage::TurnResolved { turn, .. }) = ServerMessage::decode(&raw) {
live_turn = turn;
}
}
}
assert!(checked > 0, "no frames scanned");
}