use crate::app::i18n::fill;
use crate::app::settings::GameSettings;
use crate::app::{Playback, Screen, menu_ui};
use crate::sim::Replay;
use bevy::prelude::*;
pub fn library_dir() -> std::path::PathBuf {
crate::app::paths::data_dir().join("replays")
}
pub struct Kept {
pub path: std::path::PathBuf,
pub label: String,
}
#[derive(Resource, Default)]
pub struct Library {
pub kept: Vec<Kept>,
pub selected: usize,
pub scroll: usize,
pub feedback: String,
}
impl Library {
pub fn settle(&mut self) {
let Self {
kept,
selected,
scroll,
feedback: _,
} = 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));
}
}
pub fn file_name(stamp: u64, winner: &str) -> String {
let tidy: String = winner
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.take(12)
.collect();
format!("round_{stamp:010}_{tidy}.txt")
}
pub fn prune(cap: u8) {
let kept = shelf();
for old in kept.iter().skip(usize::from(cap)) {
if let Err(e) = std::fs::remove_file(&old.path) {
warn!("could not drop the oldest round: {e}");
}
}
}
pub fn shelf() -> Vec<Kept> {
let Ok(dir) = std::fs::read_dir(library_dir()) else {
return Vec::new();
};
let mut kept: Vec<Kept> = 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_"))
})
.map(|path| {
let label = label_of(&path);
Kept { path, label }
})
.collect();
kept.sort_by(|a, b| b.path.cmp(&a.path));
kept
}
fn label_of(path: &std::path::Path) -> String {
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());
let winner = winner.unwrap_or("").replace('_', " ");
match stamp.and_then(|s| s.parse::<u64>().ok()) {
Some(stamp) => format!("{} - {winner}", clock(stamp)),
None => stem,
}
}
fn clock(stamp: u64) -> String {
let secs = stamp % 86_400;
let (h, m) = (secs / 3600, (secs % 3600) / 60);
let (day, month) = crate::app::clock::civil_date((stamp / 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 EmptyShelfNote;
const ROWS: usize = 12;
pub fn enter_library(
mut commands: Commands,
settings: Res<GameSettings>,
mut library: ResMut<Library>,
) {
library.kept = shelf();
library.settle();
library.feedback.clear();
let tr = settings.tr();
commands
.spawn((LibraryUi, menu_ui::between_bars()))
.with_children(|wrap| {
wrap.spawn(menu_ui::screen_card()).with_children(|card| {
card.spawn(menu_ui::heading(tr.replays_heading, true));
card.spawn((
EmptyShelfNote,
Text::new(""),
TextFont {
font_size: FontSize::Px(19.0),
..default()
},
TextColor(crate::app::palette::PARCHMENT.with_alpha(0.6)),
Node {
width: Val::Px(ROW_W),
margin: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
..default()
},
));
for row in 0..ROWS {
card.spawn((LibraryRow(row), menu_ui::card_row()))
.with_children(|line| {
line.spawn((LibraryRow(row), menu_ui::cell(ROW_W, 19.0)));
});
}
});
});
}
const ROW_W: f32 = 420.0;
pub fn update_library(
library: Res<Library>,
settings: Res<GameSettings>,
mut cells: Query<(&LibraryRow, &mut Text, &mut TextColor), Without<EmptyShelfNote>>,
mut rows: Query<(&LibraryRow, &mut BackgroundColor)>,
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 { "" });
let want = if empty { Display::Flex } else { Display::None };
if node.display != want {
node.display = want;
}
}
let at = |row: usize| library.scroll + row;
for (row, mut text, mut color) in &mut cells {
let line = match library.kept.get(at(row.0)) {
Some(kept) => kept
.label
.replace(" - draw", &format!(" - {}", tr.replay_draw)),
None => String::new(),
};
let picked = at(row.0) == 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.0)).is_some()) {
(true, _) => Color::WHITE,
(false, true) => crate::app::palette::PARCHMENT.with_alpha(0.75),
(false, false) => crate::app::palette::PARCHMENT.with_alpha(0.45),
},
);
}
for (row, mut fill) in &mut rows {
let ground = menu_ui::band(at(row.0) == library.selected && !library.kept.is_empty());
if fill.0 != ground {
fill.0 = ground;
}
}
}
pub fn library_input(
keys: Res<ButtonInput<KeyCode>>,
settings: Res<GameSettings>,
mut clipboard: ResMut<Clipboard>,
mut library: ResMut<Library>,
mut playback: ResMut<Playback>,
mut next_screen: ResMut<NextState<Screen>>,
) {
let tr = settings.tr();
if keys.just_pressed(KeyCode::Escape) {
next_screen.set(Screen::Menu);
return;
}
let shown = library.kept.len();
if shown > 0 {
library.selected = menu_ui::nav(&keys, library.selected, shown);
library.settle();
}
if keys.just_pressed(KeyCode::KeyC) {
library.feedback = copy_selected(&mut clipboard, tr, &library);
}
if keys.just_pressed(KeyCode::KeyV) {
library.feedback = keep_pasted(&mut clipboard, tr);
library.kept = shelf();
library.settle();
}
if !keys.just_pressed(KeyCode::Enter) {
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 = e,
}
}
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<(String, String), String> {
let text =
crate::app::codes::payload_text(pasted, tr, crate::share::Kind::Round, tr.code_round_bad)?;
let replay = Replay::parse(&text).map_err(|e| fill(tr.code_round_bad, &[("e", &e)]))?;
Ok((replay.level.name.clone(), text))
}
fn keep_pasted(clipboard: &mut Clipboard, tr: &crate::app::i18n::Tr) -> String {
let (winner, text) = match round_from(crate::app::codes::paste(clipboard), tr) {
Ok(round) => round,
Err(complaint) => return complaint,
};
let stamp = crate::app::clock::now_secs();
let path = library_dir().join(file_name(stamp, &winner));
match crate::app::paths::write_atomic(&path, &text) {
Ok(()) => tr.code_round_saved.to_string(),
Err(e) => fill(tr.code_round_bad, &[("e", &e.to_string())]),
}
}
#[derive(Resource)]
pub struct PlaybackSpeed(pub u8);
impl Default for PlaybackSpeed {
fn default() -> Self {
PlaybackSpeed(1)
}
}
impl PlaybackSpeed {
pub const STEPS: [u8; 3] = [1, 2, 4];
fn stepped(self) -> PlaybackSpeed {
let next = Self::STEPS
.iter()
.position(|&s| s == self.0)
.map_or(0, |at| (at + 1) % Self::STEPS.len());
PlaybackSpeed(Self::STEPS[next])
}
}
pub fn playback_speed_input(
keys: Res<ButtonInput<KeyCode>>,
playback: Res<Playback>,
mut speed: ResMut<PlaybackSpeed>,
) {
if playback.0.is_some() && keys.just_pressed(KeyCode::KeyS) {
*speed = PlaybackSpeed(speed.0).stepped();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shelf_window_follows_the_cursor() {
let mut library = Library {
kept: (0..ROWS + 8)
.map(|n| Kept {
path: format!("{n}.txt").into(),
label: n.to_string(),
})
.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() {
assert_eq!(file_name(12_345, "Anna"), "round_0000012345_Anna.txt");
assert_eq!(file_name(7, "Bo/../etc"), "round_0000000007_Bo____etc.txt");
assert_eq!(file_name(7, ""), "round_0000000007_.txt");
assert!(file_name(2, "a") < file_name(10, "a"));
assert!(file_name(1, &"x".repeat(40)).len() < 40);
}
#[test]
fn labels_read_as_a_time_and_a_winner() {
let label = label_of(std::path::Path::new("replays/round_1751328000_Anna.txt"));
assert!(label.contains("Anna"), "{label}");
assert!(label.contains('/') && label.contains(':'), "{label}");
assert_eq!(clock(1_751_328_000), "01/07 00:00");
let odd = label_of(std::path::Path::new("replays/round_handmade.txt"));
assert_eq!(odd, "round_handmade");
}
#[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(
"Anna",
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");
assert_eq!(back, text, "what went in is what comes out");
assert!(!winner.is_empty(), "and it knows what to file it under");
}
#[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 the_speed_key_cycles_the_steps() {
let mut speed = PlaybackSpeed::default();
assert_eq!(speed.0, 1);
for want in [2, 4, 1, 2] {
speed = PlaybackSpeed(speed.0).stepped();
assert_eq!(speed.0, want);
}
}
}