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 record(&mut self, actions: [PlayerAction; MAX_PLAYERS]) {
48 self.inputs.push(actions);
49 }
50
51 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 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 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 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 #[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 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 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}