Match loop on the client side: orders go to the server, a confirmed turn steps the world five times, and a checksum of the shared state catches worlds that diverge
This commit is contained in:
1 parent
87890adc53
commit
42034b062b
7 files changed
+303
-10
No files matched your search
+15
-6
@@ -594,6 +594,8 @@ pub struct Game {
|
||||
#[serde(skip)]
|
||||
step_acc: f32, // real time not yet paid for with a step
|
||||
#[serde(skip)]
|
||||
pub netplay: Option<crate::netplay::Netplay>, // a match: turns come in, the world steps per turn
|
||||
#[serde(skip)]
|
||||
pub hints: bool, // show hint messages (setting)
|
||||
#[serde(skip, default = "hint_times")]
|
||||
hint_t: [f32; 32], // last time each hint slot was shown
|
||||
@@ -774,6 +776,7 @@ impl Game {
|
||||
cmd_seq: 0,
|
||||
sim_steps: 0,
|
||||
step_acc: 0.0,
|
||||
netplay: None,
|
||||
hint_t: hint_times(),
|
||||
reserve: 0.0,
|
||||
incoming: Vec::new(),
|
||||
@@ -1176,13 +1179,19 @@ impl Game {
|
||||
// ---------------------------------------------------------------- update
|
||||
|
||||
pub fn update(&mut self, dt: f32) {
|
||||
self.step_acc = (self.step_acc + dt).min(STEP * MAX_STEPS_PER_FRAME as f32);
|
||||
let mut steps = 0usize;
|
||||
while self.step_acc >= STEP {
|
||||
self.step_acc -= STEP;
|
||||
steps += 1;
|
||||
}
|
||||
let cmds = std::mem::take(&mut self.pending);
|
||||
let cmds = if self.netplay.is_some() {
|
||||
// a match runs turn by turn (see netplay.rs): without the next turn the world stands still,
|
||||
// and local orders are not applied here - they travel to the server first
|
||||
Vec::new()
|
||||
} else {
|
||||
self.step_acc = (self.step_acc + dt).min(STEP * MAX_STEPS_PER_FRAME as f32);
|
||||
while self.step_acc >= STEP {
|
||||
self.step_acc -= STEP;
|
||||
steps += 1;
|
||||
}
|
||||
std::mem::take(&mut self.pending)
|
||||
};
|
||||
self.apply_turn(&cmds, steps);
|
||||
// what only the camera cares about runs in real time: it is not part of the shared state
|
||||
self.update_particles(dt);
|
||||
|
||||
@@ -13,6 +13,7 @@ mod gfx;
|
||||
mod i18n;
|
||||
mod map;
|
||||
mod menu;
|
||||
mod netplay;
|
||||
mod personality;
|
||||
mod platform;
|
||||
mod orders;
|
||||
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
impl Game {
|
||||
// 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;
|
||||
}
|
||||
self.netplay = Some(Netplay::default());
|
||||
true
|
||||
}
|
||||
|
||||
pub fn in_match(&self) -> bool {
|
||||
self.netplay.is_some()
|
||||
}
|
||||
|
||||
// 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 client that is paused or finished leaves the turn out (a match pauses while somebody is away).
|
||||
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
|
||||
}
|
||||
|
||||
// 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);
|
||||
mix(&mut h, self.state 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));
|
||||
}
|
||||
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_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);
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user