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    /// Record who was playing. Called once, at the moment the round starts,
48    /// because that is when the shell knows.
49    pub fn named(mut self, names: [String; MAX_PLAYERS]) -> Replay {
50        self.names = names;
51        self
52    }
53
54    pub fn record(&mut self, actions: [PlayerAction; MAX_PLAYERS]) {
55        self.inputs.push(actions);
56    }
57
58    /// Rebuild the starting board and run every recorded tick; returns the
59    /// final board.
60    pub fn playback(&self) -> Board {
61        let mut board = self.level.board();
62        for actions in &self.inputs {
63            board.tick(actions);
64        }
65        board
66    }
67
68    pub fn to_text(&self) -> String {
69        use std::fmt::Write;
70        let mut out = String::new();
71        let _ = writeln!(out, "{HEADER}");
72        // Written only when there is something to say, so a replay of an
73        // unnamed local round is byte-for-byte what it always was.
74        if self.names.iter().any(|n| !n.is_empty()) {
75            let _ = writeln!(out, "{NAMES_MARK} {}", self.names.join("|"));
76        }
77        out.push_str(&self.level.to_text());
78        let _ = writeln!(out, "{INPUTS_MARK}");
79        for actions in &self.inputs {
80            if actions.iter().all(|a| matches!(a, PlayerAction::None)) {
81                out.push_str(".\n");
82                continue;
83            }
84            for action in actions {
85                let [a, b, c] = encode_action(*action);
86                let _ = write!(out, "{a:02x}{b:02x}{c:02x}");
87            }
88            out.push('\n');
89        }
90        out
91    }
92
93    pub fn parse(text: &str) -> Result<Replay, String> {
94        let rest = text
95            .strip_prefix(HEADER)
96            .ok_or("not a replay-v1 file")?
97            .trim_start_matches(['\r', '\n']);
98        // A file written before names were kept simply has no such line,
99        // and reads exactly as it always did.
100        let (names, rest) = match rest.strip_prefix(NAMES_MARK) {
101            Some(after) => {
102                let (line, rest) = after.split_once('\n').unwrap_or((after, ""));
103                let mut names: [String; MAX_PLAYERS] = Default::default();
104                for (slot, name) in names.iter_mut().zip(line.trim().split('|')) {
105                    *slot = name.trim().to_string();
106                }
107                (names, rest)
108            }
109            None => (Default::default(), rest),
110        };
111        let (level_text, input_text) = rest
112            .split_once(INPUTS_MARK)
113            .ok_or("missing inputs: section")?;
114        let level = Level::parse(level_text)?;
115        let mut inputs = Vec::new();
116        for line in input_text.lines() {
117            let line = line.trim();
118            if line.is_empty() {
119                continue;
120            }
121            if line == "." {
122                inputs.push([PlayerAction::None; MAX_PLAYERS]);
123                continue;
124            }
125            // The length is in bytes and the slicing below is too, so a
126            // corrupt file with the right byte count but a multi-byte
127            // character in it would split one and panic. Hex is ASCII.
128            if line.len() != MAX_PLAYERS * 6 || !line.is_ascii() {
129                return Err(format!("bad input line {line:?}"));
130            }
131            let mut actions = [PlayerAction::None; MAX_PLAYERS];
132            for (i, action) in actions.iter_mut().enumerate() {
133                let hex = &line[i * 6..i * 6 + 6];
134                let a = u8::from_str_radix(&hex[0..2], 16).map_err(|e| e.to_string())?;
135                let b = u8::from_str_radix(&hex[2..4], 16).map_err(|e| e.to_string())?;
136                let c = u8::from_str_radix(&hex[4..6], 16).map_err(|e| e.to_string())?;
137                *action = decode_action([a, b, c]);
138            }
139            inputs.push(actions);
140        }
141        Ok(Replay {
142            level,
143            inputs,
144            names,
145        })
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    /// Who was playing survives the trip, and a file written before names
154    /// were kept still reads.
155    #[test]
156    fn a_replay_remembers_who_was_playing() {
157        let level = Level::parse(
158            "name: T\nposts: 1\ncrab: 0,0 R R common\nmap:\n\
159             +-+-+\n|. 0|\n+ + +\n|. .|\n+-+-+\n",
160        )
161        .expect("level");
162        let mut replay = Replay::new(level).named([
163            "Anna".into(),
164            "Bo".into(),
165            String::new(),
166            String::new(),
167            String::new(),
168            String::new(),
169        ]);
170        replay.record([PlayerAction::None; MAX_PLAYERS]);
171        let text = replay.to_text();
172        let back = Replay::parse(&text).expect("round trip");
173        assert_eq!(back.names[0], "Anna");
174        assert_eq!(back.names[1], "Bo");
175        assert_eq!(back.names[2], "", "a seat nobody named stays unnamed");
176        assert_eq!(back.inputs.len(), 1, "and the round itself is untouched");
177
178        // An unnamed round writes no line at all, so the file is what it
179        // always was, and reads back the same way.
180        let plain = Replay::new(back.level.clone());
181        assert!(!plain.to_text().contains("names:"));
182        let plain_back = Replay::parse(&plain.to_text()).expect("round trip");
183        assert!(plain_back.names.iter().all(String::is_empty));
184    }
185
186    #[test]
187    fn parse_rejects_garbage() {
188        assert!(super::Replay::parse("not a replay").is_err());
189        assert!(super::Replay::parse("").is_err());
190    }
191
192    use crate::sim::direction::Direction;
193    use crate::sim::{CrabKind, Handedness, Spawner, TileKind};
194
195    fn arena() -> Level {
196        let mut board = Board::new(12, 9, 77);
197        board.set_tile(11, 4, TileKind::Castle(0));
198        board.set_tile(
199            0,
200            4,
201            TileKind::Spawner(Spawner {
202                dir: Direction::Right,
203                period: 30,
204            }),
205        );
206        board.spawn_crab(3, 3, Direction::Down, Handedness::Right, CrabKind::Giant);
207        board.spawn_gull(6, 6, Direction::Up);
208        board.set_gull_period(200);
209        Level::from_board("Arena", 3, board)
210    }
211
212    #[test]
213    fn replay_reproduces_the_match_bit_for_bit() {
214        let level = arena();
215        let mut replay = Replay::new(level.clone());
216        let mut live = level.board();
217        for t in 0u32..900 {
218            let mut actions = [PlayerAction::None; MAX_PLAYERS];
219            if t % 40 == 5 {
220                actions[0] = PlayerAction::Place {
221                    x: (t % 12) as u8,
222                    y: 5,
223                    dir: Direction::Up,
224                };
225            }
226            if t % 90 == 30 {
227                actions[1] = PlayerAction::Remove {
228                    x: (t % 12) as u8,
229                    y: 5,
230                };
231            }
232            replay.record(actions);
233            live.tick(&actions);
234        }
235        // Round-trip through the text format, then play back.
236        let text = replay.to_text();
237        let parsed = Replay::parse(&text).unwrap_or_else(|e| panic!("parse: {e}"));
238        assert_eq!(parsed.inputs.len(), 900);
239        let replayed = parsed.playback();
240        assert_eq!(
241            replayed.state_hash(),
242            live.state_hash(),
243            "replay must reproduce the live match exactly"
244        );
245    }
246}