Skip to main content

pinch_points/app/
replays.rs

1//! The replay library: every finished round kept under a name you can find
2//! it by, and the screen for picking one.
3//!
4//! Rounds are filed by the day and time they were played plus who won, which
5//! is what anyone remembers about a match ("the one Bo took on the twelfth").
6
7use 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
13/// Where the library lives. `last.txt` still sits beside it, because the
14/// dev hooks and the menu's Replay entry reach for the newest round by name.
15pub fn library_dir() -> std::path::PathBuf {
16    crate::app::paths::data_dir().join("replays")
17}
18
19/// One kept round: the file, and what to call it on screen.
20pub struct Kept {
21    pub path: std::path::PathBuf,
22    pub label: String,
23}
24
25/// The library screen's state: what is on the shelf, and where the cursor is.
26#[derive(Resource, Default)]
27pub struct Library {
28    pub kept: Vec<Kept>,
29    pub selected: usize,
30    /// First row shown: the shelf can hold more rounds than the card has
31    /// rows (see [`ROWS`]), so the window slides to keep the cursor in it.
32    pub scroll: usize,
33    /// Set when a pick fails to parse, so the screen can say so.
34    pub feedback: String,
35}
36
37impl Library {
38    /// Keep the cursor on the shelf and the window on the cursor. Called
39    /// after every move and every re-read: the shelf can shrink under the
40    /// cursor (a pasted code that failed, a re-entry after pruning) as
41    /// well as grow past the rows.
42    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        // Only as far as it must: down when the cursor falls off the
51        // bottom, up when it climbs off the top, and never past a shelf
52        // that got shorter.
53        *scroll = (*scroll)
54            .min(*selected)
55            .max(selected.saturating_sub(ROWS - 1))
56            .min(kept.len().saturating_sub(ROWS));
57    }
58}
59
60/// The file name for a round finished now, by who won it.
61///
62/// Named from the wall clock rather than a counter: a counter needs the
63/// directory read to know what is next, and two copies of the game would
64/// disagree about it. Seconds since the epoch sort correctly as text for the
65/// next few hundred years.
66pub 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
75/// Drop the oldest rounds until at most `cap` are kept.
76///
77/// Called after a round is filed, so the shelf is trimmed at the one moment
78/// it can have grown. Best-effort like every other write here: a file that
79/// will not delete stays on the shelf rather than stopping the game, and
80/// the next round tries again.
81///
82/// Only rounds are counted and only rounds are removed. `last.txt` and the
83/// highlight reel live in the same directory and belong to the menu, not to
84/// the shelf.
85pub fn prune(cap: u8) {
86    prune_in(&library_dir(), cap);
87}
88
89/// [`prune`] on a directory named outright, so a test can hand it a
90/// scratch folder instead of the player's library.
91pub 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
100/// Everything on the shelf, newest first.
101pub fn shelf() -> Vec<Kept> {
102    shelf_in(&library_dir())
103}
104
105/// [`shelf`] on a directory named outright, for the same reason as
106/// [`prune_in`]: the data directory comes from the process environment,
107/// and a test that changes that races every other test in the binary.
108pub 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    // Newest first: the names sort by timestamp, so this is just a reverse.
128    kept.sort_by(|a, b| b.path.cmp(&a.path));
129    kept
130}
131
132/// `round_0000012345_Anna.txt` reads as "Anna, 12345". The timestamp is
133/// shown as a date only if the clock agrees it is one; a file copied from
134/// another machine is still listed, just plainly.
135fn 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        // The separator is not decoration: "15/08 09:49  Mon" reads as a
146        // weekday, and Mon is a player.
147        Some(stamp) => format!("{}  -  {winner}", clock(stamp)),
148        None => stem,
149    }
150}
151
152/// A stamp as `dd/mm hh:mm`, on the clock in the room. No date library and
153/// no need for one: days since the epoch convert with the same arithmetic
154/// the daily challenge already uses, and the offset to add first comes from
155/// the system zone (see [`crate::app::clock::local_offset`]).
156///
157/// The offset is taken at the stamp rather than now, so a round kept in
158/// August and one kept in January are each shown on the clock that was on
159/// the wall when they were played, not on today's.
160fn clock(stamp: u64) -> String {
161    stamp_text(stamp, crate::app::clock::local_offset(stamp))
162}
163
164/// The same, with the offset handed in: what the shelf renders is a
165/// function of the stamp and the zone, and only the zone needs a file to
166/// answer it. Split so the format can be tested on a machine in any zone,
167/// which is every machine.
168fn stamp_text(stamp: u64, offset: i64) -> String {
169    // A clock set before 1970 already reads as zero (`clock::now_secs`);
170    // west of Greenwich the offset can push such a stamp below it, and the
171    // arithmetic below is unsigned. It stays at the epoch.
172    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/// The "nothing kept yet" line, shown in place of the rows on an empty shelf.
186#[derive(Component)]
187pub struct EmptyShelfNote;
188
189/// How many rows the shelf shows at once.
190const 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                // The empty-shelf line, which the rows cannot carry: a row
207                // cell is one shelf-wide column that does not wrap, and this
208                // sentence is wider than it, so squeezed in there it ran off
209                // the side of the card. Its own line, the shelf's width and
210                // free to wrap, keeps it inside. Blank, and out of the way,
211                // the moment there is a round to show.
212                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                    // No `no_wrap` here, unlike the row cells: the width is
221                    // fixed to the shelf and the line is free to take a
222                    // second row rather than run off the card.
223                    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
239/// The shelf is one column, as wide as a stamp and a winner's name.
240const 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    // The empty-shelf line carries the "nothing yet" message now, so the
251    // rows never do. On a shelf with rounds on it, it is taken out of the
252    // layout entirely (not merely blanked), or its empty line would sit
253    // between the heading and the first round as a gap.
254    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    // Row `r` of the card shows shelf entry `scroll + r`.
260    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            // "draw" is filed in the file name, where it has to stay the
264            // same word on every machine; it is read out translated.
265            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
287/// Arrows pick, Enter watches, C copies a share code, V pastes one, Esc
288/// leaves.
289pub 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    // The cursor walks the whole shelf, not just the rows on show; the
304    // window follows it.
305    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        // Whatever happened, the shelf may have grown; re-read it so the new
316        // round is there to pick rather than waiting for the screen to be
317        // left and come back to.
318        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
339/// The selected round as a share code on the clipboard.
340///
341/// A round is thirty kilobytes of text and compresses to about eight
342/// thousand characters, which is a paste rather than a thing anyone types,
343/// so the message says how long it is and the player decides whether that
344/// goes in a chat window or a file.
345fn 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
366/// The round a pasted code carries, and the text to file it under, or what
367/// to say about why not.
368///
369/// Parsed before anything is written, so what lands in the library is a round
370/// this build can actually play back: a file that only fails when someone
371/// presses Enter on it is worse than no file. Takes the pasted code rather
372/// than the clipboard so it can be tested without touching the real one.
373fn 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
383/// Who took a pasted round, for the shelf to file it under like every
384/// other round. The file carries the inputs and not the result, so the
385/// round is played through to its last tick; it carries no team mode
386/// either, so it is read as a free-for-all. Named as the recording named
387/// the seat, or by its number. It used to be filed under the beach's
388/// name, next to rounds filed under a player's.
389fn 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
403/// A round off the clipboard, onto the shelf.
404fn 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/// Playback speed while watching, so a three-minute round can be skimmed.
418#[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    /// The speeds the key steps through.
429    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
440/// `S` cycles 1x, 2x, 4x while a replay is playing.
441pub 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    /// A shelf longer than the card scrolls: the window follows the cursor
456    /// down past the last row and back up, and never opens past the end.
457    #[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        // Down to the last shown row: still no scroll.
471        library.selected = ROWS - 1;
472        library.settle();
473        assert_eq!(library.scroll, 0);
474        // One further: the window slides by one.
475        library.selected = ROWS;
476        library.settle();
477        assert_eq!(library.scroll, 1);
478        // The last entry is reachable, and the window shows a full card.
479        library.selected = ROWS + 7;
480        library.settle();
481        assert_eq!(library.scroll, 8);
482        // Back to the top: the window climbs with the cursor.
483        library.selected = 3;
484        library.settle();
485        assert_eq!(library.scroll, 3);
486        // A shelf that shrinks pulls the cursor and the window back in.
487        library.kept.truncate(4);
488        library.selected = 10;
489        library.settle();
490        assert_eq!((library.selected, library.scroll), (3, 0));
491        // An empty shelf is a cursor at nought and no window at all.
492        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        // A name with punctuation, or none at all, still makes a file name.
501        assert_eq!(file_name(7, "Bo/../etc"), "round_0000000007_Bo____etc.txt");
502        assert_eq!(file_name(7, ""), "round_0000000007_.txt");
503        // Zero-padded so plain text sorting is chronological.
504        assert!(file_name(2, "a") < file_name(10, "a"));
505        // And over-long winners are cut rather than making a silly path.
506        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        // A file from somewhere else is listed under its own name.
515        let odd = label_of(std::path::Path::new("replays/round_handmade.txt"));
516        assert_eq!(odd, "round_handmade");
517    }
518
519    /// A stamp reads on the clock that was on the wall, which is not the
520    /// one in Greenwich. The offset is handed in here because the machine
521    /// running this is in whatever zone it is in.
522    #[test]
523    fn a_stamp_reads_on_the_local_clock() {
524        // 2025-07-01 00:00:00 UTC, day 20270.
525        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        // West of Greenwich the same instant is still the day before, which
529        // is the case a bare `stamp % 86_400` gets wrong.
530        assert_eq!(
531            stamp_text(NOON_LESS_TWELVE, -4 * 3600),
532            "30/06 20:00",
533            "Halifax"
534        );
535        // And a stamp so early the offset would take it below the epoch
536        // stays at the epoch rather than wrapping to the far future.
537        assert_eq!(stamp_text(60, -4 * 3600), "01/01 00:00");
538    }
539
540    /// A round survives being carried as a share code and comes back as the
541    /// same text, filed under whoever took it, which for forty ticks of
542    /// nobody banking anything is nobody. The clipboard itself is not
543    /// touched: with `system_clipboard` on it is the real one, and a test
544    /// has no business sitting on what someone just copied.
545    #[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    /// A pasted round is filed under whoever took it, worked out by
570    /// playing it through: named as the recording named the seat, or by
571    /// its number when it did not.
572    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    /// The three ways a paste is not a round, each with its own answer. One
601    /// shrug for all three leaves a player guessing which mistake they made.
602    #[test]
603    fn what_is_not_a_round_says_which_way_it_is_not() {
604        let tr = &crate::app::i18n::EN;
605        // Nothing readable on the clipboard at all.
606        assert_eq!(round_from(None, tr), Err(tr.code_none_pasted.to_string()));
607        // A real code, but for a level - the commonest mix-up, since both
608        // are copied with the same gesture from different screens.
609        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        // A round code carrying something that is not a round.
616        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    /// The transport clock reads as a clock. It is the one place a player
621    /// sees how far through a round they are, and `0:7` for seven seconds
622    /// past the minute is the kind of thing that reads as a bug.
623    #[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        // A three-minute Turf War, which is the longest round that ships.
630        assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 185), "3:05");
631    }
632
633    /// It counts in ticks, not in frames: the transport is drawn every
634    /// frame and the sim advances thirty times a second, so a clock that
635    /// read the wrong one would run at whatever the display happened to do.
636    #[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// --- the transport bar ------------------------------------------------------
655
656/// The row of controls under a replay: where the round has got to, whether
657/// it is running, and how fast.
658///
659/// A recording used to be a thing you started and then watched go past,
660/// with the speed hidden in the status slot and no way to stop it. This is
661/// the shape people already know from every video player: a track that
662/// fills, a state, and a clock.
663#[derive(Component)]
664pub struct ReplayBar;
665
666/// The filled part of the track. Its width is the round's progress.
667#[derive(Component)]
668pub struct ReplayProgress;
669
670/// The line under the track: the state, the speed, and the clock.
671#[derive(Component)]
672pub struct ReplayReadout;
673
674/// How wide the track is drawn, in pixels.
675const TRACK: f32 = 420.0;
676
677/// Keep the bar up while a recording is playing, and only then.
678///
679/// Reconciled every frame rather than hung off the screen transition: a
680/// replay is entered and left through the same versus screen a live match
681/// uses, so there is no transition of its own to hang it on.
682pub 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                // The band the chrome reserves at the foot of the board,
704                // which the field guide stands down from while a
705                // recording is playing.
706                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            // The track: a sunk rail with a gold fill creeping along it.
717            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
763/// Minutes and seconds of a tick count, at the sim's fixed rate.
764fn stamp(ticks: usize) -> String {
765    let seconds = ticks / crate::sim::TICKS_PER_SECOND as usize;
766    format!("{}:{:02}", seconds / 60, seconds % 60)
767}
768
769/// Write the track and the readout from where the recording has got to.
770pub 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        // Written only when it moves: a `Node` marked changed relays out
786        // the whole interface, every frame the replay plays.
787        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
808/// Space stops and starts a recording.
809///
810/// Only while one is playing: in a live match the same key pulls a
811/// signpost, and a round nobody can pause is the point of a live match.
812pub 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    // Not behind the pause card. That card sets `Paused` once, on opening,
819    // and never re-asserts it, so a Space pressed under it would start the
820    // recording running again with the card still up saying it is stopped.
821    if menu.open || playback.0.is_none() {
822        return;
823    }
824    if keys.just_pressed(KeyCode::Space) {
825        paused.0 = !paused.0;
826    }
827}