1293 lines
52 KiB
Rust
1293 lines
52 KiB
Rust
// DungeonCraft service, match relay over WebSocket.
|
|
//
|
|
// The relay never simulates the dungeon. It keeps rooms, seats and a 100 ms turn clock: clients send
|
|
// their commands, the relay confirms a turn with the commands of all sides, and every client runs
|
|
// exactly five simulation steps. Checksums are relayed, not checked.
|
|
//
|
|
// Lobby messages (JSON, one object per WebSocket frame):
|
|
// {"t":"rooms"} -> list of open matches
|
|
// {"t":"create","sides":[0,2,3,4]?,"map_w":96?,"map_h":128?,"difficulty":1?,"mission":12?,"doc":{..}?,"surface":true?}
|
|
// -> "surface": a free match whose eastern half lies above ground
|
|
// -> "doc": an editor campaign (CampaignDoc JSON) for a co-op
|
|
// match of its mission "mission"; it goes along with "start"
|
|
// "seed":77?,"carry":[..]? with a doc: an interrupted run goes
|
|
// on - "start" then has this seed, the carry and "resume":true
|
|
// {"t":"join","code":"ABC123"}
|
|
// {"t":"take_side","side":2}
|
|
// {"t":"ready","ready":true}
|
|
// {"t":"start"} -> side 0 starts the match
|
|
// {"t":"cmds","cmds":[Command]} -> commands of the sender's side
|
|
// {"t":"sum","turn":1,"sum":1234} -> checksum for a peer
|
|
// {"t":"chat","text":"hello"}
|
|
// {"t":"resume"} -> side 0 resumes a paused match
|
|
// {"t":"abort"} -> side 0 closes the match
|
|
// {"t":"set_ai","side":2,"ai":true} -> the host marks an empty seat as AI, or clears it again
|
|
// {"t":"set_team","team":1,"side":3?} -> team 0..3 (A-D) of the sender's seat before the start; the
|
|
// host may name an empty or AI seat with "side"
|
|
// {"t":"next","mission":3,"seed":77?,"carry":[..]?} -> the host starts the next mission of an editor campaign
|
|
// co-op match; every client gets "next" (no campaign: they
|
|
// have it) with the seed, sides, AI seats and the carry
|
|
// {"t":"leave"} -> a player leaves; a started human seat becomes AI-controlled while anyone is connected
|
|
//
|
|
// Server messages include: hello, rooms, room, start, next, turn, sum, chat, closed, error, pong.
|
|
|
|
use crate::AppState;
|
|
use axum::{
|
|
extract::{
|
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
|
Query, State,
|
|
},
|
|
response::Response,
|
|
};
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use getrandom::getrandom;
|
|
use serde::Deserialize;
|
|
use serde_json::{json, Value};
|
|
use std::collections::HashMap;
|
|
use tokio::sync::mpsc;
|
|
use tokio::time::{sleep, Duration};
|
|
|
|
const TURN_MS: u64 = 100;
|
|
const CHAT_MAX: usize = 80;
|
|
const MAX_PENDING: usize = 200;
|
|
const MAX_ROOMS: usize = 64;
|
|
const MASTER_SIDES: [u8; 4] = [0, 2, 3, 4];
|
|
const MAX_MISSIONS: usize = 30; // keep this in step with the campaign mission count in the client
|
|
// an editor campaign travels in one WebSocket message (create, start): as large as a stored campaign
|
|
pub const MAX_WS_MESSAGE: usize = 40 << 20;
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct Netplay {
|
|
rooms: HashMap<String, Room>,
|
|
clients: HashMap<u64, Client>,
|
|
next_conn: u64,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Client {
|
|
name: String,
|
|
#[allow(dead_code)] // kept for the lobby: the name came from a logged-in user, not from the query
|
|
user: Option<String>,
|
|
room: Option<String>,
|
|
tx: mpsc::UnboundedSender<String>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Room {
|
|
code: String,
|
|
sides: Vec<u8>,
|
|
seats: HashMap<u8, Seat>,
|
|
pending: HashMap<u8, Vec<Value>>,
|
|
turn: u32,
|
|
started: bool,
|
|
paused: bool,
|
|
missing: bool,
|
|
seed: Option<u32>,
|
|
map_w: usize,
|
|
map_h: usize,
|
|
difficulty: u8,
|
|
mission: Option<usize>,
|
|
doc: Option<Value>, // editor campaign of a co-op match (sent to every client at the start)
|
|
resume: Option<(u32, Value)>, // a resumed editor campaign run: its seed and what the masters carry
|
|
surface: bool, // a free match half above ground
|
|
events: bool, // random events in a free match
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Seat {
|
|
conn: Option<u64>,
|
|
name: String,
|
|
ready: bool,
|
|
ai: bool,
|
|
team: u8, // 0..3 (A-D) in a versus match; by default every seat is a team of its own
|
|
}
|
|
|
|
fn default_team(side: u8) -> u8 {
|
|
MASTER_SIDES.iter().position(|s| *s == side).unwrap_or(0) as u8
|
|
}
|
|
|
|
fn new_seat(side: u8, ai: bool) -> Seat {
|
|
Seat { conn: None, name: String::new(), ready: false, ai, team: default_team(side) }
|
|
}
|
|
|
|
impl Room {
|
|
fn has_connection(&self) -> bool {
|
|
self.seats.values().any(|s| s.conn.is_some())
|
|
}
|
|
|
|
fn active_sides(&self) -> Vec<u8> {
|
|
self.sides
|
|
.iter()
|
|
.copied()
|
|
.filter(|side| self.seats.get(side).map(|seat| seat.conn.is_some() || seat.ai).unwrap_or(false))
|
|
.collect()
|
|
}
|
|
|
|
fn desired_ai_sides(&self) -> Vec<u8> {
|
|
self.sides
|
|
.iter()
|
|
.filter(|side| self.seats.get(side).map(|seat| seat.conn.is_none() && seat.ai).unwrap_or(false))
|
|
.copied()
|
|
.collect()
|
|
}
|
|
|
|
fn started_ai_sides(&self) -> Vec<u8> {
|
|
self.active_sides()
|
|
.iter()
|
|
.filter(|side| self.seats.get(side).map(|seat| seat.conn.is_none()).unwrap_or(false))
|
|
.copied()
|
|
.collect()
|
|
}
|
|
|
|
fn room_ai_sides(&self) -> Vec<u8> {
|
|
if self.started {
|
|
self.started_ai_sides()
|
|
} else {
|
|
self.desired_ai_sides()
|
|
}
|
|
}
|
|
|
|
// the team of every side of `sides`, in the same order
|
|
fn teams_of(&self, sides: &[u8]) -> Vec<u8> {
|
|
sides.iter().map(|s| self.seats.get(s).map_or(default_team(*s), |seat| seat.team)).collect()
|
|
}
|
|
|
|
// the name of the editor campaign and the title of its current mission (room list)
|
|
fn campaign_names(&self) -> (Option<String>, Option<String>) {
|
|
let Some(doc) = &self.doc else { return (None, None) };
|
|
let name = doc.get("name").and_then(|n| n.as_str()).map(|n| n.chars().take(40).collect::<String>());
|
|
let title = self.mission.and_then(|m| doc.get("missions").and_then(|ms| ms.get(m))).and_then(|m| m.get("title")).and_then(|t| t.as_str()).map(|t| t.chars().take(40).collect::<String>());
|
|
(name, title)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct WsQuery {
|
|
name: Option<String>,
|
|
token: Option<String>,
|
|
}
|
|
|
|
fn room_code() -> String {
|
|
let mut b = [0u8; 6];
|
|
getrandom(&mut b).ok();
|
|
let letters: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
b.iter().map(|x| letters[(*x as usize) % letters.len()] as char).collect()
|
|
}
|
|
|
|
fn random_seed() -> u32 {
|
|
let mut b = [0u8; 4];
|
|
getrandom(&mut b).ok();
|
|
u32::from_le_bytes(b)
|
|
}
|
|
|
|
fn clean_name(n: Option<String>) -> String {
|
|
let s: String = n.unwrap_or_default().chars().filter(|c| !c.is_control()).take(24).collect();
|
|
if s.trim().is_empty() {
|
|
"Guest".into()
|
|
} else {
|
|
s.trim().to_string()
|
|
}
|
|
}
|
|
|
|
fn sides_of(v: Option<&Value>) -> Vec<u8> {
|
|
let mut out: Vec<u8> = match v {
|
|
Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_u64().map(|n| n as u8)).filter(|s| MASTER_SIDES.contains(s)).collect(),
|
|
_ => Vec::new(),
|
|
};
|
|
out.sort_by_key(|s| MASTER_SIDES.iter().position(|m| m == s).unwrap_or(9));
|
|
out.dedup();
|
|
if !out.contains(&0) {
|
|
out.insert(0, 0);
|
|
}
|
|
out
|
|
}
|
|
|
|
fn sort_sides(sides: &mut Vec<u8>) {
|
|
sides.sort_by_key(|s| MASTER_SIDES.iter().position(|m| m == s).unwrap_or(9));
|
|
}
|
|
|
|
fn mission_of(v: Option<&Value>) -> Option<usize> {
|
|
match v {
|
|
None | Some(Value::Null) => None,
|
|
Some(x) => x.as_u64().map(|m| m as usize),
|
|
}
|
|
}
|
|
|
|
pub async fn netplay_ws(ws: WebSocketUpgrade, Query(q): Query<WsQuery>, State(st): State<AppState>) -> Response {
|
|
ws.max_message_size(MAX_WS_MESSAGE).max_frame_size(MAX_WS_MESSAGE).on_upgrade(move |socket| handle_socket(socket, st, q))
|
|
}
|
|
|
|
async fn handle_socket(socket: WebSocket, st: AppState, q: WsQuery) {
|
|
let user = match q.token.as_deref() {
|
|
Some(t) => st.user_by_token(t).await,
|
|
None => None,
|
|
};
|
|
let (mut ws_tx, mut ws_rx) = socket.split();
|
|
let (net_tx, mut net_rx) = mpsc::unbounded_channel::<String>();
|
|
let conn = st.netplay.lock().await.connect(net_tx, q.name, user);
|
|
tokio::spawn(async move {
|
|
while let Some(msg) = net_rx.recv().await {
|
|
if ws_tx.send(Message::Text(msg.into())).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
while let Some(msg) = ws_rx.next().await {
|
|
match msg {
|
|
Ok(Message::Text(text)) => {
|
|
if let Ok(v) = serde_json::from_str::<Value>(&text) {
|
|
let new_rooms = st.netplay.lock().await.handle(conn, v);
|
|
for code in new_rooms {
|
|
spawn_turn_loop(st.clone(), code);
|
|
}
|
|
}
|
|
}
|
|
Ok(Message::Close(_)) => break,
|
|
Err(_) => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
st.netplay.lock().await.disconnect(conn);
|
|
}
|
|
|
|
pub fn spawn_turn_loop(st: AppState, code: String) {
|
|
tokio::spawn(async move {
|
|
loop {
|
|
sleep(Duration::from_millis(TURN_MS)).await;
|
|
let mut net = st.netplay.lock().await;
|
|
if !net.rooms.contains_key(&code) {
|
|
break;
|
|
}
|
|
net.tick(&code);
|
|
}
|
|
});
|
|
}
|
|
|
|
impl Netplay {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn connect(&mut self, tx: mpsc::UnboundedSender<String>, name: Option<String>, user: Option<String>) -> u64 {
|
|
let id = self.next_conn.wrapping_add(1);
|
|
self.next_conn = id;
|
|
let name = user.clone().unwrap_or_else(|| clean_name(name));
|
|
self.clients.insert(id, Client { name, user, room: None, tx });
|
|
self.send(id, json!({"t":"hello","name":self.clients[&id].name}));
|
|
self.rooms_msg(id);
|
|
id
|
|
}
|
|
|
|
pub fn disconnect(&mut self, conn: u64) {
|
|
if let Some(c) = self.clients.remove(&conn) {
|
|
if let Some(code) = c.room {
|
|
self.leave_room(conn, &code, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
// returns newly created room codes whose turn loop the service has to start
|
|
pub fn handle(&mut self, conn: u64, msg: Value) -> Vec<String> {
|
|
let t = msg.get("t").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
match t.as_str() {
|
|
"rooms" => {
|
|
self.rooms_msg(conn);
|
|
}
|
|
"create" => {
|
|
if let Some(c) = self.create(conn, &msg) {
|
|
return vec![c];
|
|
}
|
|
}
|
|
"join" => self.join(conn, &msg),
|
|
"take_side" => self.take_side(conn, &msg),
|
|
"ready" => self.ready(conn, &msg),
|
|
"set_ai" => self.set_ai(conn, &msg),
|
|
"set_team" => self.set_team(conn, &msg),
|
|
"next" => self.next(conn, &msg),
|
|
"start" => self.start(conn),
|
|
"cmds" => self.cmds(conn, &msg),
|
|
"sum" => self.sum(conn, &msg),
|
|
"chat" => self.chat(conn, &msg),
|
|
"resume" => self.resume(conn),
|
|
"abort" => self.abort(conn),
|
|
"leave" => {
|
|
let code = self.clients.get(&conn).and_then(|c| c.room.clone());
|
|
if let Some(code) = code {
|
|
self.leave_room(conn, &code, false);
|
|
}
|
|
}
|
|
"ping" => {
|
|
self.send(conn, json!({"t":"pong","c":msg.get("c").cloned().unwrap_or(Value::Null)}));
|
|
}
|
|
_ => self.error(conn, "unknown message"),
|
|
}
|
|
Vec::new()
|
|
}
|
|
|
|
pub fn tick(&mut self, code: &str) {
|
|
let action = match self.rooms.get_mut(code) {
|
|
Some(r) => {
|
|
if r.started && !r.paused {
|
|
let mut cmds = Vec::new();
|
|
for side in r.sides.clone() {
|
|
if let Some(v) = r.pending.remove(&side) {
|
|
cmds.extend(v);
|
|
}
|
|
}
|
|
r.turn += 1;
|
|
Some((r.turn, cmds, r.started_ai_sides()))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
None => return,
|
|
};
|
|
if let Some((turn, cmds, ai)) = action {
|
|
self.broadcast_room(code, json!({"t":"turn","turn":turn,"cmds":cmds,"ai":ai}));
|
|
}
|
|
}
|
|
|
|
fn create(&mut self, conn: u64, msg: &Value) -> Option<String> {
|
|
if self.rooms.len() >= MAX_ROOMS {
|
|
self.error(conn, "too many matches open");
|
|
return None;
|
|
}
|
|
let code = loop {
|
|
let c = room_code();
|
|
if !self.rooms.contains_key(&c) {
|
|
break c;
|
|
}
|
|
};
|
|
let sides = sides_of(msg.get("sides"));
|
|
let map_w = msg.get("map_w").and_then(|v| v.as_u64()).unwrap_or(96).clamp(72, 256) as usize;
|
|
let map_h = msg.get("map_h").and_then(|v| v.as_u64()).unwrap_or(128).clamp(72, 256) as usize;
|
|
let difficulty = msg.get("difficulty").and_then(|v| v.as_u64()).unwrap_or(1).clamp(0, 2) as u8;
|
|
let mission = mission_of(msg.get("mission"));
|
|
let doc = msg.get("doc").filter(|d| !d.is_null()).cloned();
|
|
let resume = match (&doc, msg.get("seed").and_then(|v| v.as_u64())) {
|
|
(Some(_), Some(seed)) => Some((seed as u32, msg.get("carry").filter(|c| c.is_array()).cloned().unwrap_or_else(|| json!([])))),
|
|
_ => None,
|
|
};
|
|
match &doc {
|
|
Some(d) => {
|
|
let count = d.get("missions").and_then(|m| m.as_array()).map_or(0, |m| m.len());
|
|
if !d.is_object() || mission.map_or(true, |m| m >= count) {
|
|
self.error(conn, "unknown campaign mission");
|
|
return None;
|
|
}
|
|
}
|
|
None => {
|
|
if mission.map_or(false, |m| m >= MAX_MISSIONS) {
|
|
self.error(conn, "unknown campaign mission");
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into());
|
|
let seats = sides.iter().map(|s| (*s, new_seat(*s, false))).collect::<HashMap<_, _>>();
|
|
let room = Room { code: code.clone(), sides, seats, pending: HashMap::new(), turn: 0, started: false, paused: false, missing: false, seed: None, map_w, map_h, difficulty, mission, doc, resume, surface: msg.get("surface").and_then(|v| v.as_bool()).unwrap_or(false), events: msg.get("events").and_then(|v| v.as_bool()).unwrap_or(false) };
|
|
self.rooms.insert(code.clone(), room);
|
|
if let Some(c) = self.clients.get_mut(&conn) {
|
|
c.room = Some(code.clone());
|
|
}
|
|
if let Some(r) = self.rooms.get_mut(&code) {
|
|
if let Some(s) = r.seats.get_mut(&0) {
|
|
s.conn = Some(conn);
|
|
s.name = name;
|
|
s.ready = false;
|
|
}
|
|
}
|
|
self.rooms_msg(conn);
|
|
self.room_msg(conn, &code);
|
|
Some(code)
|
|
}
|
|
|
|
fn join(&mut self, conn: u64, msg: &Value) {
|
|
let code = msg.get("code").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
|
|
if code.len() != 6 {
|
|
self.error(conn, "match code needs 6 characters");
|
|
return;
|
|
}
|
|
if !self.rooms.contains_key(&code) {
|
|
self.error(conn, "no such match");
|
|
return;
|
|
}
|
|
if self.rooms[&code].started {
|
|
self.error(conn, "cannot join a match in progress");
|
|
return;
|
|
}
|
|
let side = match self.rooms[&code].sides.iter().copied().find(|s| self.rooms[&code].seats.get(s).map(|seat| seat.conn.is_none() && !seat.ai).unwrap_or(false)) {
|
|
Some(s) => s,
|
|
None => {
|
|
self.error(conn, "match is full");
|
|
return;
|
|
}
|
|
};
|
|
let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into());
|
|
if let Some(c) = self.clients.get_mut(&conn) {
|
|
c.room = Some(code.clone());
|
|
}
|
|
if let Some(r) = self.rooms.get_mut(&code) {
|
|
if let Some(s) = r.seats.get_mut(&side) {
|
|
s.conn = Some(conn);
|
|
s.name = name;
|
|
s.ready = false;
|
|
}
|
|
}
|
|
self.rooms_msg(conn);
|
|
self.room_msg_all(&code);
|
|
}
|
|
|
|
fn take_side(&mut self, conn: u64, msg: &Value) {
|
|
let side = match msg.get("side").and_then(|v| v.as_u64()) {
|
|
Some(n) => n as u8,
|
|
None => return self.error(conn, "need a side"),
|
|
};
|
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
|
Some(c) => c,
|
|
None => return self.error(conn, "not in a match"),
|
|
};
|
|
if !MASTER_SIDES.contains(&side) {
|
|
return self.error(conn, "unknown side");
|
|
}
|
|
let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into());
|
|
let mut started = false;
|
|
let mut taken = false;
|
|
if let Some(r) = self.rooms.get_mut(&code) {
|
|
started = r.started;
|
|
if !started && r.sides.contains(&side) && r.seats.get(&side).map(|s| s.conn.is_none() && !s.ai).unwrap_or(false) {
|
|
for s in r.seats.values_mut() {
|
|
if s.conn == Some(conn) {
|
|
s.conn = None;
|
|
s.name.clear();
|
|
s.ready = false;
|
|
}
|
|
}
|
|
if let Some(t) = r.seats.get_mut(&side) {
|
|
t.conn = Some(conn);
|
|
t.name = name;
|
|
t.ready = false;
|
|
t.ai = false;
|
|
taken = true;
|
|
}
|
|
}
|
|
}
|
|
if taken {
|
|
self.room_msg_all(&code);
|
|
} else if started {
|
|
self.error(conn, "match already started");
|
|
} else {
|
|
self.error(conn, "side not free");
|
|
}
|
|
}
|
|
|
|
fn ready(&mut self, conn: u64, msg: &Value) {
|
|
let ready = msg.get("ready").and_then(|v| v.as_bool()).unwrap_or(true);
|
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
|
Some(c) => c,
|
|
None => return self.error(conn, "not in a match"),
|
|
};
|
|
let mut ok = false;
|
|
if let Some(r) = self.rooms.get_mut(&code) {
|
|
for s in r.seats.values_mut() {
|
|
if s.conn == Some(conn) {
|
|
s.ready = ready;
|
|
ok = true;
|
|
}
|
|
}
|
|
}
|
|
if ok {
|
|
self.room_msg_all(&code);
|
|
} else {
|
|
self.error(conn, "no seat");
|
|
}
|
|
}
|
|
|
|
fn set_ai(&mut self, conn: u64, msg: &Value) {
|
|
let side = match msg.get("side").and_then(|v| v.as_u64()) {
|
|
Some(n) => n as u8,
|
|
None => return self.error(conn, "need a side"),
|
|
};
|
|
let ai = msg.get("ai").and_then(|v| v.as_bool()).unwrap_or(true);
|
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
|
Some(c) => c,
|
|
None => return self.error(conn, "not in a match"),
|
|
};
|
|
if !MASTER_SIDES.contains(&side) {
|
|
return self.error(conn, "unknown side");
|
|
}
|
|
if side == 0 {
|
|
return self.error(conn, "the host seat stays human");
|
|
}
|
|
if let Some(r) = self.rooms.get_mut(&code) {
|
|
if r.started {
|
|
return self.error(conn, "AI seats can only be changed before the start");
|
|
}
|
|
if r.seats.get(&0).and_then(|s| s.conn) != Some(conn) {
|
|
return self.error(conn, "only the red master can set AI seats");
|
|
}
|
|
if !r.seats.contains_key(&side) {
|
|
if !ai {
|
|
return self.error(conn, "side not in match");
|
|
}
|
|
r.sides.push(side);
|
|
sort_sides(&mut r.sides);
|
|
r.seats.insert(side, new_seat(side, true));
|
|
} else {
|
|
if r.seats[&side].conn.is_some() {
|
|
return self.error(conn, "seat is taken");
|
|
}
|
|
if let Some(s) = r.seats.get_mut(&side) {
|
|
s.ai = ai;
|
|
s.ready = false;
|
|
if !ai {
|
|
s.name.clear();
|
|
}
|
|
}
|
|
}
|
|
self.room_msg_all(&code);
|
|
} else {
|
|
self.error(conn, "not in a match");
|
|
}
|
|
}
|
|
|
|
fn set_team(&mut self, conn: u64, msg: &Value) {
|
|
let team = match msg.get("team").and_then(|v| v.as_u64()) {
|
|
Some(n) if n < MASTER_SIDES.len() as u64 => n as u8,
|
|
_ => return self.error(conn, "unknown team"),
|
|
};
|
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
|
Some(c) => c,
|
|
None => return self.error(conn, "not in a match"),
|
|
};
|
|
let Some(own) = self.side_of(&code, conn) else { return self.error(conn, "no seat") };
|
|
let side = msg.get("side").and_then(|v| v.as_u64()).map_or(own, |n| n as u8);
|
|
if let Some(r) = self.rooms.get_mut(&code) {
|
|
if r.started {
|
|
return self.error(conn, "teams can only be changed before the start");
|
|
}
|
|
let Some(seat) = r.seats.get(&side) else { return self.error(conn, "side not in match") };
|
|
if side != own && (own != 0 || seat.conn.is_some()) {
|
|
return self.error(conn, "only the red master can set the team of a free or AI seat");
|
|
}
|
|
if let Some(seat) = r.seats.get_mut(&side) {
|
|
seat.team = team;
|
|
}
|
|
}
|
|
self.room_msg_all(&code);
|
|
}
|
|
|
|
// the next mission of an editor campaign co-op match: the host decided it (the branch follows
|
|
// the reached goal) and hands the carry-over of every side along; the clients already have the
|
|
// campaign. The turn clock starts again.
|
|
fn next(&mut self, conn: u64, msg: &Value) {
|
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
|
Some(c) => c,
|
|
None => return self.error(conn, "not in a match"),
|
|
};
|
|
let mission = mission_of(msg.get("mission"));
|
|
let seed = msg.get("seed").and_then(|v| v.as_u64()).map(|s| s as u32);
|
|
let carry = msg.get("carry").filter(|c| c.is_array()).cloned().unwrap_or(Value::Array(Vec::new()));
|
|
let out = match self.rooms.get_mut(&code) {
|
|
Some(r) => {
|
|
if !r.started {
|
|
return self.error(conn, "match not started");
|
|
}
|
|
if r.seats.get(&0).and_then(|s| s.conn) != Some(conn) {
|
|
return self.error(conn, "only the red master can start the next mission");
|
|
}
|
|
let count = r.doc.as_ref().and_then(|d| d.get("missions")).and_then(|m| m.as_array()).map_or(0, |m| m.len());
|
|
let Some(m) = mission.filter(|m| *m < count) else { return self.error(conn, "unknown campaign mission") };
|
|
r.mission = Some(m);
|
|
r.seed = Some(seed.unwrap_or_else(random_seed));
|
|
r.turn = 0;
|
|
r.pending.clear();
|
|
r.paused = false;
|
|
r.missing = false;
|
|
r.sides = r.active_sides();
|
|
json!({"t":"next","seed":r.seed.unwrap_or(0),"difficulty":r.difficulty,"sides":r.sides,"teams":r.teams_of(&r.sides),"mission":m,"ai":r.started_ai_sides(),"carry":carry})
|
|
}
|
|
None => return self.error(conn, "not in a match"),
|
|
};
|
|
self.room_msg_all(&code);
|
|
self.broadcast_room(&code, out);
|
|
}
|
|
|
|
fn start(&mut self, conn: u64) {
|
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
|
Some(c) => c,
|
|
None => return self.error(conn, "not in a match"),
|
|
};
|
|
if let Some(r) = self.rooms.get_mut(&code) {
|
|
if r.started {
|
|
return self.error(conn, "match already started");
|
|
}
|
|
if r.seats.get(&0).and_then(|s| s.conn) != Some(conn) {
|
|
return self.error(conn, "only the red master can start");
|
|
}
|
|
for s in r.seats.values() {
|
|
if s.conn.is_some() && !s.ready {
|
|
return self.error(conn, "not everyone is ready");
|
|
}
|
|
}
|
|
let active = r.active_sides();
|
|
if active.is_empty() || !active.contains(&0) {
|
|
return self.error(conn, "no playable master seat");
|
|
}
|
|
if active.len() < 2 && r.mission.is_none() {
|
|
return self.error(conn, "a free match needs at least two masters");
|
|
}
|
|
r.started = true;
|
|
r.paused = false;
|
|
r.missing = false;
|
|
r.seed = Some(r.resume.as_ref().map_or_else(random_seed, |x| x.0));
|
|
r.turn = 0;
|
|
r.sides = active;
|
|
}
|
|
let msg = match self.rooms.get(&code) {
|
|
Some(r) => {
|
|
let mut m = json!({"t":"start","seed":r.seed.unwrap_or(0),"map_w":r.map_w,"map_h":r.map_h,"difficulty":r.difficulty,"sides":r.sides,"teams":r.teams_of(&r.sides),"mission":r.mission,"ai":r.started_ai_sides()});
|
|
if let Some(doc) = &r.doc {
|
|
m["doc"] = doc.clone();
|
|
}
|
|
if r.surface {
|
|
m["surface"] = json!(true);
|
|
}
|
|
if r.events {
|
|
m["events"] = json!(true);
|
|
}
|
|
if let Some((_, carry)) = &r.resume {
|
|
m["carry"] = carry.clone();
|
|
m["resume"] = json!(true);
|
|
}
|
|
m
|
|
}
|
|
None => return,
|
|
};
|
|
self.room_msg_all(&code);
|
|
self.broadcast_room(&code, msg);
|
|
}
|
|
|
|
fn cmds(&mut self, conn: u64, msg: &Value) {
|
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
|
Some(c) => c,
|
|
None => return,
|
|
};
|
|
let side = match self.side_of(&code, conn) {
|
|
Some(s) => s,
|
|
None => return,
|
|
};
|
|
let cmds = match msg.get("cmds").and_then(|v| v.as_array()) {
|
|
Some(a) => a.clone(),
|
|
None => return,
|
|
};
|
|
if cmds.iter().any(|c| c.get("side").and_then(|v| v.as_u64()) != Some(side as u64)) {
|
|
return self.error(conn, "bad command side");
|
|
}
|
|
if let Some(r) = self.rooms.get_mut(&code) {
|
|
if !r.started || r.paused {
|
|
return;
|
|
}
|
|
let list = r.pending.entry(side).or_default();
|
|
for c in cmds {
|
|
if list.len() < MAX_PENDING {
|
|
list.push(c);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn sum(&mut self, conn: u64, msg: &Value) {
|
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
|
Some(c) => c,
|
|
None => return,
|
|
};
|
|
let from = self.side_of(&code, conn);
|
|
let turn = msg.get("turn").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
|
let sum = match msg.get("sum").and_then(|v| v.as_u64()) {
|
|
Some(s) => s,
|
|
None => return,
|
|
};
|
|
self.broadcast_room_except(&code, conn, json!({"t":"sum","from":from,"turn":turn,"sum":sum}));
|
|
}
|
|
|
|
fn chat(&mut self, conn: u64, msg: &Value) {
|
|
let text: String = msg.get("text").and_then(|v| v.as_str()).unwrap_or("").chars().filter(|c| !c.is_control()).take(CHAT_MAX).collect();
|
|
let text = text.trim().to_string();
|
|
if text.is_empty() {
|
|
return;
|
|
}
|
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
|
Some(c) => c,
|
|
None => return,
|
|
};
|
|
let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into());
|
|
let side = self.side_of(&code, conn);
|
|
let channel = msg.get("channel").and_then(|v| v.as_str()).unwrap_or("all");
|
|
let chat = json!({"t":"chat","name":name,"text":text,"side":side,"channel":channel});
|
|
if channel == "team" {
|
|
match side {
|
|
Some(side) => self.broadcast_room_side(&code, side, chat),
|
|
None => self.broadcast_room(&code, chat),
|
|
}
|
|
} else {
|
|
self.broadcast_room(&code, chat);
|
|
}
|
|
}
|
|
|
|
fn resume(&mut self, conn: u64) {
|
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
|
Some(c) => c,
|
|
None => return self.error(conn, "not in a match"),
|
|
};
|
|
if let Some(r) = self.rooms.get_mut(&code) {
|
|
if !r.started {
|
|
return self.error(conn, "match not started");
|
|
}
|
|
if r.seats.get(&0).and_then(|s| s.conn) != Some(conn) {
|
|
return self.error(conn, "only the red master can resume");
|
|
}
|
|
r.paused = false;
|
|
r.missing = false;
|
|
}
|
|
self.room_msg_all(&code);
|
|
}
|
|
|
|
fn abort(&mut self, conn: u64) {
|
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
|
Some(c) => c,
|
|
None => return self.error(conn, "not in a match"),
|
|
};
|
|
let side = self.side_of(&code, conn);
|
|
if side != Some(0) {
|
|
return self.error(conn, "only the red master can abort");
|
|
}
|
|
self.broadcast_room(&code, json!({"t":"closed","why":"aborted"}));
|
|
if let Some(c) = self.clients.get_mut(&conn) {
|
|
c.room = None;
|
|
}
|
|
self.rooms.remove(&code);
|
|
self.rooms_msg(conn);
|
|
}
|
|
|
|
fn leave_room(&mut self, conn: u64, code: &str, _disconnect: bool) {
|
|
let (remove_room, messages) = match self.rooms.get_mut(code) {
|
|
Some(r) => {
|
|
for s in r.seats.values_mut() {
|
|
if s.conn == Some(conn) {
|
|
if r.started {
|
|
s.ai = true;
|
|
}
|
|
s.conn = None;
|
|
s.name.clear();
|
|
s.ready = false;
|
|
}
|
|
}
|
|
if r.started {
|
|
if r.has_connection() {
|
|
r.paused = false;
|
|
r.missing = false;
|
|
(false, Vec::new())
|
|
} else {
|
|
(true, vec![json!({"t":"closed","why":"last player left"})])
|
|
}
|
|
} else {
|
|
let empty = r.seats.values().all(|s| s.conn.is_none());
|
|
if empty {
|
|
(true, Vec::new())
|
|
} else {
|
|
(false, Vec::new())
|
|
}
|
|
}
|
|
}
|
|
None => (false, Vec::new()),
|
|
};
|
|
for msg in messages {
|
|
self.broadcast_room(code, msg);
|
|
}
|
|
if !remove_room && self.rooms.contains_key(code) {
|
|
self.room_msg_all(code);
|
|
}
|
|
if remove_room {
|
|
if let Some(c) = self.clients.get_mut(&conn) {
|
|
c.room = None;
|
|
}
|
|
self.rooms.remove(code);
|
|
self.rooms_msg(conn);
|
|
}
|
|
}
|
|
|
|
fn side_of(&self, code: &str, conn: u64) -> Option<u8> {
|
|
let r = self.rooms.get(code)?;
|
|
r.seats.iter().find(|(_, s)| s.conn == Some(conn)).map(|(side, _)| *side)
|
|
}
|
|
|
|
fn error(&mut self, conn: u64, why: &str) {
|
|
self.send(conn, json!({"t":"error","why":why}));
|
|
}
|
|
|
|
fn send(&mut self, conn: u64, v: Value) {
|
|
if let Some(c) = self.clients.get_mut(&conn) {
|
|
let _ = c.tx.send(v.to_string());
|
|
}
|
|
}
|
|
|
|
fn broadcast_room(&mut self, code: &str, v: Value) {
|
|
let targets: Vec<u64> = self.rooms.get(code).map(|r| r.seats.values().filter_map(|s| s.conn).collect()).unwrap_or_default();
|
|
for conn in targets {
|
|
self.send(conn, v.clone());
|
|
}
|
|
}
|
|
|
|
fn broadcast_room_except(&mut self, code: &str, except: u64, v: Value) {
|
|
let targets: Vec<u64> = self.rooms.get(code).map(|r| r.seats.values().filter_map(|s| s.conn).filter(|c| *c != except).collect()).unwrap_or_default();
|
|
for conn in targets {
|
|
self.send(conn, v.clone());
|
|
}
|
|
}
|
|
|
|
fn broadcast_room_side(&mut self, code: &str, side: u8, v: Value) {
|
|
let targets: Vec<u64> = self
|
|
.rooms
|
|
.get(code)
|
|
.map(|r| r.seats.iter().filter(|(s, seat)| **s == side && seat.conn.is_some()).filter_map(|(_, seat)| seat.conn).collect())
|
|
.unwrap_or_default();
|
|
for conn in targets {
|
|
self.send(conn, v.clone());
|
|
}
|
|
}
|
|
|
|
fn room_msg(&mut self, conn: u64, code: &str) {
|
|
if let Some(v) = self.room_json(code) {
|
|
self.send(conn, v);
|
|
}
|
|
}
|
|
|
|
fn room_msg_all(&mut self, code: &str) {
|
|
if let Some(v) = self.room_json(code) {
|
|
self.broadcast_room(code, v);
|
|
}
|
|
}
|
|
|
|
fn room_json(&self, code: &str) -> Option<Value> {
|
|
let r = self.rooms.get(code)?;
|
|
let sides: Vec<u8> = if r.started { r.active_sides() } else { r.sides.clone() };
|
|
let seats: Vec<Value> = sides
|
|
.iter()
|
|
.filter_map(|side| r.seats.get(side).map(|s| json!({"side":side,"name":s.name,"ready":s.ready,"connected":s.conn.is_some(),"ai":s.ai,"team":s.team})))
|
|
.collect();
|
|
let ai: Vec<u8> = r.room_ai_sides();
|
|
let (campaign, title) = r.campaign_names();
|
|
Some(json!({
|
|
"t":"room",
|
|
"code":r.code,
|
|
"sides":sides,
|
|
"seats":seats,
|
|
"ai":ai,
|
|
"turn":r.turn,
|
|
"started":r.started,
|
|
"paused":r.paused,
|
|
"missing":r.missing,
|
|
"map_w":r.map_w,
|
|
"map_h":r.map_h,
|
|
"difficulty":r.difficulty,
|
|
"seed":r.seed,
|
|
"mission":r.mission,
|
|
"editor":r.doc.is_some(),
|
|
"campaign":campaign,
|
|
"title":title
|
|
}))
|
|
}
|
|
|
|
fn rooms_msg(&mut self, conn: u64) {
|
|
let rooms: Vec<Value> = self
|
|
.rooms
|
|
.values()
|
|
.map(|r| {
|
|
let players = r.seats.values().filter(|s| s.conn.is_some()).count();
|
|
let (campaign, title) = r.campaign_names();
|
|
json!({"code":r.code,"players":players,"sides":r.sides,"teams":r.teams_of(&r.sides),"started":r.started,"paused":r.paused,"mission":r.mission,"editor":r.doc.is_some(),"campaign":campaign,"title":title})
|
|
})
|
|
.collect();
|
|
self.send(conn, json!({"t":"rooms","rooms":rooms}));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tokio::sync::mpsc::UnboundedReceiver;
|
|
|
|
fn pop(rx: &mut UnboundedReceiver<String>) -> Value {
|
|
let s = rx.try_recv().expect("no message");
|
|
serde_json::from_str(&s).expect("bad json")
|
|
}
|
|
|
|
fn until(rx: &mut UnboundedReceiver<String>, kind: &str) -> Value {
|
|
loop {
|
|
let v = pop(rx);
|
|
if v["t"] == kind {
|
|
return v;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_relay_confirms_one_turn_after_the_next() {
|
|
let mut net = Netplay::new();
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let red = net.connect(tx, Some("red".into()), None);
|
|
let rooms = net.handle(red, json!({"t":"create","sides":[0,2]}));
|
|
assert_eq!(rooms.len(), 1);
|
|
let code = rooms[0].clone();
|
|
|
|
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
|
let green = net.connect(tx2, Some("green".into()), None);
|
|
net.handle(green, json!({"t":"join","code":&code}));
|
|
net.handle(red, json!({"t":"ready"}));
|
|
net.handle(green, json!({"t":"ready"}));
|
|
net.handle(red, json!({"t":"start"}));
|
|
|
|
let start_red = until(&mut rx, "start");
|
|
let start_green = until(&mut rx2, "start");
|
|
assert_eq!(start_red["seed"], start_green["seed"]);
|
|
assert_eq!(start_red["sides"], json!([0, 2]));
|
|
|
|
let cmd = json!({"side":0,"seq":0,"act":{"Dig":{"x0":5,"y0":5,"x1":6,"y1":6}}});
|
|
net.handle(red, json!({"t":"cmds","cmds":[cmd.clone()]}));
|
|
net.tick(&code);
|
|
|
|
let turn_red = until(&mut rx, "turn");
|
|
let turn_green = until(&mut rx2, "turn");
|
|
assert_eq!(turn_red["turn"].as_u64(), Some(1));
|
|
assert_eq!(turn_red["cmds"].as_array().unwrap().len(), 1);
|
|
assert_eq!(turn_green["cmds"], turn_red["cmds"]);
|
|
|
|
net.handle(red, json!({"t":"sum","turn":1,"sum":12345678901234567890u64}));
|
|
let sum = until(&mut rx2, "sum");
|
|
assert_eq!(sum["sum"].as_u64(), Some(12345678901234567890u64));
|
|
}
|
|
|
|
#[test]
|
|
fn a_room_remembers_a_coop_campaign_mission() {
|
|
let mut net = Netplay::new();
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let red = net.connect(tx, Some("red".into()), None);
|
|
let rooms = net.handle(red, json!({"t":"create","sides":[0,2],"mission":2}));
|
|
let code = rooms[0].clone();
|
|
let room = until(&mut rx, "room");
|
|
assert_eq!(room["mission"].as_u64(), Some(2));
|
|
|
|
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
|
let green = net.connect(tx2, Some("green".into()), None);
|
|
net.handle(green, json!({"t":"join","code":&code}));
|
|
net.handle(red, json!({"t":"ready"}));
|
|
net.handle(green, json!({"t":"ready"}));
|
|
net.handle(red, json!({"t":"start"}));
|
|
|
|
let start_red = until(&mut rx, "start");
|
|
let start_green = until(&mut rx2, "start");
|
|
assert_eq!(start_red["mission"], start_green["mission"]);
|
|
assert_eq!(start_red["mission"].as_u64(), Some(2));
|
|
}
|
|
|
|
#[test]
|
|
fn a_resumed_run_starts_with_its_seed_and_carry() {
|
|
let mut net = Netplay::new();
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let red = net.connect(tx, Some("red".into()), None);
|
|
let doc = json!({"format":"dungeoncraft-campaign","name":"Mine","missions":[{"key":"a","title":"First"},{"key":"b","title":"Second","size":[96,96]}]});
|
|
let carry = json!([[0, {"gold": 100.0, "creatures": []}]]);
|
|
net.handle(red, json!({"t":"create","sides":[0,2],"mission":1,"doc":doc,"seed":4242,"carry":carry}));
|
|
net.handle(red, json!({"t":"ready"}));
|
|
net.handle(red, json!({"t":"start"}));
|
|
let start = until(&mut rx, "start");
|
|
assert_eq!(start["seed"].as_u64(), Some(4242));
|
|
assert_eq!(start["carry"], carry);
|
|
assert_eq!(start["resume"].as_bool(), Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn an_editor_campaign_goes_from_the_create_to_every_start() {
|
|
let mut net = Netplay::new();
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let red = net.connect(tx, Some("red".into()), None);
|
|
let doc = json!({"format":"dungeoncraft-campaign","name":"Mine","missions":[{"key":"a","title":"First"},{"key":"b","title":"Second","size":[96,96]}]});
|
|
// a mission the campaign does not have is refused
|
|
assert!(net.handle(red, json!({"t":"create","sides":[0,2],"mission":2,"doc":doc})).is_empty());
|
|
assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("unknown campaign mission"));
|
|
let rooms = net.handle(red, json!({"t":"create","sides":[0,2],"map_w":96,"map_h":96,"mission":1,"doc":doc}));
|
|
let code = rooms[0].clone();
|
|
let room = until(&mut rx, "room");
|
|
assert_eq!(room["editor"].as_bool(), Some(true));
|
|
assert!(room.get("doc").is_none(), "room updates stay small");
|
|
|
|
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
|
let green = net.connect(tx2, Some("green".into()), None);
|
|
net.handle(green, json!({"t":"join","code":&code}));
|
|
net.handle(red, json!({"t":"ready"}));
|
|
net.handle(green, json!({"t":"ready"}));
|
|
net.handle(red, json!({"t":"start"}));
|
|
let start_red = until(&mut rx, "start");
|
|
let start_green = until(&mut rx2, "start");
|
|
assert_eq!(start_red["seed"], start_green["seed"]);
|
|
assert_eq!(start_green["mission"].as_u64(), Some(1));
|
|
assert_eq!(start_red["doc"], doc);
|
|
assert_eq!(start_green["doc"], doc, "the joined player gets the campaign too");
|
|
}
|
|
|
|
#[test]
|
|
fn a_built_in_start_carries_no_campaign() {
|
|
let mut net = Netplay::new();
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let red = net.connect(tx, Some("red".into()), None);
|
|
net.handle(red, json!({"t":"create","sides":[0,2],"mission":2}));
|
|
net.handle(red, json!({"t":"ready"}));
|
|
net.handle(red, json!({"t":"start"}));
|
|
let start = until(&mut rx, "start");
|
|
assert!(start.get("doc").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn players_choose_their_teams_and_the_start_carries_them() {
|
|
let mut net = Netplay::new();
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let red = net.connect(tx, Some("red".into()), None);
|
|
let rooms = net.handle(red, json!({"t":"create","sides":[0,2,3]}));
|
|
let code = rooms[0].clone();
|
|
let room = until(&mut rx, "room");
|
|
let teams: Vec<u64> = room["seats"].as_array().unwrap().iter().map(|s| s["team"].as_u64().unwrap()).collect();
|
|
assert_eq!(teams, vec![0, 1, 2], "every seat is its own team at first");
|
|
|
|
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
|
let green = net.connect(tx2, Some("green".into()), None);
|
|
net.handle(green, json!({"t":"join","code":&code}));
|
|
// green joins red's team; green may not decide for blue's seat, red (host) may
|
|
net.handle(green, json!({"t":"set_team","team":0}));
|
|
net.handle(green, json!({"t":"set_team","team":3,"side":3}));
|
|
assert_eq!(until(&mut rx2, "error")["why"].as_str(), Some("only the red master can set the team of a free or AI seat"));
|
|
net.handle(red, json!({"t":"set_ai","side":3,"ai":true}));
|
|
net.handle(red, json!({"t":"set_team","team":3,"side":3}));
|
|
net.handle(red, json!({"t":"set_team","team":9}));
|
|
assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("unknown team"));
|
|
let room = net.room_json(&code).unwrap();
|
|
let team = |side: u64| room["seats"].as_array().unwrap().iter().find(|s| s["side"].as_u64() == Some(side)).unwrap()["team"].as_u64();
|
|
assert_eq!((team(0), team(2), team(3)), (Some(0), Some(0), Some(3)));
|
|
|
|
net.handle(red, json!({"t":"rooms"}));
|
|
let list = until(&mut rx, "rooms");
|
|
assert_eq!(list["rooms"][0]["teams"], json!([0, 0, 3]));
|
|
|
|
net.handle(red, json!({"t":"ready"}));
|
|
net.handle(green, json!({"t":"ready"}));
|
|
net.handle(red, json!({"t":"start"}));
|
|
let start = until(&mut rx2, "start");
|
|
assert_eq!(start["sides"], json!([0, 2, 3]));
|
|
assert_eq!(start["teams"], json!([0, 0, 3]));
|
|
net.handle(red, json!({"t":"set_team","team":1}));
|
|
assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("teams can only be changed before the start"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_host_starts_the_next_editor_mission_for_everybody() {
|
|
let mut net = Netplay::new();
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let red = net.connect(tx, Some("red".into()), None);
|
|
let doc = json!({"name":"Deep Run","missions":[{"key":"a","title":"First"},{"key":"b","title":"Second"}]});
|
|
let rooms = net.handle(red, json!({"t":"create","sides":[0,2],"map_w":96,"map_h":96,"mission":0,"doc":doc}));
|
|
let code = rooms[0].clone();
|
|
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
|
let green = net.connect(tx2, Some("green".into()), None);
|
|
net.handle(green, json!({"t":"rooms"}));
|
|
let list = until(&mut rx2, "rooms");
|
|
assert_eq!(list["rooms"][0]["campaign"].as_str(), Some("Deep Run"), "the room list names the campaign");
|
|
assert_eq!(list["rooms"][0]["title"].as_str(), Some("First"));
|
|
net.handle(green, json!({"t":"join","code":&code}));
|
|
net.handle(red, json!({"t":"ready"}));
|
|
net.handle(green, json!({"t":"ready"}));
|
|
net.handle(red, json!({"t":"start"}));
|
|
until(&mut rx, "start");
|
|
until(&mut rx2, "start");
|
|
net.handle(red, json!({"t":"cmds","cmds":[{"side":0,"seq":0,"act":"CancelOrders"}]}));
|
|
net.tick(&code);
|
|
net.tick(&code);
|
|
assert_eq!(net.rooms[&code].turn, 2);
|
|
|
|
// only the host decides; the mission must exist
|
|
net.handle(green, json!({"t":"next","mission":1}));
|
|
assert_eq!(until(&mut rx2, "error")["why"].as_str(), Some("only the red master can start the next mission"));
|
|
net.handle(red, json!({"t":"next","mission":5}));
|
|
assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("unknown campaign mission"));
|
|
|
|
net.handle(red, json!({"t":"cmds","cmds":[{"side":0,"seq":1,"act":"CancelOrders"}]}));
|
|
let carry = json!([[0, {"creatures":[["Ogre",3,10.0]],"gold":300.0}], [2, {"creatures":[],"gold":40.0}]]);
|
|
net.handle(red, json!({"t":"next","mission":1,"seed":4711,"carry":carry}));
|
|
let next_red = until(&mut rx, "next");
|
|
let next_green = until(&mut rx2, "next");
|
|
assert_eq!(next_green["mission"].as_u64(), Some(1));
|
|
assert_eq!(next_green["seed"].as_u64(), Some(4711), "the run seed of the host");
|
|
assert_eq!(next_green["sides"], json!([0, 2]));
|
|
assert_eq!(next_green["carry"], carry, "the carry goes to everybody");
|
|
assert_eq!(next_red, next_green);
|
|
assert!(next_green.get("doc").is_none(), "the clients have the campaign already");
|
|
let room = net.room_json(&code).unwrap();
|
|
assert_eq!(room["title"].as_str(), Some("Second"));
|
|
assert_eq!(room["turn"].as_u64(), Some(0), "the turn clock starts again");
|
|
net.tick(&code);
|
|
let turn = until(&mut rx2, "turn");
|
|
assert_eq!(turn["turn"].as_u64(), Some(1));
|
|
assert_eq!(turn["cmds"], json!([]), "orders of the old mission are dropped");
|
|
}
|
|
|
|
#[test]
|
|
fn a_non_host_cannot_start_a_match() {
|
|
let mut net = Netplay::new();
|
|
let (tx, _rx) = mpsc::unbounded_channel();
|
|
let red = net.connect(tx, Some("red".into()), None);
|
|
let rooms = net.handle(red, json!({"t":"create","sides":[0,2]}));
|
|
let code = rooms[0].clone();
|
|
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
|
let green = net.connect(tx2, Some("green".into()), None);
|
|
net.handle(green, json!({"t":"join","code":&code}));
|
|
net.handle(green, json!({"t":"ready"}));
|
|
net.handle(green, json!({"t":"start"}));
|
|
let err = until(&mut rx2, "error");
|
|
assert_eq!(err["why"].as_str(), Some("only the red master can start"));
|
|
assert!(!net.rooms[&code].started);
|
|
}
|
|
|
|
#[test]
|
|
fn a_started_match_continues_with_an_ai_side_when_a_player_leaves() {
|
|
let mut net = Netplay::new();
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let red = net.connect(tx, Some("red".into()), None);
|
|
let rooms = net.handle(red, json!({"t":"create","sides":[0,2]}));
|
|
let code = rooms[0].clone();
|
|
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
|
let green = net.connect(tx2, Some("green".into()), None);
|
|
net.handle(green, json!({"t":"join","code":&code}));
|
|
net.handle(red, json!({"t":"ready"}));
|
|
net.handle(green, json!({"t":"ready"}));
|
|
net.handle(red, json!({"t":"start"}));
|
|
until(&mut rx, "start");
|
|
until(&mut rx2, "start");
|
|
|
|
net.disconnect(green);
|
|
let room = until(&mut rx, "room");
|
|
assert_eq!(room["ai"].as_array().unwrap()[0].as_u64(), Some(2));
|
|
net.tick(&code);
|
|
let turn = until(&mut rx, "turn");
|
|
assert_eq!(turn["ai"].as_array().unwrap()[0].as_u64(), Some(2));
|
|
}
|
|
|
|
#[test]
|
|
fn a_host_can_mark_an_empty_seat_as_ai() {
|
|
let mut net = Netplay::new();
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let red = net.connect(tx, Some("red".into()), None);
|
|
let rooms = net.handle(red, json!({"t":"create","sides":[0,2]}));
|
|
let code = rooms[0].clone();
|
|
net.handle(red, json!({"t":"set_ai","side":2,"ai":true}));
|
|
net.handle(red, json!({"t":"ready"}));
|
|
net.handle(red, json!({"t":"start"}));
|
|
|
|
let start = until(&mut rx, "start");
|
|
assert_eq!(start["sides"], json!([0, 2]));
|
|
assert_eq!(start["ai"], json!([2]));
|
|
|
|
net.tick(&code);
|
|
let turn = until(&mut rx, "turn");
|
|
assert_eq!(turn["ai"], json!([2]));
|
|
}
|
|
|
|
#[test]
|
|
fn a_coop_host_can_clear_an_ai_seat_and_start_alone() {
|
|
let mut net = Netplay::new();
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let red = net.connect(tx, Some("red".into()), None);
|
|
let _rooms = net.handle(red, json!({"t":"create","sides":[0,2],"mission":2}));
|
|
net.handle(red, json!({"t":"set_ai","side":2,"ai":true}));
|
|
net.handle(red, json!({"t":"set_ai","side":2,"ai":false}));
|
|
net.handle(red, json!({"t":"ready"}));
|
|
net.handle(red, json!({"t":"start"}));
|
|
|
|
let start = until(&mut rx, "start");
|
|
assert_eq!(start["sides"], json!([0]));
|
|
assert_eq!(start["ai"], json!([]));
|
|
}
|
|
|
|
#[test]
|
|
fn a_free_match_does_not_turn_empty_seats_into_ai_by_itself() {
|
|
let mut net = Netplay::new();
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let red = net.connect(tx, Some("red".into()), None);
|
|
let rooms = net.handle(red, json!({"t":"create","sides":[0,2]}));
|
|
let code = rooms[0].clone();
|
|
net.handle(red, json!({"t":"ready"}));
|
|
net.handle(red, json!({"t":"start"}));
|
|
|
|
let err = until(&mut rx, "error");
|
|
assert_eq!(err["why"].as_str(), Some("a free match needs at least two masters"));
|
|
assert!(!net.rooms[&code].started);
|
|
}
|
|
|
|
#[test]
|
|
fn the_relay_closes_a_started_match_after_the_last_player_leaves() {
|
|
let mut net = Netplay::new();
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let red = net.connect(tx, Some("red".into()), None);
|
|
let rooms = net.handle(red, json!({"t":"create","sides":[0,2]}));
|
|
let code = rooms[0].clone();
|
|
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
|
let green = net.connect(tx2, Some("green".into()), None);
|
|
net.handle(green, json!({"t":"join","code":&code}));
|
|
net.handle(red, json!({"t":"ready"}));
|
|
net.handle(green, json!({"t":"ready"}));
|
|
net.handle(red, json!({"t":"start"}));
|
|
until(&mut rx, "start");
|
|
until(&mut rx2, "start");
|
|
|
|
net.disconnect(green);
|
|
until(&mut rx, "room");
|
|
net.disconnect(red);
|
|
assert!(net.rooms.get(&code).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn team_chat_only_reaches_the_same_side() {
|
|
let mut net = Netplay::new();
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let red = net.connect(tx, Some("red".into()), None);
|
|
let rooms = net.handle(red, json!({"t":"create","sides":[0,2]}));
|
|
let code = rooms[0].clone();
|
|
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
|
let green = net.connect(tx2, Some("green".into()), None);
|
|
net.handle(green, json!({"t":"join","code":&code}));
|
|
|
|
net.handle(red, json!({"t":"chat","text":"hello","channel":"all"}));
|
|
let all_red = until(&mut rx, "chat");
|
|
assert_eq!(all_red["text"].as_str(), Some("hello"));
|
|
assert_eq!(all_red["side"].as_u64(), Some(0));
|
|
let all_green = until(&mut rx2, "chat");
|
|
assert_eq!(all_green["text"].as_str(), Some("hello"));
|
|
assert_eq!(all_green["side"].as_u64(), Some(0));
|
|
|
|
net.handle(red, json!({"t":"chat","text":"secret","channel":"team"}));
|
|
let team_red = until(&mut rx, "chat");
|
|
assert_eq!(team_red["text"].as_str(), Some("secret"));
|
|
assert_eq!(team_red["side"].as_u64(), Some(0));
|
|
assert!(rx2.try_recv().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn the_relay_answers_a_ping_with_the_same_correlation_number() {
|
|
let mut net = Netplay::new();
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let red = net.connect(tx, Some("red".into()), None);
|
|
net.handle(red, json!({"t":"ping","c":7}));
|
|
|
|
let pong = until(&mut rx, "pong");
|
|
assert_eq!(pong["c"].as_u64(), Some(7));
|
|
}
|
|
}
|