Files
magicka-vm/crates/server/src/lib.rs
T
linus-dandClaude Opus 4.8 9d9d5ce41c 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>
2026-06-21 20:20:46 -07:00

607 lines
20 KiB
Rust

//! `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());
}
}