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
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
//! Dev-only environment hooks (`PINCH_*`): shortcuts past the menu,
//! headless screenshots, and campaign autoplay. All inert unless the
//! matching variable is set.
//!
//! Every `PINCH_*` variable the game answers to is read here, either by the
//! [`DevHook`] ladder or by one of the typed accessors below, so this file
//! is the complete inventory of the dev surface.

use crate::app::{Campaign, PendingActions, Phase, Screen, Sim, announce, match_setup, net};
use crate::sim::{Direction, PlayerAction, TideEvent};
use bevy::prelude::*;

/// `PINCH_WINDOW=WxH`: open at a given size instead of the default, so a
/// screen can be checked at the sizes players will actually drag it to.
pub(super) fn window_size() -> Option<(f32, f32)> {
    let raw = std::env::var("PINCH_WINDOW").ok()?;
    let (w, h) = raw.split_once(['x', 'X'])?;
    Some((w.trim().parse().ok()?, h.trim().parse().ok()?))
}

/// `PINCH_ON_TOP=1`: keep the window above every other, so a screen
/// recording of its corner of the desktop (`ffmpeg -f x11grab`) films the
/// game rather than whatever the window manager left in front of it.
pub(super) fn on_top() -> bool {
    std::env::var("PINCH_ON_TOP").is_ok()
}

/// Whether this run keeps a window whatever the settings say:
/// `PINCH_WINDOW` sized it, or `PINCH_SCREENSHOT` came to photograph it
/// (see [`crate::app::settings::window_mode`]).
pub(super) fn window_pinned() -> bool {
    std::env::var("PINCH_WINDOW").is_ok() || screenshotting()
}

/// `PINCH_LOBBY_HOST=n`: host a LAN lobby unattended and launch once `n`
/// peers are aboard (`=1`, the historical value, launches on the first).
pub(super) fn auto_host_quota() -> Option<usize> {
    std::env::var("PINCH_LOBBY_HOST")
        .ok()
        .map(|quota| quota.parse().unwrap_or(1))
}

/// `PINCH_LOBBY_JOIN`: join the first LAN host the lobby hears, unattended.
pub(super) fn auto_join() -> bool {
    std::env::var("PINCH_LOBBY_JOIN").is_ok()
}

/// `PINCH_LOBBY_JOIN_BOT`: join the first LAN host the lobby hears as a
/// bot (route 1), opening the doorway for it unattended.
pub(super) fn auto_join_bot() -> bool {
    std::env::var("PINCH_LOBBY_JOIN_BOT").is_ok()
}

/// `PINCH_LOBBY_WATCH`: the same, but as a spectator.
pub(super) fn auto_watch() -> bool {
    std::env::var("PINCH_LOBBY_WATCH").is_ok()
}

/// `PINCH_HOST=port`: host a direct online session, no lobby.
pub(super) fn direct_host() -> Option<String> {
    std::env::var("PINCH_HOST").ok()
}

/// `PINCH_JOIN=ip:port`: the joining half of the direct pair.
pub(super) fn direct_join() -> Option<String> {
    std::env::var("PINCH_JOIN").ok()
}

/// `PINCH_BOT_SEATS=n`: the top n of the AI's seats are left to bots
/// instead, for the match-setup hook and a hosted lobby.
pub(super) fn bot_seats() -> Option<u8> {
    std::env::var("PINCH_BOT_SEATS").ok()?.parse().ok()
}

/// `PINCH_LAUNCH`: start the match on the setup screen as if Enter had
/// been pressed, opening the doorway for any bot seats.
pub(crate) fn launch() -> bool {
    std::env::var("PINCH_LAUNCH").is_ok()
}

/// `PINCH_BOT_KEY=KEY`: the doorway's first chair takes this key rather
/// than a fresh one, so a script can start its bot with a string it knew
/// in advance. Never for a doorway on the LAN: the key is the lock.
pub(crate) fn bot_key() -> Option<crate::bots::connstr::Key> {
    crate::bots::connstr::Key::parse(&std::env::var("PINCH_BOT_KEY").ok()?)
}

/// `PINCH_BOTS=n`: n AI players in a hosted lobby match (and in the direct
/// `PINCH_HOST`/`PINCH_JOIN` pair, where both sides must set it because
/// there is no lobby to agree it for them).
pub(super) fn bots() -> Option<u8> {
    std::env::var("PINCH_BOTS").ok()?.parse().ok()
}

/// `PINCH_THREADS=n`: the cap on Bevy's compute pool, for sweeping it on a
/// machine before deciding what the shipped cap should be. `0` lifts the cap
/// back to Bevy's own default, which is every core the io and async pools
/// did not take.
pub(super) fn compute_threads() -> Option<usize> {
    let n: usize = std::env::var("PINCH_THREADS").ok()?.parse().ok()?;
    Some(if n == 0 { usize::MAX } else { n })
}

/// `PINCH_SEATS=n`: how many seats a skirmish sets the table for.
fn seats() -> Option<u8> {
    std::env::var("PINCH_SEATS").ok()?.parse().ok()
}

/// `PINCH_COOP`: the Tide Pool starts in co-op, two cursors on one pool of
/// arrows, so the second pair of hands can be shot without a C pressed.
pub(super) fn coop() -> bool {
    std::env::var("PINCH_COOP").is_ok()
}

/// `PINCH_SANDBOX`: a local arena with preloaded castles.
pub(super) fn sandbox() -> bool {
    std::env::var("PINCH_SANDBOX").is_ok()
}

/// `PINCH_ST_EXEC`: every main-world schedule on the single-threaded
/// executor, for the backlog's CPU measurement.
pub(super) fn single_threaded_executor() -> bool {
    std::env::var("PINCH_ST_EXEC").is_ok()
}

/// `PINCH_SHUFFLE=<seed>`: put every main-world schedule's unordered systems
/// in a shuffled order, seeded, so an ordering bug shows itself instead of
/// hiding behind whichever order the graph happens to settle on. Only in a
/// build with the `shuffle` feature (`cargo run --features shuffle`); add
/// `PINCH_ST_EXEC=1` for one fixed order per seed, since the parallel
/// executor still starts whatever system is ready first.
///
/// The bug class it is for is the one that kept recurring here: two
/// systems reading the same key in the same frame with no order between
/// them (a chat line's Enter also leaving the results card, the pause
/// card's Enter also calling the next round). Each such bug was invisible
/// until the arbitrary order happened to go the wrong way.
#[cfg(feature = "shuffle")]
pub(super) fn shuffle_seed() -> Option<u64> {
    let seed = std::env::var("PINCH_SHUFFLE").ok()?;
    // Any word will do as a seed; a number is used as itself so a run can
    // be repeated from what its log line said.
    Some(seed.parse().unwrap_or_else(|_| {
        use std::hash::{Hash, Hasher};
        let mut hasher = std::collections::hash_map::DefaultHasher::new();
        seed.hash(&mut hasher);
        hasher.finish()
    }))
}

/// `PINCH_NO_UPDATE`: never ask GitHub for a newer release this run, for
/// scripted launches and screenshots, and for the machine with no network
/// that should not spend eight seconds finding that out.
pub(super) fn no_update_check() -> bool {
    std::env::var("PINCH_NO_UPDATE").is_ok()
}

/// `PINCH_UPDATE_DEMO`: offer a made-up newer release on the menu without
/// asking GitHub, so the update card can be looked at before there is a
/// release to look at (and, once there is, whenever the wording changes).
pub(super) fn update_demo() -> bool {
    std::env::var("PINCH_UPDATE_DEMO").is_ok()
}

/// A shortcut past the menu, as asked for by the environment.
///
/// Read by one pure function over a variable lookup, so the precedence
/// (which hook wins when two are set) is explicit and testable.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum DevHook {
    /// `PINCH_LOBBY_HOST` / `PINCH_LOBBY_JOIN`: the LAN lobby screen.
    Lobby,
    /// `PINCH_HOST` / `PINCH_JOIN`: straight into an online round.
    Online,
    /// `PINCH_SANDBOX`: a local arena with preloaded castles.
    Sandbox,
    /// `PINCH_AUTOPLAY=<level>`: the puzzle campaign from that level (1 is
    /// the first), playing its own authored solutions - or, with
    /// `PINCH_NOSOLVE=1`, playing none of them, so a timed level runs out.
    Autoplay {
        level: usize,
    },
    Editor,
    Settings,
    Controls,
    Achievements,
    /// `PINCH_LANGUAGE`: the language picker.
    Language,
    /// `PINCH_STAGES=tide|beach`: the stage list for either puzzle list.
    StageSelect {
        beach: bool,
    },
    /// `PINCH_SKIRMISH=classic|large|xl|ocean|custom`, plus `PINCH_SERIES=3|5`
    /// for a series: four seats, three of them AI. Any unrecognised
    /// value is the classic beach.
    Skirmish {
        map: match_setup::MapChoice,
        series: crate::app::tournament::SeriesLength,
    },
    /// `PINCH_MATCH`: the match-setup screen.
    MatchSetup,
    /// `PINCH_REPLAY`: watch the last saved round.
    Replay,
    /// `PINCH_LIBRARY`: the kept rounds.
    Replays,
}

impl DevHook {
    /// The hook the environment asks for, in precedence order. `var` is
    /// injected so the ladder can be tested without touching the real
    /// process environment.
    pub(super) fn from_env(var: impl Fn(&str) -> Option<String>) -> Option<DevHook> {
        let set = |name: &str| var(name).is_some();
        if set("PINCH_LOBBY_HOST")
            || set("PINCH_LOBBY_JOIN")
            || set("PINCH_LOBBY_JOIN_BOT")
            || set("PINCH_LOBBY_WATCH")
        {
            return Some(DevHook::Lobby);
        }
        if set("PINCH_HOST") || set("PINCH_JOIN") {
            return Some(DevHook::Online);
        }
        if set("PINCH_SANDBOX") {
            return Some(DevHook::Sandbox);
        }
        if let Some(level) = var("PINCH_AUTOPLAY") {
            return Some(DevHook::Autoplay {
                // `=1` has always meant "on", and level 1 is what it did.
                level: level.parse::<usize>().unwrap_or(1).max(1) - 1,
            });
        }
        for (name, hook) in [
            ("PINCH_EDITOR", DevHook::Editor),
            ("PINCH_SETTINGS", DevHook::Settings),
            ("PINCH_CONTROLS", DevHook::Controls),
            ("PINCH_ACHIEVEMENTS", DevHook::Achievements),
            ("PINCH_LANGUAGE", DevHook::Language),
            ("PINCH_LIBRARY", DevHook::Replays),
        ] {
            if set(name) {
                return Some(hook);
            }
        }
        if let Some(list) = var("PINCH_STAGES") {
            return Some(DevHook::StageSelect {
                beach: list == "beach",
            });
        }
        if let Some(size) = var("PINCH_SKIRMISH") {
            return Some(DevHook::Skirmish {
                map: match size.as_str() {
                    "large" => match_setup::MapChoice::GenLarge,
                    "xl" => match_setup::MapChoice::GenXl,
                    "ocean" => match_setup::MapChoice::GenOcean,
                    "custom" => match_setup::MapChoice::Custom,
                    _ => match_setup::MapChoice::GenClassic,
                },
                // Bare `PINCH_SERIES=1` still means the long series it
                // always meant; `=3` asks for the short one.
                series: match var("PINCH_SERIES").as_deref() {
                    None => crate::app::tournament::SeriesLength::Single,
                    Some("3") => crate::app::tournament::SeriesLength::BestOfThree,
                    Some(_) => crate::app::tournament::SeriesLength::BestOfFive,
                },
            });
        }
        if set("PINCH_MATCH") {
            return Some(DevHook::MatchSetup);
        }
        set("PINCH_REPLAY").then_some(DevHook::Replay)
    }
}

/// Act on the environment's shortcut, if it asked for one.
pub(super) fn kickoff(
    mut campaign: ResMut<Campaign>,
    mut online: ResMut<net::Online>,
    mut config: ResMut<match_setup::MatchConfig>,
    mut tournament: ResMut<crate::app::tournament::Tournament>,
    mut playback: ResMut<crate::app::Playback>,
    mut next_screen: ResMut<NextState<Screen>>,
) {
    let Some(hook) = DevHook::from_env(|name| std::env::var(name).ok()) else {
        return;
    };
    match hook {
        DevHook::Lobby => {
            // The lobby sends the host's match terms, so the same hooks that
            // configure a local skirmish configure a hosted one.
            config.map = debug_map().unwrap_or(config.map);
            config.bots = bots().unwrap_or(config.bots);
            next_screen.set(Screen::Lobby);
        }
        DevHook::Online => {
            // Binding the socket is the side effect the enum cannot carry;
            // a failure here just leaves the player on the menu.
            if let Some(session) = net::session_from_env() {
                online.0 = Some(session);
                next_screen.set(Screen::Versus);
            }
        }
        DevHook::Sandbox => {
            next_screen.set(Screen::Versus);
        }
        DevHook::Autoplay { level } => {
            campaign.index = level.min(campaign.levels.len() - 1);
            next_screen.set(Screen::Puzzle);
        }
        DevHook::Editor => next_screen.set(Screen::Editor),
        DevHook::Settings => next_screen.set(Screen::Settings),
        DevHook::Controls => next_screen.set(Screen::Controls),
        DevHook::Achievements => next_screen.set(Screen::Achievements),
        DevHook::Language => next_screen.set(Screen::Language),
        DevHook::Replays => next_screen.set(Screen::Replays),
        DevHook::StageSelect { beach } => {
            if beach {
                let levels = crate::sim::challenge_levels();
                let builtins = levels.len();
                campaign.reset(crate::app::CampaignKind::BeachDay, levels, builtins);
            }
            next_screen.set(Screen::StageSelect);
        }
        DevHook::Skirmish { map, series } => {
            config.seats = seats().unwrap_or(4).clamp(2, crate::sim::MAX_PLAYERS as u8);
            config.bots = config.seats - 1;
            config.map = map;
            config.series = series;
            // A hand-typed PINCH_BOTS is not trusted to leave a human seat:
            // more bots than seats underflows `seats - bots` everywhere it
            // is used to count humans.
            config.bots = bots().unwrap_or(config.seats - 1).min(config.seats - 1);
            if series.is_series() {
                *tournament = crate::app::tournament::Tournament::start(series);
            }
            config.armed = true;
            next_screen.set(Screen::Versus);
        }
        DevHook::MatchSetup => {
            // `PINCH_SEATS`/`PINCH_BOTS` apply here too, so the screen can
            // be seen with a full table rather than only the default pair.
            config.seats = seats()
                .unwrap_or(config.seats)
                .clamp(2, crate::sim::MAX_PLAYERS as u8);
            config.bots = bots().unwrap_or(config.bots).min(config.seats - 1);
            for slot in 0..bot_seats().unwrap_or(0).min(config.bots) {
                let seat = usize::from(config.seats - 1 - slot);
                config.controllers[seat] = crate::app::SeatController::Bot;
            }
            next_screen.set(Screen::MatchSetup);
        }
        DevHook::Replay => {
            match std::fs::read_to_string(crate::app::replay_path())
                .map_err(|e| e.to_string())
                .and_then(|t| crate::sim::Replay::parse(&t))
            {
                Ok(replay) => {
                    playback.0 = Some((replay, 0));
                    next_screen.set(Screen::Versus);
                }
                Err(e) => warn!("PINCH_REPLAY: no replay to watch: {e}"),
            }
        }
    }
}

/// Dev hook: `PINCH_SKIRMISH=classic|large|xl|ocean|custom` also names the
/// map for a hosted lobby match.
fn debug_map() -> Option<match_setup::MapChoice> {
    Some(match std::env::var("PINCH_SKIRMISH").ok()?.as_str() {
        "large" => match_setup::MapChoice::GenLarge,
        "xl" => match_setup::MapChoice::GenXl,
        "ocean" => match_setup::MapChoice::GenOcean,
        // `custom` takes the first handmade beach with castles enough,
        // which is how the wire path gets exercised without a menu.
        "custom" => match_setup::MapChoice::Custom,
        _ => match_setup::MapChoice::GenClassic,
    })
}

/// Whether a hook that reaches straight into the local board has to stand
/// down, which in an online round it does.
///
/// Peers agree by running the same frames from the same inputs. These two
/// hooks run on one machine and put something on one board, so the very
/// next hash is a desync: a real one, reported honestly, a few seconds
/// into a round that was started to watch something else entirely. It
/// looks exactly like the netcode having gone wrong, and it costs
/// somebody the time to prove it has not. `PINCH_SPECTATOR` already
/// refuses to echo a line the table was not given, for the same reason.
///
/// The online way to call an event down on a table is `PINCH_SPECTATOR`
/// with an event number: it goes through the vote the host counts and
/// comes back as an action on a frame, which every peer runs.
fn only_off_the_wire(online: &net::Online, var: &str) -> bool {
    if online.0.is_some() {
        warn!(
            "{var}: ignored in an online round, where one peer's board is \
             not its own to change; use PINCH_SPECTATOR=<event> instead"
        );
        return true;
    }
    false
}

/// Dev hook: `PINCH_TIDE=<0-8>` fires a real tide event a few seconds in
/// (or `PINCH_TIDE_AT` seconds in),
/// rather than the banner alone, so what the event *does* can be watched.
/// Seven is the castle swap.
pub(super) fn debug_tide(mut sim: ResMut<Sim>, online: Res<net::Online>, mut hook: Local<OneShot>) {
    let Some(which) = hook.due("PINCH_TIDE", sim.0.ticks()) else {
        return;
    };
    if only_off_the_wire(&online, "PINCH_TIDE") {
        return;
    }
    let index = which.parse::<usize>().unwrap_or(0) % TideEvent::ALL.len();
    sim.0.force_tide_event(TideEvent::ALL[index], 0);
}

/// A hook that reads its variable once, waits for the round to have
/// something on the beach worth looking at, and then fires exactly once.
///
/// Three hooks share the shape: read the environment into a `Local` so it
/// is not re-read every frame, bail unless the round has run a few seconds,
/// set a `fired` flag.
/// The mid-round moments a screenshot can be taken of, each fired once.
#[derive(Default)]
pub(super) struct MomentHooks {
    pause: OneShot,
    over: OneShot,
    interlude: OneShot,
}

#[derive(Default)]
pub(super) struct OneShot {
    /// The variable and its `_AT` override, read on the first frame.
    setting: Option<(Option<String>, Option<u64>)>,
    fired: bool,
}

impl OneShot {
    /// A few seconds in: long enough for crabs to be out and moving, so
    /// whatever the hook does happens over a board with something on it.
    const WAIT: u64 = 4;

    /// What `var` was set to, the one time this is due, and `None` on every
    /// other frame and for every unset variable.
    fn due(&mut self, var: &str, ticks: u64) -> Option<String> {
        self.due_after(var, ticks, Self::WAIT)
    }

    /// The same, for a hook that wants the round further along than most.
    ///
    /// `<var>_AT=<s>` moves any hook to that many seconds into the round,
    /// for a moment wanted on a beach that has filled up: a lure or a
    /// Crab Mania four seconds in has hardly a crab to show.
    fn due_after(&mut self, var: &str, ticks: u64, seconds: u64) -> Option<String> {
        let (setting, at) = self.setting.get_or_insert_with(|| {
            let at = std::env::var(format!("{var}_AT"))
                .ok()
                .and_then(|s| s.parse().ok());
            (std::env::var(var).ok(), at)
        });
        let setting = setting.clone()?;
        let seconds = at.unwrap_or(seconds);
        if self.fired || ticks < seconds * u64::from(crate::sim::TICKS_PER_SECOND) {
            return None;
        }
        self.fired = true;
        Some(setting)
    }
}

/// Dev hook: `PINCH_LURE=<seat>` starts a lure a few seconds in (or
/// `PINCH_LURE_AT` seconds in), which is otherwise something you wait for
/// a molting crab to do.
pub(super) fn debug_lure(mut sim: ResMut<Sim>, online: Res<net::Online>, mut hook: Local<OneShot>) {
    let Some(which) = hook.due("PINCH_LURE", sim.0.ticks()) else {
        return;
    };
    if only_off_the_wire(&online, "PINCH_LURE") {
        return;
    }
    let seat = which.parse::<u8>().unwrap_or(0);
    sim.0
        .force_lure(seat.min(crate::sim::MAX_PLAYERS as u8 - 1));
}

/// Dev hook: `PINCH_SPECTATOR=card|say|pick<n>|<0-6>` works the spectator keys for a watcher that
/// has no hands: `card` opens the event list for a screenshot, and a
/// number casts that vote a few seconds in, which is otherwise a thing
/// only a person standing behind a chair can do.
///
/// The whole point of it is the chain it exercises: a vote leaves the
/// watcher, the host counts it, settles on it, and puts it into an action
/// on a frame, and the event fires on every screen at once.
pub(super) fn debug_spectator(
    mut commands: Commands,
    sim: Res<Sim>,
    settings: Res<crate::app::settings::GameSettings>,
    mut card: ResMut<crate::app::spectators::SpectatorCard>,
    mut online: ResMut<net::Online>,
    mut hook: Local<OneShot>,
) {
    let Some(which) = hook.due("PINCH_SPECTATOR", sim.0.ticks()) else {
        return;
    };
    if !crate::app::spectators::is_spectating(&online) {
        return;
    }
    if which == "say" {
        // A line from a spoke, which is the path the hub has to repeat:
        // what a spectator says reaches the table only by the host passing
        // it on, and for a while it did not.
        // The same refusal the real path makes: the host drops a line
        // that resolves to no name, so echoing one here would show this
        // screen something nobody else was given. The hook had the bug it
        // was used to find, which is a poor thing for a hook to have.
        let me = settings.names[0].clone();
        if let Some(session) = &mut online.0
            && !me.is_empty()
        {
            let line = "hello from the back row";
            session
                .transport
                .send(crate::transport::NetMsg::chat(&me, line));
            session.heard.push((me, line.to_string()));
        }
        return;
    }
    if which == "card" {
        card.0 = true;
        crate::app::spectators::spawn_card(&mut commands, &settings);
        return;
    }
    // `pick<n>` calls seat n (1-based, as the list numbers them) to win,
    // through the same send the pick card makes.
    if let Some(seat) = which
        .strip_prefix("pick")
        .and_then(|n| n.parse::<u8>().ok())
    {
        if let Some(session) = &mut online.0 {
            let seat = seat.saturating_sub(1);
            session.stands.my_pick = Some(seat);
            session
                .transport
                .send(crate::transport::NetMsg::SpectatorPick { seat });
        }
        return;
    }
    let at = which.parse::<usize>().unwrap_or(0);
    let event = crate::app::spectators::SPECTATOR_EVENTS
        [at % crate::app::spectators::SPECTATOR_EVENTS.len()];
    if let Some(session) = &mut online.0 {
        session
            .transport
            .send(crate::transport::NetMsg::SpectatorVote {
                event: event.index() as u8,
            });
    }
}

/// Dev hook: `PINCH_BANNER=lure|surge|<0-8>` raises one centre-screen
/// announcement a few seconds into the round, so the banner can be
/// screenshotted over a board with something on it, rather than waiting for
/// a sparkling crab to be banked, which may not happen for minutes.
pub(super) fn debug_banner(
    sim: Res<Sim>,
    mut announcer: ResMut<announce::Announcer>,
    mut hook: Local<OneShot>,
) {
    // Later than the others on purpose: a banner wants a board with crabs
    // spread over it behind the words.
    let Some(which) = hook.due_after("PINCH_BANNER", sim.0.ticks(), 8) else {
        return;
    };
    announcer.push(match which.as_str() {
        "lure" => announce::Announcement::Lure(0),
        "surge" => announce::Announcement::Surge,
        index => announce::Announcement::Tide(
            TideEvent::ALL[index.parse::<usize>().unwrap_or(0) % TideEvent::ALL.len()],
        ),
    });
}

/// Whether this run exists to take a picture.
///
/// The visual harness reads probe pixels off the result, and the camera
/// shake (`boot::shake_camera`) moves every one of them: the wave that ends
/// a round is precisely what `PINCH_NOSOLVE` exists to photograph. A shot
/// run holds the camera still.
pub(super) fn screenshotting() -> bool {
    std::env::var("PINCH_SCREENSHOT").is_ok()
}

/// Dev hook: with `PINCH_SCREENSHOT=/path/out.png`, saves a frame after
/// `PINCH_SCREENSHOT_AT` seconds (default 2) and exits shortly after. Used to
/// verify rendering headlessly; harmless otherwise.
pub(super) fn debug_screenshot(
    mut commands: Commands,
    time: Res<Time>,
    mut fired: Local<bool>,
    mut config: Local<Option<Option<(String, f32)>>>,
    mut exit: MessageWriter<AppExit>,
) {
    // Env lookups allocate; resolve them once, not every frame.
    let config = config.get_or_insert_with(|| {
        std::env::var("PINCH_SCREENSHOT").ok().map(|path| {
            let at = std::env::var("PINCH_SCREENSHOT_AT")
                .ok()
                .and_then(|s| s.parse().ok())
                .unwrap_or(2.0);
            (path, at)
        })
    });
    let Some((path, at)) = config else {
        return;
    };
    let (path, at) = (path.clone(), *at);
    use bevy::render::view::screenshot::{Screenshot, save_to_disk};
    if !*fired && time.elapsed_secs() > at {
        *fired = true;
        commands
            .spawn(Screenshot::primary_window())
            .observe(save_to_disk(path));
    }
    if *fired && time.elapsed_secs() > at + 1.5 {
        exit.write(AppExit::Success);
    }
}

/// Dev hook: with `PINCH_FRAMES=1`, every five seconds print the frames'
/// average, 99th percentile and worst time to stderr. For judging a
/// screen's cost from its frame times rather than its CPU, which a vsynced
/// frame hides: a build that spends twice as long per frame still shows
/// the same frame rate until it misses one.
pub(super) fn frame_times(
    time: Res<Time<Real>>,
    mut on: Local<Option<bool>>,
    mut frames: Local<Vec<f32>>,
    mut since: Local<f32>,
) {
    if !*on.get_or_insert_with(|| std::env::var("PINCH_FRAMES").is_ok()) {
        return;
    }
    let dt = time.delta_secs() * 1000.0;
    if dt > 20.0 {
        eprintln!("slow frame at {:.3}s: {dt:.1} ms", time.elapsed_secs());
    }
    frames.push(dt);
    *since += dt;
    if *since < 5000.0 {
        return;
    }
    let mut sorted = std::mem::take(&mut *frames);
    sorted.sort_by(f32::total_cmp);
    let n = sorted.len();
    let mean = sorted.iter().sum::<f32>() / n as f32;
    eprintln!(
        "frames: {n} in {:.1}s, mean {mean:.2} ms, p99 {:.2} ms, worst {:.2} ms",
        *since / 1000.0,
        sorted[(n * 99 / 100).min(n - 1)],
        sorted[n - 1],
    );
    *since = 0.0;
}

/// Dev hook: mid-round moments for screenshots. `PINCH_PAUSE=1` raises the
/// pause card a couple of seconds into a versus round; `PINCH_OVER=1` calls
/// the round over so the results card can be shot; `PINCH_INTERLUDE=1`
/// leaves for the series interlude with a mid-series tally on the card.
/// All inert unless set, like every hook here.
pub(super) fn debug_moments(
    sim: Res<Sim>,
    mut keys: ResMut<ButtonInput<KeyCode>>,
    mut tournament: ResMut<crate::app::tournament::Tournament>,
    screen: Res<State<Screen>>,
    mut next_screen: ResMut<NextState<Screen>>,
    mut next_vphase: ResMut<NextState<crate::app::VersusPhase>>,
    mut hooks: Local<MomentHooks>,
) {
    let MomentHooks {
        pause: pause_hook,
        over: over_hook,
        interlude: interlude_hook,
    } = &mut *hooks;
    if *screen.get() != Screen::Versus {
        return;
    }
    if pause_hook
        .due_after("PINCH_PAUSE", sim.0.ticks(), 2)
        .is_some()
    {
        // A synthetic Escape, so the real pause path opens the card. Let
        // go of at once: the input plugin clears only the `just_` sets, so
        // a key left pressed stays held and swallows the real Escape that
        // should close the card. `just_pressed` survives the release.
        keys.press(KeyCode::Escape);
        keys.release(KeyCode::Escape);
    }
    if over_hook
        .due_after("PINCH_OVER", sim.0.ticks(), 2)
        .is_some()
    {
        next_vphase.set(crate::app::VersusPhase::Over);
    }
    if interlude_hook
        .due_after("PINCH_INTERLUDE", sim.0.ticks(), 2)
        .is_some()
    {
        use crate::app::tournament::{SeriesLength, Tournament};
        let mut wins = [0; crate::sim::MAX_PLAYERS];
        wins[0] = 1;
        wins[1] = 1;
        // The length `PINCH_SERIES` asked for, so the card can be shot at
        // either. Hardcoding the long one here left the best-of-three card
        // unreachable, and it read "Round 3 of 5" for a year. Bare
        // `PINCH_INTERLUDE=1`, with no series in play, still demos the
        // long one, which is what it always did.
        let length = match tournament.length.is_series() {
            true => tournament.length,
            false => SeriesLength::BestOfFive,
        };
        // Two rounds are on the tally, so the card should read as the
        // third. One less than that, because `enter_interlude` counts the
        // round on the way in and this screen is being entered.
        let round = 3.min(length.rounds()) - 1;
        *tournament = Tournament::taken_up(length, round, wins);
        next_screen.set(Screen::Interlude);
    }
}

/// Dev hook: with `PINCH_AUTOPLAY=1`, the puzzle setup phase immediately
/// applies the level's authored solution and starts the run.
///
/// `PINCH_NOSOLVE=1` starts the run without placing anything, so a timed
/// level plays itself out and loses. That is the only way to reach the end
/// of a round unattended: every other path out of a level is a win, and a
/// win stops the clock before it can run out. It is how the wave that ends
/// a round gets looked at (`board_render::wash`).
pub(super) fn debug_autoplay(
    mut sim: ResMut<Sim>,
    campaign: Res<Campaign>,
    mut enabled: Local<Option<bool>>,
    mut next_phase: ResMut<NextState<Phase>>,
) {
    if !*enabled.get_or_insert_with(|| std::env::var("PINCH_AUTOPLAY").is_ok()) {
        return;
    }
    if std::env::var("PINCH_NOSOLVE").is_err() {
        let level = campaign.current();
        if let Err((x, y, _)) = level.place_solution(&mut sim.0) {
            warn!(
                "PINCH_AUTOPLAY: {:?} refused its own solution at ({x},{y})",
                level.name
            );
        }
    }
    next_phase.set(Phase::Running);
}

/// `PINCH_AUTOPILOT=easy|normal|hard`, or `=1` for normal: the level the
/// local seat plays itself at. Anything else names no level and leaves the
/// seat to its player.
pub(super) fn autopilot_level() -> Option<crate::sim::BotLevel> {
    use crate::sim::BotLevel;
    match std::env::var("PINCH_AUTOPILOT").ok()?.as_str() {
        "easy" => Some(BotLevel::Easy),
        "1" | "normal" => Some(BotLevel::Normal),
        "hard" => Some(BotLevel::Hard),
        _ => None,
    }
}

/// Dev hook: play this process's own seat with the bot brain, through the
/// same pending-actions path a keystroke takes.
///
/// `PINCH_BOTS` is not this. An AI seat is derived: it sits *beside* the
/// humans, the lockstep never carries it, and every peer works out its
/// moves from the frame they have all agreed on, so a table of five AI
/// seats puts nothing on the wire. This drives a *human* seat instead, so
/// the placements are committed and relayed exactly as a player's are and
/// six processes can play a real round with nobody at the keyboards.
///
/// Only ever a seat this process holds: a spectator commits nothing, and a
/// peer that reached for another's chair would be refused by the host and
/// would be asking for the desync the seat check exists to prevent.
pub(super) fn debug_autopilot(
    sim: Res<Sim>,
    online: Res<net::Online>,
    mut pending: ResMut<PendingActions>,
    mut level: Local<Option<Option<crate::sim::BotLevel>>>,
) {
    let Some(level) = *level.get_or_insert_with(autopilot_level) else {
        return;
    };
    let seat = match online.0.as_ref() {
        Some(session) => match session.session.seat() {
            Some(seat) => seat,
            None => return,
        },
        None => 0,
    };
    let Some(slot) = pending.0.get_mut(usize::from(seat)) else {
        return;
    };
    // Never over a real one: a hand on the keys still beats the autopilot,
    // which is what makes this usable to watch as well as to measure.
    if matches!(slot, PlayerAction::None) {
        *slot = crate::sim::bot_action(&sim.0, seat, level);
    }
}

/// Dev hook: with `PINCH_NET_PROBE=1`, submit one scripted signpost three
/// seconds into a versus round, through the normal pending-actions path (so
/// online it rides the lockstep like a real keystroke). On the classic arena
/// an Up arrow at (1,4) routes the left spawner stream into P1's castle:
/// a deterministic, decisive round for end-to-end validation.
pub(super) fn debug_net_probe(
    sim: Res<Sim>,
    online: Res<net::Online>,
    mut pending: ResMut<PendingActions>,
    mut fired: Local<bool>,
    mut enabled: Local<Option<bool>>,
) {
    if *fired
        || !*enabled.get_or_insert_with(|| std::env::var("PINCH_NET_PROBE").is_ok())
        || sim.0.ticks() < 90
    {
        return;
    }
    *fired = true;
    let seat = online
        .0
        .as_ref()
        .and_then(|session| session.session.seat())
        .unwrap_or(0);
    pending.0[seat as usize] = PlayerAction::Place {
        x: 1,
        y: 4,
        dir: Direction::Up,
    };
    info!("net probe: seat {seat} placed (1,4) Up");
}

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

    /// A lookup over a fixed list, standing in for the environment.
    fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
        move |name| {
            vars.iter()
                .find(|(key, _)| *key == name)
                .map(|(_, value)| (*value).to_string())
        }
    }

    #[test]
    fn no_hooks_means_the_menu() {
        assert_eq!(DevHook::from_env(env(&[])), None);
        assert_eq!(DevHook::from_env(env(&[("PATH", "/usr/bin")])), None);
    }

    /// Every hook is reachable on its own.
    #[test]
    fn each_hook_is_reachable() {
        for (var, expected) in [
            ("PINCH_LOBBY_HOST", DevHook::Lobby),
            ("PINCH_LOBBY_JOIN", DevHook::Lobby),
            ("PINCH_LOBBY_JOIN_BOT", DevHook::Lobby),
            ("PINCH_HOST", DevHook::Online),
            ("PINCH_JOIN", DevHook::Online),
            ("PINCH_SANDBOX", DevHook::Sandbox),
            ("PINCH_EDITOR", DevHook::Editor),
            ("PINCH_SETTINGS", DevHook::Settings),
            ("PINCH_CONTROLS", DevHook::Controls),
            ("PINCH_ACHIEVEMENTS", DevHook::Achievements),
            ("PINCH_LANGUAGE", DevHook::Language),
            ("PINCH_LIBRARY", DevHook::Replays),
            ("PINCH_STAGES", DevHook::StageSelect { beach: false }),
            ("PINCH_AUTOPLAY", DevHook::Autoplay { level: 0 }),
            ("PINCH_MATCH", DevHook::MatchSetup),
            ("PINCH_REPLAY", DevHook::Replay),
        ] {
            assert_eq!(
                DevHook::from_env(env(&[(var, "1")])),
                Some(expected),
                "{var}"
            );
        }
    }

    /// The skirmish hook carries its map size, defaulting to the classic
    /// board for anything it does not recognise, and picks up the series
    /// flag only alongside it.
    #[test]
    fn skirmish_reads_its_size_and_series_flag() {
        use match_setup::MapChoice;
        let skirmish = |vars: &[(&str, &str)]| DevHook::from_env(env(vars));
        use crate::app::tournament::SeriesLength;
        assert_eq!(
            skirmish(&[("PINCH_SKIRMISH", "large")]),
            Some(DevHook::Skirmish {
                map: MapChoice::GenLarge,
                series: SeriesLength::Single
            })
        );
        assert_eq!(
            skirmish(&[("PINCH_SKIRMISH", "xl"), ("PINCH_SERIES", "1")]),
            Some(DevHook::Skirmish {
                map: MapChoice::GenXl,
                series: SeriesLength::BestOfFive
            }),
            "the historical bare flag still means the long series"
        );
        assert_eq!(
            skirmish(&[("PINCH_SKIRMISH", "xl"), ("PINCH_SERIES", "3")]),
            Some(DevHook::Skirmish {
                map: MapChoice::GenXl,
                series: SeriesLength::BestOfThree
            })
        );
        assert_eq!(
            skirmish(&[("PINCH_SKIRMISH", "wibble")]),
            Some(DevHook::Skirmish {
                map: MapChoice::GenClassic,
                series: SeriesLength::Single
            })
        );
        // Autoplay carries the level to start on; the historical `=1`
        // still means the first level.
        assert_eq!(
            DevHook::from_env(env(&[("PINCH_AUTOPLAY", "1")])),
            Some(DevHook::Autoplay { level: 0 })
        );
        assert_eq!(
            DevHook::from_env(env(&[("PINCH_AUTOPLAY", "25")])),
            Some(DevHook::Autoplay { level: 24 })
        );

        // The stage list takes which of the two puzzle lists to show.
        assert_eq!(
            DevHook::from_env(env(&[("PINCH_STAGES", "beach")])),
            Some(DevHook::StageSelect { beach: true })
        );

        // PINCH_SERIES alone is not a shortcut; it only qualifies one.
        assert_eq!(skirmish(&[("PINCH_SERIES", "1")]), None);
    }

    /// Precedence, and the reason this is one function at all: the
    /// lobby beats a direct session, a session beats the sandbox, and the
    /// screens beat the skirmish.
    #[test]
    fn the_ladder_has_a_fixed_precedence() {
        let all = env(&[
            ("PINCH_LOBBY_HOST", "1"),
            ("PINCH_HOST", "47777"),
            ("PINCH_SANDBOX", "1"),
            ("PINCH_EDITOR", "1"),
            ("PINCH_SKIRMISH", "xl"),
            ("PINCH_REPLAY", "1"),
        ]);
        assert_eq!(DevHook::from_env(all), Some(DevHook::Lobby));
        assert_eq!(
            DevHook::from_env(env(&[("PINCH_HOST", "47777"), ("PINCH_SANDBOX", "1")])),
            Some(DevHook::Online)
        );
        assert_eq!(
            DevHook::from_env(env(&[("PINCH_SANDBOX", "1"), ("PINCH_EDITOR", "1")])),
            Some(DevHook::Sandbox)
        );
        assert_eq!(
            DevHook::from_env(env(&[("PINCH_EDITOR", "1"), ("PINCH_SKIRMISH", "xl")])),
            Some(DevHook::Editor)
        );
        assert_eq!(
            DevHook::from_env(env(&[("PINCH_SKIRMISH", "xl"), ("PINCH_REPLAY", "1")])),
            Some(DevHook::Skirmish {
                map: match_setup::MapChoice::GenXl,
                series: crate::app::tournament::SeriesLength::Single
            })
        );
    }
}