Nachrichten zwischen Host und Gästen

Gäste grüßen mit Protokollversion, Regel-Kennung, Schlüssel und Name
und schicken danach nur Aktionen ohne Spielernummer. Der Host schickt
seine Welt, Ablehnungen mit Grund und jeden Takt mit seinen Befehlen,
jede Sekunde mit einer Prüfsumme. Ein Gruß einer anderen Version bleibt
erkennbar, und das Format ist in den festgeschriebenen Werten
verankert.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Steven Wroblewski
2026-10-01 18:14:04 +02:00
parent f2e4c91082
commit a0cb6b12b7
4 changed files with 505 additions and 0 deletions
+51
View File
@@ -8,8 +8,11 @@
//! value here deliberately.
use grovewright_core::codec::Writer;
use grovewright_core::command::Command;
use grovewright_core::hash::fnv1a64;
use grovewright_core::map::{Map, Pos, Terrain, Tile};
use grovewright_core::player::PlayerId;
use grovewright_core::protocol::{Action, Refusal, ToGuest, ToHost};
use grovewright_core::replay::Recording;
use grovewright_core::rules::{
end_hash, frozen_offline_script, frozen_script, rules_id, RULES_SEED,
@@ -114,3 +117,51 @@ fn the_map_encoding_is_unchanged() {
core/src/snapshot.rs; then update these bytes"
);
}
/// Covers every message between host and guests, built from literal values.
#[test]
fn the_protocol_format_is_unchanged() {
let to_host = [
ToHost::Hello {
rules: 0x0102_0304_0506_0708,
key: [3; 16],
name: "Ada".to_owned(),
},
ToHost::Act(Action::Dig {
pos: Pos::new(-2, 9),
}),
ToHost::Diverged { tick: 40, hash: 77 },
];
let to_guest = [
ToGuest::World {
snapshot: vec![5, 6, 7],
},
ToGuest::Refused(Refusal::WorldFull),
ToGuest::Tick {
tick: 12,
commands: vec![Command::Dig {
player: PlayerId(1),
pos: Pos::new(3, 4),
}],
check: Some(99),
},
ToGuest::Tick {
tick: 13,
commands: vec![],
check: None,
},
];
let mut bytes = Vec::new();
for message in &to_host {
bytes.extend(message.encode());
}
for message in &to_guest {
bytes.extend(message.encode());
}
assert_eq!(
fnv1a64(&bytes),
0xc263_69c0_f892_247b,
"the messages between host and guests changed; raise \
PROTOCOL_VERSION in core/src/protocol.rs; then update this value"
);
}
+177
View File
@@ -0,0 +1,177 @@
use grovewright_core::codec::{DecodeError, Writer};
use grovewright_core::command::Command;
use grovewright_core::map::Pos;
use grovewright_core::player::{PlayerId, MAX_NAME_BYTES};
use grovewright_core::protocol::{
Action, Refusal, ToGuest, ToHost, MAX_GUEST_MESSAGE_BYTES, PROTOCOL_VERSION,
};
#[test]
fn every_message_to_the_host_survives_encoding() {
let messages = [
ToHost::Hello {
rules: 0x0123_4567_89ab_cdef,
key: [9; 16],
name: "Ada".to_owned(),
},
ToHost::Act(Action::Dig {
pos: Pos::new(-3, 70),
}),
ToHost::Diverged {
tick: 1_234,
hash: u64::MAX,
},
];
for message in &messages {
assert_eq!(&ToHost::decode(&message.encode()).unwrap(), message);
}
}
#[test]
fn every_message_to_a_guest_survives_encoding() {
let messages = [
ToGuest::World {
snapshot: vec![1, 2, 3],
},
ToGuest::Refused(Refusal::TooManyPlayers),
ToGuest::Tick {
tick: 99,
commands: vec![
Command::Leave {
player: PlayerId(2),
},
Command::Dig {
player: PlayerId(0),
pos: Pos::new(4, 5),
},
],
check: Some(7),
},
ToGuest::Tick {
tick: 100,
commands: vec![],
check: None,
},
];
for message in &messages {
assert_eq!(&ToGuest::decode(&message.encode()).unwrap(), message);
}
}
#[test]
fn an_action_becomes_a_command_for_the_given_player() {
let action = Action::Dig {
pos: Pos::new(1, 2),
};
assert_eq!(
action.command(PlayerId(3)),
Command::Dig {
player: PlayerId(3),
pos: Pos::new(1, 2)
}
);
}
#[test]
fn a_greeting_in_another_protocol_version_can_still_be_recognised() {
let mut w = Writer::new();
w.u8(1); // the tag of a greeting, which never changes
w.u32(PROTOCOL_VERSION + 1);
w.raw(b"whatever the other version sends after its version");
assert_eq!(
ToHost::decode(&w.into_bytes()),
Ok(ToHost::ForeignHello {
protocol: PROTOCOL_VERSION + 1
})
);
}
#[test]
fn every_refusal_keeps_its_code() {
let codes = [
(Refusal::OtherProtocol, 1),
(Refusal::OtherRules, 2),
(Refusal::WorldFull, 3),
(Refusal::TooManyPlayers, 4),
(Refusal::AlreadyPlaying, 5),
];
for (refusal, code) in codes {
assert_eq!(refusal.code(), code);
assert_eq!(Refusal::from_code(code), Some(refusal));
}
assert_eq!(Refusal::from_code(0), None);
assert_eq!(Refusal::from_code(6), None);
}
#[test]
fn a_greeting_shortens_an_overlong_name() {
let hello = ToHost::Hello {
rules: 1,
key: [1; 16],
name: "x".repeat(MAX_NAME_BYTES + 5),
};
assert_eq!(
ToHost::decode(&hello.encode()),
Ok(ToHost::Hello {
rules: 1,
key: [1; 16],
name: "x".repeat(MAX_NAME_BYTES),
})
);
}
#[test]
fn a_greeting_with_an_overlong_name_written_by_hand_is_rejected() {
let mut w = Writer::new();
w.u8(1);
w.u32(PROTOCOL_VERSION);
w.u64(1);
w.raw(&[1; 16]);
w.str(&"x".repeat(MAX_NAME_BYTES + 1));
assert_eq!(
ToHost::decode(&w.into_bytes()),
Err(DecodeError::InvalidValue("player name"))
);
}
#[test]
fn broken_messages_are_rejected() {
assert_eq!(ToHost::decode(&[]), Err(DecodeError::UnexpectedEnd));
assert_eq!(
ToHost::decode(&[0xEE]),
Err(DecodeError::InvalidValue("message tag"))
);
assert_eq!(
ToHost::decode(&[2, 0xEE]),
Err(DecodeError::InvalidValue("action tag"))
);
assert_eq!(
ToHost::decode(&[2; MAX_GUEST_MESSAGE_BYTES + 1]),
Err(DecodeError::InvalidValue("message length"))
);
let mut act = ToHost::Act(Action::Dig {
pos: Pos::new(0, 0),
})
.encode();
act.push(0);
assert_eq!(ToHost::decode(&act), Err(DecodeError::TrailingBytes));
assert_eq!(
ToGuest::decode(&[0xEE]),
Err(DecodeError::InvalidValue("message tag"))
);
assert_eq!(
ToGuest::decode(&[2, 99]),
Err(DecodeError::InvalidValue("refusal"))
);
let tick = ToGuest::Tick {
tick: 1,
commands: vec![],
check: Some(5),
}
.encode();
assert_eq!(
ToGuest::decode(&tick[..tick.len() - 1]),
Err(DecodeError::UnexpectedEnd)
);
}