use super::OnlineSession;
use super::peers::Place;
use super::rounds::Invitation;
use crate::sim::{Board, Lockstep};
use crate::transport::{CATCH_UP_CHUNK, CATCH_UP_PARTS, NetMsg, UdpTransport};
#[derive(Default)]
pub struct CatchUp {
pub board: Option<Board>,
pub(crate) from_frame: Option<u32>,
pub(super) owed: Vec<usize>,
}
impl OnlineSession {
pub fn caught_up(transport: UdpTransport, caught: CaughtUp) -> OnlineSession {
let CaughtUp {
invitation,
frame,
board,
} = caught;
let humans = invitation.terms.humans(invitation.seats);
let mut session = OnlineSession::invited(transport, invitation);
session.session =
Lockstep::observer_from((0..humans).collect(), crate::sim::DEFAULT_DELAY, frame);
session.stall.reset(frame);
session.catch_up.board = Some(board);
session.catch_up.from_frame = Some(frame);
session
}
pub(super) fn late_watcher(&self, peer: usize) -> bool {
self.home.from_lobby && self.queue_place(peer).is_some()
}
pub fn send_catch_ups(&mut self, board: &Board) {
if self.catch_up.owed.is_empty() {
return;
}
let frame = self.session.frame();
if board.round_over() || board.ticks() != u64::from(frame) {
self.catch_up.owed.clear();
return;
}
let parts = pack(&self.start_msg(None), frame, board);
for peer in std::mem::take(&mut self.catch_up.owed) {
if parts.is_empty() {
self.peers.row(peer).place = Place::Queued;
let name = self
.peers
.get(peer)
.map(|p| p.name.clone())
.unwrap_or_default();
let answer = self.answer_greeting(peer, &name, true);
self.transport.send_to(peer, answer);
continue;
}
for part in &parts {
self.transport.send_to(peer, part.clone());
}
}
}
}
pub fn pack(start: &NetMsg, frame: u32, board: &Board) -> Vec<NetMsg> {
let invitation = start.clone().encode();
let Ok(len) = u16::try_from(invitation.len()) else {
return Vec::new();
};
let mut payload = len.to_le_bytes().to_vec();
payload.extend_from_slice(&invitation);
payload.extend_from_slice(board.to_snapshot().as_bytes());
let packed = crate::lzw::compress(&payload, 8);
let pieces: Vec<&[u8]> = packed.chunks(CATCH_UP_CHUNK).collect();
let Ok(parts) = u8::try_from(pieces.len()) else {
return Vec::new();
};
if parts > CATCH_UP_PARTS {
return Vec::new();
}
pieces
.into_iter()
.enumerate()
.map(|(part, piece)| NetMsg::CatchUp {
frame,
part: part as u8,
parts,
bytes: piece.to_vec(),
})
.collect()
}
pub struct CaughtUp {
pub invitation: Invitation,
pub frame: u32,
pub board: Board,
}
#[derive(Default)]
pub struct Assembly {
frame: Option<u32>,
parts: Vec<Option<Vec<u8>>>,
}
impl Assembly {
pub fn take(&mut self, frame: u32, part: u8, parts: u8, bytes: Vec<u8>) -> Option<CaughtUp> {
match self.frame {
Some(current) if frame < current => return None,
Some(current) if frame == current && self.parts.len() == usize::from(parts) => {}
_ => {
self.frame = Some(frame);
self.parts = vec![None; usize::from(parts)];
}
}
*self.parts.get_mut(usize::from(part))? = Some(bytes);
if self.parts.iter().any(Option::is_none) {
return None;
}
let packed: Vec<u8> = std::mem::take(&mut self.parts)
.into_iter()
.flatten()
.flatten()
.collect();
self.frame = None;
unpack(&packed, frame)
}
}
fn unpack(packed: &[u8], frame: u32) -> Option<CaughtUp> {
let payload = crate::lzw::decompress(packed, 8)?;
let len = usize::from(u16::from_le_bytes(payload.get(..2)?.try_into().ok()?));
let start = NetMsg::decode(payload.get(2..2 + len)?)?;
let board = Board::parse_snapshot(std::str::from_utf8(payload.get(2 + len..)?).ok()?).ok()?;
if board.ticks() != u64::from(frame) {
return None;
}
let NetMsg::Start {
seats,
seat: None,
terms,
names,
kinds,
standing,
beach,
} = start
else {
return None;
};
Some(CaughtUp {
invitation: Invitation {
seats,
seat: None,
terms,
names,
kinds,
standing,
beach,
},
frame,
board,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sim::{BotLevel, MAX_PLAYERS, PlayerAction, bot_action, generate_arena};
use crate::transport::{MatchTerms, wire_name};
fn start() -> NetMsg {
NetMsg::Start {
seats: 6,
seat: None,
terms: MatchTerms::default(),
names: [wire_name("WWWWWWWWWWWWWWWWWWWWWWWW"); MAX_PLAYERS],
kinds: [0; MAX_PLAYERS],
standing: None,
beach: Vec::new(),
}
}
fn crowded() -> Board {
let mut board = generate_arena(5, 6, 20, 13);
for _ in 0..2400 {
let actions: [PlayerAction; MAX_PLAYERS] =
std::array::from_fn(|seat| bot_action(&board, seat as u8, BotLevel::Hard));
board.tick(&actions);
}
board
}
fn pieces(parts: &[NetMsg]) -> Vec<(u32, u8, u8, Vec<u8>)> {
parts
.iter()
.map(|msg| {
let NetMsg::CatchUp {
frame,
part,
parts,
bytes,
} = msg
else {
panic!("not a part: {msg:?}");
};
(*frame, *part, *parts, bytes.clone())
})
.collect()
}
#[test]
fn a_crowded_round_survives_the_trip_in_parts() {
let board = crowded();
let frame = board.ticks() as u32;
let parts = pack(&start(), frame, &board);
assert!(!parts.is_empty());
assert!(
parts.len() <= usize::from(CATCH_UP_PARTS) / 4,
"{} parts: well inside the bound",
parts.len()
);
let wired: Vec<NetMsg> = parts
.into_iter()
.map(|msg| NetMsg::decode(&msg.encode()).expect("decodes"))
.collect();
let mut assembly = Assembly::default();
let mut whole = None;
for (frame, part, parts, bytes) in pieces(&wired).into_iter().rev() {
assert!(whole.is_none(), "complete before the last part");
whole = assembly.take(frame, part, parts, bytes);
}
let caught = whole.expect("put back together");
assert_eq!(caught.frame, frame);
assert_eq!(caught.board.state_hash(), board.state_hash());
assert_eq!(caught.invitation.seats, 6);
assert_eq!(caught.invitation.seat, None);
}
#[test]
fn parts_of_two_snapshots_are_never_mixed() {
let mut board = crowded();
let old = pieces(&pack(&start(), board.ticks() as u32, &board));
board.tick(&[PlayerAction::None; MAX_PLAYERS]);
let new = pieces(&pack(&start(), board.ticks() as u32, &board));
assert!(old.len() > 1, "a snapshot in several parts");
let mut assembly = Assembly::default();
let (o_frame, o_part, o_parts, o_bytes) = old[0].clone();
assert!(assembly.take(o_frame, o_part, o_parts, o_bytes).is_none());
let mut whole = None;
for (i, (frame, part, parts, bytes)) in new.iter().cloned().enumerate() {
if i == 1 {
let (f, p, n, b) = old[1].clone();
assert!(assembly.take(f, p, n, b).is_none(), "a leftover is ignored");
}
whole = assembly.take(frame, part, parts, bytes);
}
let caught = whole.expect("the newer snapshot completes");
assert_eq!(caught.board.state_hash(), board.state_hash());
}
#[test]
fn a_snapshot_that_does_not_add_up_is_refused() {
let board = crowded();
let frame = board.ticks() as u32;
let seated = NetMsg::Start {
seats: 6,
seat: Some(1),
terms: MatchTerms::default(),
names: [wire_name(""); MAX_PLAYERS],
kinds: [0; MAX_PLAYERS],
standing: None,
beach: Vec::new(),
};
for (msg, at) in [(seated, frame), (start(), frame + 1)] {
let mut assembly = Assembly::default();
let mut whole = None;
for (_, part, parts, bytes) in pieces(&pack(&msg, frame, &board)) {
whole = assembly.take(at, part, parts, bytes);
}
assert!(whole.is_none());
}
}
}
#[cfg(test)]
mod session_tests {
use super::super::*;
use super::*;
use crate::sim::{DEFAULT_DELAY, InputMsg, generate_arena};
fn drain(socket: &mut UdpTransport) -> Vec<NetMsg> {
let mut heard = Vec::new();
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
heard.extend(socket.recv_all().into_iter().map(|(msg, _)| msg));
}
heard
}
#[test]
fn a_watcher_greeting_mid_round_is_sent_the_round_and_a_player_is_queued() {
let mut host = OnlineSession::new(
UdpTransport::host(0).expect("host socket"),
Lockstep::new(0, vec![0, 1], DEFAULT_DELAY),
2,
MatchTerms::default(),
);
host.home.from_lobby = true;
let port = host.transport.local_addr().expect("addr").port();
let bo = UdpTransport::join(("127.0.0.1", port)).expect("join");
bo.send(NetMsg::hello("Bo"));
drain(&mut host.transport);
host.peers.deal(&[Some(1)]);
let mut board = generate_arena(3, 2, 12, 9);
for frame in 0..60 {
host.session.commit_local(PlayerAction::None);
host.session.receive(InputMsg {
player: 1,
frame: DEFAULT_DELAY + frame,
action: PlayerAction::None,
});
while let Some(actions) = host.session.advance() {
board.tick(&actions);
}
}
assert!(host.session.frame() > 50);
let mut dee = UdpTransport::join(("127.0.0.1", port)).expect("join");
dee.send(NetMsg::watch("Dee"));
let mut cy = UdpTransport::join(("127.0.0.1", port)).expect("join");
std::thread::sleep(std::time::Duration::from_millis(20));
cy.send(NetMsg::hello("Cy"));
std::thread::sleep(std::time::Duration::from_millis(20));
host.pump(PlayerAction::None, |_| {});
host.send_catch_ups(&board);
assert_eq!(host.peers.planned(), 1, "the plan is still the launch's");
assert_eq!(
host.peers.get(1).map(|p| p.place),
Some(Place::LateWatching)
);
assert!(
host.peers.follows_the_round(1),
"Dee is sent the round from now on"
);
assert!(host.watching_this_round(1), "and counts in the crowd");
assert!(!host.peers.follows_the_round(2), "Cy, in line, is not");
let mut assembly = Assembly::default();
let caught = drain(&mut dee)
.into_iter()
.find_map(|msg| {
let NetMsg::CatchUp {
frame,
part,
parts,
bytes,
} = msg
else {
return None;
};
assembly.take(frame, part, parts, bytes)
})
.expect("the round arrived whole");
assert_eq!(caught.frame, host.session.frame());
assert_eq!(caught.board.state_hash(), board.state_hash());
assert_eq!(caught.invitation.seat, None);
let session = OnlineSession::caught_up(dee, caught);
assert!(session.watching());
assert_eq!(session.session.frame(), host.session.frame());
assert!(session.catch_up.board.is_some());
let queued = drain(&mut cy)
.into_iter()
.any(|msg| matches!(msg, NetMsg::Queued { .. }));
assert!(queued, "a player arriving mid-round waits for the next one");
}
#[test]
fn a_lobby_table_catches_up_whoever_came_late_however_small() {
let mut host = OnlineSession::new(
UdpTransport::host(0).expect("host socket"),
Lockstep::new(0, vec![0], DEFAULT_DELAY),
2,
MatchTerms::default(),
);
assert!(!host.late_watcher(0), "the direct pair");
host.home.from_lobby = true;
assert_eq!(host.peers.planned(), 0, "nobody else sat down");
assert!(host.late_watcher(0), "the host alone with the AI");
host.peers.deal(&[Some(1)]);
assert!(!host.late_watcher(0), "one at the launch is not late");
assert!(host.late_watcher(1));
}
#[test]
fn a_finished_round_is_not_sent_to_anybody() {
let mut host = OnlineSession::new(
UdpTransport::host(0).expect("host socket"),
Lockstep::new(0, vec![0], DEFAULT_DELAY),
2,
MatchTerms::default(),
);
host.home.from_lobby = true;
let port = host.transport.local_addr().expect("addr").port();
let mut dee = UdpTransport::join(("127.0.0.1", port)).expect("join");
dee.send(NetMsg::watch("Dee"));
std::thread::sleep(std::time::Duration::from_millis(20));
host.pump(PlayerAction::None, |_| {});
let mut board = generate_arena(3, 2, 12, 9);
board.set_round_length(Some(0));
assert!(board.round_over());
host.send_catch_ups(&board);
assert!(host.catch_up.owed.is_empty());
let sent = drain(&mut dee)
.into_iter()
.any(|msg| matches!(msg, NetMsg::CatchUp { .. }));
assert!(!sent, "nothing to watch, nothing sent");
}
#[test]
fn a_watcher_stuck_on_its_first_frame_asks_again() {
use crate::app::Screen;
for stuck in [true, false] {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.init_resource::<Time>();
app.init_resource::<crate::app::lobby::Homecoming>();
let mut session = OnlineSession::new(
UdpTransport::join(("127.0.0.1", 47998)).expect("join"),
Lockstep::observer_from(vec![0, 1], DEFAULT_DELAY, 400),
2,
MatchTerms::default(),
);
session.catch_up.from_frame = Some(if stuck { 400 } else { 399 });
app.insert_resource(Online(Some(session)));
app.add_systems(Update, catch_up_again);
for _ in 0..4 {
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_secs(1));
app.update();
}
let gone = app.world().resource::<Online>().0.is_none();
assert_eq!(gone, stuck);
assert_eq!(
app.world()
.resource::<crate::app::lobby::Homecoming>()
.0
.is_some(),
stuck
);
}
}
}