diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index cfa695b..f12ea88 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -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; diff --git a/crates/shared/src/protocol.rs b/crates/shared/src/protocol.rs new file mode 100644 index 0000000..399d73c --- /dev/null +++ b/crates/shared/src/protocol.rs @@ -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, + /// 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, + /// Packs the client is missing relative to the server, each with an optional download source. Empty in M1. + pub missing_packs: Vec, + /// 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, +} + +/// 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, +} + +/// 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) + } +}