use crate::sim::BotLevel;
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],
pub ai: [Option<BotLevel>; MAX_PLAYERS],
pub teams: u8,
pub scores: Option<[u32; 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_STEM: &str = "replay-v";
const VERSION: u32 = 3;
const OLDEST: u32 = 2;
const INPUTS_MARK: &str = "inputs:";
const NAMES_MARK: &str = "names:";
const KINDS_MARK: &str = "kinds:";
const AI_MARK: &str = "ai:";
const NO_AI: &str = "-";
const TEAMS_MARK: &str = "teams:";
const SCORES_MARK: &str = "scores:";
pub const NEEDS_NEWER: &str = "this replay needs a newer version of the game";
pub fn seat_name(raw: &str) -> String {
raw.chars()
.map(|c| match c {
'|' => '/',
c if c.is_control() => ' ',
c => c,
})
.collect::<String>()
.trim()
.to_string()
}
pub const MAX_TICKS: usize = 20 * 60 * crate::sim::board::TICKS_PER_SECOND as usize;
impl Replay {
pub fn new(level: Level) -> Replay {
Replay {
level,
inputs: Vec::new(),
names: Default::default(),
kinds: Default::default(),
ai: [None; MAX_PLAYERS],
teams: 0,
scores: None,
}
}
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_STEM}{VERSION}");
if self.names.iter().any(|n| !n.is_empty()) {
let names: Vec<String> = self.names.iter().map(|n| seat_name(n)).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("|"));
}
if self.ai.iter().any(Option::is_some) {
let ai: Vec<&str> = self
.ai
.iter()
.map(|level| level.map_or(NO_AI, BotLevel::token))
.collect();
let _ = writeln!(out, "{AI_MARK} {}", ai.join("|"));
}
if self.teams != 0 {
let _ = writeln!(out, "{TEAMS_MARK} {}", self.teams);
}
if let Some(scores) = &self.scores {
let scores: Vec<String> = scores.iter().map(u32::to_string).collect();
let _ = writeln!(out, "{SCORES_MARK} {}", scores.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 (mut replay, input_text) = Self::parse_head(text)?;
for line in input_text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if replay.inputs.len() == MAX_TICKS {
return Err(format!(
"longer than the {MAX_TICKS} ticks a round can last"
));
}
if line == "." {
replay.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]);
}
replay.inputs.push(actions);
}
Ok(replay)
}
pub fn head(text: &str) -> Result<Replay, String> {
Self::parse_head(text).map(|(head, _)| head)
}
fn parse_head(text: &str) -> Result<(Replay, &str), String> {
let (first, rest) = text.split_once('\n').unwrap_or((text, ""));
let version = first
.trim_end_matches('\r')
.strip_prefix(HEADER_STEM)
.and_then(|version| version.parse::<u32>().ok());
match version {
Some(version) if (OLDEST..=VERSION).contains(&version) => {}
Some(version) if version > VERSION => return Err(NEEDS_NEWER.to_string()),
Some(_) | None => return Err("not a replay this build can play".to_string()),
}
let rest = rest.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 = seat_name(name);
}
(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 (ai, rest) = match rest.strip_prefix(AI_MARK) {
Some(after) => {
let (line, rest) = after.split_once('\n').unwrap_or((after, ""));
let mut ai = [None; MAX_PLAYERS];
for (slot, token) in ai.iter_mut().zip(line.trim().split('|')) {
*slot = match token.trim() {
NO_AI => None,
token => Some(
BotLevel::from_token(token)
.ok_or_else(|| format!("no AI level {token:?}"))?,
),
};
}
(ai, rest)
}
None => ([None; MAX_PLAYERS], rest),
};
let (teams, rest) = match rest.strip_prefix(TEAMS_MARK) {
Some(after) => {
let (line, rest) = after.split_once('\n').unwrap_or((after, ""));
let teams = line
.trim()
.parse::<u8>()
.map_err(|_| format!("no scoring mode {:?}", line.trim()))?;
(teams, rest)
}
None => (0, rest),
};
let (scores, rest) = match rest.strip_prefix(SCORES_MARK) {
Some(after) => {
let (line, rest) = after.split_once('\n').unwrap_or((after, ""));
let mut scores = [0; MAX_PLAYERS];
for (slot, score) in scores.iter_mut().zip(line.trim().split('|')) {
*slot = score
.trim()
.parse::<u32>()
.map_err(|_| format!("no score {:?}", score.trim()))?;
}
(Some(scores), rest)
}
None => (None, rest),
};
let (level_text, input_text) = rest
.split_once(INPUTS_MARK)
.ok_or("missing inputs: section")?;
let level = Level::parse(level_text)?;
let head = Replay {
level,
inputs: Vec::new(),
names,
kinds,
ai,
teams,
scores,
};
Ok((head, input_text))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_replay_remembers_how_it_was_scored() {
let level = Level::parse("name: T\nposts: 1\nmap:\n+-+-+\n|0 1|\n+-+-+\n").expect("level");
let mut replay = Replay::new(level);
replay.record([PlayerAction::None; MAX_PLAYERS]);
assert!(!replay.to_text().contains(TEAMS_MARK));
replay.kinds[1] = SeatKind::Bot;
replay.teams = 2;
let back = Replay::parse(&replay.to_text()).expect("round trip");
assert_eq!((back.teams, back.kinds[1]), (2, SeatKind::Bot));
assert!(Replay::parse(&replay.to_text().replace("teams: 2", "teams: x")).is_err());
}
#[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 a_replay_remembers_each_ai_level() {
let mut replay = Replay::new(arena());
replay.record([PlayerAction::None; MAX_PLAYERS]);
assert!(!replay.to_text().contains(AI_MARK), "no AI, no line");
replay.kinds[1] = SeatKind::Ai;
replay.kinds[3] = SeatKind::Ai;
replay.ai[1] = Some(BotLevel::Hard);
replay.ai[3] = Some(BotLevel::Easy);
let text = replay.to_text();
assert!(text.contains("ai: -|fierce|-|easy|-|-"), "{text}");
for back in [
Replay::parse(&text).expect("round trip"),
Replay::head(&text).expect("its head"),
] {
assert_eq!(back.ai, replay.ai);
assert_eq!(back.kinds, replay.kinds);
}
assert_eq!(Replay::parse(&text).expect("whole").inputs.len(), 1);
assert!(Replay::head(&text).expect("head").inputs.is_empty());
let old = text.replace("ai: -|fierce|-|easy|-|-\n", "");
let back = Replay::parse(&old).expect("an older recording");
assert_eq!(back.kinds[1], SeatKind::Ai);
assert_eq!(back.ai, [None; MAX_PLAYERS]);
assert_eq!(back.inputs.len(), 1);
assert!(Replay::parse(&text.replace("fierce", "furious")).is_err());
assert!(Replay::head(&text.replace("fierce", "furious")).is_err());
}
#[test]
fn a_replay_remembers_how_the_round_ended() {
let mut replay = Replay::new(arena());
replay.teams = 1;
replay.record([PlayerAction::None; MAX_PLAYERS]);
assert!(
!replay.to_text().contains(SCORES_MARK),
"none kept, no line"
);
replay.scores = Some([87, 42, 30, 12, 0, 0]);
let text = replay.to_text();
assert!(
text.contains("teams: 1\nscores: 87|42|30|12|0|0\n"),
"{text}"
);
for back in [
Replay::parse(&text).expect("round trip"),
Replay::head(&text).expect("its head"),
] {
assert_eq!(back.scores, replay.scores);
assert_eq!(back.teams, 1);
}
assert_eq!(Replay::parse(&text).expect("whole").inputs.len(), 1);
assert!(Replay::head(&text).expect("head").inputs.is_empty());
let old = text.replace("scores: 87|42|30|12|0|0\n", "");
let back = Replay::parse(&old).expect("an older recording");
assert_eq!(back.scores, None);
assert_eq!(back.inputs.len(), 1);
assert!(Replay::head(&text.replace("|42|", "|x|")).is_err());
let level_part = text
.split_once(SCORES_MARK)
.map(|(_, rest)| rest)
.expect("scores");
assert!(Level::parse(&format!("{SCORES_MARK}{level_part}")).is_err());
}
#[test]
fn a_recording_longer_than_any_round_is_refused() {
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);
for _ in 0..MAX_TICKS {
replay.record([PlayerAction::None; MAX_PLAYERS]);
}
let longest = Replay::parse(&replay.to_text()).expect("the longest that reads");
assert_eq!(longest.inputs.len(), MAX_TICKS);
replay.record([PlayerAction::None; MAX_PLAYERS]);
assert!(Replay::parse(&replay.to_text()).is_err(), "one tick over");
}
#[test]
fn parse_rejects_garbage() {
assert!(Replay::parse("not a replay").is_err());
assert!(Replay::parse("").is_err());
}
#[test]
fn the_header_says_which_builds_can_play_it() {
let mut replay = Replay::new(arena());
replay.names[0] = "Anna".into();
replay.scores = Some([4, 1, 0, 0, 0, 0]);
replay.record([PlayerAction::None; MAX_PLAYERS]);
let text = replay.to_text();
assert!(text.starts_with("replay-v3\n"), "{text}");
let v2 = text.replacen("replay-v3", "replay-v2", 1);
let back = Replay::parse(&v2).expect("a v2 file with every line");
assert_eq!(
(back.names[0].as_str(), back.scores),
("Anna", replay.scores)
);
let bare = Replay::new(arena())
.to_text()
.replacen("replay-v3", "replay-v2", 1);
let back = Replay::parse(&bare).expect("a v2 file with none");
assert_eq!(back.scores, None);
let crlf = text.replacen("replay-v3\n", "replay-v3\r\n", 1);
assert!(Replay::parse(&crlf).is_ok(), "a file that crossed Windows");
let v1 = text.replacen("replay-v3", "replay-v1", 1);
assert_eq!(
Replay::parse(&v1).unwrap_err(),
"not a replay this build can play"
);
for later in ["replay-v4", "replay-v30"] {
let text = text.replacen("replay-v3", later, 1);
assert_eq!(Replay::parse(&text).unwrap_err(), NEEDS_NEWER, "{later}");
assert!(Replay::head(&text).is_err());
}
}
#[test]
fn a_name_reads_back_as_seat_name_spells_it() {
let mut replay = Replay::new(arena());
replay.names = ["Bo ", " Bo!", "a|b", "\tx", "", ""].map(String::from);
replay.record([PlayerAction::None; MAX_PLAYERS]);
let back = Replay::parse(&replay.to_text()).expect("round trip");
for (raw, read) in replay.names.iter().zip(&back.names) {
assert_eq!(read, &seat_name(raw), "{raw:?}");
}
assert_eq!(back.names[0], "Bo");
assert_eq!(seat_name(&seat_name("Bo ")), "Bo", "settled once spelled");
}
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.rules_mut().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"
);
}
}