feat(shared): add network control-protocol message types
This commit is contained in:
parent
ae33251c70
commit
41ba2e5d5c
|
|
@ -5,5 +5,6 @@
|
||||||
//! This crate contains data structures and constants that are used by both the client and the server.
|
//! This crate contains data structures and constants that are used by both the client and the server.
|
||||||
|
|
||||||
pub mod generator;
|
pub mod generator;
|
||||||
|
pub mod protocol;
|
||||||
pub mod save;
|
pub mod save;
|
||||||
pub mod world;
|
pub mod world;
|
||||||
|
|
|
||||||
283
crates/shared/src/protocol.rs
Normal file
283
crates/shared/src/protocol.rs
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
//! Network protocol types and constants.
|
||||||
|
//!
|
||||||
|
//! This module defines the pure, serde-serializable messages used for network communication between the client and server. It contains no networking logic or async dependencies.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Wire-protocol version. Incremented on any breaking change to the message layout below.
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// Messages carried on the control stream (stream 0): handshake and disconnect.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum ControlMessage {
|
||||||
|
/// First message a client sends after the QUIC/TLS handshake.
|
||||||
|
ClientHello(ClientHello),
|
||||||
|
/// Server acceptance carrying negotiated session parameters.
|
||||||
|
HandshakeAck(HandshakeAck),
|
||||||
|
/// Server refusal with a machine-readable reason.
|
||||||
|
HandshakeReject(HandshakeReject),
|
||||||
|
/// Orderly session teardown initiated by either side.
|
||||||
|
Disconnect(Disconnect),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First message a client sends after the QUIC/TLS handshake.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ClientHello {
|
||||||
|
/// Protocol version the client was built against; compared to `PROTOCOL_VERSION`.
|
||||||
|
pub protocol_version: u32,
|
||||||
|
/// Human-readable client build string (e.g. crate version + git hash).
|
||||||
|
pub client_build: String,
|
||||||
|
/// Identity the player presents. Minimal for M1.
|
||||||
|
pub player_identity: PlayerIdentity,
|
||||||
|
/// Content packs the client has installed. Empty in M1; validated later.
|
||||||
|
pub installed_packs: Vec<PackRef>,
|
||||||
|
/// Optional protocol feature bits the client requests. Zero in M1.
|
||||||
|
pub requested_features: FeatureFlags,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server acceptance carrying negotiated session parameters.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct HandshakeAck {
|
||||||
|
/// Server's protocol version (equal to the client's on success).
|
||||||
|
pub protocol_version: u32,
|
||||||
|
/// Human-readable server build string.
|
||||||
|
pub server_build: String,
|
||||||
|
/// Packs the world requires, each with an optional download source. Empty in M1.
|
||||||
|
pub world_packs: Vec<RequiredPack>,
|
||||||
|
/// Packs the client is missing relative to the server, each with an optional download source. Empty in M1.
|
||||||
|
pub missing_packs: Vec<RequiredPack>,
|
||||||
|
/// Which stream carries which purpose for this session.
|
||||||
|
pub stream_layout: StreamLayout,
|
||||||
|
/// Advisory server tick rate in Hz, for client clock setup.
|
||||||
|
pub tick_rate_hint: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server refusal with a machine-readable reason.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct HandshakeReject {
|
||||||
|
/// Machine-readable rejection category.
|
||||||
|
pub reason: RejectReason,
|
||||||
|
/// Human-readable detail for logs and UI.
|
||||||
|
pub detail: String,
|
||||||
|
/// Optional URL directing the user to a compatible build or pack, when the rejection is recoverable (e.g. `ProtocolMismatch`, `PackMismatch`).
|
||||||
|
pub upgrade_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Orderly session teardown initiated by either side.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Disconnect {
|
||||||
|
/// Human-readable reason shown to the peer and logged.
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Machine-readable categories for handshake rejection.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum RejectReason {
|
||||||
|
/// Client protocol version does not match the server's.
|
||||||
|
ProtocolMismatch,
|
||||||
|
/// Client is missing required packs or has incompatible versions.
|
||||||
|
PackMismatch,
|
||||||
|
/// Client failed to authenticate.
|
||||||
|
AuthFailed,
|
||||||
|
/// Client is banned from the server.
|
||||||
|
Banned,
|
||||||
|
/// Server is full.
|
||||||
|
Full,
|
||||||
|
/// Server encountered an internal error during handshake.
|
||||||
|
ServerError,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identity presented by the player to the server.
|
||||||
|
// TODO: use authenticated identity once the Account system exists.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct PlayerIdentity {
|
||||||
|
/// Human-readable display name.
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reference to a content pack (resource pack, data pack, or Lua mod) as it appears in a modlist exchanged during the handshake.
|
||||||
|
///
|
||||||
|
/// Identity is the pair (`id`, `content_hash`): `id` names the pack and `content_hash` is the authoritative value compared when deciding whether two installations agree. `version` is informational (display, logs, upgrade prompts) and is **not** the match key — two builds sharing a version but differing in contents are distinct packs.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct PackRef {
|
||||||
|
/// Namespaced content identifier of the pack (`namespace:id`). Charset validation is deferred to the modlist-matching concept (out of M1 scope).
|
||||||
|
pub id: String,
|
||||||
|
/// Human-readable semantic version. Informational only; not the match key.
|
||||||
|
pub version: String,
|
||||||
|
/// Canonical hash of the pack contents; the authoritative match key.
|
||||||
|
// TODO: pin the canonical hashing procedure (traversal order, newline normalization) so independent builds of one pack hash identically.
|
||||||
|
pub content_hash: [u8; 32],
|
||||||
|
/// Tier the pack was classified into, which governs whether a client/server mismatch on this pack is fatal or tolerated. Inferred by the owner from the pack's folder contents (see Load order), never self-declared.
|
||||||
|
pub tier: PackTier,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classification of a content pack, determining the handshake matching rule applied to it. Inferred from folder contents, not self-declared: `assets/`-only is a resource pack, `data/`-only is a data pack, presence of `scripts/` is a Lua mod.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum PackTier {
|
||||||
|
/// Client-only asset overlay (`assets/` only). Never streamed by the server, never matched.
|
||||||
|
Resource,
|
||||||
|
/// Declarative content (`data/` only). Must match exactly between peers.
|
||||||
|
Data,
|
||||||
|
/// Lua mod (`scripts/`, optionally `data/` and `assets/`); full API access.
|
||||||
|
Mod {
|
||||||
|
/// Set when the mod ships no `data/` and every system is `scope = "client"`, so a client/server mismatch on it cannot desync authoritative state and is therefore tolerated. Not trusted blindly by the server for packs carrying data or server-scoped systems.
|
||||||
|
client_only: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PackTier {
|
||||||
|
/// Returns whether a pack of this tier must match byte-for-byte between client and server for the connection to be accepted. Resource packs are never matched; data packs and non-`client_only` mods must match exactly (see Load order § modlist matching).
|
||||||
|
#[must_use]
|
||||||
|
pub fn requires_strict_match(self) -> bool {
|
||||||
|
match self {
|
||||||
|
PackTier::Resource => false,
|
||||||
|
PackTier::Data => true,
|
||||||
|
PackTier::Mod { client_only } => !client_only,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pack the server's world requires, paired with an optional out-of-band download source. Sent server → client in the handshake; the client fetches any it lacks via the URL when present, otherwise over the QUIC asset stream.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct RequiredPack {
|
||||||
|
/// Identity and tier of the required pack.
|
||||||
|
pub pack: PackRef,
|
||||||
|
/// Optional HTTP(S) URL to fetch the pack from, bypassing the QUIC asset stream for large downloads. `None` means fetch over the asset stream.
|
||||||
|
pub download_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional protocol feature bits.
|
||||||
|
#[repr(transparent)]
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
pub struct FeatureFlags(pub u32);
|
||||||
|
|
||||||
|
/// Mapping of logical purposes to QUIC stream IDs.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct StreamLayout {
|
||||||
|
/// Stream ID for control messages (handshake, disconnect).
|
||||||
|
pub control: u8,
|
||||||
|
/// Stream ID for client input to server.
|
||||||
|
pub input: u8,
|
||||||
|
/// Stream ID for server authoritative state updates.
|
||||||
|
pub authority: u8,
|
||||||
|
/// Stream ID for highest detail chunk updates (LOD0).
|
||||||
|
pub chunk_lod0: u8,
|
||||||
|
/// Stream ID for chunk updates (LOD1).
|
||||||
|
pub chunk_lod1: u8,
|
||||||
|
/// Stream ID for chunk updates (LOD2).
|
||||||
|
pub chunk_lod2: u8,
|
||||||
|
/// Stream ID for chunk updates (LOD3).
|
||||||
|
pub chunk_lod3: u8,
|
||||||
|
/// Stream ID for lowest detail chunk updates (LOD4).
|
||||||
|
pub chunk_lod4: u8,
|
||||||
|
/// Stream ID for downloading assets.
|
||||||
|
pub asset: u8,
|
||||||
|
/// Stream ID for downloading mod scripts.
|
||||||
|
pub mod_data: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StreamLayout {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
control: 0,
|
||||||
|
input: 1,
|
||||||
|
authority: 2,
|
||||||
|
chunk_lod0: 3,
|
||||||
|
chunk_lod1: 4,
|
||||||
|
chunk_lod2: 5,
|
||||||
|
chunk_lod3: 6,
|
||||||
|
chunk_lod4: 7,
|
||||||
|
asset: 8,
|
||||||
|
mod_data: 9,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_layout_default_is_canonical() {
|
||||||
|
let layout = StreamLayout::default();
|
||||||
|
assert_eq!(layout.control, 0);
|
||||||
|
assert_eq!(layout.input, 1);
|
||||||
|
assert_eq!(layout.authority, 2);
|
||||||
|
assert_eq!(layout.chunk_lod0, 3);
|
||||||
|
assert_eq!(layout.chunk_lod1, 4);
|
||||||
|
assert_eq!(layout.chunk_lod2, 5);
|
||||||
|
assert_eq!(layout.chunk_lod3, 6);
|
||||||
|
assert_eq!(layout.chunk_lod4, 7);
|
||||||
|
assert_eq!(layout.asset, 8);
|
||||||
|
assert_eq!(layout.mod_data, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn roundtrip_test(msg: &ControlMessage) -> Result<(), postcard::Error> {
|
||||||
|
let bytes = postcard::to_stdvec(msg)?;
|
||||||
|
let decoded: ControlMessage = postcard::from_bytes(&bytes)?;
|
||||||
|
assert_eq!(msg, &decoded);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_client_hello() -> Result<(), postcard::Error> {
|
||||||
|
let msg = ControlMessage::ClientHello(ClientHello {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
client_build: "synvael-client-0.1.0".to_string(),
|
||||||
|
player_identity: PlayerIdentity {
|
||||||
|
display_name: "Player1".to_string(),
|
||||||
|
},
|
||||||
|
installed_packs: vec![PackRef {
|
||||||
|
id: "core:base".to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
content_hash: [0; 32],
|
||||||
|
tier: PackTier::Data,
|
||||||
|
}],
|
||||||
|
requested_features: FeatureFlags(0),
|
||||||
|
});
|
||||||
|
roundtrip_test(&msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_handshake_ack() -> Result<(), postcard::Error> {
|
||||||
|
let msg = ControlMessage::HandshakeAck(HandshakeAck {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
server_build: "synvael-server-0.1.0".to_string(),
|
||||||
|
world_packs: vec![],
|
||||||
|
missing_packs: vec![],
|
||||||
|
stream_layout: StreamLayout::default(),
|
||||||
|
tick_rate_hint: 20,
|
||||||
|
});
|
||||||
|
roundtrip_test(&msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_handshake_reject() -> Result<(), postcard::Error> {
|
||||||
|
let msg = ControlMessage::HandshakeReject(HandshakeReject {
|
||||||
|
reason: RejectReason::ProtocolMismatch,
|
||||||
|
detail: "Expected v1, got v2".to_string(),
|
||||||
|
upgrade_url: Some("https://synvael.example/download".to_string()),
|
||||||
|
});
|
||||||
|
roundtrip_test(&msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pack_tier_match_rules() {
|
||||||
|
// Resource packs are client-side overlays: a mismatch is always tolerated.
|
||||||
|
assert!(!PackTier::Resource.requires_strict_match());
|
||||||
|
// Data packs affect authoritative content and must match exactly.
|
||||||
|
assert!(PackTier::Data.requires_strict_match());
|
||||||
|
// A client-only mod cannot desync server state, so a mismatch is tolerated.
|
||||||
|
assert!(!PackTier::Mod { client_only: true }.requires_strict_match());
|
||||||
|
// A server-affecting mod must match exactly.
|
||||||
|
assert!(PackTier::Mod { client_only: false }.requires_strict_match());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_disconnect() -> Result<(), postcard::Error> {
|
||||||
|
let msg = ControlMessage::Disconnect(Disconnect {
|
||||||
|
reason: "Server closing".to_string(),
|
||||||
|
});
|
||||||
|
roundtrip_test(&msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue