use crate::app::cycle::Cycle;
use crate::app::gamepad::LastDevice;
use crate::app::i18n::fill;
use crate::app::menu_ui::scroll_bar;
use crate::app::settings::GameSettings;
use crate::app::{Playback, Screen, menu_ui, palette};
use crate::sim::{BotLevel, Replay, SeatKind};
use bevy::prelude::*;
mod keep;
mod playback;
pub use keep::{
KEEP_CAP, KeepKey, KeepOffer, RoundToKeep, Unkept, keep_input, keep_words, kept_lead,
let_the_round_go,
};
pub use playback::{
PlaybackSpeed, ReplayBar, ReplayProgress, ReplayReadout, playback_pause_input,
playback_speed_input, tend_replay_bar, update_replay_bar,
};
pub fn library_dir() -> std::path::PathBuf {
crate::app::paths::data_dir().join("replays")
}
pub struct Kept {
pub path: std::path::PathBuf,
pub when: String,
pub winner: String,
pub draw: bool,
pub seats: Option<u8>,
pub won_by: Option<WonBy>,
pub points: Option<u32>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum WonBy {
Ai(Option<BotLevel>),
Bot,
}
#[derive(Resource)]
pub struct Library {
pub dir: std::path::PathBuf,
pub kept: Vec<Kept>,
pub selected: usize,
pub scroll: usize,
pub feedback: String,
pub delete: menu_ui::SecondPress<std::path::PathBuf>,
}
impl Default for Library {
fn default() -> Self {
Self {
dir: library_dir(),
kept: Vec::new(),
selected: 0,
scroll: 0,
feedback: String::new(),
delete: menu_ui::SecondPress::default(),
}
}
}
impl Library {
pub fn settle(&mut self) {
let Self {
dir: _,
kept,
selected,
scroll,
feedback: _,
delete: _,
} = self;
*selected = (*selected).min(kept.len().saturating_sub(1));
*scroll = (*scroll)
.min(*selected)
.max(selected.saturating_sub(ROWS - 1))
.min(kept.len().saturating_sub(ROWS));
}
}
impl scroll_bar::Scrolls for Library {
fn window(&self) -> scroll_bar::ScrollWindow {
scroll_bar::ScrollWindow::rows(self.scroll, ROWS, self.kept.len())
}
}
pub fn file_name(stamp: u64, winner: Option<&str>) -> String {
match winner {
Some(winner) => format!("round_{stamp:010}_{}.txt", tidy(winner)),
None => format!("round_{stamp:010}.txt"),
}
}
fn tidy(winner: &str) -> String {
winner
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.take(12)
.collect()
}
pub fn free_path(dir: &std::path::Path, stamp: u64, winner: Option<&str>) -> std::path::PathBuf {
(stamp..)
.map(|at| dir.join(file_name(at, winner)))
.find(|path| !path.exists())
.unwrap_or_else(|| dir.join(file_name(stamp, winner)))
}
pub fn prune(cap: u8) {
prune_in(&library_dir(), cap);
}
pub fn prune_in(dir: &std::path::Path, cap: u8) {
for old in listed_in(dir).iter().skip(usize::from(cap)) {
if let Err(e) = std::fs::remove_file(old) {
warn!("could not drop the oldest round: {e}");
}
}
}
pub fn cap_changed_in(dir: &std::path::Path, was: u8, now: u8) {
if now < was {
prune_in(dir, now);
}
}
pub fn delete_in(dir: &std::path::Path, path: &std::path::Path) -> std::io::Result<()> {
if !listed_in(dir).iter().any(|kept| kept == path) {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"not a kept round",
));
}
std::fs::remove_file(path)
}
pub fn shelf_in(dir: &std::path::Path) -> Vec<Kept> {
shelf_read(dir, |path| std::fs::read_to_string(path).ok())
}
fn shelf_read(
dir: &std::path::Path,
mut read: impl FnMut(&std::path::Path) -> Option<String>,
) -> Vec<Kept> {
listed_in(dir)
.into_iter()
.map(|path| {
let text = read(&path);
kept(path, text.as_deref())
})
.collect()
}
fn listed_in(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let Ok(dir) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut kept: Vec<std::path::PathBuf> = dir
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.extension().is_some_and(|ext| ext == "txt")
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("round_"))
})
.collect();
kept.sort_by(|a, b| b.cmp(a));
kept
}
fn kept(path: std::path::PathBuf, text: Option<&str>) -> Kept {
let named = name_of(&path);
let head = text.and_then(|text| Replay::head(text).ok());
let seats = head
.as_ref()
.map(|head| crate::app::session::replay_seats(&head.level.board()));
let won_by = match (&head, seats, &named.filed) {
(Some(head), Some(seats), Some(filed)) => won_by(head, seats, filed),
_ => None,
};
let points = match (&head, seats) {
(Some(head), Some(seats)) if named.filed.is_some() || named.draw => {
head.scores.map(|scores| {
let mode = crate::app::teams::recorded(head).played_by(seats);
crate::app::teams::top_total(&scores, seats, mode)
})
}
_ => None,
};
Kept {
path,
when: named.when,
winner: named.filed.as_deref().map(shown).unwrap_or_default(),
draw: named.draw,
seats,
won_by,
points,
}
}
struct Named {
when: String,
filed: Option<String>,
draw: bool,
}
fn name_of(path: &std::path::Path) -> Named {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("round")
.to_string();
let mut parts = stem.splitn(3, '_');
let (_, stamp, winner) = (parts.next(), parts.next(), parts.next());
match (stamp.and_then(|s| s.parse::<u64>().ok()), winner) {
(Some(stamp), Some(winner)) => Named {
when: clock(stamp),
filed: Some(winner.to_string()),
draw: false,
},
(Some(stamp), None) => Named {
when: clock(stamp),
filed: None,
draw: true,
},
(None, _) => Named {
when: stem,
filed: None,
draw: false,
},
}
}
fn shown(filed: &str) -> String {
filed.replace('_', " ")
}
fn won_by(head: &Replay, seats: u8, filed: &str) -> Option<WonBy> {
let mode = crate::app::teams::recorded(head).played_by(seats);
let mut found = None;
for team in 0..mode.teams(seats) {
let spelled = crate::app::i18n::ALL_LANGS.iter().any(|lang| {
let label = crate::app::teams::team_label(mode, team, seats, |seat| {
crate::app::name_or_label(&head.names, lang.tr(), seat)
});
tidy(&label) == filed
});
if !spelled {
continue;
}
let held = held_by(head, &mode.seats_of(team, seats));
match found {
None => found = Some(held),
Some(other) if other != held => return None,
Some(_) => {}
}
}
found.flatten()
}
fn held_by(head: &Replay, seats: &[u8]) -> Option<WonBy> {
let kinds: Vec<SeatKind> = seats.iter().map(|&s| head.kinds[usize::from(s)]).collect();
let levels: Vec<Option<BotLevel>> = seats.iter().map(|&s| head.ai[usize::from(s)]).collect();
if kinds.iter().all(|&kind| kind == SeatKind::Bot) {
return Some(WonBy::Bot);
}
if !kinds.iter().all(|&kind| kind == SeatKind::Ai) {
return None;
}
let level = levels[0].filter(|_| levels.iter().all(|&l| l == levels[0]));
Some(WonBy::Ai(level))
}
fn clock(stamp: u64) -> String {
stamp_text(stamp, crate::app::clock::local_offset(stamp))
}
fn stamp_text(stamp: u64, offset: i64) -> String {
let local = (stamp as i64 + offset).max(0) as u64;
let secs = local % 86_400;
let (h, m) = (secs / 3600, (secs % 3600) / 60);
let (day, month) = crate::app::clock::civil_date((local / 86_400) as u32);
format!("{day:02}/{month:02} {h:02}:{m:02}")
}
#[derive(Component)]
pub struct LibraryUi;
#[derive(Component)]
pub struct LibraryRow(usize);
#[derive(Component)]
pub struct LibraryCell(usize, Column);
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Column {
When,
Who,
Points,
Players,
}
impl Column {
const ALL: [Column; 4] = [Column::When, Column::Who, Column::Points, Column::Players];
fn width(self) -> f32 {
match self {
Column::When => WHEN_W,
Column::Who => WHO_W,
Column::Points => POINTS_W,
Column::Players => PLAYERS_W,
}
}
fn text(self, kept: &Kept, tr: &crate::app::i18n::Tr) -> String {
match self {
Column::When => kept.when.clone(),
Column::Who => who(kept, tr),
Column::Points => points(kept, tr),
Column::Players => players(kept, tr),
}
}
}
#[derive(Component)]
pub struct EmptyShelfNote;
const ROWS: usize = 12;
pub fn enter_library(
mut commands: Commands,
settings: Res<GameSettings>,
mut library: ResMut<Library>,
) {
library.kept = shelf_in(&library.dir);
library.delete = menu_ui::SecondPress::default();
library.settle();
let tr = settings.tr();
library.feedback = tr.replay_drop_hint.to_string();
commands
.spawn((LibraryUi, menu_ui::between_bars()))
.with_children(|wrap| {
wrap.spawn(menu_ui::screen_card()).with_children(|card| {
menu_ui::heading_row(card, tr.replays_heading, None);
card.spawn((
EmptyShelfNote,
Text::new(""),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::ROW),
..default()
},
TextColor(palette::PARCHMENT.with_alpha(0.6)),
Node {
width: Val::Px(ROW_W),
margin: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
..default()
},
));
let bar = scroll_bar::BarOf::<Library>::default();
scroll_bar::beside(card, menu_ui::ROW_GAP, bar, |rows| {
for row in 0..ROWS {
rows.spawn((LibraryRow(row), menu_ui::card_row()))
.with_children(|line| {
for column in Column::ALL {
line.spawn((
LibraryCell(row, column),
menu_ui::cell(column.width(), ROW_FONT),
));
}
});
}
});
});
});
}
const ROW_FONT: f32 = menu_ui::type_scale::ROW;
const WHEN_W: f32 = 150.0;
const WHO_W: f32 = 410.0;
const POINTS_W: f32 = 120.0;
const PLAYERS_W: f32 = 140.0;
const ROW_W: f32 = WHEN_W + WHO_W + POINTS_W + PLAYERS_W;
pub fn update_library(
library: Res<Library>,
settings: Res<GameSettings>,
mut cells: Query<(&LibraryCell, &mut Text, &mut TextColor), Without<EmptyShelfNote>>,
mut rows: Query<(&LibraryRow, &mut BackgroundColor, &mut Node), Without<EmptyShelfNote>>,
mut empty_note: Query<(&mut Text, &mut Node), With<EmptyShelfNote>>,
) {
let tr = settings.tr();
let empty = library.kept.is_empty();
for (mut note, mut node) in &mut empty_note {
menu_ui::set_text(&mut note, if empty { tr.replays_empty } else { "" });
menu_ui::set_shown(&mut node, empty);
}
let at = |row: usize| library.scroll + row;
for (&LibraryCell(row, column), mut text, mut color) in &mut cells {
let line = library
.kept
.get(at(row))
.map_or_else(String::new, |kept| column.text(kept, tr));
let picked = at(row) == library.selected && !library.kept.is_empty();
menu_ui::set_text(&mut text, &line);
menu_ui::set_color(
&mut color,
match (picked, library.kept.get(at(row)).is_some()) {
(true, _) => Color::WHITE,
(false, true) => palette::PARCHMENT.with_alpha(0.75),
(false, false) => palette::PARCHMENT.with_alpha(0.45),
},
);
}
for (row, mut fill, mut node) in &mut rows {
let ground = menu_ui::band(at(row.0) == library.selected && !library.kept.is_empty());
menu_ui::set_bg(&mut fill, ground);
menu_ui::set_shown(&mut node, !empty);
}
}
fn shelf_line(kept: &Kept, tr: &crate::app::i18n::Tr) -> String {
[kept.when.clone(), who(kept, tr), points(kept, tr)]
.into_iter()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" - ")
}
fn who(kept: &Kept, tr: &crate::app::i18n::Tr) -> String {
match kept.draw {
true => tr.replay_draw.to_string(),
false => format!("{}{}", kept.winner, tag(kept.won_by, tr)),
}
}
fn tag(won_by: Option<WonBy>, tr: &crate::app::i18n::Tr) -> String {
match won_by {
None => String::new(),
Some(WonBy::Ai(None)) => tr.tag_ai.to_string(),
Some(WonBy::Ai(Some(level))) => fill(
tr.replay_tag_ai_level,
&[("level", tr.bot_levels[level.index()])],
),
Some(WonBy::Bot) => tr.replay_tag_bot.to_string(),
}
}
fn points(kept: &Kept, tr: &crate::app::i18n::Tr) -> String {
kept.points.map_or_else(String::new, |points| {
fill(tr.replay_points, &[("n", &points.to_string())])
})
}
fn players(kept: &Kept, tr: &crate::app::i18n::Tr) -> String {
kept.seats.map_or_else(String::new, |seats| {
fill(tr.replay_players, &[("n", &seats.to_string())])
})
}
pub(crate) const DELETE_CAP: char = 'D';
fn delete_control(tr: &crate::app::i18n::Tr, device: LastDevice) -> String {
crate::app::gamepad::scheme::spelled(
tr,
device,
&crate::app::gamepad::Face::Replays,
crate::app::gamepad::scheme::Does::Delete,
&DELETE_CAP.to_string(),
)
}
pub fn library_input(
keyboard: crate::app::keycaps::Keyboard,
(settings, device, time): (Res<GameSettings>, Res<LastDevice>, Res<Time>),
mut clipboard: ResMut<Clipboard>,
mut library: ResMut<Library>,
mut playback: ResMut<Playback>,
mut next_screen: ResMut<NextState<Screen>>,
) {
let keys = &keyboard.keys;
let tr = settings.tr();
let now = time.elapsed_secs();
if library.delete.lapse(now) {
library.feedback = tr.replay_drop_hint.to_string();
}
if keys.just_pressed(KeyCode::Escape) {
next_screen.set(Screen::Menu);
return;
}
if keyboard.just(DELETE_CAP) {
library.feedback = ask_or_delete(&mut library, tr, *device, now);
return;
}
if keys.get_just_pressed().next().is_some() && library.delete.take_back() {
library.feedback = tr.replay_drop_hint.to_string();
}
let shown = library.kept.len();
if shown > 0 {
library.selected = menu_ui::nav(keys, library.selected, shown);
library.settle();
}
if keyboard.global('C') {
library.feedback = copy_selected(&mut clipboard, tr, &library);
}
if keyboard.just('V') {
library.feedback = keep_pasted(&mut clipboard, tr, &library.dir, settings.replay_cap);
library.kept = shelf_in(&library.dir);
library.settle();
}
if !menu_ui::enter(keys) {
return;
}
let Some(kept) = library.kept.get(library.selected) else {
return;
};
match std::fs::read_to_string(&kept.path)
.map_err(|e| e.to_string())
.and_then(|text| Replay::parse(&text))
{
Ok(replay) => {
playback.0 = Some((replay, 0));
next_screen.set(Screen::Versus);
}
Err(e) => library.feedback = why_not(tr, &e),
}
}
pub fn why_not(tr: &crate::app::i18n::Tr, e: &str) -> String {
match e == crate::sim::NEEDS_NEWER {
true => tr.replay_newer.to_string(),
false => e.to_string(),
}
}
fn ask_or_delete(
library: &mut Library,
tr: &crate::app::i18n::Tr,
device: LastDevice,
now: f32,
) -> String {
let Some(kept) = library.kept.get(library.selected) else {
return tr.replays_empty.to_string();
};
let (path, line) = (kept.path.clone(), shelf_line(kept, tr));
if !library.delete.press(now, path.clone()) {
let key = delete_control(tr, device);
return fill(tr.replay_delete_confirm, &[("k", &key), ("r", &line)]);
}
let deleted = delete_in(&library.dir, &path);
library.kept = shelf_in(&library.dir);
library.settle();
match deleted {
Ok(()) => fill(tr.replay_deleted, &[("r", &line)]),
Err(e) => fill(
tr.replay_delete_failed,
&[("r", &line), ("e", &e.to_string())],
),
}
}
fn copy_selected(
clipboard: &mut Clipboard,
tr: &crate::app::i18n::Tr,
library: &Library,
) -> String {
let Some(kept) = library.kept.get(library.selected) else {
return tr.replays_empty.to_string();
};
let text = match std::fs::read_to_string(&kept.path) {
Ok(text) => text,
Err(e) => return fill(tr.code_round_bad, &[("e", &e.to_string())]),
};
crate::app::codes::copy_feedback(
clipboard,
tr,
crate::share::Kind::Round,
text.as_bytes(),
tr.code_copied,
)
}
fn round_from(
pasted: Option<(crate::share::Kind, Vec<u8>)>,
tr: &crate::app::i18n::Tr,
) -> Result<(Option<String>, String), String> {
let text =
crate::app::codes::payload_text(pasted, tr, crate::share::Kind::Round, tr.code_round_bad)?;
let mut replay =
Replay::parse(&text).map_err(|e| fill(tr.code_round_bad, &[("e", &why_not(tr, &e))]))?;
let winner = ended(&mut replay, tr);
Ok((winner, replay.to_text()))
}
fn ended(replay: &mut Replay, tr: &crate::app::i18n::Tr) -> Option<String> {
let board = replay.playback();
replay.scores = Some(*board.scores());
let seats = crate::app::session::replay_seats(&board);
let mode = crate::app::teams::recorded(replay).played_by(seats);
crate::app::teams::winner_label(board.scores(), seats, mode, |seat| {
crate::app::name_or_label(&replay.names, tr, seat)
})
}
fn keep_pasted(
clipboard: &mut Clipboard,
tr: &crate::app::i18n::Tr,
dir: &std::path::Path,
cap: u8,
) -> String {
keep(crate::app::codes::paste(clipboard), tr, dir, cap)
}
fn keep(
pasted: Option<(crate::share::Kind, Vec<u8>)>,
tr: &crate::app::i18n::Tr,
dir: &std::path::Path,
cap: u8,
) -> String {
let (winner, text) = match round_from(pasted, tr) {
Ok(round) => round,
Err(complaint) => return complaint,
};
let path = free_path(dir, crate::app::clock::now_secs(), winner.as_deref());
match crate::app::paths::write_atomic(&path, &text) {
Ok(()) => {
prune_in(dir, cap);
tr.code_round_saved.to_string()
}
Err(e) => fill(tr.code_round_bad, &[("e", &e.to_string())]),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn winner_of(replay: &Replay, tr: &crate::app::i18n::Tr) -> Option<String> {
ended(&mut replay.clone(), tr)
}
#[test]
fn the_shelf_window_follows_the_cursor() {
let mut library = Library {
kept: (0..ROWS + 8)
.map(|n| kept(format!("round_{n}.txt").into(), None))
.collect(),
..Library::default()
};
library.settle();
assert_eq!((library.selected, library.scroll), (0, 0));
library.selected = ROWS - 1;
library.settle();
assert_eq!(library.scroll, 0);
library.selected = ROWS;
library.settle();
assert_eq!(library.scroll, 1);
library.selected = ROWS + 7;
library.settle();
assert_eq!(library.scroll, 8);
library.selected = 3;
library.settle();
assert_eq!(library.scroll, 3);
library.kept.truncate(4);
library.selected = 10;
library.settle();
assert_eq!((library.selected, library.scroll), (3, 0));
library.kept.clear();
library.settle();
assert_eq!((library.selected, library.scroll), (0, 0));
}
#[test]
fn round_names_sort_newest_last_and_survive_odd_winners() {
let name = |stamp, winner| file_name(stamp, Some(winner));
assert_eq!(name(12_345, "Anna"), "round_0000012345_Anna.txt");
assert_eq!(name(7, "Bo/../etc"), "round_0000000007_Bo____etc.txt");
assert_eq!(name(7, ""), "round_0000000007_.txt");
assert_eq!(file_name(7, None), "round_0000000007.txt");
assert!(name(2, "a") < name(10, "a"));
assert!(name(1, &"x".repeat(40)).len() < 40);
}
#[test]
fn labels_read_as_a_time_and_a_winner() {
let tr = &crate::app::i18n::EN;
let round = kept("replays/round_1751328000_Anna_B.txt".into(), None);
assert_eq!(round.winner, "Anna B");
assert!(
round.when.contains('/') && round.when.contains(':'),
"{}",
round.when
);
assert!(!round.draw);
assert_eq!(shelf_line(&round, tr), format!("{} - Anna B", round.when));
let odd = kept("replays/round_handmade.txt".into(), None);
assert_eq!(
(odd.when.as_str(), odd.winner.as_str()),
("round_handmade", "")
);
assert!(!odd.draw);
assert_eq!(shelf_line(&odd, tr), "round_handmade");
}
#[test]
fn a_draw_is_told_from_a_winner_called_draw() {
let tr = &crate::app::i18n::EN;
let read = |winner| kept(file_name(1_751_328_000, winner).into(), None);
let tie = read(None);
assert!(tie.draw);
assert!(tie.winner.is_empty(), "the word is the shelf's to add");
assert_eq!(who(&tie, tr), "draw");
for name in ["draw", "drawbridge"] {
let round = read(Some(name));
assert!(!round.draw, "{name} won");
assert_eq!(who(&round, tr), name);
}
}
fn round_text(seats: u8, kinds: &[SeatKind], ai: &[Option<BotLevel>]) -> String {
let board = crate::sim::generate_arena(7, seats, 16, 12);
let mut replay = Replay::new(crate::sim::Level::from_board("Versus", 3, board));
replay.kinds[..kinds.len()].copy_from_slice(kinds);
replay.ai[..ai.len()].copy_from_slice(ai);
replay.record([crate::sim::PlayerAction::None; crate::sim::MAX_PLAYERS]);
replay.to_text()
}
fn row(winner: Option<&str>, text: &str) -> Kept {
kept(file_name(1_751_328_000, winner).into(), Some(text))
}
#[test]
fn a_row_says_what_took_the_round_and_how_many_played() {
use SeatKind::{Ai, Bot, Human};
let tr = &crate::app::i18n::EN;
let text = round_text(
4,
&[Human, Ai, Bot, Ai],
&[None, Some(BotLevel::Hard), None, Some(BotLevel::Easy)],
);
let cases = [
(Some("P1"), "P1"),
(Some("P2"), "P2 (AI fierce)"),
(Some("P3"), "P3 (bot)"),
(Some("P4"), "P4 (AI easy)"),
(None, "draw"),
];
for (winner, said) in cases {
let round = row(winner, &text);
assert_eq!(who(&round, tr), said);
assert_eq!(players(&round, tr), "4 players");
}
let de = crate::app::i18n::Lang::De.tr();
assert_eq!(who(&row(Some("S2"), &text), de), "S2 (KI wild)");
let named = text.replacen("replay-v3\n", "replay-v3\nnames: Anna|Deep Blue 2000\n", 1);
assert_eq!(
who(&row(Some("Deep Blue 2000"), &named), tr),
"Deep Blue 20 (AI fierce)"
);
assert_eq!(who(&row(Some("Zed"), &text), tr), "Zed");
}
#[test]
fn an_older_recording_says_what_it_can() {
use SeatKind::{Ai, Human};
let tr = &crate::app::i18n::EN;
let text = round_text(3, &[Human, Ai, Ai], &[None, Some(BotLevel::Normal)]);
let no_levels: String = text
.lines()
.filter(|line| !line.starts_with("ai:"))
.map(|line| format!("{line}\n"))
.collect();
assert_ne!(no_levels, text, "the line was there to take out");
assert_eq!(who(&row(Some("P2"), &no_levels), tr), "P2 (AI)");
assert_eq!(players(&row(Some("P2"), &no_levels), tr), "3 players");
let no_kinds: String = no_levels
.lines()
.filter(|line| !line.starts_with("kinds:"))
.map(|line| format!("{line}\n"))
.collect();
assert_eq!(who(&row(Some("P2"), &no_kinds), tr), "P2");
let junk = row(Some("P2"), "not a replay");
assert_eq!(
(who(&junk, tr).as_str(), players(&junk, tr).as_str()),
("P2", "")
);
}
#[test]
fn a_team_is_marked_only_when_held_alike() {
use SeatKind::{Ai, Human};
let tr = &crate::app::i18n::EN;
let with_teams = |kinds: &[SeatKind], ai: &[Option<BotLevel>]| {
let board = crate::sim::generate_arena(7, 4, 16, 12);
let mut replay = Replay::new(crate::sim::Level::from_board("Versus", 3, board));
replay.kinds[..kinds.len()].copy_from_slice(kinds);
replay.ai[..ai.len()].copy_from_slice(ai);
replay.teams = crate::app::teams::TeamMode::Pairs.index() as u8;
replay.to_text()
};
let hard = Some(BotLevel::Hard);
let level_pair = with_teams(&[Human, Human, Ai, Ai], &[None, None, hard, hard]);
assert_eq!(
who(&row(Some("P3+P4"), &level_pair), tr),
"P3 P4 (AI fierce)"
);
assert_eq!(who(&row(Some("P1+P2"), &level_pair), tr), "P1 P2");
let mixed_levels = with_teams(&[Human, Human, Ai, Ai], &[None, None, hard, None]);
assert_eq!(who(&row(Some("P3+P4"), &mixed_levels), tr), "P3 P4 (AI)");
let mixed = with_teams(&[Human, Human, Human, Ai], &[None, None, None, hard]);
assert_eq!(who(&row(Some("P3+P4"), &mixed), tr), "P3 P4");
assert_eq!(players(&row(Some("P3+P4"), &mixed), tr), "4 players");
}
#[test]
fn a_name_two_seats_answer_to_marks_nothing() {
use SeatKind::{Ai, Human};
let tr = &crate::app::i18n::EN;
let text = round_text(2, &[Human, Ai], &[None, Some(BotLevel::Hard)]).replacen(
"replay-v3\n",
"replay-v3\nnames: Bo|Bo\n",
1,
);
assert_eq!(who(&row(Some("Bo"), &text), tr), "Bo");
let text = round_text(2, &[Human, Ai], &[None, Some(BotLevel::Hard)]).replacen(
"replay-v3\n",
"replay-v3\nnames: P2|\n",
1,
);
assert_eq!(who(&row(Some("P2"), &text), tr), "P2");
}
fn with_scores(text: &str, scores: [u32; crate::sim::MAX_PLAYERS]) -> String {
let mut head = Replay::parse(text).expect("a round");
head.scores = Some(scores);
head.to_text()
}
#[test]
fn a_row_says_what_the_round_was_won_with() {
use SeatKind::{Ai, Human};
let tr = &crate::app::i18n::EN;
let plain = round_text(4, &[Human, Ai], &[None, Some(BotLevel::Hard)]);
let text = with_scores(&plain, [12, 87, 30, 42, 0, 0]);
let won = row(Some("P2"), &text);
assert_eq!(won.points, Some(87));
assert_eq!(
(who(&won, tr).as_str(), points(&won, tr).as_str()),
("P2 (AI fierce)", "87 pts")
);
assert_eq!(
shelf_line(&won, tr),
format!("{} - P2 (AI fierce) - 87 pts", won.when)
);
let mut paired = Replay::parse(&text).expect("a round");
paired.teams = crate::app::teams::TeamMode::Pairs.index() as u8;
let paired = row(Some("P1+P2"), &paired.to_text());
assert_eq!(points(&paired, tr), "99 pts");
let tie = row(None, &with_scores(&plain, [5, 9, 9, 1, 0, 0]));
assert_eq!(
(who(&tie, tr).as_str(), points(&tie, tr).as_str()),
("draw", "9 pts")
);
assert_eq!(
shelf_line(&tie, tr),
format!("{} - draw - 9 pts", tie.when)
);
let old = row(Some("P2"), &plain);
assert_eq!(old.points, None);
assert_eq!(points(&old, tr), "");
assert_eq!(
shelf_line(&old, tr),
format!("{} - P2 (AI fierce)", old.when)
);
let odd = kept("replays/round_handmade.txt".into(), Some(&text));
assert_eq!(odd.points, None);
for lang in crate::app::i18n::ALL_LANGS {
assert!(points(&won, lang.tr()).contains("87"), "{lang:?}");
}
}
#[test]
fn a_row_with_absurd_scores_is_listed() {
let plain = round_text(4, &[], &[]);
let mut paired = Replay::parse(&with_scores(&plain, [u32::MAX; crate::sim::MAX_PLAYERS]))
.expect("a round");
paired.teams = crate::app::teams::TeamMode::Pairs.index() as u8;
let round = row(Some("P1+P2"), &paired.to_text());
assert_eq!(round.points, Some(u32::MAX));
let tr = &crate::app::i18n::EN;
assert!(points(&round, tr).contains(&u32::MAX.to_string()));
}
#[test]
fn the_shelf_never_plays_a_round() {
let text = with_scores(&round_text(3, &[], &[]), [4, 11, 2, 0, 0, 0]);
let broken = format!("{text}not an input line\n");
assert!(Replay::parse(&broken).is_err(), "it would not replay");
let dir = crate::app::paths::test_dir("never-plays");
std::fs::write(dir.join(file_name(1_751_328_000, Some("P2"))), &broken).expect("written");
let shelf = shelf_in(&dir);
assert_eq!(shelf.len(), 1);
assert_eq!((shelf[0].points, shelf[0].seats), (Some(11), Some(3)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_row_counts_the_seats_off_the_beach() {
let tr = &crate::app::i18n::Lang::Ru.tr();
for seats in 2..=crate::sim::MAX_PLAYERS as u8 {
let round = row(Some("P1"), &round_text(seats, &[], &[]));
assert_eq!(round.seats, Some(seats));
assert_eq!(players(&round, tr), format!("игроков: {seats}"));
}
}
#[test]
fn listing_the_shelf_reads_each_round_once() {
let dir = stocked("reads-once", 5);
let mut reads: Vec<std::path::PathBuf> = Vec::new();
let shelf = shelf_read(&dir, |path| {
reads.push(path.to_path_buf());
std::fs::read_to_string(path).ok()
});
assert_eq!(shelf.len(), 5);
let mut listed: Vec<_> = shelf.iter().map(|kept| kept.path.clone()).collect();
reads.sort();
listed.sort();
assert_eq!(reads, listed, "each round once, and only rounds");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn every_column_fits_in_every_language() {
use crate::app::i18n::metrics::text_px;
let fits = |lang, column: Column, text: &str| {
let px = text_px(text, ROW_FONT);
assert!(
px <= column.width() - 8.0,
"{lang:?} {column:?}: {text:?} is {px:.1}px of {}",
column.width()
);
};
for lang in crate::app::i18n::ALL_LANGS {
let tr = lang.tr();
fits(lang, Column::When, &stamp_text(1_751_328_000, 0));
let mut tags: Vec<String> = [None, Some(WonBy::Ai(None)), Some(WonBy::Bot)]
.into_iter()
.map(|won_by| tag(won_by, tr))
.collect();
tags.extend(
crate::app::match_setup::BOT_LEVELS
.iter()
.map(|&level| tag(Some(WonBy::Ai(Some(level))), tr)),
);
for tag in tags {
for name in ["W".repeat(12), "あ".repeat(12)] {
fits(lang, Column::Who, &format!("{name}{tag}"));
}
}
fits(lang, Column::Who, tr.replay_draw);
fits(
lang,
Column::Points,
&fill(tr.replay_points, &[("n", "9999")]),
);
let six = crate::sim::MAX_PLAYERS.to_string();
fits(
lang,
Column::Players,
&fill(tr.replay_players, &[("n", &six)]),
);
}
}
#[test]
fn the_tags_use_the_games_own_words() {
for lang in crate::app::i18n::ALL_LANGS {
let tr = lang.tr();
let ai = tr.tag_ai.trim_end_matches(')');
assert!(
tr.replay_tag_ai_level.starts_with(&format!("{ai} ")),
"{lang:?}"
);
assert!(tr.replay_tag_ai_level.contains("{level}"), "{lang:?}");
assert_eq!(
tr.replay_tag_bot,
format!(" ({})", tr.match_bot),
"{lang:?}"
);
assert!(tr.replay_players.contains("{n}"), "{lang:?}");
assert!(tr.replay_points.contains("{n}"), "{lang:?}");
}
}
#[test]
fn pasted_rounds_keep_their_own_files_and_the_cap() {
let tr = &crate::app::i18n::EN;
let dir = crate::app::paths::test_dir("pasted-rounds");
let code = || {
let text = banked_round().to_text();
crate::share::decode(&crate::share::encode(
crate::share::Kind::Round,
text.as_bytes(),
))
};
assert_eq!(keep(code(), tr, &dir, 10), tr.code_round_saved);
assert_eq!(keep(code(), tr, &dir, 10), tr.code_round_saved);
assert_eq!(shelf_in(&dir).len(), 2, "two pastes, two rounds");
std::fs::write(dir.join(file_name(5, Some("P1"))), "x").expect("written");
assert_eq!(
free_path(&dir, 5, Some("P1")),
dir.join(file_name(6, Some("P1"))),
"a taken name is passed over for the next second"
);
assert_eq!(
free_path(&dir, 5, None),
dir.join(file_name(5, None)),
"and a free one is kept"
);
for _ in 0..4 {
keep(code(), tr, &dir, 3);
}
assert_eq!(shelf_in(&dir).len(), 3, "and the cap holds");
let _ = std::fs::remove_dir_all(&dir);
}
fn stocked(name: &str, rounds: u64) -> std::path::PathBuf {
let dir = crate::app::paths::test_dir(name);
for n in 0..rounds {
let path = dir.join(file_name(1_751_328_000 + 60 * n, Some(&format!("P{n}"))));
std::fs::write(path, "x").expect("written");
}
std::fs::write(dir.join("last.txt"), "x").expect("written");
std::fs::write(dir.join("highlight.gif"), "x").expect("written");
dir
}
#[test]
fn the_rows_sit_in_the_middle_of_a_card_that_fits() {
use bevy::camera::{ComputedCameraValues, RenderTargetInfo, Viewport};
let (width, height) = (1280u32, 720u32);
let dir = stocked("centred", 3);
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
TransformPlugin,
AssetPlugin::default(),
ImagePlugin::default(),
bevy::text::TextPlugin,
bevy::ui::UiPlugin,
));
app.add_plugins((
bevy::input::InputPlugin,
WindowPlugin {
primary_window: None,
exit_condition: bevy::window::ExitCondition::DontExit,
..default()
},
bevy::a11y::AccessibilityPlugin,
DefaultPickingPlugins,
));
app.init_asset::<TextureAtlasLayout>();
let settings = GameSettings::default();
app.insert_resource(UiScale(crate::app::settings::applied_ui_ratio(
settings.ui_ratio(),
width as f32,
height as f32,
)));
app.insert_resource(settings);
app.insert_resource(Library {
dir: dir.clone(),
..Library::default()
});
app.world_mut().spawn((
Camera2d,
Camera {
computed: ComputedCameraValues {
target_info: Some(RenderTargetInfo {
physical_size: UVec2::new(width, height),
scale_factor: 1.0,
}),
..default()
},
viewport: Some(Viewport {
physical_size: UVec2::new(width, height),
..default()
}),
..default()
},
));
app.add_systems(Startup, enter_library);
app.add_systems(Update, update_library);
for _ in 0..4 {
app.update();
}
let world = app.world_mut();
let span = |world: &World, entity: Entity| {
let node = world.get::<ComputedNode>(entity).expect("laid out");
let at = world.get::<UiGlobalTransform>(entity).expect("placed");
(
at.translation.x - node.size.x / 2.0,
at.translation.x + node.size.x / 2.0,
)
};
let mut cards = world.query_filtered::<Entity, With<menu_ui::ShoreCard>>();
let card = cards.single(world).expect("one card");
let mut rows = world.query::<(Entity, &LibraryRow)>();
let row = rows
.iter(world)
.find(|(_, row)| row.0 == 0)
.map(|(entity, _)| entity)
.expect("a row");
let (card_left, card_right) = span(world, card);
let (row_left, row_right) = span(world, row);
let middle = |(left, right): (f32, f32)| (left + right) / 2.0;
assert!(
(middle((row_left, row_right)) - middle((card_left, card_right))).abs() < 1.0,
"row {row_left}..{row_right} in card {card_left}..{card_right}"
);
assert!(
card_left >= 0.0 && card_right <= width as f32,
"card {card_left}..{card_right} in a {width}px window"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn library_app(dir: &std::path::Path) -> (App, Entity) {
use crate::app::gamepad::{Bridged, Face, PadFace, PadPick};
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.insert_resource(State::new(Screen::Replays));
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<Bridged>();
app.init_resource::<PadPick>();
app.init_resource::<Time>();
app.init_resource::<LastDevice>();
app.init_resource::<Clipboard>();
app.init_resource::<Playback>();
app.init_resource::<crate::app::cursor::LiveSeatKeys>();
app.insert_resource(PadFace(Face::Replays));
app.init_resource::<GameSettings>();
app.init_resource::<crate::app::keycaps::KeyCaps>();
let mut library = Library {
dir: dir.to_path_buf(),
..Library::default()
};
library.kept = shelf_in(dir);
app.insert_resource(library);
let pad = app.world_mut().spawn(Gamepad::default()).id();
app.add_systems(
Update,
(
crate::app::gamepad::pad_menu_bridge,
library_input,
crate::app::gamepad::pad_bridge_release,
)
.chain(),
);
(app, pad)
}
fn tap(app: &mut App, key: KeyCode) {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(key);
app.update();
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.release(key);
keys.clear();
}
fn wait(app: &mut App, secs: f32) {
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_secs_f32(secs));
}
#[test]
fn the_bar_follows_the_shelf_window() {
use bevy::ecs::system::RunSystemOnce;
use scroll_bar::{BarOf, reading};
let dir = stocked("scroll-bar", (ROWS + 6) as u64);
let (mut app, _) = library_app(&dir);
app.add_systems(
Update,
(update_library, scroll_bar::follow::<Library>)
.chain()
.after(library_input),
);
app.world_mut()
.run_system_once(enter_library)
.expect("the shelf is built");
app.update();
let bar = |app: &mut App| reading::<BarOf<Library>>(app.world_mut());
let share = ROWS as f32 / (ROWS + 6) as f32;
assert_eq!(bar(&mut app), Some([0.0, share, 1.0 - share]), "at the top");
for _ in 0..ROWS + 5 {
tap(&mut app, KeyCode::ArrowDown);
}
assert_eq!(app.world().resource::<Library>().selected, ROWS + 5);
assert_eq!(
bar(&mut app),
Some([1.0 - share, share, 0.0]),
"at the bottom"
);
for left in (ROWS..ROWS + 6).rev() {
tap(&mut app, KeyCode::KeyD);
wait(&mut app, 0.5);
tap(&mut app, KeyCode::KeyD);
assert_eq!(app.world().resource::<Library>().kept.len(), left);
let share = ROWS as f32 / left as f32;
let want = (left > ROWS).then_some([1.0 - share, share, 0.0]);
assert_eq!(bar(&mut app), want, "{left} rounds left");
}
let _ = std::fs::remove_dir_all(&dir);
}
fn winners(dir: &std::path::Path) -> Vec<String> {
shelf_in(dir).into_iter().map(|kept| kept.winner).collect()
}
#[test]
fn a_delete_asks_first_and_takes_the_round_named() {
let tr = &crate::app::i18n::EN;
let dir = stocked("delete-asks", 4);
let (mut app, _) = library_app(&dir);
let library = |app: &App| -> (usize, String) {
let library = app.world().resource::<Library>();
(library.selected, library.feedback.clone())
};
let named = shelf_line(&shelf_in(&dir)[0], tr);
tap(&mut app, KeyCode::KeyD);
assert_eq!(
winners(&dir),
["P3", "P2", "P1", "P0"],
"one press only asks"
);
assert_eq!(
library(&app).1,
fill(tr.replay_delete_confirm, &[("k", "D"), ("r", &named)])
);
wait(&mut app, 1.0);
tap(&mut app, KeyCode::KeyD);
assert_eq!(winners(&dir), ["P2", "P1", "P0"], "the second deletes");
assert_eq!(
library(&app),
(0, fill(tr.replay_deleted, &[("r", &named)]))
);
assert_eq!(app.world().resource::<Library>().kept.len(), 3, "re-read");
tap(&mut app, KeyCode::KeyD);
tap(&mut app, KeyCode::KeyS);
assert_eq!(library(&app), (1, tr.replay_drop_hint.to_string()));
tap(&mut app, KeyCode::KeyD);
assert_eq!(
winners(&dir),
["P2", "P1", "P0"],
"so this press asks again"
);
tap(&mut app, KeyCode::KeyZ);
tap(&mut app, KeyCode::KeyD);
assert_eq!(winners(&dir).len(), 3, "and so does this one");
wait(&mut app, menu_ui::SECOND_PRESS_SECS + 1.0);
app.update();
assert_eq!(library(&app).1, tr.replay_drop_hint);
tap(&mut app, KeyCode::KeyD);
assert_eq!(winners(&dir).len(), 3, "a late second press only asks");
tap(&mut app, KeyCode::KeyD);
assert_eq!(winners(&dir), ["P2", "P0"]);
assert_eq!(library(&app).0, 1);
tap(&mut app, KeyCode::KeyD);
tap(&mut app, KeyCode::KeyD);
assert_eq!(winners(&dir), ["P2"]);
assert_eq!(library(&app).0, 0);
tap(&mut app, KeyCode::KeyD);
tap(&mut app, KeyCode::KeyD);
assert!(app.world().resource::<Library>().kept.is_empty());
assert!(winners(&dir).is_empty());
assert!(dir.join("last.txt").exists() && dir.join("highlight.gif").exists());
tap(&mut app, KeyCode::KeyD);
assert_eq!(library(&app), (0, tr.replays_empty.to_string()));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_pad_deletes_through_the_bridge_and_is_named_its_button() {
let tr = &crate::app::i18n::EN;
let dir = stocked("delete-pad", 2);
let (mut app, pad) = library_app(&dir);
app.insert_resource(LastDevice::Pad);
let button = crate::app::gamepad::scheme::Pad::Select
.button()
.expect("a button");
let press = |app: &mut App| {
let mut gamepad = app.world_mut().get_mut::<Gamepad>(pad).expect("pad");
gamepad.digital_mut().press(button);
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
let mut gamepad = app.world_mut().get_mut::<Gamepad>(pad).expect("pad");
gamepad.digital_mut().release(button);
gamepad.digital_mut().clear();
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
};
let named = shelf_line(&shelf_in(&dir)[0], tr);
press(&mut app);
assert_eq!(winners(&dir), ["P1", "P0"], "Select only asks");
assert_eq!(
app.world().resource::<Library>().feedback,
fill(tr.replay_delete_confirm, &[("k", "Select"), ("r", &named)])
);
press(&mut app);
assert_eq!(winners(&dir), ["P0"], "and Select again deletes");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_delete_is_named_by_the_control_that_does_it() {
use crate::app::gamepad::{Face, scheme};
for lang in crate::app::i18n::ALL_LANGS {
let prompt = lang.tr().prompt_replays;
assert!(
prompt.contains(&format!("{DELETE_CAP}:"))
|| prompt.contains(&format!("{DELETE_CAP} :")),
"{lang:?}: {prompt}"
);
}
let tr = &crate::app::i18n::EN;
assert_eq!(delete_control(tr, LastDevice::Keyboard), "D");
assert_eq!(delete_control(tr, LastDevice::Pad), "Select");
assert!(scheme::legend(tr, &Face::Replays).contains("Select: delete"));
assert!(!crate::app::binds::GLOBAL_LETTERS.contains(&DELETE_CAP));
}
#[test]
fn only_shelf_rounds_are_deletable() {
let dir = stocked("delete-shelf-only", 1);
let elsewhere = stocked("delete-elsewhere", 1);
for path in [
dir.join("last.txt"),
dir.join("highlight.gif"),
shelf_in(&elsewhere)[0].path.clone(),
] {
assert!(delete_in(&dir, &path).is_err(), "{path:?}");
assert!(path.exists(), "{path:?} kept");
}
let round = shelf_in(&dir)[0].path.clone();
delete_in(&dir, &round).expect("deleted");
assert!(!round.exists());
assert!(delete_in(&dir, &round).is_err());
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&elsewhere);
}
#[test]
fn a_round_that_will_not_delete_says_so() {
let tr = &crate::app::i18n::EN;
let dir = stocked("delete-fails", 1);
std::fs::create_dir(dir.join(file_name(1_751_400_000, Some("Stuck")))).expect("made");
let mut library = Library {
dir: dir.clone(),
kept: shelf_in(&dir),
..Library::default()
};
let named = shelf_line(&library.kept[0], tr);
ask_or_delete(&mut library, tr, LastDevice::Keyboard, 0.0);
let said = ask_or_delete(&mut library, tr, LastDevice::Keyboard, 1.0);
assert!(
said.starts_with(&fill(tr.replay_delete_failed, &[("r", &named), ("e", "")])),
"{said}"
);
assert_eq!(library.kept.len(), 2, "still on the shelf");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_lowered_cap_prunes_at_once_and_a_raised_one_never_does() {
let dir = stocked("cap-changed", 12);
cap_changed_in(&dir, 5, 10);
assert_eq!(winners(&dir).len(), 12, "raised: nothing goes");
cap_changed_in(&dir, 20, 20);
assert_eq!(winners(&dir).len(), 12, "unchanged: nothing goes");
cap_changed_in(&dir, 20, 5);
assert_eq!(
winners(&dir),
["P11", "P10", "P9", "P8", "P7"],
"newest kept"
);
assert!(dir.join("last.txt").exists() && dir.join("highlight.gif").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_stamp_reads_on_the_local_clock() {
const NOON_LESS_TWELVE: u64 = 1_751_328_000;
assert_eq!(stamp_text(NOON_LESS_TWELVE, 0), "01/07 00:00", "Greenwich");
assert_eq!(stamp_text(NOON_LESS_TWELVE, 7200), "01/07 02:00", "Paris");
assert_eq!(
stamp_text(NOON_LESS_TWELVE, -4 * 3600),
"30/06 20:00",
"Halifax"
);
assert_eq!(stamp_text(60, -4 * 3600), "01/01 00:00");
}
#[test]
fn a_round_travels_as_a_code_and_comes_back() {
let tr = &crate::app::i18n::EN;
let mut replay = Replay::new(crate::sim::Level::from_board(
"Turf War",
3,
crate::sim::classic_arena(false, 2),
));
for tick in 0..40u8 {
let mut actions = [crate::sim::PlayerAction::None; crate::sim::MAX_PLAYERS];
actions[0] = crate::sim::PlayerAction::Place {
x: tick % 10,
y: 3,
dir: crate::sim::Direction::Down,
};
replay.record(actions);
}
let text = replay.to_text();
let code = crate::share::encode(crate::share::Kind::Round, text.as_bytes());
let (winner, back) = round_from(crate::share::decode(&code), tr).expect("a round");
let mut ended = replay.clone();
ended.scores = Some(*replay.playback().scores());
assert_eq!(back, ended.to_text(), "what went in, with how it ended");
assert_eq!(
winner, None,
"filed under the result (a draw), not the beach"
);
}
fn banked_round() -> Replay {
let level = crate::sim::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..60 {
replay.record([crate::sim::PlayerAction::None; crate::sim::MAX_PLAYERS]);
}
assert!(replay.playback().scores()[0] > 0, "the crab walked home");
replay
}
#[test]
fn a_pasted_round_is_filed_under_the_name_of_whoever_took_it() {
let tr = &crate::app::i18n::EN;
let mut replay = banked_round();
replay.names[0] = "Anna".into();
assert_eq!(winner_of(&replay, tr).as_deref(), Some("Anna"));
}
#[test]
fn a_pasted_round_keeps_its_ai_level() {
let tr = &crate::app::i18n::EN;
let dir = crate::app::paths::test_dir("pasted-ai");
let mut replay = banked_round();
replay.kinds[0] = SeatKind::Ai;
replay.ai[0] = Some(BotLevel::Hard);
replay.scores = Some([999; crate::sim::MAX_PLAYERS]);
let text = replay.to_text();
let code = crate::share::encode(crate::share::Kind::Round, text.as_bytes());
assert_eq!(
keep(crate::share::decode(&code), tr, &dir, 10),
tr.code_round_saved
);
let shelf = shelf_in(&dir);
let filed = Replay::parse(&std::fs::read_to_string(&shelf[0].path).expect("read"))
.expect("a round");
let board = replay.playback();
assert_eq!(filed.scores, Some(*board.scores()), "the playthrough's");
assert_eq!(filed.ai, replay.ai);
assert_eq!(filed.inputs.len(), replay.inputs.len());
let mode = crate::app::teams::recorded(&replay).played_by(2);
assert_eq!(
shelf[0].points,
Some(crate::app::teams::top_total(board.scores(), 2, mode))
);
assert!(shelf[0].points > Some(0), "the crab walked home");
assert_eq!(who(&shelf[0], tr), "P1 (AI fierce)");
assert_eq!(players(&shelf[0], tr), "2 players");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_pasted_round_with_no_names_is_filed_under_the_seat() {
let tr = &crate::app::i18n::EN;
assert_eq!(winner_of(&banked_round(), tr).as_deref(), Some("P1"));
}
#[test]
fn a_pasted_round_in_pairs_is_filed_under_the_pair() {
let tr = &crate::app::i18n::EN;
let level = crate::sim::Level::parse(
"name: T\nposts: 1\ncrab: 0,0 R R common\nmap:\n\
+-+-+-+-+-+\n|. 0 1 2 3|\n+ + + + + +\n|. . . . .|\n+-+-+-+-+-+\n",
)
.expect("level");
let mut replay = Replay::new(level);
for _ in 0..60 {
replay.record([crate::sim::PlayerAction::None; crate::sim::MAX_PLAYERS]);
}
assert_eq!(winner_of(&replay, tr).as_deref(), Some("P1"));
replay.teams = crate::app::teams::TeamMode::Pairs.index() as u8;
let back = Replay::parse(&replay.to_text()).expect("reads back");
assert_eq!(winner_of(&back, tr).as_deref(), Some("P1+P2"));
}
#[test]
fn what_is_not_a_round_says_which_way_it_is_not() {
let tr = &crate::app::i18n::EN;
assert_eq!(round_from(None, tr), Err(tr.code_none_pasted.to_string()));
let level = crate::share::encode(crate::share::Kind::Level, b"name: X\nposts: 3\n");
let complaint = round_from(crate::share::decode(&level), tr).expect_err("not a round");
assert!(
complaint.contains("a level") && complaint.contains("a round"),
"{complaint}"
);
let junk = crate::share::encode(crate::share::Kind::Round, b"not a replay at all");
assert!(round_from(crate::share::decode(&junk), tr).is_err());
}
#[test]
fn a_later_builds_round_is_refused_in_the_players_language() {
let later = round_text(2, &[], &[]).replacen("replay-v3", "replay-v99", 1);
let code = crate::share::encode(crate::share::Kind::Round, later.as_bytes());
let refused = Replay::parse(&later).expect_err("a later build's");
for lang in crate::app::i18n::ALL_LANGS {
let tr = lang.tr();
let pasted = round_from(crate::share::decode(&code), tr).expect_err("refused");
assert_eq!(
pasted,
fill(tr.code_round_bad, &[("e", tr.replay_newer)]),
"{lang:?}"
);
assert_eq!(why_not(tr, &refused), tr.replay_newer, "{lang:?}");
if lang != crate::app::i18n::Lang::En {
assert_ne!(tr.replay_newer, crate::sim::NEEDS_NEWER, "{lang:?}");
}
}
let tr = &crate::app::i18n::EN;
assert_eq!(why_not(tr, "not a replay"), "not a replay");
}
}