Files
dungeoncraft/src/lobby.rs
T

1024 lines
42 KiB
Rust

// Multiplayer step C (client): the browser-side lobby and turn bridge.
//
// The WebSocket itself is opened by the browser plugin (`dc_ws_*` in www/dc_plugin.js). This module
// only speaks the relay protocol from server/src/netplay.rs, turns `Command`s into JSON and feeds the
// game with confirmed turns. It can also run without a socket in tests: requests go into `outbox`,
// server answers are put back with `feed`.
#![allow(dead_code)]
use crate::commands::Command;
use serde_json::{json, Value};
use std::collections::VecDeque;
const MAX_CHAT: usize = 80;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Phase {
#[default]
Offline,
Lobby,
InLobby,
Playing,
Closed,
}
#[derive(Clone, Debug, Default)]
pub struct SeatUi {
pub side: u8,
pub name: String,
pub ready: bool,
pub connected: bool,
pub ai: bool,
pub team: u8, // 0..3 = A..D (versus)
}
#[derive(Clone, Debug, Default)]
pub struct RoomUi {
pub code: String,
pub players: usize,
pub sides: Vec<u8>,
pub started: bool,
pub paused: bool,
pub mission: Option<usize>,
pub campaign: Option<String>, // the editor campaign of a co-op room
pub title: Option<String>, // and the title of its current mission
}
#[derive(Clone, Debug, Default)]
pub struct MatchUi {
pub phase: Phase,
pub connected: bool,
pub name: String,
pub code: Option<String>,
pub local_side: Option<u8>,
pub ready: bool,
pub is_host: bool,
pub sides: Vec<u8>,
pub seats: Vec<SeatUi>,
pub ai: Vec<u8>,
pub coop: bool, // the open room plays a co-op mission (no teams to choose)
pub rooms: Vec<RoomUi>,
pub started: bool,
pub paused: bool,
pub missing: bool,
pub turn: u32,
pub error: Option<String>,
pub status: Option<String>,
pub chat: Vec<(Option<u8>, String, String)>,
}
impl MatchUi {
pub fn room_open(&self) -> bool {
self.code.is_some() || !self.seats.is_empty()
}
}
#[derive(Clone, Debug)]
pub struct StartInfo {
pub seed: u32,
pub map_w: usize,
pub map_h: usize,
pub difficulty: u8,
pub sides: Vec<u8>,
pub local_side: Option<u8>,
pub mission: Option<usize>,
pub ai: Vec<u8>,
// an editor campaign sent along by the host: `mission` is then one of its missions
pub doc: Option<crate::campaign_doc::CampaignDoc>,
pub editor: bool, // the start carried an editor campaign (even one that could not be read)
pub teams: Vec<(u8, u8)>, // (side, team) of a versus match
pub next: bool, // the next mission of a running editor campaign co-op match
pub carry: Vec<(u8, crate::campaign::Carry)>, // what every side brings into that mission
pub surface: bool, // a free match whose eastern half lies above ground
pub events: bool, // random events in a free match
}
#[derive(Clone, Debug, Default)]
struct SeatInfo {
side: u8,
name: String,
ready: bool,
connected: bool,
ai: bool,
team: u8,
}
#[derive(Clone, Debug, Default)]
struct RoomInfo {
code: String,
sides: Vec<u8>,
seats: Vec<SeatInfo>,
ai: Vec<u8>,
turn: u32,
started: bool,
paused: bool,
missing: bool,
mission: Option<usize>,
}
#[derive(Clone, Debug)]
pub struct MatchClient {
pub name: String,
pub connected: bool,
pub outbox: VecDeque<String>,
code: Option<String>,
room: Option<RoomInfo>,
rooms: Vec<RoomUi>,
local_side: Option<u8>,
error: Option<String>,
status: Option<String>,
chat: Vec<(Option<u8>, String, String)>,
chat_seen: usize,
start: Option<StartInfo>,
doc: Option<crate::campaign_doc::CampaignDoc>, // the editor campaign of the running match
turns: Vec<(u32, Vec<Command>, Vec<u8>)>,
checks: Vec<(Option<u8>, u32, u64)>,
pub ping_ms: f32,
ping_seq: u64,
ping_next: f64,
ping_pending: Option<(u64, f64)>,
}
impl MatchClient {
pub fn new() -> Self {
MatchClient {
name: "Guest".into(),
connected: false,
outbox: VecDeque::new(),
code: None,
room: None,
rooms: Vec::new(),
local_side: None,
error: None,
status: None,
chat: Vec::new(),
chat_seen: 0,
start: None,
doc: None,
turns: Vec::new(),
checks: Vec::new(),
ping_ms: 0.0,
ping_seq: 0,
ping_next: 0.0,
ping_pending: None,
}
}
pub fn phase(&self) -> Phase {
if !self.connected {
return Phase::Offline;
}
match &self.room {
Some(r) if r.started => Phase::Playing,
Some(_) => Phase::InLobby,
None if self.error.is_some() || self.status.as_deref().map(|s| s != "offline").unwrap_or(false) => Phase::Closed,
None => Phase::Lobby,
}
}
pub fn on_connect(&mut self, name: &str) {
self.connected = true;
self.name = name.to_string();
self.status = None;
self.ping_ms = 0.0;
self.ping_pending = None;
self.ping_next = 0.0;
self.rooms_request();
}
pub fn on_disconnect(&mut self) {
self.connected = false;
self.room = None;
self.code = None;
self.local_side = None;
self.start = None;
self.doc = None;
self.turns.clear();
self.status = Some("offline".into());
self.ping_ms = 0.0;
self.ping_pending = None;
self.ping_next = 0.0;
}
pub fn tick_ping(&mut self, now: f64) {
if let Some((_, start)) = self.ping_pending {
if now - start > 5.0 {
self.ping_pending = None;
self.ping_ms = 0.0;
}
}
if !self.connected || self.ping_pending.is_some() || now < self.ping_next {
return;
}
self.ping_seq = self.ping_seq.wrapping_add(1);
self.outbox.push_back(json!({"t":"ping","c":self.ping_seq}).to_string());
self.ping_pending = Some((self.ping_seq, now));
self.ping_next = now + 2.0;
}
pub fn rooms_request(&mut self) {
self.outbox.push_back(json!({"t":"rooms"}).to_string());
}
pub fn create(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8) {
self.send_create(sides, map_w, map_h, difficulty, None);
}
// a free match; `surface`: its eastern half lies above ground
pub fn create_land(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8, surface: bool, events: bool) {
let n = self.outbox.len();
self.send_create(sides, map_w, map_h, difficulty, None);
if let (true, Some(text)) = (surface || events, self.outbox.get_mut(n)) {
if let Ok(mut msg) = serde_json::from_str::<Value>(text) {
msg["surface"] = json!(surface);
msg["events"] = json!(events);
*text = msg.to_string();
}
}
}
pub fn create_coop(&mut self, mission: usize, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8) {
self.send_create(sides, map_w, map_h, difficulty, Some(mission));
}
// a co-op match of an editor campaign: the whole campaign file goes to the relay, which hands
// it to every client with the start message
pub fn create_coop_doc(&mut self, doc: &crate::campaign_doc::CampaignDoc, mission: usize, sides: &[u8], difficulty: u8) {
self.create_coop_doc_resumed(doc, mission, sides, difficulty, None);
}
// the same, going on with an interrupted run: its seed and what every master carries along
pub fn create_coop_doc_resumed(&mut self, doc: &crate::campaign_doc::CampaignDoc, mission: usize, sides: &[u8], difficulty: u8, resume: Option<(u32, &[(u8, crate::campaign::Carry)])>) {
let Some(m) = doc.missions.get(mission) else { return };
let (map_w, map_h) = m.size;
let doc = serde_json::to_value(doc).unwrap_or(Value::Null);
let n = self.outbox.len();
self.send_create_with(sides, map_w, map_h, difficulty, Some(mission), Some(doc));
if let (Some((seed, carry)), Some(text)) = (resume, self.outbox.get_mut(n)) {
if let Ok(mut msg) = serde_json::from_str::<Value>(text) {
msg["seed"] = json!(seed);
msg["carry"] = serde_json::to_value(carry).unwrap_or(Value::Array(Vec::new()));
*text = msg.to_string();
}
}
}
fn send_create(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8, mission: Option<usize>) {
self.send_create_with(sides, map_w, map_h, difficulty, mission, None);
}
fn send_create_with(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8, mission: Option<usize>, doc: Option<Value>) {
if !self.connected {
return;
}
self.error = None;
self.status = None;
self.chat.clear();
self.chat_seen = 0;
let mut msg = json!({"t":"create","sides":sides,"map_w":map_w,"map_h":map_h,"difficulty":difficulty,"mission":mission});
if let Some(doc) = doc {
msg["doc"] = doc;
}
self.outbox.push_back(msg.to_string());
}
pub fn join(&mut self, code: &str) {
if !self.connected {
return;
}
let code = code.trim().to_uppercase();
if code.len() != 6 {
self.error = Some("match code needs 6 characters".into());
return;
}
self.error = None;
self.status = None;
self.chat.clear();
self.chat_seen = 0;
self.outbox.push_back(json!({"t":"join","code":code}).to_string());
}
pub fn take_side(&mut self, side: u8) {
if !self.connected {
return;
}
self.outbox.push_back(json!({"t":"take_side","side":side}).to_string());
}
pub fn set_ai(&mut self, side: u8, ai: bool) {
if !self.connected {
return;
}
self.outbox.push_back(json!({"t":"set_ai","side":side,"ai":ai}).to_string());
}
// the team (0..3 = A..D) of the own seat, or - for the host - of a free or AI seat
pub fn set_team(&mut self, side: Option<u8>, team: u8) {
if !self.connected {
return;
}
let mut msg = json!({"t":"set_team","team":team});
if let Some(side) = side {
msg["side"] = json!(side);
}
self.outbox.push_back(msg.to_string());
}
// the host starts the next mission of the editor campaign for everybody: the run's seed and
// what every side carries along travel with it
pub fn next_mission(&mut self, mission: usize, seed: u32, carry: &[(u8, crate::campaign::Carry)]) {
if !self.connected {
return;
}
let carry = serde_json::to_value(carry).unwrap_or(Value::Array(Vec::new()));
self.outbox.push_back(json!({"t":"next","mission":mission,"seed":seed,"carry":carry}).to_string());
}
pub fn ready(&mut self, ready: bool) {
if !self.connected {
return;
}
self.outbox.push_back(json!({"t":"ready","ready":ready}).to_string());
}
pub fn start(&mut self) {
if !self.connected {
return;
}
self.outbox.push_back(json!({"t":"start"}).to_string());
}
pub fn resume(&mut self) {
if !self.connected {
return;
}
self.outbox.push_back(json!({"t":"resume"}).to_string());
}
pub fn abort(&mut self) {
if !self.connected {
return;
}
self.outbox.push_back(json!({"t":"abort"}).to_string());
}
pub fn leave(&mut self) {
if !self.connected {
return;
}
self.outbox.push_back(json!({"t":"leave"}).to_string());
}
pub fn chat(&mut self, text: &str, team: bool) {
let text: String = text.trim().chars().take(MAX_CHAT).collect();
if !self.connected || text.is_empty() {
return;
}
let msg = if team { json!({"t":"chat","text":text,"channel":"team"}) } else { json!({"t":"chat","text":text,"channel":"all"}) };
self.outbox.push_back(msg.to_string());
}
pub fn send_commands(&mut self, cmds: &[Command]) {
if !self.connected || cmds.is_empty() {
return;
}
self.outbox.push_back(json!({"t":"cmds","cmds":cmds}).to_string());
}
pub fn send_checksum(&mut self, turn: u32, sum: u64) {
if !self.connected {
return;
}
self.outbox.push_back(json!({"t":"sum","turn":turn,"sum":sum}).to_string());
}
pub fn feed(&mut self, text: &str) {
self.feed_at(text, 0.0);
}
pub fn feed_at(&mut self, text: &str, now: f64) {
let v: Value = match serde_json::from_str(text) {
Ok(v) => v,
Err(_) => return,
};
match v.get("t").and_then(|x| x.as_str()).unwrap_or("") {
"hello" => {
if let Some(n) = v.get("name").and_then(|x| x.as_str()) {
self.name = n.to_string();
}
}
"pong" => {
if let Some(c) = v.get("c").and_then(|x| x.as_u64()) {
if let Some((seq, start)) = self.ping_pending {
if seq == c {
self.ping_ms = ((now - start).max(0.0) * 1000.0) as f32;
self.ping_pending = None;
}
}
}
}
"rooms" => self.parse_rooms(&v),
"room" => self.parse_room(&v),
"start" => self.parse_start(&v),
"next" => self.parse_next(&v),
"turn" => self.parse_turn(&v),
"sum" => self.parse_sum(&v),
"chat" => {
let name = v.get("name").and_then(|x| x.as_str()).unwrap_or("?").to_string();
let text = v.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string();
let side = v.get("side").and_then(|x| x.as_u64()).map(|x| x as u8);
self.chat.push((side, name, text));
while self.chat.len() > 40 {
self.chat.remove(0);
if self.chat_seen > 0 {
self.chat_seen -= 1;
}
}
}
"closed" => {
self.status = Some(v.get("why").and_then(|x| x.as_str()).unwrap_or("closed").to_string());
self.room = None;
self.code = None;
self.local_side = None;
self.start = None;
self.doc = None;
self.turns.clear();
self.chat.clear();
self.chat_seen = 0;
}
"error" => {
self.error = Some(v.get("why").and_then(|x| x.as_str()).unwrap_or("error").to_string());
}
_ => {}
}
}
pub fn take_start(&mut self) -> Option<StartInfo> {
self.start.take()
}
pub fn take_turns(&mut self) -> Vec<(u32, Vec<Command>, Vec<u8>)> {
std::mem::take(&mut self.turns)
}
pub fn take_checks(&mut self) -> Vec<(Option<u8>, u32, u64)> {
std::mem::take(&mut self.checks)
}
pub fn take_new_chat(&mut self) -> Vec<(Option<u8>, String, String)> {
if self.chat_seen >= self.chat.len() {
return Vec::new();
}
let out = self.chat[self.chat_seen..].to_vec();
self.chat_seen = self.chat.len();
out
}
fn parse_rooms(&mut self, v: &Value) {
let Some(arr) = v.get("rooms").and_then(|x| x.as_array()) else { return };
self.rooms = arr
.iter()
.map(|r| RoomUi {
code: r.get("code").and_then(|x| x.as_str()).unwrap_or("").to_string(),
players: r.get("players").and_then(|x| x.as_u64()).unwrap_or(0) as usize,
sides: array_bytes(r.get("sides")),
started: r.get("started").and_then(|x| x.as_bool()).unwrap_or(false),
paused: r.get("paused").and_then(|x| x.as_bool()).unwrap_or(false),
mission: r.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize),
campaign: r.get("campaign").and_then(|x| x.as_str()).map(|x| x.to_string()),
title: r.get("title").and_then(|x| x.as_str()).map(|x| x.to_string()),
})
.collect();
}
fn parse_room(&mut self, v: &Value) {
let mut room = self.room.take().unwrap_or_default();
if let Some(code) = v.get("code").and_then(|x| x.as_str()) {
room.code = code.to_string();
self.code = Some(code.to_string());
}
let sides = array_bytes(v.get("sides"));
if !sides.is_empty() {
room.sides = sides;
}
let seats = seats_from(v.get("seats"));
if !seats.is_empty() {
room.seats = seats;
}
if let Some(x) = v.get("turn").and_then(|x| x.as_u64()) {
room.turn = x as u32;
}
room.ai = array_bytes(v.get("ai"));
if let Some(x) = v.get("started").and_then(|x| x.as_bool()) {
room.started = x;
}
if let Some(x) = v.get("paused").and_then(|x| x.as_bool()) {
room.paused = x;
}
if let Some(x) = v.get("missing").and_then(|x| x.as_bool()) {
room.missing = x;
}
if v.get("mission").is_some() {
room.mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize);
}
if let Some(why) = v.get("why").and_then(|x| x.as_str()) {
self.status = Some(why.to_string());
}
self.local_side = room.seats.iter().find(|s| s.name == self.name && s.connected).map(|s| s.side);
if !room.code.is_empty() || !room.seats.is_empty() {
self.room = Some(room);
} else {
self.room = None;
}
}
fn parse_start(&mut self, v: &Value) {
let seed = v.get("seed").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
let map_w = v.get("map_w").and_then(|x| x.as_u64()).unwrap_or(96) as usize;
let map_h = v.get("map_h").and_then(|x| x.as_u64()).unwrap_or(128) as usize;
let difficulty = v.get("difficulty").and_then(|x| x.as_u64()).unwrap_or(1) as u8;
let sides = array_bytes(v.get("sides"));
let mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize);
let editor = v.get("doc").map_or(false, |d| d.is_object());
let doc = v.get("doc").filter(|d| d.is_object()).and_then(|d| <crate::campaign_doc::CampaignDoc as serde::Deserialize>::deserialize(d).ok());
self.doc = doc.clone();
let teams = teams_from(&sides, v.get("teams"));
let mut ai = array_bytes(v.get("ai"));
if ai.is_empty() {
if let Some(r) = self.room.as_ref() {
ai = r.ai.clone();
}
}
let local_side = self.room.as_ref().and_then(|r| r.seats.iter().find(|s| s.name == self.name && s.connected).map(|s| s.side));
self.local_side = local_side;
if let Some(r) = self.room.as_mut() {
r.started = true;
r.paused = false;
r.missing = false;
r.turn = 0;
if !sides.is_empty() {
r.sides = sides.clone();
}
r.ai = ai.clone();
}
// a resumed editor campaign run goes on like the next mission of a running one
let resume = editor && v.get("resume").and_then(|x| x.as_bool()).unwrap_or(false);
let carry: Vec<(u8, crate::campaign::Carry)> = if resume { v.get("carry").cloned().and_then(|c| serde_json::from_value(c).ok()).unwrap_or_default() } else { Vec::new() };
let surface = v.get("surface").and_then(|x| x.as_bool()).unwrap_or(false);
let events = v.get("events").and_then(|x| x.as_bool()).unwrap_or(false);
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai, doc, editor, teams, next: resume, carry, surface, events });
self.error = None;
self.status = None;
}
// the next mission of the running editor campaign: built from the campaign of the start
fn parse_next(&mut self, v: &Value) {
let Some(doc) = self.doc.clone() else { return };
let Some(mission) = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize) else { return };
let Some(m) = doc.missions.get(mission) else { return };
let (map_w, map_h) = m.size;
let seed = v.get("seed").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
let difficulty = v.get("difficulty").and_then(|x| x.as_u64()).unwrap_or(1) as u8;
let sides = array_bytes(v.get("sides"));
let ai = array_bytes(v.get("ai"));
let teams = teams_from(&sides, v.get("teams"));
let carry: Vec<(u8, crate::campaign::Carry)> = v.get("carry").cloned().and_then(|c| serde_json::from_value(c).ok()).unwrap_or_default();
// turns that came before belong to the mission that ended
self.turns.clear();
let local_side = self.room.as_ref().and_then(|r| r.seats.iter().find(|s| s.name == self.name && s.connected).map(|s| s.side)).or(self.local_side);
self.local_side = local_side;
if let Some(r) = self.room.as_mut() {
r.turn = 0;
r.paused = false;
r.missing = false;
r.mission = Some(mission);
if !sides.is_empty() {
r.sides = sides.clone();
}
r.ai = ai.clone();
}
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission: Some(mission), ai, doc: Some(doc), editor: true, teams, next: true, carry, surface: false, events: false });
self.error = None;
}
fn parse_turn(&mut self, v: &Value) {
let turn = v.get("turn").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
if let Some(r) = self.room.as_mut() {
r.turn = turn;
r.ai = array_bytes(v.get("ai"));
}
let cmds = match v.get("cmds").cloned() {
Some(v) => serde_json::from_value::<Vec<Command>>(v).unwrap_or_default(),
None => Vec::new(),
};
let ai = array_bytes(v.get("ai"));
self.turns.push((turn, cmds, ai));
}
fn parse_sum(&mut self, v: &Value) {
let turn = v.get("turn").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
let sum = match v.get("sum").and_then(|x| x.as_u64()) {
Some(s) => s,
None => return,
};
let from = v.get("from").and_then(|x| x.as_u64()).map(|n| n as u8);
self.checks.push((from, turn, sum));
}
pub fn ui(&self) -> MatchUi {
let room = self.room.clone().unwrap_or_default();
let local = self.local_side;
let ready = room.seats.iter().find(|s| Some(s.side) == local).map(|s| s.ready).unwrap_or(false);
let host = room.seats.iter().find(|s| s.side == 0);
let is_host = local == Some(0) && host.map(|s| s.connected).unwrap_or(false);
MatchUi {
phase: self.phase(),
connected: self.connected,
name: self.name.clone(),
code: room.code.is_empty().then(|| self.code.clone()).unwrap_or(Some(room.code.clone())).clone(),
local_side: local,
ready,
is_host,
sides: room.sides.clone(),
ai: room.ai.clone(),
coop: room.mission.is_some(),
seats: room.seats.iter().map(|s| SeatUi { side: s.side, name: s.name.clone(), ready: s.ready, connected: s.connected, ai: s.ai, team: s.team }).collect(),
rooms: self.rooms.clone(),
started: room.started,
paused: room.paused,
missing: room.missing,
turn: room.turn,
error: self.error.clone(),
status: self.status.clone(),
chat: self.chat.clone(),
}
}
}
// every seat starts as a team of its own (the relay does the same)
fn default_team(side: u8) -> u8 {
crate::map::Owner::MASTER_SIDES.iter().position(|s| *s == side).unwrap_or(0) as u8
}
fn array_bytes(v: Option<&Value>) -> Vec<u8> {
v.and_then(|x| x.as_array())
.map(|a| a.iter().filter_map(|x| x.as_u64().map(|n| n as u8)).collect())
.unwrap_or_default()
}
// (side, team) pairs from the teams list that goes along with `sides`
fn teams_from(sides: &[u8], v: Option<&Value>) -> Vec<(u8, u8)> {
let teams = array_bytes(v);
sides.iter().copied().zip(teams).collect()
}
// versus map sizes and how many master seats each one has
pub const MATCH_SIZES: [usize; 7] = [80, 96, 128, 160, 192, 224, 256];
pub fn seats_for(size: usize) -> &'static [u8] {
match size {
0..=80 => &[0, 2],
81..=96 => &[0, 2, 3],
_ => &[0, 2, 3, 4],
}
}
fn seats_from(v: Option<&Value>) -> Vec<SeatInfo> {
v.and_then(|x| x.as_array())
.map(|arr| {
arr.iter()
.map(|s| SeatInfo {
side: s.get("side").and_then(|x| x.as_u64()).unwrap_or(0) as u8,
name: s.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string(),
ready: s.get("ready").and_then(|x| x.as_bool()).unwrap_or(false),
connected: s.get("connected").and_then(|x| x.as_bool()).unwrap_or(false),
ai: s.get("ai").and_then(|x| x.as_bool()).unwrap_or(false),
team: s.get("team").and_then(|x| x.as_u64()).unwrap_or_else(|| default_team(s.get("side").and_then(|x| x.as_u64()).unwrap_or(0) as u8) as u64) as u8,
})
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::{Act, Rect};
fn pop(client: &mut MatchClient) -> Value {
let s = client.outbox.pop_front().expect("no request");
serde_json::from_str(&s).expect("bad json")
}
#[test]
fn the_bridge_turns_orders_into_json_and_turns_back_into_commands() {
let mut client = MatchClient::new();
client.on_connect("red");
let req = pop(&mut client); // rooms
assert_eq!(req["t"].as_str(), Some("rooms"));
client.create(&[0, 2], 96, 128, 1);
let req = pop(&mut client);
assert_eq!(req["t"].as_str(), Some("create"));
client.feed(r#"{"t":"room","code":"ABC123","sides":[0,2],"seats":[{"side":0,"name":"red","ready":false,"connected":true}],"turn":0,"started":false,"paused":false,"missing":false}"#);
client.feed(r#"{"t":"start","seed":7,"map_w":96,"map_h":128,"difficulty":1,"sides":[0,2]}"#);
let start = client.take_start().expect("start info");
assert_eq!(start.seed, 7);
assert_eq!(start.local_side, Some(0));
assert_eq!(start.mission, None);
client.create_coop(2, &[0, 2], 80, 72, 1);
let coop = pop(&mut client);
assert_eq!(coop["t"].as_str(), Some("create"));
assert_eq!(coop["mission"].as_u64(), Some(2));
client.feed(r#"{"t":"start","seed":8,"map_w":80,"map_h":72,"difficulty":1,"sides":[0,2],"mission":2}"#);
let coop_start = client.take_start().expect("co-op start info");
assert_eq!(coop_start.mission, Some(2));
client.send_commands(&[Command { side: 0, seq: 0, act: Act::Dig(Rect::new(1, 2, 3, 4)) }]);
let req = pop(&mut client);
assert_eq!(req["t"].as_str(), Some("cmds"));
assert_eq!(req["cmds"][0]["side"].as_u64(), Some(0));
client.feed(r#"{"t":"turn","turn":1,"cmds":[{"side":0,"seq":0,"act":{"Dig":{"x0":1,"y0":2,"x1":3,"y1":4}}}]}"#);
let turns = client.take_turns();
assert_eq!(turns.len(), 1);
assert_eq!(turns[0].1[0].side, 0);
assert!(matches!(turns[0].1[0].act, Act::Dig(_)));
client.feed(r#"{"t":"sum","from":2,"turn":1,"sum":42}"#);
let checks = client.take_checks();
assert_eq!(checks[0], (Some(2), 1, 42));
client.chat("hello", true);
let req = pop(&mut client);
assert_eq!(req["t"].as_str(), Some("chat"));
assert_eq!(req["channel"].as_str(), Some("team"));
client.feed(r#"{"t":"chat","name":"red","text":"hello","side":0,"channel":"team"}"#);
let chat = client.take_new_chat();
assert_eq!(chat[0], (Some(0), "red".to_string(), "hello".to_string()));
}
#[test]
fn the_bridge_times_the_server_ping() {
let mut client = MatchClient::new();
client.on_connect("red");
pop(&mut client); // rooms
client.tick_ping(10.0);
let req = pop(&mut client);
assert_eq!(req["t"].as_str(), Some("ping"));
assert_eq!(req["c"].as_u64(), Some(1));
client.feed_at(r#"{"t":"pong","c":1}"#, 10.25);
assert!(client.ping_ms > 200.0 && client.ping_ms < 300.0, "the pong came 250 ms after the ping");
client.tick_ping(11.0);
assert!(client.outbox.is_empty(), "the next ping waits for its two-second interval");
client.tick_ping(12.0);
let next = pop(&mut client);
assert_eq!(next["c"].as_u64(), Some(2));
}
#[test]
fn the_bridge_holds_ai_seats_through_the_lobby() {
let mut client = MatchClient::new();
client.on_connect("red");
pop(&mut client); // rooms
client.create(&[0, 2, 3, 4], 96, 128, 1);
pop(&mut client); // create
client.set_ai(3, true);
let req = pop(&mut client);
assert_eq!(req["t"].as_str(), Some("set_ai"));
assert_eq!(req["side"].as_u64(), Some(3));
assert_eq!(req["ai"].as_bool(), Some(true));
client.feed(r#"{"t":"room","code":"ABC123","sides":[0,2,3,4],"seats":[{"side":0,"name":"red","ready":false,"connected":true,"ai":false},{"side":2,"name":"","ready":false,"connected":false,"ai":false},{"side":3,"name":"","ready":false,"connected":false,"ai":true},{"side":4,"name":"","ready":false,"connected":false,"ai":false}],"ai":[3],"turn":0,"started":false,"paused":false,"missing":false}"#);
let ui = client.ui();
assert_eq!(ui.ai, vec![3u8]);
assert!(ui.seats.iter().any(|s| s.side == 3 && s.ai));
assert!(ui.seats.iter().any(|s| s.side == 2 && !s.connected && !s.ai));
client.feed(r#"{"t":"start","seed":7,"map_w":96,"map_h":128,"difficulty":1,"sides":[0,3],"ai":[3]}"#);
let start = client.take_start().expect("start info with ai seats");
assert_eq!(start.sides, vec![0u8, 3u8]);
assert_eq!(start.ai, vec![3u8]);
}
// a small editor campaign: a random map with a rival master and block scripts, and a painted
// map that still holds a rival master
fn coop_doc() -> crate::campaign_doc::CampaignDoc {
use crate::blocks::{Block, Val};
use crate::campaign_doc::*;
let mut doc = CampaignDoc { name: "Coop test".into(), ..CampaignDoc::default() };
let mut m = MissionDoc { key: "c1".into(), title: "Random".into(), size: (80, 80), seed: 5, goals: vec!["KillAll".into(), "Rivals".into()], waves: true, first_wave: 3.0, gen: GenDoc { rivers: 1, lava: 1, forts: 1, rivals: 1, difficulty: 1, surface: false }, ..MissionDoc::default() };
m.scripts.start = vec![Block::Gold { amount: Val::num(250.0) }];
m.scripts.triggers = vec![TriggerDoc { name: "wave".into(), kind: TriggerKind::Timer { secs: 2.0, every: true }, once: false, enabled: true, script: vec![Block::Wave { count: Val::num(1.0) }] }];
doc.missions.push(m.clone());
let mut painted = crate::campaign::start_editor_mission(&MissionDoc { size: (96, 96), seed: 9, ..m.clone() }, 1, 3);
assert!(!painted.rivals.is_empty(), "the painted map has a rival master");
painted.state = 0;
doc.missions.push(MissionDoc { key: "c2".into(), title: "Painted".into(), size: (96, 96), map: MapDoc::Fixed { game: serde_json::to_string(&painted).unwrap() }, ..m });
doc
}
// what the game does with a co-op start of an editor campaign (main.rs start_coop_game)
fn coop_game(info: &StartInfo) -> crate::game::Game {
let doc = info.doc.as_ref().expect("the start carries the campaign");
let i = info.mission.expect("a mission");
assert!(crate::campaign::is_editor_coop_mission(&doc.missions[i]));
let mut g = crate::campaign::start_editor_coop_mission(&doc.missions[i], i, info.seed);
g.coop = true;
assert!(g.start_match(&info.sides, info.local_side.unwrap()), "the masters fit the map");
g.sync_ai_sides(&info.ai, info.difficulty);
crate::campaign::apply_coop_carry(&mut g, &info.carry);
g
}
#[test]
fn two_clients_play_two_editor_missions_in_a_row_with_carry() {
use crate::game::EK;
let doc = coop_doc();
let mut clients = [MatchClient::new(), MatchClient::new()];
let room = r#"{"t":"room","code":"ABC123","sides":[0,2],"seats":[{"side":0,"name":"red","ready":true,"connected":true},{"side":2,"name":"green","ready":true,"connected":true}],"ai":[],"turn":0,"started":false,"mission":0}"#;
let start = json!({"t":"start","seed":99,"map_w":80,"map_h":80,"difficulty":1,"sides":[0,2],"teams":[0,1],"mission":0,"ai":[],"doc":serde_json::to_value(&doc).unwrap()}).to_string();
let mut games = Vec::new();
for (c, name) in clients.iter_mut().zip(["red", "green"]) {
c.on_connect(name);
c.outbox.clear();
c.feed(room);
c.feed(&start);
let info = c.take_start().expect("start info");
assert!(!info.next && info.carry.is_empty());
games.push(coop_game(&info));
}
for t in 0..60u32 {
for g in games.iter_mut() {
g.receive_turn(&[]);
}
if t % 20 == 19 {
assert_eq!(games[0].checksum(), games[1].checksum(), "mission 1, turn {t}");
}
}
// green raised a strong creature, red has a weak one; then the goal is reached on both
for g in games.iter_mut() {
let (cx, cy) = g.core_of_side(2);
let local = g.local_side;
g.switch_to(2);
let ogre = g.spawn(EK::Ogre, false, cx as f32 + 4.5, cy as f32 + 1.5, 6);
g.switch_to(0);
let (rx, ry) = g.core_of_side(0);
g.spawn(EK::Kobold, false, rx as f32 + 4.5, ry as f32 + 1.5, 1);
g.switch_to(local);
assert!(g.ents.iter().any(|e| e.id == ogre && e.side == 2));
g.state = 1;
}
let (next, _) = doc.next_mission_choice(0, |goal| games[0].match_goal_done(goal));
assert_eq!(next, Some(1), "the run goes on with the next mission");
let carry = crate::campaign::coop_carry_from(&games[0]);
assert_eq!(serde_json::to_string(&carry).unwrap(), serde_json::to_string(&crate::campaign::coop_carry_from(&games[1])).unwrap(), "every client sees the same carry");
let green_carry = &carry.iter().find(|c| c.0 == 2).unwrap().1;
assert!(green_carry.creatures.iter().any(|c| c.0 == EK::Ogre && c.1 == 6), "green takes his ogre along");
assert_eq!(green_carry.gold, (games[0].gold_of(2) * crate::campaign::CARRY_GOLD).floor());
// the host asks the relay for the next mission; the relay hands it to everybody
clients[0].next_mission(1, 99, &carry);
let req = pop(&mut clients[0]);
assert_eq!(req["t"].as_str(), Some("next"));
assert_eq!(req["mission"].as_u64(), Some(1));
let next = json!({"t":"next","seed":req["seed"],"difficulty":1,"sides":[0,2],"teams":[0,1],"mission":1,"ai":[],"carry":req["carry"]}).to_string();
let mut games2 = Vec::new();
for c in clients.iter_mut() {
c.feed(r#"{"t":"turn","turn":61,"cmds":[]}"#); // a late turn of the old mission
c.feed(&next);
let info = c.take_start().expect("the next mission starts");
assert!(info.next && info.editor && info.doc.is_some());
assert_eq!(info.mission, Some(1));
assert_eq!(info.carry.len(), 2);
assert!(c.take_turns().is_empty(), "turns of the old mission are dropped");
games2.push(coop_game(&info));
}
let (a, b) = (&games2[0], &games2[1]);
assert_eq!(a.checksum(), b.checksum(), "mission 2: the same start with the carry");
assert!(a.ents.iter().any(|e| e.alive && e.side == 2 && e.kind == EK::Ogre && e.level == 6), "the ogre came along with green");
let mut games2 = games2;
let (cx, cy) = games2[0].core_of_side(0);
for t in 0..80u32 {
let cmds = if t % 10 == 0 { vec![Command { side: 0, seq: t, act: Act::Dig(Rect::new(cx + 5, cy + (t / 10) as i32 - 3, cx + 6, cy + (t / 10) as i32 - 2)) }] } else { Vec::new() };
for g in games2.iter_mut() {
g.receive_turn(&cmds);
}
if t % 10 == 9 {
assert_eq!(games2[0].checksum(), games2[1].checksum(), "mission 2: identical up to turn {t}");
}
}
}
#[test]
fn the_bridge_sends_teams_and_reads_them_back() {
let mut client = MatchClient::new();
client.on_connect("red");
pop(&mut client); // rooms
client.set_team(None, 1);
let req = pop(&mut client);
assert_eq!((req["t"].as_str(), req["team"].as_u64(), req.get("side")), (Some("set_team"), Some(1), None));
client.set_team(Some(3), 2);
assert_eq!(pop(&mut client)["side"].as_u64(), Some(3));
client.feed(r#"{"t":"room","code":"ABC123","sides":[0,2],"seats":[{"side":0,"name":"red","connected":true,"team":1},{"side":2,"name":"","connected":false,"team":1}],"mission":null}"#);
let ui = client.ui();
assert!(!ui.coop);
assert_eq!(ui.seats.iter().map(|s| s.team).collect::<Vec<_>>(), vec![1, 1]);
client.feed(r#"{"t":"rooms","rooms":[{"code":"ABC123","players":1,"sides":[0,2],"mission":0,"editor":true,"campaign":"Deep Run","title":"First"}]}"#);
let ui = client.ui();
assert_eq!(ui.rooms[0].campaign.as_deref(), Some("Deep Run"));
assert_eq!(ui.rooms[0].title.as_deref(), Some("First"));
client.feed(r#"{"t":"start","seed":7,"sides":[0,2],"teams":[1,1]}"#);
let start = client.take_start().unwrap();
assert_eq!(start.teams, vec![(0, 1), (2, 1)]);
}
#[test]
fn an_interrupted_coop_run_goes_on_with_its_seed_and_carry() {
let doc = coop_doc();
let mut host = MatchClient::new();
host.on_connect("red");
pop(&mut host); // rooms
let carry = vec![(0u8, crate::campaign::Carry { gold: 250.0, ..Default::default() })];
host.create_coop_doc_resumed(&doc, 1, &[0, 2, 3, 4], 1, Some((4242, &carry)));
let create = pop(&mut host);
assert_eq!(create["seed"].as_u64(), Some(4242));
assert_eq!(create["carry"][0][0].as_u64(), Some(0));
assert_eq!(create["mission"].as_u64(), Some(1));
let start = json!({"t":"start","seed":4242,"map_w":96,"map_h":96,"difficulty":1,"sides":[0,2],"mission":1,"ai":[],"doc":serde_json::to_value(&doc).unwrap(),"carry":create["carry"].clone(),"resume":true}).to_string();
host.feed(&start);
let info = host.take_start().unwrap();
assert!(info.next, "a resumed run keeps its progress");
assert_eq!(info.seed, 4242);
assert_eq!(info.carry.len(), 1);
assert_eq!(info.carry[0].1.gold, 250.0);
// old stored progress without the run position still reads
let p: crate::campaign_doc::CampaignProgress = serde_json::from_str(r#"{"done":[true],"completed_choice":[null],"run_seed":5}"#).unwrap();
assert_eq!((p.next, p.carry.len()), (None, 0));
}
#[test]
fn two_clients_play_the_same_editor_campaign_mission_in_coop() {
let doc = coop_doc();
let mut host = MatchClient::new();
host.on_connect("red");
pop(&mut host); // rooms
host.create_coop_doc(&doc, 1, &[0, 2, 3, 4], 1);
let create = pop(&mut host);
assert_eq!(create["mission"].as_u64(), Some(1));
assert_eq!(create["map_w"].as_u64(), Some(96));
assert_eq!(create["doc"]["missions"].as_array().map(|a| a.len()), Some(2), "the whole campaign goes along");
for mission in [0usize, 1] {
let mut clients = [MatchClient::new(), MatchClient::new()];
let room = r#"{"t":"room","code":"ABC123","sides":[0,2,3,4],"seats":[{"side":0,"name":"red","ready":true,"connected":true},{"side":2,"name":"green","ready":true,"connected":true},{"side":3,"name":"","connected":false,"ai":true}],"ai":[3],"turn":0,"started":false}"#;
let start = json!({"t":"start","seed":4711,"map_w":80,"map_h":80,"difficulty":1,"sides":[0,2,3],"mission":mission,"ai":[3],"doc":serde_json::to_value(&doc).unwrap()}).to_string();
let mut games = Vec::new();
for (c, name) in clients.iter_mut().zip(["red", "green"]) {
c.on_connect(name);
c.feed(room);
c.feed(&start);
let info = c.take_start().expect("start info");
assert!(info.editor);
games.push(coop_game(&info));
}
let (a, b) = games.split_at_mut(1);
let (a, b) = (&mut a[0], &mut b[0]);
assert_eq!((a.local_side, b.local_side), (0, 2));
assert!(a.rivals.iter().all(|r| r.side == 3), "only the AI seat plays as a rival in co-op");
assert!(!a.mission.as_ref().unwrap().goals.contains(&crate::game::Goal::Rivals));
assert!(a.script.is_some());
assert_eq!(a.checksum(), b.checksum(), "mission {mission}: the same start");
let (cx, cy) = a.map.core;
for t in 0..120u32 {
let cmds = if t % 10 == 0 { vec![Command { side: 0, seq: t, act: Act::Dig(Rect::new(cx + 5, cy + (t / 10) as i32 - 3, cx + 6, cy + (t / 10) as i32 - 2)) }] } else { Vec::new() };
a.receive_turn(&cmds);
b.receive_turn(&cmds);
if t % 10 == 9 {
assert_eq!(a.checksum(), b.checksum(), "mission {mission}: the worlds stayed identical up to turn {t}");
}
}
assert!(a.sim_steps > 0 && a.sim_steps == b.sim_steps);
assert!(a.script.as_ref().unwrap().error.is_none(), "{:?}", a.script.as_ref().unwrap().error);
}
}
#[test]
fn a_broken_editor_campaign_in_the_start_is_noticed() {
let mut client = MatchClient::new();
client.on_connect("red");
client.feed(r#"{"t":"start","seed":1,"sides":[0],"mission":0,"doc":{"missions":"nope"}}"#);
let info = client.take_start().expect("start info");
assert!(info.editor && info.doc.is_none(), "the game refuses the start instead of playing a built-in mission");
}
}