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
+299
-6
No files matched your search
+7
-3
@@ -44,6 +44,11 @@ per turn. Fog of war, particles and the camera are local and not part of the sha
|
|||||||
Particles, effects and the camera keep running in real time: they are local and not part of the shared state.
|
Particles, effects and the camera keep running in real time: they are local and not part of the shared state.
|
||||||
- **Who speaks**: messages, hints and interface sounds belong to the master who is being processed
|
- **Who speaks**: messages, hints and interface sounds belong to the master who is being processed
|
||||||
(`Game::speaking`); what another master orders never shows up on my screen.
|
(`Game::speaking`); what another master orders never shows up on my screen.
|
||||||
|
- **Turns and checksums** (`netplay.rs`): `Game::start_match(sides, local)` starts a match,
|
||||||
|
`Game::take_orders()` hands the local orders to the server, `Game::receive_turn(cmds)` applies a confirmed
|
||||||
|
turn (five steps) and `Game::checksum()` compares the worlds of the clients - a peer with a different number
|
||||||
|
is reported once (`net.desync`). A client without the next turn stands still: that is the "pause while
|
||||||
|
missing" rule, and the tests run two clients of one match side by side.
|
||||||
- Savegames, tests and the standalone file are unchanged: `cargo test --release` covers the command path
|
- Savegames, tests and the standalone file are unchanged: `cargo test --release` covers the command path
|
||||||
(including "the same actions on both paths build the same world").
|
(including "the same actions on both paths build the same world").
|
||||||
|
|
||||||
@@ -58,9 +63,8 @@ per turn. Fog of war, particles and the camera are local and not part of the sha
|
|||||||
| E | Alliances and coop rules, AI masters for free and abandoned slots | planned |
|
| E | Alliances and coop rules, AI masters for free and abandoned slots | planned |
|
||||||
| F | Coop campaign: 20 missions with story (EN/DE) | planned |
|
| F | Coop campaign: 20 missions with story (EN/DE) | planned |
|
||||||
|
|
||||||
Still open on the way to C: the turn/turn-boundary client loop, the state checksum, and heroes and
|
Still open on the way to C: heroes and alliances for the new sides (today the hero gate path always leads to
|
||||||
alliances for the new sides (today the hero gate path always leads to side 0's core, and 2/3 are the
|
side 0's core, and in free play 2 and 3 are the rivals' sides), and what a match leaves out of savegames.
|
||||||
rivals' sides in free play).
|
|
||||||
|
|
||||||
## Masters and sides
|
## Masters and sides
|
||||||
|
|
||||||
|
|||||||
@@ -604,6 +604,7 @@ msg.wage_unpaid = Dein {0} hat kein Gold für seinen Lohn gefunden und ist wüte
|
|||||||
owner.rival1 = gehört Mossgrim, dem grünen Meister
|
owner.rival1 = gehört Mossgrim, dem grünen Meister
|
||||||
owner.rival2 = gehört Lady Nightwell, der blauen Meisterin
|
owner.rival2 = gehört Lady Nightwell, der blauen Meisterin
|
||||||
owner.rival3 = gehört dem gelben Meister
|
owner.rival3 = gehört dem gelben Meister
|
||||||
|
net.desync = Die Partie ist auseinandergegangen: dieser Rechner sieht einen anderen Dungeon als die anderen.
|
||||||
|
|
||||||
# rival masters (free game)
|
# rival masters (free game)
|
||||||
newgame.difficulty = Gegner
|
newgame.difficulty = Gegner
|
||||||
|
|||||||
@@ -605,6 +605,7 @@ msg.wage_unpaid = Your {0} found no gold for its wage and is furious!
|
|||||||
owner.rival1 = held by Mossgrim, the green master
|
owner.rival1 = held by Mossgrim, the green master
|
||||||
owner.rival2 = held by Lady Nightwell, the blue master
|
owner.rival2 = held by Lady Nightwell, the blue master
|
||||||
owner.rival3 = held by the yellow master
|
owner.rival3 = held by the yellow master
|
||||||
|
net.desync = The match has diverged: this computer sees a different dungeon than the others.
|
||||||
|
|
||||||
# rival masters (free game)
|
# rival masters (free game)
|
||||||
newgame.difficulty = Opponents
|
newgame.difficulty = Opponents
|
||||||
|
|||||||
+11
-2
@@ -594,6 +594,8 @@ pub struct Game {
|
|||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
step_acc: f32, // real time not yet paid for with a step
|
step_acc: f32, // real time not yet paid for with a step
|
||||||
#[serde(skip)]
|
#[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)
|
pub hints: bool, // show hint messages (setting)
|
||||||
#[serde(skip, default = "hint_times")]
|
#[serde(skip, default = "hint_times")]
|
||||||
hint_t: [f32; 32], // last time each hint slot was shown
|
hint_t: [f32; 32], // last time each hint slot was shown
|
||||||
@@ -774,6 +776,7 @@ impl Game {
|
|||||||
cmd_seq: 0,
|
cmd_seq: 0,
|
||||||
sim_steps: 0,
|
sim_steps: 0,
|
||||||
step_acc: 0.0,
|
step_acc: 0.0,
|
||||||
|
netplay: None,
|
||||||
hint_t: hint_times(),
|
hint_t: hint_times(),
|
||||||
reserve: 0.0,
|
reserve: 0.0,
|
||||||
incoming: Vec::new(),
|
incoming: Vec::new(),
|
||||||
@@ -1176,13 +1179,19 @@ impl Game {
|
|||||||
// ---------------------------------------------------------------- update
|
// ---------------------------------------------------------------- update
|
||||||
|
|
||||||
pub fn update(&mut self, dt: f32) {
|
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;
|
let mut steps = 0usize;
|
||||||
|
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 {
|
while self.step_acc >= STEP {
|
||||||
self.step_acc -= STEP;
|
self.step_acc -= STEP;
|
||||||
steps += 1;
|
steps += 1;
|
||||||
}
|
}
|
||||||
let cmds = std::mem::take(&mut self.pending);
|
std::mem::take(&mut self.pending)
|
||||||
|
};
|
||||||
self.apply_turn(&cmds, steps);
|
self.apply_turn(&cmds, steps);
|
||||||
// what only the camera cares about runs in real time: it is not part of the shared state
|
// what only the camera cares about runs in real time: it is not part of the shared state
|
||||||
self.update_particles(dt);
|
self.update_particles(dt);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ mod gfx;
|
|||||||
mod i18n;
|
mod i18n;
|
||||||
mod map;
|
mod map;
|
||||||
mod menu;
|
mod menu;
|
||||||
|
mod netplay;
|
||||||
mod personality;
|
mod personality;
|
||||||
mod platform;
|
mod platform;
|
||||||
mod orders;
|
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