diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index f12ea88..33bb1fd 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -7,4 +7,5 @@ pub mod generator; pub mod protocol; pub mod save; +pub mod session; pub mod world; diff --git a/crates/shared/src/protocol.rs b/crates/shared/src/protocol.rs index e12ff53..539a7aa 100644 --- a/crates/shared/src/protocol.rs +++ b/crates/shared/src/protocol.rs @@ -2,8 +2,9 @@ //! Network protocol types and constants. //! -//! The module is split by stream purpose: [`control`]-stream handshake and disconnect messages, and the [`chunk`]-sync request/delivery messages. Control-stream types are re-exported here so callers continue to refer to `shared::protocol::` regardless of the internal layout, while the chunk types stay namespaced under `shared::protocol::chunk` to keep the two protocols visually distinct. +//! The module is split by stream purpose: [`control`]-stream handshake and disconnect messages, and the [`chunk`]-sync request/delivery messages, and the periodic [`authority`]-stream state. Control-stream types are re-exported here so callers continue to refer to `shared::protocol::` regardless of the internal layout, while the chunk types stay namespaced under `shared::protocol::chunk` to keep the two protocols visually distinct. +pub mod authority; pub mod chunk; mod control; diff --git a/crates/shared/src/protocol/authority.rs b/crates/shared/src/protocol/authority.rs new file mode 100644 index 0000000..acbabea --- /dev/null +++ b/crates/shared/src/protocol/authority.rs @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Authority-stream messages: periodic state pushed from server to client. +//! +//! The authority stream ([`StreamLayout::authority`](super::StreamLayout::authority), id 2) carries state the server is the sole authority over and pushes without being asked. Diagnostics are the first such payload; simulation snapshots will join them on the same stream. + +use serde::{Deserialize, Serialize}; + +/// Messages carried on the authority stream (stream 2): periodic server-authoritative state. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum AuthorityMessage { + /// Periodic simulation diagnostics, emitted on a fixed wall-clock cadence rather than per tick. + ServerStats(ServerStats), +} + +/// A snapshot of the server's simulation health, sent roughly once per second. +/// +/// The measured figures exist because the nominal tick rate advertised in [`HandshakeAck::tick_rate_hint`](super::HandshakeAck::tick_rate_hint) is a constant: it states what the server intends to run at and can never reveal that it is falling behind. Everything here is observed. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)] +pub struct ServerStats { + /// Ticks actually completed in the reporting window, expressed per second. Below the nominal rate when the server is overrunning its budget. + pub measured_tps: f32, + /// Mean wall-clock duration of a tick body in the reporting window, in milliseconds, excluding the sleep that pads the tick out to its period. + pub mean_tick_ms: f32, + /// Longest tick body observed in the reporting window, in milliseconds. A mean within budget alongside a spiking maximum indicates intermittent stalls rather than sustained overload. + pub max_tick_ms: f32, + /// Share of the nominal tick period consumed by the mean tick body, in percent. Values at or above 100 mean the server no longer has headroom. + pub tick_budget_percent: f32, + /// Chunks resident in the server's world cache. + pub loaded_chunks: u32, + /// Chunk generation jobs outstanding in the server's worker pool. + pub chunks_in_flight: u32, + /// Clients with an established session. + pub connected_clients: u32, + /// Entities in the server's ECS world, players included. + pub entities: u32, + /// Players currently in the world. + pub players: u32, + /// Wall-clock time since the simulation loop started, in seconds. + pub uptime_secs: u64, +} + +#[cfg(test)] +#[path = "../tests/protocol_authority.rs"] +mod tests; diff --git a/crates/shared/src/protocol/control.rs b/crates/shared/src/protocol/control.rs index 54a9338..2965863 100644 --- a/crates/shared/src/protocol/control.rs +++ b/crates/shared/src/protocol/control.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; /// Wire-protocol version. Incremented on any breaking change to the message layout below. -pub const PROTOCOL_VERSION: u32 = 1; +pub const PROTOCOL_VERSION: u32 = 2; /// Messages carried on the control stream (stream 0): handshake and disconnect. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/crates/shared/src/session.rs b/crates/shared/src/session.rs new file mode 100644 index 0000000..4430303 --- /dev/null +++ b/crates/shared/src/session.rs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Session-level concepts describing the relationship between a client and the server it is playing against. + +/// Which kind of server a client session is running against. +/// +/// Deliberately not part of the wire protocol. The client already knows the answer without asking: it either spawned a server in-process or dialled a socket. A server-declared field would be redundant at best and spoofable at worst, so the value is constructed client-side from facts the client already holds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServerKind { + /// The server runs in this process, backing single-player. + Integrated, + /// The server is a separate process reached over the network. + Dedicated { + /// Whether the server's address is off this machine. Decided from the `SocketAddr` the client dialled, not from anything the server says. + remote: bool, + }, +} + +impl ServerKind { + /// Classifies a dedicated server from the address the client dialled. + /// + /// A loopback address means the process is on this machine (a locally hosted server), which is distinct from an integrated one: it is still a separate process reached over a socket. + #[must_use] + pub const fn dedicated(addr: std::net::SocketAddr) -> Self { + Self::Dedicated { + remote: !addr.ip().is_loopback(), + } + } + + /// Returns a short human-readable label for the session's server kind. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Integrated => "integrated", + Self::Dedicated { remote: false } => "dedicated (local)", + Self::Dedicated { remote: true } => "dedicated (remote)", + } + } +} + +#[cfg(test)] +#[path = "tests/session.rs"] +mod tests; diff --git a/crates/shared/src/tests/protocol_authority.rs b/crates/shared/src/tests/protocol_authority.rs new file mode 100644 index 0000000..beb405a --- /dev/null +++ b/crates/shared/src/tests/protocol_authority.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Wire round-trip tests for the authority-stream messages. + +use super::*; + +#[test] +fn roundtrip_server_stats() -> Result<(), postcard::Error> { + let msg = AuthorityMessage::ServerStats(ServerStats { + measured_tps: 19.4, + mean_tick_ms: 12.5, + max_tick_ms: 48.0, + tick_budget_percent: 25.0, + loaded_chunks: 4096, + chunks_in_flight: 12, + connected_clients: 1, + entities: 37, + players: 1, + uptime_secs: 3600, + }); + + let bytes = postcard::to_stdvec(&msg)?; + let decoded: AuthorityMessage = postcard::from_bytes(&bytes)?; + assert_eq!(decoded, msg); + Ok(()) +} + +#[test] +fn default_stats_survive_a_round_trip() { + // The default is what a server reports before its first window has closed; it must decode as cleanly as a populated one. + let msg = AuthorityMessage::ServerStats(ServerStats::default()); + let bytes = postcard::to_stdvec(&msg).unwrap_or_default(); + assert_eq!( + postcard::from_bytes::(&bytes).ok(), + Some(msg) + ); +} diff --git a/crates/shared/src/tests/session.rs b/crates/shared/src/tests/session.rs new file mode 100644 index 0000000..0536ff5 --- /dev/null +++ b/crates/shared/src/tests/session.rs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Unit tests for server-kind classification. + +use super::*; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; + +#[test] +fn loopback_addresses_classify_as_local() { + let v4 = SocketAddr::from((Ipv4Addr::LOCALHOST, 25565)); + let v6 = SocketAddr::from((Ipv6Addr::LOCALHOST, 25565)); + + assert_eq!( + ServerKind::dedicated(v4), + ServerKind::Dedicated { remote: false } + ); + assert_eq!( + ServerKind::dedicated(v6), + ServerKind::Dedicated { remote: false } + ); +} + +#[test] +fn non_loopback_addresses_classify_as_remote() { + let addr = SocketAddr::from((Ipv4Addr::new(10, 0, 0, 4), 25565)); + assert_eq!( + ServerKind::dedicated(addr), + ServerKind::Dedicated { remote: true } + ); +} + +#[test] +fn each_kind_has_a_distinct_label() { + assert_eq!(ServerKind::Integrated.label(), "integrated"); + assert_eq!( + ServerKind::Dedicated { remote: false }.label(), + "dedicated (local)" + ); + assert_eq!( + ServerKind::Dedicated { remote: true }.label(), + "dedicated (remote)" + ); +}