use super::*;
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum NetMsg {
Hello { name: WireName, bot: bool },
Watch { name: WireName },
Inputs(Vec<InputMsg>),
Hash { frame: u32, hash: u64 },
Start {
seats: u8,
seat: Option<u8>,
terms: MatchTerms,
names: [WireName; crate::sim::MAX_PLAYERS],
kinds: [u8; crate::sim::MAX_PLAYERS],
standing: Option<SeriesStanding>,
beach: Vec<u8>,
},
Pause { frame: u32 },
Resume { frame: u32 },
Abandoned { seat: u8, frame: u32 },
Roster {
seats: u8,
names: [WireName; crate::sim::MAX_PLAYERS],
terms: MatchTerms,
kinds: [u8; crate::sim::MAX_PLAYERS],
},
Kicked,
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 },
}
#[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 watch(name: &str) -> NetMsg {
NetMsg::Watch {
name: wire_name(name),
}
}
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 HIGHEST_TAG: u8 = TAG_KICKED;
pub const MAX_INPUTS_PER_DATAGRAM: usize = 127;
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,
}
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,
}
}
}
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)
}
}
impl MatchTerms {
const BYTES: usize = 16;
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
}
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)?,
})
}
}
pub(super) fn encode_inputs(msgs: &[InputMsg], buf: &mut [u8; MAX_DATAGRAM]) -> usize {
debug_assert!(
msgs.len() <= MAX_INPUTS_PER_DATAGRAM,
"{} inputs in one datagram",
msgs.len()
);
let count = msgs.len().min(MAX_INPUTS_PER_DATAGRAM);
buf[0] = TAG_INPUTS;
buf[1] = PROTOCOL_VERSION;
buf[2] = count as u8;
for (index, msg) in msgs[..count].iter().enumerate() {
let at = 3 + index * INPUT_BYTES;
buf[at..at + INPUT_BYTES].copy_from_slice(&msg.encode());
}
3 + count * INPUT_BYTES
}
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::Incompatible { version } => return vec![TAG_INCOMPATIBLE, version],
};
bytes.push(PROTOCOL_VERSION);
match self {
NetMsg::Incompatible { .. } | NetMsg::Kicked => {}
NetMsg::Resume { frame } => bytes.extend_from_slice(&frame.to_le_bytes()),
NetMsg::Queued { ahead } => bytes.push(ahead),
NetMsg::Abandoned { seat, frame } => {
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,
} => {
bytes.push(seats);
for name in names {
bytes.extend_from_slice(&name);
}
bytes.extend_from_slice(&terms.encode());
bytes.extend_from_slice(&kinds);
}
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 inputs) => {
debug_assert!(
inputs.len() <= MAX_INPUTS_PER_DATAGRAM,
"{} inputs in one datagram",
inputs.len()
);
let count = inputs.len().min(MAX_INPUTS_PER_DATAGRAM);
bytes.push(count as u8);
for input in &inputs[..count] {
bytes.extend_from_slice(&input.encode());
}
}
NetMsg::Hash { frame, hash } => {
bytes.extend_from_slice(&frame.to_le_bytes());
bytes.extend_from_slice(&hash.to_le_bytes());
}
NetMsg::Start {
seats,
seat,
terms,
names,
kinds,
standing,
beach,
} => {
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);
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::Pause { frame } => bytes.extend_from_slice(&frame.to_le_bytes()),
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_WATCH => Some(NetMsg::Watch {
name: body.get(..WIRE_NAME)?.try_into().ok()?,
}),
TAG_INPUTS => {
let count = usize::from(*body.first()?);
if count > MAX_INPUTS_PER_DATAGRAM {
return None;
}
let packed = body.get(1..1 + count * INPUT_BYTES)?;
Some(NetMsg::Inputs(
packed
.as_chunks::<INPUT_BYTES>()
.0
.iter()
.map(|chunk| InputMsg::decode(*chunk))
.collect(),
))
}
TAG_RETIRED_INPUT => None,
TAG_HASH => {
let frame = u32::from_le_bytes(body.get(..4)?.try_into().ok()?);
let hash = u64::from_le_bytes(body.get(4..12)?.try_into().ok()?);
Some(NetMsg::Hash { 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)?);
let humans = terms.humans(seats);
if !(2..=crate::sim::MAX_PLAYERS as u8).contains(&seats)
|| (seat != SPECTATOR_SEAT && seat >= humans)
{
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 after = series_at + 1 + crate::sim::MAX_PLAYERS;
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 {
seats,
seat,
terms,
names,
kinds,
standing,
beach,
})
}
TAG_PAUSE => Some(NetMsg::Pause {
frame: u32::from_le_bytes(body.get(..4)?.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 {
frame: u32::from_le_bytes(body.get(..4)?.try_into().ok()?),
}),
TAG_QUEUED => Some(NetMsg::Queued {
ahead: *body.first()?,
}),
TAG_ABANDONED => {
let seat = *body.first()?;
let frame = u32::from_le_bytes(body.get(1..5)?.try_into().ok()?);
(seat < crate::sim::MAX_PLAYERS as u8).then_some(NetMsg::Abandoned { 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()?;
Some(NetMsg::Roster {
seats: *body.first()?,
names,
terms,
kinds,
})
}
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::Chat {
name: widest,
text: wire_chat(&"W".repeat(CHAT_CHARS)),
},
NetMsg::Start {
seats: 6,
seat: Some(5),
terms: MatchTerms::default(),
names: [widest; crate::sim::MAX_PLAYERS],
kinds: [0; crate::sim::MAX_PLAYERS],
standing: None,
beach: vec![0xAB; MAX_BEACH_BYTES],
},
NetMsg::Hash {
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(vec![
InputMsg {
player: 5,
frame: u32::MAX,
action: PlayerAction::Place {
x: 19,
y: 12,
dir: Direction::Left,
},
};
MAX_INPUTS_PER_DATAGRAM
]),
] {
let len = msg.clone().encode().len();
assert!(len <= MAX_DATAGRAM, "{len} bytes: {msg:?}");
}
}
#[test]
fn a_full_resend_tail_is_one_datagram() {
let span = (crate::sim::DEFAULT_DELAY + crate::sim::MAX_COMMIT_LEAD) as usize;
let tail = 2 * span;
assert!(
tail <= MAX_INPUTS_PER_DATAGRAM,
"a {tail}-message tail does not fit {MAX_INPUTS_PER_DATAGRAM} per datagram",
);
let len = NetMsg::Inputs(vec![
InputMsg {
player: 5,
frame: u32::MAX,
action: PlayerAction::None,
};
tail
])
.encode()
.len();
assert!(len <= MAX_DATAGRAM, "{len} bytes");
}
#[test]
fn the_two_encoders_agree() {
for count in [0usize, 1, 34, MAX_INPUTS_PER_DATAGRAM] {
let msgs: Vec<InputMsg> = (0..count)
.map(|i| InputMsg {
player: (i % crate::sim::MAX_PLAYERS) as u8,
frame: i as u32 * 7,
action: PlayerAction::Place {
x: (i % 20) as u8,
y: (i % 13) as u8,
dir: Direction::Right,
},
})
.collect();
let mut buf = [0u8; MAX_DATAGRAM];
let len = encode_inputs(&msgs, &mut buf);
assert_eq!(
&buf[..len],
NetMsg::Inputs(msgs.clone()).encode().as_slice(),
"{count} inputs"
);
assert_eq!(
NetMsg::decode(&buf[..len]),
Some(NetMsg::Inputs(msgs)),
"{count} inputs"
);
}
}
#[test]
fn a_version_ten_input_is_refused_and_answered() {
let mut old = vec![TAG_RETIRED_INPUT, 10];
old.extend_from_slice(&[0u8; INPUT_BYTES]);
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,
};
assert_eq!(NetMsg::decode(&roster.clone().encode()), Some(roster));
let start = NetMsg::Start {
seats: 3,
seat: Some(1),
terms: MatchTerms::default(),
names: [wire_name("x"); crate::sim::MAX_PLAYERS],
kinds,
standing: None,
beach: vec![1, 2, 3],
};
assert_eq!(NetMsg::decode(&start.clone().encode()), Some(start));
assert_eq!(
NetMsg::decode(&NetMsg::Kicked.encode()),
Some(NetMsg::Kicked)
);
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 {
seats: 6,
seat: Some(5),
terms: MatchTerms::default(),
names: [widest; crate::sim::MAX_PLAYERS],
kinds: [0; crate::sim::MAX_PLAYERS],
standing: None,
beach: vec![0xAB; MAX_BEACH_BYTES],
}
.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 { frame: 0 },
NetMsg::Resume { frame: 70_000 },
NetMsg::Pause { 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(vec![InputMsg {
player: 1,
frame: 42,
action: PlayerAction::Place {
x: 3,
y: 8,
dir: Direction::Down,
},
}]),
NetMsg::Inputs(Vec::new()),
NetMsg::Inputs(vec![
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,
},
},
]),
NetMsg::Hash {
frame: 990,
hash: 0xDEAD_BEEF_0BAD_F00D,
},
NetMsg::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}"))),
kinds: [0; crate::sim::MAX_PLAYERS],
standing: None,
beach: b"a handmade beach".to_vec(),
},
NetMsg::Start {
seats: 4,
seat: Some(2),
terms: MatchTerms {
series: 2,
..MatchTerms::default()
},
names: [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS],
kinds: [0; crate::sim::MAX_PLAYERS],
standing: Some(SeriesStanding {
round: 3,
wins: [1, 0, 1, 0, 0, 0],
}),
beach: Vec::new(),
},
NetMsg::Incompatible { version: 9 },
NetMsg::Queued { ahead: 0 },
NetMsg::Queued { ahead: 4 },
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()
},
},
NetMsg::Abandoned { seat: 0, frame: 0 },
NetMsg::Abandoned {
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 {
seats: 6,
seat: Some(5),
terms: MatchTerms::default(),
names: [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS],
kinds: [0; crate::sim::MAX_PLAYERS],
standing: None,
beach: Vec::new(),
};
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 { seat: None, .. })
));
let with_bots = |seats, bots, seat| {
NetMsg::decode(
&NetMsg::Start {
seats,
seat: Some(seat),
terms: MatchTerms {
bots,
..MatchTerms::default()
},
names: [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS],
kinds: [0; crate::sim::MAX_PLAYERS],
standing: None,
beach: Vec::new(),
}
.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 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 {
seats: 6,
seat: Some(2),
terms: MatchTerms::default(),
names: [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS],
kinds: [0; crate::sim::MAX_PLAYERS],
standing: None,
beach: Vec::new(),
}
.encode(),
NetMsg::Hash { frame: 7, hash: 9 }.encode(),
NetMsg::Inputs(vec![
InputMsg {
player: 1,
frame: 9,
action: crate::sim::PlayerAction::None,
};
6
])
.encode(),
ANNOUNCE_MAGIC.to_vec(),
Vec::new(),
];
let mut beacon = ANNOUNCE_MAGIC.to_vec();
beacon.extend_from_slice(&49213u16.to_le_bytes());
beacon.push(BEACON_RUNNING);
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 { 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 _ = beacon_name(&bytes, bytes.len(), BEACON_NAME_AT);
let _ = beacon_name(&bytes, bytes.len(), BEACON_HOST_AT);
}
}
}