use super::*;
use crate::sim::InputMsg;
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Start {
pub seats: u8,
pub seat: Option<u8>,
pub terms: MatchTerms,
pub names: [WireName; crate::sim::MAX_PLAYERS],
pub kinds: [u8; crate::sim::MAX_PLAYERS],
pub standing: Option<SeriesStanding>,
pub beach: Vec<u8>,
pub deal: u32,
pub crowned: u8,
pub delay: u8,
}
#[cfg(test)]
impl Start {
pub fn blank() -> Start {
Start {
seats: 2,
seat: None,
terms: MatchTerms::default(),
names: [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS],
kinds: [0; crate::sim::MAX_PLAYERS],
standing: None,
beach: Vec::new(),
deal: 0,
crowned: 0,
delay: crate::sim::DEFAULT_DELAY as u8,
}
}
}
pub fn crowned_bits(crowned: &[bool; crate::sim::MAX_PLAYERS]) -> u8 {
crowned
.iter()
.enumerate()
.filter(|(_, won)| **won)
.fold(0, |bits, (seat, _)| bits | 1 << seat)
}
pub fn is_crowned(bits: u8, seat: u8) -> bool {
bits & 1u8.checked_shl(u32::from(seat)).unwrap_or(0) != 0
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum NetMsg {
Hello { name: WireName, bot: bool },
Watch { name: WireName },
Inputs(Batch),
Hash { round: u8, frame: u32, hash: u64 },
Start(Start),
Pause { round: u8, frame: u32 },
Resume { round: u8, frame: u32 },
Abandoned { round: u8, seat: u8, frame: u32 },
Roster {
seats: u8,
names: [WireName; crate::sim::MAX_PLAYERS],
terms: MatchTerms,
kinds: [u8; crate::sim::MAX_PLAYERS],
beach: WireName,
chair: Option<bool>,
picking: bool,
},
Kicked,
NoBots,
Full,
Leaving,
Chat { name: WireName, text: WireChat },
SpectatorTally { watching: u8, open: u8, wait: u8 },
SpectatorVote { event: u8 },
SpectatorPick { seat: u8 },
CrowdPicks {
picks: [u8; crate::sim::MAX_PLAYERS],
open: u8,
},
Queued { ahead: u8 },
CatchUp {
frame: u32,
part: u8,
parts: u8,
bytes: Vec<u8>,
},
Incompatible { version: u8 },
Ping { stamp: u32 },
Pong { stamp: u32 },
Clock { gap: i16 },
}
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct Batch {
pub round: u8,
pub inputs: Vec<InputMsg>,
pub wants: Option<[u32; crate::sim::MAX_PLAYERS]>,
pub pause: Option<u32>,
pub resume: Option<u32>,
pub abandoned: Vec<(u8, u32)>,
pub clock: Option<i16>,
}
pub fn round_tag(rounds_dealt: u32) -> u8 {
rounds_dealt.to_le_bytes()[0]
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct SeriesStanding {
pub round: u8,
pub wins: [u8; crate::sim::MAX_PLAYERS],
}
impl SeriesStanding {
pub fn opening() -> SeriesStanding {
SeriesStanding {
round: 1,
wins: [0; crate::sim::MAX_PLAYERS],
}
}
}
impl NetMsg {
pub fn hello(name: &str) -> NetMsg {
NetMsg::Hello {
name: wire_name(name),
bot: false,
}
}
pub fn hello_bot(name: &str) -> NetMsg {
NetMsg::Hello {
name: wire_name(name),
bot: true,
}
}
pub fn greets(&self) -> bool {
matches!(self, NetMsg::Hello { .. } | NetMsg::Watch { .. })
}
pub fn watch(name: &str) -> NetMsg {
NetMsg::Watch {
name: wire_name(name),
}
}
pub fn ping() -> NetMsg {
NetMsg::Ping {
stamp: ping_stamp(),
}
}
pub fn chat(who: &str, line: &str) -> NetMsg {
NetMsg::Chat {
name: wire_name(who),
text: wire_chat(line),
}
}
}
const TAG_HELLO: u8 = 0;
const TAG_RETIRED_INPUT: u8 = 1;
const TAG_HASH: u8 = 2;
const TAG_START: u8 = 3;
const TAG_PAUSE: u8 = 4;
const TAG_RESUME: u8 = 5;
const TAG_WATCH: u8 = 6;
const TAG_INCOMPATIBLE: u8 = 7;
const TAG_QUEUED: u8 = 8;
const TAG_CHAT: u8 = 9;
const TAG_ROSTER: u8 = 10;
const TAG_ABANDONED: u8 = 11;
const TAG_INPUTS: u8 = 12;
const TAG_SPECTATOR_VOTE: u8 = 13;
const TAG_SPECTATOR_TALLY: u8 = 14;
const TAG_CATCH_UP: u8 = 15;
const TAG_SPECTATOR_PICK: u8 = 16;
const TAG_CROWD_PICKS: u8 = 17;
const TAG_KICKED: u8 = 18;
const TAG_LEAVING: u8 = 19;
const TAG_NO_BOTS: u8 = 20;
const TAG_PING: u8 = 21;
const TAG_PONG: u8 = 22;
const TAG_CLOCK: u8 = 23;
const TAG_FULL: u8 = 24;
const TAG_RETIRED_NO_CHAIR: u8 = 25;
const HIGHEST_TAG: u8 = TAG_RETIRED_NO_CHAIR;
const CARRIES_WANTS: u8 = 1;
const CARRIES_PAUSE: u8 = 2;
const CARRIES_RESUME: u8 = 4;
const CARRIES_CLOCK: u8 = 8;
const CARRIES_ABANDONED: u8 = 16;
const CARRIES_ANY: u8 =
CARRIES_WANTS | CARRIES_PAUSE | CARRIES_RESUME | CARRIES_CLOCK | CARRIES_ABANDONED;
const RUN_HEAD: usize = 6;
const ACTION_BYTES: usize = 3;
const RUN_MOST: usize = u8::MAX as usize;
pub const CATCH_UP_CHUNK: usize = 900;
pub const CATCH_UP_PARTS: u8 = 32;
const SPECTATOR_SEAT: u8 = u8::MAX;
pub fn kind_byte(kind: crate::sim::SeatKind) -> u8 {
match kind {
crate::sim::SeatKind::Human => 0,
crate::sim::SeatKind::Ai => 1,
crate::sim::SeatKind::Bot => 2,
}
}
pub fn wire_kinds(kinds: &[crate::sim::SeatKind]) -> [u8; crate::sim::MAX_PLAYERS] {
let mut out = [0u8; crate::sim::MAX_PLAYERS];
for (slot, kind) in out.iter_mut().zip(kinds) {
*slot = kind_byte(*kind);
}
out
}
pub fn kind_from_byte(byte: u8) -> crate::sim::SeatKind {
match byte {
1 => crate::sim::SeatKind::Ai,
2 => crate::sim::SeatKind::Bot,
_ => crate::sim::SeatKind::Human,
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct MatchTerms {
pub bots: u8,
pub bot_level: u8,
pub map: u8,
pub gulls: u8,
pub round: u8,
pub teams: u8,
pub seed: u64,
pub series: u8,
pub posts: u8,
pub order: [u8; crate::sim::MAX_PLAYERS],
}
pub const IN_SEAT_ORDER: [u8; crate::sim::MAX_PLAYERS] = [0, 1, 2, 3, 4, 5];
impl Default for MatchTerms {
fn default() -> Self {
MatchTerms {
bots: 0,
bot_level: 0,
map: 0,
gulls: 0,
round: 0,
teams: 0,
seed: 0,
series: 0,
posts: crate::sim::MAX_SIGNPOSTS_PER_PLAYER as u8,
order: IN_SEAT_ORDER,
}
}
}
impl MatchTerms {
pub fn is_series(self) -> bool {
self.series != 0
}
pub fn humans(self, seats: u8) -> u8 {
seats.saturating_sub(self.bots).max(1)
}
pub fn seat_of_place(self, place: u8) -> u8 {
self.order.get(usize::from(place)).copied().unwrap_or(place)
}
pub fn player_seats(self, seats: u8) -> Vec<u8> {
let mut out: Vec<u8> = (0..self.humans(seats))
.map(|place| self.seat_of_place(place))
.collect();
out.sort_unstable();
out
}
pub fn ai_seats(self, seats: u8) -> Vec<u8> {
(seats.saturating_sub(self.bots)..seats)
.map(|place| self.seat_of_place(place))
.collect()
}
pub fn order_fits(self, seats: u8) -> bool {
(0..seats).all(|place| self.seat_of_place(place) < seats)
}
}
impl MatchTerms {
const BYTES: usize = 22;
fn encode(self) -> [u8; Self::BYTES] {
let mut out = [0u8; Self::BYTES];
out[0] = self.bots;
out[1] = self.bot_level;
out[2] = self.map;
out[3] = self.gulls;
out[4] = self.round;
out[5] = self.teams;
out[6..14].copy_from_slice(&self.seed.to_le_bytes());
out[14] = self.series;
out[15] = self.posts;
out[16..22].copy_from_slice(&self.order);
out
}
fn decode(bytes: &[u8]) -> Option<MatchTerms> {
let seed = u64::from_le_bytes(bytes.get(6..14)?.try_into().ok()?);
Some(MatchTerms {
bots: *bytes.first()?,
bot_level: *bytes.get(1)?,
map: *bytes.get(2)?,
gulls: *bytes.get(3)?,
round: *bytes.get(4)?,
teams: *bytes.get(5)?,
seed,
series: *bytes.get(14)?,
posts: *bytes.get(15)?,
order: seat_order(bytes.get(16..22)?)?,
})
}
}
fn seat_order(bytes: &[u8]) -> Option<[u8; crate::sim::MAX_PLAYERS]> {
let order: [u8; crate::sim::MAX_PLAYERS] = bytes.try_into().ok()?;
let mut sorted = order;
sorted.sort_unstable();
(order[0] == 0 && sorted == IN_SEAT_ORDER).then_some(order)
}
fn runs(inputs: &[InputMsg]) -> impl Iterator<Item = (usize, usize)> + '_ {
let mut at = 0;
std::iter::from_fn(move || {
let first = inputs.get(at)?;
let mut len = 1;
while len < RUN_MOST
&& inputs.get(at + len).is_some_and(|next| {
next.player == first.player
&& Some(next.frame) == first.frame.checked_add(len as u32)
})
{
len += 1;
}
let run = (at, len);
at += len;
Some(run)
})
}
fn write_batch(batch: &Batch, out: &mut Vec<u8>) {
let Batch {
round,
inputs,
wants,
pause,
resume,
abandoned,
clock,
} = batch;
let mut flags = 0;
for (carried, bit) in [
(wants.is_some(), CARRIES_WANTS),
(pause.is_some(), CARRIES_PAUSE),
(resume.is_some(), CARRIES_RESUME),
(clock.is_some(), CARRIES_CLOCK),
(!abandoned.is_empty(), CARRIES_ABANDONED),
] {
if carried {
flags |= bit;
}
}
out.push(*round);
out.push(flags);
if let Some(wants) = wants {
let base = wants.iter().copied().min().unwrap_or(0);
out.extend_from_slice(&base.to_le_bytes());
out.extend(wants.iter().map(|want| (want - base).min(255) as u8));
}
if let Some(frame) = pause {
out.extend_from_slice(&frame.to_le_bytes());
}
if let Some(frame) = resume {
out.extend_from_slice(&frame.to_le_bytes());
}
if let Some(gap) = clock {
out.extend_from_slice(&gap.to_le_bytes());
}
if !abandoned.is_empty() {
let kept = &abandoned[..abandoned.len().min(crate::sim::MAX_PLAYERS)];
out.push(kept.len() as u8);
for (seat, frame) in kept {
out.push(*seat);
out.extend_from_slice(&frame.to_le_bytes());
}
}
let count_at = out.len();
out.push(0);
let mut count = 0u8;
for (start, len) in runs(inputs) {
let Some(next) = count.checked_add(1) else {
debug_assert!(false, "more than 255 runs in one batch");
break;
};
count = next;
let first = inputs[start];
out.push(first.player);
out.extend_from_slice(&first.frame.to_le_bytes());
out.push(len as u8);
for input in &inputs[start..start + len] {
out.extend_from_slice(&crate::sim::encode_action(input.action));
}
}
out[count_at] = count;
}
fn read_batch(body: &[u8]) -> Option<Batch> {
let mut at = 0;
let mut take = |len: usize| {
let bytes = body.get(at..at + len);
at += len;
bytes
};
let round = *take(1)?.first()?;
let flags = *take(1)?.first()?;
if flags & !CARRIES_ANY != 0 {
return None;
}
let mut batch = Batch {
round,
..Batch::default()
};
if flags & CARRIES_WANTS != 0 {
let base = u32::from_le_bytes(take(4)?.try_into().ok()?);
let mut wants = [0; crate::sim::MAX_PLAYERS];
for (want, offset) in wants.iter_mut().zip(take(crate::sim::MAX_PLAYERS)?) {
*want = base.checked_add(u32::from(*offset))?;
}
batch.wants = Some(wants);
}
if flags & CARRIES_PAUSE != 0 {
batch.pause = Some(u32::from_le_bytes(take(4)?.try_into().ok()?));
}
if flags & CARRIES_RESUME != 0 {
batch.resume = Some(u32::from_le_bytes(take(4)?.try_into().ok()?));
}
if flags & CARRIES_CLOCK != 0 {
batch.clock = Some(i16::from_le_bytes(take(2)?.try_into().ok()?));
}
if flags & CARRIES_ABANDONED != 0 {
let count = usize::from(*take(1)?.first()?);
if count == 0 || count > crate::sim::MAX_PLAYERS {
return None;
}
for _ in 0..count {
let notice = take(5)?;
let seat = notice[0];
if usize::from(seat) >= crate::sim::MAX_PLAYERS {
return None;
}
batch
.abandoned
.push((seat, u32::from_le_bytes(notice[1..5].try_into().ok()?)));
}
}
let runs = *take(1)?.first()?;
for _ in 0..runs {
let head = take(RUN_HEAD)?;
let player = head[0];
let first = u32::from_le_bytes(head[1..5].try_into().ok()?);
let len = usize::from(head[5]);
if usize::from(player) >= crate::sim::MAX_PLAYERS {
return None;
}
let actions = take(len * ACTION_BYTES)?;
for (offset, packed) in actions.as_chunks::<ACTION_BYTES>().0.iter().enumerate() {
batch.inputs.push(InputMsg {
player,
frame: first.checked_add(offset as u32)?,
action: crate::sim::decode_action(*packed),
});
}
}
Some(batch)
}
pub fn write_pieces(batch: &Batch, mut emit: impl FnMut(&[u8])) {
let notices = Batch {
inputs: Vec::new(),
clock: None,
..batch.clone()
};
let wants_only = Batch {
round: batch.round,
wants: batch.wants,
..Batch::default()
};
let mut out = Vec::with_capacity(MAX_DATAGRAM);
let mut piece = notices;
let mut write = |piece: &mut Batch, out: &mut Vec<u8>| {
piece.inputs.sort_by_key(|input| input.player);
out.clear();
out.push(TAG_INPUTS);
out.push(PROTOCOL_VERSION);
write_batch(piece, out);
debug_assert!(out.len() <= MAX_DATAGRAM, "{} bytes", out.len());
emit(out);
};
let head = |piece: &Batch| {
let mut out = Vec::new();
write_batch(piece, &mut out);
2 + out.len()
};
let clock_room = if batch.clock.is_some() { 2 } else { 0 };
let mut size = head(&piece) + clock_room;
let mut last: [Option<(u32, usize)>; crate::sim::MAX_PLAYERS] = Default::default();
for &input in &batch.inputs {
let seat = usize::from(input.player).min(crate::sim::MAX_PLAYERS - 1);
let extends = last[seat].is_some_and(|(frame, len)| {
len < RUN_MOST && Some(input.frame) == frame.checked_add(1)
});
let cost = ACTION_BYTES + if extends { 0 } else { RUN_HEAD };
if size + cost > MAX_DATAGRAM && !piece.inputs.is_empty() {
write(&mut piece, &mut out);
piece = wants_only.clone();
size = head(&piece) + clock_room + ACTION_BYTES + RUN_HEAD;
last = Default::default();
last[seat] = Some((input.frame, 1));
} else {
size += cost;
last[seat] = Some(match (extends, last[seat]) {
(true, Some((_, len))) => (input.frame, len + 1),
_ => (input.frame, 1),
});
}
piece.inputs.push(input);
}
piece.clock = batch.clock;
write(&mut piece, &mut out);
}
impl NetMsg {
pub fn encode(self) -> Vec<u8> {
let mut bytes = match self {
NetMsg::Hello { .. } => vec![TAG_HELLO],
NetMsg::Watch { .. } => vec![TAG_WATCH],
NetMsg::Queued { .. } => vec![TAG_QUEUED],
NetMsg::Chat { .. } => vec![TAG_CHAT],
NetMsg::Roster { .. } => vec![TAG_ROSTER],
NetMsg::Abandoned { .. } => vec![TAG_ABANDONED],
NetMsg::Inputs(_) => vec![TAG_INPUTS],
NetMsg::Hash { .. } => vec![TAG_HASH],
NetMsg::Start(_) => vec![TAG_START],
NetMsg::Pause { .. } => vec![TAG_PAUSE],
NetMsg::SpectatorVote { .. } => vec![TAG_SPECTATOR_VOTE],
NetMsg::SpectatorTally { .. } => vec![TAG_SPECTATOR_TALLY],
NetMsg::Resume { .. } => vec![TAG_RESUME],
NetMsg::CatchUp { .. } => vec![TAG_CATCH_UP],
NetMsg::SpectatorPick { .. } => vec![TAG_SPECTATOR_PICK],
NetMsg::CrowdPicks { .. } => vec![TAG_CROWD_PICKS],
NetMsg::Kicked => vec![TAG_KICKED],
NetMsg::NoBots => vec![TAG_NO_BOTS],
NetMsg::Full => vec![TAG_FULL],
NetMsg::Leaving => vec![TAG_LEAVING],
NetMsg::Ping { .. } => vec![TAG_PING],
NetMsg::Pong { .. } => vec![TAG_PONG],
NetMsg::Clock { .. } => vec![TAG_CLOCK],
NetMsg::Incompatible { version } => return vec![TAG_INCOMPATIBLE, version],
};
bytes.push(PROTOCOL_VERSION);
match self {
NetMsg::Incompatible { .. }
| NetMsg::Kicked
| NetMsg::NoBots
| NetMsg::Full
| NetMsg::Leaving => {}
NetMsg::Resume { round, frame } | NetMsg::Pause { round, frame } => {
bytes.push(round);
bytes.extend_from_slice(&frame.to_le_bytes());
}
NetMsg::Ping { stamp } | NetMsg::Pong { stamp } => {
bytes.extend_from_slice(&stamp.to_le_bytes());
}
NetMsg::Clock { gap } => bytes.extend_from_slice(&gap.to_le_bytes()),
NetMsg::Queued { ahead } => bytes.push(ahead),
NetMsg::Abandoned { round, seat, frame } => {
bytes.push(round);
bytes.push(seat);
bytes.extend_from_slice(&frame.to_le_bytes());
}
NetMsg::Chat { name, text } => {
bytes.extend_from_slice(&name);
bytes.extend_from_slice(&text);
}
NetMsg::Roster {
seats,
names,
terms,
kinds,
beach,
chair,
picking,
} => {
bytes.push(seats);
for name in names {
bytes.extend_from_slice(&name);
}
bytes.extend_from_slice(&terms.encode());
bytes.extend_from_slice(&kinds);
bytes.extend_from_slice(&beach);
bytes.push(match chair {
None => 0,
Some(true) => 1,
Some(false) => 2,
});
bytes.push(u8::from(picking));
}
NetMsg::Hello { name, bot } => {
bytes.extend_from_slice(&name);
bytes.push(u8::from(bot));
}
NetMsg::Watch { name } => bytes.extend_from_slice(&name),
NetMsg::Inputs(ref batch) => write_batch(batch, &mut bytes),
NetMsg::Hash { round, frame, hash } => {
bytes.push(round);
bytes.extend_from_slice(&frame.to_le_bytes());
bytes.extend_from_slice(&hash.to_le_bytes());
}
NetMsg::Start(Start {
seats,
seat,
terms,
names,
kinds,
standing,
beach,
deal,
crowned,
delay,
}) => {
bytes.push(seats);
bytes.push(seat.unwrap_or(SPECTATOR_SEAT));
bytes.extend_from_slice(&terms.encode());
for name in names {
bytes.extend_from_slice(&name);
}
bytes.extend_from_slice(&kinds);
let SeriesStanding { round, wins } = standing.unwrap_or(SeriesStanding {
round: 0,
wins: [0; crate::sim::MAX_PLAYERS],
});
bytes.push(round);
bytes.extend_from_slice(&wins);
bytes.extend_from_slice(&deal.to_le_bytes());
bytes.push(crowned);
bytes.push(delay);
let len = u16::try_from(beach.len()).unwrap_or(0);
bytes.extend_from_slice(&len.to_le_bytes());
bytes.extend_from_slice(&beach[..usize::from(len)]);
}
NetMsg::CatchUp {
frame,
part,
parts,
bytes: ref piece,
} => {
debug_assert!(piece.len() <= CATCH_UP_CHUNK, "{} bytes", piece.len());
let len = piece.len().min(CATCH_UP_CHUNK);
bytes.extend_from_slice(&frame.to_le_bytes());
bytes.push(part);
bytes.push(parts);
bytes.extend_from_slice(&(len as u16).to_le_bytes());
bytes.extend_from_slice(&piece[..len]);
}
NetMsg::SpectatorVote { event } => bytes.push(event),
NetMsg::SpectatorPick { seat } => bytes.push(seat),
NetMsg::CrowdPicks { picks, open } => {
bytes.extend_from_slice(&picks);
bytes.push(open);
}
NetMsg::SpectatorTally {
watching,
open,
wait,
} => bytes.extend_from_slice(&[watching, open, wait]),
}
bytes
}
pub fn peek_version(bytes: &[u8]) -> Option<u8> {
let tag = *bytes.first()?;
(tag <= HIGHEST_TAG)
.then(|| bytes.get(1).copied())
.flatten()
}
pub fn decode(bytes: &[u8]) -> Option<NetMsg> {
let tag = *bytes.first()?;
let version = *bytes.get(1)?;
if tag == TAG_INCOMPATIBLE {
return Some(NetMsg::Incompatible { version });
}
if version != PROTOCOL_VERSION {
return None;
}
let body = bytes.get(2..)?;
match tag {
TAG_HELLO => Some(NetMsg::Hello {
name: body.get(..WIRE_NAME)?.try_into().ok()?,
bot: *body.get(WIRE_NAME)? != 0,
}),
TAG_KICKED => Some(NetMsg::Kicked),
TAG_NO_BOTS => Some(NetMsg::NoBots),
TAG_FULL => Some(NetMsg::Full),
TAG_RETIRED_NO_CHAIR => None,
TAG_LEAVING => Some(NetMsg::Leaving),
TAG_WATCH => Some(NetMsg::Watch {
name: body.get(..WIRE_NAME)?.try_into().ok()?,
}),
TAG_INPUTS => read_batch(body).map(NetMsg::Inputs),
TAG_RETIRED_INPUT => None,
TAG_HASH => {
let round = *body.first()?;
let frame = u32::from_le_bytes(body.get(1..5)?.try_into().ok()?);
let hash = u64::from_le_bytes(body.get(5..13)?.try_into().ok()?);
Some(NetMsg::Hash { round, frame, hash })
}
TAG_START => {
let terms = MatchTerms::decode(body.get(2..)?)?;
let names = read_table(body.get(2 + MatchTerms::BYTES..)?)?;
let (seats, seat) = (*body.first()?, *body.get(1)?);
if !(2..=crate::sim::MAX_PLAYERS as u8).contains(&seats)
|| !terms.order_fits(seats)
|| (seat != SPECTATOR_SEAT && !terms.player_seats(seats).contains(&seat))
{
return None;
}
let seat = (seat != SPECTATOR_SEAT).then_some(seat);
debug_assert!(seat.is_none_or(|seat| seat < seats));
let kinds_at = 2 + MatchTerms::BYTES + WIRE_NAME * crate::sim::MAX_PLAYERS;
let kinds: [u8; crate::sim::MAX_PLAYERS] = body
.get(kinds_at..kinds_at + crate::sim::MAX_PLAYERS)?
.try_into()
.ok()?;
let series_at = kinds_at + crate::sim::MAX_PLAYERS;
let round = *body.get(series_at)?;
let wins: [u8; crate::sim::MAX_PLAYERS] = body
.get(series_at + 1..series_at + 1 + crate::sim::MAX_PLAYERS)?
.try_into()
.ok()?;
let standing = (round != 0).then_some(SeriesStanding { round, wins });
let deal_at = series_at + 1 + crate::sim::MAX_PLAYERS;
let deal = u32::from_le_bytes(body.get(deal_at..deal_at + 4)?.try_into().ok()?);
let crowned = *body.get(deal_at + 4)?;
let delay = *body.get(deal_at + 5)?;
if u32::from(delay) > crate::sim::MAX_DELAY {
return None;
}
let after = deal_at + 6;
let len = usize::from(u16::from_le_bytes(
body.get(after..after + 2)?.try_into().ok()?,
));
let beach = body.get(after + 2..after + 2 + len)?.to_vec();
Some(NetMsg::Start(Start {
seats,
seat,
terms,
names,
kinds,
standing,
beach,
deal,
crowned,
delay,
}))
}
TAG_PING => Some(NetMsg::Ping {
stamp: u32::from_le_bytes(body.get(..4)?.try_into().ok()?),
}),
TAG_PONG => Some(NetMsg::Pong {
stamp: u32::from_le_bytes(body.get(..4)?.try_into().ok()?),
}),
TAG_CLOCK => Some(NetMsg::Clock {
gap: i16::from_le_bytes(body.get(..2)?.try_into().ok()?),
}),
TAG_PAUSE => Some(NetMsg::Pause {
round: *body.first()?,
frame: u32::from_le_bytes(body.get(1..5)?.try_into().ok()?),
}),
TAG_CATCH_UP => {
let frame = u32::from_le_bytes(body.get(..4)?.try_into().ok()?);
let (part, parts) = (*body.get(4)?, *body.get(5)?);
let len = usize::from(u16::from_le_bytes(body.get(6..8)?.try_into().ok()?));
if parts == 0 || parts > CATCH_UP_PARTS || part >= parts || len > CATCH_UP_CHUNK {
return None;
}
Some(NetMsg::CatchUp {
frame,
part,
parts,
bytes: body.get(8..8 + len)?.to_vec(),
})
}
TAG_SPECTATOR_VOTE => Some(NetMsg::SpectatorVote {
event: *body.first()?,
}),
TAG_SPECTATOR_PICK => {
let seat = *body.first()?;
(seat < crate::sim::MAX_PLAYERS as u8).then_some(NetMsg::SpectatorPick { seat })
}
TAG_CROWD_PICKS => Some(NetMsg::CrowdPicks {
picks: body.get(..crate::sim::MAX_PLAYERS)?.try_into().ok()?,
open: *body.get(crate::sim::MAX_PLAYERS)?,
}),
TAG_SPECTATOR_TALLY => Some(NetMsg::SpectatorTally {
watching: *body.first()?,
open: *body.get(1)?,
wait: *body.get(2)?,
}),
TAG_RESUME => Some(NetMsg::Resume {
round: *body.first()?,
frame: u32::from_le_bytes(body.get(1..5)?.try_into().ok()?),
}),
TAG_QUEUED => Some(NetMsg::Queued {
ahead: *body.first()?,
}),
TAG_ABANDONED => {
let (round, seat) = (*body.first()?, *body.get(1)?);
let frame = u32::from_le_bytes(body.get(2..6)?.try_into().ok()?);
(seat < crate::sim::MAX_PLAYERS as u8).then_some(NetMsg::Abandoned {
round,
seat,
frame,
})
}
TAG_ROSTER => {
let table = body.get(1..)?;
let names = read_table(table)?;
let terms_at = WIRE_NAME * crate::sim::MAX_PLAYERS;
let terms = MatchTerms::decode(table.get(terms_at..)?)?;
let kinds_at = terms_at + MatchTerms::BYTES;
let kinds = table
.get(kinds_at..kinds_at + crate::sim::MAX_PLAYERS)?
.try_into()
.ok()?;
let beach_at = kinds_at + crate::sim::MAX_PLAYERS;
let beach = table.get(beach_at..beach_at + WIRE_NAME)?.try_into().ok()?;
let chair = match table.get(beach_at + WIRE_NAME)? {
0 => None,
1 => Some(true),
2 => Some(false),
_ => return None,
};
let picking = match table.get(beach_at + WIRE_NAME + 1)? {
0 => false,
1 => true,
_ => return None,
};
Some(NetMsg::Roster {
seats: *body.first()?,
names,
terms,
kinds,
beach,
chair,
picking,
})
}
TAG_CHAT => Some(NetMsg::Chat {
name: body.get(..WIRE_NAME)?.try_into().ok()?,
text: body
.get(WIRE_NAME..WIRE_NAME + WIRE_CHAT)?
.try_into()
.ok()?,
}),
_ => None,
}
}
}
fn read_table(bytes: &[u8]) -> Option<[WireName; crate::sim::MAX_PLAYERS]> {
let mut names = [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS];
for (i, name) in names.iter_mut().enumerate() {
*name = bytes
.get(i * WIRE_NAME..(i + 1) * WIRE_NAME)?
.try_into()
.ok()?;
}
Some(names)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_message_fits_the_receive_buffer() {
let widest = wire_name("WWWWWWWWWWWWWWWWWWWWWWWW");
for msg in [
NetMsg::Hello {
name: widest,
bot: true,
},
NetMsg::Kicked,
NetMsg::NoBots,
NetMsg::Full,
NetMsg::Leaving,
NetMsg::Chat {
name: widest,
text: wire_chat(&"W".repeat(CHAT_CHARS)),
},
NetMsg::Start(Start {
seats: 6,
seat: Some(5),
names: [widest; crate::sim::MAX_PLAYERS],
beach: vec![0xAB; MAX_BEACH_BYTES],
..Start::blank()
}),
NetMsg::Hash {
round: u8::MAX,
frame: u32::MAX,
hash: u64::MAX,
},
NetMsg::CatchUp {
frame: u32::MAX,
part: CATCH_UP_PARTS - 1,
parts: CATCH_UP_PARTS,
bytes: vec![0xAB; CATCH_UP_CHUNK],
},
NetMsg::Inputs(Batch {
inputs: tail(5, 2 * longest_span()),
..notices()
}),
] {
let len = msg.clone().encode().len();
assert!(len <= MAX_DATAGRAM, "{len} bytes: {msg:?}");
}
}
fn longest_span() -> usize {
(crate::sim::MAX_DELAY + crate::sim::RESEND_LEAD) as usize
}
fn tail(seat: u8, frames: usize) -> Vec<InputMsg> {
(0..frames as u32)
.map(|frame| InputMsg {
player: seat,
frame: 70_000 + frame,
action: PlayerAction::Place {
x: 19,
y: 12,
dir: Direction::Left,
},
})
.collect()
}
fn notices() -> Batch {
Batch {
round: 7,
inputs: Vec::new(),
wants: Some([70_000, 70_002, 70_001, 70_040, 70_000, 70_003]),
pause: Some(70_050),
resume: Some(70_010),
abandoned: vec![(3, 69_000), (5, 69_500)],
clock: Some(-3),
}
}
fn pieces(batch: &Batch) -> Vec<Vec<u8>> {
let mut pieces = Vec::new();
write_pieces(batch, |bytes| pieces.push(bytes.to_vec()));
pieces
}
#[test]
fn a_full_resend_tail_is_one_datagram() {
let batch = Batch {
inputs: tail(5, 2 * longest_span()),
..notices()
};
let pieces = pieces(&batch);
assert_eq!(pieces.len(), 1);
assert!(pieces[0].len() <= 300, "{} bytes", pieces[0].len());
assert_eq!(NetMsg::decode(&pieces[0]), Some(NetMsg::Inputs(batch)));
}
#[test]
fn a_batch_too_big_for_a_datagram_comes_back_whole_in_several() {
let frames = 2 * longest_span();
let mut inputs: Vec<InputMsg> = (0..crate::sim::MAX_PLAYERS as u8)
.flat_map(|seat| tail(seat, frames))
.collect();
inputs.sort_by_key(|input| (input.frame, input.player));
let batch = Batch {
inputs: inputs.clone(),
..notices()
};
let pieces = pieces(&batch);
assert!(pieces.len() > 1, "{} datagram", pieces.len());
let mut back = Vec::new();
let mut reached = 0;
for (index, bytes) in pieces.iter().enumerate() {
assert!(bytes.len() <= MAX_DATAGRAM, "{} bytes", bytes.len());
let Some(NetMsg::Inputs(piece)) = NetMsg::decode(bytes) else {
panic!("datagram {index} does not decode");
};
assert_eq!(piece.wants, batch.wants, "datagram {index}");
let first = index == 0;
assert_eq!(piece.round, batch.round, "datagram {index}");
assert_eq!(piece.pause.is_some(), first, "datagram {index}");
assert_eq!(piece.abandoned.is_empty(), !first, "datagram {index}");
let last = index == pieces.len() - 1;
assert_eq!(piece.clock.is_some(), last, "datagram {index}");
let lowest = piece.inputs.iter().map(|input| input.frame).min();
assert!(lowest >= Some(reached), "datagram {index} goes back");
reached = piece
.inputs
.iter()
.map(|input| input.frame)
.max()
.unwrap_or(0);
back.extend(piece.inputs);
}
back.sort_by_key(|input| (input.frame, input.player));
assert_eq!(back, inputs);
let sent: usize = pieces.iter().map(Vec::len).sum();
assert!(
sent < (pieces.len() - 1) * MAX_DATAGRAM + 100 + MAX_DATAGRAM / 2,
"{sent} bytes in {} datagrams",
pieces.len()
);
}
#[test]
fn a_batch_with_no_inputs_is_one_small_datagram() {
let batch = Batch {
wants: notices().wants,
..Batch::default()
};
let pieces = pieces(&batch);
assert_eq!(pieces.len(), 1);
assert_eq!(pieces[0].len(), 2 + 2 + 4 + crate::sim::MAX_PLAYERS + 1);
assert_eq!(NetMsg::decode(&pieces[0]), Some(NetMsg::Inputs(batch)));
let far = NetMsg::Inputs(Batch {
wants: Some([9, 9 + 300, 9, 9, 9, 9]),
..Batch::default()
});
let Some(NetMsg::Inputs(back)) = NetMsg::decode(&far.encode()) else {
panic!("does not decode");
};
assert_eq!(back.wants, Some([9, 9 + 255, 9, 9, 9, 9]));
}
#[test]
fn a_version_ten_input_is_refused_and_answered() {
let mut old = vec![TAG_RETIRED_INPUT, 10];
old.extend_from_slice(&[0u8; 8]);
assert_eq!(NetMsg::decode(&old), None, "not read as anything");
assert_eq!(
NetMsg::peek_version(&old),
Some(10),
"and answered with why"
);
}
#[test]
fn what_holds_a_seat_survives_the_wire() {
for bot in [false, true] {
let hello = NetMsg::Hello {
name: wire_name("Greedy (Ana)"),
bot,
};
assert_eq!(NetMsg::decode(&hello.clone().encode()), Some(hello));
}
let kinds = wire_kinds(&[
crate::sim::SeatKind::Human,
crate::sim::SeatKind::Bot,
crate::sim::SeatKind::Ai,
]);
assert_eq!(kinds, [0, 2, 1, 0, 0, 0]);
let roster = NetMsg::Roster {
seats: 3,
names: [wire_name("x"); crate::sim::MAX_PLAYERS],
terms: MatchTerms::default(),
kinds,
beach: wire_name("The Pier"),
chair: Some(false),
picking: true,
};
assert_eq!(NetMsg::decode(&roster.clone().encode()), Some(roster));
let start = NetMsg::Start(Start {
seats: 3,
seat: Some(1),
names: [wire_name("x"); crate::sim::MAX_PLAYERS],
kinds,
beach: vec![1, 2, 3],
..Start::blank()
});
assert_eq!(NetMsg::decode(&start.clone().encode()), Some(start));
assert_eq!(
NetMsg::decode(&NetMsg::Kicked.encode()),
Some(NetMsg::Kicked)
);
assert_eq!(
NetMsg::decode(&NetMsg::Leaving.encode()),
Some(NetMsg::Leaving)
);
assert_eq!(kind_from_byte(2), crate::sim::SeatKind::Bot);
assert_eq!(
kind_from_byte(99),
crate::sim::SeatKind::Human,
"unknown reads as a person"
);
}
#[test]
fn a_start_carrying_the_largest_beach_still_fits() {
let widest = wire_name("WWWWWWWWWWWWWWWWWWWWWWWW");
let len = NetMsg::Start(Start {
seats: 6,
seat: Some(5),
names: [widest; crate::sim::MAX_PLAYERS],
beach: vec![0xAB; MAX_BEACH_BYTES],
..Start::blank()
})
.encode()
.len();
assert!(len <= MAX_DATAGRAM, "{len} bytes");
let spare = MAX_DATAGRAM - len;
assert!(spare >= 16, "only {spare} bytes of slack left");
}
#[test]
fn every_series_index_but_zero_names_a_series() {
let dialled = |series| MatchTerms {
series,
..MatchTerms::default()
};
assert!(!dialled(0).is_series(), "the dial's single round");
for index in 1..=u8::MAX {
assert!(dialled(index).is_series(), "index {index}");
}
}
#[test]
fn the_humans_are_what_the_ai_leaves_and_never_none() {
let with = |bots| MatchTerms {
bots,
..MatchTerms::default()
};
assert_eq!(with(0).humans(6), 6, "no AI, every chair a player's");
assert_eq!(with(4).humans(6), 2, "four bots behind two people");
assert_eq!(with(5).humans(6), 1, "the host alone");
assert_eq!(with(6).humans(6), 1, "and still the host alone");
assert_eq!(with(u8::MAX).humans(6), 1, "however wild the byte");
assert_eq!(with(0).humans(0), 1, "even a table of nobody seats one");
for seats in 0..=u8::MAX {
for bots in [0, 1, 5, 6, 7, u8::MAX] {
let humans = with(bots).humans(seats);
assert!(humans >= 1, "{seats} seats, {bots} bots");
assert!(humans <= seats.max(1), "{seats} seats, {bots} bots");
}
}
}
#[test]
fn a_pick_off_the_table_is_refused() {
let mut bytes = NetMsg::SpectatorPick { seat: 1 }.encode();
*bytes.last_mut().expect("a seat byte") = crate::sim::MAX_PLAYERS as u8;
assert!(NetMsg::decode(&bytes).is_none());
}
#[test]
fn decode_rejects_garbage() {
assert!(NetMsg::decode(&[]).is_none());
assert!(NetMsg::decode(&[0xFF, 1, 2, 3]).is_none());
assert!(NetMsg::decode(b"PNCH?").is_none());
}
use crate::sim::{Direction, PlayerAction};
#[test]
fn messages_round_trip() {
for msg in [
NetMsg::hello("Anna"),
NetMsg::hello("Überlang-Name-über-die-Kappe-hinaus"),
NetMsg::watch("Dee"),
NetMsg::Resume { round: 0, frame: 0 },
NetMsg::Resume {
round: 255,
frame: 70_000,
},
NetMsg::Pause { round: 3, frame: 7 },
NetMsg::SpectatorVote { event: 8 },
NetMsg::SpectatorTally {
watching: 3,
open: 2,
wait: 0,
},
NetMsg::SpectatorPick { seat: 5 },
NetMsg::CrowdPicks {
picks: [0, 3, 1, 0, 0, 2],
open: 27,
},
NetMsg::CatchUp {
frame: 901,
part: 1,
parts: 3,
bytes: vec![7; 40],
},
NetMsg::Inputs(Batch {
inputs: vec![InputMsg {
player: 1,
frame: 42,
action: PlayerAction::Place {
x: 3,
y: 8,
dir: Direction::Down,
},
}],
..Batch::default()
}),
NetMsg::Inputs(Batch::default()),
NetMsg::Inputs(notices()),
NetMsg::Inputs(Batch {
wants: Some([9, 9 + 255, 9, 9, 9, 9]),
..Batch::default()
}),
NetMsg::Inputs(Batch {
inputs: vec![
InputMsg {
player: 2,
frame: 3999,
action: PlayerAction::None,
},
InputMsg {
player: 5,
frame: 4000,
action: PlayerAction::Place {
x: 18,
y: 11,
dir: Direction::Left,
},
},
InputMsg {
player: 5,
frame: 4001,
action: PlayerAction::None,
},
InputMsg {
player: 5,
frame: 4002,
action: PlayerAction::Place {
x: 0,
y: 0,
dir: Direction::Up,
},
},
InputMsg {
player: 2,
frame: 4000,
action: PlayerAction::Remove { x: 4, y: 4 },
},
],
..notices()
}),
NetMsg::Hash {
round: 9,
frame: 990,
hash: 0xDEAD_BEEF_0BAD_F00D,
},
NetMsg::Start(Start {
seats: 6,
seat: Some(3),
terms: MatchTerms {
bots: 2,
teams: 1,
seed: 0x1234_5678_9ABC_DEF0,
..MatchTerms::default()
},
names: std::array::from_fn(|i| wire_name(&format!("Seat {i}"))),
beach: b"a handmade beach".to_vec(),
deal: 0x0102_0304,
crowned: 0b0000_0101,
delay: crate::sim::MAX_DELAY as u8,
..Start::blank()
}),
NetMsg::Start(Start {
seats: 4,
seat: Some(2),
terms: MatchTerms {
series: 2,
..MatchTerms::default()
},
standing: Some(SeriesStanding {
round: 3,
wins: [1, 0, 1, 0, 0, 0],
}),
..Start::blank()
}),
NetMsg::Incompatible { version: 9 },
NetMsg::Ping { stamp: 0 },
NetMsg::Pong { stamp: 0xFEED_BEEF },
NetMsg::Clock { gap: -3 },
NetMsg::Clock { gap: i16::MAX },
NetMsg::Queued { ahead: 0 },
NetMsg::Queued { ahead: 4 },
NetMsg::Full,
NetMsg::Chat {
name: wire_name("Anna"),
text: wire_chat("wait for me!"),
},
NetMsg::Roster {
seats: 4,
names: std::array::from_fn(|i| wire_name(&format!("P{i}"))),
kinds: [0; crate::sim::MAX_PLAYERS],
terms: MatchTerms {
bots: 1,
map: 3,
..MatchTerms::default()
},
beach: [0; WIRE_NAME],
chair: None,
picking: false,
},
NetMsg::Abandoned {
round: 0,
seat: 0,
frame: 0,
},
NetMsg::Abandoned {
round: 1,
seat: crate::sim::MAX_PLAYERS as u8 - 1,
frame: 123_456,
},
] {
assert_eq!(NetMsg::decode(&msg.clone().encode()), Some(msg));
}
}
#[test]
fn a_start_that_seats_more_than_the_table_is_refused() {
let good = NetMsg::Start(Start {
seats: 6,
seat: Some(5),
..Start::blank()
});
assert_eq!(NetMsg::decode(&good.clone().encode()), Some(good.clone()));
for (seats, seat) in [(7, 0), (255, 0), (1, 0), (0, 0), (4, 4), (4, 200)] {
let mut bytes = good.clone().encode();
bytes[2] = seats;
bytes[3] = seat;
assert_eq!(NetMsg::decode(&bytes), None, "{seats} seats, sat at {seat}");
}
let mut watching = good.clone().encode();
watching[3] = SPECTATOR_SEAT;
assert!(matches!(
NetMsg::decode(&watching),
Some(NetMsg::Start(Start { seat: None, .. }))
));
let with_bots = |seats, bots, seat| {
NetMsg::decode(
&NetMsg::Start(Start {
seats,
seat: Some(seat),
terms: MatchTerms {
bots,
..MatchTerms::default()
},
..Start::blank()
})
.encode(),
)
};
assert!(with_bots(6, 4, 1).is_some(), "the last human seat");
assert!(with_bots(6, 4, 2).is_none(), "the first AI seat");
assert!(with_bots(6, 4, 3).is_none());
assert!(with_bots(6, 6, 1).is_none(), "more bots than chairs");
assert!(with_bots(6, 6, 0).is_some(), "seat zero always stands");
}
#[test]
fn the_seat_order_travels_and_a_broken_one_is_refused() {
let order = [0, 2, 1, 3, 4, 5];
let start = |seat, order| {
NetMsg::Start(Start {
seats: 4,
seat: Some(seat),
terms: MatchTerms {
bots: 2,
order,
..MatchTerms::default()
},
..Start::blank()
})
};
let good = start(2, order);
assert_eq!(NetMsg::decode(&good.clone().encode()), Some(good));
assert!(
NetMsg::decode(&start(1, order).encode()).is_none(),
"seat 1 is an AI's under this order"
);
for broken in [[1, 0, 2, 3, 4, 5], [0, 1, 1, 3, 4, 5], [0, 1, 2, 3, 4, 9]] {
assert!(
NetMsg::decode(&start(2, broken).encode()).is_none(),
"{broken:?}"
);
}
assert!(NetMsg::decode(&start(2, [0, 5, 1, 3, 4, 2]).encode()).is_none());
}
#[test]
fn another_build_is_refused_and_told_so() {
let mut hello = NetMsg::hello("Anna").encode();
assert_eq!(hello[1], PROTOCOL_VERSION, "the version byte is byte 1");
hello[1] = PROTOCOL_VERSION.wrapping_add(1);
assert_eq!(NetMsg::decode(&hello), None, "not ours, so not acted on");
assert_eq!(
NetMsg::peek_version(&hello),
Some(PROTOCOL_VERSION.wrapping_add(1)),
"but it can still be identified, which is what gets it answered"
);
let refusal = NetMsg::Incompatible { version: 77 }.encode();
assert_eq!(refusal.len(), 2, "its layout is frozen at two bytes");
assert_eq!(
NetMsg::decode(&refusal),
Some(NetMsg::Incompatible { version: 77 })
);
assert_eq!(NetMsg::peek_version(&[0xFE, 3]), None);
assert_eq!(NetMsg::peek_version(b"PNCH1"), None, "an announcement");
}
}
#[cfg(test)]
mod wire_fuzz_probe {
use super::*;
#[test]
fn no_datagram_can_break_a_decoder() {
let mut rng = crate::sim::Pcg32::new(0xDEAD_BEEF, 0x1357);
let mut seeds: Vec<Vec<u8>> = vec![
NetMsg::hello("Anna").encode(),
NetMsg::watch("Dee").encode(),
NetMsg::Queued { ahead: 3 }.encode(),
NetMsg::Chat {
name: wire_name("Bo"),
text: wire_chat("ready?"),
}
.encode(),
NetMsg::Start(Start {
seats: 6,
seat: Some(2),
..Start::blank()
})
.encode(),
NetMsg::Hash {
round: 1,
frame: 7,
hash: 9,
}
.encode(),
NetMsg::Inputs(Batch {
inputs: (0..6)
.map(|frame| InputMsg {
player: (frame % 2) as u8,
frame: 9 + frame / 2,
action: crate::sim::PlayerAction::None,
})
.collect(),
wants: Some([9, 12, 9, 9, 10, 9]),
pause: Some(40),
abandoned: vec![(1, 30)],
clock: Some(2),
..Batch::default()
})
.encode(),
ANNOUNCE_MAGIC.to_vec(),
Vec::new(),
];
let mut beacon = ANNOUNCE_MAGIC.to_vec();
beacon.extend_from_slice(&49213u16.to_le_bytes());
beacon.push(Kind::Running as u8);
beacon.extend_from_slice(&wire_name("Room 3"));
beacon.push(4);
beacon.push(6);
beacon.extend_from_slice(&0x5EA5u64.to_le_bytes());
seeds.push(beacon);
for round in 0..80_000u32 {
let mut bytes = seeds[(round as usize) % seeds.len()].clone();
for _ in 0..(rng.next_u32() % 6) + 1 {
if bytes.is_empty() {
bytes.push((rng.next_u32() % 256) as u8);
continue;
}
let at = (rng.next_u32() as usize) % bytes.len();
match rng.next_u32() % 4 {
0 => bytes[at] = (rng.next_u32() % 256) as u8,
1 => drop(bytes.remove(at)),
2 => bytes.insert(at, (rng.next_u32() % 256) as u8),
_ => bytes.truncate(at),
}
}
if let Some(msg) = NetMsg::decode(&bytes) {
assert_eq!(
NetMsg::decode(&msg.clone().encode()),
Some(msg.clone()),
"{bytes:?}"
);
if let NetMsg::Start(Start { seats, seat, .. }) = msg {
assert!((2..=crate::sim::MAX_PLAYERS as u8).contains(&seats));
assert!(seat.is_none_or(|seat| seat < seats));
}
if let NetMsg::Abandoned { seat, .. } = msg {
assert!(usize::from(seat) < crate::sim::MAX_PLAYERS);
}
}
let _ = NetMsg::peek_version(&bytes);
let _ = decode(&bytes);
}
}
}