pinch_points/sim/
replay.rs1use crate::sim::board::{Board, MAX_PLAYERS, PlayerAction};
12use crate::sim::level::Level;
13use 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 pub names: [String; MAX_PLAYERS],
29}
30
31const HEADER: &str = "replay-v1";
32const INPUTS_MARK: &str = "inputs:";
33const 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 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 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 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 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 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 #[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 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 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}