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,15 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "magicka-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
protocol = { path = "../protocol" }
|
||||
game_runtime = { path = "../game_runtime" }
|
||||
world_model = { path = "../world_model" }
|
||||
web_client = { path = "../web_client" }
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Minimal HTTP/1.1 request parsing — only enough to tell a static GET from a
|
||||
//! WebSocket upgrade and to read the upgrade key. Tolerant and total: a
|
||||
//! malformed request yields `None`, never a panic.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{self, BufRead};
|
||||
|
||||
/// A parsed request head.
|
||||
pub struct Request {
|
||||
pub method: String,
|
||||
pub path: String,
|
||||
pub headers: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn header(&self, name: &str) -> Option<&str> {
|
||||
self.headers.get(&name.to_ascii_lowercase()).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// True if this is a WebSocket upgrade request.
|
||||
pub fn is_websocket_upgrade(&self) -> bool {
|
||||
self.header("upgrade")
|
||||
.map(|v| v.eq_ignore_ascii_case("websocket"))
|
||||
.unwrap_or(false)
|
||||
&& self
|
||||
.header("connection")
|
||||
.map(|v| v.to_ascii_lowercase().contains("upgrade"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn websocket_key(&self) -> Option<&str> {
|
||||
self.header("sec-websocket-key")
|
||||
}
|
||||
}
|
||||
|
||||
/// Read and parse the request head from a buffered reader. Returns `Ok(None)`
|
||||
/// on a clean EOF before any bytes.
|
||||
pub fn read_request<R: BufRead>(r: &mut R) -> io::Result<Option<Request>> {
|
||||
let mut line = String::new();
|
||||
let n = r.read_line(&mut line)?;
|
||||
if n == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut parts = line.trim_end().split_whitespace();
|
||||
let method = match parts.next() {
|
||||
Some(m) => m.to_string(),
|
||||
None => return Ok(None),
|
||||
};
|
||||
let path = parts.next().unwrap_or("/").to_string();
|
||||
|
||||
let mut headers = BTreeMap::new();
|
||||
loop {
|
||||
let mut h = String::new();
|
||||
let hn = r.read_line(&mut h)?;
|
||||
if hn == 0 {
|
||||
break;
|
||||
}
|
||||
let trimmed = h.trim_end();
|
||||
if trimmed.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some((k, v)) = trimmed.split_once(':') {
|
||||
headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string());
|
||||
}
|
||||
// Bound header count defensively.
|
||||
if headers.len() > 100 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Some(Request { method, path, headers }))
|
||||
}
|
||||
|
||||
/// Build the 101 Switching Protocols handshake response.
|
||||
pub fn handshake_response(accept: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 101 Switching Protocols\r\n\
|
||||
Upgrade: websocket\r\n\
|
||||
Connection: Upgrade\r\n\
|
||||
Sec-WebSocket-Accept: {accept}\r\n\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::BufReader;
|
||||
|
||||
#[test]
|
||||
fn parses_websocket_upgrade() {
|
||||
let raw = "GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: abc\r\n\r\n";
|
||||
let mut r = BufReader::new(raw.as_bytes());
|
||||
let req = read_request(&mut r).unwrap().unwrap();
|
||||
assert_eq!(req.method, "GET");
|
||||
assert_eq!(req.path, "/ws");
|
||||
assert!(req.is_websocket_upgrade());
|
||||
assert_eq!(req.websocket_key(), Some("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_plain_get() {
|
||||
let raw = "GET /app.js HTTP/1.1\r\nHost: x\r\n\r\n";
|
||||
let mut r = BufReader::new(raw.as_bytes());
|
||||
let req = read_request(&mut r).unwrap().unwrap();
|
||||
assert!(!req.is_websocket_upgrade());
|
||||
assert_eq!(req.path, "/app.js");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_is_none() {
|
||||
let mut r = BufReader::new("".as_bytes());
|
||||
assert!(read_request(&mut r).unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
//! `server` — the authoritative match server (Phase B of `plan2.md`), built on
|
||||
//! `std::net` with no external crates.
|
||||
//!
|
||||
//! Responsibilities the server owns: match state, the turn timer, collecting
|
||||
//! submitted actions, driving resolution through [`game_runtime`], visibility
|
||||
//! filtering, replay recording, and disconnect handling. The browser is served
|
||||
//! the embedded client and then speaks the `protocol` over a WebSocket.
|
||||
//!
|
||||
//! Authority guarantees enforced here and covered by tests:
|
||||
//! * **No panic on bad input** — every client packet is decoded with the total
|
||||
//! `protocol` decoder; a failure becomes a `ValidationReport`, never a crash.
|
||||
//! * **Late input rejected deterministically** — a `SubmitTurn` for any turn
|
||||
//! other than the live one, or after the deadline, is rejected with a stable
|
||||
//! reason.
|
||||
//! * **Disconnect cannot corrupt a match** — a dropped connection simply stops
|
||||
//! submitting; that player's turns default to `Wait` and the match continues.
|
||||
//! * **Client cannot mutate hidden state** — only intent is accepted, and the
|
||||
//! hidden ground truth is never serialized to a client.
|
||||
|
||||
pub mod http;
|
||||
pub mod ws;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{BufReader, Write};
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::mpsc::{self, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use game_runtime::{duel_roster, solo_roster, Match};
|
||||
use protocol::{
|
||||
Action, ClientMessage, MatchId, PlayerId, ReplayTurn, RuneDiagnostics, ServerMessage,
|
||||
};
|
||||
|
||||
/// How long the timer thread sleeps between ticks.
|
||||
const TICK: Duration = Duration::from_millis(40);
|
||||
/// Replay turns per `ReplayChunk`.
|
||||
const REPLAY_CHUNK: usize = 16;
|
||||
|
||||
/// An outbound item for a single connection's writer thread. Routing every
|
||||
/// write through one thread keeps frames from interleaving.
|
||||
enum Out {
|
||||
Text(String),
|
||||
Pong(Vec<u8>),
|
||||
Close,
|
||||
}
|
||||
|
||||
/// One live match plus its scheduling and connection state.
|
||||
struct Session {
|
||||
m: Match,
|
||||
turn_len: Duration,
|
||||
deadline: Instant,
|
||||
pending: BTreeMap<u32, Action>,
|
||||
conns: BTreeMap<u32, Sender<Out>>,
|
||||
/// Entity ids that are human-controlled (vs. a dummy).
|
||||
human_slots: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn deadline_ms(&self, now: Instant) -> u64 {
|
||||
self.deadline.saturating_duration_since(now).as_millis() as u64
|
||||
}
|
||||
|
||||
fn snapshot_msg(&self, player: u32) -> ServerMessage {
|
||||
ServerMessage::MatchState {
|
||||
match_id: self.m.id,
|
||||
player_id: PlayerId(player),
|
||||
turn: self.m.turn,
|
||||
snapshot: self.m.visible_for(player),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared manager: all matches, behind one mutex.
|
||||
pub struct Manager {
|
||||
sessions: BTreeMap<u64, Session>,
|
||||
next_auto_id: u64,
|
||||
turn_ms: u64,
|
||||
}
|
||||
|
||||
/// Result of a successful join.
|
||||
struct JoinOk {
|
||||
match_id: MatchId,
|
||||
player_id: u32,
|
||||
initial: ServerMessage,
|
||||
turn_started: ServerMessage,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
fn new(turn_ms: u64) -> Manager {
|
||||
Manager { sessions: BTreeMap::new(), next_auto_id: 1, turn_ms }
|
||||
}
|
||||
|
||||
fn turn_len(&self) -> Duration {
|
||||
Duration::from_millis(self.turn_ms)
|
||||
}
|
||||
|
||||
/// Join (or create) a match. `requested = None` creates a fresh solo match
|
||||
/// (player + dummy). `requested = Some(id)` joins an existing duel by id, or
|
||||
/// creates that duel and takes the first human slot.
|
||||
fn join(
|
||||
&mut self,
|
||||
name: &str,
|
||||
requested: Option<MatchId>,
|
||||
tx: Sender<Out>,
|
||||
) -> Result<JoinOk, String> {
|
||||
let now = Instant::now();
|
||||
let turn_len = self.turn_len();
|
||||
let key = match requested {
|
||||
Some(m) => m.0,
|
||||
None => {
|
||||
let id = self.next_auto_id;
|
||||
self.next_auto_id += 1;
|
||||
id
|
||||
}
|
||||
};
|
||||
|
||||
// Create the session if absent.
|
||||
if !self.sessions.contains_key(&key) {
|
||||
let (roster, human_slots) = if requested.is_some() {
|
||||
(duel_roster(name, "opponent"), vec![1u32, 2])
|
||||
} else {
|
||||
(solo_roster(name), vec![1u32])
|
||||
};
|
||||
let m = Match::new(MatchId(key), key, roster);
|
||||
self.sessions.insert(
|
||||
key,
|
||||
Session {
|
||||
m,
|
||||
turn_len,
|
||||
deadline: now + turn_len,
|
||||
pending: BTreeMap::new(),
|
||||
conns: BTreeMap::new(),
|
||||
human_slots,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let session = self.sessions.get_mut(&key).unwrap();
|
||||
// Find the first human slot without a live connection.
|
||||
let slot = session
|
||||
.human_slots
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|s| !session.conns.contains_key(s))
|
||||
.ok_or_else(|| "match is full".to_string())?;
|
||||
|
||||
// Adopt the player's chosen name on their entity.
|
||||
if let Some(e) = session.m.entity_mut(slot) {
|
||||
e.name = name.to_string();
|
||||
}
|
||||
session.conns.insert(slot, tx);
|
||||
|
||||
Ok(JoinOk {
|
||||
match_id: MatchId(key),
|
||||
player_id: slot,
|
||||
initial: session.snapshot_msg(slot),
|
||||
turn_started: ServerMessage::TurnStarted {
|
||||
turn: session.m.turn,
|
||||
deadline_ms: session.deadline_ms(now),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Queue a turn submission. Rejects late / wrong-turn submissions
|
||||
/// deterministically.
|
||||
fn submit(
|
||||
&mut self,
|
||||
match_key: u64,
|
||||
player: u32,
|
||||
turn: u64,
|
||||
action: Action,
|
||||
) -> Result<(), String> {
|
||||
let now = Instant::now();
|
||||
let session = self
|
||||
.sessions
|
||||
.get_mut(&match_key)
|
||||
.ok_or_else(|| "no such match".to_string())?;
|
||||
if session.m.finished {
|
||||
return Err("match has ended".to_string());
|
||||
}
|
||||
if turn != session.m.turn {
|
||||
return Err(format!(
|
||||
"wrong turn: submitted {}, live turn is {}",
|
||||
turn, session.m.turn
|
||||
));
|
||||
}
|
||||
if now > session.deadline {
|
||||
return Err("late: turn deadline has passed".to_string());
|
||||
}
|
||||
session.pending.insert(player, action);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_program(
|
||||
&mut self,
|
||||
match_key: u64,
|
||||
player: u32,
|
||||
tokens: Vec<protocol::RuneTokenWire>,
|
||||
) -> Result<RuneDiagnostics, String> {
|
||||
let session = self
|
||||
.sessions
|
||||
.get_mut(&match_key)
|
||||
.ok_or_else(|| "no such match".to_string())?;
|
||||
session.m.set_program(player, tokens);
|
||||
Ok(session.m.diagnostics_for_player(player))
|
||||
}
|
||||
|
||||
fn inspect(&self, match_key: u64, target: u32) -> Result<ServerMessage, String> {
|
||||
let session = self
|
||||
.sessions
|
||||
.get(&match_key)
|
||||
.ok_or_else(|| "no such match".to_string())?;
|
||||
let diagnostics = session.m.diagnostics_for_player(target);
|
||||
Ok(ServerMessage::ObservationResult { target, diagnostics })
|
||||
}
|
||||
|
||||
fn replay_chunks(&self, match_key: u64) -> Result<Vec<ServerMessage>, String> {
|
||||
let session = self
|
||||
.sessions
|
||||
.get(&match_key)
|
||||
.ok_or_else(|| "no such match".to_string())?;
|
||||
let m = &session.m;
|
||||
let turns: Vec<ReplayTurn> = m
|
||||
.replay
|
||||
.turns
|
||||
.iter()
|
||||
.map(|rt| ReplayTurn {
|
||||
turn: rt.turn,
|
||||
inputs: rt
|
||||
.inputs
|
||||
.iter()
|
||||
.map(|i| (i.player, i.action.clone()))
|
||||
.collect(),
|
||||
runtime_hash: format!("{}", rt.turn_hash),
|
||||
})
|
||||
.collect();
|
||||
let final_hash = m.final_hash_hex();
|
||||
let chunks: Vec<&[ReplayTurn]> = if turns.is_empty() {
|
||||
vec![&[]]
|
||||
} else {
|
||||
turns.chunks(REPLAY_CHUNK).collect()
|
||||
};
|
||||
let total = chunks.len() as u32;
|
||||
Ok(chunks
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| ServerMessage::ReplayChunk {
|
||||
match_id: m.id,
|
||||
seed: m.seed,
|
||||
index: i as u32,
|
||||
total,
|
||||
turns: c.to_vec(),
|
||||
final_hash: final_hash.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Advance any match whose deadline has elapsed. Runs under the lock; sends
|
||||
/// are non-blocking on unbounded channels.
|
||||
fn tick(&mut self, now: Instant) {
|
||||
let mut empty: Vec<u64> = Vec::new();
|
||||
for (key, session) in self.sessions.iter_mut() {
|
||||
if session.conns.is_empty() {
|
||||
empty.push(*key);
|
||||
continue;
|
||||
}
|
||||
if session.m.finished || now < session.deadline {
|
||||
continue;
|
||||
}
|
||||
// Resolve the turn from queued submissions.
|
||||
let subs: Vec<(u32, Action)> =
|
||||
session.pending.iter().map(|(p, a)| (*p, a.clone())).collect();
|
||||
let events = session.m.resolve_turn(&subs);
|
||||
session.pending.clear();
|
||||
let runtime_hash = session.m.last_turn_hash_hex();
|
||||
let turn = session.m.turn;
|
||||
// Broadcast the resolved state, filtered per player.
|
||||
for (pid, tx) in session.conns.iter() {
|
||||
let msg = ServerMessage::TurnResolved {
|
||||
turn,
|
||||
snapshot: session.m.visible_for(*pid),
|
||||
runtime_hash: runtime_hash.clone(),
|
||||
events: events.clone(),
|
||||
};
|
||||
let _ = tx.send(Out::Text(msg.encode()));
|
||||
}
|
||||
// Open the next turn unless the match just ended.
|
||||
if !session.m.finished {
|
||||
session.deadline = now + session.turn_len;
|
||||
let ts = ServerMessage::TurnStarted {
|
||||
turn,
|
||||
deadline_ms: session.turn_len.as_millis() as u64,
|
||||
};
|
||||
for tx in session.conns.values() {
|
||||
let _ = tx.send(Out::Text(ts.encode()));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drop sessions nobody is connected to (replay no longer reachable).
|
||||
for key in empty {
|
||||
self.sessions.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
fn disconnect(&mut self, match_key: u64, player: u32) {
|
||||
if let Some(session) = self.sessions.get_mut(&match_key) {
|
||||
session.conns.remove(&player);
|
||||
if session.conns.is_empty() {
|
||||
self.sessions.remove(&match_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration.
|
||||
pub struct Config {
|
||||
pub turn_ms: u64,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Config {
|
||||
let turn_ms = std::env::var("MAGICKA_TURN_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(5000);
|
||||
Config { turn_ms }
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the server on `addr`. Returns the bound address (useful when binding
|
||||
/// to port 0 in tests). Spawns the accept loop and the turn-timer thread as
|
||||
/// detached background threads.
|
||||
pub fn serve(addr: &str, cfg: Config) -> std::io::Result<SocketAddr> {
|
||||
let listener = TcpListener::bind(addr)?;
|
||||
let local = listener.local_addr()?;
|
||||
let manager = Arc::new(Mutex::new(Manager::new(cfg.turn_ms)));
|
||||
|
||||
// Turn timer.
|
||||
{
|
||||
let mgr = Arc::clone(&manager);
|
||||
thread::spawn(move || loop {
|
||||
thread::sleep(TICK);
|
||||
let now = Instant::now();
|
||||
lock(&mgr).tick(now);
|
||||
});
|
||||
}
|
||||
|
||||
// Accept loop.
|
||||
{
|
||||
let mgr = Arc::clone(&manager);
|
||||
thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
if let Ok(stream) = stream {
|
||||
let mgr = Arc::clone(&mgr);
|
||||
thread::spawn(move || {
|
||||
let _ = handle_conn(stream, mgr);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(local)
|
||||
}
|
||||
|
||||
/// Blocking entry point for the binary.
|
||||
pub fn run(addr: &str) -> std::io::Result<()> {
|
||||
let local = serve(addr, Config::from_env())?;
|
||||
eprintln!("magicka-server listening on http://{local} (open it in a browser)");
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(3600));
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_conn(stream: TcpStream, mgr: Arc<Mutex<Manager>>) -> std::io::Result<()> {
|
||||
stream.set_nodelay(true).ok();
|
||||
let mut head_reader = BufReader::new(stream.try_clone()?);
|
||||
let req = match http::read_request(&mut head_reader)? {
|
||||
Some(r) => r,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if !req.is_websocket_upgrade() {
|
||||
// Static asset.
|
||||
let mut s = stream;
|
||||
let resp = web_client::http_response(&req.path).unwrap_or_else(web_client::not_found);
|
||||
s.write_all(&resp)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Complete the WebSocket handshake.
|
||||
let key = match req.websocket_key() {
|
||||
Some(k) => k,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let accept = ws::accept_key(key);
|
||||
{
|
||||
let mut s = stream.try_clone()?;
|
||||
s.write_all(http::handshake_response(&accept).as_bytes())?;
|
||||
s.flush()?;
|
||||
}
|
||||
|
||||
// Writer thread: the only thing that ever writes to this socket.
|
||||
let (tx, rx) = mpsc::channel::<Out>();
|
||||
let mut write_stream = stream.try_clone()?;
|
||||
let writer = thread::spawn(move || {
|
||||
for out in rx {
|
||||
let r = match out {
|
||||
Out::Text(s) => ws::write_text(&mut write_stream, &s),
|
||||
Out::Pong(p) => ws::write_pong(&mut write_stream, &p),
|
||||
Out::Close => {
|
||||
let _ = ws::write_close(&mut write_stream);
|
||||
break;
|
||||
}
|
||||
};
|
||||
if r.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Reader loop.
|
||||
let mut read_stream = stream;
|
||||
let mut match_key: Option<u64> = None;
|
||||
let mut player_id: Option<u32> = None;
|
||||
|
||||
loop {
|
||||
match ws::read_message(&mut read_stream) {
|
||||
Ok(Some(ws::Message::Text(raw))) => {
|
||||
dispatch(&mgr, &tx, &raw, &mut match_key, &mut player_id);
|
||||
}
|
||||
Ok(Some(ws::Message::Ping(p))) => {
|
||||
let _ = tx.send(Out::Pong(p));
|
||||
}
|
||||
Ok(Some(ws::Message::Pong)) => {}
|
||||
Ok(Some(ws::Message::Close)) | Ok(None) => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect: deregister so the match continues without corruption.
|
||||
if let (Some(k), Some(p)) = (match_key, player_id) {
|
||||
lock(&mgr).disconnect(k, p);
|
||||
}
|
||||
let _ = tx.send(Out::Close);
|
||||
drop(tx);
|
||||
let _ = writer.join();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatch one decoded client message. Never panics: a decode failure or any
|
||||
/// rejected operation becomes a `ValidationReport`/`ErrorEvent`.
|
||||
fn dispatch(
|
||||
mgr: &Arc<Mutex<Manager>>,
|
||||
tx: &Sender<Out>,
|
||||
raw: &str,
|
||||
match_key: &mut Option<u64>,
|
||||
player_id: &mut Option<u32>,
|
||||
) {
|
||||
let msg = match ClientMessage::decode(raw) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
send(tx, ServerMessage::ValidationReport {
|
||||
accepted: false,
|
||||
detail: format!("malformed packet: {e}"),
|
||||
diagnostics: RuneDiagnostics::default(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match msg {
|
||||
ClientMessage::JoinMatch { name, match_id } => {
|
||||
if player_id.is_some() {
|
||||
send(tx, err_event("already_joined", "this connection already joined a match"));
|
||||
return;
|
||||
}
|
||||
let mut m = lock(mgr);
|
||||
match m.join(&name, match_id, tx.clone()) {
|
||||
Ok(ok) => {
|
||||
*match_key = Some(ok.match_id.0);
|
||||
*player_id = Some(ok.player_id);
|
||||
send(tx, ok.initial);
|
||||
send(tx, ok.turn_started);
|
||||
}
|
||||
Err(detail) => send(tx, err_event("join_failed", &detail)),
|
||||
}
|
||||
}
|
||||
ClientMessage::SubmitTurn { turn, action } => {
|
||||
let (Some(k), Some(p)) = (*match_key, *player_id) else {
|
||||
send(tx, err_event("not_joined", "join a match first"));
|
||||
return;
|
||||
};
|
||||
let res = lock(mgr).submit(k, p, turn, action);
|
||||
match res {
|
||||
Ok(()) => send(tx, ServerMessage::ValidationReport {
|
||||
accepted: true,
|
||||
detail: format!("action queued for turn {turn}"),
|
||||
diagnostics: RuneDiagnostics::default(),
|
||||
}),
|
||||
Err(detail) => send(tx, ServerMessage::ValidationReport {
|
||||
accepted: false,
|
||||
detail,
|
||||
diagnostics: RuneDiagnostics::default(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
ClientMessage::EditRuneProgram { tokens } => {
|
||||
let (Some(k), Some(p)) = (*match_key, *player_id) else {
|
||||
send(tx, err_event("not_joined", "join a match first"));
|
||||
return;
|
||||
};
|
||||
match lock(mgr).set_program(k, p, tokens) {
|
||||
Ok(diagnostics) => send(tx, ServerMessage::ValidationReport {
|
||||
accepted: true,
|
||||
detail: "program updated".to_string(),
|
||||
diagnostics,
|
||||
}),
|
||||
Err(detail) => send(tx, err_event("edit_failed", &detail)),
|
||||
}
|
||||
}
|
||||
ClientMessage::InspectTarget { target } => {
|
||||
let Some(k) = *match_key else {
|
||||
send(tx, err_event("not_joined", "join a match first"));
|
||||
return;
|
||||
};
|
||||
match lock(mgr).inspect(k, target) {
|
||||
Ok(m) => send(tx, m),
|
||||
Err(detail) => send(tx, err_event("inspect_failed", &detail)),
|
||||
}
|
||||
}
|
||||
ClientMessage::RequestReplay { match_id } => {
|
||||
match lock(mgr).replay_chunks(match_id.0) {
|
||||
Ok(chunks) => {
|
||||
for c in chunks {
|
||||
send(tx, c);
|
||||
}
|
||||
}
|
||||
Err(detail) => send(tx, err_event("replay_failed", &detail)),
|
||||
}
|
||||
}
|
||||
ClientMessage::Ping { .. } => {
|
||||
// Liveness only; the WebSocket layer already handles control pings.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send(tx: &Sender<Out>, msg: ServerMessage) {
|
||||
let _ = tx.send(Out::Text(msg.encode()));
|
||||
}
|
||||
|
||||
/// Acquire the manager lock, recovering a poisoned guard. A panic in any single
|
||||
/// connection or tick must not permanently brick the server for everyone else.
|
||||
fn lock(mgr: &Arc<Mutex<Manager>>) -> std::sync::MutexGuard<'_, Manager> {
|
||||
mgr.lock().unwrap_or_else(|p| p.into_inner())
|
||||
}
|
||||
|
||||
fn err_event(code: &str, detail: &str) -> ServerMessage {
|
||||
ServerMessage::ErrorEvent { code: code.to_string(), detail: detail.to_string() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn manager_join_and_resolve_is_authoritative() {
|
||||
let mut m = Manager::new(10);
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
let ok = m.join("dev", None, tx).unwrap();
|
||||
assert_eq!(ok.player_id, 1);
|
||||
let key = ok.match_id.0;
|
||||
// Submit a cast for the live turn.
|
||||
assert!(m.submit(key, 1, 0, Action::Cast).is_ok());
|
||||
// Wrong turn is rejected deterministically.
|
||||
let e = m.submit(key, 1, 99, Action::Cast).unwrap_err();
|
||||
assert!(e.contains("wrong turn"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_drops_session_when_last_leaves() {
|
||||
let mut m = Manager::new(10);
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
let ok = m.join("dev", None, tx).unwrap();
|
||||
let key = ok.match_id.0;
|
||||
assert!(m.sessions.contains_key(&key));
|
||||
m.disconnect(key, 1);
|
||||
assert!(!m.sessions.contains_key(&key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duel_assigns_two_human_slots() {
|
||||
let mut m = Manager::new(10);
|
||||
let (tx1, _r1) = mpsc::channel();
|
||||
let (tx2, _r2) = mpsc::channel();
|
||||
let a = m.join("a", Some(MatchId(42)), tx1).unwrap();
|
||||
let b = m.join("b", Some(MatchId(42)), tx2).unwrap();
|
||||
assert_eq!(a.player_id, 1);
|
||||
assert_eq!(b.player_id, 2);
|
||||
// Third join to a full duel is rejected.
|
||||
let (tx3, _r3) = mpsc::channel();
|
||||
assert!(m.join("c", Some(MatchId(42)), tx3).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! The Magicka VM web server binary. Serves the embedded browser client and the
|
||||
//! authoritative WebSocket protocol. Bind address via `MAGICKA_ADDR`
|
||||
//! (default `127.0.0.1:8080`); turn length via `MAGICKA_TURN_MS`.
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
let addr = std::env::var("MAGICKA_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".to_string());
|
||||
server::run(&addr)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//! Minimal WebSocket (RFC 6455) support over `std::net`, no external crates.
|
||||
//! Implements just what the game needs: the upgrade handshake (SHA1 + base64),
|
||||
//! masked client-frame reading with fragment reassembly, and unmasked
|
||||
//! server-frame writing. All reads are length-checked so a hostile frame
|
||||
//! returns an `Err`, never a panic or unbounded allocation.
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
const WS_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
/// Reject any single message larger than this (defensive bound).
|
||||
pub const MAX_MESSAGE: usize = 1 << 20; // 1 MiB
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SHA-1 (FIPS 180-1). Used only for the handshake accept key.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn sha1(data: &[u8]) -> [u8; 20] {
|
||||
let mut h: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];
|
||||
let ml = (data.len() as u64) * 8;
|
||||
let mut msg = data.to_vec();
|
||||
msg.push(0x80);
|
||||
while msg.len() % 64 != 56 {
|
||||
msg.push(0);
|
||||
}
|
||||
msg.extend_from_slice(&ml.to_be_bytes());
|
||||
|
||||
for chunk in msg.chunks_exact(64) {
|
||||
let mut w = [0u32; 80];
|
||||
for (i, wi) in w.iter_mut().enumerate().take(16) {
|
||||
*wi = u32::from_be_bytes([
|
||||
chunk[i * 4],
|
||||
chunk[i * 4 + 1],
|
||||
chunk[i * 4 + 2],
|
||||
chunk[i * 4 + 3],
|
||||
]);
|
||||
}
|
||||
for i in 16..80 {
|
||||
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
|
||||
}
|
||||
let (mut a, mut b, mut c, mut d, mut e) = (h[0], h[1], h[2], h[3], h[4]);
|
||||
for (i, &wi) in w.iter().enumerate() {
|
||||
let (f, k) = match i {
|
||||
0..=19 => ((b & c) | ((!b) & d), 0x5A827999u32),
|
||||
20..=39 => (b ^ c ^ d, 0x6ED9EBA1),
|
||||
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC),
|
||||
_ => (b ^ c ^ d, 0xCA62C1D6),
|
||||
};
|
||||
let tmp = a
|
||||
.rotate_left(5)
|
||||
.wrapping_add(f)
|
||||
.wrapping_add(e)
|
||||
.wrapping_add(k)
|
||||
.wrapping_add(wi);
|
||||
e = d;
|
||||
d = c;
|
||||
c = b.rotate_left(30);
|
||||
b = a;
|
||||
a = tmp;
|
||||
}
|
||||
h[0] = h[0].wrapping_add(a);
|
||||
h[1] = h[1].wrapping_add(b);
|
||||
h[2] = h[2].wrapping_add(c);
|
||||
h[3] = h[3].wrapping_add(d);
|
||||
h[4] = h[4].wrapping_add(e);
|
||||
}
|
||||
|
||||
let mut out = [0u8; 20];
|
||||
for (i, hi) in h.iter().enumerate() {
|
||||
out[i * 4..i * 4 + 4].copy_from_slice(&hi.to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// base64 (standard alphabet).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn base64(data: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::new();
|
||||
for chunk in data.chunks(3) {
|
||||
let b = [
|
||||
chunk[0],
|
||||
*chunk.get(1).unwrap_or(&0),
|
||||
*chunk.get(2).unwrap_or(&0),
|
||||
];
|
||||
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
|
||||
out.push(ALPHABET[((n >> 18) & 63) as usize] as char);
|
||||
out.push(ALPHABET[((n >> 12) & 63) as usize] as char);
|
||||
if chunk.len() > 1 {
|
||||
out.push(ALPHABET[((n >> 6) & 63) as usize] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
if chunk.len() > 2 {
|
||||
out.push(ALPHABET[(n & 63) as usize] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the `Sec-WebSocket-Accept` value for a client key.
|
||||
pub fn accept_key(client_key: &str) -> String {
|
||||
let mut concat = client_key.to_string();
|
||||
concat.push_str(WS_GUID);
|
||||
base64(&sha1(concat.as_bytes()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frames.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Opcode {
|
||||
Continuation,
|
||||
Text,
|
||||
Binary,
|
||||
Close,
|
||||
Ping,
|
||||
Pong,
|
||||
}
|
||||
|
||||
impl Opcode {
|
||||
fn from_u8(v: u8) -> Option<Opcode> {
|
||||
Some(match v {
|
||||
0x0 => Opcode::Continuation,
|
||||
0x1 => Opcode::Text,
|
||||
0x2 => Opcode::Binary,
|
||||
0x8 => Opcode::Close,
|
||||
0x9 => Opcode::Ping,
|
||||
0xA => Opcode::Pong,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Frame {
|
||||
fin: bool,
|
||||
opcode: Opcode,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
fn read_frame<R: Read>(r: &mut R) -> io::Result<Frame> {
|
||||
let mut hdr = [0u8; 2];
|
||||
r.read_exact(&mut hdr)?;
|
||||
let fin = hdr[0] & 0x80 != 0;
|
||||
let opcode = Opcode::from_u8(hdr[0] & 0x0f)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "bad opcode"))?;
|
||||
let masked = hdr[1] & 0x80 != 0;
|
||||
let len7 = (hdr[1] & 0x7f) as usize;
|
||||
let len = match len7 {
|
||||
126 => {
|
||||
let mut b = [0u8; 2];
|
||||
r.read_exact(&mut b)?;
|
||||
u16::from_be_bytes(b) as usize
|
||||
}
|
||||
127 => {
|
||||
let mut b = [0u8; 8];
|
||||
r.read_exact(&mut b)?;
|
||||
u64::from_be_bytes(b) as usize
|
||||
}
|
||||
n => n,
|
||||
};
|
||||
if len > MAX_MESSAGE {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "frame too large"));
|
||||
}
|
||||
// Per RFC, client frames MUST be masked.
|
||||
let mask = if masked {
|
||||
let mut m = [0u8; 4];
|
||||
r.read_exact(&mut m)?;
|
||||
Some(m)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut payload = vec![0u8; len];
|
||||
r.read_exact(&mut payload)?;
|
||||
if let Some(m) = mask {
|
||||
for (i, b) in payload.iter_mut().enumerate() {
|
||||
*b ^= m[i % 4];
|
||||
}
|
||||
}
|
||||
Ok(Frame { fin, opcode, payload })
|
||||
}
|
||||
|
||||
/// A complete application message read from the socket. Control frames are
|
||||
/// surfaced rather than answered inline so that *all* socket writes can be
|
||||
/// funneled through a single writer (avoiding interleaved frames when a server
|
||||
/// is both broadcasting and answering pings).
|
||||
pub enum Message {
|
||||
Text(String),
|
||||
/// A ping with its payload; the caller must reply with a pong.
|
||||
Ping(Vec<u8>),
|
||||
/// A pong (informational).
|
||||
Pong,
|
||||
/// The peer requested close.
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Read one full WebSocket message, reassembling fragments. Returns `Ok(None)`
|
||||
/// on a clean EOF. Reads only — never writes to the socket.
|
||||
pub fn read_message<R: Read>(stream: &mut R) -> io::Result<Option<Message>> {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let mut msg_op: Option<Opcode> = None;
|
||||
loop {
|
||||
let frame = match read_frame(stream) {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
match frame.opcode {
|
||||
Opcode::Close => return Ok(Some(Message::Close)),
|
||||
Opcode::Ping => return Ok(Some(Message::Ping(frame.payload))),
|
||||
Opcode::Pong => return Ok(Some(Message::Pong)),
|
||||
Opcode::Text | Opcode::Binary => {
|
||||
if msg_op.is_some() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "interleaved frame"));
|
||||
}
|
||||
msg_op = Some(frame.opcode);
|
||||
buf.extend_from_slice(&frame.payload);
|
||||
}
|
||||
Opcode::Continuation => {
|
||||
if msg_op.is_none() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "stray continuation"));
|
||||
}
|
||||
buf.extend_from_slice(&frame.payload);
|
||||
}
|
||||
}
|
||||
if buf.len() > MAX_MESSAGE {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "message too large"));
|
||||
}
|
||||
if frame.fin {
|
||||
// We only surface text to the application; binary is decoded lossily.
|
||||
let s = String::from_utf8_lossy(&buf).into_owned();
|
||||
return Ok(Some(Message::Text(s)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_frame<W: Write>(w: &mut W, opcode: Opcode, payload: &[u8]) -> io::Result<()> {
|
||||
let op = match opcode {
|
||||
Opcode::Continuation => 0x0,
|
||||
Opcode::Text => 0x1,
|
||||
Opcode::Binary => 0x2,
|
||||
Opcode::Close => 0x8,
|
||||
Opcode::Ping => 0x9,
|
||||
Opcode::Pong => 0xA,
|
||||
};
|
||||
let mut frame = vec![0x80 | op];
|
||||
let len = payload.len();
|
||||
if len < 126 {
|
||||
frame.push(len as u8);
|
||||
} else if len < 65536 {
|
||||
frame.push(126);
|
||||
frame.extend_from_slice(&(len as u16).to_be_bytes());
|
||||
} else {
|
||||
frame.push(127);
|
||||
frame.extend_from_slice(&(len as u64).to_be_bytes());
|
||||
}
|
||||
frame.extend_from_slice(payload);
|
||||
w.write_all(&frame)?;
|
||||
w.flush()
|
||||
}
|
||||
|
||||
/// Send a text message (server frames are never masked).
|
||||
pub fn write_text<W: Write>(w: &mut W, text: &str) -> io::Result<()> {
|
||||
write_frame(w, Opcode::Text, text.as_bytes())
|
||||
}
|
||||
|
||||
/// Send a pong frame echoing a ping payload.
|
||||
pub fn write_pong<W: Write>(w: &mut W, payload: &[u8]) -> io::Result<()> {
|
||||
write_frame(w, Opcode::Pong, payload)
|
||||
}
|
||||
|
||||
/// Send a close frame.
|
||||
pub fn write_close<W: Write>(w: &mut W) -> io::Result<()> {
|
||||
write_frame(w, Opcode::Close, &[])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rfc_example_accept_key() {
|
||||
// The canonical example from RFC 6455 section 1.3.
|
||||
assert_eq!(
|
||||
accept_key("dGhlIHNhbXBsZSBub25jZQ=="),
|
||||
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha1_known_vector() {
|
||||
// "abc" -> a9993e364706816aba3e25717850c26c9cd0d89d
|
||||
let d = sha1(b"abc");
|
||||
let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
|
||||
assert_eq!(hex, "a9993e364706816aba3e25717850c26c9cd0d89d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_roundtrip_lengths() {
|
||||
assert_eq!(base64(b""), "");
|
||||
assert_eq!(base64(b"f"), "Zg==");
|
||||
assert_eq!(base64(b"fo"), "Zm8=");
|
||||
assert_eq!(base64(b"foo"), "Zm9v");
|
||||
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_text_frame_roundtrips_through_reader() {
|
||||
use std::io::Cursor;
|
||||
// Build a masked client text frame for "hi".
|
||||
let payload = b"hi";
|
||||
let mask = [0x01, 0x02, 0x03, 0x04];
|
||||
let mut frame = vec![0x81, 0x80 | payload.len() as u8];
|
||||
frame.extend_from_slice(&mask);
|
||||
for (i, &b) in payload.iter().enumerate() {
|
||||
frame.push(b ^ mask[i % 4]);
|
||||
}
|
||||
// Cursor implements Read+Write (write goes nowhere useful but pong path
|
||||
// is not exercised here).
|
||||
let mut cur = Cursor::new(frame);
|
||||
match read_message(&mut cur).unwrap() {
|
||||
Some(Message::Text(s)) => assert_eq!(s, "hi"),
|
||||
_ => panic!("expected text"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_frame_is_rejected() {
|
||||
use std::io::Cursor;
|
||||
// Declares a 127-length (8-byte) payload of u64::MAX — must error, not OOM.
|
||||
let mut frame = vec![0x81, 0x80 | 127];
|
||||
frame.extend_from_slice(&u64::MAX.to_be_bytes());
|
||||
frame.extend_from_slice(&[0, 0, 0, 0]); // partial mask
|
||||
let mut cur = Cursor::new(frame);
|
||||
assert!(read_message(&mut cur).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user