832 lines
33 KiB
Rust
832 lines
33 KiB
Rust
// Multiplayer, client side of deterministic lockstep: the local orders go to the server, the server
|
|
// confirms a turn with the orders of all masters, and the world advances one turn at a time (five
|
|
// steps of 20 ms). A client that receives nothing stands still. Every client computes the same
|
|
// world from the same turns, so comparing a checksum of that world shows a divergence (desync).
|
|
#![allow(dead_code)] // the server relay and the Multiplayer menu (steps C and D) call all of this
|
|
|
|
use crate::commands::Command;
|
|
use crate::game::Game;
|
|
use crate::map::{Kind, Owner};
|
|
|
|
// one server turn of 100 ms: five fixed simulation steps
|
|
pub const STEPS_PER_TURN: usize = 5;
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Netplay {
|
|
pub turn: u32, // turns applied so far
|
|
pub desync: bool, // a peer reported a different world
|
|
pub last_sum: u32, // last turn whose checksum was sent
|
|
}
|
|
|
|
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
|
|
const FNV_PRIME: u64 = 0x100_0000_01b3;
|
|
|
|
fn mix(h: &mut u64, v: u64) {
|
|
*h ^= v;
|
|
*h = h.wrapping_mul(FNV_PRIME);
|
|
}
|
|
|
|
fn mixf(h: &mut u64, v: f32) {
|
|
mix(h, v.to_bits() as u64);
|
|
}
|
|
|
|
fn mixp(h: &mut u64, p: Option<(i32, i32)>) {
|
|
match p {
|
|
Some((x, y)) => {
|
|
mix(h, x as u32 as u64);
|
|
mix(h, y as u32 as u64);
|
|
}
|
|
None => mix(h, u64::MAX),
|
|
}
|
|
}
|
|
|
|
// a value whose type is not a plain number (Job carries which tile it works on)
|
|
fn mixdbg(h: &mut u64, v: impl std::fmt::Debug) {
|
|
for b in format!("{v:?}").bytes() {
|
|
mix(h, b as u64);
|
|
}
|
|
}
|
|
|
|
// the side whose ground a tile of `owner` is (unowned ground counts as the first player's)
|
|
pub fn side_of_owner(owner: Owner) -> u8 {
|
|
match owner {
|
|
Owner::Hero => 1,
|
|
Owner::Rival1 => 2,
|
|
Owner::Rival2 => 3,
|
|
Owner::Rival3 => 4,
|
|
Owner::Player | Owner::None => 0,
|
|
}
|
|
}
|
|
|
|
// gold amounts a master can send to an ally; `GOLD_ALL` sends everything he has
|
|
pub const GOLD_ALL: u32 = u32::MAX;
|
|
pub const GOLD_AMOUNTS: [u32; 4] = [100, 500, 1000, GOLD_ALL];
|
|
|
|
impl Game {
|
|
// ------------------------------------------------------------ teams and allies
|
|
|
|
// the team of a master seat in a versus match (sides without an entry are a team of their own)
|
|
pub fn team_of(&self, side: u8) -> u8 {
|
|
self.teams.iter().find(|t| t.0 == side).map_or(10 + side, |t| t.1)
|
|
}
|
|
|
|
// a master seat of the match: a human master or an AI seat
|
|
pub fn is_seat_side(&self, side: u8) -> bool {
|
|
Owner::MASTER_SIDES.contains(&side) && (self.is_master(side) || self.ai_sides.contains(&side))
|
|
}
|
|
|
|
// sides that never fight each other: the same side, co-op partners and team mates. Only shared
|
|
// state goes in, so every client of a match decides the same.
|
|
pub fn allied(&self, a: u8, b: u8) -> bool {
|
|
if a == b {
|
|
return true;
|
|
}
|
|
if !self.is_seat_side(a) || !self.is_seat_side(b) {
|
|
return false;
|
|
}
|
|
self.coop || self.team_of(a) == self.team_of(b)
|
|
}
|
|
|
|
// the teams of a versus match: (side, team) for the master seats
|
|
pub fn set_teams(&mut self, teams: &[(u8, u8)]) {
|
|
self.teams = teams.iter().copied().filter(|(s, _)| Owner::MASTER_SIDES.contains(s)).collect();
|
|
}
|
|
|
|
// ------------------------------------------------------------ fallen hearts
|
|
|
|
pub fn is_out(&self, side: u8) -> bool {
|
|
self.out_sides.contains(&side)
|
|
}
|
|
|
|
// the master at this computer lost his heart: he watches the rest of the match
|
|
pub fn spectating(&self) -> bool {
|
|
self.netplay.is_some() && self.is_out(self.local_side)
|
|
}
|
|
|
|
fn master_state(&self, side: u8) -> Option<&crate::game::MasterState> {
|
|
self.masters.iter().find(|m| m.side == side)
|
|
}
|
|
|
|
pub fn core_hp_of(&self, side: u8) -> f32 {
|
|
if side == self.me_side {
|
|
self.core_hp
|
|
} else {
|
|
self.master_state(side).map_or(0.0, |m| m.core_hp)
|
|
}
|
|
}
|
|
|
|
pub fn core_of_side(&self, side: u8) -> (i32, i32) {
|
|
if side == self.me_side {
|
|
self.map.core
|
|
} else {
|
|
self.master_state(side).map_or(self.map.core, |m| m.core)
|
|
}
|
|
}
|
|
|
|
pub fn gold_of(&self, side: u8) -> f32 {
|
|
if side == self.me_side {
|
|
self.gold
|
|
} else {
|
|
self.master_state(side).map_or(0.0, |m| m.gold)
|
|
}
|
|
}
|
|
|
|
// damage to the soul core of a master (whichever master is being processed)
|
|
pub fn damage_master_core(&mut self, side: u8, dmg: f32) {
|
|
if side == self.me_side {
|
|
self.core_hp -= dmg;
|
|
} else if let Some(m) = self.masters.iter_mut().find(|m| m.side == side) {
|
|
m.core_hp -= dmg;
|
|
}
|
|
}
|
|
|
|
// the heart of a master of the match that is no ally of `side`, near (x, y)
|
|
pub(crate) fn enemy_master_near(&self, side: u8, x: f32, y: f32) -> Option<u8> {
|
|
if self.masters.is_empty() {
|
|
return None;
|
|
}
|
|
self.master_sides().into_iter().filter(|&s| s != side && !self.is_out(s) && !self.allied(side, s)).find(|&s| {
|
|
let (cx, cy) = self.core_of_side(s);
|
|
(x - cx as f32 - 1.5).powi(2) + (y - cy as f32 - 1.5).powi(2) <= 8.0 * 8.0
|
|
})
|
|
}
|
|
|
|
// a message and a sound for the player at this computer, whichever master is being processed
|
|
fn tell_local(&mut self, key: &'static str, args: Vec<String>, sfx: Option<crate::game::Sfx>) {
|
|
let me = self.me_side;
|
|
let local = self.local_side;
|
|
self.switch_to(local);
|
|
self.msg(key, args);
|
|
if let Some(sfx) = sfx {
|
|
self.sfx_ui(sfx);
|
|
}
|
|
self.switch_to(me);
|
|
}
|
|
|
|
// a master of a match whose core has no strength left is out. His creatures fight on without a
|
|
// heart until they fall; he keeps watching. Runs in the shared part of the step.
|
|
pub(crate) fn update_fallen_masters(&mut self) {
|
|
if self.netplay.is_none() {
|
|
return;
|
|
}
|
|
for side in self.master_sides() {
|
|
if self.is_out(side) || self.core_hp_of(side) > 0.0 {
|
|
continue;
|
|
}
|
|
self.out_sides.push(side);
|
|
self.master_fell(side);
|
|
}
|
|
}
|
|
|
|
fn master_fell(&mut self, side: u8) {
|
|
let me = self.me_side;
|
|
self.switch_to(side);
|
|
self.core_hp = 0.0;
|
|
self.flag = None;
|
|
// what he held in his hand is put down beside the broken heart
|
|
let (cx, cy) = self.map.core;
|
|
let hand = std::mem::take(&mut self.hand);
|
|
for (k, id) in hand.iter().enumerate() {
|
|
if let Some(e) = self.ents.iter_mut().find(|e| e.id == *id && e.held) {
|
|
e.held = false;
|
|
e.x = cx as f32 + 1.5 + (k % 3) as f32 * 0.6 - 0.6;
|
|
e.y = cy as f32 + 3.5;
|
|
e.disp_x = e.x;
|
|
e.disp_y = e.y;
|
|
e.job = crate::game::Job::None;
|
|
e.path.clear();
|
|
}
|
|
}
|
|
// the heart is gone: its tiles become plain ground of that master
|
|
let owner = Owner::of_side(side);
|
|
for t in self.map.t.iter_mut() {
|
|
if t.kind == Kind::Core && t.owner == owner {
|
|
t.kind = Kind::Floor;
|
|
}
|
|
}
|
|
self.switch_to(me);
|
|
if side == self.local_side {
|
|
self.tell_local("msg.master_out_you", vec![], Some(crate::game::Sfx::Defeat));
|
|
} else {
|
|
self.tell_local("msg.master_out", vec![format!("@net.side{side}")], Some(crate::game::Sfx::CoreHit));
|
|
}
|
|
}
|
|
|
|
// goals of a mission played in a match: what one master holds (mana springs) counts for any of
|
|
// the masters still in the game, everything else is shared
|
|
pub fn match_goal_done(&self, goal: crate::game::Goal) -> bool {
|
|
match goal {
|
|
crate::game::Goal::Springs(n) => self.master_sides().into_iter().filter(|s| !self.is_out(*s)).any(|s| {
|
|
let springs = if s == self.me_side { self.springs } else { self.master_state(s).map_or(0, |m| m.springs) };
|
|
springs >= n as usize
|
|
}),
|
|
g => self.goal_done(g),
|
|
}
|
|
}
|
|
|
|
// The end of a match, decided from shared state only so that all clients stop in the same step:
|
|
// a mission (co-op) is lost when every master's heart fell; a versus match is over when only one
|
|
// team is left - the masters of that team win, the others lose.
|
|
pub(crate) fn check_match_end(&mut self) {
|
|
if self.state != 0 {
|
|
return;
|
|
}
|
|
let alive: Vec<u8> = self.master_sides().into_iter().filter(|s| !self.is_out(*s)).collect();
|
|
let lost = |g: &mut Game, key: &'static str| {
|
|
g.state = 2;
|
|
g.msg(key, vec![]);
|
|
g.sfx_ui(crate::game::Sfx::Defeat);
|
|
};
|
|
if let Some(m) = &self.mission {
|
|
let (limit, goals) = (m.time_limit, m.goals.clone());
|
|
if alive.is_empty() {
|
|
return lost(self, "msg.defeat");
|
|
}
|
|
if limit > 0.0 && self.time > limit {
|
|
return lost(self, "msg.time_up");
|
|
}
|
|
if !goals.is_empty() && goals.iter().all(|&g| self.match_goal_done(g)) {
|
|
self.state = 1;
|
|
self.msg("msg.mission_won", vec![]);
|
|
self.sfx_ui(crate::game::Sfx::Victory);
|
|
}
|
|
return;
|
|
}
|
|
// the teams that started (masters and AI seats) and the ones still standing
|
|
let mut sides: Vec<u8> = self.master_sides();
|
|
sides.extend(self.rivals.iter().map(|r| r.side));
|
|
let mut teams: Vec<u8> = sides.iter().map(|&s| self.team_of(s)).collect();
|
|
teams.sort_unstable();
|
|
teams.dedup();
|
|
let mut standing: Vec<u8> = alive.iter().copied().chain(self.rivals.iter().filter(|r| r.alive).map(|r| r.side)).map(|s| self.team_of(s)).collect();
|
|
standing.sort_unstable();
|
|
standing.dedup();
|
|
if alive.is_empty() {
|
|
return lost(self, "msg.defeat");
|
|
}
|
|
let over = if teams.len() >= 2 {
|
|
standing.len() <= 1
|
|
} else {
|
|
// everybody on one team: the humans' gate is the enemy
|
|
self.gate_on && self.gate_hp <= 0.0
|
|
};
|
|
if !over {
|
|
return;
|
|
}
|
|
if standing.contains(&self.team_of(self.local_side)) {
|
|
self.state = 1;
|
|
self.msg("msg.match_won", vec![]);
|
|
self.sfx_ui(crate::game::Sfx::Victory);
|
|
} else {
|
|
lost(self, "msg.match_lost");
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------ gold for allies
|
|
|
|
// the masters the local master can send gold to: human allies whose heart still beats
|
|
pub fn gold_allies(&self) -> Vec<u8> {
|
|
if self.netplay.is_none() || self.is_out(self.local_side) {
|
|
return Vec::new();
|
|
}
|
|
self.master_sides().into_iter().filter(|&s| s != self.local_side && !self.is_out(s) && self.allied(self.local_side, s)).collect()
|
|
}
|
|
|
|
// the master being processed sends gold to an ally (never more than he has, never more than
|
|
// the ally's treasury takes)
|
|
pub(crate) fn send_gold(&mut self, to: u8, amount: u32) {
|
|
let from = self.me_side;
|
|
if to == from || !self.is_master(to) || !self.allied(from, to) || self.is_out(to) || self.is_out(from) {
|
|
return;
|
|
}
|
|
let want = (amount as f32).min(self.gold.floor());
|
|
if want < 1.0 {
|
|
self.msg("msg.gold_none", vec![]);
|
|
self.sfx_ui(crate::game::Sfx::Error);
|
|
return;
|
|
}
|
|
self.switch_to(to);
|
|
let got = self.add_gold(want).floor();
|
|
if got >= 1.0 {
|
|
self.msg("msg.gold_received", vec![(got as i32).to_string(), format!("@net.side{from}")]);
|
|
self.sfx_ui(crate::game::Sfx::Payday);
|
|
}
|
|
self.switch_to(from);
|
|
if got < 1.0 {
|
|
self.msg("msg.gold_full", vec![format!("@net.side{to}")]);
|
|
self.sfx_ui(crate::game::Sfx::Error);
|
|
return;
|
|
}
|
|
self.gold -= got;
|
|
self.msg("msg.gold_sent", vec![(got as i32).to_string(), format!("@net.side{to}")]);
|
|
self.sfx_ui(crate::game::Sfx::Deposit);
|
|
}
|
|
|
|
// a match with masters on `sides` (of 0, 2, 3, 4), `local` is the master who plays at this computer
|
|
pub fn start_match(&mut self, sides: &[u8], local: u8) -> bool {
|
|
if !self.start_masters(sides, local) {
|
|
return false;
|
|
}
|
|
// every master has to be able to build his dungeon at once
|
|
if let Some(v) = self.known.get_mut(crate::game::SUMMON_TOOL) {
|
|
*v = true;
|
|
}
|
|
for m in self.masters.iter_mut() {
|
|
if let Some(v) = m.known.get_mut(crate::game::SUMMON_TOOL) {
|
|
*v = true;
|
|
}
|
|
}
|
|
self.netplay = Some(Netplay::default());
|
|
true
|
|
}
|
|
|
|
pub fn in_match(&self) -> bool {
|
|
self.netplay.is_some()
|
|
}
|
|
|
|
// a seat without a human in a started match: that side keeps playing as a rival-style AI
|
|
pub fn sync_ai_sides(&mut self, ai: &[u8], difficulty: u8) {
|
|
if self.netplay.is_none() {
|
|
return;
|
|
}
|
|
self.ai_difficulty = difficulty;
|
|
self.ai_sides = ai
|
|
.iter()
|
|
.copied()
|
|
.filter(|side| *side != self.local_side)
|
|
.collect();
|
|
for side in self.ai_sides.clone() {
|
|
if self.is_out(side) {
|
|
continue; // a fallen master who left has nothing left for an AI to play
|
|
}
|
|
let core = if side == self.me_side {
|
|
continue;
|
|
} else if let Some(m) = self.masters.iter().find(|m| m.side == side) {
|
|
Some(m.core)
|
|
} else {
|
|
None
|
|
};
|
|
let Some(core) = core else { continue };
|
|
if let Some(k) = self.masters.iter().position(|m| m.side == side) {
|
|
self.masters.remove(k);
|
|
}
|
|
if !self.is_rival_side(side) {
|
|
self.add_rival(side, core.0 + 1, core.1 + 1, self.ai_difficulty);
|
|
}
|
|
}
|
|
}
|
|
|
|
// the relay ended a match, or the player leaves it: the world stops being a turn-based match
|
|
pub fn end_match(&mut self) {
|
|
self.netplay = None;
|
|
}
|
|
|
|
// what the local master ordered since the last turn: these go to the server
|
|
pub fn take_orders(&mut self) -> Vec<Command> {
|
|
std::mem::take(&mut self.pending)
|
|
}
|
|
|
|
// the server confirmed a turn: run the orders of all masters, then the fixed steps of the turn.
|
|
// A local client that is paused or finished leaves the turn out; empty match seats are AI sides.
|
|
pub fn receive_turn(&mut self, cmds: &[Command]) -> u32 {
|
|
if self.netplay.is_none() || self.state != 0 || self.paused {
|
|
return 0;
|
|
}
|
|
self.apply_turn(cmds, STEPS_PER_TURN);
|
|
match self.netplay.as_mut() {
|
|
Some(np) => {
|
|
np.turn += 1;
|
|
np.turn
|
|
}
|
|
None => 0,
|
|
}
|
|
}
|
|
|
|
// a peer's checksum of turn `turn`: when we are standing at that turn as well, the numbers have
|
|
// to match. A mismatch is reported once and makes the match unusable (the message says it).
|
|
pub fn check_peer(&mut self, turn: u32, sum: u64) -> bool {
|
|
let same = match &self.netplay {
|
|
Some(np) => np.turn != turn || sum == self.checksum(),
|
|
None => true,
|
|
};
|
|
if same {
|
|
return true;
|
|
}
|
|
if let Some(np) = self.netplay.as_mut() {
|
|
np.desync = true;
|
|
}
|
|
self.msg("net.desync", vec![]);
|
|
self.sfx_ui(crate::game::Sfx::Error);
|
|
false
|
|
}
|
|
|
|
pub fn checksum_due(&mut self) -> Option<u32> {
|
|
let turn = match &self.netplay {
|
|
Some(np) => np.turn,
|
|
None => return None,
|
|
};
|
|
if turn < 10 {
|
|
return None;
|
|
}
|
|
let due = match &self.netplay {
|
|
Some(np) => turn.saturating_sub(np.last_sum) >= 10,
|
|
None => false,
|
|
};
|
|
if !due {
|
|
return None;
|
|
}
|
|
if let Some(np) = self.netplay.as_mut() {
|
|
np.last_sum = turn;
|
|
}
|
|
Some(turn)
|
|
}
|
|
|
|
// the part of the world every client of a match has to agree on: the map, the creatures, the
|
|
// economy of every master, the wave and the random state. Fog, particles and the camera are
|
|
// local and stay out of it.
|
|
pub fn checksum(&self) -> u64 {
|
|
let mut h = FNV_OFFSET;
|
|
mix(&mut h, self.sim_steps);
|
|
// whether the world is still running: the end itself is told per team (won or lost)
|
|
mix(&mut h, (self.state != 0) as u64);
|
|
mix(&mut h, self.next_id as u64);
|
|
mix(&mut h, self.rng.0 as u64);
|
|
mix(&mut h, self.wave as u64);
|
|
mixf(&mut h, self.time);
|
|
mixf(&mut h, self.gate_hp);
|
|
mixf(&mut h, self.last_wave_value);
|
|
mix(&mut h, self.towers.len() as u64);
|
|
mix(&mut h, self.traps.len() as u64);
|
|
mix(&mut h, self.rollers.len() as u64);
|
|
mix(&mut h, self.graves.len() as u64);
|
|
for t in self.map.t.iter() {
|
|
mix(&mut h, t.kind as u8 as u64);
|
|
mix(&mut h, t.owner as u8 as u64);
|
|
mix(&mut h, t.marked as u64);
|
|
mix(&mut h, t.reinforced as u64);
|
|
mix(&mut h, t.res as u64);
|
|
mix(&mut h, t.var as u64);
|
|
mixf(&mut h, t.hp);
|
|
mix(&mut h, t.gold as u32 as u64);
|
|
mix(&mut h, t.reserved as u64);
|
|
mix(&mut h, t.bed as u64);
|
|
mixf(&mut h, t.food);
|
|
}
|
|
for e in self.ents.iter() {
|
|
mix(&mut h, e.id as u64);
|
|
mix(&mut h, e.kind as u8 as u64);
|
|
mix(&mut h, e.side as u64);
|
|
mix(&mut h, e.hero as u64);
|
|
mix(&mut h, e.alive as u64);
|
|
mix(&mut h, e.held as u64);
|
|
mix(&mut h, e.level as u64);
|
|
mixf(&mut h, e.x);
|
|
mixf(&mut h, e.y);
|
|
mixf(&mut h, e.hp);
|
|
mixf(&mut h, e.max_hp);
|
|
mixf(&mut h, e.xp);
|
|
mixdbg(&mut h, &e.job);
|
|
mixf(&mut h, e.timer);
|
|
mixf(&mut h, e.cd);
|
|
mixf(&mut h, e.think);
|
|
mixf(&mut h, e.repath);
|
|
mix(&mut h, e.carry as u32 as u64);
|
|
mix(&mut h, e.carry_res as u64);
|
|
mixf(&mut h, e.hunger);
|
|
mixf(&mut h, e.energy);
|
|
mixf(&mut h, e.happy);
|
|
mixp(&mut h, e.bed.map(|b| (b as i32, 0)));
|
|
mix(&mut h, e.moving as u64);
|
|
mix(&mut h, e.working as u64);
|
|
mixf(&mut h, e.slow);
|
|
mix(&mut h, e.captive as u64);
|
|
mix(&mut h, e.carrier as u64);
|
|
mixf(&mut h, e.torture_t);
|
|
mixf(&mut h, e.skill_cd);
|
|
mixp(&mut h, e.waypoint);
|
|
mixf(&mut h, e.boost);
|
|
mixf(&mut h, e.stun);
|
|
mixf(&mut h, e.haste);
|
|
mixf(&mut h, e.shield);
|
|
mixf(&mut h, e.invis);
|
|
mixf(&mut h, e.plague);
|
|
mixf(&mut h, e.ab_cd);
|
|
mixf(&mut h, e.mood_t);
|
|
mix(&mut h, e.guard as u64);
|
|
mix(&mut h, e.path.len() as u64);
|
|
}
|
|
let mut sums: Vec<(u8, f32, f32, f32, f32, usize, usize, f32, usize, (i32, i32))> = Vec::new();
|
|
sums.push((self.me_side, self.gold, self.mana, self.core_hp, self.payday, self.hand.len(), self.counts.iter().sum(), self.reserve, self.springs, self.map.core));
|
|
for m in self.masters.iter() {
|
|
sums.push((m.side, m.gold, m.mana, m.core_hp, m.payday, m.hand.len(), m.counts.iter().sum(), m.reserve, m.springs, m.core));
|
|
}
|
|
sums.sort_by(|a, b| a.0.cmp(&b.0));
|
|
for (side, gold, mana, core_hp, payday, hand, count, reserve, springs, core) in sums.iter() {
|
|
mix(&mut h, *side as u64);
|
|
mixf(&mut h, *gold);
|
|
mixf(&mut h, *mana);
|
|
mixf(&mut h, *core_hp);
|
|
mixf(&mut h, *payday);
|
|
mix(&mut h, *hand as u64);
|
|
mix(&mut h, *count as u64);
|
|
mixf(&mut h, *reserve);
|
|
mix(&mut h, *springs as u64);
|
|
mixp(&mut h, Some(*core));
|
|
}
|
|
for r in self.rivals.iter() {
|
|
mixp(&mut h, Some(r.core));
|
|
mix(&mut h, r.side as u64);
|
|
mix(&mut h, r.alive as u64);
|
|
mix(&mut h, r.radius as u64);
|
|
mix(&mut h, r.difficulty as u64);
|
|
mixf(&mut h, r.hp);
|
|
mixf(&mut h, r.grow_t);
|
|
mixf(&mut h, r.spawn_t);
|
|
mixf(&mut h, r.attack_t);
|
|
mixf(&mut h, r.power);
|
|
}
|
|
for side in self.ai_sides.iter() {
|
|
mix(&mut h, *side as u64);
|
|
}
|
|
for side in self.out_sides.iter() {
|
|
mix(&mut h, 100 + *side as u64);
|
|
}
|
|
h
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::commands::{Act, Rect};
|
|
use crate::map::{Kind, Owner};
|
|
|
|
fn client(seed: u32, local: u8) -> Game {
|
|
let mut g = Game::new(seed, 96, 128);
|
|
g.init_fog();
|
|
assert!(g.start_match(&[0, 2, 3], local), "a match of three masters");
|
|
g
|
|
}
|
|
|
|
// the top-left corner of the core of `owner`
|
|
fn core_of(g: &Game, owner: Owner) -> (i32, i32) {
|
|
for y in 0..128i32 {
|
|
for x in 0..96i32 {
|
|
let t = &g.map.t[(y as usize) * 96 + x as usize];
|
|
if t.kind == Kind::Core && t.owner == owner {
|
|
return (x, y);
|
|
}
|
|
}
|
|
}
|
|
panic!("no core of {owner:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn two_clients_of_one_match_see_the_same_world() {
|
|
let (mut a, mut b) = (client(21, 0), client(21, 2));
|
|
assert_eq!(a.checksum(), b.checksum(), "the same start");
|
|
let (red, green) = (core_of(&a, Owner::Player), core_of(&a, Owner::Rival1));
|
|
for t in 0..40u32 {
|
|
let (d, o) = (t as i32 * 2, t as i32 * 2);
|
|
let turn = vec![
|
|
Command { side: 0, seq: t * 2, act: Act::Dig(Rect::new(red.0 + 7 + o, red.1 - 4, red.0 + 8 + o, red.1 - 3)) },
|
|
Command { side: 2, seq: t * 2 + 1, act: Act::Dig(Rect::new(green.0 + 4, green.1 + 5 + d, green.0 + 5, green.1 + 6 + d)) },
|
|
];
|
|
a.receive_turn(&turn);
|
|
b.receive_turn(&turn);
|
|
}
|
|
assert_eq!(a.sim_steps, b.sim_steps);
|
|
assert_eq!(a.sim_steps, 40 * STEPS_PER_TURN as u64, "five steps in every turn");
|
|
assert_eq!(a.gold, b.gold);
|
|
assert_eq!(a.checksum(), b.checksum(), "the worlds stayed identical");
|
|
// real time alone never moves a match forward
|
|
a.update(1.0);
|
|
b.update(1.0);
|
|
assert_eq!(a.sim_steps, 40 * STEPS_PER_TURN as u64);
|
|
assert_eq!(a.checksum(), b.checksum());
|
|
}
|
|
|
|
#[test]
|
|
fn a_new_match_master_can_summon_at_once() {
|
|
let mut g = client(24, 2);
|
|
assert!(g.tool_allowed(crate::game::SUMMON_TOOL), "summoning is known in a match");
|
|
assert_eq!(g.worker_count(), 4, "a new master starts with his own four imps");
|
|
let (cx, cy) = core_of(&g, Owner::Rival1);
|
|
let mana = g.mana;
|
|
let cost = g.summon_cost();
|
|
g.queue(Act::Summon { x: cx as f32 - 0.5, y: cy as f32 + 0.5 });
|
|
let turn = g.take_orders();
|
|
g.receive_turn(&turn);
|
|
assert!(cost > 0.0 && g.mana < mana, "summoning costs mana");
|
|
assert!(g.ents.iter().any(|e| e.kind == crate::game::EK::Worker && e.side == 2 && e.alive), "the summoned imp belongs to the master who summoned it");
|
|
assert_eq!(g.worker_count(), 5, "the summon counts as the master's own worker");
|
|
}
|
|
|
|
#[test]
|
|
fn a_lost_seat_in_a_match_is_converted_to_the_same_ai_side() {
|
|
let (mut a, mut b) = (client(31, 0), client(31, 2));
|
|
a.sync_ai_sides(&[3], 1);
|
|
b.sync_ai_sides(&[3], 1);
|
|
assert_eq!(a.checksum(), b.checksum(), "both clients convert side 3 the same way");
|
|
assert!(a.is_rival_side(3) && b.is_rival_side(3));
|
|
assert!(!a.is_master(3) && !b.is_master(3));
|
|
a.receive_turn(&[]);
|
|
b.receive_turn(&[]);
|
|
assert_eq!(a.checksum(), b.checksum(), "and keep playing it identically");
|
|
}
|
|
|
|
#[test]
|
|
fn a_client_without_the_next_turn_stands_still_and_its_orders_wait() {
|
|
let mut g = client(22, 0);
|
|
let (cx, cy) = core_of(&g, Owner::Player);
|
|
g.queue(Act::Dig(Rect::new(cx + 7, cy, cx + 8, cy + 1)));
|
|
for _ in 0..10 {
|
|
g.update(0.2);
|
|
}
|
|
assert_eq!(g.sim_steps, 0, "no turn, no step");
|
|
assert_eq!(g.netplay.as_ref().unwrap().turn, 0);
|
|
let orders = g.take_orders();
|
|
assert_eq!(orders.len(), 1, "the order waits for the server");
|
|
assert!(g.pending.is_empty());
|
|
g.receive_turn(&orders);
|
|
assert_eq!(g.sim_steps, STEPS_PER_TURN as u64);
|
|
assert_eq!(g.netplay.as_ref().unwrap().turn, 1);
|
|
}
|
|
|
|
// every client of one match: the same world, seen from another seat
|
|
fn clients(seed: u32, locals: &[u8], teams: &[(u8, u8)]) -> Vec<Game> {
|
|
locals
|
|
.iter()
|
|
.map(|&local| {
|
|
let mut g = client(seed, local);
|
|
g.set_teams(teams);
|
|
g
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn turn_all(games: &mut [Game], cmds: &[Command]) {
|
|
for g in games.iter_mut() {
|
|
g.receive_turn(cmds);
|
|
}
|
|
}
|
|
|
|
fn same_world(games: &[Game], why: &str) {
|
|
let sum = games[0].checksum();
|
|
for g in games.iter().skip(1) {
|
|
assert_eq!(g.checksum(), sum, "{why}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_fallen_master_watches_while_the_match_goes_on() {
|
|
let mut games = clients(41, &[0, 2, 3], &[]);
|
|
for g in games.iter_mut() {
|
|
g.damage_master_core(2, crate::game::CORE_HP * 2.0);
|
|
}
|
|
turn_all(&mut games, &[]);
|
|
same_world(&games, "the heart fell in the same step everywhere");
|
|
for g in games.iter() {
|
|
assert!(g.is_out(2) && !g.is_out(0) && !g.is_out(3));
|
|
assert_eq!(g.state, 0, "the match goes on for the others");
|
|
}
|
|
let green = &games[1];
|
|
assert!(green.spectating() && !games[0].spectating());
|
|
assert!(green.msgs.iter().any(|m| m.key == "msg.master_out_you"));
|
|
assert!(games[0].msgs.iter().any(|m| m.key == "msg.master_out"));
|
|
assert!(!green.map.t.iter().any(|t| t.kind == Kind::Core && t.owner == Owner::Rival1), "the heart is broken");
|
|
let workers = green.ents.iter().filter(|e| e.alive && e.side == 2).count();
|
|
assert!(workers > 0, "his creatures go on without a heart");
|
|
// the spectator sees everything and cannot order anything
|
|
let mut games = games;
|
|
for _ in 0..30 {
|
|
turn_all(&mut games, &[]);
|
|
}
|
|
assert!(games[1].map.visible.iter().all(|v| *v), "no fog for the spectator");
|
|
assert!(!games[0].map.visible.iter().all(|v| *v), "the others still have fog");
|
|
let (gx, gy) = games[1].core_of_side(2);
|
|
games[1].queue(Act::Dig(Rect::new(gx + 7, gy, gx + 8, gy + 1)));
|
|
assert!(games[1].take_orders().is_empty(), "a spectator gives no orders");
|
|
// an order that still comes in for the fallen side changes nothing
|
|
let stray = vec![Command { side: 2, seq: 0, act: Act::Dig(Rect::new(gx + 7, gy, gx + 8, gy + 1)) }];
|
|
turn_all(&mut games, &stray);
|
|
assert!(!games[0].map.t[crate::map::idx(gx + 7, gy)].marked);
|
|
for _ in 0..50 {
|
|
turn_all(&mut games, &[]);
|
|
}
|
|
same_world(&games, "the spectator stays in step");
|
|
assert!(games.iter().all(|g| g.state == 0));
|
|
// the last enemy falls: red wins, blue (and the spectator) lose, in the same step
|
|
for g in games.iter_mut() {
|
|
g.damage_master_core(3, crate::game::CORE_HP * 2.0);
|
|
}
|
|
turn_all(&mut games, &[]);
|
|
same_world(&games, "the end came in the same step everywhere");
|
|
assert_eq!(games[0].state, 1, "red is the last master standing");
|
|
assert_eq!(games[1].state, 2);
|
|
assert_eq!(games[2].state, 2);
|
|
assert!(games[0].msgs.iter().any(|m| m.key == "msg.match_won"));
|
|
}
|
|
|
|
#[test]
|
|
fn team_mates_do_not_fight_and_win_together() {
|
|
// red and green are team A, blue is team B
|
|
let teams = [(0u8, 0u8), (2, 0), (3, 1)];
|
|
let mut games = clients(43, &[0, 2, 3], &teams);
|
|
let g = &games[0];
|
|
assert!(g.allied(0, 2) && g.allied(2, 0) && !g.allied(0, 3) && !g.allied(2, 3));
|
|
assert_eq!(games[1].gold_allies(), vec![0u8], "green can send gold to red");
|
|
assert!(games[2].gold_allies().is_empty(), "blue has no team mate");
|
|
// a creature of red next to one of green: no target; one of blue is
|
|
for g in games.iter_mut() {
|
|
let (cx, cy) = g.core_of_side(2);
|
|
let (x, y) = (cx as f32 + 4.5, cy as f32 + 4.5);
|
|
let red = g.spawn(crate::game::EK::Kobold, false, x, y, 3);
|
|
let green = g.spawn(crate::game::EK::Kobold, false, x + 1.0, y, 3);
|
|
for (id, side) in [(red, 0u8), (green, 2u8)] {
|
|
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
|
|
e.side = side;
|
|
}
|
|
}
|
|
let red_e = g.ents.iter().find(|e| e.id == red).unwrap().clone();
|
|
assert_eq!(g.nearest_enemy(&red_e, 3.0), None, "team mates are no targets");
|
|
let blue = g.spawn(crate::game::EK::Kobold, false, x, y + 1.0, 3);
|
|
if let Some(e) = g.ents.iter_mut().find(|e| e.id == blue) {
|
|
e.side = 3;
|
|
}
|
|
assert_eq!(g.nearest_enemy(&red_e, 3.0), Some(blue), "the other team is");
|
|
}
|
|
for _ in 0..40 {
|
|
turn_all(&mut games, &[]);
|
|
}
|
|
same_world(&games, "teams play the same on every client");
|
|
// the fog shows the team mate's ground
|
|
let (gx, gy) = games[0].core_of_side(2);
|
|
assert!(games[0].map.visible[crate::map::idx(gx + 1, gy + 1)], "red sees green's heart");
|
|
assert!(!games[2].map.visible[crate::map::idx(gx + 1, gy + 1)], "blue does not");
|
|
// one of team A falls: the match goes on; team B falls: team A wins together
|
|
for g in games.iter_mut() {
|
|
g.damage_master_core(0, crate::game::CORE_HP * 2.0);
|
|
}
|
|
turn_all(&mut games, &[]);
|
|
assert!(games.iter().all(|g| g.state == 0), "green still stands for team A");
|
|
for g in games.iter_mut() {
|
|
g.damage_master_core(3, crate::game::CORE_HP * 2.0);
|
|
}
|
|
turn_all(&mut games, &[]);
|
|
same_world(&games, "the end came in the same step everywhere");
|
|
assert_eq!(games[0].state, 1, "red was out, but his team won");
|
|
assert_eq!(games[1].state, 1);
|
|
assert_eq!(games[2].state, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn gold_goes_to_an_ally_on_every_client() {
|
|
let mut games = clients(45, &[0, 2, 3], &[(0, 0), (2, 0), (3, 1)]);
|
|
let red0 = games[0].gold_of(0);
|
|
let green0 = games[0].gold_of(2);
|
|
games[0].queue(Act::SendGold { to: 2, amount: 500 });
|
|
let orders = games[0].take_orders();
|
|
assert_eq!(orders.len(), 1);
|
|
turn_all(&mut games, &orders);
|
|
for g in games.iter() {
|
|
assert_eq!(g.gold_of(0), red0 - 500.0, "red paid");
|
|
assert_eq!(g.gold_of(2), green0 + 500.0, "green got it");
|
|
}
|
|
assert!(games[1].msgs.iter().any(|m| m.key == "msg.gold_received"), "the receiver hears of it");
|
|
assert!(games[1].events.iter().any(|e| e.sfx == crate::game::Sfx::Payday));
|
|
assert!(games[0].msgs.iter().any(|m| m.key == "msg.gold_sent"));
|
|
same_world(&games, "both clients have the same gold");
|
|
// not more than he has (and not more than the ally's treasury takes); never to an enemy
|
|
let all = vec![
|
|
Command { side: 2, seq: 0, act: Act::SendGold { to: 0, amount: crate::netplay::GOLD_ALL } },
|
|
Command { side: 0, seq: 1, act: Act::SendGold { to: 3, amount: 100 } },
|
|
Command { side: 3, seq: 0, act: Act::SendGold { to: 0, amount: 100 } },
|
|
];
|
|
let blue = games[0].gold_of(3);
|
|
let (red1, green1) = (games[0].gold_of(0), games[0].gold_of(2));
|
|
turn_all(&mut games, &all);
|
|
for g in games.iter() {
|
|
let sent = green1 - g.gold_of(2);
|
|
assert!(sent > 0.0 && sent <= green1, "green sends what he has: {sent}");
|
|
assert_eq!(g.gold_of(0), red1 + sent, "red gets all of it");
|
|
assert!(g.gold_of(0) <= g.gold_cap() + 0.5 || g.local_side != 0);
|
|
assert!((g.gold_of(3) - blue).abs() < 1.0, "blue got nothing from red and gave nothing");
|
|
}
|
|
same_world(&games, "still the same gold everywhere");
|
|
}
|
|
|
|
#[test]
|
|
fn a_world_that_develops_differently_is_caught_by_the_checksum() {
|
|
let (mut a, mut b) = (client(23, 0), client(23, 0));
|
|
let (cx, cy) = core_of(&a, Owner::Player);
|
|
let turn = vec![Command { side: 0, seq: 0, act: Act::Dig(Rect::new(cx + 7, cy, cx + 8, cy + 1)) }];
|
|
a.receive_turn(&turn);
|
|
b.receive_turn(&[]); // b lost that turn
|
|
assert_ne!(a.checksum(), b.checksum());
|
|
assert!(!b.check_peer(a.netplay.as_ref().unwrap().turn, a.checksum()), "b notices the difference");
|
|
assert!(b.netplay.as_ref().unwrap().desync, "and it is recorded");
|
|
assert!(b.msgs.iter().any(|m| m.key == "net.desync"));
|
|
}
|
|
}
|