pinch-points 0.8.0

A fast, kid-friendly crab-routing game: route streams of crabs into your sandcastle before time runs out
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
//! The replay library: every finished round kept under a name you can find
//! it by, and the screen for picking one.
//!
//! Rounds are filed by the day and time they were played plus who won, which
//! is what anyone remembers about a match ("the one Bo took on the twelfth").

use crate::app::i18n::fill;
use crate::app::settings::GameSettings;
use crate::app::{Playback, Screen, menu_ui, palette};
use crate::sim::Replay;
use bevy::prelude::*;

/// Where the library lives. `last.txt` still sits beside it, because the
/// dev hooks and the menu's Replay entry reach for the newest round by name.
pub fn library_dir() -> std::path::PathBuf {
    crate::app::paths::data_dir().join("replays")
}

/// One kept round: the file, and what to call it on screen.
pub struct Kept {
    pub path: std::path::PathBuf,
    /// When it was played and who took it; for a draw, only when, since
    /// the word for a draw is the player's language's and is added as the
    /// row is drawn.
    pub label: String,
    /// Nobody took it. A flag rather than a word in the label, so a player
    /// who calls themselves "draw" is still a winner.
    pub draw: bool,
}

/// The library screen's state: what is on the shelf, and where the cursor is.
#[derive(Resource, Default)]
pub struct Library {
    pub kept: Vec<Kept>,
    pub selected: usize,
    /// First row shown: the shelf can hold more rounds than the card has
    /// rows (see [`ROWS`]), so the window slides to keep the cursor in it.
    pub scroll: usize,
    /// Set when a pick fails to parse, so the screen can say so.
    pub feedback: String,
}

impl Library {
    /// Keep the cursor on the shelf and the window on the cursor. Called
    /// after every move and every re-read: the shelf can shrink under the
    /// cursor (a pasted code that failed, a re-entry after pruning) as
    /// well as grow past the rows.
    pub fn settle(&mut self) {
        let Self {
            kept,
            selected,
            scroll,
            feedback: _,
        } = self;
        *selected = (*selected).min(kept.len().saturating_sub(1));
        // Only as far as it must: down when the cursor falls off the
        // bottom, up when it climbs off the top, and never past a shelf
        // that got shorter.
        *scroll = (*scroll)
            .min(*selected)
            .max(selected.saturating_sub(ROWS - 1))
            .min(kept.len().saturating_sub(ROWS));
    }
}

/// The file name for a round finished now, by who won it, or `None` for
/// a draw.
///
/// Named from the wall clock rather than a counter: a counter needs the
/// directory read to know what is next, and two copies of the game would
/// disagree about it. Seconds since the epoch sort correctly as text for the
/// next few hundred years.
///
/// A draw is the name with no winner part at all, which no winner's name
/// can produce: even an empty name leaves its `_`. It used to be filed under
/// the word "draw", and a player who had called themselves that was read
/// back as nobody.
pub fn file_name(stamp: u64, winner: Option<&str>) -> String {
    match winner {
        Some(winner) => {
            let tidy: String = winner
                .chars()
                .map(|c| if c.is_alphanumeric() { c } else { '_' })
                .take(12)
                .collect();
            format!("round_{stamp:010}_{tidy}.txt")
        }
        None => format!("round_{stamp:010}.txt"),
    }
}

/// Where in `dir` to file a round finished at `stamp`: [`file_name`], or,
/// when that is taken, the same at the next free second.
///
/// Two rounds with one winner in one second are one file name, and the
/// second write replaced the first: two codes pasted in quick succession,
/// say. A second later on the label costs nothing, since the shelf shows
/// minutes, and the order stays the order they were filed in.
pub fn free_path(dir: &std::path::Path, stamp: u64, winner: Option<&str>) -> std::path::PathBuf {
    (stamp..)
        .map(|at| dir.join(file_name(at, winner)))
        .find(|path| !path.exists())
        .unwrap_or_else(|| dir.join(file_name(stamp, winner)))
}

/// Drop the oldest rounds until at most `cap` are kept.
///
/// Called after a round is filed, the one moment the shelf can have grown.
/// Best-effort like every other write here: a file that will not delete
/// stays on the shelf, and the next round tries again.
///
/// Only rounds are counted and only rounds are removed. `last.txt` and the
/// highlight reel live in the same directory and belong to the menu, not to
/// the shelf.
pub fn prune(cap: u8) {
    prune_in(&library_dir(), cap);
}

/// [`prune`] on a directory named outright, so a test can hand it a
/// scratch folder instead of the player's library.
pub fn prune_in(dir: &std::path::Path, cap: u8) {
    let kept = shelf_in(dir);
    for old in kept.iter().skip(usize::from(cap)) {
        if let Err(e) = std::fs::remove_file(&old.path) {
            warn!("could not drop the oldest round: {e}");
        }
    }
}

/// Everything on the shelf, newest first.
pub fn shelf() -> Vec<Kept> {
    shelf_in(&library_dir())
}

/// [`shelf`] on a directory named outright, for the same reason as
/// [`prune_in`]: the data directory comes from the process environment,
/// and a test that changes that races every other test in the binary.
pub fn shelf_in(dir: &std::path::Path) -> Vec<Kept> {
    let Ok(dir) = std::fs::read_dir(dir) else {
        return Vec::new();
    };
    let mut kept: Vec<Kept> = dir
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .filter(|path| {
            path.extension().is_some_and(|ext| ext == "txt")
                && path
                    .file_name()
                    .and_then(|n| n.to_str())
                    .is_some_and(|n| n.starts_with("round_"))
        })
        .map(|path| {
            let (label, draw) = label_of(&path);
            Kept { path, label, draw }
        })
        .collect();
    // Newest first: the names sort by timestamp, so this is just a reverse.
    kept.sort_by(|a, b| b.path.cmp(&a.path));
    kept
}

/// `round_0000012345_Anna.txt` reads as "Anna, 12345", and
/// `round_0000012345.txt`, with no winner part, as a draw at 12345 (see
/// [`file_name`]); the second half of the answer says which. The timestamp
/// is shown as a date only if the clock agrees it is one; a file copied
/// from another machine is still listed, just plainly.
fn label_of(path: &std::path::Path) -> (String, bool) {
    let stem = path
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("round")
        .to_string();
    let mut parts = stem.splitn(3, '_');
    let (_, stamp, winner) = (parts.next(), parts.next(), parts.next());
    match (stamp.and_then(|s| s.parse::<u64>().ok()), winner) {
        // The separator is not decoration: "15/08 09:49  Mon" reads as a
        // weekday, and Mon is a player.
        (Some(stamp), Some(winner)) => (
            format!("{}  -  {}", clock(stamp), winner.replace('_', " ")),
            false,
        ),
        (Some(stamp), None) => (clock(stamp), true),
        (None, _) => (stem, false),
    }
}

/// A stamp as `dd/mm hh:mm`, on the clock in the room. No date library and
/// no need for one: days since the epoch convert with the same arithmetic
/// the daily challenge already uses, and the offset to add first comes from
/// the system zone (see [`crate::app::clock::local_offset`]).
///
/// The offset is taken at the stamp rather than now, so a round is shown on
/// the clock that was on the wall when it was played.
fn clock(stamp: u64) -> String {
    stamp_text(stamp, crate::app::clock::local_offset(stamp))
}

/// The same, with the offset handed in, so the format can be tested on a
/// machine in any zone.
fn stamp_text(stamp: u64, offset: i64) -> String {
    // A clock set before 1970 already reads as zero (`clock::now_secs`);
    // west of Greenwich the offset can push such a stamp below it, and the
    // arithmetic below is unsigned. It stays at the epoch.
    let local = (stamp as i64 + offset).max(0) as u64;
    let secs = local % 86_400;
    let (h, m) = (secs / 3600, (secs % 3600) / 60);
    let (day, month) = crate::app::clock::civil_date((local / 86_400) as u32);
    format!("{day:02}/{month:02} {h:02}:{m:02}")
}

#[derive(Component)]
pub struct LibraryUi;

#[derive(Component)]
pub struct LibraryRow(usize);

/// The "nothing kept yet" line, shown in place of the rows on an empty shelf.
#[derive(Component)]
pub struct EmptyShelfNote;

/// How many rows the shelf shows at once.
const ROWS: usize = 12;

pub fn enter_library(
    mut commands: Commands,
    settings: Res<GameSettings>,
    mut library: ResMut<Library>,
) {
    library.kept = shelf();
    library.settle();
    let tr = settings.tr();
    // Any replay file at all can be watched, from a cup's folder or a
    // friend's message: the status line says how, until it has news.
    library.feedback = tr.replay_drop_hint.to_string();
    commands
        .spawn((LibraryUi, menu_ui::between_bars()))
        .with_children(|wrap| {
            wrap.spawn(menu_ui::screen_card()).with_children(|card| {
                menu_ui::heading_row(card, tr.replays_heading, None);
                // The empty-shelf line, which the rows cannot carry: a row
                // cell is one shelf-wide column that does not wrap, and
                // this sentence is wider than it. Its own line, free to
                // wrap, keeps it inside, and it goes the moment there is a
                // round to show.
                card.spawn((
                    EmptyShelfNote,
                    Text::new(""),
                    TextFont {
                        font_size: FontSize::Px(menu_ui::type_scale::ROW),
                        ..default()
                    },
                    TextColor(palette::PARCHMENT.with_alpha(0.6)),
                    // No `no_wrap` here, unlike the row cells: the width is
                    // fixed to the shelf and the line is free to take a
                    // second row rather than run off the card.
                    Node {
                        width: Val::Px(ROW_W),
                        margin: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
                        ..default()
                    },
                ));
                for row in 0..ROWS {
                    card.spawn((LibraryRow(row), menu_ui::card_row()))
                        .with_children(|line| {
                            line.spawn((LibraryRow(row), menu_ui::cell(ROW_W, 19.0)));
                        });
                }
            });
        });
}

/// The shelf is one column, as wide as a stamp and a winner's name.
const ROW_W: f32 = 420.0;

pub fn update_library(
    library: Res<Library>,
    settings: Res<GameSettings>,
    mut cells: Query<(&LibraryRow, &mut Text, &mut TextColor), Without<EmptyShelfNote>>,
    mut rows: Query<(&LibraryRow, &mut BackgroundColor, &mut Node), Without<EmptyShelfNote>>,
    mut empty_note: Query<(&mut Text, &mut Node), With<EmptyShelfNote>>,
) {
    let tr = settings.tr();
    // The empty-shelf line carries the "nothing yet" message now, so the
    // rows never do. On a shelf with rounds on it, it is taken out of the
    // layout entirely (not merely blanked), or its empty line would sit
    // between the heading and the first round as a gap.
    let empty = library.kept.is_empty();
    for (mut note, mut node) in &mut empty_note {
        menu_ui::set_text(&mut note, if empty { tr.replays_empty } else { "" });
        menu_ui::set_shown(&mut node, empty);
    }
    // Row `r` of the card shows shelf entry `scroll + r`.
    let at = |row: usize| library.scroll + row;
    for (row, mut text, mut color) in &mut cells {
        let line = match library.kept.get(at(row.0)) {
            // A draw is filed as no winner at all, and said here in the
            // player's language.
            Some(kept) if kept.draw => format!("{}  -  {}", kept.label, tr.replay_draw),
            Some(kept) => kept.label.clone(),
            None => String::new(),
        };
        let picked = at(row.0) == library.selected && !library.kept.is_empty();
        menu_ui::set_text(&mut text, &line);
        menu_ui::set_color(
            &mut color,
            match (picked, library.kept.get(at(row.0)).is_some()) {
                (true, _) => Color::WHITE,
                (false, true) => palette::PARCHMENT.with_alpha(0.75),
                (false, false) => palette::PARCHMENT.with_alpha(0.45),
            },
        );
    }
    for (row, mut fill, mut node) in &mut rows {
        let ground = menu_ui::band(at(row.0) == library.selected && !library.kept.is_empty());
        menu_ui::set_bg(&mut fill, ground);
        // A slot with nothing in it still holds its height, so pasting a
        // round does not resize the card under the cursor. An empty shelf
        // has no cursor and nothing to keep still, and twelve blank slots
        // under one sentence is the first thing a new player sees here, so
        // there the note really does stand in place of the rows.
        menu_ui::set_shown(&mut node, !empty);
    }
}

/// Arrows pick, Enter watches, C copies a share code, V pastes one, Esc
/// leaves.
pub fn library_input(
    keys: Res<ButtonInput<KeyCode>>,
    settings: Res<GameSettings>,
    caps: Res<crate::app::keycaps::KeyCaps>,
    mut clipboard: ResMut<Clipboard>,
    mut library: ResMut<Library>,
    mut playback: ResMut<Playback>,
    mut next_screen: ResMut<NextState<Screen>>,
) {
    let tr = settings.tr();
    if keys.just_pressed(KeyCode::Escape) {
        next_screen.set(Screen::Menu);
        return;
    }
    // The cursor walks the whole shelf, not just the rows on show; the
    // window follows it.
    let shown = library.kept.len();
    if shown > 0 {
        library.selected = menu_ui::nav(&keys, library.selected, shown);
        library.settle();
    }
    if caps.just_pressed(&keys, 'C') {
        library.feedback = copy_selected(&mut clipboard, tr, &library);
    }
    if caps.just_pressed(&keys, 'V') {
        library.feedback = keep_pasted(&mut clipboard, tr, &library_dir(), settings.replay_cap);
        // Whatever happened, the shelf may have grown; re-read it so the new
        // round is there to pick rather than waiting for the screen to be
        // left and come back to.
        library.kept = shelf();
        library.settle();
    }
    if !menu_ui::enter(&keys) {
        return;
    }
    let Some(kept) = library.kept.get(library.selected) else {
        return;
    };
    match std::fs::read_to_string(&kept.path)
        .map_err(|e| e.to_string())
        .and_then(|text| Replay::parse(&text))
    {
        Ok(replay) => {
            playback.0 = Some((replay, 0));
            next_screen.set(Screen::Versus);
        }
        Err(e) => library.feedback = e,
    }
}

/// The selected round as a share code on the clipboard.
///
/// A round is thirty kilobytes of text and compresses to about eight
/// thousand characters, so the message says how long it is and the player
/// decides whether that goes in a chat window or a file.
fn copy_selected(
    clipboard: &mut Clipboard,
    tr: &crate::app::i18n::Tr,
    library: &Library,
) -> String {
    let Some(kept) = library.kept.get(library.selected) else {
        return tr.replays_empty.to_string();
    };
    let text = match std::fs::read_to_string(&kept.path) {
        Ok(text) => text,
        Err(e) => return fill(tr.code_round_bad, &[("e", &e.to_string())]),
    };
    crate::app::codes::copy_feedback(
        clipboard,
        tr,
        crate::share::Kind::Round,
        text.as_bytes(),
        tr.code_copied,
    )
}

/// The round a pasted code carries, and the text to file it under, or what
/// to say about why not.
///
/// Parsed before anything is written, so what lands in the library is a
/// round this build can play back rather than a file that fails when
/// somebody presses Enter on it. Takes the pasted code rather than the
/// clipboard so it can be tested without touching the real one.
fn round_from(
    pasted: Option<(crate::share::Kind, Vec<u8>)>,
    tr: &crate::app::i18n::Tr,
) -> Result<(Option<String>, String), String> {
    let text =
        crate::app::codes::payload_text(pasted, tr, crate::share::Kind::Round, tr.code_round_bad)?;
    let replay = Replay::parse(&text).map_err(|e| fill(tr.code_round_bad, &[("e", &e)]))?;
    Ok((winner_of(&replay, tr), text))
}

/// Who took a pasted round, for the shelf to file it under like every
/// other round, or `None` for a draw. The file carries the inputs and not
/// the result, so the round is played through to its last tick; it carries
/// no team mode either, so it is read as a free-for-all. Named as the
/// recording named the seat, or by its number.
fn winner_of(replay: &Replay, tr: &crate::app::i18n::Tr) -> Option<String> {
    let board = replay.playback();
    let leaders = crate::app::side_panels::leading_seats(
        board.scores(),
        board.castle_seats(),
        crate::app::teams::TeamMode::Solo,
    );
    let seat = leaders.iter().position(|&led| led)?;
    Some(if replay.names[seat].is_empty() {
        crate::app::seat_label(tr, seat as u8)
    } else {
        replay.names[seat].clone()
    })
}

/// A round off the clipboard, onto the shelf in `dir`.
fn keep_pasted(
    clipboard: &mut Clipboard,
    tr: &crate::app::i18n::Tr,
    dir: &std::path::Path,
    cap: u8,
) -> String {
    keep(crate::app::codes::paste(clipboard), tr, dir, cap)
}

/// A pasted code onto the shelf in `dir`, which then keeps at most `cap`
/// rounds as it does after a round is played: a shelf filled by pasting is
/// still the shelf the setting promises. Takes the code rather than the
/// clipboard, as [`round_from`] does, so a test can file one.
fn keep(
    pasted: Option<(crate::share::Kind, Vec<u8>)>,
    tr: &crate::app::i18n::Tr,
    dir: &std::path::Path,
    cap: u8,
) -> String {
    let (winner, text) = match round_from(pasted, tr) {
        Ok(round) => round,
        Err(complaint) => return complaint,
    };
    let path = free_path(dir, crate::app::clock::now_secs(), winner.as_deref());
    match crate::app::paths::write_atomic(&path, &text) {
        Ok(()) => {
            prune_in(dir, cap);
            tr.code_round_saved.to_string()
        }
        Err(e) => fill(tr.code_round_bad, &[("e", &e.to_string())]),
    }
}

/// Playback speed while watching, so a three-minute round can be skimmed.
#[derive(Resource)]
pub struct PlaybackSpeed(pub u8);

impl Default for PlaybackSpeed {
    fn default() -> Self {
        PlaybackSpeed(1)
    }
}

impl PlaybackSpeed {
    /// The speeds the key steps through.
    pub const STEPS: [u8; 3] = [1, 2, 4];

    fn stepped(self) -> PlaybackSpeed {
        let next = Self::STEPS
            .iter()
            .position(|&s| s == self.0)
            .map_or(0, |at| (at + 1) % Self::STEPS.len());
        PlaybackSpeed(Self::STEPS[next])
    }
}

/// `S` cycles 1x, 2x, 4x while a replay is playing.
pub fn playback_speed_input(
    keys: Res<ButtonInput<KeyCode>>,
    playback: Res<Playback>,
    mut speed: ResMut<PlaybackSpeed>,
) {
    if playback.0.is_some() && keys.just_pressed(KeyCode::KeyS) {
        *speed = PlaybackSpeed(speed.0).stepped();
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A shelf longer than the card scrolls: the window follows the cursor
    /// down past the last row and back up, and never opens past the end.
    #[test]
    fn the_shelf_window_follows_the_cursor() {
        let mut library = Library {
            kept: (0..ROWS + 8)
                .map(|n| Kept {
                    path: format!("{n}.txt").into(),
                    label: n.to_string(),
                    draw: false,
                })
                .collect(),
            ..Library::default()
        };
        library.settle();
        assert_eq!((library.selected, library.scroll), (0, 0));
        // Down to the last shown row: still no scroll.
        library.selected = ROWS - 1;
        library.settle();
        assert_eq!(library.scroll, 0);
        // One further: the window slides by one.
        library.selected = ROWS;
        library.settle();
        assert_eq!(library.scroll, 1);
        // The last entry is reachable, and the window shows a full card.
        library.selected = ROWS + 7;
        library.settle();
        assert_eq!(library.scroll, 8);
        // Back to the top: the window climbs with the cursor.
        library.selected = 3;
        library.settle();
        assert_eq!(library.scroll, 3);
        // A shelf that shrinks pulls the cursor and the window back in.
        library.kept.truncate(4);
        library.selected = 10;
        library.settle();
        assert_eq!((library.selected, library.scroll), (3, 0));
        // An empty shelf is a cursor at nought and no window at all.
        library.kept.clear();
        library.settle();
        assert_eq!((library.selected, library.scroll), (0, 0));
    }

    #[test]
    fn round_names_sort_newest_last_and_survive_odd_winners() {
        let name = |stamp, winner| file_name(stamp, Some(winner));
        assert_eq!(name(12_345, "Anna"), "round_0000012345_Anna.txt");
        // A name with punctuation, or none at all, still makes a file name.
        assert_eq!(name(7, "Bo/../etc"), "round_0000000007_Bo____etc.txt");
        assert_eq!(name(7, ""), "round_0000000007_.txt");
        // A draw has no winner part, which no name can spell.
        assert_eq!(file_name(7, None), "round_0000000007.txt");
        // Zero-padded so plain text sorting is chronological.
        assert!(name(2, "a") < name(10, "a"));
        // And over-long winners are cut rather than making a silly path.
        assert!(name(1, &"x".repeat(40)).len() < 40);
    }

    #[test]
    fn labels_read_as_a_time_and_a_winner() {
        let (label, draw) = label_of(std::path::Path::new("replays/round_1751328000_Anna.txt"));
        assert!(label.contains("Anna"), "{label}");
        assert!(label.contains('/') && label.contains(':'), "{label}");
        assert!(!draw);
        // A file from somewhere else is listed under its own name.
        let odd = label_of(std::path::Path::new("replays/round_handmade.txt"));
        assert_eq!(odd, ("round_handmade".to_string(), false));
    }

    /// A draw is a flag, not a word in the label. The word used to be
    /// swapped for its translation wherever it appeared, so a winner called
    /// "drawbridge" came out half translated, and one called "draw" was
    /// shown as a tie.
    #[test]
    fn a_draw_is_told_from_a_winner_called_draw() {
        let read = |winner| label_of(std::path::Path::new(&file_name(1_751_328_000, winner)));
        let (tie, draw) = read(None);
        assert!(draw, "{tie}");
        assert!(
            !tie.contains("draw"),
            "the word is the shelf's to add: {tie}"
        );
        for name in ["draw", "drawbridge"] {
            let (label, draw) = read(Some(name));
            assert!(!draw, "{name} won");
            assert!(label.ends_with(&format!("  -  {name}")), "{label}");
        }
    }

    /// A scratch folder of its own, gone before and after.
    fn scratch(name: &str) -> std::path::PathBuf {
        let dir = std::env::temp_dir().join(format!("pinch-{name}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).expect("a scratch folder");
        dir
    }

    /// Two codes for rounds the same seat won, pasted within one second,
    /// are two rounds on the shelf: they used to share a file name and the
    /// second replaced the first. And pasting keeps the shelf to its cap,
    /// as finishing a round does.
    #[test]
    fn pasted_rounds_keep_their_own_files_and_the_cap() {
        let tr = &crate::app::i18n::EN;
        let dir = scratch("pasted-rounds");
        let code = || {
            let text = banked_round().to_text();
            crate::share::decode(&crate::share::encode(
                crate::share::Kind::Round,
                text.as_bytes(),
            ))
        };
        assert_eq!(keep(code(), tr, &dir, 10), tr.code_round_saved);
        assert_eq!(keep(code(), tr, &dir, 10), tr.code_round_saved);
        assert_eq!(shelf_in(&dir).len(), 2, "two pastes, two rounds");
        // The second-later rule itself, on a clock held still.
        std::fs::write(dir.join(file_name(5, Some("P1"))), "x").expect("written");
        assert_eq!(
            free_path(&dir, 5, Some("P1")),
            dir.join(file_name(6, Some("P1"))),
            "a taken name is passed over for the next second"
        );
        assert_eq!(
            free_path(&dir, 5, None),
            dir.join(file_name(5, None)),
            "and a free one is kept"
        );
        for _ in 0..4 {
            keep(code(), tr, &dir, 3);
        }
        assert_eq!(shelf_in(&dir).len(), 3, "and the cap holds");
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// A stamp reads on the clock that was on the wall, which is not the
    /// one in Greenwich. The offset is handed in here because the machine
    /// running this is in whatever zone it is in.
    #[test]
    fn a_stamp_reads_on_the_local_clock() {
        // 2025-07-01 00:00:00 UTC, day 20270.
        const NOON_LESS_TWELVE: u64 = 1_751_328_000;
        assert_eq!(stamp_text(NOON_LESS_TWELVE, 0), "01/07 00:00", "Greenwich");
        assert_eq!(stamp_text(NOON_LESS_TWELVE, 7200), "01/07 02:00", "Paris");
        // West of Greenwich the same instant is still the day before, which
        // is the case a bare `stamp % 86_400` gets wrong.
        assert_eq!(
            stamp_text(NOON_LESS_TWELVE, -4 * 3600),
            "30/06 20:00",
            "Halifax"
        );
        // And a stamp so early the offset would take it below the epoch
        // stays at the epoch rather than wrapping to the far future.
        assert_eq!(stamp_text(60, -4 * 3600), "01/01 00:00");
    }

    /// A round survives being carried as a share code and comes back as the
    /// same text, filed under whoever took it, which for forty ticks of
    /// nobody banking anything is nobody. The clipboard itself is not
    /// touched: with `system_clipboard` on it is the real one.
    #[test]
    fn a_round_travels_as_a_code_and_comes_back() {
        let tr = &crate::app::i18n::EN;
        let mut replay = Replay::new(crate::sim::Level::from_board(
            "Turf War",
            3,
            crate::sim::classic_arena(false, 2),
        ));
        for tick in 0..40u8 {
            let mut actions = [crate::sim::PlayerAction::None; crate::sim::MAX_PLAYERS];
            actions[0] = crate::sim::PlayerAction::Place {
                x: tick % 10,
                y: 3,
                dir: crate::sim::Direction::Down,
            };
            replay.record(actions);
        }
        let text = replay.to_text();
        let code = crate::share::encode(crate::share::Kind::Round, text.as_bytes());
        let (winner, back) = round_from(crate::share::decode(&code), tr).expect("a round");
        assert_eq!(back, text, "what went in is what comes out");
        assert_eq!(
            winner, None,
            "filed under the result (a draw), not the beach"
        );
    }

    /// A pasted round is filed under whoever took it, worked out by
    /// playing it through: named as the recording named the seat, or by
    /// its number when it did not.
    fn banked_round() -> Replay {
        let level = crate::sim::Level::parse(
            "name: T\nposts: 1\ncrab: 0,0 R R common\nmap:\n\
             +-+-+\n|. 0|\n+ + +\n|. .|\n+-+-+\n",
        )
        .expect("level");
        let mut replay = Replay::new(level);
        for _ in 0..60 {
            replay.record([crate::sim::PlayerAction::None; crate::sim::MAX_PLAYERS]);
        }
        assert!(replay.playback().scores()[0] > 0, "the crab walked home");
        replay
    }

    #[test]
    fn a_pasted_round_is_filed_under_the_name_of_whoever_took_it() {
        let tr = &crate::app::i18n::EN;
        let mut replay = banked_round();
        replay.names[0] = "Anna".into();
        assert_eq!(winner_of(&replay, tr).as_deref(), Some("Anna"));
    }

    #[test]
    fn a_pasted_round_with_no_names_is_filed_under_the_seat() {
        let tr = &crate::app::i18n::EN;
        assert_eq!(winner_of(&banked_round(), tr).as_deref(), Some("P1"));
    }

    /// The three ways a paste is not a round, each with its own answer. One
    /// shrug for all three leaves a player guessing which mistake they made.
    #[test]
    fn what_is_not_a_round_says_which_way_it_is_not() {
        let tr = &crate::app::i18n::EN;
        // Nothing readable on the clipboard at all.
        assert_eq!(round_from(None, tr), Err(tr.code_none_pasted.to_string()));
        // A real code, but for a level - the commonest mix-up, since both
        // are copied with the same gesture from different screens.
        let level = crate::share::encode(crate::share::Kind::Level, b"name: X\nposts: 3\n");
        let complaint = round_from(crate::share::decode(&level), tr).expect_err("not a round");
        assert!(
            complaint.contains("a level") && complaint.contains("a round"),
            "{complaint}"
        );
        // A round code carrying something that is not a round.
        let junk = crate::share::encode(crate::share::Kind::Round, b"not a replay at all");
        assert!(round_from(crate::share::decode(&junk), tr).is_err());
    }

    /// The transport clock reads as a clock. It is the one place a player
    /// sees how far through a round they are, and `0:7` for seven seconds
    /// past the minute is the kind of thing that reads as a bug.
    #[test]
    fn the_transport_clock_pads_its_seconds() {
        assert_eq!(stamp(0), "0:00", "the very start");
        assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 7), "0:07");
        assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 59), "0:59");
        assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 60), "1:00");
        // A three-minute Turf War, which is the longest round that ships.
        assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 185), "3:05");
    }

    /// It counts in ticks, not in frames: the transport is drawn every
    /// frame and the sim advances thirty times a second, so a clock that
    /// read the wrong one would run at whatever the display happened to do.
    #[test]
    fn the_transport_clock_counts_in_ticks() {
        let one_second = crate::sim::TICKS_PER_SECOND as usize;
        assert_eq!(stamp(one_second - 1), "0:00", "part of a second is not one");
        assert_eq!(stamp(one_second), "0:01", "and a whole one is");
    }

    #[test]
    fn the_speed_key_cycles_the_steps() {
        let mut speed = PlaybackSpeed::default();
        assert_eq!(speed.0, 1);
        for want in [2, 4, 1, 2] {
            speed = PlaybackSpeed(speed.0).stepped();
            assert_eq!(speed.0, want);
        }
    }
}

// --- the transport bar ------------------------------------------------------

/// The row of controls under a replay: where the round has got to, whether
/// it is running, and how fast.
///
/// The shape people already know from every video player: a track that
/// fills, a state, and a clock.
#[derive(Component)]
pub struct ReplayBar;

/// The filled part of the track. Its width is the round's progress.
#[derive(Component)]
pub struct ReplayProgress;

/// The line under the track: the state, the speed, and the clock.
#[derive(Component)]
pub struct ReplayReadout;

/// How wide the track is drawn, in pixels.
const TRACK: f32 = 420.0;

/// Keep the bar up while a recording is playing, and only then.
///
/// Reconciled every frame rather than hung off the screen transition: a
/// replay is entered and left through the same versus screen a live match
/// uses, so there is no transition of its own to hang it on.
pub fn tend_replay_bar(
    mut commands: Commands,
    playback: Res<Playback>,
    screen: Res<State<Screen>>,
    bar: Query<Entity, With<ReplayBar>>,
) {
    let watching = playback.0.is_some() && *screen.get() == Screen::Versus;
    if !watching {
        for entity in &bar {
            commands.entity(entity).despawn();
        }
        return;
    }
    if !bar.is_empty() {
        return;
    }
    commands
        .spawn((
            ReplayBar,
            Node {
                position_type: PositionType::Absolute,
                // The band the chrome reserves at the foot of the board,
                // which the field guide stands down from while a
                // recording is playing.
                bottom: Val::Px(56.0),
                left: Val::Px(0.0),
                width: Val::Percent(100.0),
                flex_direction: FlexDirection::Column,
                align_items: AlignItems::Center,
                row_gap: Val::Px(5.0),
                ..default()
            },
        ))
        .with_children(|column| {
            // The track: a sunk rail with a gold fill creeping along it.
            column
                .spawn((
                    Node {
                        width: Val::Px(TRACK),
                        height: Val::Px(6.0),
                        border_radius: BorderRadius::all(Val::Px(3.0)),
                        ..default()
                    },
                    BackgroundColor(palette::BAR_TRACK),
                ))
                .with_children(|rail| {
                    rail.spawn((
                        ReplayProgress,
                        Node {
                            width: Val::Percent(0.0),
                            height: Val::Percent(100.0),
                            border_radius: BorderRadius::all(Val::Px(3.0)),
                            ..default()
                        },
                        BackgroundColor(palette::GOLD),
                    ));
                });
            column
                .spawn((
                    Node {
                        padding: UiRect::axes(Val::Px(12.0), Val::Px(3.0)),
                        border_radius: BorderRadius::all(Val::Px(10.0)),
                        ..default()
                    },
                    BackgroundColor(palette::PILL_FILL),
                ))
                .with_children(|pill| {
                    pill.spawn((
                        ReplayReadout,
                        Text::new(String::new()),
                        TextFont {
                            font_size: FontSize::Px(menu_ui::type_scale::FINE),
                            ..default()
                        },
                        TextLayout::no_wrap(),
                        TextColor(palette::PARCHMENT),
                    ));
                });
        });
}

/// Minutes and seconds of a tick count, at the sim's fixed rate.
fn stamp(ticks: usize) -> String {
    let seconds = ticks / crate::sim::TICKS_PER_SECOND as usize;
    format!("{}:{:02}", seconds / 60, seconds % 60)
}

/// Write the track and the readout from where the recording has got to.
pub fn update_replay_bar(
    playback: Res<Playback>,
    paused: Res<crate::app::Paused>,
    speed: Res<PlaybackSpeed>,
    settings: Res<GameSettings>,
    mut track: Query<&mut Node, With<ReplayProgress>>,
    mut readout: Query<&mut Text, With<ReplayReadout>>,
) {
    let Some((replay, at)) = &playback.0 else {
        return;
    };
    let total = replay.inputs.len().max(1);
    let through = (*at).min(total);
    let width = Val::Percent(through as f32 / total as f32 * 100.0);
    for mut node in &mut track {
        // Written only when it moves: a `Node` marked changed relays out
        // the whole interface, every frame the replay plays.
        if node.width != width {
            node.width = width;
        }
    }
    let tr = settings.tr();
    let line = format!(
        "{}  {}x   {} / {}",
        if paused.0 {
            tr.replay_paused
        } else {
            tr.replay_playing
        },
        speed.0,
        stamp(through),
        stamp(total)
    );
    for mut text in &mut readout {
        menu_ui::set_text(&mut text, &line);
    }
}

/// Space stops and starts a recording.
///
/// Only while one is playing: in a live match the same key pulls a
/// signpost.
///
/// The pause card is kept off this by `keys_are_free` in the schedule,
/// which this system used to do for itself: the card sets `Paused` once,
/// on opening, and never re-asserts it, so a Space pressed under it would
/// start the recording running again with the card still up saying it is
/// stopped. Every other key on a play screen wanted the same rule.
pub fn playback_pause_input(
    keys: Res<ButtonInput<KeyCode>>,
    playback: Res<Playback>,
    mut paused: ResMut<crate::app::Paused>,
) {
    if playback.0.is_none() {
        return;
    }
    if keys.just_pressed(KeyCode::Space) {
        paused.0 = !paused.0;
    }
}