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.
- **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
+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.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
+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.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
+11 -2
View File
@@ -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;
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;
}
let cmds = std::mem::take(&mut self.pending);
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);
+1
View File
@@ -13,6 +13,7 @@ mod gfx;
mod i18n;
mod map;
mod menu;
mod netplay;
mod personality;
mod platform;
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"));
}
}