use crate::sim::board::{Board, MAX_PLAYERS, PlayerAction};
use crate::sim::level::Level;
use crate::sim::net::{decode_action, encode_action};
#[derive(Clone, Debug)]
pub struct Replay {
pub level: Level,
pub inputs: Vec<[PlayerAction; MAX_PLAYERS]>,
pub names: [String; MAX_PLAYERS],
pub kinds: [SeatKind; MAX_PLAYERS],
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum SeatKind {
#[default]
Human,
Ai,
Bot,
}
impl SeatKind {
pub fn token(self) -> &'static str {
match self {
SeatKind::Human => "human",
SeatKind::Ai => "ai",
SeatKind::Bot => "bot",
}
}
pub fn from_token(token: &str) -> Option<SeatKind> {
match token {
"human" => Some(SeatKind::Human),
"ai" => Some(SeatKind::Ai),
"bot" => Some(SeatKind::Bot),
_ => None,
}
}
}
const HEADER: &str = "replay-v2";
const INPUTS_MARK: &str = "inputs:";
const NAMES_MARK: &str = "names:";
const KINDS_MARK: &str = "kinds:";
impl Replay {
pub fn new(level: Level) -> Replay {
Replay {
level,
inputs: Vec::new(),
names: Default::default(),
kinds: Default::default(),
}
}
pub fn record(&mut self, actions: [PlayerAction; MAX_PLAYERS]) {
self.inputs.push(actions);
}
pub fn playback(&self) -> Board {
let mut board = self.level.board();
for actions in &self.inputs {
board.tick(actions);
}
board
}
pub fn to_text(&self) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(out, "{HEADER}");
if self.names.iter().any(|n| !n.is_empty()) {
let names: Vec<String> = self
.names
.iter()
.map(|n| {
n.chars()
.map(|c| match c {
'|' => '/',
c if c.is_control() => ' ',
c => c,
})
.collect()
})
.collect();
let _ = writeln!(out, "{NAMES_MARK} {}", names.join("|"));
}
if self.kinds.iter().any(|k| *k != SeatKind::Human) {
let kinds: Vec<&str> = self.kinds.iter().map(|k| k.token()).collect();
let _ = writeln!(out, "{KINDS_MARK} {}", kinds.join("|"));
}
out.push_str(&self.level.to_text());
let _ = writeln!(out, "{INPUTS_MARK}");
for actions in &self.inputs {
if actions.iter().all(|a| matches!(a, PlayerAction::None)) {
out.push_str(".\n");
continue;
}
for action in actions {
let [a, b, c] = encode_action(*action);
let _ = write!(out, "{a:02x}{b:02x}{c:02x}");
}
out.push('\n');
}
out
}
pub fn parse(text: &str) -> Result<Replay, String> {
let rest = text
.strip_prefix(HEADER)
.ok_or("not a replay this build can play")?
.trim_start_matches(['\r', '\n']);
let (names, rest) = match rest.strip_prefix(NAMES_MARK) {
Some(after) => {
let (line, rest) = after.split_once('\n').unwrap_or((after, ""));
let mut names: [String; MAX_PLAYERS] = Default::default();
for (slot, name) in names.iter_mut().zip(line.trim().split('|')) {
*slot = name.trim().to_string();
}
(names, rest)
}
None => (Default::default(), rest),
};
let (kinds, rest) = match rest.strip_prefix(KINDS_MARK) {
Some(after) => {
let (line, rest) = after.split_once('\n').unwrap_or((after, ""));
let mut kinds = [SeatKind::Human; MAX_PLAYERS];
for (slot, kind) in kinds.iter_mut().zip(line.trim().split('|')) {
*slot = SeatKind::from_token(kind.trim())
.ok_or_else(|| format!("no seat kind {:?}", kind.trim()))?;
}
(kinds, rest)
}
None => (Default::default(), rest),
};
let (level_text, input_text) = rest
.split_once(INPUTS_MARK)
.ok_or("missing inputs: section")?;
let level = Level::parse(level_text)?;
let mut inputs = Vec::new();
for line in input_text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if line == "." {
inputs.push([PlayerAction::None; MAX_PLAYERS]);
continue;
}
if line.len() != MAX_PLAYERS * 6 || !line.is_ascii() {
return Err(format!("bad input line {line:?}"));
}
let mut actions = [PlayerAction::None; MAX_PLAYERS];
for (i, action) in actions.iter_mut().enumerate() {
let hex = &line[i * 6..i * 6 + 6];
let a = u8::from_str_radix(&hex[0..2], 16).map_err(|e| e.to_string())?;
let b = u8::from_str_radix(&hex[2..4], 16).map_err(|e| e.to_string())?;
let c = u8::from_str_radix(&hex[4..6], 16).map_err(|e| e.to_string())?;
*action = decode_action([a, b, c]);
}
inputs.push(actions);
}
Ok(Replay {
level,
inputs,
names,
kinds,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_replay_remembers_who_was_playing() {
let level = Level::parse(
"name: T\nposts: 1\ncrab: 0,0 R R common\nmap:\n\
+-+-+\n|. 0|\n+ + +\n|. .|\n+-+-+\n",
)
.expect("level");
let mut replay = Replay::new(level);
replay.names[0] = "Anna".into();
replay.names[1] = "Bo".into();
replay.record([PlayerAction::None; MAX_PLAYERS]);
let text = replay.to_text();
let back = Replay::parse(&text).expect("round trip");
assert_eq!(back.names[0], "Anna");
assert_eq!(back.names[1], "Bo");
assert_eq!(back.names[2], "", "a seat nobody named stays unnamed");
assert_eq!(back.inputs.len(), 1, "and the round itself is untouched");
let plain = Replay::new(back.level.clone());
assert!(!plain.to_text().contains("names:"));
let plain_back = Replay::parse(&plain.to_text()).expect("round trip");
assert!(plain_back.names.iter().all(String::is_empty));
}
#[test]
fn a_name_cannot_break_the_names_line() {
let mut replay = Replay::new(arena());
replay.names[0] = "a|b\nc".into();
replay.names[1] = "Bo".into();
replay.record([PlayerAction::None; MAX_PLAYERS]);
let back = Replay::parse(&replay.to_text()).expect("round trip");
assert!(!back.names[0].contains('|'), "{:?}", back.names[0]);
assert!(back.names[0].starts_with('a'), "{:?}", back.names[0]);
assert_eq!(back.names[1], "Bo");
assert_eq!(back.inputs.len(), 1);
}
#[test]
fn a_replay_remembers_what_held_each_seat() {
let level = arena();
let mut replay = Replay::new(level.clone());
replay.names[0] = "Greedy".into();
replay.kinds[0] = SeatKind::Bot;
replay.kinds[1] = SeatKind::Ai;
replay.record([PlayerAction::None; MAX_PLAYERS]);
let text = replay.to_text();
assert!(text.contains("kinds: bot|ai|human"));
let back = Replay::parse(&text).expect("round trip");
assert_eq!(
back.kinds[..3],
[SeatKind::Bot, SeatKind::Ai, SeatKind::Human]
);
assert!(!Replay::new(level).to_text().contains("kinds:"));
let level_part = text
.split_once("kinds:")
.map(|(_, rest)| rest)
.expect("kinds");
assert!(Level::parse(&format!("kinds:{level_part}")).is_err());
assert!(Replay::parse(&text.replace("kinds: bot", "kinds: robot")).is_err());
}
#[test]
fn parse_rejects_garbage() {
assert!(Replay::parse("not a replay").is_err());
assert!(Replay::parse("").is_err());
}
use crate::sim::direction::Direction;
use crate::sim::{CrabKind, Handedness, Spawner, TileKind};
fn arena() -> Level {
let mut board = Board::new(12, 9, 77);
board.set_tile(11, 4, TileKind::Castle(0));
board.set_tile(
0,
4,
TileKind::Spawner(Spawner {
dir: Direction::Right,
period: 30,
}),
);
board.spawn_crab(3, 3, Direction::Down, Handedness::Right, CrabKind::Giant);
board.spawn_gull(6, 6, Direction::Up);
board.set_gull_period(200);
Level::from_board("Arena", 3, board)
}
#[test]
fn replay_reproduces_the_match_bit_for_bit() {
let level = arena();
let mut replay = Replay::new(level.clone());
let mut live = level.board();
for t in 0u32..900 {
let mut actions = [PlayerAction::None; MAX_PLAYERS];
if t % 40 == 5 {
actions[0] = PlayerAction::Place {
x: (t % 12) as u8,
y: 5,
dir: Direction::Up,
};
}
if t % 90 == 30 {
actions[1] = PlayerAction::Remove {
x: (t % 12) as u8,
y: 5,
};
}
replay.record(actions);
live.tick(&actions);
}
let text = replay.to_text();
let parsed = Replay::parse(&text).unwrap_or_else(|e| panic!("parse: {e}"));
assert_eq!(parsed.inputs.len(), 900);
let replayed = parsed.playback();
assert_eq!(
replayed.state_hash(),
live.state_hash(),
"replay must reproduce the live match exactly"
);
}
}