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
//! The other end of the handshake: greeting a host until it answers, and
//! taking up the invitation when it does.
//!
//! A joiner talks to nobody but the host, which is why the table and every
//! line said at it have to be relayed rather than overheard.

use super::*;
use crate::app::i18n::fill;

/// What pressing Enter, or having just given a name, comes to.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Pick {
    /// No beach was asked for, or the one asked for is not there.
    Nothing,
    /// Ask who is sitting here before seating them.
    AskName(usize),
    /// Take this one.
    Take(usize),
    /// It is full, this round and the next; say so rather than dialling.
    Full(usize),
    /// The beach a name was just given for went off the air while it was
    /// typed: say so, rather than an Enter that does nothing.
    Gone,
}

/// What a frame knows about joining.
///
/// A struct rather than three bools in a row behind two `Option<usize>`s,
/// for the reason its sibling [`HostAsk`] is one: any two of them could be
/// swapped without the compiler noticing.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) struct JoinAsk {
    /// Enter, on the row under the cursor.
    pub enter_on: Option<usize>,
    /// What the player was doing when they were stopped and asked their
    /// name, if they were.
    pub intent: Option<Intent>,
    /// The unattended dev hook, which takes the first beach it hears and
    /// must never be stopped to answer a question nobody is there for.
    pub auto_join: bool,
    /// Already at a beach, one's own or somebody else's.
    pub busy: bool,
    /// There is a name on file, so there is nothing to ask.
    pub named: bool,
    /// The player armed W: they mean to watch, which needs no chair, so a
    /// full beach is still theirs to take.
    pub to_watch: bool,
}
/// Which beach a frame is asking for, and what has to happen before it can
/// be had.
///
/// Split from the socket work so the decision can be tested: which row a
/// key means, that a name is asked for first, that a beach already left
/// behind is not dialled again, and that a full one is refused.
pub(super) fn which_beach(ask: JoinAsk, hosts: &[HostEntry]) -> Pick {
    let JoinAsk {
        enter_on,
        intent,
        auto_join,
        busy,
        named,
        to_watch,
    } = ask;
    let asked = match (intent, auto_join) {
        // A name was just given on the way here; the beach it was given for
        // is the one to take, if it is still on the air.
        (Some(Intent::Join(addr)), _) => match hosts.iter().position(|host| host.addr == addr) {
            Some(at) => Some(at),
            None => return Pick::Gone,
        },
        // Neither of these is a row of this list: hosting is not joining,
        // and a dialled address was typed because it is on no row.
        // Both are dealt with by the caller.
        (Some(Intent::Host | Intent::Dial(_) | Intent::JoinBot(_)), _) => return Pick::Nothing,
        (None, true) => Some(0),
        (None, false) => enter_on,
    };
    let Some(at) = asked.filter(|at| *at < hosts.len()) else {
        return Pick::Nothing;
    };
    if busy {
        return Pick::Nothing;
    }
    // A watcher takes no seat, so a full beach is no obstacle: the host
    // answers a `Watch` with a spectator's place however many are aboard.
    // A full socket is full for a watcher too: it has no room for one
    // more peer of any kind.
    if hosts[at].full || (!to_watch && !hosts[at].has_room()) {
        return Pick::Full(at);
    }
    // The dev hooks join unattended; a prompt nobody is there to answer
    // would hang them.
    match named || auto_join || intent.is_some() {
        true => Pick::Take(at),
        false => Pick::AskName(at),
    }
}
/// Take a beach: by Enter on the cursor, or because a name was just given
/// on the way to one. A number key only moves the cursor
/// ([`walk_the_list`]): the list is sorted by name, so a beach that comes
/// on the air above the one meant moves every number, and "press 3" said
/// across a room would join whichever beach had slid into row three.
pub(super) fn take_a_beach(
    keys: &ButtonInput<KeyCode>,
    settings: &GameSettings,
    state: &mut LobbyState,
    tr: &'static crate::app::i18n::Tr,
    intent: Option<Intent>,
    auto_join: bool,
) {
    // A dialled address skips the list entirely: there is no row to pick,
    // no beacon to have heard, and nothing about it to be full or in
    // progress, because none of that is known until the host answers.
    if let Some(Intent::Dial(addr)) = intent {
        if !state.standing().at_a_beach() {
            dial_at(state, settings, tr, addr);
        }
        return;
    }
    let enter_on = crate::app::menu_ui::enter(keys)
        .then(|| state.selected_index())
        .flatten();
    let pick = which_beach(
        JoinAsk {
            enter_on,
            intent,
            auto_join,
            busy: state.standing().at_a_beach(),
            named: !settings.names[0].trim().is_empty(),
            to_watch: state.watching(),
        },
        &state.hosts,
    );
    if auto_join && matches!(pick, Pick::Take(_)) {
        state.auto_done = true;
    }
    match pick {
        Pick::Nothing => {}
        // The table shows everyone by name, so a player with none is asked
        // for one before they are seated. See `player_name`.
        Pick::AskName(at) => {
            state.typing = Some(
                Typing::player_name(Intent::Join(state.hosts[at].addr), &settings.names[0])
                    .or_suggest(|| suggested_name(tr)),
            );
        }
        // A full beach is worth neither joining nor queueing for: there is
        // no chair for this player at the end of it either way.
        Pick::Full(_) => state.feedback = tr.lobby_beach_full.to_string(),
        Pick::Gone => state.feedback = tr.lobby_host_closed.to_string(),
        Pick::Take(at) => dial(state, settings, tr, at),
    }
}
/// Open a socket to the beach on row `at` and greet it.
fn dial(
    state: &mut LobbyState,
    settings: &GameSettings,
    tr: &'static crate::app::i18n::Tr,
    at: usize,
) {
    debug_assert!(
        at < state.hosts.len(),
        "dialling row {at} of {}",
        state.hosts.len()
    );
    let Some(host) = state.hosts.get(at) else {
        return;
    };
    let (addr, id) = (host.addr, host.id);
    dial_at(state, settings, tr, addr);
    if let Some(joined) = state.joined_mut() {
        joined.beach = (id != 0).then_some(id);
    }
}
/// How long a joiner keeps calling a beach that never answers.
///
/// The host answers a greeting the moment it hears one, and sends the
/// table round every [`ANNOUNCE_EVERY`] besides, so silence this long is
/// not a lost packet: there is nothing at that address. Longer than
/// [`HOST_TTL`], so on the first lobby port a beach that is merely being
/// slow about its beacon is never given up on before the list would give
/// up on it.
///
/// Not longer than [`host_ttl`] up the port ladder, and not meant to be:
/// the list waits that long there because beacons reach it one in four,
/// while a beach answers its joiner directly at the full rate whatever
/// port the joiner listens on. So a joiner up the ladder can give up on a
/// beach the list still shows, and [`answer_the_silence`] takes that row
/// off the list as it does, rather than leave a crashed beach there for
/// another fourteen seconds to be picked again.
pub(super) const NO_ANSWER_AFTER: f32 = 6.0;
const _: () = {
    assert!(NO_ANSWER_AFTER > HOST_TTL);
    assert!(NO_ANSWER_AFTER > 3.0 * ANNOUNCE_EVERY);
};
/// Whether anybody is actually there, said out loud.
///
/// A dialled socket is not a connection, since UDP tells nobody anything,
/// so until the host says a word this peer is *calling*, not aboard. Said
/// "aboard" from the first frame, a mistyped address, a firewall and a host
/// who quit thirty seconds ago all look like a beach that is slow to
/// start.
///
/// Returns whether the call was given up on, in which case the frame is
/// over: there is no socket left to read.
fn answer_the_silence(
    state: &mut LobbyState,
    tr: &'static crate::app::i18n::Tr,
    heard: bool,
    delta: f32,
) -> bool {
    let Some(joined) = state.joined_mut() else {
        return false;
    };
    if heard {
        // The first word is the moment this becomes a beach rather than an
        // address. Later ones only keep the patience topped up.
        let first_word = !joined.host_answered;
        joined.host_answered = true;
        joined.host_silence = 0.0;
        let watching = joined.watching;
        if first_word {
            state.feedback = match watching {
                true => tr.lobby_watching.to_string(),
                false => tr.lobby_aboard.to_string(),
            };
        }
        return false;
    }
    joined.host_silence += delta;
    if joined.host_silence < NO_ANSWER_AFTER {
        return false;
    }
    // Let the socket go rather than call forever. The address is still in
    // settings, so J offers it back pre-filled and a typo costs one
    // character rather than the whole line.
    let called = joined
        .transport
        .peer_addr()
        .map(|addr| addr.to_string())
        .unwrap_or_default();
    // A beach that said all along it had no room is there, and full: so
    // said, and its row stays on the list, which says full too.
    if joined.told_full {
        state.let_go();
        state.feedback = tr.lobby_no_room.to_string();
        return true;
    }
    // And its row goes from the list with it: nothing answers there, and
    // up the port ladder the list would otherwise keep it long after this
    // (see `NO_ANSWER_AFTER`). A beach whose beacon is still on the air is
    // listed again by the next one.
    if let Some(gone) = state.joined_entry().map(|host| host.addr) {
        state.hosts.retain(|host| host.addr != gone);
    }
    state.let_go();
    state.feedback = fill(tr.lobby_no_answer, &[("a", &called)]);
    true
}
/// Open a socket to a beach at a known address and greet it.
///
/// The bottom of both roads in: the row picked off the list, and the
/// address typed out by hand. A beach that was never heard from is joined
/// by the same greeting as one that was: the beacon is how a beach is
/// *found*, never how it is entered.
pub(super) fn dial_at(
    state: &mut LobbyState,
    settings: &GameSettings,
    tr: &'static crate::app::i18n::Tr,
    addr: SocketAddr,
) {
    dial_as(state, settings, tr, addr, None);
}

/// Dial `addr`, as a bot's seat named `bot` if there is one (route 1).
pub(super) fn dial_as(
    state: &mut LobbyState,
    settings: &GameSettings,
    tr: &'static crate::app::i18n::Tr,
    addr: SocketAddr,
    bot: Option<String>,
) {
    match UdpTransport::join(addr) {
        Ok(transport) => {
            let watching = state.watching() && bot.is_none();
            let mut joined = Joined::dialled(transport, watching);
            joined.bot = bot;
            joined.transport.send(joined.greeting(&settings.names[0]));
            // Calling, not aboard: nothing has answered yet, and on UDP
            // an open socket is no evidence that anything will.
            state.standing = Standing::Joining(joined);
            state.feedback = fill(tr.lobby_calling, &[("a", &addr.to_string())]);
        }
        Err(e) => state.feedback = fill(tr.lobby_could_not_join, &[("e", &e.to_string())]),
    }
}
/// Whether a `Start` off the wire invites this lobby to a new round,
/// rather than repeating the one it just walked out of.
///
/// A host still sitting on its results card re-answers every greeting
/// with the finished round's `Start`, which is right for a latecomer who
/// missed the launch and wrong for a table that came back here from that
/// very round. The seed is what tells them apart: a fresh round is struck
/// on a fresh seed. Not the deal number a round in progress goes by
/// (`OnlineSession::is_next_round`): the seed is all a joiner carries back to
/// the lobby from the round it left (`Joined::played_seed`), and all the
/// question needs, which is "the round just played, or not", not which of
/// two rounds came later.
pub(super) fn a_fresh_invitation(played: Option<u64>, invitation: &Invitation) -> bool {
    played != Some(invitation.terms.seed)
}

/// Take up the host's invitation: build the session it describes, arm a
/// series if it is one, and walk into the arena.
pub(super) fn accept_the_invitation(
    state: &mut LobbyState,
    online: &mut Online,
    tournament: &mut crate::app::tournament::Tournament,
    next_screen: &mut NextState<Screen>,
    next_vphase: &mut NextState<VersusPhase>,
    started: Option<Invitation>,
) {
    if let Some(invitation) = started {
        let terms = (invitation.terms, invitation.standing);
        walk_into_the_arena(
            state,
            online,
            tournament,
            (next_screen, next_vphase),
            terms,
            |transport| OnlineSession::invited(transport, invitation),
        );
    }
}

/// Walk into a round already running, as a watcher: the session starts
/// at the frame the host's board was taken at, and the arena from that
/// board (`net::catch_up`).
fn catch_up_with_the_round(
    state: &mut LobbyState,
    online: &mut Online,
    tournament: &mut crate::app::tournament::Tournament,
    next_screen: &mut NextState<Screen>,
    next_vphase: &mut NextState<VersusPhase>,
    caught: crate::app::net::catch_up::CaughtUp,
) {
    let terms = (caught.invitation.terms, caught.invitation.standing);
    walk_into_the_arena(
        state,
        online,
        tournament,
        (next_screen, next_vphase),
        terms,
        |transport| OnlineSession::caught_up(transport, caught),
    );
}

/// The one way from a greeted beach into the arena, whichever session the
/// host's answer builds: the socket handed over, the series armed from the
/// host's terms, and the session marked as having a lobby to walk back to.
fn walk_into_the_arena(
    state: &mut LobbyState,
    online: &mut Online,
    tournament: &mut crate::app::tournament::Tournament,
    (next_screen, next_vphase): (&mut NextState<Screen>, &mut NextState<VersusPhase>),
    (terms, standing): (MatchTerms, Option<crate::transport::SeriesStanding>),
    session: impl FnOnce(UdpTransport) -> OnlineSession,
) {
    let Standing::Joining(joined) = std::mem::take(&mut state.standing) else {
        unreachable!("an invitation for a lobby that greeted nobody");
    };
    // The host's invitation says whether this is a series, and where it
    // stands: a joiner that assumed otherwise would stop after one round,
    // and one admitted mid-series would start its own tally.
    *tournament = crate::app::tournament::Tournament::from_terms(terms, standing);
    let mut session = session(joined.transport);
    // Formed here, so a finished match knows it has a lobby to walk this
    // table back to.
    session.home.from_lobby = true;
    session.home.wants_to_watch = joined.watching;
    session.bot = joined.bot;
    online.0 = Some(session);
    next_vphase.set(VersusPhase::Countdown);
    next_screen.set(Screen::Versus);
}
/// Joining: keep greeting the host until our seat assignment arrives
/// (Start rides UDP; hellos are re-answered until one lands).
/// What to tell a peer the host has just put in line.
///
/// The host answers everyone who turns up mid-round with the same
/// `Queued`, because a place in line is all it is deciding. Which *kind*
/// of peer is waiting is this machine's own business, and only this
/// machine knows it: somebody who armed W is not in line for a chair at
/// all, and was being told it was next up for one it never asked for.
///
/// Its own function because the rule is the part worth being able to
/// test, the path to it being a socket and a running round.
///
/// While the host picks the next round's map (`picking`, the roster sent
/// with the `Queued`), that round is dealt the table the map is picked
/// for, and a player in line watches it and plays the one after
/// (`PeerBook::frozen_plan`), wherever it stands.
fn queued_feedback(
    tr: &'static crate::app::i18n::Tr,
    watching: bool,
    picking: bool,
    ahead: u8,
) -> String {
    match (watching, picking, ahead) {
        // No place to report: the crowd has room whoever is playing, so
        // there is nobody ahead of them and nothing to be next after.
        (true, _, _) => tr.lobby_queued_watching.to_string(),
        (false, true, _) => tr.lobby_queued_picking.to_string(),
        (false, false, 0) => tr.lobby_queued_next.to_string(),
        (false, false, n) => fill(tr.lobby_queued_behind, &[("n", &n.to_string())]),
    }
}

/// Keep a joiner's line in step with the host's lobby roster, which says
/// whether the table has a chair for it: past the last one, the line says
/// the launch seats it watching, as the launch does
/// (`PeerBook::seat_plan`); once one frees, it is aboard again. Only on a
/// change, so the line is not rewritten under a player reading it.
///
/// A lobby roster is also the host's word that it is gathering again, so
/// a peer that was in line for the next round is in line no longer: the
/// queue was told on the way in and nothing else takes it back, and a
/// beach dialled by its address has no row on the list to say so. Said
/// with the chair in the same breath, so a player past the last chair is
/// not told it is aboard first.
fn tell_of_a_chair(
    state: &mut LobbyState,
    tr: &'static crate::app::i18n::Tr,
    no_chair: bool,
    picking: bool,
) {
    let Some(joined) = state.joined_mut() else {
        return;
    };
    let was_queued = joined.queued.take().is_some();
    // A watcher wants no chair.
    let no_chair = no_chair && !joined.watching;
    if joined.no_chair == no_chair && joined.host_picking == picking && !was_queued {
        return;
    }
    joined.no_chair = no_chair;
    joined.host_picking = picking;
    state.feedback = joiners_line(tr, joined).to_string();
}

/// A joiner's status line at the lobby card, by what the host last said:
/// the host picking the map, which the whole room waits on, else where
/// this peer will be when the match starts.
pub(super) fn joiners_line(tr: &'static crate::app::i18n::Tr, joined: &Joined) -> &'static str {
    match (joined.host_picking, joined.watching, joined.no_chair) {
        (true, _, _) => tr.lobby_host_picking,
        (false, true, _) => tr.lobby_watching,
        (false, false, true) => tr.lobby_no_chair,
        (false, false, false) => tr.lobby_aboard,
    }
}

/// Everything the host said to a joiner in one frame, gathered off the
/// socket before any of it is acted on.
#[derive(Default)]
struct HostWord {
    /// Anything at all, even a message this screen throws away, is proof
    /// somebody is there, which is the one thing a joiner cannot otherwise
    /// tell.
    heard: bool,
    started: Option<Invitation>,
    caught: Option<crate::app::net::catch_up::CaughtUp>,
    /// Why the host put this joiner out, the most telling reason when
    /// several arrived together (`PutOut::hear`).
    put_out: Option<PutOut>,
    queued: Option<u8>,
    /// The lobby roster's word on a chair: `Some(true)` for a would-be
    /// player the table has none for, `Some(false)` for one it has, `None`
    /// for a frame with no lobby roster.
    no_chair: Option<bool>,
    /// The lobby roster's word on the host picking the map.
    picking: bool,
    said: Vec<(crate::transport::WireName, crate::transport::WireChat)>,
    table: Option<(Vec<String>, [u8; MAX_PLAYERS])>,
    host_terms: Option<MatchTerms>,
}

/// Greet the host if it is time, and gather what it said this frame,
/// `delta` seconds after the last.
fn hear_the_host(joined: &mut Joined, hello: Option<NetMsg>, delta: f32) -> HostWord {
    let mut word = HostWord::default();
    joined.host_goodbye.age(delta);
    let transport = &mut joined.transport;
    if let Some(hello) = hello {
        transport.send(hello);
    }
    for (msg, _) in transport.recv_all() {
        // A goodbye starts its clock at the first copy, and any other word
        // from the host takes it back (`Joined::host_goodbye`). A beach
        // with no room is not one this peer is at, so its word is no
        // answer either (`Joined::told_full`).
        if msg == NetMsg::Leaving {
            joined.host_goodbye.spoke();
        } else if msg == NetMsg::Full {
            joined.told_full = true;
        } else {
            word.heard = true;
            joined.told_full = false;
            joined.host_goodbye.heard();
        }
        match msg {
            NetMsg::Start(start) => word.started = Some(start),
            NetMsg::Kicked => PutOut::hear(&mut word.put_out, PutOut::Kicked),
            NetMsg::NoBots => PutOut::hear(&mut word.put_out, PutOut::NoBots),
            NetMsg::Leaving | NetMsg::Full => {}
            // A watcher at a beach mid-round: the round as it stands,
            // part by part, until there is a whole of it to walk into.
            //
            // Taken whatever this lobby asked for. The host sends it
            // only to a peer it has placed watching this round: one
            // that greeted to watch, and also a player it dealt a
            // watching place for want of a chair whose `Start` was
            // lost (`lost_its_start`). Refused here, that player
            // walked in at frame zero, a second behind the tails.
            NetMsg::CatchUp {
                frame,
                part,
                parts,
                bytes,
            } => {
                if let Some(whole) = joined.catching_up.take(frame, part, parts, bytes) {
                    word.caught = Some(whole);
                }
            }
            NetMsg::Incompatible { version } => {
                PutOut::hear(&mut word.put_out, PutOut::Mismatch(version));
            }
            // That beach is mid-round. The greeting already repeats
            // every second, so it doubles as the place in line: stay
            // here, keep saying hello, and the host answers with a
            // `Start` when the next round comes round.
            NetMsg::Queued { ahead } => word.queued = Some(ahead),
            NetMsg::Chat { name, text } => word.said.push((name, text)),
            // Round things, and this screen has no round.
            NetMsg::SpectatorVote { .. }
            | NetMsg::SpectatorTally { .. }
            | NetMsg::SpectatorPick { .. }
            | NetMsg::CrowdPicks { .. } => {}
            // Who else is here. A joiner has spoken to nobody but the
            // host and would otherwise sit at an apparently empty beach.
            NetMsg::Roster {
                seats,
                names,
                terms,
                kinds,
                // The host's handmade beach: the lobby card does not name
                // the map, which is picked at the start.
                beach: _,
                chair,
                picking,
            } => {
                // The table is as many places as the host says, in seat
                // order. Not the leading run of named ones: a nameless
                // player, or a bot, mid-round, is a place all the same,
                // and stopping at it cut the table short and dealt the
                // terms card the wrong teams.
                word.table = Some((
                    crate::transport::table_from_wire(&names)
                        .into_iter()
                        .take(usize::from(seats))
                        .collect(),
                    kinds,
                ));
                word.host_terms = Some(terms);
                word.no_chair = chair.map(|chair| !chair);
                word.picking = picking;
            }
            // The host timing the round trip: answered at once, since the
            // wait is what it measures.
            NetMsg::Ping { stamp } => transport.send(NetMsg::Pong { stamp }),
            NetMsg::Hello { .. }
            | NetMsg::Watch { .. }
            | NetMsg::Pong { .. }
            | NetMsg::Clock { .. }
            | NetMsg::Inputs(_)
            | NetMsg::Hash { .. }
            | NetMsg::Pause { .. }
            | NetMsg::Abandoned { .. }
            | NetMsg::Resume { .. } => {}
        }
    }
    if joined.host_goodbye.stood(LOBBY_GOODBYE_GRACE) {
        PutOut::hear(&mut word.put_out, PutOut::Closed);
    }
    word
}

/// How long a host's goodbye has to stand, with no word from the host
/// since, before a lobby joiner believes it. Longer than the host's
/// once-a-second roster, which is what takes a forged one back: the lobby
/// host answers no single greeting. A host that really leaves the lobby
/// says so on the beacon too (`host_closed`), which is still taken at once.
pub(super) const LOBBY_GOODBYE_GRACE: f32 = 1.5;

pub fn join_tick(
    time: Res<Time>,
    settings: Res<GameSettings>,
    mut state: ResMut<LobbyState>,
    mut online: ResMut<Online>,
    mut tournament: ResMut<crate::app::tournament::Tournament>,
    mut next_screen: ResMut<NextState<Screen>>,
    mut next_vphase: ResMut<NextState<VersusPhase>>,
) {
    let Some(joined) = state.joined_mut() else {
        return;
    };
    let say_hello = once_a_second(&mut joined.hello_in, time.delta_secs());
    let hello = say_hello.then(|| joined.greeting(&settings.names[0]));
    let word = hear_the_host(joined, hello, time.delta_secs());
    if answer_the_silence(&mut state, settings.tr(), word.heard, time.delta_secs()) {
        return;
    }
    if let Some(why) = word.put_out {
        state.let_go();
        state.feedback = why.feedback(settings.tr());
        return;
    }
    let HostWord {
        heard: _,
        started,
        caught,
        put_out: _,
        queued,
        no_chair,
        picking,
        said,
        table,
        host_terms,
    } = word;
    if let Some((table, kinds)) = table {
        state.table_kinds = kinds
            .iter()
            .take(table.len())
            .map(|&byte| crate::transport::kind_from_byte(byte))
            .collect();
        // The seats the host dealt the places, from the terms beside them.
        if let Some(terms) = &host_terms {
            state.seated = Seated::from_terms(terms, table.len() as u8);
        }
        // A place the host sent no name for reads as its seat, as on
        // every other screen.
        let seated = state.seated;
        state.table = (0..table.len())
            .map(|place| {
                let name = table.get(place).map_or("", String::as_str);
                match name.is_empty() {
                    true => crate::app::seat_label(settings.tr(), seated.seat_of(place)),
                    false => name.to_string(),
                }
            })
            .collect();
    }
    if let Some(terms) = host_terms
        && let Some(joined) = state.joined_mut()
    {
        joined.host_terms = Some(HostTerms { terms });
    }
    for (name, text) in said {
        state.hear(&name, &text);
    }
    if let Some(no_chair) = no_chair {
        tell_of_a_chair(&mut state, settings.tr(), no_chair, picking);
    }
    if let Some(ahead) = queued {
        let watching = state.joined().is_some_and(|joined| joined.watching);
        state.feedback = queued_feedback(settings.tr(), watching, picking, ahead);
        // Kept as well as said, so the prompt under that line can agree
        // with it. A queued peer is not waiting for the host to launch:
        // the host launched without it.
        if let Some(joined) = state.joined_mut() {
            joined.queued = Some(ahead);
        }
    }
    if let Some(caught) = caught {
        catch_up_with_the_round(
            &mut state,
            &mut online,
            &mut tournament,
            &mut next_screen,
            &mut next_vphase,
            caught,
        );
        return;
    }
    let played = state.joined().and_then(Joined::played_seed);
    let started = started.filter(|invitation| a_fresh_invitation(played, invitation));
    accept_the_invitation(
        &mut state,
        &mut online,
        &mut tournament,
        &mut next_screen,
        &mut next_vphase,
        started,
    );
}

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

    /// A bot turned away for being a bot is told so, not that the host
    /// asked it to leave: the host never singled it out. A plain kick in
    /// the same frame is the same going, with less said.
    #[test]
    fn a_bot_turned_away_hears_the_beach_is_not_taking_bots() {
        let tr = &crate::app::i18n::EN;
        for order in [
            [PutOut::NoBots, PutOut::Kicked],
            [PutOut::Kicked, PutOut::NoBots],
        ] {
            let mut put_out = None;
            for why in order {
                PutOut::hear(&mut put_out, why);
            }
            assert_eq!(put_out, Some(PutOut::NoBots));
        }
        assert_eq!(PutOut::NoBots.feedback(tr), tr.lobby_no_bots);
        let mut put_out = None;
        PutOut::hear(&mut put_out, PutOut::Kicked);
        assert_eq!(
            put_out.map(|why| why.feedback(tr)),
            Some(tr.lobby_kicked_you.to_string())
        );
    }

    /// The roster's table is as many places as it says, a nameless one
    /// among them: a seat mid-round whose player greeted with no name, or
    /// a bot's. Read as the leading run of named places, a table of three
    /// was one, and the terms card dealt the wrong teams from it.
    #[test]
    fn a_nameless_place_does_not_cut_the_roster_short() {
        let mut host = UdpTransport::host(0).expect("bind");
        let port = host.local_addr().expect("addr").port();
        let mut joined = Joined::dialled(
            UdpTransport::join(("127.0.0.1", port)).expect("join"),
            false,
        );
        let settle = || std::thread::sleep(std::time::Duration::from_millis(20));
        joined.transport.send(NetMsg::hello("Cy"));
        settle();
        let _ = host.recv_all();
        let table = ["Anna".to_string(), String::new(), "Cy".to_string()];
        host.send(NetMsg::Roster {
            seats: 3,
            names: crate::transport::wire_table(&table),
            terms: MatchTerms::default(),
            kinds: [0; MAX_PLAYERS],
            beach: [0; crate::transport::WIRE_NAME],
            chair: Some(true),
            picking: false,
        });
        settle();
        let word = hear_the_host(&mut joined, None, 0.0);
        let (heard, _) = word.table.expect("a table");
        assert_eq!(heard, table, "three places, the nameless one included");
    }

    /// The host's word on a chair rides in each lobby roster: one saying
    /// there is none leaves this player without a chair, and the next one
    /// saying there is says a chair came free. A round's roster, sent to a
    /// peer in line, says nothing about chairs; the lobby's ends the line,
    /// and says the chair with it, so a player in line past the last chair
    /// is never told it is aboard, whether or not its beach is on the list.
    #[test]
    fn a_roster_says_whether_there_is_a_chair_and_ends_the_line() {
        let mut host = UdpTransport::host(0).expect("bind");
        let port = host.local_addr().expect("addr").port();
        let mut state = LobbyState {
            standing: Standing::Joining(Joined::dialled(
                UdpTransport::join(("127.0.0.1", port)).expect("join"),
                false,
            )),
            ..LobbyState::default()
        };
        let settle = || std::thread::sleep(std::time::Duration::from_millis(20));
        state
            .joined()
            .expect("joined")
            .transport
            .send(NetMsg::hello("Gil"));
        settle();
        let _ = host.recv_all();
        let roster = |chair| NetMsg::Roster {
            seats: 6,
            names: crate::transport::wire_table(&[]),
            terms: MatchTerms::default(),
            kinds: [0; MAX_PLAYERS],
            beach: [0; crate::transport::WIRE_NAME],
            chair,
            picking: false,
        };
        let tr = &crate::app::i18n::EN;
        let hear = |state: &mut LobbyState| {
            settle();
            let joined = state.joined_mut().expect("joined");
            let word = hear_the_host(joined, None, 0.0);
            if let Some(no_chair) = word.no_chair {
                tell_of_a_chair(state, tr, no_chair, word.picking);
            }
        };
        host.send(roster(Some(false)));
        hear(&mut state);
        assert!(state.joined().is_some_and(|joined| joined.no_chair));
        assert_eq!(state.feedback, tr.lobby_no_chair);
        host.send(roster(Some(true)));
        hear(&mut state);
        assert!(state.joined().is_some_and(|joined| !joined.no_chair));
        assert_eq!(state.feedback, tr.lobby_aboard, "a chair came free");

        // In line while a round plays: the round's roster leaves it there.
        if let Some(joined) = state.joined_mut() {
            joined.queued = Some(0);
        }
        state.feedback = "next up".into();
        host.send(roster(None));
        hear(&mut state);
        assert_eq!(state.joined().and_then(|joined| joined.queued), Some(0));
        assert_eq!(state.feedback, "next up");
        // Back in the lobby, past the last chair: out of line, no chair.
        host.send(roster(Some(false)));
        hear(&mut state);
        assert_eq!(state.joined().and_then(|joined| joined.queued), None);
        assert_eq!(state.feedback, tr.lobby_no_chair, "never told aboard");
    }

    /// A beach with no room says so to every greeting, and a peer it has
    /// forgotten frees a place a moment later: the joiner keeps calling,
    /// and is aboard on the first other word. Only a beach that says
    /// nothing else for the whole patience is given up on, as full.
    #[test]
    fn a_full_beach_is_called_until_a_place_frees() {
        let mut host = UdpTransport::host(0).expect("bind");
        let port = host.local_addr().expect("addr").port();
        let mut state = LobbyState {
            standing: Standing::Joining(Joined::dialled(
                UdpTransport::join(("127.0.0.1", port)).expect("join"),
                false,
            )),
            ..LobbyState::default()
        };
        let settle = || std::thread::sleep(std::time::Duration::from_millis(20));
        let tr = &crate::app::i18n::EN;
        state
            .joined()
            .expect("joined")
            .transport
            .send(NetMsg::hello("Gil"));
        settle();
        let _ = host.recv_all();
        let tick = |state: &mut LobbyState, delta: f32| {
            settle();
            let joined = state.joined_mut().expect("joined");
            let word = hear_the_host(joined, None, delta);
            assert_eq!(word.put_out, None, "Full is not the end of the call");
            answer_the_silence(state, tr, word.heard, delta)
        };
        host.send(NetMsg::Full);
        assert!(!tick(&mut state, 1.0), "still calling");
        host.send(NetMsg::Full);
        assert!(!tick(&mut state, NO_ANSWER_AFTER - 2.0), "still calling");
        // A place freed, and the host answered as it answers anybody.
        host.send(NetMsg::chat("", "hi"));
        assert!(!tick(&mut state, 0.5));
        assert_eq!(state.feedback, tr.lobby_aboard);

        // Another beach that never has room: full, said so, at the end of
        // the patience and not before.
        let mut state = LobbyState {
            standing: Standing::Joining(Joined::dialled(
                UdpTransport::join(("127.0.0.1", port)).expect("join"),
                false,
            )),
            ..LobbyState::default()
        };
        state
            .joined()
            .expect("joined")
            .transport
            .send(NetMsg::hello("Hal"));
        settle();
        let _ = host.recv_all();
        host.send(NetMsg::Full);
        assert!(!tick(&mut state, NO_ANSWER_AFTER - 1.0));
        host.send(NetMsg::Full);
        assert!(tick(&mut state, 1.0), "given up on");
        assert_eq!(state.feedback, tr.lobby_no_room);
    }

    /// A host's goodbye in the lobby is believed only once it has stood
    /// for the grace with nothing else from the host. Believed on arrival,
    /// one forged datagram emptied the whole table; the host's own roster,
    /// once a second, takes a forged one back.
    #[test]
    fn a_lobby_joiner_believes_a_goodbye_only_after_the_grace() {
        let host = UdpTransport::host(0).expect("bind");
        let port = host.local_addr().expect("addr").port();
        let mut joined = Joined::dialled(
            UdpTransport::join(("127.0.0.1", port)).expect("join"),
            false,
        );
        let mut host = host;
        let settle = || std::thread::sleep(std::time::Duration::from_millis(20));
        joined.transport.send(NetMsg::hello("Cy"));
        settle();
        assert_eq!(host.recv_all().len(), 1, "greeted");

        // Forged, and then the host's roster comes round.
        host.send(NetMsg::Leaving);
        settle();
        assert!(hear_the_host(&mut joined, None, 0.0).put_out.is_none());
        assert!(
            hear_the_host(&mut joined, None, 1.0).put_out.is_none(),
            "inside the grace"
        );
        host.send(NetMsg::chat("Sam", "hi"));
        settle();
        assert!(hear_the_host(&mut joined, None, 0.1).put_out.is_none());
        assert!(
            hear_the_host(&mut joined, None, LOBBY_GOODBYE_GRACE)
                .put_out
                .is_none(),
            "taken back by the host's next word"
        );

        // Real: nothing after it.
        host.send(NetMsg::Leaving);
        settle();
        assert!(hear_the_host(&mut joined, None, 0.0).put_out.is_none());
        assert_eq!(
            hear_the_host(&mut joined, None, LOBBY_GOODBYE_GRACE).put_out,
            Some(PutOut::Closed),
            "and then the beach has closed"
        );
    }

    /// A frame in which somebody with a name on file is choosing a beach and
    /// nothing else is going on, so the tests below say only what they
    /// change.
    fn asking() -> JoinAsk {
        JoinAsk {
            enter_on: None,
            intent: None,
            auto_join: false,
            busy: false,
            named: true,
            to_watch: false,
        }
    }

    fn beaches(spec: &[(u8, u8)]) -> Vec<HostEntry> {
        spec.iter()
            .enumerate()
            .map(|(i, (taken, seats))| HostEntry {
                addr: format!("10.0.0.{}:47777", i + 1).parse().expect("addr"),
                id: i as u64 + 1,
                name: format!("beach{i}"),
                host: "Sam".to_string(),
                taken: *taken,
                seats: *seats,
                running: false,
                bots: false,
                full: false,
                age: 0.0,
                heard: 1,
            })
            .collect()
    }

    /// Three beaches, room at all of them, and a player who has a name.
    fn open() -> Vec<HostEntry> {
        beaches(&[(1, 6), (1, 6), (1, 6)])
    }

    /// A beach dialled by hand is greeted exactly as one picked off the
    /// list: the beacon is how a beach is found, never how it is entered.
    /// Over a real socket, since the point is that no broadcast was
    /// involved in getting here.
    #[test]
    fn a_dialled_beach_hears_the_same_greeting_as_a_listed_one() {
        let mut host = UdpTransport::host(0).expect("bind");
        let port = host.local_addr().expect("addr").port();
        let mut state = LobbyState::default();
        let mut settings = GameSettings::default();
        settings.names[0] = "Cy".into();
        let there: SocketAddr = format!("127.0.0.1:{port}").parse().expect("addr");
        dial_at(&mut state, &settings, &crate::app::i18n::EN, there);
        assert!(state.joined().is_some(), "the socket is open and greeting");
        let mut greeted = None;
        for _ in 0..40 {
            std::thread::sleep(std::time::Duration::from_millis(5));
            for (msg, _) in host.recv_all() {
                if let NetMsg::Hello { name, .. } = msg {
                    greeted = Some(crate::transport::name_from_wire(&name));
                }
            }
            if greeted.is_some() {
                break;
            }
        }
        assert_eq!(greeted.as_deref(), Some("Cy"), "the host heard who called");
    }

    /// Which row Enter means: whatever the cursor is on, never past the
    /// list.
    #[test]
    fn a_key_picks_the_row_it_names_and_no_further() {
        let hosts = open();
        assert_eq!(
            which_beach(
                JoinAsk {
                    enter_on: Some(1),
                    ..asking()
                },
                &hosts
            ),
            Pick::Take(1)
        );
        assert_eq!(
            which_beach(
                JoinAsk {
                    enter_on: Some(6),
                    ..asking()
                },
                &hosts
            ),
            Pick::Nothing,
            "a cursor past the end of the list"
        );
        assert_eq!(
            which_beach(
                JoinAsk {
                    enter_on: Some(2),
                    ..asking()
                },
                &hosts
            ),
            Pick::Take(2),
            "Enter takes the cursor's beach"
        );
        assert_eq!(
            which_beach(asking(), &hosts),
            Pick::Nothing,
            "and nothing pressed takes nothing"
        );
        assert_eq!(
            which_beach(
                JoinAsk {
                    enter_on: Some(0),
                    ..asking()
                },
                &[]
            ),
            Pick::Nothing,
            "nor does a key with an empty hall"
        );
    }

    /// Nobody is seated before they have said who they are, and the beach
    /// they were stopped on the way to is the one they get afterwards.
    #[test]
    fn a_nameless_player_is_asked_first_and_lands_where_they_meant() {
        let hosts = open();
        assert_eq!(
            which_beach(
                JoinAsk {
                    enter_on: Some(2),
                    named: false,
                    ..asking()
                },
                &hosts
            ),
            Pick::AskName(2),
            "asked, and asked about the right beach"
        );
        // Answering comes back as an intent naming that same beach.
        assert_eq!(
            which_beach(
                JoinAsk {
                    intent: Some(Intent::Join(hosts[2].addr)),
                    ..asking()
                },
                &hosts
            ),
            Pick::Take(2)
        );
        // A beach that came on the air above it while the name was typed
        // moves it down the list, and the one meant is still the one taken.
        let mut resorted = open();
        resorted.reverse();
        assert_eq!(
            which_beach(
                JoinAsk {
                    intent: Some(Intent::Join(hosts[2].addr)),
                    ..asking()
                },
                &resorted
            ),
            Pick::Take(0)
        );
        // A beach that went away while the name was being typed is not
        // dialled, and nothing else is dialled in its place.
        assert_eq!(
            which_beach(
                JoinAsk {
                    intent: Some(Intent::Join(hosts[2].addr)),
                    ..asking()
                },
                &hosts[..1]
            ),
            Pick::Gone
        );
    }

    /// A full beach is refused outright: there is no chair at the end of
    /// it, this round or the next, so queueing would be a lie.
    #[test]
    fn a_full_beach_is_refused_rather_than_queued_for() {
        let hosts = beaches(&[(6, 6), (5, 6), (0, 0)]);
        assert_eq!(
            which_beach(
                JoinAsk {
                    enter_on: Some(0),
                    ..asking()
                },
                &hosts
            ),
            Pick::Full(0)
        );
        assert_eq!(
            which_beach(
                JoinAsk {
                    enter_on: Some(1),
                    ..asking()
                },
                &hosts
            ),
            Pick::Take(1),
            "one chair is still a chair"
        );
        assert_eq!(
            which_beach(
                JoinAsk {
                    enter_on: Some(2),
                    ..asking()
                },
                &hosts
            ),
            Pick::Take(2),
            "a beach that described no table is not a full one"
        );
    }

    /// A beach whose socket is full has no room for a watcher either: the
    /// next peer of any kind is turned away there.
    #[test]
    fn a_full_socket_is_full_for_a_watcher_too() {
        let mut hosts = open();
        hosts[0].full = true;
        let ask = |to_watch| {
            which_beach(
                JoinAsk {
                    enter_on: Some(0),
                    to_watch,
                    ..asking()
                },
                &hosts,
            )
        };
        assert_eq!(ask(true), Pick::Full(0));
        assert_eq!(ask(false), Pick::Full(0));
    }

    /// Already somewhere, hosting or joining, and the keys mean nothing.
    /// Without this Enter would open a second socket over the first.
    #[test]
    fn keys_do_nothing_once_you_are_already_at_a_beach() {
        let hosts = open();
        for pressed in [Some(0), Some(1)] {
            assert_eq!(
                which_beach(
                    JoinAsk {
                        enter_on: pressed,
                        busy: true,
                        ..asking()
                    },
                    &hosts
                ),
                Pick::Nothing
            );
        }
        assert_eq!(
            which_beach(
                JoinAsk {
                    intent: Some(Intent::Join(hosts[0].addr)),
                    busy: true,
                    ..asking()
                },
                &hosts
            ),
            Pick::Nothing
        );
    }

    /// Hosting is somebody else's intent entirely and must not be mistaken
    /// for a join, or pressing H would dial the beach under the cursor.
    #[test]
    fn the_intent_to_host_is_not_the_intent_to_join() {
        let hosts = open();
        assert_eq!(
            which_beach(
                JoinAsk {
                    enter_on: Some(1),
                    intent: Some(Intent::Host),
                    ..asking()
                },
                &hosts
            ),
            Pick::Nothing
        );
    }

    /// A beach that never answers is given up on and said so, rather than
    /// called forever under a screen that claims you are aboard.
    ///
    /// A mistyped address, a firewall and a friend who quit thirty seconds
    /// ago all look on UDP like a host who has not started the round yet,
    /// so silence has to be worth something.
    #[test]
    fn a_beach_that_never_answers_is_given_up_on() {
        // Nothing is listening at this address; that is the point.
        let mut state = LobbyState {
            standing: Standing::Joining(Joined::dialled(
                UdpTransport::join(("127.0.0.1", 47999)).expect("join"),
                false,
            )),
            ..LobbyState::default()
        };
        // Listed, as a crashed beach still is for a while up the port
        // ladder, next to one that is fine.
        let mut listed = beaches(&[(1, 6), (1, 6)]);
        listed[0].addr = "127.0.0.1:47999".parse().expect("addr");
        state.hosts = listed;

        let quiet = answer_the_silence(&mut state, &crate::app::i18n::EN, false, 1.0);
        assert!(!quiet, "one second is a lost packet, not an empty address");
        assert!(state.joined().is_some(), "still calling");

        let quiet = answer_the_silence(&mut state, &crate::app::i18n::EN, false, NO_ANSWER_AFTER);
        assert!(quiet, "and then it stops calling");
        assert!(state.joined().is_none(), "the socket is let go");
        assert!(
            state.feedback.contains("127.0.0.1:47999"),
            "and says which address went unanswered: {:?}",
            state.feedback
        );
        assert!(
            !state.standing().at_a_beach(),
            "and the lobby is back to browsing"
        );
        let left: Vec<&str> = state.hosts.iter().map(|host| host.name.as_str()).collect();
        assert_eq!(
            left,
            ["beach1"],
            "the beach that never answered is not offered again"
        );
    }

    /// Somebody who came to watch is not in line for a chair.
    ///
    /// The host puts everyone who turns up mid-round in the same queue and
    /// says so with the same `Queued`, so the only machine that knows a
    /// peer armed W is that peer. Found by dialling into a running round
    /// as a spectator: the screen said "you are next up", over a prompt
    /// saying it would play the next round, to somebody who had asked for
    /// neither.
    #[test]
    fn a_watcher_in_the_queue_is_not_waiting_for_a_chair() {
        let tr = &crate::app::i18n::EN;
        assert_eq!(queued_feedback(tr, false, false, 0), tr.lobby_queued_next);
        assert_eq!(
            queued_feedback(tr, false, false, 3),
            fill(tr.lobby_queued_behind, &[("n", "3")]),
            "a player is told where in the line it stands"
        );

        // A watcher is told the same thing wherever it stands, because
        // where it stands does not decide anything: the crowd has room.
        for ahead in [0, 1, 9] {
            for picking in [false, true] {
                assert_eq!(
                    queued_feedback(tr, true, picking, ahead),
                    tr.lobby_queued_watching,
                    "with {ahead} ahead"
                );
            }
        }
        assert_ne!(
            tr.lobby_queued_watching, tr.lobby_queued_next,
            "and it is not the line a player is given"
        );
    }

    /// A player in line while the host picks the next map is told it
    /// watches that round, which is dealt the table the map was picked
    /// for, wherever it stands in line: not that it is next up.
    #[test]
    fn a_player_in_line_while_the_host_picks_watches_that_round() {
        let tr = &crate::app::i18n::EN;
        for ahead in [0, 2] {
            assert_eq!(
                queued_feedback(tr, false, true, ahead),
                tr.lobby_queued_picking
            );
        }
    }

    /// The first word back is the moment a called address becomes a beach.
    #[test]
    fn the_first_word_is_what_makes_it_a_beach() {
        let mut state = LobbyState {
            standing: Standing::Joining(Joined::dialled(
                UdpTransport::join(("127.0.0.1", 47999)).expect("join"),
                false,
            )),
            feedback: "calling...".into(),
            ..LobbyState::default()
        };

        answer_the_silence(&mut state, &crate::app::i18n::EN, true, 0.1);
        assert_eq!(state.feedback, crate::app::i18n::EN.lobby_aboard);
        assert!(state.joined().is_some_and(|joined| joined.host_answered));

        // And the patience is topped up by every word after it, so a long
        // wait in a lobby is never mistaken for an empty address.
        answer_the_silence(
            &mut state,
            &crate::app::i18n::EN,
            false,
            NO_ANSWER_AFTER - 0.1,
        );
        answer_the_silence(&mut state, &crate::app::i18n::EN, true, 0.1);
        let quiet = answer_the_silence(&mut state, &crate::app::i18n::EN, false, 1.0);
        assert!(!quiet, "the clock restarted when the host spoke");
        assert!(state.joined().is_some());
    }

    /// Nor is a dialled address: it names no row of this list, which is why
    /// it was typed at all, and reading it as one takes whatever is under
    /// the cursor instead.
    #[test]
    fn a_dialled_address_is_not_a_row_of_the_list() {
        let hosts = open();
        let there: SocketAddr = "192.168.1.5:47777".parse().expect("addr");
        assert_eq!(
            which_beach(
                JoinAsk {
                    enter_on: Some(1),
                    intent: Some(Intent::Dial(there)),
                    ..asking()
                },
                &hosts
            ),
            Pick::Nothing
        );
        // And with nothing on the air at all, which is the state the
        // whole feature exists for.
        assert_eq!(
            which_beach(
                JoinAsk {
                    intent: Some(Intent::Dial(there)),
                    ..asking()
                },
                &[]
            ),
            Pick::Nothing
        );
    }

    /// The round a table has just walked out of is not an invitation back
    /// into it.
    ///
    /// A host sitting on its results card re-answers every greeting with
    /// that round's `Start`, which is right for a latecomer and wrong for
    /// the peers that were in it: they greet the moment they are back in
    /// the lobby and would bounce straight into the finished round. The
    /// seed is what tells a fresh round from a repeat.
    #[test]
    fn the_round_just_played_is_not_an_invitation_back_into_it() {
        let invitation = |seed| Invitation {
            seats: 2,
            seat: Some(1),
            terms: MatchTerms {
                seed,
                ..MatchTerms::default()
            },
            ..Invitation::blank()
        };
        assert!(
            !a_fresh_invitation(Some(42), &invitation(42)),
            "the host is repeating the round we just left"
        );
        assert!(
            a_fresh_invitation(Some(42), &invitation(43)),
            "a fresh seed is a fresh round, and this table is in it"
        );
        // A lobby that has played nothing takes any invitation, which is
        // every ordinary join.
        assert!(a_fresh_invitation(None, &invitation(42)));
    }

    /// The dev hook joins the first beach it hears, unattended, so it is
    /// never stopped for a name nobody is there to type.
    #[test]
    fn the_unattended_hook_takes_the_first_beach_without_being_asked() {
        let hosts = open();
        assert_eq!(
            which_beach(
                JoinAsk {
                    auto_join: true,
                    named: false,
                    ..asking()
                },
                &hosts
            ),
            Pick::Take(0),
            "nameless, and taken anyway"
        );
        assert_eq!(
            which_beach(
                JoinAsk {
                    auto_join: true,
                    named: false,
                    ..asking()
                },
                &[]
            ),
            Pick::Nothing,
            "but it cannot take what is not there"
        );
    }
}