286 lines
13 KiB
Rust
286 lines
13 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
//! Dedicated server for Synvael.
|
|
//!
|
|
//! The server handles the authoritative game simulation, including world management, physics, and combat.
|
|
|
|
/// A bounded LRU cache of regenerated chunk baselines, shared across the worker pool.
|
|
pub mod chunk_cache;
|
|
/// Per-connection chunk-streaming state: desired-set tracking and delivery.
|
|
pub mod client_stream;
|
|
/// Entity components describing players and other world-streaming anchors.
|
|
pub mod player;
|
|
/// On-disk persistence: region files and the atomic durability layer.
|
|
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.
|
|
pub mod tick_stats;
|
|
/// Authoritative chunk storage and generation logic for the server.
|
|
pub mod world_server;
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::fs;
|
|
use std::net::{Ipv4Addr, SocketAddr};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::Context;
|
|
use bevy_ecs::prelude::{Query, ResMut, Schedule, With, Without, World};
|
|
use glam::Vec3;
|
|
use shared::generator::{VoxelGenerator, WorldGenConfig};
|
|
use shared::protocol::authority::ServerStats;
|
|
use shared::world::{Chunk, ChunkPos, EntityPos};
|
|
use tracing::{debug, info, trace, warn};
|
|
|
|
use client_stream::ClientStream;
|
|
use net::{NetworkServer, ServerEvent};
|
|
use player::{Player, Position, ViewDistance};
|
|
use tick_stats::{TickMeter, TickWindow};
|
|
use world_server::{ServerWorld, cylinder_chunks};
|
|
|
|
/// Nominal simulation rate, in ticks per second. Sole source of truth for both the loop's target period and the advisory rate advertised to clients in the handshake.
|
|
// TODO: make configurable through server configs once the real tick scheduler lands.
|
|
const TICK_RATE_HZ: u16 = 20;
|
|
|
|
/// 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);
|
|
|
|
/// 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.
|
|
fn stream_chunks(
|
|
anchors: Query<(&Position, &ViewDistance), With<Player>>,
|
|
mut world: ResMut<ServerWorld>,
|
|
) {
|
|
// Desired set is the union of every anchor's cylinder; a chunk survives as long as it lies within any one player's view.
|
|
let mut desired = HashSet::new();
|
|
for (position, view) in &anchors {
|
|
cylinder_chunks(position.0.chunk, view.0, &mut desired);
|
|
}
|
|
|
|
world.reconcile(&desired);
|
|
}
|
|
|
|
fn main() -> anyhow::Result<()> {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("debug")),
|
|
)
|
|
.init();
|
|
|
|
info!("Starting Synvael server");
|
|
|
|
let config_str = fs::read_to_string("assets/data/worldgen/default.json")
|
|
.context("reading worldgen config assets/data/worldgen/default.json")?;
|
|
|
|
let worldgen_config: WorldGenConfig =
|
|
serde_json::from_str(&config_str).context("parsing worldgen config as JSON")?;
|
|
|
|
info!("Successfully loaded world configuration");
|
|
debug!("Base height: {}", worldgen_config.base_height);
|
|
debug!("Noise scale: {}", worldgen_config.noise_scale);
|
|
debug!("Surface block: {}", worldgen_config.surface_block.0);
|
|
debug!("Subsurface block: {}", worldgen_config.subsurface_block.0);
|
|
debug!("Stone block: {}", worldgen_config.stone_block.0);
|
|
|
|
let seed = 4_813_530;
|
|
|
|
let generator = VoxelGenerator::new(worldgen_config, seed);
|
|
|
|
// The region directory holds the `.region` save files for this world
|
|
// TODO: resolve it per named world under a shared save root.
|
|
let region_dir = std::path::PathBuf::from("saves/default/region");
|
|
|
|
// Number of chunk baselines the worker pool retains before evicting the least-recently-used entry
|
|
// TODO: make this configurable through server configs
|
|
let cache_capacity =
|
|
std::num::NonZeroUsize::new(4_096).context("chunk cache capacity is non-zero")?;
|
|
|
|
let mut world = World::new();
|
|
world.insert_resource(ServerWorld::new(generator, region_dir, cache_capacity));
|
|
|
|
// Spawn a single dummy player anchor at the world origin.
|
|
world.spawn((
|
|
Player,
|
|
Position(EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::ZERO)),
|
|
ViewDistance(4),
|
|
));
|
|
|
|
// A schedule is one tick's worth of systems; running it advances the world.
|
|
let mut schedule = Schedule::default();
|
|
schedule.add_systems(stream_chunks);
|
|
|
|
// Loading phase: dispatch the initial region and wait for the worker pool to finish before granting control.
|
|
info!("Streaming initial region");
|
|
// The loop below spins as fast as the worker pool is polled, so progress is reported only when the resident count actually advances. Logging every iteration would emit thousands of identical lines before the endpoint is even bound.
|
|
let mut last_reported = usize::MAX;
|
|
loop {
|
|
schedule.run(&mut world);
|
|
|
|
let server_world = world.resource::<ServerWorld>();
|
|
let resident = server_world.loaded_count();
|
|
let in_flight = server_world.in_flight_count();
|
|
if resident != last_reported {
|
|
// Loading progress is simply the resident fraction of all known chunks.
|
|
let total = resident + in_flight;
|
|
debug!(resident, in_flight, total, "loading progress");
|
|
last_reported = resident;
|
|
}
|
|
|
|
// The region is ready once at least one chunk has been generated and none remain in flight.
|
|
if server_world.streaming_idle() && resident > 0 {
|
|
break;
|
|
}
|
|
}
|
|
info!("Initial region ready; granting player control");
|
|
|
|
// Spawn the networking thread and bind the QUIC endpoint. The synchronous simulation loop below communicates with it only by draining events.
|
|
let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, net::DEFAULT_PORT));
|
|
let (network, local_addr) =
|
|
NetworkServer::spawn(bind, env!("CARGO_PKG_VERSION").to_owned(), TICK_RATE_HZ)
|
|
.context("spawning network server")?;
|
|
info!(%local_addr, "network endpoint listening");
|
|
|
|
run_simulation(&mut world, &network)
|
|
}
|
|
|
|
/// Assembles the diagnostics snapshot pushed to clients at the end of a measurement window.
|
|
///
|
|
/// The world and ECS figures are read at the moment of the call rather than averaged over the window: they describe a level of occupancy, for which the current value is the meaningful reading. Only the timing figures in `window` are aggregates.
|
|
fn collect_server_stats(
|
|
world: &mut World,
|
|
window: &TickWindow,
|
|
connected_clients: usize,
|
|
started_at: Instant,
|
|
) -> ServerStats {
|
|
let (loaded_chunks, chunks_in_flight) = {
|
|
let server_world = world.resource::<ServerWorld>();
|
|
(server_world.loaded_count(), server_world.in_flight_count())
|
|
};
|
|
let players = world
|
|
.query_filtered::<(), With<Player>>()
|
|
.iter(world)
|
|
.count();
|
|
let entities = world
|
|
.query_filtered::<(), (With<Position>, Without<Player>)>()
|
|
.iter(world)
|
|
.count();
|
|
|
|
ServerStats {
|
|
measured_tps: window.measured_tps,
|
|
mean_tick_ms: window.mean_tick_ms,
|
|
max_tick_ms: window.max_tick_ms,
|
|
tick_budget_percent: window.tick_budget_percent,
|
|
loaded_chunks: u32::try_from(loaded_chunks).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),
|
|
entities: u32::try_from(entities).unwrap_or(u32::MAX),
|
|
players: u32::try_from(players).unwrap_or(u32::MAX),
|
|
uptime_secs: started_at.elapsed().as_secs(),
|
|
}
|
|
}
|
|
|
|
/// Emits one measurement window's diagnostics to the log.
|
|
///
|
|
/// 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) {
|
|
info!("\n{}", stats::format_panel(stats));
|
|
}
|
|
|
|
/// Runs the authoritative simulation loop forever, at the fixed cadence given by [`TICK_PERIOD`].
|
|
fn run_simulation(world: &mut World, network: &NetworkServer) -> ! {
|
|
// Chunk diffs are computed against an all-air baseline so each delivered payload is self-contained: the client renders only server-owned content and has no generator to reconstruct a worldgen baseline. Allocated once and shared across every delivery.
|
|
let empty_baseline = Chunk::default();
|
|
|
|
// Per-connection streaming state, keyed by the stable session id the network thread assigns.
|
|
let mut clients: HashMap<u64, ClientStream> = HashMap::new();
|
|
|
|
let started_at = Instant::now();
|
|
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 {
|
|
let tick_start = Instant::now();
|
|
|
|
// Fold network events into per-client subscription state.
|
|
for event in network.poll_events() {
|
|
match event {
|
|
ServerEvent::ClientConnected {
|
|
id,
|
|
hello,
|
|
chunks,
|
|
authority,
|
|
} => {
|
|
info!(id, name = %hello.player_identity.display_name, "client connected");
|
|
clients.insert(id, ClientStream::new(chunks, authority));
|
|
}
|
|
ServerEvent::ClientDisconnected { id, reason } => {
|
|
info!(id, %reason, "client disconnected");
|
|
clients.remove(&id);
|
|
}
|
|
ServerEvent::ChunkSubscribe { id, request } => {
|
|
if let Some(client) = clients.get_mut(&id) {
|
|
let (added, drops) = client.resubscribe(request.center, request.radius);
|
|
// Fires on every chunk boundary the client crosses, so it sits below the connect and disconnect events rather than beside them.
|
|
debug!(id, added, drops, "client resubscribed");
|
|
} else {
|
|
warn!(id, "chunk subscribe from unknown session");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reconcile the resident world to the union of every client's desired set. A chunk survives as long as any connected client wants it; when no client is connected the union is empty and the world drains.
|
|
let mut desired = HashSet::new();
|
|
for client in clients.values() {
|
|
desired.extend(client.desired().iter().copied());
|
|
}
|
|
world.resource_mut::<ServerWorld>().reconcile(&desired);
|
|
|
|
// Deliver newly-resident chunks to each client. Loads dispatched above may not be resident this tick; `flush` retries on later ticks until the worker pool returns them.
|
|
let server_world = world.resource::<ServerWorld>();
|
|
for (id, client) in &mut clients {
|
|
let delivered = client.flush(server_world, &empty_baseline);
|
|
if delivered > 0 {
|
|
// Per-tick and per-client, so it sits below the default filter: the aggregate chunk figures in the status line cover routine operation, and this level is for tracing an individual client's deliveries.
|
|
trace!(id, delivered, "delivered resident chunks");
|
|
}
|
|
}
|
|
|
|
// Sleep only the unused remainder of the tick's budget, so the period stays [`TICK_PERIOD`] rather than growing with the cost of the work above. A tick that overruns its budget does not sleep at all; the overrun is reported because it is the signal that the server is falling behind its nominal rate.
|
|
let elapsed = tick_start.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.
|
|
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);
|
|
|
|
// 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() {
|
|
client.send_stats(stats);
|
|
}
|
|
}
|
|
|
|
if elapsed > TICK_PERIOD {
|
|
warn!(
|
|
elapsed_ms = elapsed.as_secs_f32() * 1000.0,
|
|
budget_ms = TICK_PERIOD.as_secs_f32() * 1000.0,
|
|
"tick overran its budget"
|
|
);
|
|
} else {
|
|
// `saturating_sub` cannot underflow here (the branch already establishes `elapsed <= TICK_PERIOD`) and is used because `Duration` subtraction panics on overflow.
|
|
std::thread::sleep(TICK_PERIOD.saturating_sub(elapsed));
|
|
}
|
|
}
|
|
}
|