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
+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());
}
}
}