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:
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "protocol"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
world_model = { path = "../world_model" }
|
||||
rune_ir = { path = "../rune_ir" }
|
||||
trace_model = { path = "../trace_model" }
|
||||
reference_runtime = { path = "../reference_runtime" }
|
||||
@@ -0,0 +1,531 @@
|
||||
//! A complete hand-rolled JSON value, serializer, and parser (no external
|
||||
//! crates). The orchestrator's `ci_reports::json` is write-only; the protocol
|
||||
//! needs to *parse* untrusted client packets too, and parsing must be **total**
|
||||
//! — any byte sequence yields `Ok` or `Err`, never a panic. That totality is
|
||||
//! what lets the server treat a malformed packet as a deterministic
|
||||
//! `ValidationReport` rather than a crash.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
/// A parsed JSON value. Objects use a `BTreeMap` so key order is canonical,
|
||||
/// which keeps re-serialization stable and hashable.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum Json {
|
||||
Null,
|
||||
Bool(bool),
|
||||
/// All numbers are carried as `f64`; integer accessors round-trip exact
|
||||
/// values within the safe integer range, which is all the protocol uses.
|
||||
Num(f64),
|
||||
Str(String),
|
||||
Arr(Vec<Json>),
|
||||
Obj(BTreeMap<String, Json>),
|
||||
}
|
||||
|
||||
impl Json {
|
||||
pub fn s(v: impl Into<String>) -> Json {
|
||||
Json::Str(v.into())
|
||||
}
|
||||
pub fn i(v: i64) -> Json {
|
||||
Json::Num(v as f64)
|
||||
}
|
||||
pub fn u(v: u64) -> Json {
|
||||
Json::Num(v as f64)
|
||||
}
|
||||
pub fn obj(fields: Vec<(&str, Json)>) -> Json {
|
||||
let mut m = BTreeMap::new();
|
||||
for (k, v) in fields {
|
||||
m.insert(k.to_string(), v);
|
||||
}
|
||||
Json::Obj(m)
|
||||
}
|
||||
|
||||
// ---- typed accessors (all fallible, none panic) ----
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&Json> {
|
||||
match self {
|
||||
Json::Obj(m) => m.get(key),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Json::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn as_f64(&self) -> Option<f64> {
|
||||
match self {
|
||||
Json::Num(n) => Some(*n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn as_i64(&self) -> Option<i64> {
|
||||
match self {
|
||||
Json::Num(n) if n.is_finite() => Some(*n as i64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn as_u64(&self) -> Option<u64> {
|
||||
match self {
|
||||
Json::Num(n) if n.is_finite() && *n >= 0.0 => Some(*n as u64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn as_u8(&self) -> Option<u8> {
|
||||
self.as_u64().and_then(|v| u8::try_from(v).ok())
|
||||
}
|
||||
pub fn as_bool(&self) -> Option<bool> {
|
||||
match self {
|
||||
Json::Bool(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn as_arr(&self) -> Option<&[Json]> {
|
||||
match self {
|
||||
Json::Arr(a) => Some(a),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: required field accessors that produce a descriptive error.
|
||||
pub fn field<'a>(&'a self, key: &str) -> Result<&'a Json, JsonError> {
|
||||
self.get(key).ok_or_else(|| JsonError::Field(key.to_string()))
|
||||
}
|
||||
pub fn str_field(&self, key: &str) -> Result<String, JsonError> {
|
||||
self.field(key)?
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| JsonError::Type(key.to_string(), "string"))
|
||||
}
|
||||
pub fn u64_field(&self, key: &str) -> Result<u64, JsonError> {
|
||||
self.field(key)?
|
||||
.as_u64()
|
||||
.ok_or_else(|| JsonError::Type(key.to_string(), "u64"))
|
||||
}
|
||||
pub fn i64_field(&self, key: &str) -> Result<i64, JsonError> {
|
||||
self.field(key)?
|
||||
.as_i64()
|
||||
.ok_or_else(|| JsonError::Type(key.to_string(), "i64"))
|
||||
}
|
||||
pub fn arr_field<'a>(&'a self, key: &str) -> Result<&'a [Json], JsonError> {
|
||||
self.field(key)?
|
||||
.as_arr()
|
||||
.ok_or_else(|| JsonError::Type(key.to_string(), "array"))
|
||||
}
|
||||
|
||||
// ---- serialization ----
|
||||
|
||||
/// Compact canonical serialization (no whitespace). Deterministic because
|
||||
/// object keys are stored sorted.
|
||||
pub fn to_compact(&self) -> String {
|
||||
let mut out = String::new();
|
||||
self.write(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn write(&self, out: &mut String) {
|
||||
match self {
|
||||
Json::Null => out.push_str("null"),
|
||||
Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
|
||||
Json::Num(n) => {
|
||||
if !n.is_finite() {
|
||||
out.push_str("null");
|
||||
} else if *n == n.trunc() && n.abs() < 9_007_199_254_740_992.0 {
|
||||
// Exact integer: print without a decimal point.
|
||||
out.push_str(&(*n as i64).to_string());
|
||||
} else {
|
||||
out.push_str(&format!("{}", n));
|
||||
}
|
||||
}
|
||||
Json::Str(s) => write_str(out, s),
|
||||
Json::Arr(items) => {
|
||||
out.push('[');
|
||||
for (i, it) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
it.write(out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
Json::Obj(m) => {
|
||||
out.push('{');
|
||||
for (i, (k, v)) in m.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
write_str(out, k);
|
||||
out.push(':');
|
||||
v.write(out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_str(out: &mut String, s: &str) {
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
|
||||
/// A JSON parse / shape error. Carrying a message keeps decode failures
|
||||
/// diagnosable without ever unwinding.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum JsonError {
|
||||
Parse(String),
|
||||
Field(String),
|
||||
Type(String, &'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for JsonError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
JsonError::Parse(m) => write!(f, "json parse error: {m}"),
|
||||
JsonError::Field(k) => write!(f, "missing field: {k}"),
|
||||
JsonError::Type(k, t) => write!(f, "field {k} is not a {t}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for JsonError {}
|
||||
|
||||
/// Parse a JSON document. Total: never panics on any input.
|
||||
pub fn parse(input: &str) -> Result<Json, JsonError> {
|
||||
let bytes = input.as_bytes();
|
||||
let mut p = Parser { bytes, pos: 0, depth: 0 };
|
||||
p.skip_ws();
|
||||
let v = p.value()?;
|
||||
p.skip_ws();
|
||||
if p.pos != bytes.len() {
|
||||
return Err(JsonError::Parse("trailing characters".into()));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Maximum nesting depth. Bounds recursion so a deeply-nested adversarial
|
||||
/// packet returns `Err` instead of overflowing the stack.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
struct Parser<'a> {
|
||||
bytes: &'a [u8],
|
||||
pos: usize,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.bytes.get(self.pos).copied()
|
||||
}
|
||||
|
||||
fn skip_ws(&mut self) {
|
||||
while let Some(b) = self.peek() {
|
||||
if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' {
|
||||
self.pos += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn value(&mut self) -> Result<Json, JsonError> {
|
||||
self.depth += 1;
|
||||
if self.depth > MAX_DEPTH {
|
||||
return Err(JsonError::Parse("max depth exceeded".into()));
|
||||
}
|
||||
let r = match self.peek() {
|
||||
Some(b'{') => self.object(),
|
||||
Some(b'[') => self.array(),
|
||||
Some(b'"') => Ok(Json::Str(self.string()?)),
|
||||
Some(b't') | Some(b'f') => self.boolean(),
|
||||
Some(b'n') => self.null(),
|
||||
Some(b'-') | Some(b'0'..=b'9') => self.number(),
|
||||
Some(c) => Err(JsonError::Parse(format!("unexpected byte '{}'", c as char))),
|
||||
None => Err(JsonError::Parse("unexpected end".into())),
|
||||
};
|
||||
self.depth -= 1;
|
||||
r
|
||||
}
|
||||
|
||||
fn expect(&mut self, b: u8) -> Result<(), JsonError> {
|
||||
if self.peek() == Some(b) {
|
||||
self.pos += 1;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(JsonError::Parse(format!("expected '{}'", b as char)))
|
||||
}
|
||||
}
|
||||
|
||||
fn object(&mut self) -> Result<Json, JsonError> {
|
||||
self.expect(b'{')?;
|
||||
let mut m = BTreeMap::new();
|
||||
self.skip_ws();
|
||||
if self.peek() == Some(b'}') {
|
||||
self.pos += 1;
|
||||
return Ok(Json::Obj(m));
|
||||
}
|
||||
loop {
|
||||
self.skip_ws();
|
||||
let key = self.string()?;
|
||||
self.skip_ws();
|
||||
self.expect(b':')?;
|
||||
self.skip_ws();
|
||||
let val = self.value()?;
|
||||
m.insert(key, val);
|
||||
self.skip_ws();
|
||||
match self.peek() {
|
||||
Some(b',') => {
|
||||
self.pos += 1;
|
||||
continue;
|
||||
}
|
||||
Some(b'}') => {
|
||||
self.pos += 1;
|
||||
break;
|
||||
}
|
||||
_ => return Err(JsonError::Parse("expected ',' or '}'".into())),
|
||||
}
|
||||
}
|
||||
Ok(Json::Obj(m))
|
||||
}
|
||||
|
||||
fn array(&mut self) -> Result<Json, JsonError> {
|
||||
self.expect(b'[')?;
|
||||
let mut a = Vec::new();
|
||||
self.skip_ws();
|
||||
if self.peek() == Some(b']') {
|
||||
self.pos += 1;
|
||||
return Ok(Json::Arr(a));
|
||||
}
|
||||
loop {
|
||||
self.skip_ws();
|
||||
a.push(self.value()?);
|
||||
self.skip_ws();
|
||||
match self.peek() {
|
||||
Some(b',') => {
|
||||
self.pos += 1;
|
||||
continue;
|
||||
}
|
||||
Some(b']') => {
|
||||
self.pos += 1;
|
||||
break;
|
||||
}
|
||||
_ => return Err(JsonError::Parse("expected ',' or ']'".into())),
|
||||
}
|
||||
}
|
||||
Ok(Json::Arr(a))
|
||||
}
|
||||
|
||||
fn string(&mut self) -> Result<String, JsonError> {
|
||||
self.expect(b'"')?;
|
||||
let mut s = String::new();
|
||||
loop {
|
||||
match self.peek() {
|
||||
None => return Err(JsonError::Parse("unterminated string".into())),
|
||||
Some(b'"') => {
|
||||
self.pos += 1;
|
||||
break;
|
||||
}
|
||||
Some(b'\\') => {
|
||||
self.pos += 1;
|
||||
match self.peek() {
|
||||
Some(b'"') => s.push('"'),
|
||||
Some(b'\\') => s.push('\\'),
|
||||
Some(b'/') => s.push('/'),
|
||||
Some(b'n') => s.push('\n'),
|
||||
Some(b'r') => s.push('\r'),
|
||||
Some(b't') => s.push('\t'),
|
||||
Some(b'b') => s.push('\u{0008}'),
|
||||
Some(b'f') => s.push('\u{000c}'),
|
||||
Some(b'u') => {
|
||||
let cp = self.hex4()?;
|
||||
// Handle surrogate pairs.
|
||||
if (0xD800..=0xDBFF).contains(&cp) {
|
||||
if self.peek() == Some(b'\\') {
|
||||
self.pos += 1;
|
||||
if self.peek() == Some(b'u') {
|
||||
let lo = self.hex4()?;
|
||||
if (0xDC00..=0xDFFF).contains(&lo) {
|
||||
let c = 0x10000
|
||||
+ ((cp - 0xD800) << 10)
|
||||
+ (lo - 0xDC00);
|
||||
if let Some(ch) = char::from_u32(c) {
|
||||
s.push(ch);
|
||||
} else {
|
||||
s.push('\u{FFFD}');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
s.push('\u{FFFD}');
|
||||
} else if let Some(ch) = char::from_u32(cp) {
|
||||
s.push(ch);
|
||||
} else {
|
||||
s.push('\u{FFFD}');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => return Err(JsonError::Parse("bad escape".into())),
|
||||
}
|
||||
self.pos += 1;
|
||||
}
|
||||
Some(_) => {
|
||||
// Copy one UTF-8 codepoint from the source.
|
||||
let start = self.pos;
|
||||
let len = utf8_len(self.bytes[start]);
|
||||
if start + len > self.bytes.len() {
|
||||
return Err(JsonError::Parse("bad utf8".into()));
|
||||
}
|
||||
match std::str::from_utf8(&self.bytes[start..start + len]) {
|
||||
Ok(chunk) => s.push_str(chunk),
|
||||
Err(_) => return Err(JsonError::Parse("bad utf8".into())),
|
||||
}
|
||||
self.pos += len;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn hex4(&mut self) -> Result<u32, JsonError> {
|
||||
// assumes the 'u' has been consumed
|
||||
self.pos += 1;
|
||||
let mut v: u32 = 0;
|
||||
for _ in 0..4 {
|
||||
let d = self
|
||||
.peek()
|
||||
.and_then(|b| (b as char).to_digit(16))
|
||||
.ok_or_else(|| JsonError::Parse("bad \\u".into()))?;
|
||||
v = v * 16 + d;
|
||||
self.pos += 1;
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn boolean(&mut self) -> Result<Json, JsonError> {
|
||||
if self.bytes[self.pos..].starts_with(b"true") {
|
||||
self.pos += 4;
|
||||
Ok(Json::Bool(true))
|
||||
} else if self.bytes[self.pos..].starts_with(b"false") {
|
||||
self.pos += 5;
|
||||
Ok(Json::Bool(false))
|
||||
} else {
|
||||
Err(JsonError::Parse("bad literal".into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn null(&mut self) -> Result<Json, JsonError> {
|
||||
if self.bytes[self.pos..].starts_with(b"null") {
|
||||
self.pos += 4;
|
||||
Ok(Json::Null)
|
||||
} else {
|
||||
Err(JsonError::Parse("bad literal".into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn number(&mut self) -> Result<Json, JsonError> {
|
||||
let start = self.pos;
|
||||
if self.peek() == Some(b'-') {
|
||||
self.pos += 1;
|
||||
}
|
||||
while let Some(b'0'..=b'9') = self.peek() {
|
||||
self.pos += 1;
|
||||
}
|
||||
if self.peek() == Some(b'.') {
|
||||
self.pos += 1;
|
||||
while let Some(b'0'..=b'9') = self.peek() {
|
||||
self.pos += 1;
|
||||
}
|
||||
}
|
||||
if let Some(b'e') | Some(b'E') = self.peek() {
|
||||
self.pos += 1;
|
||||
if let Some(b'+') | Some(b'-') = self.peek() {
|
||||
self.pos += 1;
|
||||
}
|
||||
while let Some(b'0'..=b'9') = self.peek() {
|
||||
self.pos += 1;
|
||||
}
|
||||
}
|
||||
let slice = std::str::from_utf8(&self.bytes[start..self.pos])
|
||||
.map_err(|_| JsonError::Parse("bad number".into()))?;
|
||||
slice
|
||||
.parse::<f64>()
|
||||
.map(Json::Num)
|
||||
.map_err(|_| JsonError::Parse("bad number".into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn utf8_len(b: u8) -> usize {
|
||||
if b < 0x80 {
|
||||
1
|
||||
} else if b >> 5 == 0b110 {
|
||||
2
|
||||
} else if b >> 4 == 0b1110 {
|
||||
3
|
||||
} else if b >> 3 == 0b11110 {
|
||||
4
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_basic() {
|
||||
let v = Json::obj(vec![
|
||||
("a", Json::i(42)),
|
||||
("b", Json::Arr(vec![Json::Bool(true), Json::Null, Json::s("x")])),
|
||||
("c", Json::Num(1.5)),
|
||||
]);
|
||||
let s = v.to_compact();
|
||||
let back = parse(&s).unwrap();
|
||||
assert_eq!(v, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_never_panics_on_garbage() {
|
||||
let deep = "{".repeat(1000);
|
||||
let cases = [
|
||||
"", "{", "[", "\"", "nul", "{\"a\":}", "[1,2,", "tru", "12.3.4",
|
||||
"{\"a\"1}", "\\", "\"\\u00\"", deep.as_str(),
|
||||
];
|
||||
for c in cases {
|
||||
// Must return without panicking; value is irrelevant.
|
||||
let _ = parse(c);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_is_rejected_not_overflowed() {
|
||||
let deep = "[".repeat(10_000);
|
||||
assert!(parse(&deep).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integers_roundtrip_exact() {
|
||||
let v = Json::i(-1234567890123);
|
||||
assert_eq!(parse(&v.to_compact()).unwrap().as_i64(), Some(-1234567890123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_roundtrip() {
|
||||
let v = Json::s("line\ntab\tquote\"slash\\end");
|
||||
let s = v.to_compact();
|
||||
assert_eq!(parse(&s).unwrap(), v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
//! `protocol` — the versioned, serializable, hashable client/server message
|
||||
//! contract (Phase A of `plan2.md`). Defined **before** any UI.
|
||||
//!
|
||||
//! Invariants enforced here:
|
||||
//! * Every message carries a protocol version (`PROTOCOL_VERSION`); a decoder
|
||||
//! rejects mismatched versions deterministically.
|
||||
//! * Decoding is **total**: any byte string yields `Ok(msg)` or `Err(..)`,
|
||||
//! never a panic. The server relies on this to turn a malformed client
|
||||
//! packet into a `ValidationReport`/`ErrorEvent` instead of crashing.
|
||||
//! * Every server output is **hashable** ([`ServerMessage::content_hash`]) over
|
||||
//! a canonical (sorted-key, whitespace-free) serialization, so replays and
|
||||
//! the browser can verify byte-for-byte agreement with the server.
|
||||
//! * No game truth lives client-side: client messages carry only *intent*
|
||||
//! (movement choice, rune program, slot selection, inspection request).
|
||||
|
||||
pub mod json;
|
||||
|
||||
pub use json::{parse, Json, JsonError};
|
||||
use world_model::{Hash, Hasher};
|
||||
|
||||
/// Protocol version. Bumped on any wire-incompatible change. Both peers check
|
||||
/// it on every message.
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
/// Stable identifier for a connected player within a match.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct PlayerId(pub u32);
|
||||
|
||||
/// Stable identifier for a match.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct MatchId(pub u64);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rune token wire form (mirror of `rune_ir::RuneToken`, kept independent so the
|
||||
// wire format does not silently change when the IR changes).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One rune token as it crosses the wire. `op` is the opcode index
|
||||
/// (`rune_ir::Op::to_u8`); every field is interpreted modulo its range by the
|
||||
/// runtime, so no token value is ever rejected.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct RuneTokenWire {
|
||||
pub op: u8,
|
||||
pub a: u8,
|
||||
pub b: u8,
|
||||
pub c: u8,
|
||||
pub imm: i64,
|
||||
}
|
||||
|
||||
impl RuneTokenWire {
|
||||
pub fn to_json(self) -> Json {
|
||||
Json::obj(vec![
|
||||
("op", Json::u(self.op as u64)),
|
||||
("a", Json::u(self.a as u64)),
|
||||
("b", Json::u(self.b as u64)),
|
||||
("c", Json::u(self.c as u64)),
|
||||
("imm", Json::i(self.imm)),
|
||||
])
|
||||
}
|
||||
pub fn from_json(j: &Json) -> Result<Self, JsonError> {
|
||||
Ok(RuneTokenWire {
|
||||
op: j.field("op")?.as_u8().ok_or(JsonError::Type("op".into(), "u8"))?,
|
||||
a: j.field("a")?.as_u8().ok_or(JsonError::Type("a".into(), "u8"))?,
|
||||
b: j.field("b")?.as_u8().ok_or(JsonError::Type("b".into(), "u8"))?,
|
||||
c: j.field("c")?.as_u8().ok_or(JsonError::Type("c".into(), "u8"))?,
|
||||
imm: j.i64_field("imm")?,
|
||||
})
|
||||
}
|
||||
pub fn into_token(self) -> rune_ir::RuneToken {
|
||||
rune_ir::RuneToken {
|
||||
op: rune_ir::Op::from_u8(self.op),
|
||||
a: self.a,
|
||||
b: self.b,
|
||||
c: self.c,
|
||||
imm: self.imm,
|
||||
}
|
||||
}
|
||||
pub fn from_token(t: &rune_ir::RuneToken) -> Self {
|
||||
RuneTokenWire { op: t.op.to_u8(), a: t.a, b: t.b, c: t.c, imm: t.imm }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Player intent / actions (client -> server only).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single per-turn action chosen by a player. The browser only ever sends
|
||||
/// *intent*; the server is the sole authority on the outcome.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum Action {
|
||||
/// Step one cell on the arena grid (`dx`,`dy` in {-1,0,1}).
|
||||
Move { dx: i32, dy: i32 },
|
||||
/// Inspect a target entity (request diagnostics about it).
|
||||
Inspect { target: u32 },
|
||||
/// Cast the player's currently-edited rune program.
|
||||
Cast,
|
||||
/// Basic stick/melee attack against a target entity.
|
||||
Attack { target: u32 },
|
||||
/// Pass the turn.
|
||||
Wait,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn to_json(&self) -> Json {
|
||||
match self {
|
||||
Action::Move { dx, dy } => Json::obj(vec![
|
||||
("kind", Json::s("move")),
|
||||
("dx", Json::i(*dx as i64)),
|
||||
("dy", Json::i(*dy as i64)),
|
||||
]),
|
||||
Action::Inspect { target } => Json::obj(vec![
|
||||
("kind", Json::s("inspect")),
|
||||
("target", Json::u(*target as u64)),
|
||||
]),
|
||||
Action::Cast => Json::obj(vec![("kind", Json::s("cast"))]),
|
||||
Action::Attack { target } => Json::obj(vec![
|
||||
("kind", Json::s("attack")),
|
||||
("target", Json::u(*target as u64)),
|
||||
]),
|
||||
Action::Wait => Json::obj(vec![("kind", Json::s("wait"))]),
|
||||
}
|
||||
}
|
||||
pub fn from_json(j: &Json) -> Result<Self, JsonError> {
|
||||
match j.str_field("kind")?.as_str() {
|
||||
"move" => {
|
||||
let dx = j.i64_field("dx")? as i32;
|
||||
let dy = j.i64_field("dy")? as i32;
|
||||
// Clamp to legal step range so a hostile client cannot teleport.
|
||||
Ok(Action::Move { dx: dx.clamp(-1, 1), dy: dy.clamp(-1, 1) })
|
||||
}
|
||||
"inspect" => Ok(Action::Inspect { target: j.u64_field("target")? as u32 }),
|
||||
"cast" => Ok(Action::Cast),
|
||||
"attack" => Ok(Action::Attack { target: j.u64_field("target")? as u32 }),
|
||||
"wait" => Ok(Action::Wait),
|
||||
other => Err(JsonError::Parse(format!("unknown action kind '{other}'"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ClientMessage.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Everything a browser may send. Intent only — never game truth.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum ClientMessage {
|
||||
/// Request to join (or create) a match. `name` is a dev/anonymous label.
|
||||
JoinMatch { name: String, match_id: Option<MatchId> },
|
||||
/// Submit this turn's action for the current turn number.
|
||||
SubmitTurn { turn: u64, action: Action },
|
||||
/// Replace the player's editable rune program (library/editor state).
|
||||
EditRuneProgram { tokens: Vec<RuneTokenWire> },
|
||||
/// Ask for diagnostics about a target entity.
|
||||
InspectTarget { target: u32 },
|
||||
/// Ask the server to stream the recorded replay for a match.
|
||||
RequestReplay { match_id: MatchId },
|
||||
/// Liveness ping.
|
||||
Ping { nonce: u64 },
|
||||
}
|
||||
|
||||
impl ClientMessage {
|
||||
fn type_tag(&self) -> &'static str {
|
||||
match self {
|
||||
ClientMessage::JoinMatch { .. } => "JoinMatch",
|
||||
ClientMessage::SubmitTurn { .. } => "SubmitTurn",
|
||||
ClientMessage::EditRuneProgram { .. } => "EditRuneProgram",
|
||||
ClientMessage::InspectTarget { .. } => "InspectTarget",
|
||||
ClientMessage::RequestReplay { .. } => "RequestReplay",
|
||||
ClientMessage::Ping { .. } => "Ping",
|
||||
}
|
||||
}
|
||||
|
||||
fn body(&self) -> Json {
|
||||
match self {
|
||||
ClientMessage::JoinMatch { name, match_id } => Json::obj(vec![
|
||||
("name", Json::s(name.clone())),
|
||||
(
|
||||
"match_id",
|
||||
match match_id {
|
||||
Some(m) => Json::u(m.0),
|
||||
None => Json::Null,
|
||||
},
|
||||
),
|
||||
]),
|
||||
ClientMessage::SubmitTurn { turn, action } => Json::obj(vec![
|
||||
("turn", Json::u(*turn)),
|
||||
("action", action.to_json()),
|
||||
]),
|
||||
ClientMessage::EditRuneProgram { tokens } => Json::obj(vec![(
|
||||
"tokens",
|
||||
Json::Arr(tokens.iter().map(|t| t.to_json()).collect()),
|
||||
)]),
|
||||
ClientMessage::InspectTarget { target } => {
|
||||
Json::obj(vec![("target", Json::u(*target as u64))])
|
||||
}
|
||||
ClientMessage::RequestReplay { match_id } => {
|
||||
Json::obj(vec![("match_id", Json::u(match_id.0))])
|
||||
}
|
||||
ClientMessage::Ping { nonce } => Json::obj(vec![("nonce", Json::u(*nonce))]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical envelope: `{v, type, body}`.
|
||||
pub fn to_json(&self) -> Json {
|
||||
Json::obj(vec![
|
||||
("v", Json::u(PROTOCOL_VERSION as u64)),
|
||||
("type", Json::s(self.type_tag())),
|
||||
("body", self.body()),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> String {
|
||||
self.to_json().to_compact()
|
||||
}
|
||||
|
||||
/// Decode a wire string. Total: never panics. Rejects version mismatch.
|
||||
pub fn decode(raw: &str) -> Result<ClientMessage, JsonError> {
|
||||
let j = parse(raw)?;
|
||||
Self::from_json(&j)
|
||||
}
|
||||
|
||||
pub fn from_json(j: &Json) -> Result<ClientMessage, JsonError> {
|
||||
let v = j.u64_field("v")?;
|
||||
if v != PROTOCOL_VERSION as u64 {
|
||||
return Err(JsonError::Parse(format!(
|
||||
"protocol version mismatch: got {v}, expected {PROTOCOL_VERSION}"
|
||||
)));
|
||||
}
|
||||
let ty = j.str_field("type")?;
|
||||
let body = j.field("body")?;
|
||||
match ty.as_str() {
|
||||
"JoinMatch" => {
|
||||
let name = body.str_field("name")?;
|
||||
let match_id = match body.get("match_id") {
|
||||
Some(Json::Null) | None => None,
|
||||
Some(other) => other.as_u64().map(MatchId),
|
||||
};
|
||||
Ok(ClientMessage::JoinMatch { name, match_id })
|
||||
}
|
||||
"SubmitTurn" => {
|
||||
let turn = body.u64_field("turn")?;
|
||||
let action = Action::from_json(body.field("action")?)?;
|
||||
Ok(ClientMessage::SubmitTurn { turn, action })
|
||||
}
|
||||
"EditRuneProgram" => {
|
||||
let arr = body.arr_field("tokens")?;
|
||||
// Bound the program length defensively.
|
||||
if arr.len() > MAX_PROGRAM_TOKENS {
|
||||
return Err(JsonError::Parse("program too long".into()));
|
||||
}
|
||||
let mut tokens = Vec::with_capacity(arr.len());
|
||||
for t in arr {
|
||||
tokens.push(RuneTokenWire::from_json(t)?);
|
||||
}
|
||||
Ok(ClientMessage::EditRuneProgram { tokens })
|
||||
}
|
||||
"InspectTarget" => Ok(ClientMessage::InspectTarget {
|
||||
target: body.u64_field("target")? as u32,
|
||||
}),
|
||||
"RequestReplay" => Ok(ClientMessage::RequestReplay {
|
||||
match_id: MatchId(body.u64_field("match_id")?),
|
||||
}),
|
||||
"Ping" => Ok(ClientMessage::Ping { nonce: body.u64_field("nonce")? }),
|
||||
other => Err(JsonError::Parse(format!("unknown client message '{other}'"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard upper bound on a submitted rune program, enforced at decode.
|
||||
pub const MAX_PROGRAM_TOKENS: usize = 256;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visibility / knowledge layer (Phase F). This is filtered *game state*, not UI
|
||||
// notes: the client renders exactly what the server says is observable, and the
|
||||
// hidden ground truth never crosses the wire.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// How well a piece of state is known to the observing player.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Knowledge {
|
||||
Known,
|
||||
Unknown,
|
||||
Suspected,
|
||||
Contradicted,
|
||||
NewlyObserved,
|
||||
}
|
||||
|
||||
impl Knowledge {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Knowledge::Known => "known",
|
||||
Knowledge::Unknown => "unknown",
|
||||
Knowledge::Suspected => "suspected",
|
||||
Knowledge::Contradicted => "contradicted",
|
||||
Knowledge::NewlyObserved => "newly_observed",
|
||||
}
|
||||
}
|
||||
pub fn from_str(s: &str) -> Option<Knowledge> {
|
||||
Some(match s {
|
||||
"known" => Knowledge::Known,
|
||||
"unknown" => Knowledge::Unknown,
|
||||
"suspected" => Knowledge::Suspected,
|
||||
"contradicted" => Knowledge::Contradicted,
|
||||
"newly_observed" => Knowledge::NewlyObserved,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One domain as the player observes it. Only *visible observed* lanes carry a
|
||||
/// value; non-visible observed lanes and **all hidden lanes** are redacted.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct VisibleDomain {
|
||||
pub index: u8,
|
||||
pub name: String,
|
||||
/// `Some(v)` for a visible observed lane, `None` for a redacted lane.
|
||||
pub observed: Vec<Option<i64>>,
|
||||
/// Per-lane knowledge tag.
|
||||
pub knowledge: Vec<Knowledge>,
|
||||
}
|
||||
|
||||
impl VisibleDomain {
|
||||
fn to_json(&self) -> Json {
|
||||
Json::obj(vec![
|
||||
("index", Json::u(self.index as u64)),
|
||||
("name", Json::s(self.name.clone())),
|
||||
(
|
||||
"observed",
|
||||
Json::Arr(
|
||||
self.observed
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
Some(v) => Json::i(*v),
|
||||
None => Json::Null,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
(
|
||||
"knowledge",
|
||||
Json::Arr(self.knowledge.iter().map(|k| Json::s(k.name())).collect()),
|
||||
),
|
||||
])
|
||||
}
|
||||
fn from_json(j: &Json) -> Result<Self, JsonError> {
|
||||
let observed = j
|
||||
.arr_field("observed")?
|
||||
.iter()
|
||||
.map(|v| match v {
|
||||
Json::Null => None,
|
||||
other => other.as_i64(),
|
||||
})
|
||||
.collect();
|
||||
let knowledge = j
|
||||
.arr_field("knowledge")?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().and_then(Knowledge::from_str))
|
||||
.collect();
|
||||
Ok(VisibleDomain {
|
||||
index: j.field("index")?.as_u8().ok_or(JsonError::Type("index".into(), "u8"))?,
|
||||
name: j.str_field("name")?,
|
||||
observed,
|
||||
knowledge,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An entity (player/dummy) as seen on the arena.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct VisibleEntity {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub hp: i32,
|
||||
pub is_self: bool,
|
||||
pub alive: bool,
|
||||
}
|
||||
|
||||
impl VisibleEntity {
|
||||
fn to_json(&self) -> Json {
|
||||
Json::obj(vec![
|
||||
("id", Json::u(self.id as u64)),
|
||||
("name", Json::s(self.name.clone())),
|
||||
("x", Json::i(self.x as i64)),
|
||||
("y", Json::i(self.y as i64)),
|
||||
("hp", Json::i(self.hp as i64)),
|
||||
("is_self", Json::Bool(self.is_self)),
|
||||
("alive", Json::Bool(self.alive)),
|
||||
])
|
||||
}
|
||||
fn from_json(j: &Json) -> Result<Self, JsonError> {
|
||||
Ok(VisibleEntity {
|
||||
id: j.u64_field("id")? as u32,
|
||||
name: j.str_field("name")?,
|
||||
x: j.i64_field("x")? as i32,
|
||||
y: j.i64_field("y")? as i32,
|
||||
hp: j.i64_field("hp")? as i32,
|
||||
is_self: j.field("is_self")?.as_bool().unwrap_or(false),
|
||||
alive: j.field("alive")?.as_bool().unwrap_or(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's filtered view of the world for one player (Phase F).
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct VisibleWorldSnapshot {
|
||||
pub turn: u64,
|
||||
pub arena_w: i32,
|
||||
pub arena_h: i32,
|
||||
pub observed_domains: Vec<VisibleDomain>,
|
||||
pub observed_entities: Vec<VisibleEntity>,
|
||||
/// Short human-readable environment descriptors (arena conditions).
|
||||
pub observed_environment: Vec<String>,
|
||||
/// Prior-turn outcome summaries the player has already witnessed.
|
||||
pub known_history: Vec<String>,
|
||||
/// Inferred (suspected) markers, e.g. "domain 3 likely volatile".
|
||||
pub inferred_markers: Vec<String>,
|
||||
/// Count of state values deliberately withheld (hidden lanes + masked
|
||||
/// observed lanes). Proof that hidden state exists and is *not* sent.
|
||||
pub hidden_state_redactions: u32,
|
||||
}
|
||||
|
||||
impl VisibleWorldSnapshot {
|
||||
pub fn to_json(&self) -> Json {
|
||||
Json::obj(vec![
|
||||
("turn", Json::u(self.turn)),
|
||||
("arena_w", Json::i(self.arena_w as i64)),
|
||||
("arena_h", Json::i(self.arena_h as i64)),
|
||||
(
|
||||
"observed_domains",
|
||||
Json::Arr(self.observed_domains.iter().map(|d| d.to_json()).collect()),
|
||||
),
|
||||
(
|
||||
"observed_entities",
|
||||
Json::Arr(self.observed_entities.iter().map(|e| e.to_json()).collect()),
|
||||
),
|
||||
(
|
||||
"observed_environment",
|
||||
Json::Arr(self.observed_environment.iter().map(|s| Json::s(s.clone())).collect()),
|
||||
),
|
||||
(
|
||||
"known_history",
|
||||
Json::Arr(self.known_history.iter().map(|s| Json::s(s.clone())).collect()),
|
||||
),
|
||||
(
|
||||
"inferred_markers",
|
||||
Json::Arr(self.inferred_markers.iter().map(|s| Json::s(s.clone())).collect()),
|
||||
),
|
||||
("hidden_state_redactions", Json::u(self.hidden_state_redactions as u64)),
|
||||
])
|
||||
}
|
||||
pub fn from_json(j: &Json) -> Result<Self, JsonError> {
|
||||
let observed_domains = j
|
||||
.arr_field("observed_domains")?
|
||||
.iter()
|
||||
.map(VisibleDomain::from_json)
|
||||
.collect::<Result<_, _>>()?;
|
||||
let observed_entities = j
|
||||
.arr_field("observed_entities")?
|
||||
.iter()
|
||||
.map(VisibleEntity::from_json)
|
||||
.collect::<Result<_, _>>()?;
|
||||
let strs = |key| -> Result<Vec<String>, JsonError> {
|
||||
Ok(j.arr_field(key)?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect())
|
||||
};
|
||||
Ok(VisibleWorldSnapshot {
|
||||
turn: j.u64_field("turn")?,
|
||||
arena_w: j.i64_field("arena_w")? as i32,
|
||||
arena_h: j.i64_field("arena_h")? as i32,
|
||||
observed_domains,
|
||||
observed_entities,
|
||||
observed_environment: strs("observed_environment")?,
|
||||
known_history: strs("known_history")?,
|
||||
inferred_markers: strs("inferred_markers")?,
|
||||
hidden_state_redactions: j.u64_field("hidden_state_redactions")? as u32,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Player-facing diagnostics for a rune program (Phase D). Strictly *observed*
|
||||
/// claims — never "guaranteed damage" or full hidden state.
|
||||
#[derive(Clone, PartialEq, Debug, Default)]
|
||||
pub struct RuneDiagnostics {
|
||||
pub known_reads: Vec<String>,
|
||||
pub known_writes: Vec<String>,
|
||||
pub observed_risks: Vec<String>,
|
||||
pub unknown_listeners: u32,
|
||||
pub previous_outcomes: Vec<String>,
|
||||
}
|
||||
|
||||
impl RuneDiagnostics {
|
||||
fn to_json(&self) -> Json {
|
||||
Json::obj(vec![
|
||||
("known_reads", Json::Arr(self.known_reads.iter().map(|s| Json::s(s.clone())).collect())),
|
||||
("known_writes", Json::Arr(self.known_writes.iter().map(|s| Json::s(s.clone())).collect())),
|
||||
("observed_risks", Json::Arr(self.observed_risks.iter().map(|s| Json::s(s.clone())).collect())),
|
||||
("unknown_listeners", Json::u(self.unknown_listeners as u64)),
|
||||
("previous_outcomes", Json::Arr(self.previous_outcomes.iter().map(|s| Json::s(s.clone())).collect())),
|
||||
])
|
||||
}
|
||||
fn from_json(j: &Json) -> Result<Self, JsonError> {
|
||||
let strs = |key| -> Vec<String> {
|
||||
j.get(key)
|
||||
.and_then(|v| v.as_arr())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
Ok(RuneDiagnostics {
|
||||
known_reads: strs("known_reads"),
|
||||
known_writes: strs("known_writes"),
|
||||
observed_risks: strs("observed_risks"),
|
||||
unknown_listeners: j.get("unknown_listeners").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
|
||||
previous_outcomes: strs("previous_outcomes"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One recorded turn in a replay stream (Phase G).
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct ReplayTurn {
|
||||
pub turn: u64,
|
||||
/// `(player_id, action)` pairs applied this turn, in canonical order.
|
||||
pub inputs: Vec<(u32, Action)>,
|
||||
/// The runtime canonical replay hash produced this turn.
|
||||
pub runtime_hash: String,
|
||||
}
|
||||
|
||||
impl ReplayTurn {
|
||||
fn to_json(&self) -> Json {
|
||||
Json::obj(vec![
|
||||
("turn", Json::u(self.turn)),
|
||||
(
|
||||
"inputs",
|
||||
Json::Arr(
|
||||
self.inputs
|
||||
.iter()
|
||||
.map(|(pid, a)| {
|
||||
Json::obj(vec![("player", Json::u(*pid as u64)), ("action", a.to_json())])
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
("runtime_hash", Json::s(self.runtime_hash.clone())),
|
||||
])
|
||||
}
|
||||
fn from_json(j: &Json) -> Result<Self, JsonError> {
|
||||
let inputs = j
|
||||
.arr_field("inputs")?
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let pid = e.u64_field("player")? as u32;
|
||||
let a = Action::from_json(e.field("action")?)?;
|
||||
Ok((pid, a))
|
||||
})
|
||||
.collect::<Result<_, JsonError>>()?;
|
||||
Ok(ReplayTurn {
|
||||
turn: j.u64_field("turn")?,
|
||||
inputs,
|
||||
runtime_hash: j.str_field("runtime_hash")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ServerMessage.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Everything the server may send. Every variant is hashable; the browser can
|
||||
/// verify it matches a recorded replay.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum ServerMessage {
|
||||
/// Assigned identity + match parameters on join.
|
||||
MatchState {
|
||||
match_id: MatchId,
|
||||
player_id: PlayerId,
|
||||
turn: u64,
|
||||
snapshot: VisibleWorldSnapshot,
|
||||
},
|
||||
/// A new turn has begun; `deadline_ms` is the wall-clock budget.
|
||||
TurnStarted { turn: u64, deadline_ms: u64 },
|
||||
/// A turn resolved authoritatively. Carries the runtime replay hash so the
|
||||
/// client can verify determinism.
|
||||
TurnResolved {
|
||||
turn: u64,
|
||||
snapshot: VisibleWorldSnapshot,
|
||||
runtime_hash: String,
|
||||
events: Vec<String>,
|
||||
},
|
||||
/// Result of an inspection request (filtered observations of a target).
|
||||
ObservationResult { target: u32, diagnostics: RuneDiagnostics },
|
||||
/// Validation feedback for a client packet (accepted/rejected + why).
|
||||
ValidationReport { accepted: bool, detail: String, diagnostics: RuneDiagnostics },
|
||||
/// One chunk of a replay stream.
|
||||
ReplayChunk {
|
||||
match_id: MatchId,
|
||||
seed: u64,
|
||||
index: u32,
|
||||
total: u32,
|
||||
turns: Vec<ReplayTurn>,
|
||||
final_hash: String,
|
||||
},
|
||||
/// A protocol/transport error that is not tied to a specific submission.
|
||||
ErrorEvent { code: String, detail: String },
|
||||
}
|
||||
|
||||
impl ServerMessage {
|
||||
fn type_tag(&self) -> &'static str {
|
||||
match self {
|
||||
ServerMessage::MatchState { .. } => "MatchState",
|
||||
ServerMessage::TurnStarted { .. } => "TurnStarted",
|
||||
ServerMessage::TurnResolved { .. } => "TurnResolved",
|
||||
ServerMessage::ObservationResult { .. } => "ObservationResult",
|
||||
ServerMessage::ValidationReport { .. } => "ValidationReport",
|
||||
ServerMessage::ReplayChunk { .. } => "ReplayChunk",
|
||||
ServerMessage::ErrorEvent { .. } => "ErrorEvent",
|
||||
}
|
||||
}
|
||||
|
||||
fn body(&self) -> Json {
|
||||
match self {
|
||||
ServerMessage::MatchState { match_id, player_id, turn, snapshot } => Json::obj(vec![
|
||||
("match_id", Json::u(match_id.0)),
|
||||
("player_id", Json::u(player_id.0 as u64)),
|
||||
("turn", Json::u(*turn)),
|
||||
("snapshot", snapshot.to_json()),
|
||||
]),
|
||||
ServerMessage::TurnStarted { turn, deadline_ms } => Json::obj(vec![
|
||||
("turn", Json::u(*turn)),
|
||||
("deadline_ms", Json::u(*deadline_ms)),
|
||||
]),
|
||||
ServerMessage::TurnResolved { turn, snapshot, runtime_hash, events } => Json::obj(vec![
|
||||
("turn", Json::u(*turn)),
|
||||
("snapshot", snapshot.to_json()),
|
||||
("runtime_hash", Json::s(runtime_hash.clone())),
|
||||
("events", Json::Arr(events.iter().map(|s| Json::s(s.clone())).collect())),
|
||||
]),
|
||||
ServerMessage::ObservationResult { target, diagnostics } => Json::obj(vec![
|
||||
("target", Json::u(*target as u64)),
|
||||
("diagnostics", diagnostics.to_json()),
|
||||
]),
|
||||
ServerMessage::ValidationReport { accepted, detail, diagnostics } => Json::obj(vec![
|
||||
("accepted", Json::Bool(*accepted)),
|
||||
("detail", Json::s(detail.clone())),
|
||||
("diagnostics", diagnostics.to_json()),
|
||||
]),
|
||||
ServerMessage::ReplayChunk { match_id, seed, index, total, turns, final_hash } => {
|
||||
Json::obj(vec![
|
||||
("match_id", Json::u(match_id.0)),
|
||||
("seed", Json::u(*seed)),
|
||||
("index", Json::u(*index as u64)),
|
||||
("total", Json::u(*total as u64)),
|
||||
("turns", Json::Arr(turns.iter().map(|t| t.to_json()).collect())),
|
||||
("final_hash", Json::s(final_hash.clone())),
|
||||
])
|
||||
}
|
||||
ServerMessage::ErrorEvent { code, detail } => Json::obj(vec![
|
||||
("code", Json::s(code.clone())),
|
||||
("detail", Json::s(detail.clone())),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Json {
|
||||
Json::obj(vec![
|
||||
("v", Json::u(PROTOCOL_VERSION as u64)),
|
||||
("type", Json::s(self.type_tag())),
|
||||
("body", self.body()),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> String {
|
||||
self.to_json().to_compact()
|
||||
}
|
||||
|
||||
/// Stable content hash over the canonical serialization. Because object
|
||||
/// keys are sorted and there is no whitespace, identical messages hash
|
||||
/// identically across machines — this is how replays are verified.
|
||||
pub fn content_hash(&self) -> Hash {
|
||||
let mut h = Hasher::new();
|
||||
h.write_tag("server-message");
|
||||
h.write_bytes(self.encode().as_bytes());
|
||||
h.finish()
|
||||
}
|
||||
|
||||
pub fn decode(raw: &str) -> Result<ServerMessage, JsonError> {
|
||||
let j = parse(raw)?;
|
||||
Self::from_json(&j)
|
||||
}
|
||||
|
||||
pub fn from_json(j: &Json) -> Result<ServerMessage, JsonError> {
|
||||
let v = j.u64_field("v")?;
|
||||
if v != PROTOCOL_VERSION as u64 {
|
||||
return Err(JsonError::Parse(format!(
|
||||
"protocol version mismatch: got {v}, expected {PROTOCOL_VERSION}"
|
||||
)));
|
||||
}
|
||||
let ty = j.str_field("type")?;
|
||||
let body = j.field("body")?;
|
||||
match ty.as_str() {
|
||||
"MatchState" => Ok(ServerMessage::MatchState {
|
||||
match_id: MatchId(body.u64_field("match_id")?),
|
||||
player_id: PlayerId(body.u64_field("player_id")? as u32),
|
||||
turn: body.u64_field("turn")?,
|
||||
snapshot: VisibleWorldSnapshot::from_json(body.field("snapshot")?)?,
|
||||
}),
|
||||
"TurnStarted" => Ok(ServerMessage::TurnStarted {
|
||||
turn: body.u64_field("turn")?,
|
||||
deadline_ms: body.u64_field("deadline_ms")?,
|
||||
}),
|
||||
"TurnResolved" => Ok(ServerMessage::TurnResolved {
|
||||
turn: body.u64_field("turn")?,
|
||||
snapshot: VisibleWorldSnapshot::from_json(body.field("snapshot")?)?,
|
||||
runtime_hash: body.str_field("runtime_hash")?,
|
||||
events: body
|
||||
.arr_field("events")?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect(),
|
||||
}),
|
||||
"ObservationResult" => Ok(ServerMessage::ObservationResult {
|
||||
target: body.u64_field("target")? as u32,
|
||||
diagnostics: RuneDiagnostics::from_json(body.field("diagnostics")?)?,
|
||||
}),
|
||||
"ValidationReport" => Ok(ServerMessage::ValidationReport {
|
||||
accepted: body.field("accepted")?.as_bool().unwrap_or(false),
|
||||
detail: body.str_field("detail")?,
|
||||
diagnostics: RuneDiagnostics::from_json(body.field("diagnostics")?)?,
|
||||
}),
|
||||
"ReplayChunk" => Ok(ServerMessage::ReplayChunk {
|
||||
match_id: MatchId(body.u64_field("match_id")?),
|
||||
seed: body.u64_field("seed")?,
|
||||
index: body.u64_field("index")? as u32,
|
||||
total: body.u64_field("total")? as u32,
|
||||
turns: body
|
||||
.arr_field("turns")?
|
||||
.iter()
|
||||
.map(ReplayTurn::from_json)
|
||||
.collect::<Result<_, _>>()?,
|
||||
final_hash: body.str_field("final_hash")?,
|
||||
}),
|
||||
"ErrorEvent" => Ok(ServerMessage::ErrorEvent {
|
||||
code: body.str_field("code")?,
|
||||
detail: body.str_field("detail")?,
|
||||
}),
|
||||
other => Err(JsonError::Parse(format!("unknown server message '{other}'"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_snapshot() -> VisibleWorldSnapshot {
|
||||
VisibleWorldSnapshot {
|
||||
turn: 3,
|
||||
arena_w: 8,
|
||||
arena_h: 8,
|
||||
observed_domains: vec![VisibleDomain {
|
||||
index: 0,
|
||||
name: "aether".into(),
|
||||
observed: vec![Some(1), None, Some(-4), None],
|
||||
knowledge: vec![
|
||||
Knowledge::Known,
|
||||
Knowledge::Unknown,
|
||||
Knowledge::NewlyObserved,
|
||||
Knowledge::Unknown,
|
||||
],
|
||||
}],
|
||||
observed_entities: vec![VisibleEntity {
|
||||
id: 1,
|
||||
name: "you".into(),
|
||||
x: 2,
|
||||
y: 3,
|
||||
hp: 30,
|
||||
is_self: true,
|
||||
alive: true,
|
||||
}],
|
||||
observed_environment: vec!["calm".into()],
|
||||
known_history: vec!["turn 2: you moved".into()],
|
||||
inferred_markers: vec!["domain 4 likely volatile".into()],
|
||||
hidden_state_redactions: 18,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_messages_roundtrip() {
|
||||
let msgs = vec![
|
||||
ClientMessage::JoinMatch { name: "dev".into(), match_id: None },
|
||||
ClientMessage::JoinMatch { name: "dev".into(), match_id: Some(MatchId(9)) },
|
||||
ClientMessage::SubmitTurn { turn: 4, action: Action::Move { dx: 1, dy: -1 } },
|
||||
ClientMessage::SubmitTurn { turn: 4, action: Action::Cast },
|
||||
ClientMessage::SubmitTurn { turn: 4, action: Action::Attack { target: 2 } },
|
||||
ClientMessage::EditRuneProgram {
|
||||
tokens: vec![RuneTokenWire { op: 0, a: 1, b: 2, c: 3, imm: -7 }],
|
||||
},
|
||||
ClientMessage::InspectTarget { target: 5 },
|
||||
ClientMessage::RequestReplay { match_id: MatchId(42) },
|
||||
ClientMessage::Ping { nonce: 123 },
|
||||
];
|
||||
for m in msgs {
|
||||
let s = m.encode();
|
||||
assert_eq!(ClientMessage::decode(&s).unwrap(), m, "roundtrip failed for {m:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_messages_roundtrip_and_hash_is_stable() {
|
||||
let msgs = vec![
|
||||
ServerMessage::MatchState {
|
||||
match_id: MatchId(1),
|
||||
player_id: PlayerId(1),
|
||||
turn: 0,
|
||||
snapshot: sample_snapshot(),
|
||||
},
|
||||
ServerMessage::TurnStarted { turn: 1, deadline_ms: 5000 },
|
||||
ServerMessage::TurnResolved {
|
||||
turn: 1,
|
||||
snapshot: sample_snapshot(),
|
||||
runtime_hash: "deadbeefcafef00d".into(),
|
||||
events: vec!["you cast".into(), "dummy took 4".into()],
|
||||
},
|
||||
ServerMessage::ValidationReport {
|
||||
accepted: false,
|
||||
detail: "late".into(),
|
||||
diagnostics: RuneDiagnostics::default(),
|
||||
},
|
||||
];
|
||||
for m in msgs {
|
||||
let s = m.encode();
|
||||
let back = ServerMessage::decode(&s).unwrap();
|
||||
assert_eq!(back, m);
|
||||
// Hash is a pure function of the canonical bytes.
|
||||
assert_eq!(m.content_hash(), back.content_hash());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_mismatch_is_rejected() {
|
||||
let mut j = ClientMessage::Ping { nonce: 1 }.to_json();
|
||||
if let Json::Obj(ref mut m) = j {
|
||||
m.insert("v".into(), Json::u(999));
|
||||
}
|
||||
assert!(ClientMessage::from_json(&j).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_is_total_on_garbage() {
|
||||
for raw in ["", "{}", "null", "{\"v\":1}", "{\"v\":1,\"type\":\"Nope\",\"body\":{}}"] {
|
||||
// Must be Err, never a panic.
|
||||
assert!(ClientMessage::decode(raw).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user