Skip to main content

pinch_points/sim/
replay.rs

1//! Replays (spec §7.7): the starting level plus the per-tick input list.
2//! Playing it back through `Board::tick` reproduces the match bit-for-bit.
3//!
4//! Text format: a `replay-v1` header, an optional `names:` line, the level
5//! text, then `inputs:` with one
6//! line per tick: six hex digits per seat (full x, full y, op/dir byte), so
7//! `MAX_PLAYERS` of them, where a bare `.` is the common all-idle tick. A
8//! full byte per axis, like the wire (spec §7.6), so boards wider than 16
9//! tiles replay exactly.
10
11use crate::sim::board::{Board, MAX_PLAYERS, PlayerAction};
12use crate::sim::level::Level;
13// One codec for the 3-byte action, shared with the wire: a replay is the
14// same bytes the lockstep carries, so widening one widens both.
15use crate::sim::net::{decode_action, encode_action};
16
17#[derive(Clone, Debug)]
18pub struct Replay {
19    pub level: Level,
20    pub inputs: Vec<[PlayerAction; MAX_PLAYERS]>,
21    /// What each seat was called when the round was played, empty for a
22    /// seat that was never named.
23    ///
24    /// Not derivable from anything else in the file, and not the watcher's
25    /// business to supply: a round played online was played by whoever the
26    /// table agreed on, and a replay that fell back to the local couch
27    /// names put this machine's P1 on somebody else's crabs.
28    pub names: [String; MAX_PLAYERS],
29}
30
31const HEADER: &str = "replay-v1";
32const INPUTS_MARK: &str = "inputs:";
33/// Seat names, one line, `|` between them. Read before the level text
34/// rather than inside it: the level format knows nothing about who was
35/// holding the controller, and should not have to.
36const NAMES_MARK: &str = "names:";
37
38impl Replay {
39    pub fn new(level: Level) -> Replay {
40        Replay {
41            level,
42            inputs: Vec::new(),
43            names: Default::default(),
44        }
45    }
46
47    pub fn record(&mut self, actions: [PlayerAction; MAX_PLAYERS]) {
48        self.inputs.push(actions);
49    }
50
51    /// Rebuild the starting board and run every recorded tick; returns the
52    /// final board.
53    pub fn playback(&self) -> Board {
54        let mut board = self.level.board();
55        for actions in &self.inputs {
56            board.tick(actions);
57        }
58        board
59    }
60
61    pub fn to_text(&self) -> String {
62        use std::fmt::Write;
63        let mut out = String::new();
64        let _ = writeln!(out, "{HEADER}");
65        // Written only when there is something to say, so a replay of an
66        // unnamed local round is byte-for-byte what it always was.
67        if self.names.iter().any(|n| !n.is_empty()) {
68            let _ = writeln!(out, "{NAMES_MARK} {}", self.names.join("|"));
69        }
70        out.push_str(&self.level.to_text());
71        let _ = writeln!(out, "{INPUTS_MARK}");
72        for actions in &self.inputs {
73            if actions.iter().all(|a| matches!(a, PlayerAction::None)) {
74                out.push_str(".\n");
75                continue;
76            }
77            for action in actions {
78                let [a, b, c] = encode_action(*action);
79                let _ = write!(out, "{a:02x}{b:02x}{c:02x}");
80            }
81            out.push('\n');
82        }
83        out
84    }
85
86    pub fn parse(text: &str) -> Result<Replay, String> {
87        let rest = text
88            .strip_prefix(HEADER)
89            .ok_or("not a replay-v1 file")?
90            .trim_start_matches(['\r', '\n']);
91        // A file written before names were kept simply has no such line,
92        // and reads exactly as it always did.
93        let (names, rest) = match rest.strip_prefix(NAMES_MARK) {
94            Some(after) => {
95                let (line, rest) = after.split_once('\n').unwrap_or((after, ""));
96                let mut names: [String; MAX_PLAYERS] = Default::default();
97                for (slot, name) in names.iter_mut().zip(line.trim().split('|')) {
98                    *slot = name.trim().to_string();
99                }
100                (names, rest)
101            }
102            None => (Default::default(), rest),
103        };
104        let (level_text, input_text) = rest
105            .split_once(INPUTS_MARK)
106            .ok_or("missing inputs: section")?;
107        let level = Level::parse(level_text)?;
108        let mut inputs = Vec::new();
109        for line in input_text.lines() {
110            let line = line.trim();
111            if line.is_empty() {
112                continue;
113            }
114            if line == "." {
115                inputs.push([PlayerAction::None; MAX_PLAYERS]);
116                continue;
117            }
118            // The length is in bytes and the slicing below is too, so a
119            // corrupt file with the right byte count but a multi-byte
120            // character in it would split one and panic. Hex is ASCII.
121            if line.len() != MAX_PLAYERS * 6 || !line.is_ascii() {
122                return Err(format!("bad input line {line:?}"));
123            }
124            let mut actions = [PlayerAction::None; MAX_PLAYERS];
125            for (i, action) in actions.iter_mut().enumerate() {
126                let hex = &line[i * 6..i * 6 + 6];
127                let a = u8::from_str_radix(&hex[0..2], 16).map_err(|e| e.to_string())?;
128                let b = u8::from_str_radix(&hex[2..4], 16).map_err(|e| e.to_string())?;
129                let c = u8::from_str_radix(&hex[4..6], 16).map_err(|e| e.to_string())?;
130                *action = decode_action([a, b, c]);
131            }
132            inputs.push(actions);
133        }
134        Ok(Replay {
135            level,
136            inputs,
137            names,
138        })
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    /// Who was playing survives the trip, and a file written before names
147    /// were kept still reads.
148    #[test]
149    fn a_replay_remembers_who_was_playing() {
150        let level = Level::parse(
151            "name: T\nposts: 1\ncrab: 0,0 R R common\nmap:\n\
152             +-+-+\n|. 0|\n+ + +\n|. .|\n+-+-+\n",
153        )
154        .expect("level");
155        let mut replay = Replay::new(level);
156        replay.names[0] = "Anna".into();
157        replay.names[1] = "Bo".into();
158        replay.record([PlayerAction::None; MAX_PLAYERS]);
159        let text = replay.to_text();
160        let back = Replay::parse(&text).expect("round trip");
161        assert_eq!(back.names[0], "Anna");
162        assert_eq!(back.names[1], "Bo");
163        assert_eq!(back.names[2], "", "a seat nobody named stays unnamed");
164        assert_eq!(back.inputs.len(), 1, "and the round itself is untouched");
165
166        // An unnamed round writes no line at all, so the file is what it
167        // always was, and reads back the same way.
168        let plain = Replay::new(back.level.clone());
169        assert!(!plain.to_text().contains("names:"));
170        let plain_back = Replay::parse(&plain.to_text()).expect("round trip");
171        assert!(plain_back.names.iter().all(String::is_empty));
172    }
173
174    #[test]
175    fn parse_rejects_garbage() {
176        assert!(super::Replay::parse("not a replay").is_err());
177        assert!(super::Replay::parse("").is_err());
178    }
179
180    use crate::sim::direction::Direction;
181    use crate::sim::{CrabKind, Handedness, Spawner, TileKind};
182
183    fn arena() -> Level {
184        let mut board = Board::new(12, 9, 77);
185        board.set_tile(11, 4, TileKind::Castle(0));
186        board.set_tile(
187            0,
188            4,
189            TileKind::Spawner(Spawner {
190                dir: Direction::Right,
191                period: 30,
192            }),
193        );
194        board.spawn_crab(3, 3, Direction::Down, Handedness::Right, CrabKind::Giant);
195        board.spawn_gull(6, 6, Direction::Up);
196        board.set_gull_period(200);
197        Level::from_board("Arena", 3, board)
198    }
199
200    #[test]
201    fn replay_reproduces_the_match_bit_for_bit() {
202        let level = arena();
203        let mut replay = Replay::new(level.clone());
204        let mut live = level.board();
205        for t in 0u32..900 {
206            let mut actions = [PlayerAction::None; MAX_PLAYERS];
207            if t % 40 == 5 {
208                actions[0] = PlayerAction::Place {
209                    x: (t % 12) as u8,
210                    y: 5,
211                    dir: Direction::Up,
212                };
213            }
214            if t % 90 == 30 {
215                actions[1] = PlayerAction::Remove {
216                    x: (t % 12) as u8,
217                    y: 5,
218                };
219            }
220            replay.record(actions);
221            live.tick(&actions);
222        }
223        // Round-trip through the text format, then play back.
224        let text = replay.to_text();
225        let parsed = Replay::parse(&text).unwrap_or_else(|e| panic!("parse: {e}"));
226        assert_eq!(parsed.inputs.len(), 900);
227        let replayed = parsed.playback();
228        assert_eq!(
229            replayed.state_hash(),
230            live.state_hash(),
231            "replay must reproduce the live match exactly"
232        );
233    }
234}