//! `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), Close, } /// One live match plus its scheduling and connection state. struct Session { m: Match, turn_len: Duration, deadline: Instant, pending: BTreeMap, conns: BTreeMap>, /// Entity ids that are human-controlled (vs. a dummy). human_slots: Vec, } 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, 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, tx: Sender, ) -> Result { 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, ) -> Result { 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 { 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, String> { let session = self .sessions .get(&match_key) .ok_or_else(|| "no such match".to_string())?; let m = &session.m; let turns: Vec = 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 = 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 { 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>) -> 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::(); 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 = None; let mut player_id: Option = 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>, tx: &Sender, raw: &str, match_key: &mut Option, player_id: &mut Option, ) { 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, 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>) -> 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()); } }