263 lines
11 KiB
Rust
263 lines
11 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;
|
|
/// 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, World};
|
|
use glam::Vec3;
|
|
use shared::generator::{VoxelGenerator, WorldGenConfig};
|
|
use shared::protocol::authority::ServerStats;
|
|
use shared::world::{Chunk, ChunkPos, EntityPos};
|
|
use tracing::{debug, info, 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);
|
|
|
|
/// 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);
|
|
}
|
|
|
|
let stats = world.reconcile(&desired);
|
|
info!(
|
|
loaded = stats.loaded,
|
|
unloaded = stats.unloaded,
|
|
resident = stats.resident,
|
|
in_flight = stats.in_flight,
|
|
"streaming reconcile"
|
|
);
|
|
}
|
|
|
|
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");
|
|
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();
|
|
// Loading progress is simply the resident fraction of all known chunks.
|
|
let total = resident + in_flight;
|
|
debug!(resident, in_flight, total, "loading progress");
|
|
|
|
// 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.entities().len();
|
|
|
|
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,
|
|
players: u32::try_from(players).unwrap_or(u32::MAX),
|
|
uptime_secs: started_at.elapsed().as_secs(),
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
|
|
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);
|
|
info!(
|
|
id,
|
|
added, drops, "client {id}: +{added} chunks, -{drops} drops"
|
|
);
|
|
} 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 {
|
|
debug!(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.
|
|
if let Some(window) = meter.take_window(tick_start + elapsed) {
|
|
let stats = collect_server_stats(world, &window, clients.len(), started_at);
|
|
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));
|
|
}
|
|
}
|
|
}
|