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,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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user