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:
Richard Zink committed 2026-11-04 11:07:24 +01:00
1 parent 87890adc53
commit 42034b062b
7 files changed
+299 -6

No files matched your search

+1 -1
View File
@@ -1 +1 @@
v202611-00004 v202611-00005
+7 -3
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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);
+1
View File
@@ -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
View File
@@ -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"));
}
}