feat(shared): add network control-protocol message types

This commit is contained in:
Serkyo 2026-07-12 13:33:36 +02:00
parent ae33251c70
commit 41ba2e5d5c
2 changed files with 284 additions and 0 deletions

View file

@ -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;

View 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)
}
}