feat(shared)!: add server stats message and kind

This commit is contained in:
Serkyo 2026-07-31 03:14:20 +02:00
parent 8b031de071
commit 01f566e824
7 changed files with 172 additions and 2 deletions

View file

@ -7,4 +7,5 @@
pub mod generator;
pub mod protocol;
pub mod save;
pub mod session;
pub mod world;

View file

@ -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::<Type>` 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::<Type>` 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;

View file

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

View file

@ -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)]

View file

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

View file

@ -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::<AuthorityMessage>(&bytes).ok(),
Some(msg)
);
}

View file

@ -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)"
);
}