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>
607 lines
20 KiB
Rust
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());
|
|
}
|
|
}
|