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            width: _,
20            height: _,
21            h_walls: _,
22            v_walls: _,
23            tiles: _,
24            signposts: _,
25            // hash_creatures
26            crabs: _,
27            scores: _,
28            rng: _,
29            gulls: _,
30            next_gull_id: _,
31            // hash_round
32            tick: _,
33            signpost_seq: _,
34            next_crab_id: _,
35            signpost_cap: _,
36            cap_policy: _,
37            gull_period: _,
38            round_length: _,
39            lure: _,
40            lure_cooldown: _,
41            crabs_banked: _,
42            golden_banked: _,
43            events_enabled: _,
44            mania: _,
45            tempo: _,
46            last_event: _,
47            wrap: _,
48            // Outside the fingerprint, each for a reason of its own: the
49            // construction seed is dead once the PRNG state (which *is*
50            // hashed) has been derived from it, and the event queue is
51            // filled and drained inside a single tick, so it is always
52            // empty by the time anyone hashes.
53            seed: _,
54            event_queue: _,
55        } = self;
56        let mut h = Fnv::new();
57        self.hash_terrain(&mut h);
58        self.hash_creatures(&mut h);
59        self.hash_round(&mut h);
60        h.finish()
61    }
62
63    /// The board itself: size, walls, tiles, signposts. Field order is part
64    /// of the contract: never reorder these, only append.
65    fn hash_terrain(&self, h: &mut Fnv) {
66        h.u8(self.width);
67        h.u8(self.height);
68        for &wall in &self.h_walls {
69            h.bool(wall);
70        }
71        for &wall in &self.v_walls {
72            h.bool(wall);
73        }
74        for tile in &self.tiles {
75            match *tile {
76                TileKind::Empty => h.u8(0),
77                TileKind::Rock => h.u8(1),
78                TileKind::Castle(owner) => {
79                    h.u8(2);
80                    h.u8(owner);
81                }
82                TileKind::Spawner(s) => {
83                    h.u8(3);
84                    h.u8(s.dir.id());
85                    h.u32(s.period);
86                }
87                TileKind::Turnstile { next_right } => {
88                    h.u8(4);
89                    h.bool(next_right);
90                }
91                TileKind::Kelp => h.u8(5),
92                TileKind::Pool => h.u8(6),
93            }
94        }
95        for slot in &self.signposts {
96            match slot {
97                None => h.u8(0),
98                Some(sp) => {
99                    h.u8(1);
100                    h.u8(sp.dir.id());
101                    h.u8(sp.owner);
102                    h.u8(match sp.health {
103                        SignpostHealth::Full => 0,
104                        SignpostHealth::Worn => 1,
105                    });
106                    h.u64(sp.seq);
107                    h.u64(sp.placed);
108                }
109            }
110        }
111    }
112
113    /// Everything alive, the scores they are chasing, and the PRNG that
114    /// drives them.
115    fn hash_creatures(&self, h: &mut Fnv) {
116        for crab in &self.crabs {
117            h.u32(crab.id);
118            h.u16(crab.tile);
119            h.u8(crab.dir.id());
120            h.u16(crab.progress);
121            h.u16(crab.prev_tile);
122            h.u16(crab.prev_progress);
123            h.u8(crab.prev_dir.id());
124            h.u8(crab.handed.id());
125            h.u8(crab.kind.id());
126        }
127        for &score in &self.scores {
128            h.u32(score);
129        }
130        let (state, inc) = self.rng.hash_state();
131        h.u64(state);
132        h.u64(inc);
133        for gull in &self.gulls {
134            h.u32(gull.id);
135            h.u16(gull.tile);
136            h.u8(gull.dir.id());
137            h.u16(gull.progress);
138            h.u16(gull.prev_tile);
139            h.u16(gull.prev_progress);
140            h.u8(gull.prev_dir.id());
141            h.u8(gull.handed.id());
142            match gull.state {
143                GullState::Walking => h.u8(0),
144                GullState::Flying { remaining } => {
145                    h.u8(1);
146                    h.u8(remaining);
147                }
148            }
149            h.u32(gull.takeoff_in);
150        }
151        h.u32(self.next_gull_id);
152    }
153
154    /// The round's own state: the tide, the active tide effects, and the
155    /// counters that outlive a single creature.
156    fn hash_round(&self, h: &mut Fnv) {
157        h.u32(self.gull_period);
158        match self.round_length {
159            None => h.u8(0),
160            Some(len) => {
161                h.u8(1);
162                h.u32(len);
163            }
164        }
165        match self.lure {
166            None => h.u8(0),
167            Some((p, t)) => {
168                h.u8(1);
169                h.u8(p);
170                h.u32(t);
171            }
172        }
173        h.u32(self.golden_banked);
174        h.bool(self.events_enabled);
175        h.bool(self.wrap);
176        match self.mania {
177            None => h.u8(0),
178            Some((Mania::Crab, t)) => {
179                h.u8(1);
180                h.u32(t);
181            }
182            Some((Mania::Gull, t)) => {
183                h.u8(2);
184                h.u32(t);
185            }
186        }
187        match self.tempo {
188            None => h.u8(0),
189            Some((tempo, t)) => {
190                h.u8(match tempo {
191                    Tempo::Fast => 1,
192                    Tempo::Slow => 2,
193                });
194                h.u32(t);
195            }
196        }
197        match self.last_event {
198            None => h.u8(0),
199            Some((event, tick)) => {
200                // ALL's position, not the declaration discriminant: every
201                // stable meaning of an event (roulette, snapshot, strings)
202                // reads off ALL, and the hash follows the same order.
203                h.u8(1 + event.index() as u8);
204                h.u64(tick);
205            }
206        }
207        h.u64(self.tick);
208        h.u64(self.signpost_seq);
209        h.u32(self.next_crab_id);
210        h.u32(self.crabs_banked);
211        h.u8(self.signpost_cap);
212        h.u8(match self.cap_policy {
213            CapPolicy::Evict => 0,
214            CapPolicy::Reject => 1,
215        });
216        // The quiet spell after a lure. Appended here rather than slotted
217        // in beside `lure`, where it belongs by meaning, because field
218        // order is the format and only the tail is safe to grow.
219        h.u32(self.lure_cooldown);
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use crate::sim::{Board, CapPolicy, CrabKind, Direction, Handedness, Spawner, TileKind};
226
227    /// Every externally-reachable mutator must move the hash: a field that
228    /// escapes `state_hash` is a silent online-desync blind spot.
229    #[test]
230    fn every_mutator_changes_the_hash() {
231        let base = || Board::new(8, 6, 42);
232        type Mutation = (&'static str, Box<dyn Fn(&mut Board)>);
233        let mutations: Vec<Mutation> = vec![
234            ("set_tile", Box::new(|b| b.set_tile(2, 2, TileKind::Rock))),
235            (
236                "set_spawner",
237                Box::new(|b| {
238                    b.set_tile(
239                        3,
240                        3,
241                        TileKind::Spawner(Spawner {
242                            dir: Direction::Right,
243                            period: 40,
244                        }),
245                    );
246                }),
247            ),
248            (
249                "set_wall",
250                Box::new(|b| b.set_wall(1, 1, Direction::Up, true)),
251            ),
252            ("set_wrap", Box::new(|b| b.set_wrap(true))),
253            ("set_score", Box::new(|b| b.set_score(0, 7))),
254            ("set_gull_period", Box::new(|b| b.set_gull_period(99))),
255            (
256                "set_round_length",
257                Box::new(|b| b.set_round_length(Some(500))),
258            ),
259            (
260                "set_events_enabled",
261                Box::new(|b| b.set_events_enabled(true)),
262            ),
263            (
264                "set_signpost_rule",
265                Box::new(|b| b.set_signpost_rule(5, CapPolicy::Reject)),
266            ),
267            (
268                "place_signpost",
269                Box::new(|b| {
270                    b.place_signpost(0, 4, 4, Direction::Left);
271                }),
272            ),
273            (
274                "spawn_crab",
275                Box::new(|b| {
276                    b.spawn_crab(2, 3, Direction::Up, Handedness::Left, CrabKind::Common);
277                }),
278            ),
279            (
280                "spawn_gull",
281                Box::new(|b| b.spawn_gull(5, 2, Direction::Down)),
282            ),
283            ("tick_idle", Box::new(Board::tick_idle)),
284        ];
285        let clean = base().state_hash();
286        for (name, mutate) in mutations {
287            let mut board = base();
288            mutate(&mut board);
289            assert_ne!(board.state_hash(), clean, "{name} did not move state_hash");
290        }
291    }
292
293    /// State reachable only by ticking, which the mutator census above
294    /// cannot see. The lure cooldown decides whether banking a molt starts
295    /// a lure at all, and while it went unhashed two boards could sit at
296    /// the same fingerprint and then play the round differently: the one
297    /// failure the hash exists to catch.
298    #[test]
299    fn the_lure_cooldown_is_part_of_the_fingerprint() {
300        use crate::sim::{MAX_PLAYERS, PlayerAction};
301        let armed = |cooldown: u32| {
302            let mut board = Board::new(6, 5, 1);
303            board.set_tile(3, 2, TileKind::Castle(0));
304            board.lure_cooldown = cooldown;
305            board.spawn_crab(2, 2, Direction::Right, Handedness::Right, CrabKind::Molting);
306            board
307        };
308        let (mut quiet, mut ready) = (armed(200), armed(0));
309        assert_ne!(
310            quiet.state_hash(),
311            ready.state_hash(),
312            "a cooldown that changes the round has to change the hash"
313        );
314        for board in [&mut quiet, &mut ready] {
315            for _ in 0..40 {
316                board.tick(&[PlayerAction::None; MAX_PLAYERS]);
317            }
318        }
319        assert!(quiet.lure().is_none(), "the quiet spell swallows the lure");
320        assert!(ready.lure().is_some(), "a clear board starts one");
321    }
322}