Skip to main content

pinch_points/sim/board/
hashing.rs

1//! The determinism fingerprint (spec §7.5).
2
3use super::*;
4
5impl Board {
6    /// Fingerprint of the complete simulation state, in a fixed field order.
7    /// Two boards fed the same seed and inputs must agree on this after every
8    /// tick, on every platform: the determinism contract of spec §7.5.
9    pub fn state_hash(&self) -> u64 {
10        // A census of every field, exhaustive on purpose and with no rest
11        // pattern, so that adding one to `Board` stops compiling until it
12        // is either hashed below or named here as deliberately left out. A
13        // field that silently escapes the fingerprint is a desync no peer
14        // can see: both sides play differently and both report agreement.
15        // `lure_cooldown` escaped exactly this way, and only the two names
16        // under "outside" below have any business doing so.
17        let Self {
18            // hash_terrain
19            grid:
20                Grid {
21                    width: _,
22                    height: _,
23                    h_walls: _,
24                    v_walls: _,
25                    tiles: _,
26                },
27            signposts: _,
28            // hash_creatures
29            crabs: _,
30            scores: _,
31            rng: _,
32            gulls: _,
33            next_gull_id: _,
34            // hash_round
35            tick: _,
36            signpost_seq: _,
37            next_crab_id: _,
38            rules:
39                Rules {
40                    signpost_cap: _,
41                    cap_policy: _,
42                    gull_period: _,
43                    round_length: _,
44                    castle_raids: _,
45                },
46            lure: _,
47            lure_cooldown: _,
48            event_cooldown: _,
49            crabs_banked: _,
50            golden_banked: _,
51            events_enabled: _,
52            mania: _,
53            tempo: _,
54            last_event: _,
55            wrap: _,
56            // Outside the fingerprint, each for a reason of its own: the
57            // construction seed is dead once the PRNG state (which *is*
58            // hashed) has been derived from it, the event queue is filled
59            // and drained inside a single tick, so it is always empty by
60            // the time anyone hashes, and the swept-home record is written
61            // for the render layer and never read back by the sim - it
62            // cannot make two peers play differently, and hashing it would
63            // only make this build disagree with builds playing the very
64            // same round.
65            seed: _,
66            event_queue: _,
67            swept_home: _,
68        } = self;
69        let mut h = Fnv::new();
70        self.hash_terrain(&mut h);
71        self.hash_creatures(&mut h);
72        self.hash_round(&mut h);
73        h.finish()
74    }
75
76    /// The board itself: size, walls, tiles, signposts. Field order is part
77    /// of the contract: never reorder these, only append.
78    fn hash_terrain(&self, h: &mut Fnv) {
79        h.u8(self.grid.width);
80        h.u8(self.grid.height);
81        for &wall in &self.grid.h_walls {
82            h.bool(wall);
83        }
84        for &wall in &self.grid.v_walls {
85            h.bool(wall);
86        }
87        for tile in &self.grid.tiles {
88            match *tile {
89                TileKind::Empty => h.u8(0),
90                TileKind::Rock => h.u8(1),
91                TileKind::Castle(owner) => {
92                    h.u8(2);
93                    h.u8(owner);
94                }
95                TileKind::Spawner(s) => {
96                    h.u8(3);
97                    h.u8(s.dir.id());
98                    h.u32(s.period);
99                }
100                TileKind::Turnstile { next_right } => {
101                    h.u8(4);
102                    h.bool(next_right);
103                }
104                TileKind::Kelp => h.u8(5),
105                TileKind::Pool => h.u8(6),
106            }
107        }
108        for slot in &self.signposts {
109            match slot {
110                None => h.u8(0),
111                Some(sp) => {
112                    h.u8(1);
113                    h.u8(sp.dir.id());
114                    h.u8(sp.owner);
115                    h.u8(match sp.health {
116                        SignpostHealth::Full => 0,
117                        SignpostHealth::Worn => 1,
118                    });
119                    h.u64(sp.seq);
120                    h.u64(sp.placed);
121                }
122            }
123        }
124    }
125
126    /// Everything alive, the scores they are chasing, and the PRNG that
127    /// drives them.
128    fn hash_creatures(&self, h: &mut Fnv) {
129        for crab in &self.crabs {
130            h.u32(crab.id);
131            h.u16(crab.tile);
132            h.u8(crab.dir.id());
133            h.u16(crab.progress);
134            h.u16(crab.prev.tile);
135            h.u16(crab.prev.progress);
136            h.u8(crab.prev.dir.id());
137            h.u8(crab.handed.id());
138            h.u8(crab.kind.id());
139        }
140        for &score in &self.scores {
141            h.u32(score);
142        }
143        let (state, inc) = self.rng.hash_state();
144        h.u64(state);
145        h.u64(inc);
146        for gull in &self.gulls {
147            h.u32(gull.id);
148            h.u16(gull.tile);
149            h.u8(gull.dir.id());
150            h.u16(gull.progress);
151            h.u16(gull.prev.tile);
152            h.u16(gull.prev.progress);
153            h.u8(gull.prev.dir.id());
154            h.u8(gull.handed.id());
155            match gull.state {
156                GullState::Walking => h.u8(0),
157                GullState::Flying { remaining } => {
158                    h.u8(1);
159                    h.u8(remaining);
160                }
161            }
162            h.u32(gull.takeoff_in);
163        }
164        h.u32(self.next_gull_id);
165    }
166
167    /// The round's own state: the tide, the active tide effects, and the
168    /// counters that outlive a single creature.
169    fn hash_round(&self, h: &mut Fnv) {
170        h.u32(self.rules.gull_period);
171        match self.rules.round_length {
172            None => h.u8(0),
173            Some(len) => {
174                h.u8(1);
175                h.u32(len);
176            }
177        }
178        match self.lure {
179            None => h.u8(0),
180            Some((p, t)) => {
181                h.u8(1);
182                h.u8(p);
183                h.u32(t);
184            }
185        }
186        h.u32(self.golden_banked);
187        h.bool(self.rules.castle_raids);
188        h.bool(self.events_enabled);
189        h.bool(self.wrap);
190        match self.mania {
191            None => h.u8(0),
192            Some((Mania::Crab, t)) => {
193                h.u8(1);
194                h.u32(t);
195            }
196            Some((Mania::Gull, t)) => {
197                h.u8(2);
198                h.u32(t);
199            }
200        }
201        match self.tempo {
202            None => h.u8(0),
203            Some((tempo, t)) => {
204                h.u8(match tempo {
205                    Tempo::Fast => 1,
206                    Tempo::Slow => 2,
207                });
208                h.u32(t);
209            }
210        }
211        match self.last_event {
212            None => h.u8(0),
213            Some((event, tick)) => {
214                // ALL's position, not the declaration discriminant: every
215                // stable meaning of an event (roulette, snapshot, strings)
216                // reads off ALL, and the hash follows the same order.
217                h.u8(1 + event.index() as u8);
218                h.u64(tick);
219            }
220        }
221        h.u64(self.tick);
222        h.u64(self.signpost_seq);
223        h.u32(self.next_crab_id);
224        h.u32(self.crabs_banked);
225        h.u8(self.rules.signpost_cap);
226        h.u8(match self.rules.cap_policy {
227            CapPolicy::Evict => 0,
228            CapPolicy::Reject => 1,
229        });
230        // The quiet spell after a lure. Appended here rather than slotted
231        // in beside `lure`, where it belongs by meaning, because field
232        // order is the format and only the tail is safe to grow.
233        h.u32(self.lure_cooldown);
234        h.u32(self.event_cooldown);
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use crate::sim::{Board, CapPolicy, CrabKind, Direction, Handedness, Spawner, TileKind};
241
242    /// Every externally-reachable mutator must move the hash: a field that
243    /// escapes `state_hash` is a silent online-desync blind spot.
244    #[test]
245    fn every_mutator_changes_the_hash() {
246        let base = || Board::new(8, 6, 42);
247        type Mutation = (&'static str, Box<dyn Fn(&mut Board)>);
248        let mutations: Vec<Mutation> = vec![
249            ("set_tile", Box::new(|b| b.set_tile(2, 2, TileKind::Rock))),
250            (
251                "set_spawner",
252                Box::new(|b| {
253                    b.set_tile(
254                        3,
255                        3,
256                        TileKind::Spawner(Spawner {
257                            dir: Direction::Right,
258                            period: 40,
259                        }),
260                    );
261                }),
262            ),
263            (
264                "set_wall",
265                Box::new(|b| b.set_wall(1, 1, Direction::Up, true)),
266            ),
267            ("set_wrap", Box::new(|b| b.set_wrap(true))),
268            ("set_score", Box::new(|b| b.set_score(0, 7))),
269            ("set_gull_period", Box::new(|b| b.set_gull_period(99))),
270            (
271                "set_round_length",
272                Box::new(|b| b.set_round_length(Some(500))),
273            ),
274            (
275                "set_events_enabled",
276                Box::new(|b| b.set_events_enabled(true)),
277            ),
278            ("set_castle_raids", Box::new(|b| b.set_castle_raids(false))),
279            (
280                "set_signpost_rule",
281                Box::new(|b| b.set_signpost_rule(5, CapPolicy::Reject)),
282            ),
283            (
284                "place_signpost",
285                Box::new(|b| {
286                    b.place_signpost(0, 4, 4, Direction::Left);
287                }),
288            ),
289            (
290                "spawn_crab",
291                Box::new(|b| {
292                    b.spawn_crab(2, 3, Direction::Up, Handedness::Left, CrabKind::Common);
293                }),
294            ),
295            (
296                "spawn_gull",
297                Box::new(|b| b.spawn_gull(5, 2, Direction::Down)),
298            ),
299            ("tick_idle", Box::new(Board::tick_idle)),
300        ];
301        let clean = base().state_hash();
302        for (name, mutate) in mutations {
303            let mut board = base();
304            mutate(&mut board);
305            assert_ne!(board.state_hash(), clean, "{name} did not move state_hash");
306        }
307    }
308
309    /// State reachable only by ticking, which the mutator census above
310    /// cannot see. The lure cooldown decides whether banking a molt starts
311    /// a lure at all, and while it went unhashed two boards could sit at
312    /// the same fingerprint and then play the round differently: the one
313    /// failure the hash exists to catch.
314    #[test]
315    fn the_lure_cooldown_is_part_of_the_fingerprint() {
316        use crate::sim::{MAX_PLAYERS, PlayerAction};
317        let armed = |cooldown: u32| {
318            let mut board = Board::new(6, 5, 1);
319            board.set_tile(3, 2, TileKind::Castle(0));
320            board.lure_cooldown = cooldown;
321            board.spawn_crab(2, 2, Direction::Right, Handedness::Right, CrabKind::Molting);
322            board
323        };
324        let (mut quiet, mut ready) = (armed(200), armed(0));
325        assert_ne!(
326            quiet.state_hash(),
327            ready.state_hash(),
328            "a cooldown that changes the round has to change the hash"
329        );
330        for board in [&mut quiet, &mut ready] {
331            for _ in 0..40 {
332                board.tick(&[PlayerAction::None; MAX_PLAYERS]);
333            }
334        }
335        assert!(quiet.lure().is_none(), "the quiet spell swallows the lure");
336        assert!(ready.lure().is_some(), "a clear board starts one");
337    }
338
339    /// The same trap, one field along. `event_cooldown` decides whether
340    /// banking a Sparkling crab spins the roulette at all, so two boards
341    /// holding different ones play the round apart - and the census above
342    /// only forces a new field to be *named*, not hashed, which is exactly
343    /// how `lure_cooldown` slipped through in the first place. Removing the
344    /// `h.u32` for this field passes every other test in the suite.
345    #[test]
346    fn the_event_cooldown_is_part_of_the_fingerprint() {
347        use crate::sim::{MAX_PLAYERS, PlayerAction};
348        let armed = |cooldown: u32| {
349            let mut board = Board::new(6, 5, 1);
350            board.set_events_enabled(true);
351            board.set_tile(3, 2, TileKind::Castle(0));
352            board.event_cooldown = cooldown;
353            board.spawn_crab(
354                2,
355                2,
356                Direction::Right,
357                Handedness::Right,
358                CrabKind::Sparkling,
359            );
360            board
361        };
362        let (mut quiet, mut ready) = (armed(200), armed(0));
363        assert_ne!(
364            quiet.state_hash(),
365            ready.state_hash(),
366            "a cooldown that changes the round has to change the hash"
367        );
368        for board in [&mut quiet, &mut ready] {
369            for _ in 0..40 {
370                board.tick(&[PlayerAction::None; MAX_PLAYERS]);
371            }
372        }
373        assert!(
374            quiet.last_event().is_none(),
375            "the cooldown swallows the spin"
376        );
377        assert!(
378            ready.last_event().is_some(),
379            "a clear board spins the wheel"
380        );
381    }
382}