refactor(server): format the simulation statistics as a panel

This commit is contained in:
Serkyo 2026-08-02 04:36:12 +02:00
parent 3f1640ce09
commit d33fb518fa
4 changed files with 113 additions and 19 deletions

View file

@ -12,6 +12,8 @@ pub mod client_stream;
pub mod player; pub mod player;
/// On-disk persistence: region files and the atomic durability layer. /// On-disk persistence: region files and the atomic durability layer.
pub mod save; pub mod save;
/// Formatting of the periodic simulation statistics report.
pub mod stats;
/// Measurement of the simulation loop's achieved tick rate and per-tick cost. /// Measurement of the simulation loop's achieved tick rate and per-tick cost.
pub mod tick_stats; pub mod tick_stats;
/// Authoritative chunk storage and generation logic for the server. /// Authoritative chunk storage and generation logic for the server.
@ -23,7 +25,7 @@ use std::net::{Ipv4Addr, SocketAddr};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use anyhow::Context; use anyhow::Context;
use bevy_ecs::prelude::{Query, ResMut, Schedule, With, World}; use bevy_ecs::prelude::{Query, ResMut, Schedule, With, Without, World};
use glam::Vec3; use glam::Vec3;
use shared::generator::{VoxelGenerator, WorldGenConfig}; use shared::generator::{VoxelGenerator, WorldGenConfig};
use shared::protocol::authority::ServerStats; use shared::protocol::authority::ServerStats;
@ -43,6 +45,10 @@ const TICK_RATE_HZ: u16 = 20;
/// Target wall-clock period of one simulation tick, derived from [`TICK_RATE_HZ`]. /// Target wall-clock period of one simulation tick, derived from [`TICK_RATE_HZ`].
const TICK_PERIOD: Duration = Duration::from_millis(1000 / TICK_RATE_HZ as u64); const TICK_PERIOD: Duration = Duration::from_millis(1000 / TICK_RATE_HZ as u64);
/// Wall-clock period between statistics panels written to the log.
// TODO: make configurable through server configs, alongside the tick rate.
const STATUS_INTERVAL: Duration = Duration::from_mins(1);
/// Streaming system: loads and unloads chunks so that the resident set matches the union of the cylinders around every player anchor. /// Streaming system: loads and unloads chunks so that the resident set matches the union of the cylinders around every player anchor.
fn stream_chunks( fn stream_chunks(
anchors: Query<(&Position, &ViewDistance), With<Player>>, anchors: Query<(&Position, &ViewDistance), With<Player>>,
@ -158,7 +164,10 @@ fn collect_server_stats(
.query_filtered::<(), With<Player>>() .query_filtered::<(), With<Player>>()
.iter(world) .iter(world)
.count(); .count();
let entities = world.entities().len(); let entities = world
.query_filtered::<(), (With<Position>, Without<Player>)>()
.iter(world)
.count();
ServerStats { ServerStats {
measured_tps: window.measured_tps, measured_tps: window.measured_tps,
@ -168,7 +177,7 @@ fn collect_server_stats(
loaded_chunks: u32::try_from(loaded_chunks).unwrap_or(u32::MAX), loaded_chunks: u32::try_from(loaded_chunks).unwrap_or(u32::MAX),
chunks_in_flight: u32::try_from(chunks_in_flight).unwrap_or(u32::MAX), chunks_in_flight: u32::try_from(chunks_in_flight).unwrap_or(u32::MAX),
connected_clients: u32::try_from(connected_clients).unwrap_or(u32::MAX), connected_clients: u32::try_from(connected_clients).unwrap_or(u32::MAX),
entities, entities: u32::try_from(entities).unwrap_or(u32::MAX),
players: u32::try_from(players).unwrap_or(u32::MAX), players: u32::try_from(players).unwrap_or(u32::MAX),
uptime_secs: started_at.elapsed().as_secs(), uptime_secs: started_at.elapsed().as_secs(),
} }
@ -178,19 +187,7 @@ fn collect_server_stats(
/// ///
/// A dedicated server is headless and has no statistics panel to read, so the same snapshot pushed to clients is also reported locally. The cadence is the measurement window rather than the tick, which keeps the line rare enough to leave the log readable while still surfacing a rate collapse within a second. /// A dedicated server is headless and has no statistics panel to read, so the same snapshot pushed to clients is also reported locally. The cadence is the measurement window rather than the tick, which keeps the line rare enough to leave the log readable while still surfacing a rate collapse within a second.
fn report_statistics(stats: &ServerStats) { fn report_statistics(stats: &ServerStats) {
info!( info!("\n{}", stats::format_panel(stats));
tps = stats.measured_tps,
mean_tick_ms = stats.mean_tick_ms,
max_tick_ms = stats.max_tick_ms,
budget_percent = stats.tick_budget_percent,
chunks = stats.loaded_chunks,
in_flight = stats.chunks_in_flight,
clients = stats.connected_clients,
entities = stats.entities,
players = stats.players,
uptime_secs = stats.uptime_secs,
"simulation status"
);
} }
/// Runs the authoritative simulation loop forever, at the fixed cadence given by [`TICK_PERIOD`]. /// Runs the authoritative simulation loop forever, at the fixed cadence given by [`TICK_PERIOD`].
@ -203,6 +200,8 @@ fn run_simulation(world: &mut World, network: &NetworkServer) -> ! {
let started_at = Instant::now(); let started_at = Instant::now();
let mut meter = TickMeter::new(started_at, TICK_PERIOD); let mut meter = TickMeter::new(started_at, TICK_PERIOD);
// Seeded at startup so the first panel appears one interval in, rather than immediately on a world that has not yet settled.
let mut last_status = started_at;
loop { loop {
let tick_start = Instant::now(); let tick_start = Instant::now();
@ -259,9 +258,16 @@ fn run_simulation(world: &mut World, network: &NetworkServer) -> ! {
meter.record(elapsed); meter.record(elapsed);
// Diagnostics are pushed on the authority stream once per measurement window, not per tick: the figures describe the window, and per-tick delivery would be pure waste. // Diagnostics are pushed on the authority stream once per measurement window, not per tick: the figures describe the window, and per-tick delivery would be pure waste.
if let Some(window) = meter.take_window(tick_start + elapsed) { let window_end = tick_start + elapsed;
if let Some(window) = meter.take_window(window_end) {
let stats = collect_server_stats(world, &window, clients.len(), started_at); let stats = collect_server_stats(world, &window, clients.len(), started_at);
report_statistics(&stats);
// Every window reaches the clients, which display it live; the log takes one panel per [`STATUS_INTERVAL`].
if window_end.duration_since(last_status) >= STATUS_INTERVAL {
report_statistics(&stats);
last_status = window_end;
}
for client in clients.values() { for client in clients.values() {
client.send_stats(stats); client.send_stats(stats);
} }

View file

@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Formatting of the periodic simulation statistics report.
use std::fmt::Write as _;
use shared::protocol::authority::ServerStats;
/// Renders one measurement window's diagnostics as a multi-line panel.
#[must_use]
pub fn format_panel(stats: &ServerStats) -> String {
let mut out = String::with_capacity(256);
// `write!` into a String cannot fail, so the results are discarded rather than propagated.
let _ = writeln!(out, "── server statistics ──");
let _ = writeln!(
out,
"tick {:.1} tps mean {:.2} ms max {:.2} ms budget {:.0}% uptime {} s",
stats.measured_tps,
stats.mean_tick_ms,
stats.max_tick_ms,
stats.tick_budget_percent,
stats.uptime_secs
);
let _ = write!(
out,
"world chunks {} resident / {} in flight clients {} entities {} players {}",
stats.loaded_chunks,
stats.chunks_in_flight,
stats.connected_clients,
stats.entities,
stats.players
);
out
}
#[cfg(test)]
#[path = "tests/stats.rs"]
mod tests;

View file

@ -0,0 +1,48 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Unit tests for the statistics panel formatter in [`crate::stats`].
use super::*;
/// Builds a snapshot with distinguishable values in every field.
fn sample() -> ServerStats {
ServerStats {
measured_tps: 19.96,
mean_tick_ms: 1.234,
max_tick_ms: 4.567,
tick_budget_percent: 24.7,
loaded_chunks: 421,
chunks_in_flight: 3,
connected_clients: 2,
entities: 5,
players: 1,
uptime_secs: 42,
}
}
#[test]
fn every_field_of_the_snapshot_reaches_the_panel() {
let panel = format_panel(&sample());
assert!(panel.contains("20.0 tps"), "{panel}");
assert!(panel.contains("mean 1.23 ms"), "{panel}");
assert!(panel.contains("max 4.57 ms"), "{panel}");
assert!(panel.contains("budget 25%"), "{panel}");
assert!(panel.contains("uptime 42 s"), "{panel}");
assert!(
panel.contains("chunks 421 resident / 3 in flight"),
"{panel}"
);
assert!(panel.contains("clients 2"), "{panel}");
assert!(panel.contains("entities 5"), "{panel}");
assert!(panel.contains("players 1"), "{panel}");
}
#[test]
fn the_panel_is_a_header_and_two_rows_without_a_trailing_newline() {
let panel = format_panel(&sample());
assert_eq!(panel.lines().count(), 3);
// The caller supplies the leading newline, so a trailing one would open a blank line in the log.
assert!(!panel.ends_with('\n'), "{panel}");
}

View file

@ -32,7 +32,7 @@ pub struct ServerStats {
pub chunks_in_flight: u32, pub chunks_in_flight: u32,
/// Clients with an established session. /// Clients with an established session.
pub connected_clients: u32, pub connected_clients: u32,
/// Entities in the server's ECS world, players included. /// Non-player entities occupying the world.
pub entities: u32, pub entities: u32,
/// Players currently in the world. /// Players currently in the world.
pub players: u32, pub players: u32,