1use crate::app::i18n::fill;
8use crate::app::settings::GameSettings;
9use crate::app::{Playback, Screen, menu_ui, palette};
10use crate::sim::Replay;
11use bevy::prelude::*;
12
13pub fn library_dir() -> std::path::PathBuf {
16 crate::app::paths::data_dir().join("replays")
17}
18
19pub struct Kept {
21 pub path: std::path::PathBuf,
22 pub label: String,
23}
24
25#[derive(Resource, Default)]
27pub struct Library {
28 pub kept: Vec<Kept>,
29 pub selected: usize,
30 pub scroll: usize,
33 pub feedback: String,
35}
36
37impl Library {
38 pub fn settle(&mut self) {
43 let Self {
44 kept,
45 selected,
46 scroll,
47 feedback: _,
48 } = self;
49 *selected = (*selected).min(kept.len().saturating_sub(1));
50 *scroll = (*scroll)
54 .min(*selected)
55 .max(selected.saturating_sub(ROWS - 1))
56 .min(kept.len().saturating_sub(ROWS));
57 }
58}
59
60pub fn file_name(stamp: u64, winner: &str) -> String {
67 let tidy: String = winner
68 .chars()
69 .map(|c| if c.is_alphanumeric() { c } else { '_' })
70 .take(12)
71 .collect();
72 format!("round_{stamp:010}_{tidy}.txt")
73}
74
75pub fn prune(cap: u8) {
86 prune_in(&library_dir(), cap);
87}
88
89pub fn prune_in(dir: &std::path::Path, cap: u8) {
92 let kept = shelf_in(dir);
93 for old in kept.iter().skip(usize::from(cap)) {
94 if let Err(e) = std::fs::remove_file(&old.path) {
95 warn!("could not drop the oldest round: {e}");
96 }
97 }
98}
99
100pub fn shelf() -> Vec<Kept> {
102 shelf_in(&library_dir())
103}
104
105pub fn shelf_in(dir: &std::path::Path) -> Vec<Kept> {
109 let Ok(dir) = std::fs::read_dir(dir) else {
110 return Vec::new();
111 };
112 let mut kept: Vec<Kept> = dir
113 .filter_map(Result::ok)
114 .map(|entry| entry.path())
115 .filter(|path| {
116 path.extension().is_some_and(|ext| ext == "txt")
117 && path
118 .file_name()
119 .and_then(|n| n.to_str())
120 .is_some_and(|n| n.starts_with("round_"))
121 })
122 .map(|path| {
123 let label = label_of(&path);
124 Kept { path, label }
125 })
126 .collect();
127 kept.sort_by(|a, b| b.path.cmp(&a.path));
129 kept
130}
131
132fn label_of(path: &std::path::Path) -> String {
136 let stem = path
137 .file_stem()
138 .and_then(|s| s.to_str())
139 .unwrap_or("round")
140 .to_string();
141 let mut parts = stem.splitn(3, '_');
142 let (_, stamp, winner) = (parts.next(), parts.next(), parts.next());
143 let winner = winner.unwrap_or("").replace('_', " ");
144 match stamp.and_then(|s| s.parse::<u64>().ok()) {
145 Some(stamp) => format!("{} - {winner}", clock(stamp)),
148 None => stem,
149 }
150}
151
152fn clock(stamp: u64) -> String {
161 stamp_text(stamp, crate::app::clock::local_offset(stamp))
162}
163
164fn stamp_text(stamp: u64, offset: i64) -> String {
169 let local = (stamp as i64 + offset).max(0) as u64;
173 let secs = local % 86_400;
174 let (h, m) = (secs / 3600, (secs % 3600) / 60);
175 let (day, month) = crate::app::clock::civil_date((local / 86_400) as u32);
176 format!("{day:02}/{month:02} {h:02}:{m:02}")
177}
178
179#[derive(Component)]
180pub struct LibraryUi;
181
182#[derive(Component)]
183pub struct LibraryRow(usize);
184
185#[derive(Component)]
187pub struct EmptyShelfNote;
188
189const ROWS: usize = 12;
191
192pub fn enter_library(
193 mut commands: Commands,
194 settings: Res<GameSettings>,
195 mut library: ResMut<Library>,
196) {
197 library.kept = shelf();
198 library.settle();
199 library.feedback.clear();
200 let tr = settings.tr();
201 commands
202 .spawn((LibraryUi, menu_ui::between_bars()))
203 .with_children(|wrap| {
204 wrap.spawn(menu_ui::screen_card()).with_children(|card| {
205 menu_ui::heading_row(card, tr.replays_heading, None);
206 card.spawn((
213 EmptyShelfNote,
214 Text::new(""),
215 TextFont {
216 font_size: FontSize::Px(menu_ui::type_scale::ROW),
217 ..default()
218 },
219 TextColor(crate::app::palette::PARCHMENT.with_alpha(0.6)),
220 Node {
224 width: Val::Px(ROW_W),
225 margin: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
226 ..default()
227 },
228 ));
229 for row in 0..ROWS {
230 card.spawn((LibraryRow(row), menu_ui::card_row()))
231 .with_children(|line| {
232 line.spawn((LibraryRow(row), menu_ui::cell(ROW_W, 19.0)));
233 });
234 }
235 });
236 });
237}
238
239const ROW_W: f32 = 420.0;
241
242pub fn update_library(
243 library: Res<Library>,
244 settings: Res<GameSettings>,
245 mut cells: Query<(&LibraryRow, &mut Text, &mut TextColor), Without<EmptyShelfNote>>,
246 mut rows: Query<(&LibraryRow, &mut BackgroundColor)>,
247 mut empty_note: Query<(&mut Text, &mut Node), With<EmptyShelfNote>>,
248) {
249 let tr = settings.tr();
250 let empty = library.kept.is_empty();
255 for (mut note, mut node) in &mut empty_note {
256 menu_ui::set_text(&mut note, if empty { tr.replays_empty } else { "" });
257 menu_ui::set_shown(&mut node, empty);
258 }
259 let at = |row: usize| library.scroll + row;
261 for (row, mut text, mut color) in &mut cells {
262 let line = match library.kept.get(at(row.0)) {
263 Some(kept) => kept
266 .label
267 .replace(" - draw", &format!(" - {}", tr.replay_draw)),
268 None => String::new(),
269 };
270 let picked = at(row.0) == library.selected && !library.kept.is_empty();
271 menu_ui::set_text(&mut text, &line);
272 menu_ui::set_color(
273 &mut color,
274 match (picked, library.kept.get(at(row.0)).is_some()) {
275 (true, _) => Color::WHITE,
276 (false, true) => crate::app::palette::PARCHMENT.with_alpha(0.75),
277 (false, false) => crate::app::palette::PARCHMENT.with_alpha(0.45),
278 },
279 );
280 }
281 for (row, mut fill) in &mut rows {
282 let ground = menu_ui::band(at(row.0) == library.selected && !library.kept.is_empty());
283 menu_ui::set_bg(&mut fill, ground);
284 }
285}
286
287pub fn library_input(
290 keys: Res<ButtonInput<KeyCode>>,
291 settings: Res<GameSettings>,
292 caps: Res<crate::app::keycaps::KeyCaps>,
293 mut clipboard: ResMut<Clipboard>,
294 mut library: ResMut<Library>,
295 mut playback: ResMut<Playback>,
296 mut next_screen: ResMut<NextState<Screen>>,
297) {
298 let tr = settings.tr();
299 if keys.just_pressed(KeyCode::Escape) {
300 next_screen.set(Screen::Menu);
301 return;
302 }
303 let shown = library.kept.len();
306 if shown > 0 {
307 library.selected = menu_ui::nav(&keys, library.selected, shown);
308 library.settle();
309 }
310 if caps.just_pressed(&keys, 'C') {
311 library.feedback = copy_selected(&mut clipboard, tr, &library);
312 }
313 if caps.just_pressed(&keys, 'V') {
314 library.feedback = keep_pasted(&mut clipboard, tr);
315 library.kept = shelf();
319 library.settle();
320 }
321 if !menu_ui::enter(&keys) {
322 return;
323 }
324 let Some(kept) = library.kept.get(library.selected) else {
325 return;
326 };
327 match std::fs::read_to_string(&kept.path)
328 .map_err(|e| e.to_string())
329 .and_then(|text| Replay::parse(&text))
330 {
331 Ok(replay) => {
332 playback.0 = Some((replay, 0));
333 next_screen.set(Screen::Versus);
334 }
335 Err(e) => library.feedback = e,
336 }
337}
338
339fn copy_selected(
346 clipboard: &mut Clipboard,
347 tr: &crate::app::i18n::Tr,
348 library: &Library,
349) -> String {
350 let Some(kept) = library.kept.get(library.selected) else {
351 return tr.replays_empty.to_string();
352 };
353 let text = match std::fs::read_to_string(&kept.path) {
354 Ok(text) => text,
355 Err(e) => return fill(tr.code_round_bad, &[("e", &e.to_string())]),
356 };
357 crate::app::codes::copy_feedback(
358 clipboard,
359 tr,
360 crate::share::Kind::Round,
361 text.as_bytes(),
362 tr.code_copied,
363 )
364}
365
366fn round_from(
374 pasted: Option<(crate::share::Kind, Vec<u8>)>,
375 tr: &crate::app::i18n::Tr,
376) -> Result<(String, String), String> {
377 let text =
378 crate::app::codes::payload_text(pasted, tr, crate::share::Kind::Round, tr.code_round_bad)?;
379 let replay = Replay::parse(&text).map_err(|e| fill(tr.code_round_bad, &[("e", &e)]))?;
380 Ok((winner_of(&replay, tr), text))
381}
382
383fn winner_of(replay: &Replay, tr: &crate::app::i18n::Tr) -> String {
390 let board = replay.playback();
391 let leaders = crate::app::side_panels::leading_seats(
392 board.scores(),
393 board.castle_seats(),
394 crate::app::teams::TeamMode::Solo,
395 );
396 match leaders.iter().position(|&led| led) {
397 Some(seat) if !replay.names[seat].is_empty() => replay.names[seat].clone(),
398 Some(seat) => crate::app::seat_label(tr, seat as u8),
399 None => "draw".to_string(),
400 }
401}
402
403fn keep_pasted(clipboard: &mut Clipboard, tr: &crate::app::i18n::Tr) -> String {
405 let (winner, text) = match round_from(crate::app::codes::paste(clipboard), tr) {
406 Ok(round) => round,
407 Err(complaint) => return complaint,
408 };
409 let stamp = crate::app::clock::now_secs();
410 let path = library_dir().join(file_name(stamp, &winner));
411 match crate::app::paths::write_atomic(&path, &text) {
412 Ok(()) => tr.code_round_saved.to_string(),
413 Err(e) => fill(tr.code_round_bad, &[("e", &e.to_string())]),
414 }
415}
416
417#[derive(Resource)]
419pub struct PlaybackSpeed(pub u8);
420
421impl Default for PlaybackSpeed {
422 fn default() -> Self {
423 PlaybackSpeed(1)
424 }
425}
426
427impl PlaybackSpeed {
428 pub const STEPS: [u8; 3] = [1, 2, 4];
430
431 fn stepped(self) -> PlaybackSpeed {
432 let next = Self::STEPS
433 .iter()
434 .position(|&s| s == self.0)
435 .map_or(0, |at| (at + 1) % Self::STEPS.len());
436 PlaybackSpeed(Self::STEPS[next])
437 }
438}
439
440pub fn playback_speed_input(
442 keys: Res<ButtonInput<KeyCode>>,
443 playback: Res<Playback>,
444 mut speed: ResMut<PlaybackSpeed>,
445) {
446 if playback.0.is_some() && keys.just_pressed(KeyCode::KeyS) {
447 *speed = PlaybackSpeed(speed.0).stepped();
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 #[test]
458 fn the_shelf_window_follows_the_cursor() {
459 let mut library = Library {
460 kept: (0..ROWS + 8)
461 .map(|n| Kept {
462 path: format!("{n}.txt").into(),
463 label: n.to_string(),
464 })
465 .collect(),
466 ..Library::default()
467 };
468 library.settle();
469 assert_eq!((library.selected, library.scroll), (0, 0));
470 library.selected = ROWS - 1;
472 library.settle();
473 assert_eq!(library.scroll, 0);
474 library.selected = ROWS;
476 library.settle();
477 assert_eq!(library.scroll, 1);
478 library.selected = ROWS + 7;
480 library.settle();
481 assert_eq!(library.scroll, 8);
482 library.selected = 3;
484 library.settle();
485 assert_eq!(library.scroll, 3);
486 library.kept.truncate(4);
488 library.selected = 10;
489 library.settle();
490 assert_eq!((library.selected, library.scroll), (3, 0));
491 library.kept.clear();
493 library.settle();
494 assert_eq!((library.selected, library.scroll), (0, 0));
495 }
496
497 #[test]
498 fn round_names_sort_newest_last_and_survive_odd_winners() {
499 assert_eq!(file_name(12_345, "Anna"), "round_0000012345_Anna.txt");
500 assert_eq!(file_name(7, "Bo/../etc"), "round_0000000007_Bo____etc.txt");
502 assert_eq!(file_name(7, ""), "round_0000000007_.txt");
503 assert!(file_name(2, "a") < file_name(10, "a"));
505 assert!(file_name(1, &"x".repeat(40)).len() < 40);
507 }
508
509 #[test]
510 fn labels_read_as_a_time_and_a_winner() {
511 let label = label_of(std::path::Path::new("replays/round_1751328000_Anna.txt"));
512 assert!(label.contains("Anna"), "{label}");
513 assert!(label.contains('/') && label.contains(':'), "{label}");
514 let odd = label_of(std::path::Path::new("replays/round_handmade.txt"));
516 assert_eq!(odd, "round_handmade");
517 }
518
519 #[test]
523 fn a_stamp_reads_on_the_local_clock() {
524 const NOON_LESS_TWELVE: u64 = 1_751_328_000;
526 assert_eq!(stamp_text(NOON_LESS_TWELVE, 0), "01/07 00:00", "Greenwich");
527 assert_eq!(stamp_text(NOON_LESS_TWELVE, 7200), "01/07 02:00", "Paris");
528 assert_eq!(
531 stamp_text(NOON_LESS_TWELVE, -4 * 3600),
532 "30/06 20:00",
533 "Halifax"
534 );
535 assert_eq!(stamp_text(60, -4 * 3600), "01/01 00:00");
538 }
539
540 #[test]
546 fn a_round_travels_as_a_code_and_comes_back() {
547 let tr = &crate::app::i18n::EN;
548 let mut replay = Replay::new(crate::sim::Level::from_board(
549 "Turf War",
550 3,
551 crate::sim::classic_arena(false, 2),
552 ));
553 for tick in 0..40u8 {
554 let mut actions = [crate::sim::PlayerAction::None; crate::sim::MAX_PLAYERS];
555 actions[0] = crate::sim::PlayerAction::Place {
556 x: tick % 10,
557 y: 3,
558 dir: crate::sim::Direction::Down,
559 };
560 replay.record(actions);
561 }
562 let text = replay.to_text();
563 let code = crate::share::encode(crate::share::Kind::Round, text.as_bytes());
564 let (winner, back) = round_from(crate::share::decode(&code), tr).expect("a round");
565 assert_eq!(back, text, "what went in is what comes out");
566 assert_eq!(winner, "draw", "filed under the result, not the beach");
567 }
568
569 fn banked_round() -> Replay {
573 let level = crate::sim::Level::parse(
574 "name: T\nposts: 1\ncrab: 0,0 R R common\nmap:\n\
575 +-+-+\n|. 0|\n+ + +\n|. .|\n+-+-+\n",
576 )
577 .expect("level");
578 let mut replay = Replay::new(level);
579 for _ in 0..60 {
580 replay.record([crate::sim::PlayerAction::None; crate::sim::MAX_PLAYERS]);
581 }
582 assert!(replay.playback().scores()[0] > 0, "the crab walked home");
583 replay
584 }
585
586 #[test]
587 fn a_pasted_round_is_filed_under_the_name_of_whoever_took_it() {
588 let tr = &crate::app::i18n::EN;
589 let mut replay = banked_round();
590 replay.names[0] = "Anna".into();
591 assert_eq!(winner_of(&replay, tr), "Anna");
592 }
593
594 #[test]
595 fn a_pasted_round_with_no_names_is_filed_under_the_seat() {
596 let tr = &crate::app::i18n::EN;
597 assert_eq!(winner_of(&banked_round(), tr), "P1");
598 }
599
600 #[test]
603 fn what_is_not_a_round_says_which_way_it_is_not() {
604 let tr = &crate::app::i18n::EN;
605 assert_eq!(round_from(None, tr), Err(tr.code_none_pasted.to_string()));
607 let level = crate::share::encode(crate::share::Kind::Level, b"name: X\nposts: 3\n");
610 let complaint = round_from(crate::share::decode(&level), tr).expect_err("not a round");
611 assert!(
612 complaint.contains("a level") && complaint.contains("a round"),
613 "{complaint}"
614 );
615 let junk = crate::share::encode(crate::share::Kind::Round, b"not a replay at all");
617 assert!(round_from(crate::share::decode(&junk), tr).is_err());
618 }
619
620 #[test]
624 fn the_transport_clock_pads_its_seconds() {
625 assert_eq!(stamp(0), "0:00", "the very start");
626 assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 7), "0:07");
627 assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 59), "0:59");
628 assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 60), "1:00");
629 assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 185), "3:05");
631 }
632
633 #[test]
637 fn the_transport_clock_counts_in_ticks() {
638 let one_second = crate::sim::TICKS_PER_SECOND as usize;
639 assert_eq!(stamp(one_second - 1), "0:00", "part of a second is not one");
640 assert_eq!(stamp(one_second), "0:01", "and a whole one is");
641 }
642
643 #[test]
644 fn the_speed_key_cycles_the_steps() {
645 let mut speed = PlaybackSpeed::default();
646 assert_eq!(speed.0, 1);
647 for want in [2, 4, 1, 2] {
648 speed = PlaybackSpeed(speed.0).stepped();
649 assert_eq!(speed.0, want);
650 }
651 }
652}
653
654#[derive(Component)]
664pub struct ReplayBar;
665
666#[derive(Component)]
668pub struct ReplayProgress;
669
670#[derive(Component)]
672pub struct ReplayReadout;
673
674const TRACK: f32 = 420.0;
676
677pub fn tend_replay_bar(
683 mut commands: Commands,
684 playback: Res<Playback>,
685 screen: Res<State<crate::app::Screen>>,
686 bar: Query<Entity, With<ReplayBar>>,
687) {
688 let watching = playback.0.is_some() && *screen.get() == crate::app::Screen::Versus;
689 if !watching {
690 for entity in &bar {
691 commands.entity(entity).despawn();
692 }
693 return;
694 }
695 if !bar.is_empty() {
696 return;
697 }
698 commands
699 .spawn((
700 ReplayBar,
701 Node {
702 position_type: PositionType::Absolute,
703 bottom: Val::Px(56.0),
707 left: Val::Px(0.0),
708 width: Val::Percent(100.0),
709 flex_direction: FlexDirection::Column,
710 align_items: AlignItems::Center,
711 row_gap: Val::Px(5.0),
712 ..default()
713 },
714 ))
715 .with_children(|column| {
716 column
718 .spawn((
719 Node {
720 width: Val::Px(TRACK),
721 height: Val::Px(6.0),
722 border_radius: BorderRadius::all(Val::Px(3.0)),
723 ..default()
724 },
725 BackgroundColor(palette::BAR_TRACK),
726 ))
727 .with_children(|rail| {
728 rail.spawn((
729 ReplayProgress,
730 Node {
731 width: Val::Percent(0.0),
732 height: Val::Percent(100.0),
733 border_radius: BorderRadius::all(Val::Px(3.0)),
734 ..default()
735 },
736 BackgroundColor(palette::GOLD),
737 ));
738 });
739 column
740 .spawn((
741 Node {
742 padding: UiRect::axes(Val::Px(12.0), Val::Px(3.0)),
743 border_radius: BorderRadius::all(Val::Px(10.0)),
744 ..default()
745 },
746 BackgroundColor(palette::PILL_FILL),
747 ))
748 .with_children(|pill| {
749 pill.spawn((
750 ReplayReadout,
751 Text::new(String::new()),
752 TextFont {
753 font_size: FontSize::Px(menu_ui::type_scale::FINE),
754 ..default()
755 },
756 TextLayout::no_wrap(),
757 TextColor(palette::PARCHMENT),
758 ));
759 });
760 });
761}
762
763fn stamp(ticks: usize) -> String {
765 let seconds = ticks / crate::sim::TICKS_PER_SECOND as usize;
766 format!("{}:{:02}", seconds / 60, seconds % 60)
767}
768
769pub fn update_replay_bar(
771 playback: Res<Playback>,
772 paused: Res<crate::app::Paused>,
773 speed: Res<PlaybackSpeed>,
774 settings: Res<crate::app::settings::GameSettings>,
775 mut track: Query<&mut Node, With<ReplayProgress>>,
776 mut readout: Query<&mut Text, With<ReplayReadout>>,
777) {
778 let Some((replay, at)) = &playback.0 else {
779 return;
780 };
781 let total = replay.inputs.len().max(1);
782 let through = (*at).min(total);
783 let width = Val::Percent(through as f32 / total as f32 * 100.0);
784 for mut node in &mut track {
785 if node.width != width {
788 node.width = width;
789 }
790 }
791 let tr = settings.tr();
792 let line = format!(
793 "{} {}x {} / {}",
794 if paused.0 {
795 tr.replay_paused
796 } else {
797 tr.replay_playing
798 },
799 speed.0,
800 stamp(through),
801 stamp(total)
802 );
803 for mut text in &mut readout {
804 menu_ui::set_text(&mut text, &line);
805 }
806}
807
808pub fn playback_pause_input(
813 keys: Res<ButtonInput<KeyCode>>,
814 playback: Res<Playback>,
815 menu: Res<crate::app::pause::PauseMenu>,
816 mut paused: ResMut<crate::app::Paused>,
817) {
818 if menu.open || playback.0.is_none() {
822 return;
823 }
824 if keys.just_pressed(KeyCode::Space) {
825 paused.0 = !paused.0;
826 }
827}