use super::*;
use crate::app::i18n::fill;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum HostStep {
Ask,
Go,
Nothing,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) struct HostAsk {
pub answered: bool,
pub pressed_h: bool,
pub busy: bool,
pub auto: bool,
}
#[cfg(test)]
pub(super) fn idle() -> HostAsk {
HostAsk {
answered: false,
pressed_h: false,
busy: false,
auto: false,
}
}
#[cfg(test)]
pub(super) fn peers_named(names: &[&str], watchers: &[usize]) -> PeerBook {
let mut peers = PeerBook::default();
for (i, name) in names.iter().enumerate() {
peers.row(i).name = name.to_string();
}
for watcher in watchers {
peers.row(*watcher).watch = true;
}
peers
}
pub(super) fn address_here(transport: &UdpTransport) -> Option<String> {
let port = transport.local_addr().ok()?.port();
let ip = crate::transport::local_ip()?;
Some(SocketAddr::new(ip, port).to_string())
}
pub(super) fn host_step(ask: HostAsk) -> HostStep {
if ask.answered {
return HostStep::Go;
}
if ask.busy {
return HostStep::Nothing;
}
match (ask.pressed_h || ask.auto, ask.auto) {
(true, true) => HostStep::Go,
(true, false) => HostStep::Ask,
(false, _) => HostStep::Nothing,
}
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct IntoArena<'w> {
online: ResMut<'w, Online>,
tournament: ResMut<'w, crate::app::tournament::Tournament>,
next_screen: ResMut<'w, NextState<Screen>>,
next_vphase: ResMut<'w, NextState<VersusPhase>>,
bots: ResMut<'w, crate::app::bot_seats::BotSeats>,
}
impl IntoArena<'_> {
fn walk_in(&mut self, session: OnlineSession, series: crate::app::tournament::Tournament) {
self.online.0 = Some(session);
*self.tournament = series;
self.next_vphase.set(VersusPhase::Countdown);
self.next_screen.set(Screen::Versus);
}
}
pub fn host_tick(
time: Res<Time>,
(keys, mut hooked, mut held, mut partnered): (
Res<ButtonInput<KeyCode>>,
Local<bool>,
Local<u32>,
Local<bool>,
),
(settings, device): (Res<GameSettings>, Res<crate::app::gamepad::LastDevice>),
(mut config, mut menu): (
ResMut<MatchConfig>,
ResMut<crate::app::match_setup::MatchMenu>,
),
beaches: Res<crate::app::match_setup::CustomBeaches>,
mut state: ResMut<LobbyState>,
mut arena: IntoArena,
) {
let tr = settings.tr();
let LobbyState {
standing,
game_name,
..
} = &mut *state;
let Standing::Hosting(hosted) = standing else {
return;
};
let mut sent_away = Vec::new();
let arrived = host_the_bots(
&mut arena.bots,
&config,
tr,
hosted.players_aboard(),
&mut sent_away,
);
let taken = chairs_taken(1 + hosted.players_aboard() as u8, arrived.len());
let on_air = crate::transport::OnAir {
name: game_name,
host: &settings.names[0],
taken,
seats: MAX_PLAYERS as u8,
bots: config.bots_welcome,
full: hosted.transport.peer_count() >= crate::transport::MAX_PEERS,
};
let picked = work_the_socket(hosted, time.delta_secs(), on_air);
let do_announce = picked.announced;
for (_, name, text) in picked.said {
state.hear(&name, &text);
}
sent_away.extend(picked.sent_away);
for who in &sent_away {
tell_sent_away(&mut state, tr, who);
}
note_arrivals_and_departures(&mut state, tr, picked.greeted);
let joined = state.hosted().map_or(0, |hosted| hosted.peers.len());
state.table = state.roster(tr, &crate::transport::name_as_sent(&settings.names[0]));
state.table_kinds = state.roster_kinds();
seat_door_bots(&mut state, &arrived);
state.seated = super::partners::host_seated(&state, &config, settings.team_mode, &arrived);
if let Some(hosted) = state.hosted_mut() {
hosted.bot_string = arena
.bots
.door
.as_ref()
.and_then(crate::app::bot_seats::Doorway::open_string);
}
if do_announce {
send_roster(&state, &config, settings.team_mode, &beaches);
}
let (players_aboard, watchers_aboard, port) = state.hosted().map_or((0, 0, None), |hosted| {
(
hosted.players_aboard(),
hosted.watchers_aboard(),
hosted.transport.local_addr().ok().map(|addr| addr.port()),
)
});
let rivals = joined + arrived.len();
if rivals > 0 {
let aboard = players_aboard + arrived.len();
let ai = hosts_dealt(&state, &config).map_or(0, |dealt| dealt.bots);
let start = crate::app::gamepad::scheme::spelled(
tr,
*device,
&crate::app::gamepad::Face::Hosting { partners: false },
crate::app::gamepad::scheme::Does::StartMatch,
tr.key_enter,
);
state.feedback = gathering_feedback(tr, aboard, watchers_aboard, ai, &start);
} else {
state.feedback = match port {
Some(port) => fill(tr.lobby_hosting, &[("p", &port.to_string())]),
None => tr.lobby_hosting_noport.to_string(),
};
}
if state
.hosted()
.is_some_and(|hosted| hosted.picking.is_some())
{
state.feedback.clear();
*held += 1;
if crate::app::dev::lobby_pick_go() && *held == 30 {
pick::pick_the_card_here(&mut state, &mut config, &menu, &beaches);
}
let mut pick = Pick {
config: &mut config,
menu: &mut menu,
beaches: &beaches,
};
tend_the_pick(
&mut state, &settings, &mut pick, joined, &arrived, &mut arena,
);
return;
}
*held = 0;
if let Some((seat, at)) = crate::app::dev::lobby_partners()
&& rivals >= at
&& !std::mem::replace(&mut *partnered, true)
&& super::partners::partners_open(&state)
{
super::partners::pick_partners(&mut state, &arrived, seat.saturating_sub(1));
send_roster(&state, &config, settings.team_mode, &beaches);
}
let quota = crate::app::dev::auto_host_quota();
let launch = should_launch(
rivals,
state.typing.is_some()
|| state
.hosted()
.is_some_and(|hosted| hosted.kicking.is_some() || hosted.partnering),
crate::app::menu_ui::enter(&keys),
quota,
);
let hook =
rivals > 0 && crate::app::dev::lobby_pick() && !std::mem::replace(&mut *hooked, true);
if !launch && !hook {
return;
}
if quota.is_some_and(|quota| rivals >= quota) {
launch_the_match(
&mut state, &settings, &config, &beaches, joined, &mut arena, None,
);
return;
}
pick::open_the_pick(&mut state, &mut config, &mut menu, &beaches, &arrived);
send_roster(&state, &config, settings.team_mode, &beaches);
}
struct Pick<'a> {
config: &'a mut MatchConfig,
menu: &'a mut crate::app::match_setup::MatchMenu,
beaches: &'a crate::app::match_setup::CustomBeaches,
}
fn tend_the_pick(
state: &mut LobbyState,
settings: &GameSettings,
pick: &mut Pick,
joined: usize,
door: &[(crate::bots::listener::BotId, String)],
arena: &mut IntoArena,
) {
let Some((gone, chosen)) = state.hosted().and_then(|hosted| {
let picking = hosted.picking.as_ref()?;
Some((
pick::gone_from_the_table(picking, &hosted.peers, door),
picking.chosen,
))
}) else {
return;
};
if gone.is_none() && !chosen {
return;
}
let Some(picking) = state.hosted_mut().and_then(|hosted| hosted.picking.take()) else {
return;
};
if let Some(gone) = gone {
pick::close_the_pick(
picking,
pick::Close::PutBack,
pick.config,
pick.menu,
pick.beaches,
);
let tr = settings.tr();
tell_the_room(
state,
match gone {
pick::Gone::Player => tr.lobby_pick_called_off,
pick::Gone::Bot => tr.lobby_pick_called_off_bot,
},
);
send_roster(state, pick.config, settings.team_mode, pick.beaches);
return;
}
let frozen = Frozen {
bots: picking.bots.clone(),
seed: pick.menu.seed,
};
launch_the_match(
state,
settings,
pick.config,
pick.beaches,
joined,
arena,
Some(frozen),
);
pick::close_the_pick(
picking,
pick::Close::Keep,
pick.config,
pick.menu,
pick.beaches,
);
}
#[derive(Clone, Debug)]
struct Frozen {
bots: Vec<crate::bots::listener::BotId>,
seed: u64,
}
fn seat_door_bots(state: &mut LobbyState, arrived: &[(crate::bots::listener::BotId, String)]) {
for (_, name) in arrived {
if state.table.len() < MAX_PLAYERS {
state.table.push(name.clone());
state.table_kinds.push(crate::sim::SeatKind::Bot);
}
}
}
pub(super) fn send_roster(
state: &LobbyState,
config: &MatchConfig,
teams: crate::app::teams::TeamMode,
beaches: &crate::app::match_setup::CustomBeaches,
) {
let (Some(hosted), Some(dealt)) = (state.hosted(), hosts_dealt(state, config)) else {
return;
};
let terms = MatchTerms {
bots: dealt.bots,
map: dealt.map.index() as u8,
order: state.seated.order,
..crate::app::match_setup::terms(config, teams, 0)
};
let roster = |chair| NetMsg::Roster {
seats: humans_here(state),
names: crate::transport::wire_table(&state.table),
terms,
kinds: crate::transport::wire_kinds(&state.table_kinds),
beach: roster_beach(config, dealt.seats, beaches),
chair: Some(chair),
picking: hosted.picking.is_some(),
};
let chairless: Vec<usize> = match hosted.picking {
Some(_) => {
let plan = pick::frozen_plan(&hosted.peers);
hosted
.peers
.iter()
.enumerate()
.filter(|(peer, row)| row.wants_a_chair() && plan[*peer].is_none())
.map(|(peer, _)| peer)
.collect()
}
None => hosted
.peers
.off_the_table()
.filter(|(_, row)| row.wants_a_chair())
.map(|(peer, _)| peer)
.collect(),
};
for peer in 0..hosted.transport.peer_count() {
hosted
.transport
.send_to(peer, roster(!chairless.contains(&peer)));
}
}
pub(crate) fn chairs_taken(people: u8, bots: usize) -> u8 {
let held = bots.min(crate::app::net::chairs_for_bots(people.saturating_add(1)));
people.saturating_add(held as u8).min(MAX_PLAYERS as u8)
}
fn host_the_bots(
bots: &mut crate::app::bot_seats::BotSeats,
config: &MatchConfig,
tr: &'static crate::app::i18n::Tr,
people: usize,
sent_away: &mut Vec<String>,
) -> Vec<(crate::bots::listener::BotId, String)> {
if !config.bots_welcome {
if let Some(door) = bots.door.take() {
sent_away.extend(door.arrived(tr).into_iter().map(|(_, who)| who));
}
return Vec::new();
}
if bots.door.is_none() {
match crate::app::bot_seats::Doorway::open_lan() {
Ok(door) => bots.door = Some(door),
Err(e) => {
bots.feedback = fill(tr.door_could_not_listen, &[("e", &e.to_string())]);
return Vec::new();
}
}
}
let Some(door) = &mut bots.door else {
return Vec::new();
};
door.poll();
let arrived = door.arrived(tr);
let held = door
.chairs
.iter()
.filter(|chair| chair.bot.is_some())
.count();
let room = 1 + people + held < MAX_PLAYERS;
door.keep_a_chair_open(room);
arrived
}
fn launch_the_match(
state: &mut LobbyState,
settings: &GameSettings,
config: &MatchConfig,
beaches: &crate::app::match_setup::CustomBeaches,
joined: usize,
arena: &mut IntoArena,
frozen: Option<Frozen>,
) {
let Standing::Hosting(Hosted {
announcer,
transport,
peers,
announce_in: _,
kicking: _,
bot_string: _,
tail: _,
rounds_dealt,
picking: _,
partnering: _,
mut arranged,
}) = std::mem::take(&mut state.standing)
else {
unreachable!("launching a match from a lobby that is not hosting");
};
let mut peers = peers;
debug_assert_eq!(joined, peers.len());
let mut plan = match frozen {
Some(_) => pick::frozen_plan(&peers),
None => peers.seat_plan(peers.len()),
};
let people = 1 + plan.iter().filter(|seat| seat.is_some()).count() as u8;
let mut bots_here: Vec<HostedBot> = arena
.bots
.door
.as_ref()
.map(|door| door.arrived(settings.tr()))
.unwrap_or_default()
.into_iter()
.filter(|(id, _)| {
frozen
.as_ref()
.is_none_or(|frozen| frozen.bots.contains(id))
})
.map(|(id, name)| HostedBot { seat: 0, id, name })
.collect();
crate::app::net::seat_the_hosts_bots(&mut bots_here, people);
let humans = people + bots_here.len() as u8;
let dealt = dealt(config, humans);
let (bots, seats) = (dealt.bots, dealt.seats);
let seed = frozen
.as_ref()
.map_or_else(crate::app::clock::fresh_seed, |frozen| frozen.seed);
let drawn;
let config = match config.random {
true => {
let mut table = MatchConfig {
seats,
..config.clone()
};
crate::app::match_setup::draw_online_map(
&mut table,
beaches,
crate::app::clock::fresh_seed(),
);
drawn = table;
&drawn
}
false => config,
};
let beach = crate::app::match_setup::beach_bytes(config, seats, beaches);
let mut terms = MatchTerms {
bots,
map: map_for(config, seats).index() as u8,
..crate::app::match_setup::terms(config, settings.team_mode, seed)
};
crate::app::net::arrange_the_deal(
(&mut plan, &mut bots_here),
&mut peers,
arranged.as_mut(),
&mut terms,
seats,
);
let names = seat_names(&settings.names[0], &plan, &peers, &bots_here);
let kinds = crate::app::net::seat_kinds(&plan, &peers, &bots_here, terms, seats);
let standing = terms
.is_series()
.then(crate::transport::SeriesStanding::opening);
let delay = crate::app::net::input_delay(&peers, &plan);
let mut session = OnlineSession::new(
transport,
crate::app::net::lockstep_for(Some(0), terms.player_seats(seats), delay),
seats,
terms,
);
session.beach = beach;
session.peers = peers;
session.names = names;
session.arranged = arranged;
session.rounds_dealt = rounds_dealt;
session.deal(&plan, kinds, bots_here, standing);
session.stay_on_air(announcer);
session.home.from_lobby = true;
session.home.game_name = state.game_name.clone();
session.home.bots_welcome = config.bots_welcome;
let series = crate::app::tournament::Tournament::from_terms(terms, standing);
arena.walk_in(session, series);
}
fn seat_names(
me: &str,
plan: &[Option<u8>],
peers: &PeerBook,
bots_here: &[HostedBot],
) -> [String; MAX_PLAYERS] {
crate::app::net::seat_names(
crate::transport::name_as_sent(me),
plan,
|peer| peers.get(peer).map(|peer| peer.name.as_str()),
bots_here,
)
}
fn note_arrivals_and_departures(
state: &mut LobbyState,
tr: &'static crate::app::i18n::Tr,
greeted: Vec<(usize, String)>,
) {
for (from, told) in greeted {
let Some(hosted) = state.hosted_mut() else {
return;
};
let row = hosted.peers.row(from);
let arrived = row.name != told;
let newcomer = row.name.is_empty();
let watching = row.watch;
row.name.clone_from(&told);
if newcomer {
catch_up_on_the_feed(state, from);
}
if arrived {
let line = match watching {
true => tr.lobby_watching_joined,
false => tr.lobby_joined,
};
let notice = fill(line, &[("p", &told)]);
tell_the_room(state, ¬ice);
}
}
let gone: Vec<usize> = state.hosted().map_or(Vec::new(), |hosted| {
hosted.peers.to_forget(|row| row.silence > PEER_TTL)
});
for peer in gone {
let Some(hosted) = state.hosted_mut() else {
return;
};
let who = hosted
.peers
.get(peer)
.map_or_else(String::new, |p| p.name.clone());
hosted.forget_peer(peer);
if !who.is_empty() {
let notice = fill(tr.lobby_left, &[("p", &who)]);
tell_the_room(state, ¬ice);
}
}
}
#[derive(Default)]
struct Picked {
announced: bool,
greeted: Vec<(usize, String)>,
unwelcome: Vec<usize>,
sent_away: Vec<String>,
said: Vec<(
usize,
crate::transport::WireName,
crate::transport::WireChat,
)>,
}
fn work_the_socket(hosted: &mut Hosted, delta: f32, on_air: crate::transport::OnAir<'_>) -> Picked {
let Hosted {
announcer,
transport,
peers,
announce_in,
kicking: _,
bot_string: _,
tail,
rounds_dealt: _,
picking: _,
partnering: _,
arranged: _,
} = hosted;
let mut picked = Picked {
announced: once_a_second(announce_in, delta),
..Picked::default()
};
peers.age(delta);
if picked.announced
&& let Ok(addr) = transport.local_addr()
{
announcer.announce(addr.port(), on_air);
}
if picked.announced {
transport.send(NetMsg::ping());
}
let mut still_playing = Vec::new();
for (msg, from) in transport.recv_all() {
peers.reach(transport.peer_count());
peers.heard(from);
match msg {
NetMsg::Watch { name } => {
peers.row(from).watch = true;
let told = crate::transport::name_from_wire(&name);
if !told.is_empty() {
picked.greeted.push((from, told));
}
}
NetMsg::SpectatorVote { .. } | NetMsg::SpectatorTally { .. } => {}
NetMsg::Hello { bot: true, .. } if !on_air.bots => {
if !picked.unwelcome.contains(&from) {
picked.unwelcome.push(from);
}
}
NetMsg::Hello { name, bot } => {
peers.row(from).bot = bot;
let told = crate::transport::name_from_wire(&name);
if !told.is_empty() {
picked.greeted.push((from, told));
}
}
NetMsg::Chat { name, text } => {
let who = peers.vouch(from, name);
if !crate::transport::name_from_wire(&who).is_empty() {
picked.said.push((from, who, text));
}
}
NetMsg::Inputs(batch) => still_playing.push((from, batch)),
NetMsg::Pong { stamp } => peers.answered(from, stamp),
NetMsg::Hash { .. }
| NetMsg::Start(_)
| NetMsg::Pause { .. }
| NetMsg::Resume { .. }
| NetMsg::Queued { .. }
| NetMsg::Roster { .. }
| NetMsg::Abandoned { .. }
| NetMsg::CatchUp { .. }
| NetMsg::SpectatorPick { .. }
| NetMsg::CrowdPicks { .. }
| NetMsg::Incompatible { .. }
| NetMsg::Ping { .. }
| NetMsg::Clock { .. }
| NetMsg::Kicked
| NetMsg::NoBots
| NetMsg::Full => {}
NetMsg::Leaving => peers.said_goodbye(from),
}
}
tail.repeat(transport, delta, picked.announced, &still_playing);
picked.unwelcome.sort_unstable();
for &peer in picked.unwelcome.iter().rev() {
if let Some(name) = peers.get(peer).map(|row| row.name.clone())
&& !name.is_empty()
{
picked.sent_away.push(name);
}
transport.turn_away_saying(peer, NetMsg::NoBots);
peers.forget(peer);
let renumber = crate::app::net::renumber;
picked.greeted.retain_mut(|(from, _)| renumber(from, peer));
picked.said.retain_mut(|(from, ..)| renumber(from, peer));
}
for (from, name, text) in picked.said.iter() {
crate::app::net::relay(
transport,
*from,
NetMsg::Chat {
name: *name,
text: *text,
},
);
}
picked
}
pub(super) fn gathering_feedback(
tr: &'static crate::app::i18n::Tr,
players_aboard: usize,
watching: usize,
ai: u8,
start: &str,
) -> String {
let mut line = match players_aboard {
0 => fill(tr.lobby_rivals_none, &[("k", start)]),
1 => fill(tr.lobby_rivals_one, &[("k", start)]),
n => fill(tr.lobby_rivals_many, &[("n", &n.to_string()), ("k", start)]),
};
if watching > 0 {
line += &fill(tr.lobby_watchers, &[("n", &watching.to_string())]);
}
if ai > 0 {
line += &fill(tr.lobby_ai_seats, &[("n", &ai.to_string())]);
}
line
}
pub(super) fn should_launch(
joined: usize,
typing: bool,
enter: bool,
quota: Option<usize>,
) -> bool {
if joined == 0 {
return false;
}
match quota {
Some(quota) if joined >= quota => true,
_ => enter && !typing,
}
}
fn catch_up_on_the_feed(state: &LobbyState, peer: usize) {
let Some(hosted) = state.hosted() else {
return;
};
for said in &state.chat {
hosted
.transport
.send_to(peer, NetMsg::chat(&said.who, &said.line));
}
}
const KICK_PAGE: usize = crate::app::menu_ui::NUMBER_KEYS.len() - MAX_PLAYERS;
pub(crate) fn kick_pages(peers: &PeerBook) -> usize {
peers.off_the_table().count().div_ceil(KICK_PAGE).max(1)
}
pub(crate) fn numbered_off_the_table(
peers: &PeerBook,
page: usize,
) -> impl Iterator<Item = (usize, usize, &crate::app::net::Peer)> {
let numbers = MAX_PLAYERS + 1..=crate::app::menu_ui::NUMBER_KEYS.len();
let skip = page % kick_pages(peers) * KICK_PAGE;
peers
.off_the_table()
.skip(skip)
.zip(numbers)
.map(|((peer, row), at)| (at, peer, row))
}
pub fn kick_prompt(tr: &crate::app::i18n::Tr, peers: &PeerBook, page: usize) -> String {
let mut prompt = tr.lobby_kick_ask.to_string();
for (at, _, peer) in numbered_off_the_table(peers, page) {
let who = match peer.name.is_empty() {
true => "?",
false => peer.name.as_str(),
};
let sep = match at == MAX_PLAYERS + 1 {
true => " | ",
false => " ",
};
prompt.push_str(&format!("{sep}{at}. {who}"));
}
let pages = kick_pages(peers);
if pages > 1 {
let shown = (page % pages + 1).to_string();
prompt.push_str(" | ");
prompt.push_str(&fill(
tr.lobby_kick_more,
&[("p", &shown), ("n", &pages.to_string())],
));
}
prompt
}
pub(crate) fn kick_rows(
hosted: &Hosted,
bots: &crate::app::bot_seats::BotSeats,
tr: &crate::app::i18n::Tr,
) -> Vec<(u8, String)> {
let page = hosted.kicking.unwrap_or(0);
let called = |peer: &crate::app::net::Peer| match peer.name.is_empty() {
true => "?".to_string(),
false => peer.name.clone(),
};
let people: Vec<String> = hosted
.peers
.at_the_table()
.map(|(_, p)| called(p))
.collect();
let arrived = bots
.door
.as_ref()
.map_or_else(Vec::new, |door| door.arrived(tr));
let at_table = people
.into_iter()
.chain(arrived.into_iter().map(|(_, who)| who))
.zip(2..=MAX_PLAYERS);
let off = numbered_off_the_table(&hosted.peers, page).map(|(at, _, peer)| (called(peer), at));
at_table
.chain(off)
.map(|(who, at)| (at as u8, who))
.collect()
}
pub(super) fn ask_to_leave(
state: &mut LobbyState,
bots: &mut crate::app::bot_seats::BotSeats,
tr: &'static crate::app::i18n::Tr,
at: usize,
) {
let Some(hosted) = state.hosted_mut() else {
return;
};
let page = hosted.kicking.take().unwrap_or(0);
let people: Vec<usize> = hosted.peers.at_the_table().map(|(peer, _)| peer).collect();
if at <= MAX_PLAYERS
&& let Some(bot) = at.checked_sub(2 + people.len())
&& let Some(door) = &mut bots.door
&& let Some((id, who)) = door.arrived(tr).get(bot).cloned()
{
door.let_go(id);
tell_sent_away(state, tr, &who);
return;
}
let peer = match at > MAX_PLAYERS {
true => numbered_off_the_table(&hosted.peers, page)
.find(|&(number, _, _)| number == at)
.map(|(_, peer, _)| peer),
false => people.get(at.wrapping_sub(2)).copied(),
};
let Some(peer) = peer else {
return;
};
let who = hosted
.peers
.get(peer)
.map_or_else(String::new, |p| p.name.clone());
hosted.turn_away(peer);
tell_sent_away(state, tr, &who);
}
fn tell_sent_away(state: &mut LobbyState, tr: &crate::app::i18n::Tr, who: &str) {
tell_the_room(state, &fill(tr.lobby_kicked_feed, &[("p", who)]));
}
fn tell_the_room(state: &mut LobbyState, notice: &str) {
state.say("", notice);
if let Some(hosted) = state.hosted() {
hosted.transport.send(NetMsg::chat("", notice));
}
}
pub(super) fn say_goodbye(state: &LobbyState) {
if let Some(hosted) = state.hosted()
&& let Ok(addr) = hosted.transport.local_addr()
{
hosted.announcer.closing(addr.port());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_prompt_numbers_whom_the_card_has_no_row_for() {
let mut state = LobbyState {
standing: hosting_lobby(),
..LobbyState::default()
};
if let Standing::Hosting(hosted) = &mut state.standing {
hosted.peers = peers_named(&["A", "Gil", "B", "C", "D", "E", "F"], &[1]);
}
let mut bots = crate::app::bot_seats::BotSeats::default();
let tr = &crate::app::i18n::EN;
let names = |state: &LobbyState| {
state.hosted().map_or_else(Vec::new, |hosted| {
hosted
.peers
.iter()
.map(|peer| peer.name.clone())
.collect::<Vec<_>>()
})
};
let prompt = |state: &LobbyState| {
state
.hosted()
.map(|hosted| kick_prompt(tr, &hosted.peers, 0))
.unwrap_or_default()
};
assert_eq!(
prompt(&state),
format!("{} | 7. Gil 8. F", tr.lobby_kick_ask),
"the watcher and the sixth would-be player, in the order they came"
);
ask_to_leave(&mut state, &mut bots, tr, MAX_PLAYERS + 3);
assert_eq!(names(&state).len(), 7, "nobody is nine");
ask_to_leave(&mut state, &mut bots, tr, MAX_PLAYERS + 2);
assert_eq!(names(&state), ["A", "Gil", "B", "C", "D", "E"], "8 was F");
ask_to_leave(&mut state, &mut bots, tr, MAX_PLAYERS + 1);
assert_eq!(names(&state), ["A", "B", "C", "D", "E"], "7 was Gil");
assert_eq!(
prompt(&state),
tr.lobby_kick_ask,
"and nobody left off the card"
);
}
#[test]
fn tab_turns_the_k_numbers_through_everybody_off_the_table() {
let mut state = LobbyState {
standing: hosting_lobby(),
..LobbyState::default()
};
let names: Vec<String> = (0..16).map(|n| format!("P{n}")).collect();
let named: Vec<&str> = names.iter().map(String::as_str).collect();
let watching: Vec<usize> = (5..16).collect();
if let Standing::Hosting(hosted) = &mut state.standing {
hosted.peers = peers_named(&named, &watching);
}
let tr = &crate::app::i18n::EN;
let pages = state.hosted().map_or(0, |hosted| kick_pages(&hosted.peers));
assert_eq!(pages, 4, "eleven, three a page");
let prompt = |state: &LobbyState, page| {
state
.hosted()
.map(|hosted| kick_prompt(tr, &hosted.peers, page))
.unwrap_or_default()
};
let more = |page: usize| fill(tr.lobby_kick_more, &[("p", &page.to_string()), ("n", "4")]);
assert_eq!(
prompt(&state, 2),
format!(
"{} | 7. P11 8. P12 9. P13 | {}",
tr.lobby_kick_ask,
more(3)
)
);
assert_eq!(
prompt(&state, 3),
format!("{} | 7. P14 8. P15 | {}", tr.lobby_kick_ask, more(4))
);
assert_eq!(prompt(&state, 4), prompt(&state, 0), "round again");
if let Some(hosted) = state.hosted_mut() {
hosted.kicking = Some(2);
}
let mut bots = crate::app::bot_seats::BotSeats::default();
ask_to_leave(&mut state, &mut bots, tr, MAX_PLAYERS + 2);
let left: Vec<String> = state.hosted().map_or_else(Vec::new, |hosted| {
hosted.peers.iter().map(|peer| peer.name.clone()).collect()
});
assert_eq!(left.len(), 15);
assert!(
!left.contains(&"P12".to_string()),
"P12 was asked: {left:?}"
);
assert_eq!(
state.hosted().map(|hosted| hosted.kicking),
Some(None),
"and the pick is over"
);
}
#[test]
fn the_table_lists_whom_k_numbers() {
let mut state = LobbyState {
standing: hosting_lobby(),
..LobbyState::default()
};
if let Standing::Hosting(hosted) = &mut state.standing {
hosted.peers = peers_named(&["A", "B", "C", "D", "E", "F"], &[]);
}
let tr = &crate::app::i18n::EN;
let table = state.roster(tr, "Host");
assert_eq!(table, ["Host", "A", "B", "C", "D", "E"], "a row a chair");
let aboard = state.hosted().map_or(0, Hosted::players_aboard);
assert_eq!(aboard + 1, table.len(), "and the count is the rows");
assert_eq!(
state.roster_kinds().len(),
table.len(),
"and so are the kinds"
);
let mut bots = crate::app::bot_seats::BotSeats::default();
ask_to_leave(&mut state, &mut bots, tr, table.len());
let left = state.hosted().map(|hosted| {
hosted
.peers
.iter()
.map(|peer| peer.name.clone())
.collect::<Vec<_>>()
});
assert_eq!(
left.as_deref(),
Some(&["A", "B", "C", "D", "F"].map(String::from)[..])
);
}
#[test]
fn the_list_counts_bots_as_the_launch_seats_them() {
for people in 1..=MAX_PLAYERS as u8 {
for bots in 0..MAX_PLAYERS {
let taken = chairs_taken(people, bots);
let full = taken >= MAX_PLAYERS as u8;
let room_for_one_more = usize::from(people) < MAX_PLAYERS;
assert_eq!(full, !room_for_one_more, "{people} people, {bots} bots");
assert!(taken >= people, "every person is counted");
let seated_now = bots.min(crate::app::net::chairs_for_bots(people));
assert!(
usize::from(taken) + 1 >= usize::from(people) + seated_now,
"{people} people, {bots} bots read {taken}"
);
}
}
assert_eq!(chairs_taken(5, 1), 5, "five and a bot: a chair for a sixth");
assert_eq!(chairs_taken(2, 1), 3, "a bot that will sit is counted");
}
fn seat_plan(peers: &PeerBook) -> Vec<Option<u8>> {
peers.seat_plan(peers.len())
}
fn table(n: usize, watchers: &[usize]) -> PeerBook {
let names = vec![""; n];
peers_named(&names, watchers)
}
#[test]
fn the_host_wears_the_name_the_wire_gives_it() {
let peers = peers_named(&["Bo"], &[]);
let wide = "あ".repeat(12);
let names = seat_names(&wide, &seat_plan(&peers), &peers, &[]);
assert_eq!(names[0], "あ".repeat(8));
assert_eq!(
names[0],
crate::transport::table_from_wire(&crate::transport::wire_table(&names))[0],
"what the host shows is what the joiners decode"
);
assert_eq!(names[1], "Bo");
}
#[test]
fn the_table_is_dealt_in_arrival_order_behind_the_host() {
assert_eq!(seat_plan(&table(0, &[])), Vec::<Option<u8>>::new());
assert_eq!(
seat_plan(&table(1, &[])),
vec![Some(1)],
"the host keeps zero"
);
assert_eq!(seat_plan(&table(3, &[])), vec![Some(1), Some(2), Some(3)]);
}
#[test]
fn watchers_are_stepped_over_and_the_seats_close_up() {
assert_eq!(seat_plan(&table(3, &[0])), vec![None, Some(1), Some(2)]);
assert_eq!(seat_plan(&table(3, &[1])), vec![Some(1), None, Some(2)]);
assert_eq!(seat_plan(&table(3, &[0, 1, 2])), vec![None, None, None]);
}
#[test]
fn hosting_asks_before_it_announces() {
assert_eq!(host_step(idle()), HostStep::Nothing);
assert_eq!(
host_step(HostAsk {
pressed_h: true,
..idle()
}),
HostStep::Ask
);
assert_eq!(
host_step(HostAsk {
answered: true,
..idle()
}),
HostStep::Go
);
assert_eq!(
host_step(HostAsk {
pressed_h: true,
busy: true,
..idle()
}),
HostStep::Nothing
);
assert_eq!(
host_step(HostAsk {
busy: true,
..idle()
}),
HostStep::Nothing
);
assert_eq!(
host_step(HostAsk {
auto: true,
..idle()
}),
HostStep::Go
);
assert_eq!(
host_step(HostAsk {
pressed_h: true,
auto: true,
..idle()
}),
HostStep::Go
);
}
#[test]
fn the_table_runs_out_of_chairs_before_the_socket_runs_out_of_peers() {
use crate::sim::MAX_PLAYERS;
assert_eq!(
seat_plan(&table(4, &[])),
vec![Some(1), Some(2), Some(3), Some(4)]
);
assert_eq!(
seat_plan(&table(4, &[1, 3])),
vec![Some(1), None, Some(2), None]
);
let full = seat_plan(&table(crate::transport::MAX_PEERS, &[]));
assert_eq!(
full.iter().filter(|seat| seat.is_some()).count(),
MAX_PLAYERS - 1,
"the host holds the sixth chair"
);
assert!(
full.iter()
.flatten()
.all(|&seat| usize::from(seat) < MAX_PLAYERS),
"no peer is given a seat the sim has no slot for"
);
assert!(full.last().unwrap().is_none(), "the late ones watch");
}
#[test]
fn a_half_written_sentence_does_not_start_the_match() {
assert!(should_launch(1, false, true, None));
assert!(
!should_launch(1, false, false, None),
"and nothing without it"
);
assert!(
!should_launch(0, false, true, None),
"nor with an empty table"
);
assert!(!should_launch(1, true, true, None));
assert!(!should_launch(3, true, true, None));
assert!(should_launch(2, false, false, Some(2)));
assert!(should_launch(2, true, false, Some(2)));
assert!(!should_launch(1, false, false, Some(2)), "not filled yet");
}
#[test]
fn a_peer_asked_to_leave_is_told_and_forgotten() {
let mut state = LobbyState {
standing: hosting_lobby(),
..LobbyState::default()
};
let port = state
.hosted()
.expect("hosting")
.transport
.local_addr()
.expect("addr")
.port();
let mut bot = UdpTransport::join(("127.0.0.1", port)).expect("join");
bot.send(NetMsg::hello_bot("Greedy (Ana)"));
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
let hosted = state.hosted_mut().expect("hosting");
let welcome = crate::transport::OnAir {
bots: true,
full: false,
..crate::transport::OnAir::default()
};
let picked = work_the_socket(hosted, 0.0, welcome);
for (from, told) in picked.greeted {
hosted.peers.row(from).name = told;
}
if hosted
.peers
.get(0)
.is_some_and(|peer| peer.name == "Greedy (Ana)")
{
break;
}
}
assert!(
state
.hosted()
.expect("hosting")
.peers
.get(0)
.is_some_and(|p| p.bot),
"the greeting said a bot drives this seat"
);
assert_eq!(
state.roster_kinds(),
vec![crate::sim::SeatKind::Human, crate::sim::SeatKind::Bot]
);
ask_to_leave(
&mut state,
&mut crate::app::bot_seats::BotSeats::default(),
&crate::app::i18n::EN,
2,
);
assert_eq!(state.hosted().expect("hosting").peers.len(), 0, "forgotten");
assert!(
state
.chat
.iter()
.any(|said| said.line.contains("asked to leave"))
);
let mut told = false;
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
told |= bot
.recv_all()
.into_iter()
.any(|(msg, _)| msg == NetMsg::Kicked);
if told {
break;
}
}
assert!(told, "the peer was told why");
}
#[test]
fn a_joiner_that_says_goodbye_is_let_go_at_once() {
let mut state = LobbyState {
standing: hosting_lobby(),
..LobbyState::default()
};
let port = state
.hosted()
.expect("hosting")
.transport
.local_addr()
.expect("addr")
.port();
let bo = UdpTransport::join(("127.0.0.1", port)).expect("join");
bo.send(NetMsg::hello("Bo"));
let tick = |state: &mut LobbyState| {
std::thread::sleep(std::time::Duration::from_millis(5));
let hosted = state.hosted_mut().expect("hosting");
let picked = work_the_socket(hosted, 0.0, crate::transport::OnAir::default());
note_arrivals_and_departures(state, &crate::app::i18n::EN, picked.greeted);
};
for _ in 0..40 {
tick(&mut state);
if state.hosted().expect("hosting").players_aboard() == 1 {
break;
}
}
assert_eq!(state.hosted().expect("hosting").players_aboard(), 1);
drop(bo);
for _ in 0..40 {
tick(&mut state);
if state.hosted().expect("hosting").peers.is_empty() {
break;
}
}
let hosted = state.hosted().expect("hosting");
assert_eq!(hosted.transport.peer_count(), 0, "let go at once");
assert!(hosted.peers.is_empty());
assert!(
state.chat.iter().any(|said| said.line == "Bo left"),
"and the table is told: {:?}",
state.chat
);
}
#[test]
fn a_bot_is_not_seated_where_bots_are_not_welcome() {
let mut state = LobbyState {
standing: hosting_lobby(),
..LobbyState::default()
};
let port = state
.hosted()
.expect("hosting")
.transport
.local_addr()
.expect("addr")
.port();
let mut bot = UdpTransport::join(("127.0.0.1", port)).expect("join");
let person = UdpTransport::join(("127.0.0.1", port)).expect("join");
bot.send(NetMsg::hello_bot("Greedy (Ana)"));
std::thread::sleep(std::time::Duration::from_millis(20));
person.send(NetMsg::hello("Bo"));
let unwelcome = crate::transport::OnAir {
bots: false,
full: false,
..crate::transport::OnAir::default()
};
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
let hosted = state.hosted_mut().expect("hosting");
let picked = work_the_socket(hosted, 0.0, unwelcome);
for (from, told) in picked.greeted {
hosted.peers.row(from).name = told;
}
if hosted.peers.get(0).is_some_and(|peer| peer.name == "Bo") {
break;
}
}
let hosted = state.hosted().expect("hosting");
assert_eq!(hosted.peers.len(), 1, "only the person is at the table");
assert_eq!(hosted.transport.peer_count(), 1);
assert!(
hosted
.peers
.get(0)
.is_some_and(|p| p.name == "Bo" && !p.bot)
);
let mut told = false;
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
told |= bot
.recv_all()
.into_iter()
.any(|(msg, _)| msg == NetMsg::NoBots);
if told {
break;
}
}
assert!(told, "the bot was told to go, and why");
}
#[test]
fn a_bot_at_the_door_sent_away_by_the_dial_is_named_too() {
use crate::app::bot_seats::test_bots::{register, seated};
let tr = &crate::app::i18n::EN;
let mut bots = crate::app::bot_seats::BotSeats::default();
let mut config = MatchConfig::default();
let mut sent_away = Vec::new();
host_the_bots(&mut bots, &config, tr, 0, &mut sent_away);
let door = bots.door.as_mut().expect("the door opened");
let string = door.open_string().expect("a chair waits");
let _bot = register(&string, "Greedy", "Ana");
seated(door, 0);
config.bots_welcome = false;
host_the_bots(&mut bots, &config, tr, 0, &mut sent_away);
assert!(bots.door.is_none(), "the door shut");
assert_eq!(sent_away, ["Greedy (Ana)"], "and the bot was named");
}
#[test]
fn a_bot_sent_away_by_the_dial_is_named_to_the_room() {
let mut state = LobbyState {
standing: hosting_lobby(),
..LobbyState::default()
};
let port = state
.hosted()
.expect("hosting")
.transport
.local_addr()
.expect("addr")
.port();
let bot = UdpTransport::join(("127.0.0.1", port)).expect("join");
let on_air = |bots| crate::transport::OnAir {
bots,
full: false,
..crate::transport::OnAir::default()
};
bot.send(NetMsg::hello_bot("Greedy (Ana)"));
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
let hosted = state.hosted_mut().expect("hosting");
let picked = work_the_socket(hosted, 0.0, on_air(true));
for (from, told) in picked.greeted {
hosted.peers.row(from).name = told;
}
if hosted
.peers
.get(0)
.is_some_and(|peer| !peer.name.is_empty())
{
break;
}
}
bot.send(NetMsg::hello_bot("Greedy (Ana)"));
let mut sent_away = Vec::new();
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
let hosted = state.hosted_mut().expect("hosting");
sent_away.extend(work_the_socket(hosted, 0.0, on_air(false)).sent_away);
if !sent_away.is_empty() {
break;
}
}
assert_eq!(sent_away, ["Greedy (Ana)"]);
assert_eq!(state.hosted().expect("hosting").peers.len(), 0);
}
#[test]
fn a_peer_speaks_under_the_name_it_greeted_with() {
let mut state = LobbyState {
standing: hosting_lobby(),
..LobbyState::default()
};
let port = state
.hosted()
.expect("hosting")
.transport
.local_addr()
.expect("addr")
.port();
let bo = UdpTransport::join(("127.0.0.1", port)).expect("join");
bo.send(NetMsg::hello("Bo"));
let mut said = Vec::new();
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
let hosted = state.hosted_mut().expect("hosting");
let picked = work_the_socket(hosted, 0.0, crate::transport::OnAir::default());
for (from, told) in picked.greeted {
hosted.peers.row(from).name = told;
}
said.extend(picked.said);
if state
.hosted()
.expect("hosting")
.peers
.get(0)
.is_some_and(|peer| peer.name == "Bo")
{
break;
}
}
assert_eq!(
state
.hosted()
.expect("hosting")
.peers
.get(0)
.map(|p| p.name.as_str()),
Some("Bo"),
"the greeting is on file"
);
bo.send(NetMsg::chat("Bo", "ready?"));
bo.send(NetMsg::chat("Anna", "I am Anna, honest"));
bo.send(NetMsg::chat("", "Anna has left the beach"));
said.clear();
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
let hosted = state.hosted_mut().expect("hosting");
said.extend(work_the_socket(hosted, 0.0, crate::transport::OnAir::default()).said);
if said.len() >= 2 {
break;
}
}
let heard: Vec<(String, String)> = said
.iter()
.map(|(_, name, text)| {
(
crate::transport::name_from_wire(name),
crate::transport::chat_from_wire(text),
)
})
.collect();
assert_eq!(
heard,
[
("Bo".to_string(), "ready?".to_string()),
("Bo".to_string(), "I am Anna, honest".to_string()),
("Bo".to_string(), "Anna has left the beach".to_string()),
],
"every line under the name Bo greeted with: no rival's name to \
wear, and no forging the room's"
);
}
#[test]
fn a_late_arrival_is_shown_what_the_room_already_said() {
let mut state = LobbyState {
standing: hosting_lobby(),
..LobbyState::default()
};
state.say("Ann", "anyone up for a round?");
state.say("", "Bo joined");
let port = state
.hosted()
.expect("hosting")
.transport
.local_addr()
.expect("addr")
.port();
let mut latecomer = UdpTransport::join(("127.0.0.1", port)).expect("join");
latecomer.send(NetMsg::hello("Cy"));
let mut greeted = Vec::new();
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
let hosted = state.hosted_mut().expect("hosting");
greeted
.extend(work_the_socket(hosted, 0.0, crate::transport::OnAir::default()).greeted);
if !greeted.is_empty() {
break;
}
}
assert_eq!(greeted, [(0, "Cy".to_string())], "Cy is at the door");
note_arrivals_and_departures(&mut state, &crate::app::i18n::EN, greeted);
let mut heard = Vec::new();
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
for (msg, _) in latecomer.recv_all() {
if let NetMsg::Chat { name, text } = msg {
heard.push((
crate::transport::name_from_wire(&name),
crate::transport::chat_from_wire(&text),
));
}
}
if heard.len() >= 3 {
break;
}
}
assert_eq!(
heard,
[
("Ann".to_string(), "anyone up for a round?".to_string()),
(String::new(), "Bo joined".to_string()),
(String::new(), "Cy joined".to_string()),
],
"the room as it stands, oldest first, and then their own arrival"
);
}
#[test]
fn a_spectator_is_announced_by_name_and_then_held_to_it() {
let mut state = LobbyState {
standing: hosting_lobby(),
..LobbyState::default()
};
let port = state
.hosted()
.expect("hosting")
.transport
.local_addr()
.expect("addr")
.port();
let watcher = UdpTransport::join(("127.0.0.1", port)).expect("join");
watcher.send(NetMsg::watch("Dee"));
let mut greeted = Vec::new();
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
let hosted = state.hosted_mut().expect("hosting");
greeted
.extend(work_the_socket(hosted, 0.0, crate::transport::OnAir::default()).greeted);
if !greeted.is_empty() {
break;
}
}
assert_eq!(
greeted,
[(0, "Dee".to_string())],
"a spectator says what to call it, like everybody else"
);
note_arrivals_and_departures(&mut state, &crate::app::i18n::EN, greeted);
assert!(
state.chat.iter().any(|said| said.line == "Dee is watching"),
"and the room is told, in the words for watching rather than \
for taking a chair: {:?}",
state.chat
);
watcher.send(NetMsg::chat("", "Anna has left the beach"));
watcher.send(NetMsg::chat("Cy", "good luck!"));
let mut said = Vec::new();
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
let hosted = state.hosted_mut().expect("hosting");
said.extend(work_the_socket(hosted, 0.0, crate::transport::OnAir::default()).said);
if !said.is_empty() {
break;
}
}
let heard: Vec<(String, String)> = said
.iter()
.map(|(_, name, text)| {
(
crate::transport::name_from_wire(name),
crate::transport::chat_from_wire(text),
)
})
.collect();
assert_eq!(
heard,
[
("Dee".to_string(), "Anna has left the beach".to_string()),
("Dee".to_string(), "good luck!".to_string()),
],
"both lines under the name it greeted with: no rival's name to \
wear, and the room's own voice no longer on offer either, \
which it was for as long as a spectator had no name to give \
back"
);
}
#[test]
fn a_new_match_calls_in_a_joiner_still_on_the_last_card() {
use bevy::ecs::system::RunSystemOnce;
let mut state = LobbyState {
standing: hosting_lobby(),
..LobbyState::default()
};
let hosted = state.hosted_mut().expect("hosting");
let port = hosted.transport.local_addr().expect("addr").port();
let joiner = UdpTransport::join(("127.0.0.1", port)).expect("join");
joiner.send(NetMsg::hello("Bo"));
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
let _ = hosted.transport.recv_all();
if hosted.transport.peer_count() == 1 {
break;
}
}
hosted.peers = peers_named(&["Bo"], &[]);
hosted.rounds_dealt = 3;
let mut on_the_card = OnlineSession::new(
joiner,
crate::app::net::lockstep_for(Some(1), (0..2).collect(), crate::sim::DEFAULT_DELAY),
2,
MatchTerms::default(),
);
on_the_card.rounds_dealt = 3;
let mut app = App::new();
app.insert_resource(GameSettings::default());
app.init_resource::<MatchConfig>();
app.init_resource::<crate::app::match_setup::CustomBeaches>();
app.init_resource::<Online>();
app.init_resource::<crate::app::tournament::Tournament>();
app.init_resource::<crate::app::bot_seats::BotSeats>();
app.init_resource::<NextState<Screen>>();
app.init_resource::<NextState<VersusPhase>>();
app.insert_resource(state);
app.world_mut()
.run_system_once(
|mut state: ResMut<LobbyState>,
settings: Res<GameSettings>,
config: Res<MatchConfig>,
beaches: Res<crate::app::match_setup::CustomBeaches>,
mut arena: IntoArena| {
launch_the_match(
&mut state, &settings, &config, &beaches, 1, &mut arena, None,
);
},
)
.expect("the launch ran");
let mut start = None;
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
start = on_the_card
.transport
.recv_all()
.into_iter()
.find_map(|(msg, _)| {
let NetMsg::Start(start) = msg else {
return None;
};
Some(start)
});
if start.is_some() {
break;
}
}
let start = start.expect("the launch sent the joiner its place");
assert!(
on_the_card.is_next_round(&start),
"the new match's first round, dealt {}, is after the card's {}",
start.deal,
on_the_card.rounds_dealt
);
let stale = Invitation {
deal: 3,
..start.clone()
};
assert!(!on_the_card.is_next_round(&stale));
}
}