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    /// When it was played and who took it; for a draw, only when, since
23    /// the word for a draw is the player's language's and is added as the
24    /// row is drawn.
25    pub label: String,
26    /// Nobody took it. A flag rather than a word in the label, so a player
27    /// who calls themselves "draw" is still a winner.
28    pub draw: bool,
29}
30
31/// The library screen's state: what is on the shelf, and where the cursor is.
32#[derive(Resource, Default)]
33pub struct Library {
34    pub kept: Vec<Kept>,
35    pub selected: usize,
36    /// First row shown: the shelf can hold more rounds than the card has
37    /// rows (see [`ROWS`]), so the window slides to keep the cursor in it.
38    pub scroll: usize,
39    /// Set when a pick fails to parse, so the screen can say so.
40    pub feedback: String,
41}
42
43impl Library {
44    /// Keep the cursor on the shelf and the window on the cursor. Called
45    /// after every move and every re-read: the shelf can shrink under the
46    /// cursor (a pasted code that failed, a re-entry after pruning) as
47    /// well as grow past the rows.
48    pub fn settle(&mut self) {
49        let Self {
50            kept,
51            selected,
52            scroll,
53            feedback: _,
54        } = self;
55        *selected = (*selected).min(kept.len().saturating_sub(1));
56        // Only as far as it must: down when the cursor falls off the
57        // bottom, up when it climbs off the top, and never past a shelf
58        // that got shorter.
59        *scroll = (*scroll)
60            .min(*selected)
61            .max(selected.saturating_sub(ROWS - 1))
62            .min(kept.len().saturating_sub(ROWS));
63    }
64}
65
66/// The file name for a round finished now, by who won it, or `None` for
67/// a draw.
68///
69/// Named from the wall clock rather than a counter: a counter needs the
70/// directory read to know what is next, and two copies of the game would
71/// disagree about it. Seconds since the epoch sort correctly as text for the
72/// next few hundred years.
73///
74/// A draw is the name with no winner part at all, which no winner's name
75/// can produce: even an empty name leaves its `_`. It used to be filed under
76/// the word "draw", and a player who had called themselves that was read
77/// back as nobody.
78pub fn file_name(stamp: u64, winner: Option<&str>) -> String {
79    match winner {
80        Some(winner) => {
81            let tidy: String = winner
82                .chars()
83                .map(|c| if c.is_alphanumeric() { c } else { '_' })
84                .take(12)
85                .collect();
86            format!("round_{stamp:010}_{tidy}.txt")
87        }
88        None => format!("round_{stamp:010}.txt"),
89    }
90}
91
92/// Where in `dir` to file a round finished at `stamp`: [`file_name`], or,
93/// when that is taken, the same at the next free second.
94///
95/// Two rounds with one winner in one second are one file name, and the
96/// second write replaced the first: two codes pasted in quick succession,
97/// say. A second later on the label costs nothing, since the shelf shows
98/// minutes, and the order stays the order they were filed in.
99pub fn free_path(dir: &std::path::Path, stamp: u64, winner: Option<&str>) -> std::path::PathBuf {
100    (stamp..)
101        .map(|at| dir.join(file_name(at, winner)))
102        .find(|path| !path.exists())
103        .unwrap_or_else(|| dir.join(file_name(stamp, winner)))
104}
105
106/// Drop the oldest rounds until at most `cap` are kept.
107///
108/// Called after a round is filed, the one moment the shelf can have grown.
109/// Best-effort like every other write here: a file that will not delete
110/// stays on the shelf, and the next round tries again.
111///
112/// Only rounds are counted and only rounds are removed. `last.txt` and the
113/// highlight reel live in the same directory and belong to the menu, not to
114/// the shelf.
115pub fn prune(cap: u8) {
116    prune_in(&library_dir(), cap);
117}
118
119/// [`prune`] on a directory named outright, so a test can hand it a
120/// scratch folder instead of the player's library.
121pub fn prune_in(dir: &std::path::Path, cap: u8) {
122    let kept = shelf_in(dir);
123    for old in kept.iter().skip(usize::from(cap)) {
124        if let Err(e) = std::fs::remove_file(&old.path) {
125            warn!("could not drop the oldest round: {e}");
126        }
127    }
128}
129
130/// Everything on the shelf, newest first.
131pub fn shelf() -> Vec<Kept> {
132    shelf_in(&library_dir())
133}
134
135/// [`shelf`] on a directory named outright, for the same reason as
136/// [`prune_in`]: the data directory comes from the process environment,
137/// and a test that changes that races every other test in the binary.
138pub fn shelf_in(dir: &std::path::Path) -> Vec<Kept> {
139    let Ok(dir) = std::fs::read_dir(dir) else {
140        return Vec::new();
141    };
142    let mut kept: Vec<Kept> = dir
143        .filter_map(Result::ok)
144        .map(|entry| entry.path())
145        .filter(|path| {
146            path.extension().is_some_and(|ext| ext == "txt")
147                && path
148                    .file_name()
149                    .and_then(|n| n.to_str())
150                    .is_some_and(|n| n.starts_with("round_"))
151        })
152        .map(|path| {
153            let (label, draw) = label_of(&path);
154            Kept { path, label, draw }
155        })
156        .collect();
157    // Newest first: the names sort by timestamp, so this is just a reverse.
158    kept.sort_by(|a, b| b.path.cmp(&a.path));
159    kept
160}
161
162/// `round_0000012345_Anna.txt` reads as "Anna, 12345", and
163/// `round_0000012345.txt`, with no winner part, as a draw at 12345 (see
164/// [`file_name`]); the second half of the answer says which. The timestamp
165/// is shown as a date only if the clock agrees it is one; a file copied
166/// from another machine is still listed, just plainly.
167fn label_of(path: &std::path::Path) -> (String, bool) {
168    let stem = path
169        .file_stem()
170        .and_then(|s| s.to_str())
171        .unwrap_or("round")
172        .to_string();
173    let mut parts = stem.splitn(3, '_');
174    let (_, stamp, winner) = (parts.next(), parts.next(), parts.next());
175    match (stamp.and_then(|s| s.parse::<u64>().ok()), winner) {
176        // The separator is not decoration: "15/08 09:49  Mon" reads as a
177        // weekday, and Mon is a player.
178        (Some(stamp), Some(winner)) => (
179            format!("{}  -  {}", clock(stamp), winner.replace('_', " ")),
180            false,
181        ),
182        (Some(stamp), None) => (clock(stamp), true),
183        (None, _) => (stem, false),
184    }
185}
186
187/// A stamp as `dd/mm hh:mm`, on the clock in the room. No date library and
188/// no need for one: days since the epoch convert with the same arithmetic
189/// the daily challenge already uses, and the offset to add first comes from
190/// the system zone (see [`crate::app::clock::local_offset`]).
191///
192/// The offset is taken at the stamp rather than now, so a round is shown on
193/// the clock that was on the wall when it was played.
194fn clock(stamp: u64) -> String {
195    stamp_text(stamp, crate::app::clock::local_offset(stamp))
196}
197
198/// The same, with the offset handed in, so the format can be tested on a
199/// machine in any zone.
200fn stamp_text(stamp: u64, offset: i64) -> String {
201    // A clock set before 1970 already reads as zero (`clock::now_secs`);
202    // west of Greenwich the offset can push such a stamp below it, and the
203    // arithmetic below is unsigned. It stays at the epoch.
204    let local = (stamp as i64 + offset).max(0) as u64;
205    let secs = local % 86_400;
206    let (h, m) = (secs / 3600, (secs % 3600) / 60);
207    let (day, month) = crate::app::clock::civil_date((local / 86_400) as u32);
208    format!("{day:02}/{month:02} {h:02}:{m:02}")
209}
210
211#[derive(Component)]
212pub struct LibraryUi;
213
214#[derive(Component)]
215pub struct LibraryRow(usize);
216
217/// The "nothing kept yet" line, shown in place of the rows on an empty shelf.
218#[derive(Component)]
219pub struct EmptyShelfNote;
220
221/// How many rows the shelf shows at once.
222const ROWS: usize = 12;
223
224pub fn enter_library(
225    mut commands: Commands,
226    settings: Res<GameSettings>,
227    mut library: ResMut<Library>,
228) {
229    library.kept = shelf();
230    library.settle();
231    let tr = settings.tr();
232    // Any replay file at all can be watched, from a cup's folder or a
233    // friend's message: the status line says how, until it has news.
234    library.feedback = tr.replay_drop_hint.to_string();
235    commands
236        .spawn((LibraryUi, menu_ui::between_bars()))
237        .with_children(|wrap| {
238            wrap.spawn(menu_ui::screen_card()).with_children(|card| {
239                menu_ui::heading_row(card, tr.replays_heading, None);
240                // The empty-shelf line, which the rows cannot carry: a row
241                // cell is one shelf-wide column that does not wrap, and
242                // this sentence is wider than it. Its own line, free to
243                // wrap, keeps it inside, and it goes the moment there is a
244                // round to show.
245                card.spawn((
246                    EmptyShelfNote,
247                    Text::new(""),
248                    TextFont {
249                        font_size: FontSize::Px(menu_ui::type_scale::ROW),
250                        ..default()
251                    },
252                    TextColor(palette::PARCHMENT.with_alpha(0.6)),
253                    // No `no_wrap` here, unlike the row cells: the width is
254                    // fixed to the shelf and the line is free to take a
255                    // second row rather than run off the card.
256                    Node {
257                        width: Val::Px(ROW_W),
258                        margin: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
259                        ..default()
260                    },
261                ));
262                for row in 0..ROWS {
263                    card.spawn((LibraryRow(row), menu_ui::card_row()))
264                        .with_children(|line| {
265                            line.spawn((LibraryRow(row), menu_ui::cell(ROW_W, 19.0)));
266                        });
267                }
268            });
269        });
270}
271
272/// The shelf is one column, as wide as a stamp and a winner's name.
273const ROW_W: f32 = 420.0;
274
275pub fn update_library(
276    library: Res<Library>,
277    settings: Res<GameSettings>,
278    mut cells: Query<(&LibraryRow, &mut Text, &mut TextColor), Without<EmptyShelfNote>>,
279    mut rows: Query<(&LibraryRow, &mut BackgroundColor, &mut Node), Without<EmptyShelfNote>>,
280    mut empty_note: Query<(&mut Text, &mut Node), With<EmptyShelfNote>>,
281) {
282    let tr = settings.tr();
283    // The empty-shelf line carries the "nothing yet" message now, so the
284    // rows never do. On a shelf with rounds on it, it is taken out of the
285    // layout entirely (not merely blanked), or its empty line would sit
286    // between the heading and the first round as a gap.
287    let empty = library.kept.is_empty();
288    for (mut note, mut node) in &mut empty_note {
289        menu_ui::set_text(&mut note, if empty { tr.replays_empty } else { "" });
290        menu_ui::set_shown(&mut node, empty);
291    }
292    // Row `r` of the card shows shelf entry `scroll + r`.
293    let at = |row: usize| library.scroll + row;
294    for (row, mut text, mut color) in &mut cells {
295        let line = match library.kept.get(at(row.0)) {
296            // A draw is filed as no winner at all, and said here in the
297            // player's language.
298            Some(kept) if kept.draw => format!("{}  -  {}", kept.label, tr.replay_draw),
299            Some(kept) => kept.label.clone(),
300            None => String::new(),
301        };
302        let picked = at(row.0) == library.selected && !library.kept.is_empty();
303        menu_ui::set_text(&mut text, &line);
304        menu_ui::set_color(
305            &mut color,
306            match (picked, library.kept.get(at(row.0)).is_some()) {
307                (true, _) => Color::WHITE,
308                (false, true) => palette::PARCHMENT.with_alpha(0.75),
309                (false, false) => palette::PARCHMENT.with_alpha(0.45),
310            },
311        );
312    }
313    for (row, mut fill, mut node) in &mut rows {
314        let ground = menu_ui::band(at(row.0) == library.selected && !library.kept.is_empty());
315        menu_ui::set_bg(&mut fill, ground);
316        // A slot with nothing in it still holds its height, so pasting a
317        // round does not resize the card under the cursor. An empty shelf
318        // has no cursor and nothing to keep still, and twelve blank slots
319        // under one sentence is the first thing a new player sees here, so
320        // there the note really does stand in place of the rows.
321        menu_ui::set_shown(&mut node, !empty);
322    }
323}
324
325/// Arrows pick, Enter watches, C copies a share code, V pastes one, Esc
326/// leaves.
327pub fn library_input(
328    keys: Res<ButtonInput<KeyCode>>,
329    settings: Res<GameSettings>,
330    caps: Res<crate::app::keycaps::KeyCaps>,
331    mut clipboard: ResMut<Clipboard>,
332    mut library: ResMut<Library>,
333    mut playback: ResMut<Playback>,
334    mut next_screen: ResMut<NextState<Screen>>,
335) {
336    let tr = settings.tr();
337    if keys.just_pressed(KeyCode::Escape) {
338        next_screen.set(Screen::Menu);
339        return;
340    }
341    // The cursor walks the whole shelf, not just the rows on show; the
342    // window follows it.
343    let shown = library.kept.len();
344    if shown > 0 {
345        library.selected = menu_ui::nav(&keys, library.selected, shown);
346        library.settle();
347    }
348    if caps.just_pressed(&keys, 'C') {
349        library.feedback = copy_selected(&mut clipboard, tr, &library);
350    }
351    if caps.just_pressed(&keys, 'V') {
352        library.feedback = keep_pasted(&mut clipboard, tr, &library_dir(), settings.replay_cap);
353        // Whatever happened, the shelf may have grown; re-read it so the new
354        // round is there to pick rather than waiting for the screen to be
355        // left and come back to.
356        library.kept = shelf();
357        library.settle();
358    }
359    if !menu_ui::enter(&keys) {
360        return;
361    }
362    let Some(kept) = library.kept.get(library.selected) else {
363        return;
364    };
365    match std::fs::read_to_string(&kept.path)
366        .map_err(|e| e.to_string())
367        .and_then(|text| Replay::parse(&text))
368    {
369        Ok(replay) => {
370            playback.0 = Some((replay, 0));
371            next_screen.set(Screen::Versus);
372        }
373        Err(e) => library.feedback = e,
374    }
375}
376
377/// The selected round as a share code on the clipboard.
378///
379/// A round is thirty kilobytes of text and compresses to about eight
380/// thousand characters, so the message says how long it is and the player
381/// decides whether that goes in a chat window or a file.
382fn copy_selected(
383    clipboard: &mut Clipboard,
384    tr: &crate::app::i18n::Tr,
385    library: &Library,
386) -> String {
387    let Some(kept) = library.kept.get(library.selected) else {
388        return tr.replays_empty.to_string();
389    };
390    let text = match std::fs::read_to_string(&kept.path) {
391        Ok(text) => text,
392        Err(e) => return fill(tr.code_round_bad, &[("e", &e.to_string())]),
393    };
394    crate::app::codes::copy_feedback(
395        clipboard,
396        tr,
397        crate::share::Kind::Round,
398        text.as_bytes(),
399        tr.code_copied,
400    )
401}
402
403/// The round a pasted code carries, and the text to file it under, or what
404/// to say about why not.
405///
406/// Parsed before anything is written, so what lands in the library is a
407/// round this build can play back rather than a file that fails when
408/// somebody presses Enter on it. Takes the pasted code rather than the
409/// clipboard so it can be tested without touching the real one.
410fn round_from(
411    pasted: Option<(crate::share::Kind, Vec<u8>)>,
412    tr: &crate::app::i18n::Tr,
413) -> Result<(Option<String>, String), String> {
414    let text =
415        crate::app::codes::payload_text(pasted, tr, crate::share::Kind::Round, tr.code_round_bad)?;
416    let replay = Replay::parse(&text).map_err(|e| fill(tr.code_round_bad, &[("e", &e)]))?;
417    Ok((winner_of(&replay, tr), text))
418}
419
420/// Who took a pasted round, for the shelf to file it under like every
421/// other round, or `None` for a draw. The file carries the inputs and not
422/// the result, so the round is played through to its last tick; it carries
423/// no team mode either, so it is read as a free-for-all. Named as the
424/// recording named the seat, or by its number.
425fn winner_of(replay: &Replay, tr: &crate::app::i18n::Tr) -> Option<String> {
426    let board = replay.playback();
427    let leaders = crate::app::side_panels::leading_seats(
428        board.scores(),
429        board.castle_seats(),
430        crate::app::teams::TeamMode::Solo,
431    );
432    let seat = leaders.iter().position(|&led| led)?;
433    Some(if replay.names[seat].is_empty() {
434        crate::app::seat_label(tr, seat as u8)
435    } else {
436        replay.names[seat].clone()
437    })
438}
439
440/// A round off the clipboard, onto the shelf in `dir`.
441fn keep_pasted(
442    clipboard: &mut Clipboard,
443    tr: &crate::app::i18n::Tr,
444    dir: &std::path::Path,
445    cap: u8,
446) -> String {
447    keep(crate::app::codes::paste(clipboard), tr, dir, cap)
448}
449
450/// A pasted code onto the shelf in `dir`, which then keeps at most `cap`
451/// rounds as it does after a round is played: a shelf filled by pasting is
452/// still the shelf the setting promises. Takes the code rather than the
453/// clipboard, as [`round_from`] does, so a test can file one.
454fn keep(
455    pasted: Option<(crate::share::Kind, Vec<u8>)>,
456    tr: &crate::app::i18n::Tr,
457    dir: &std::path::Path,
458    cap: u8,
459) -> String {
460    let (winner, text) = match round_from(pasted, tr) {
461        Ok(round) => round,
462        Err(complaint) => return complaint,
463    };
464    let path = free_path(dir, crate::app::clock::now_secs(), winner.as_deref());
465    match crate::app::paths::write_atomic(&path, &text) {
466        Ok(()) => {
467            prune_in(dir, cap);
468            tr.code_round_saved.to_string()
469        }
470        Err(e) => fill(tr.code_round_bad, &[("e", &e.to_string())]),
471    }
472}
473
474/// Playback speed while watching, so a three-minute round can be skimmed.
475#[derive(Resource)]
476pub struct PlaybackSpeed(pub u8);
477
478impl Default for PlaybackSpeed {
479    fn default() -> Self {
480        PlaybackSpeed(1)
481    }
482}
483
484impl PlaybackSpeed {
485    /// The speeds the key steps through.
486    pub const STEPS: [u8; 3] = [1, 2, 4];
487
488    fn stepped(self) -> PlaybackSpeed {
489        let next = Self::STEPS
490            .iter()
491            .position(|&s| s == self.0)
492            .map_or(0, |at| (at + 1) % Self::STEPS.len());
493        PlaybackSpeed(Self::STEPS[next])
494    }
495}
496
497/// `S` cycles 1x, 2x, 4x while a replay is playing.
498pub fn playback_speed_input(
499    keys: Res<ButtonInput<KeyCode>>,
500    playback: Res<Playback>,
501    mut speed: ResMut<PlaybackSpeed>,
502) {
503    if playback.0.is_some() && keys.just_pressed(KeyCode::KeyS) {
504        *speed = PlaybackSpeed(speed.0).stepped();
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    /// A shelf longer than the card scrolls: the window follows the cursor
513    /// down past the last row and back up, and never opens past the end.
514    #[test]
515    fn the_shelf_window_follows_the_cursor() {
516        let mut library = Library {
517            kept: (0..ROWS + 8)
518                .map(|n| Kept {
519                    path: format!("{n}.txt").into(),
520                    label: n.to_string(),
521                    draw: false,
522                })
523                .collect(),
524            ..Library::default()
525        };
526        library.settle();
527        assert_eq!((library.selected, library.scroll), (0, 0));
528        // Down to the last shown row: still no scroll.
529        library.selected = ROWS - 1;
530        library.settle();
531        assert_eq!(library.scroll, 0);
532        // One further: the window slides by one.
533        library.selected = ROWS;
534        library.settle();
535        assert_eq!(library.scroll, 1);
536        // The last entry is reachable, and the window shows a full card.
537        library.selected = ROWS + 7;
538        library.settle();
539        assert_eq!(library.scroll, 8);
540        // Back to the top: the window climbs with the cursor.
541        library.selected = 3;
542        library.settle();
543        assert_eq!(library.scroll, 3);
544        // A shelf that shrinks pulls the cursor and the window back in.
545        library.kept.truncate(4);
546        library.selected = 10;
547        library.settle();
548        assert_eq!((library.selected, library.scroll), (3, 0));
549        // An empty shelf is a cursor at nought and no window at all.
550        library.kept.clear();
551        library.settle();
552        assert_eq!((library.selected, library.scroll), (0, 0));
553    }
554
555    #[test]
556    fn round_names_sort_newest_last_and_survive_odd_winners() {
557        let name = |stamp, winner| file_name(stamp, Some(winner));
558        assert_eq!(name(12_345, "Anna"), "round_0000012345_Anna.txt");
559        // A name with punctuation, or none at all, still makes a file name.
560        assert_eq!(name(7, "Bo/../etc"), "round_0000000007_Bo____etc.txt");
561        assert_eq!(name(7, ""), "round_0000000007_.txt");
562        // A draw has no winner part, which no name can spell.
563        assert_eq!(file_name(7, None), "round_0000000007.txt");
564        // Zero-padded so plain text sorting is chronological.
565        assert!(name(2, "a") < name(10, "a"));
566        // And over-long winners are cut rather than making a silly path.
567        assert!(name(1, &"x".repeat(40)).len() < 40);
568    }
569
570    #[test]
571    fn labels_read_as_a_time_and_a_winner() {
572        let (label, draw) = label_of(std::path::Path::new("replays/round_1751328000_Anna.txt"));
573        assert!(label.contains("Anna"), "{label}");
574        assert!(label.contains('/') && label.contains(':'), "{label}");
575        assert!(!draw);
576        // A file from somewhere else is listed under its own name.
577        let odd = label_of(std::path::Path::new("replays/round_handmade.txt"));
578        assert_eq!(odd, ("round_handmade".to_string(), false));
579    }
580
581    /// A draw is a flag, not a word in the label. The word used to be
582    /// swapped for its translation wherever it appeared, so a winner called
583    /// "drawbridge" came out half translated, and one called "draw" was
584    /// shown as a tie.
585    #[test]
586    fn a_draw_is_told_from_a_winner_called_draw() {
587        let read = |winner| label_of(std::path::Path::new(&file_name(1_751_328_000, winner)));
588        let (tie, draw) = read(None);
589        assert!(draw, "{tie}");
590        assert!(
591            !tie.contains("draw"),
592            "the word is the shelf's to add: {tie}"
593        );
594        for name in ["draw", "drawbridge"] {
595            let (label, draw) = read(Some(name));
596            assert!(!draw, "{name} won");
597            assert!(label.ends_with(&format!("  -  {name}")), "{label}");
598        }
599    }
600
601    /// A scratch folder of its own, gone before and after.
602    fn scratch(name: &str) -> std::path::PathBuf {
603        let dir = std::env::temp_dir().join(format!("pinch-{name}-{}", std::process::id()));
604        let _ = std::fs::remove_dir_all(&dir);
605        std::fs::create_dir_all(&dir).expect("a scratch folder");
606        dir
607    }
608
609    /// Two codes for rounds the same seat won, pasted within one second,
610    /// are two rounds on the shelf: they used to share a file name and the
611    /// second replaced the first. And pasting keeps the shelf to its cap,
612    /// as finishing a round does.
613    #[test]
614    fn pasted_rounds_keep_their_own_files_and_the_cap() {
615        let tr = &crate::app::i18n::EN;
616        let dir = scratch("pasted-rounds");
617        let code = || {
618            let text = banked_round().to_text();
619            crate::share::decode(&crate::share::encode(
620                crate::share::Kind::Round,
621                text.as_bytes(),
622            ))
623        };
624        assert_eq!(keep(code(), tr, &dir, 10), tr.code_round_saved);
625        assert_eq!(keep(code(), tr, &dir, 10), tr.code_round_saved);
626        assert_eq!(shelf_in(&dir).len(), 2, "two pastes, two rounds");
627        // The second-later rule itself, on a clock held still.
628        std::fs::write(dir.join(file_name(5, Some("P1"))), "x").expect("written");
629        assert_eq!(
630            free_path(&dir, 5, Some("P1")),
631            dir.join(file_name(6, Some("P1"))),
632            "a taken name is passed over for the next second"
633        );
634        assert_eq!(
635            free_path(&dir, 5, None),
636            dir.join(file_name(5, None)),
637            "and a free one is kept"
638        );
639        for _ in 0..4 {
640            keep(code(), tr, &dir, 3);
641        }
642        assert_eq!(shelf_in(&dir).len(), 3, "and the cap holds");
643        let _ = std::fs::remove_dir_all(&dir);
644    }
645
646    /// A stamp reads on the clock that was on the wall, which is not the
647    /// one in Greenwich. The offset is handed in here because the machine
648    /// running this is in whatever zone it is in.
649    #[test]
650    fn a_stamp_reads_on_the_local_clock() {
651        // 2025-07-01 00:00:00 UTC, day 20270.
652        const NOON_LESS_TWELVE: u64 = 1_751_328_000;
653        assert_eq!(stamp_text(NOON_LESS_TWELVE, 0), "01/07 00:00", "Greenwich");
654        assert_eq!(stamp_text(NOON_LESS_TWELVE, 7200), "01/07 02:00", "Paris");
655        // West of Greenwich the same instant is still the day before, which
656        // is the case a bare `stamp % 86_400` gets wrong.
657        assert_eq!(
658            stamp_text(NOON_LESS_TWELVE, -4 * 3600),
659            "30/06 20:00",
660            "Halifax"
661        );
662        // And a stamp so early the offset would take it below the epoch
663        // stays at the epoch rather than wrapping to the far future.
664        assert_eq!(stamp_text(60, -4 * 3600), "01/01 00:00");
665    }
666
667    /// A round survives being carried as a share code and comes back as the
668    /// same text, filed under whoever took it, which for forty ticks of
669    /// nobody banking anything is nobody. The clipboard itself is not
670    /// touched: with `system_clipboard` on it is the real one.
671    #[test]
672    fn a_round_travels_as_a_code_and_comes_back() {
673        let tr = &crate::app::i18n::EN;
674        let mut replay = Replay::new(crate::sim::Level::from_board(
675            "Turf War",
676            3,
677            crate::sim::classic_arena(false, 2),
678        ));
679        for tick in 0..40u8 {
680            let mut actions = [crate::sim::PlayerAction::None; crate::sim::MAX_PLAYERS];
681            actions[0] = crate::sim::PlayerAction::Place {
682                x: tick % 10,
683                y: 3,
684                dir: crate::sim::Direction::Down,
685            };
686            replay.record(actions);
687        }
688        let text = replay.to_text();
689        let code = crate::share::encode(crate::share::Kind::Round, text.as_bytes());
690        let (winner, back) = round_from(crate::share::decode(&code), tr).expect("a round");
691        assert_eq!(back, text, "what went in is what comes out");
692        assert_eq!(
693            winner, None,
694            "filed under the result (a draw), not the beach"
695        );
696    }
697
698    /// A pasted round is filed under whoever took it, worked out by
699    /// playing it through: named as the recording named the seat, or by
700    /// its number when it did not.
701    fn banked_round() -> Replay {
702        let level = crate::sim::Level::parse(
703            "name: T\nposts: 1\ncrab: 0,0 R R common\nmap:\n\
704             +-+-+\n|. 0|\n+ + +\n|. .|\n+-+-+\n",
705        )
706        .expect("level");
707        let mut replay = Replay::new(level);
708        for _ in 0..60 {
709            replay.record([crate::sim::PlayerAction::None; crate::sim::MAX_PLAYERS]);
710        }
711        assert!(replay.playback().scores()[0] > 0, "the crab walked home");
712        replay
713    }
714
715    #[test]
716    fn a_pasted_round_is_filed_under_the_name_of_whoever_took_it() {
717        let tr = &crate::app::i18n::EN;
718        let mut replay = banked_round();
719        replay.names[0] = "Anna".into();
720        assert_eq!(winner_of(&replay, tr).as_deref(), Some("Anna"));
721    }
722
723    #[test]
724    fn a_pasted_round_with_no_names_is_filed_under_the_seat() {
725        let tr = &crate::app::i18n::EN;
726        assert_eq!(winner_of(&banked_round(), tr).as_deref(), Some("P1"));
727    }
728
729    /// The three ways a paste is not a round, each with its own answer. One
730    /// shrug for all three leaves a player guessing which mistake they made.
731    #[test]
732    fn what_is_not_a_round_says_which_way_it_is_not() {
733        let tr = &crate::app::i18n::EN;
734        // Nothing readable on the clipboard at all.
735        assert_eq!(round_from(None, tr), Err(tr.code_none_pasted.to_string()));
736        // A real code, but for a level - the commonest mix-up, since both
737        // are copied with the same gesture from different screens.
738        let level = crate::share::encode(crate::share::Kind::Level, b"name: X\nposts: 3\n");
739        let complaint = round_from(crate::share::decode(&level), tr).expect_err("not a round");
740        assert!(
741            complaint.contains("a level") && complaint.contains("a round"),
742            "{complaint}"
743        );
744        // A round code carrying something that is not a round.
745        let junk = crate::share::encode(crate::share::Kind::Round, b"not a replay at all");
746        assert!(round_from(crate::share::decode(&junk), tr).is_err());
747    }
748
749    /// The transport clock reads as a clock. It is the one place a player
750    /// sees how far through a round they are, and `0:7` for seven seconds
751    /// past the minute is the kind of thing that reads as a bug.
752    #[test]
753    fn the_transport_clock_pads_its_seconds() {
754        assert_eq!(stamp(0), "0:00", "the very start");
755        assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 7), "0:07");
756        assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 59), "0:59");
757        assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 60), "1:00");
758        // A three-minute Turf War, which is the longest round that ships.
759        assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 185), "3:05");
760    }
761
762    /// It counts in ticks, not in frames: the transport is drawn every
763    /// frame and the sim advances thirty times a second, so a clock that
764    /// read the wrong one would run at whatever the display happened to do.
765    #[test]
766    fn the_transport_clock_counts_in_ticks() {
767        let one_second = crate::sim::TICKS_PER_SECOND as usize;
768        assert_eq!(stamp(one_second - 1), "0:00", "part of a second is not one");
769        assert_eq!(stamp(one_second), "0:01", "and a whole one is");
770    }
771
772    #[test]
773    fn the_speed_key_cycles_the_steps() {
774        let mut speed = PlaybackSpeed::default();
775        assert_eq!(speed.0, 1);
776        for want in [2, 4, 1, 2] {
777            speed = PlaybackSpeed(speed.0).stepped();
778            assert_eq!(speed.0, want);
779        }
780    }
781}
782
783// --- the transport bar ------------------------------------------------------
784
785/// The row of controls under a replay: where the round has got to, whether
786/// it is running, and how fast.
787///
788/// The shape people already know from every video player: a track that
789/// fills, a state, and a clock.
790#[derive(Component)]
791pub struct ReplayBar;
792
793/// The filled part of the track. Its width is the round's progress.
794#[derive(Component)]
795pub struct ReplayProgress;
796
797/// The line under the track: the state, the speed, and the clock.
798#[derive(Component)]
799pub struct ReplayReadout;
800
801/// How wide the track is drawn, in pixels.
802const TRACK: f32 = 420.0;
803
804/// Keep the bar up while a recording is playing, and only then.
805///
806/// Reconciled every frame rather than hung off the screen transition: a
807/// replay is entered and left through the same versus screen a live match
808/// uses, so there is no transition of its own to hang it on.
809pub fn tend_replay_bar(
810    mut commands: Commands,
811    playback: Res<Playback>,
812    screen: Res<State<Screen>>,
813    bar: Query<Entity, With<ReplayBar>>,
814) {
815    let watching = playback.0.is_some() && *screen.get() == Screen::Versus;
816    if !watching {
817        for entity in &bar {
818            commands.entity(entity).despawn();
819        }
820        return;
821    }
822    if !bar.is_empty() {
823        return;
824    }
825    commands
826        .spawn((
827            ReplayBar,
828            Node {
829                position_type: PositionType::Absolute,
830                // The band the chrome reserves at the foot of the board,
831                // which the field guide stands down from while a
832                // recording is playing.
833                bottom: Val::Px(56.0),
834                left: Val::Px(0.0),
835                width: Val::Percent(100.0),
836                flex_direction: FlexDirection::Column,
837                align_items: AlignItems::Center,
838                row_gap: Val::Px(5.0),
839                ..default()
840            },
841        ))
842        .with_children(|column| {
843            // The track: a sunk rail with a gold fill creeping along it.
844            column
845                .spawn((
846                    Node {
847                        width: Val::Px(TRACK),
848                        height: Val::Px(6.0),
849                        border_radius: BorderRadius::all(Val::Px(3.0)),
850                        ..default()
851                    },
852                    BackgroundColor(palette::BAR_TRACK),
853                ))
854                .with_children(|rail| {
855                    rail.spawn((
856                        ReplayProgress,
857                        Node {
858                            width: Val::Percent(0.0),
859                            height: Val::Percent(100.0),
860                            border_radius: BorderRadius::all(Val::Px(3.0)),
861                            ..default()
862                        },
863                        BackgroundColor(palette::GOLD),
864                    ));
865                });
866            column
867                .spawn((
868                    Node {
869                        padding: UiRect::axes(Val::Px(12.0), Val::Px(3.0)),
870                        border_radius: BorderRadius::all(Val::Px(10.0)),
871                        ..default()
872                    },
873                    BackgroundColor(palette::PILL_FILL),
874                ))
875                .with_children(|pill| {
876                    pill.spawn((
877                        ReplayReadout,
878                        Text::new(String::new()),
879                        TextFont {
880                            font_size: FontSize::Px(menu_ui::type_scale::FINE),
881                            ..default()
882                        },
883                        TextLayout::no_wrap(),
884                        TextColor(palette::PARCHMENT),
885                    ));
886                });
887        });
888}
889
890/// Minutes and seconds of a tick count, at the sim's fixed rate.
891fn stamp(ticks: usize) -> String {
892    let seconds = ticks / crate::sim::TICKS_PER_SECOND as usize;
893    format!("{}:{:02}", seconds / 60, seconds % 60)
894}
895
896/// Write the track and the readout from where the recording has got to.
897pub fn update_replay_bar(
898    playback: Res<Playback>,
899    paused: Res<crate::app::Paused>,
900    speed: Res<PlaybackSpeed>,
901    settings: Res<GameSettings>,
902    mut track: Query<&mut Node, With<ReplayProgress>>,
903    mut readout: Query<&mut Text, With<ReplayReadout>>,
904) {
905    let Some((replay, at)) = &playback.0 else {
906        return;
907    };
908    let total = replay.inputs.len().max(1);
909    let through = (*at).min(total);
910    let width = Val::Percent(through as f32 / total as f32 * 100.0);
911    for mut node in &mut track {
912        // Written only when it moves: a `Node` marked changed relays out
913        // the whole interface, every frame the replay plays.
914        if node.width != width {
915            node.width = width;
916        }
917    }
918    let tr = settings.tr();
919    let line = format!(
920        "{}  {}x   {} / {}",
921        if paused.0 {
922            tr.replay_paused
923        } else {
924            tr.replay_playing
925        },
926        speed.0,
927        stamp(through),
928        stamp(total)
929    );
930    for mut text in &mut readout {
931        menu_ui::set_text(&mut text, &line);
932    }
933}
934
935/// Space stops and starts a recording.
936///
937/// Only while one is playing: in a live match the same key pulls a
938/// signpost.
939///
940/// The pause card is kept off this by `keys_are_free` in the schedule,
941/// which this system used to do for itself: the card sets `Paused` once,
942/// on opening, and never re-asserts it, so a Space pressed under it would
943/// start the recording running again with the card still up saying it is
944/// stopped. Every other key on a play screen wanted the same rule.
945pub fn playback_pause_input(
946    keys: Res<ButtonInput<KeyCode>>,
947    playback: Res<Playback>,
948    mut paused: ResMut<crate::app::Paused>,
949) {
950    if playback.0.is_none() {
951        return;
952    }
953    if keys.just_pressed(KeyCode::Space) {
954        paused.0 = !paused.0;
955    }
956}