From 42034b062b11c5f3dda4c1061afd7aac199df0bb Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Wed, 4 Nov 2026 11:07:24 +0100 Subject: [PATCH] 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 --- VERSION | 2 +- docs/MULTIPLAYER.md | 10 +- lang/de.lang | 1 + lang/en.lang | 1 + src/game.rs | 21 +++- src/main.rs | 1 + src/netplay.rs | 277 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 303 insertions(+), 10 deletions(-) create mode 100644 src/netplay.rs diff --git a/VERSION b/VERSION index 0c93da4..abae0a3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v202611-00004 +v202611-00005 diff --git a/docs/MULTIPLAYER.md b/docs/MULTIPLAYER.md index 7c4cbbd..f5615b7 100644 --- a/docs/MULTIPLAYER.md +++ b/docs/MULTIPLAYER.md @@ -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. - **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. +- **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 (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 | | 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 -alliances for the new sides (today the hero gate path always leads to side 0's core, and 2/3 are the -rivals' sides in free play). +Still open on the way to C: heroes and alliances for the new sides (today the hero gate path always leads to +side 0's core, and in free play 2 and 3 are the rivals' sides), and what a match leaves out of savegames. ## Masters and sides diff --git a/lang/de.lang b/lang/de.lang index 2807ab5..c7d9dcf 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -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.rival2 = gehört Lady Nightwell, der blauen Meisterin 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) newgame.difficulty = Gegner diff --git a/lang/en.lang b/lang/en.lang index 052df4a..52da546 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -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.rival2 = held by Lady Nightwell, the blue 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) newgame.difficulty = Opponents diff --git a/src/game.rs b/src/game.rs index 5a45564..3418694 100644 --- a/src/game.rs +++ b/src/game.rs @@ -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, // 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); diff --git a/src/main.rs b/src/main.rs index 5d2db7e..c433ea2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod gfx; mod i18n; mod map; mod menu; +mod netplay; mod personality; mod platform; mod orders; diff --git a/src/netplay.rs b/src/netplay.rs new file mode 100644 index 0000000..5a044e1 --- /dev/null +++ b/src/netplay.rs @@ -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 { + 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")); + } +}