use crate::app::i18n::fill;
use crate::app::settings::GameSettings;
use crate::app::{Playback, Screen, menu_ui, palette};
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,
pub draw: bool,
}
#[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: Option<&str>) -> String {
match winner {
Some(winner) => {
let tidy: String = winner
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.take(12)
.collect();
format!("round_{stamp:010}_{tidy}.txt")
}
None => format!("round_{stamp:010}.txt"),
}
}
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) {
let kept = shelf_in(dir);
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> {
shelf_in(&library_dir())
}
pub fn shelf_in(dir: &std::path::Path) -> Vec<Kept> {
let Ok(dir) = std::fs::read_dir(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, draw) = label_of(&path);
Kept { path, label, draw }
})
.collect();
kept.sort_by(|a, b| b.path.cmp(&a.path));
kept
}
fn label_of(path: &std::path::Path) -> (String, bool) {
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)) => (
format!("{} - {}", clock(stamp), winner.replace('_', " ")),
false,
),
(Some(stamp), None) => (clock(stamp), true),
(None, _) => (stem, false),
}
}
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 EmptyShelfNote;
const ROWS: usize = 12;
pub fn enter_library(
mut commands: Commands,
settings: Res<GameSettings>,
mut library: ResMut<Library>,
) {
library.kept = shelf();
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()
},
));
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 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 (row, mut text, mut color) in &mut cells {
let line = match library.kept.get(at(row.0)) {
Some(kept) if kept.draw => format!("{} - {}", kept.label, tr.replay_draw),
Some(kept) => kept.label.clone(),
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) => 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);
}
}
pub fn library_input(
keys: Res<ButtonInput<KeyCode>>,
settings: Res<GameSettings>,
caps: Res<crate::app::keycaps::KeyCaps>,
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 caps.just_pressed(&keys, 'C') {
library.feedback = copy_selected(&mut clipboard, tr, &library);
}
if caps.just_pressed(&keys, 'V') {
library.feedback = keep_pasted(&mut clipboard, tr, &library_dir(), settings.replay_cap);
library.kept = shelf();
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 = 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<(Option<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((winner_of(&replay, tr), text))
}
fn winner_of(replay: &Replay, tr: &crate::app::i18n::Tr) -> Option<String> {
let board = replay.playback();
let leaders = crate::app::side_panels::leading_seats(
board.scores(),
board.castle_seats(),
crate::app::teams::TeamMode::Solo,
);
let seat = leaders.iter().position(|&led| led)?;
Some(if replay.names[seat].is_empty() {
crate::app::seat_label(tr, seat as u8)
} else {
replay.names[seat].clone()
})
}
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())]),
}
}
#[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(),
draw: false,
})
.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 (label, draw) = label_of(std::path::Path::new("replays/round_1751328000_Anna.txt"));
assert!(label.contains("Anna"), "{label}");
assert!(label.contains('/') && label.contains(':'), "{label}");
assert!(!draw);
let odd = label_of(std::path::Path::new("replays/round_handmade.txt"));
assert_eq!(odd, ("round_handmade".to_string(), false));
}
#[test]
fn a_draw_is_told_from_a_winner_called_draw() {
let read = |winner| label_of(std::path::Path::new(&file_name(1_751_328_000, winner)));
let (tie, draw) = read(None);
assert!(draw, "{tie}");
assert!(
!tie.contains("draw"),
"the word is the shelf's to add: {tie}"
);
for name in ["draw", "drawbridge"] {
let (label, draw) = read(Some(name));
assert!(!draw, "{name} won");
assert!(label.ends_with(&format!(" - {name}")), "{label}");
}
}
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("pinch-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a scratch folder");
dir
}
#[test]
fn pasted_rounds_keep_their_own_files_and_the_cap() {
let tr = &crate::app::i18n::EN;
let dir = scratch("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);
}
#[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");
assert_eq!(back, text, "what went in is what comes out");
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_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 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_transport_clock_pads_its_seconds() {
assert_eq!(stamp(0), "0:00", "the very start");
assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 7), "0:07");
assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 59), "0:59");
assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 60), "1:00");
assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 185), "3:05");
}
#[test]
fn the_transport_clock_counts_in_ticks() {
let one_second = crate::sim::TICKS_PER_SECOND as usize;
assert_eq!(stamp(one_second - 1), "0:00", "part of a second is not one");
assert_eq!(stamp(one_second), "0:01", "and a whole one is");
}
#[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);
}
}
}
#[derive(Component)]
pub struct ReplayBar;
#[derive(Component)]
pub struct ReplayProgress;
#[derive(Component)]
pub struct ReplayReadout;
const TRACK: f32 = 420.0;
pub fn tend_replay_bar(
mut commands: Commands,
playback: Res<Playback>,
screen: Res<State<Screen>>,
bar: Query<Entity, With<ReplayBar>>,
) {
let watching = playback.0.is_some() && *screen.get() == Screen::Versus;
if !watching {
for entity in &bar {
commands.entity(entity).despawn();
}
return;
}
if !bar.is_empty() {
return;
}
commands
.spawn((
ReplayBar,
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(56.0),
left: Val::Px(0.0),
width: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
row_gap: Val::Px(5.0),
..default()
},
))
.with_children(|column| {
column
.spawn((
Node {
width: Val::Px(TRACK),
height: Val::Px(6.0),
border_radius: BorderRadius::all(Val::Px(3.0)),
..default()
},
BackgroundColor(palette::BAR_TRACK),
))
.with_children(|rail| {
rail.spawn((
ReplayProgress,
Node {
width: Val::Percent(0.0),
height: Val::Percent(100.0),
border_radius: BorderRadius::all(Val::Px(3.0)),
..default()
},
BackgroundColor(palette::GOLD),
));
});
column
.spawn((
Node {
padding: UiRect::axes(Val::Px(12.0), Val::Px(3.0)),
border_radius: BorderRadius::all(Val::Px(10.0)),
..default()
},
BackgroundColor(palette::PILL_FILL),
))
.with_children(|pill| {
pill.spawn((
ReplayReadout,
Text::new(String::new()),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::FINE),
..default()
},
TextLayout::no_wrap(),
TextColor(palette::PARCHMENT),
));
});
});
}
fn stamp(ticks: usize) -> String {
let seconds = ticks / crate::sim::TICKS_PER_SECOND as usize;
format!("{}:{:02}", seconds / 60, seconds % 60)
}
pub fn update_replay_bar(
playback: Res<Playback>,
paused: Res<crate::app::Paused>,
speed: Res<PlaybackSpeed>,
settings: Res<GameSettings>,
mut track: Query<&mut Node, With<ReplayProgress>>,
mut readout: Query<&mut Text, With<ReplayReadout>>,
) {
let Some((replay, at)) = &playback.0 else {
return;
};
let total = replay.inputs.len().max(1);
let through = (*at).min(total);
let width = Val::Percent(through as f32 / total as f32 * 100.0);
for mut node in &mut track {
if node.width != width {
node.width = width;
}
}
let tr = settings.tr();
let line = format!(
"{} {}x {} / {}",
if paused.0 {
tr.replay_paused
} else {
tr.replay_playing
},
speed.0,
stamp(through),
stamp(total)
);
for mut text in &mut readout {
menu_ui::set_text(&mut text, &line);
}
}
pub fn playback_pause_input(
keys: Res<ButtonInput<KeyCode>>,
playback: Res<Playback>,
mut paused: ResMut<crate::app::Paused>,
) {
if playback.0.is_none() {
return;
}
if keys.just_pressed(KeyCode::Space) {
paused.0 = !paused.0;
}
}