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