pinch-points 0.9.0

A fast, kid-friendly crab-routing game: route streams of crabs into your sandcastle before time runs out
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
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
//! 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, PlayerId, 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_PARTNERS=S@N`: once `N` rivals are aboard, a host whose
/// table plays a team mode picks the next partners for seat `S` (counted
/// from 1) once, as P and then `S` on its lobby card do: before the
/// unattended launch of `PINCH_LOBBY_HOST=N`, for a match played in the
/// teams picked.
pub(super) fn lobby_partners() -> Option<(u8, usize)> {
    let value = std::env::var("PINCH_LOBBY_PARTNERS").ok()?;
    let (seat, rivals) = value.split_once('@')?;
    Some((seat.parse().ok()?, rivals.parse().ok()?))
}

/// `PINCH_LOBBY_PICK`: a host with somebody aboard presses start once,
/// unattended, which opens the map page for a screenshot of it; and on a
/// series' results card, goes on once, to the page for the next round.
pub(super) fn lobby_pick() -> bool {
    std::env::var("PINCH_LOBBY_PICK").is_ok()
}

/// `PINCH_LOBBY_PICK=go`: and on the map page, at the start and between
/// rounds, picks the card under the cursor a second later, so an
/// unattended match plays on.
pub(super) fn lobby_pick_go() -> bool {
    std::env::var("PINCH_LOBBY_PICK").is_ok_and(|value| value == "go")
}

/// `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. Read by the match-setup hook (`PINCH_MATCH`) alone.
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_ALL_PLUGINS`: keep every one of Bevy's default plugins, the
/// ones this game draws nothing with included (see
/// `schedule::trim_unused_plugins`), for measuring what they cost.
pub(super) fn all_plugins() -> bool {
    std::env::var("PINCH_ALL_PLUGINS").is_ok()
}

/// `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_MT_EXEC`: every main-world schedule back on Bevy's parallel
/// executor, for measuring it against the single-threaded one the game
/// runs on (see `schedule::run_with`).
pub(super) fn multi_threaded_executor() -> bool {
    std::env::var("PINCH_MT_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`). The
/// game's single-threaded executor runs one fixed order per seed; with
/// `PINCH_MT_EXEC=1` the parallel one 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_PAD_PROMPTS`: start as if a pad had just been pressed, so the
/// controller's prompts are up from the first frame, for a screenshot of
/// them without a pad to press.
pub(crate) fn pad_prompts() -> bool {
    std::env::var("PINCH_PAD_PROMPTS").is_ok()
}

/// `PINCH_QUIT_CARD`: open the main menu with its quit card up, as a
/// pad's Start raises it, for a screenshot of the card: its keyboard keys,
/// or with `PINCH_PAD_PROMPTS` its pad buttons.
pub(crate) fn quit_card() -> bool {
    std::env::var("PINCH_QUIT_CARD").is_ok()
}

/// `PINCH_PADS=<n>`: that many controllers plugged in from the start,
/// pads with nobody's hands on them, for screenshots of how the seats are
/// dealt without a pad to plug in. With `PINCH_PAD_PROMPTS` the first is
/// the pad the hands last used, the one that works the setup.
pub(crate) fn virtual_pads() -> usize {
    std::env::var("PINCH_PADS")
        .ok()
        .and_then(|n| n.parse().ok())
        .unwrap_or(0)
        .min(crate::sim::MAX_PLAYERS)
}

/// `PINCH_EDITOR_LEVEL=<path>`: open the editor with the level in that
/// file on the bench, as a pasted code would put it there, for a picture
/// of the editor over a level of its own.
pub fn editor_level() -> Option<std::path::PathBuf> {
    std::env::var_os("PINCH_EDITOR_LEVEL").map(std::path::PathBuf::from)
}

/// `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, and
    /// `PINCH_LOBBY_BROWSE` for its list of games with nothing pressed.
    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`: the campaign's stage list.
    StageSelect,
    /// `PINCH_SKIRMISH=classic|arena|<map>`: `classic` the generated 12x9,
    /// `arena` the handmade arena, else any map by its `--map` name
    /// ([`skirmish_map`]); plus `PINCH_SERIES=3|5` for a series: four
    /// seats, three of them AI. Any unrecognised value is the generated
    /// beach at the classic size.
    Skirmish {
        map: match_setup::MapChoice,
        series: crate::app::tournament::SeriesLength,
    },
    /// `PINCH_MATCH=1|2|3`: match setup, on that step (the first for any
    /// other value). `PINCH_MATCH_MAP=small|arena|...|random` picks step
    /// 2's card first.
    MatchSetup {
        step: match_setup::Step,
    },
    /// `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")
            || set("PINCH_LOBBY_BROWSE")
        {
            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_EDITOR_LEVEL", 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),
        ] {
            if set(name) {
                return Some(hook);
            }
        }
        if let Some(size) = var("PINCH_SKIRMISH") {
            return Some(DevHook::Skirmish {
                map: skirmish_map("PINCH_SKIRMISH", &size),
                // 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 let Some(step) = var("PINCH_MATCH") {
            return Some(DevHook::MatchSetup {
                step: match step.as_str() {
                    "2" => match_setup::Step::Map,
                    "3" => match_setup::Step::Players,
                    _ => match_setup::Step::Game,
                },
            });
        }
        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>>,
    mut setup: ResMut<match_setup::MatchMenu>,
) {
    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.lobby_bots = bots().unwrap_or(config.lobby_bots);
            // `PINCH_SERIES` hosts a series, as it plays one locally.
            match std::env::var("PINCH_SERIES").as_deref() {
                Ok("3") => config.series = crate::app::tournament::SeriesLength::BestOfThree,
                Ok(_) => config.series = crate::app::tournament::SeriesLength::BestOfFive,
                Err(_) => {}
            }
            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 => next_screen.set(Screen::StageSelect),
        DevHook::Skirmish { map, series } => {
            let seats = seats().unwrap_or(4);
            // A hand-typed PINCH_BOTS is not trusted to leave a human seat:
            // `with_ai` keeps one.
            let table = match_setup::MatchConfig::with_ai(seats, bots().unwrap_or(seats - 1));
            config.seats = table.seats;
            config.controllers = table.controllers;
            config.map = map;
            config.series = series;
            if series.is_series() {
                *tournament = crate::app::tournament::Tournament::start(series);
            }
            config.armed = true;
            next_screen.set(Screen::Versus);
        }
        DevHook::MatchSetup { step } => {
            // `PINCH_SEATS`/`PINCH_BOTS` apply here too, so the steps can
            // be seen with a full table rather than only the default pair:
            // the top PINCH_BOTS seats to the AI, the top PINCH_BOT_SEATS
            // of those to bots.
            let seats = seats().unwrap_or(config.seats);
            let ai = bots().unwrap_or(0);
            let table = match_setup::MatchConfig::with_ai(seats, ai);
            config.seats = table.seats;
            config.controllers = table.controllers;
            for slot in 0..bot_seats().unwrap_or(0).min(table.ai_seats()) {
                let seat = usize::from(config.seats - 1 - slot);
                config.controllers[seat] = crate::app::SeatController::Bot;
            }
            match std::env::var("PINCH_MATCH_MAP").ok().as_deref() {
                Some("random") => config.random = true,
                Some(name) => {
                    config.map = skirmish_map("PINCH_MATCH_MAP", name);
                    config.random = false;
                    match_setup::pick_the_map_in_force(&mut config);
                }
                None => {}
            }
            setup.step = step;
            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|arena|<map>` ([`skirmish_map`]) also
/// names the map for a hosted lobby match.
fn debug_map() -> Option<match_setup::MapChoice> {
    Some(skirmish_map(
        "PINCH_SKIRMISH",
        &std::env::var("PINCH_SKIRMISH").ok()?,
    ))
}

/// The map a `PINCH_SKIRMISH` value names, for a bots match and a hosted
/// lobby alike. `classic` is the generated beach at the classic size, as
/// it always was for these hooks (the bots' `--map` names that one
/// `generated` and the handmade arena `classic`), and `arena` the handmade
/// one a new player's Versus starts on. A name no map has falls back to
/// `classic` and says so in the log under `var`, the hook it came from,
/// rather than quietly playing a beach nobody asked for.
fn skirmish_map(var: &str, value: &str) -> match_setup::MapChoice {
    // `custom` takes the first handmade beach with castles enough, which
    // is how the wire path gets exercised without a menu; every other map
    // goes by the name the bots' `--map` gives it.
    match value {
        "classic" => match_setup::MapChoice::GenClassic,
        "arena" => match_setup::MapChoice::Classic,
        name => match_setup::MapChoice::from_token(name).unwrap_or_else(|| {
            warn!("{var}: no map called {name:?}, playing the classic generated beach");
            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. With `PINCH_TIDE_BY=<seat>` it lands as that
/// seat's spin, as a sparkling crab banked there would, rather than as a
/// call nobody spun.
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 event = TideEvent::ALL[which.parse::<usize>().unwrap_or(0) % TideEvent::ALL.len()];
    match std::env::var("PINCH_TIDE_BY")
        .ok()
        .and_then(|seat| seat.parse::<PlayerId>().ok())
    {
        Some(seat) => sim.0.force_spun_tide_event(event, seat),
        None => sim.0.force_tide_event(event, 0),
    }
}

/// 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,
}

/// 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.
///
/// Seven hooks share it: `PINCH_TIDE`, `PINCH_LURE`, `PINCH_SPECTATOR`,
/// `PINCH_BANNER`, and the three [`MomentHooks`]. Each reads the
/// environment into a `Local` so it is not re-read every frame, waits
/// until the round has run a few seconds (or its `_AT` override), and
/// fires once.
#[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.
        if let Some(session) = &mut online.0
            && let me = crate::app::lobby::speaking_as(session.bot.as_deref(), &settings.names[0])
                .to_string()
            && !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.winner.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 {
            event: TideEvent::ALL[index.parse::<usize>().unwrap_or(0) % TideEvent::ALL.len()],
            by: None,
            keeper: None,
        },
    });
}

/// 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. 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.
///
/// `PINCH_SCREENSHOT_TICK=<n>` waits for the board's clock to reach tick
/// `n` instead, which on a deterministic beach is the same moment of play
/// every run, and `PINCH_SCREENSHOT_BURST=<count>` takes that many, one
/// every `PINCH_SCREENSHOT_EVERY` ticks (default 3), as `out_00.png`,
/// `out_01.png` and on: a short animation, read frame by frame.
pub(super) fn debug_screenshot(
    mut commands: Commands,
    time: Res<Time>,
    sim: Res<Sim>,
    mut taken: Local<u32>,
    mut done_at: Local<Option<f32>>,
    mut config: Local<Option<Option<ShotPlan>>>,
    mut exit: MessageWriter<AppExit>,
) {
    // Env lookups allocate; resolve them once, not every frame.
    let config = config.get_or_insert_with(ShotPlan::from_env);
    let Some(plan) = config else {
        return;
    };
    use bevy::render::view::screenshot::{Screenshot, save_to_disk};
    if let Some(done) = *done_at {
        if time.elapsed_secs() > done + 1.5 {
            exit.write(AppExit::Success);
        }
        return;
    }
    let due = match plan.tick {
        Some(tick) => sim.0.ticks() >= tick + u64::from(*taken * plan.every),
        None => time.elapsed_secs() > plan.at,
    };
    if !due {
        return;
    }
    let path = match plan.burst {
        1 => plan.path.clone(),
        _ => {
            let stem = plan.path.strip_suffix(".png").unwrap_or(&plan.path);
            format!("{stem}_{:02}.png", *taken)
        }
    };
    commands
        .spawn(Screenshot::primary_window())
        .observe(save_to_disk(path));
    *taken += 1;
    if *taken >= plan.burst {
        *done_at = Some(time.elapsed_secs());
    }
}

/// Dev hook: `PINCH_SLOWMO=<speed>` runs the whole game at that fraction
/// of real time, the round's clock and every animation with it. With a
/// screenshot burst it films a moment a few frames apart where a burst at
/// full speed, each shot costing a fraction of a second, would catch it
/// once.
pub(super) fn slow_motion(mut time: ResMut<Time<Virtual>>) {
    if let Some(speed) = std::env::var("PINCH_SLOWMO")
        .ok()
        .and_then(|s| s.parse::<f32>().ok())
        .filter(|s| *s > 0.0)
    {
        time.set_relative_speed(speed);
    }
}

/// What [`debug_screenshot`] was asked to take.
pub(super) struct ShotPlan {
    path: String,
    at: f32,
    tick: Option<u64>,
    burst: u32,
    every: u32,
}

impl ShotPlan {
    fn from_env() -> Option<ShotPlan> {
        fn num<T: std::str::FromStr>(key: &str) -> Option<T> {
            std::env::var(key).ok()?.parse().ok()
        }
        Some(ShotPlan {
            path: std::env::var("PINCH_SCREENSHOT").ok()?,
            at: num("PINCH_SCREENSHOT_AT").unwrap_or(2.0),
            tick: num("PINCH_SCREENSHOT_TICK"),
            burst: num("PINCH_SCREENSHOT_BURST").unwrap_or(1).max(1),
            every: num("PINCH_SCREENSHOT_EVERY").unwrap_or(3).max(1),
        })
    }
}

/// 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: `PINCH_LIBRARY=<n>` opens the replay shelf with the cursor on
/// its `n`th round (from 1, so the historical `=1` is the top), and the
/// window scrolled to it as a walk down would leave it, so the middle and
/// the foot of a long shelf can be shot.
pub(super) fn debug_library_cursor(mut library: ResMut<crate::app::replays::Library>) {
    let Some(round) = std::env::var("PINCH_LIBRARY")
        .ok()
        .and_then(|n| n.parse::<usize>().ok())
    else {
        return;
    };
    library.selected = round.saturating_sub(1);
    library.settle();
}

/// Dev hook: `PINCH_LOBBY_BROWSE=<n>` holds the join list's cursor on its
/// `n`th beach (from 1, so the historical `=1` is the top) once that many
/// are on the air, the window scrolled to it as a walk down would leave
/// it, so the foot of a long list can be shot.
pub(super) fn debug_lobby_cursor(mut state: ResMut<crate::app::lobby::LobbyState>) {
    let Some(beach) = std::env::var("PINCH_LOBBY_BROWSE")
        .ok()
        .and_then(|n| n.parse::<usize>().ok())
        .and_then(|n| n.checked_sub(1))
    else {
        return;
    };
    if beach < state.hosts.len() && state.selected_index() != Some(beach) {
        state.point_at(Some(beach));
    }
}

/// Dev hook: `PINCH_DELETE=1` presses the replay shelf's delete once, a
/// second after the shelf opens, so the question it asks can be shot.
/// Pressed and let go at once, as [`debug_moments`] presses its Escape.
pub(super) fn debug_delete(
    mut keys: ResMut<ButtonInput<KeyCode>>,
    caps: Res<crate::app::keycaps::KeyCaps>,
    time: Res<Time>,
    mut opened: Local<Option<f32>>,
    mut fired: Local<bool>,
) {
    if *fired || std::env::var("PINCH_DELETE").is_err() {
        return;
    }
    let now = time.elapsed_secs();
    if now - *opened.get_or_insert(now) < 1.0 {
        return;
    }
    *fired = true;
    if let Some(key) = caps.key_for(crate::app::replays::DELETE_CAP) {
        keys.press(key);
        keys.release(key);
    }
}

/// Dev hook: `PINCH_EDITOR_PRESS=F2,F2` presses those keys in the editor,
/// one a second, the first a second after it opens: a picture of what a
/// key says (F2's question over another level's file, the line after a
/// save) without a hand at the keyboard. Keys by Bevy's names, the
/// function keys as `F1`..`F12`, and `Shift+` in front of one holds Shift
/// with it until the next press (`Shift+F1`, the card of every key).
pub(super) fn debug_editor_press(
    mut keys: ResMut<ButtonInput<KeyCode>>,
    time: Res<Time>,
    mut opened: Local<Option<f32>>,
    mut pressed: Local<usize>,
) {
    let Ok(list) = std::env::var("PINCH_EDITOR_PRESS") else {
        return;
    };
    let now = time.elapsed_secs();
    let since = now - *opened.get_or_insert(now);
    let Some(name) = list.split(',').nth(*pressed) else {
        return;
    };
    if since < (*pressed + 1) as f32 {
        return;
    }
    *pressed += 1;
    keys.release(KeyCode::ShiftLeft);
    let (shift, name) = match name.trim().strip_prefix("Shift+") {
        Some(name) => (true, name),
        None => (false, name.trim()),
    };
    match key_named(name) {
        Some(key) => {
            if shift {
                keys.press(KeyCode::ShiftLeft);
            }
            keys.press(key);
            keys.release(key);
        }
        None => warn!("PINCH_EDITOR_PRESS: no key called {name:?}"),
    }
}

/// A key by the name a dev hook gives it: Bevy's spelling ("KeyV",
/// "Escape"), the function keys among them.
fn key_named(name: &str) -> Option<KeyCode> {
    const NAMED: [KeyCode; 15] = [
        KeyCode::F1,
        KeyCode::F2,
        KeyCode::F3,
        KeyCode::F4,
        KeyCode::F5,
        KeyCode::F6,
        KeyCode::F7,
        KeyCode::F8,
        KeyCode::F9,
        KeyCode::F10,
        KeyCode::F11,
        KeyCode::F12,
        KeyCode::Escape,
        KeyCode::Enter,
        KeyCode::Tab,
    ];
    NAMED
        .into_iter()
        .find(|&key| crate::app::binds::key_name(key) == name)
        .or_else(|| crate::app::binds::any_key_from_name(name))
}

/// Dev hook: `PINCH_KEEP=1` presses the results card's keep key once, a
/// second after the card goes up, so the card can be shot before and
/// after the round is kept.
pub(super) fn debug_keep(
    mut keys: ResMut<ButtonInput<KeyCode>>,
    caps: Res<crate::app::keycaps::KeyCaps>,
    time: Res<Time>,
    mut opened: Local<Option<f32>>,
    mut fired: Local<bool>,
) {
    if *fired || std::env::var("PINCH_KEEP").is_err() {
        return;
    }
    let now = time.elapsed_secs();
    if now - *opened.get_or_insert(now) < 1.0 {
        return;
    }
    *fired = true;
    if let Some(key) = caps.key_for(crate::app::replays::KEEP_CAP) {
        keys.press(key);
        keys.release(key);
    }
}

/// 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, offering the round as
/// it stands to the shelf as a finished one would be; `PINCH_INTERLUDE=1`
/// leaves for the series interlude with a mid-series tally on the card,
/// and the round offered there the same way.
/// 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, recorder, mut keep): (
        ResMut<NextState<crate::app::VersusPhase>>,
        Res<crate::app::Recorder>,
        ResMut<crate::app::replays::RoundToKeep>,
    ),
    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);
        offer_as_finished(&recorder, &mut keep);
    }
    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);
        // The round left offered, as a finished one leaves it for the
        // interlude, so the card can be shot with the keep key on it.
        offer_as_finished(&recorder, &mut keep);
        next_screen.set(Screen::Interlude);
    }
}

/// Offer the round being recorded to the shelf as a finished one would be.
/// Filed as a draw: the hooks call the round over without asking who was
/// ahead.
fn offer_as_finished(recorder: &crate::app::Recorder, keep: &mut crate::app::replays::RoundToKeep) {
    if let Some(replay) = &recorder.0 {
        *keep = crate::app::replays::RoundToKeep::Offered(Box::new(crate::app::replays::Unkept {
            text: replay.to_text(),
            stamp: crate::app::clock::now_secs(),
            winner: None,
        }));
    }
}

/// Dev hook: with `PINCH_AUTOPLAY=<level>` set (the level is read by
/// [`DevHook::Autoplay`]; any value turns this on), 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.
///
/// `PINCH_STAY=1` opens the level and leaves it in its setup, nothing
/// placed and nothing started, for a picture of the setup's own lines;
/// `PINCH_STAY=placed` puts the authored solution down and stays in the
/// setup, for a picture of the arrows as they stand before the run.
pub(super) fn debug_autoplay(
    mut sim: ResMut<Sim>,
    campaign: Res<Campaign>,
    mut enabled: Local<Option<bool>>,
    mut next_phase: ResMut<NextState<Phase>>,
) {
    let stay = std::env::var("PINCH_STAY").ok();
    let placed = stay.as_deref() == Some("placed");
    let wanted = || std::env::var("PINCH_AUTOPLAY").is_ok() && (stay.is_none() || placed);
    if !*enabled.get_or_insert_with(wanted) {
        return;
    }
    if placed {
        *enabled = Some(false);
        if let Err((x, y, _, _)) = campaign.current().place_solution(&mut sim.0) {
            warn!("PINCH_STAY=placed: refused at ({x},{y})");
        }
        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.
///
/// With `PINCH_AUTOPILOT_WALK=1` the seat's cursor walks to each tile the
/// bot picks, a step every key-repeat interval, and the arrow goes down
/// only once it is there: a filmed round shows a cursor steering, not one
/// parked in a corner while arrows appear elsewhere. Off by default, since
/// the walk delays every placement and the measurements want the bot as
/// quick as it is.
pub(super) fn debug_autopilot(
    sim: Res<Sim>,
    online: Res<net::Online>,
    time: Res<Time>,
    settings: Res<crate::app::settings::GameSettings>,
    mut pending: ResMut<PendingActions>,
    mut cursors: Query<&mut crate::app::cursor::Cursor>,
    mut pilot: Local<Option<Autopilot>>,
) {
    let walk = pilot.get_or_insert_with(|| Autopilot {
        level: autopilot_level(),
        walks: std::env::var("PINCH_AUTOPILOT_WALK").is_ok(),
        heading: None,
        wait: 0.0,
    });
    let Some(level) = walk.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) {
        return;
    }
    if !walk.walks {
        *slot = crate::sim::bot_action(&sim.0, seat, level);
        return;
    }
    let Some(mut cursor) = cursors.iter_mut().find(|cursor| cursor.player == seat) else {
        return;
    };
    let wanted = match walk.heading {
        Some(action) => action,
        None => crate::sim::bot_action(&sim.0, seat, level),
    };
    let (x, y) = match wanted {
        PlayerAction::Place { x, y, .. } | PlayerAction::Remove { x, y } => (x, y),
        PlayerAction::None | PlayerAction::CallEvent(_) => {
            *slot = wanted;
            return;
        }
    };
    if (cursor.x, cursor.y) == (x, y) {
        *slot = wanted;
        walk.heading = None;
        return;
    }
    walk.heading = Some(wanted);
    walk.wait -= time.delta_secs();
    if walk.wait > 0.0 {
        return;
    }
    walk.wait = settings.repeat_interval;
    // One axis at a time, the longer first, as a thumb on the keys goes.
    let (dx, dy) = (
        i16::from(x) - i16::from(cursor.x),
        i16::from(y) - i16::from(cursor.y),
    );
    if dx.abs() >= dy.abs() {
        cursor.x = cursor.x.saturating_add_signed(dx.signum() as i8);
    } else {
        cursor.y = cursor.y.saturating_add_signed(dy.signum() as i8);
    }
}

/// The autopilot's level, read once, and where `PINCH_AUTOPILOT_WALK` is
/// taking the cursor and when it next steps.
pub(super) struct Autopilot {
    level: Option<crate::sim::BotLevel>,
    walks: bool,
    heading: Option<PlayerAction>,
    wait: f32,
}

/// 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::*;

    /// The Gates' old name still picks it in the hooks, and a name no map
    /// has falls back to the classic generated beach.
    #[test]
    fn the_old_kelp_keep_name_still_picks_the_gates() {
        use match_setup::MapChoice;
        assert_eq!(skirmish_map("T", "kelp-keep"), MapChoice::TheGates);
        assert_eq!(skirmish_map("T", "gates"), MapChoice::TheGates);
        assert_eq!(skirmish_map("T", "no-such-beach"), MapChoice::GenClassic);
    }

    /// 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_LOBBY_BROWSE", DevHook::Lobby),
            ("PINCH_HOST", DevHook::Online),
            ("PINCH_JOIN", DevHook::Online),
            ("PINCH_SANDBOX", DevHook::Sandbox),
            ("PINCH_EDITOR", DevHook::Editor),
            ("PINCH_EDITOR_LEVEL", 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),
            ("PINCH_AUTOPLAY", DevHook::Autoplay { level: 0 }),
            (
                "PINCH_MATCH",
                DevHook::MatchSetup {
                    step: match_setup::Step::Game,
                },
            ),
            ("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
            })
        );
        // `classic` the generated 12x9, `arena` the handmade one.
        for (token, map) in [
            ("classic", MapChoice::GenClassic),
            ("arena", MapChoice::Classic),
        ] {
            assert_eq!(
                skirmish(&[("PINCH_SKIRMISH", token)]),
                Some(DevHook::Skirmish {
                    map,
                    series: SeriesLength::Single
                }),
                "{token}"
            );
        }
        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 })
        );

        // 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
            })
        );
    }
}

/// `PINCH_PRESENT=immediate|mailbox|novsync|vsync`: how the window presents,
/// for measuring a screen uncapped. Profiling only: tearing and a busy GPU
/// are the price of seeing past the display's refresh.
pub(super) fn present_mode() -> Option<bevy::window::PresentMode> {
    use bevy::window::PresentMode;
    Some(match std::env::var("PINCH_PRESENT").ok()?.as_str() {
        "immediate" => PresentMode::Immediate,
        "mailbox" => PresentMode::Mailbox,
        "novsync" => PresentMode::AutoNoVsync,
        _ => PresentMode::AutoVsync,
    })
}

/// `PINCH_PROFILE=<secs>`: a GPU profile of whatever screen the other hooks
/// open. After `PINCH_PROFILE_WARMUP` seconds (default 8), sample every
/// render pass's GPU time (wgpu timestamp queries, through Bevy's render
/// diagnostics), the frame time and the process's CPU time for `<secs>`
/// seconds, print each pass's mean and 95th percentile, append them to
/// `PINCH_PROFILE_OUT` (if set) under the label `PINCH_PROFILE_LABEL`, and
/// exit. Pair it with `PINCH_PRESENT=immediate` to see past vsync.
///
/// `PINCH_HIDE` (a comma list of `sand`, `board`, `sea`, `pools`,
/// `shadows`, `particles`, `sprites`, `ui`, `boxshadows`) hides one kind of
/// thing, and `PINCH_GFX` (`nohdr`, `nograde`, `msaa0`, `msaa4`,
/// `shadowsamples<n>`) changes one stage of the camera, so each cost can be
/// measured by its absence.
///
/// `PINCH_PROFILE_CPU=1` leaves the GPU timestamp queries out (they cost CPU
/// time of their own every frame) and reports the frame time, the process's
/// CPU time and each thread's share of it.
#[derive(Resource, Clone, Debug, Default)]
pub(super) struct Profile {
    warmup: f32,
    span: f32,
    hide: Vec<String>,
    gfx: Vec<String>,
    cpu_only: bool,
}

impl Profile {
    pub(super) fn from_env() -> Option<Profile> {
        let span = std::env::var("PINCH_PROFILE").ok()?.parse().ok()?;
        let list = |key: &str| -> Vec<String> {
            std::env::var(key)
                .unwrap_or_default()
                .split(',')
                .filter(|s| !s.is_empty())
                .map(str::to_owned)
                .collect()
        };
        Some(Profile {
            warmup: std::env::var("PINCH_PROFILE_WARMUP")
                .ok()
                .and_then(|s| s.parse().ok())
                .unwrap_or(8.0),
            span,
            hide: list("PINCH_HIDE"),
            gfx: list("PINCH_GFX"),
            cpu_only: std::env::var("PINCH_PROFILE_CPU").is_ok(),
        })
    }

    fn hides(&self, what: &str) -> bool {
        self.hide.iter().any(|h| h == what)
    }

    fn takes_off(&self, what: &str) -> bool {
        self.gfx.iter().any(|g| g == what)
    }
}

/// `PINCH_GFX=msaa0|msaa4`: the anti-aliasing a profile run forces, over
/// the settings.
/// Read once: it is asked every frame.
pub(super) fn msaa_override() -> Option<bool> {
    static SAID: std::sync::OnceLock<Option<bool>> = std::sync::OnceLock::new();
    *SAID.get_or_init(|| {
        let gfx = std::env::var("PINCH_GFX").ok()?;
        let mut said = None;
        for g in gfx.split(',') {
            match g {
                "msaa0" => said = Some(false),
                "msaa4" => said = Some(true),
                _ => {}
            }
        }
        said
    })
}

/// Install the profiler when `PINCH_PROFILE` asks for it.
pub(super) fn install_profiling(app: &mut App) {
    let Some(profile) = Profile::from_env() else {
        return;
    };
    if !profile.cpu_only {
        app.add_plugins(bevy::render::diagnostic::RenderDiagnosticsPlugin);
    }
    app.add_plugins(bevy::diagnostic::FrameTimeDiagnosticsPlugin::default())
        .insert_resource(profile)
        .init_resource::<Churn>()
        .add_systems(
            Last,
            (profile_camera, profile_hide, profile_churn, profile_sample).chain(),
        );
}

/// What changed each frame that the renderer pays for again: sprites
/// (each distinct look is its own material in Bevy's sprite meshes), sprite
/// materials made, texts and UI nodes, summed over the sample window.
#[derive(Resource, Default)]
struct Churn {
    frames: u32,
    sprites: u64,
    sprites_changed: u64,
    materials_added: u64,
    texts_changed: u64,
    nodes_changed: u64,
    by_image: std::collections::HashMap<AssetId<Image>, u64>,
}

type SpriteMaterial = SpriteMeshMaterial;

fn profile_churn(
    mut churn: ResMut<Churn>,
    profile: Res<Profile>,
    time: Res<Time<Real>>,
    sprites: Query<Ref<Sprite>>,
    texts: Query<(), Changed<Text>>,
    nodes: Query<(), Changed<Node>>,
    mut materials: MessageReader<AssetEvent<SpriteMaterial>>,
) {
    let added = materials
        .read()
        .filter(|e| matches!(e, AssetEvent::Added { .. }))
        .count() as u64;
    if time.elapsed_secs() < profile.warmup {
        return;
    }
    churn.frames += 1;
    churn.sprites += sprites.iter().count() as u64;
    for sprite in sprites.iter().filter(Ref::is_changed) {
        churn.sprites_changed += 1;
        *churn.by_image.entry(sprite.image.id()).or_default() += 1;
    }
    churn.materials_added += added;
    churn.texts_changed += texts.iter().count() as u64;
    churn.nodes_changed += nodes.iter().count() as u64;
}

/// [`Churn`] as the line [`profile_sample`] prints, the images of the
/// sprites changed most often named.
#[derive(bevy::ecs::system::SystemParam)]
struct ChurnLine<'w> {
    churn: Res<'w, Churn>,
    assets: Res<'w, AssetServer>,
}

impl ChurnLine<'_> {
    fn line(&self) -> String {
        let churn = &*self.churn;
        let n = f64::from(churn.frames.max(1));
        let mut top: Vec<_> = churn.by_image.iter().collect();
        top.sort_by(|a, b| b.1.cmp(a.1));
        let top: Vec<String> = top
            .into_iter()
            .take(8)
            .map(|(id, count)| {
                let name = self
                    .assets
                    .get_path(*id)
                    .map_or_else(|| format!("{id:?}"), |p| p.to_string());
                format!("{name} {:.1}", *count as f64 / n)
            })
            .collect();
        format!(
            "sprites {:.0}, sprites changed {:.1}, sprite materials added {:.1}, texts changed {:.1}, nodes changed {:.1} (per frame); changed by image: {}",
            churn.sprites as f64 / n,
            churn.sprites_changed as f64 / n,
            churn.materials_added as f64 / n,
            churn.texts_changed as f64 / n,
            churn.nodes_changed as f64 / n,
            top.join(", "),
        )
    }
}

/// Change the camera and the cards as `PINCH_GFX` and `PINCH_HIDE=boxshadows`
/// say, every frame, since screens spawn new cards all along.
fn profile_camera(
    mut commands: Commands,
    profile: Res<Profile>,
    cameras: Query<Entity, With<Camera2d>>,
    box_shadows: Query<Entity, With<BoxShadow>>,
) {
    for camera in &cameras {
        let mut entity = commands.entity(camera);
        if profile.takes_off("nohdr") {
            entity.remove::<bevy::camera::Hdr>();
        }
        if profile.takes_off("nograde") {
            entity.insert(bevy::render::view::ColorGrading::default());
        }
        if let Some(n) = profile
            .gfx
            .iter()
            .find_map(|g| g.strip_prefix("shadowsamples")?.parse().ok())
        {
            entity.insert(BoxShadowSamples(n));
        }
    }
    if profile.hides("boxshadows") {
        for card in &box_shadows {
            commands.entity(card).remove::<BoxShadow>();
        }
    }
}

type SandQuad = MeshMaterial2d<crate::app::materials::SandMaterial>;
type WaterQuad = MeshMaterial2d<crate::app::materials::WaterMaterial>;
type WaterOnly = (Without<Sprite>, Without<SandQuad>);
type FadeQuad = MeshMaterial2d<crate::app::materials::FadeMaterial>;
type FadeOnly = (
    With<FadeQuad>,
    Without<Sprite>,
    Without<SandQuad>,
    Without<WaterQuad>,
    Without<Node>,
);
type UiRoot = (
    With<Node>,
    Without<ChildOf>,
    Without<Sprite>,
    Without<SandQuad>,
    Without<WaterQuad>,
);

/// Hide what `PINCH_HIDE` names, every frame, since rounds spawn new
/// things all along.
fn profile_hide(
    profile: Res<Profile>,
    art: Option<Res<crate::app::art::Art>>,
    mut sand: Query<(&mut Visibility, &SandQuad), Without<Sprite>>,
    mut water: Query<(&mut Visibility, &WaterQuad), WaterOnly>,
    mut sprites: Query<(&mut Visibility, &Sprite, Has<crate::app::effects::Particle>)>,
    mut roots: Query<&mut Visibility, UiRoot>,
    mut fades: Query<&mut Visibility, FadeOnly>,
) {
    let Some(art) = art else {
        return;
    };
    if profile.hide.is_empty() {
        return;
    }
    let shaded = &art.shaded;
    for (mut visibility, material) in &mut sand {
        let board = material.0 == shaded.board_sand;
        if (board && profile.hides("board")) || (!board && profile.hides("sand")) {
            visibility.set_if_neq(Visibility::Hidden);
        }
    }
    for (mut visibility, material) in &mut water {
        let sea = material.0 == shaded.sea || material.0 == shaded.ocean;
        if (sea && profile.hides("sea")) || (!sea && profile.hides("pools")) {
            visibility.set_if_neq(Visibility::Hidden);
        }
    }
    let shadows = [
        &art.shadow,
        &art.gull_shadow,
        &art.gull_shadow_b,
        &art.cloud,
    ];
    for (mut visibility, sprite, particle) in &mut sprites {
        let shadow = shadows.contains(&&sprite.image);
        if profile.hides("sprites")
            || (particle && profile.hides("particles"))
            || (shadow && profile.hides("shadows"))
        {
            visibility.set_if_neq(Visibility::Hidden);
        }
    }
    // Every fading quad is a particle (the footprints).
    if profile.hides("sprites") || profile.hides("particles") {
        for mut visibility in &mut fades {
            visibility.set_if_neq(Visibility::Hidden);
        }
    }
    if profile.hides("ui") {
        for mut visibility in &mut roots {
            visibility.set_if_neq(Visibility::Hidden);
        }
    }
}

/// What [`profile_sample`] has gathered: per diagnostic path, one value a
/// frame, and the last measurement read so none is counted twice.
#[derive(Default)]
struct ProfileSamples {
    started: Option<(f32, f64)>,
    threads: std::collections::BTreeMap<u32, (String, f64)>,
    seen: std::collections::HashMap<String, std::time::Instant>,
    values: std::collections::BTreeMap<String, Vec<f64>>,
    frames: u32,
}

/// The process's CPU time so far, user and system, in seconds (Linux).
fn process_cpu_secs() -> f64 {
    let Ok(stat) = std::fs::read_to_string("/proc/self/stat") else {
        return 0.0;
    };
    // Fields after the command's closing parenthesis; utime and stime are
    // the 12th and 13th of those.
    let rest = stat.rsplit_once(')').map_or("", |(_, r)| r);
    let fields: Vec<&str> = rest.split_whitespace().collect();
    let ticks: f64 = fields.get(11..13).map_or(0.0, |f| {
        f.iter().filter_map(|v| v.parse::<f64>().ok()).sum()
    });
    ticks / 100.0
}

/// Each thread's CPU time so far, user and system, in seconds, by thread id
/// with its name (Linux).
fn thread_cpu_secs() -> std::collections::BTreeMap<u32, (String, f64)> {
    let mut out = std::collections::BTreeMap::new();
    let Ok(tasks) = std::fs::read_dir("/proc/self/task") else {
        return out;
    };
    for task in tasks.flatten() {
        let Some(tid) = task.file_name().to_str().and_then(|t| t.parse().ok()) else {
            continue;
        };
        let Ok(stat) = std::fs::read_to_string(task.path().join("stat")) else {
            continue;
        };
        let name = stat
            .split_once('(')
            .and_then(|(_, r)| r.rsplit_once(')'))
            .map_or(String::new(), |(n, _)| n.to_owned());
        let rest = stat.rsplit_once(')').map_or("", |(_, r)| r);
        let fields: Vec<&str> = rest.split_whitespace().collect();
        let ticks: f64 = fields.get(11..13).map_or(0.0, |f| {
            f.iter().filter_map(|v| v.parse::<f64>().ok()).sum()
        });
        out.insert(tid, (name, ticks / 100.0));
    }
    out
}

fn profile_sample(
    profile: Res<Profile>,
    time: Res<Time<Real>>,
    store: Res<bevy::diagnostic::DiagnosticsStore>,
    windows: Query<&Window>,
    mut samples: Local<ProfileSamples>,
    mut exit: MessageWriter<AppExit>,
    churn: ChurnLine,
) {
    let now = time.elapsed_secs();
    if now < profile.warmup {
        return;
    }
    if samples.started.is_none() {
        samples.threads = thread_cpu_secs();
    }
    let (start, cpu_start) = *samples
        .started
        .get_or_insert_with(|| (now, process_cpu_secs()));
    // Mark everything already in the store as seen on the first frame, so
    // the warm-up's measurements stay out.
    let first = samples.frames == 0;
    samples.frames += 1;
    for diagnostic in store.iter() {
        let path = diagnostic.path().as_str().to_owned();
        let last = samples.seen.get(&path).copied();
        let mut per_frame: Vec<(std::time::Instant, f64)> = Vec::new();
        for m in diagnostic.measurements() {
            if last.is_some_and(|l| m.time <= l) {
                continue;
            }
            // Spans sharing a path in one frame arrive with one timestamp:
            // they add up to that frame's cost.
            match per_frame.last_mut() {
                Some((t, v)) if *t == m.time => *v += m.value,
                _ => per_frame.push((m.time, m.value)),
            }
        }
        if let Some((t, _)) = per_frame.last() {
            samples.seen.insert(path.clone(), *t);
        }
        if !first {
            samples
                .values
                .entry(path)
                .or_default()
                .extend(per_frame.into_iter().map(|(_, v)| v));
        }
    }
    if now - start < profile.span {
        return;
    }
    let cpu = process_cpu_secs() - cpu_start;
    let frames = samples.frames.saturating_sub(1).max(1);
    let size = windows.iter().next().map_or(String::new(), |w| {
        format!("{}x{}", w.physical_width(), w.physical_height())
    });
    let label = std::env::var("PINCH_PROFILE_LABEL").unwrap_or_default();
    let mut lines = vec![format!(
        "# {label} {size} frames {frames} in {:.1}s, cpu {:.2} ms/frame (all threads)",
        now - start,
        cpu * 1000.0 / f64::from(frames),
    )];
    // Per thread name (Bevy's pool workers share one name each), CPU ms per
    // frame and cores busy over the window.
    let mut by_name: std::collections::BTreeMap<String, (f64, u32)> = Default::default();
    for (tid, (name, secs)) in thread_cpu_secs() {
        let before = samples.threads.get(&tid).map_or(0.0, |(_, s)| *s);
        let entry = by_name.entry(name).or_default();
        entry.0 += secs - before;
        entry.1 += 1;
    }
    let wall = f64::from(now - start).max(1e-6);
    let mut named: Vec<_> = by_name.into_iter().filter(|(_, (s, _))| *s > 0.0).collect();
    named.sort_by(|a, b| b.1.0.total_cmp(&a.1.0));
    for (name, (secs, count)) in named {
        lines.push(format!(
            "{label}\t{size}\tthread/{name} x{count}\t{:.3}\t{:.2}\t0",
            secs * 1000.0 / f64::from(frames),
            secs / wall,
        ));
    }
    lines.push(format!("# {label} churn: {}", churn.line()));
    lines.push(format!(
        "{label}\t{size}\tcpu_ms_per_frame\t{:.3}\t{:.2}\t{frames}",
        cpu * 1000.0 / f64::from(frames),
        cpu / wall,
    ));
    let stat = |v: &[f64]| -> (f64, f64) {
        let mut sorted = v.to_vec();
        sorted.sort_by(f64::total_cmp);
        let n = sorted.len();
        let mean = sorted.iter().sum::<f64>() / n as f64;
        (mean, sorted[(n * 95 / 100).min(n - 1)])
    };
    for (path, values) in &samples.values {
        let keep = path.ends_with("elapsed_gpu")
            || path.ends_with("elapsed_cpu")
            || path.starts_with("fps")
            || path.starts_with("frame_time");
        if !keep || values.is_empty() {
            continue;
        }
        let (mean, p95) = stat(values);
        lines.push(format!(
            "{label}\t{size}\t{path}\t{mean:.3}\t{p95:.3}\t{}",
            values.len()
        ));
    }
    let text = lines.join("\n");
    eprintln!("{text}");
    if let Ok(out) = std::env::var("PINCH_PROFILE_OUT") {
        use std::io::Write;
        if let Ok(mut file) = std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(out)
        {
            let _ = writeln!(file, "{text}");
        }
    }
    exit.write(AppExit::Success);
}