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.
|
||||
|
||||
pub mod generator;
|
||||
pub mod protocol;
|
||||
pub mod save;
|
||||
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