// 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; /// 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::world::{Chunk, ChunkPos, EntityPos}; use tracing::{debug, info, warn}; use client_stream::ClientStream; use net::{NetworkServer, ServerEvent}; use player::{Player, Position, ViewDistance}; 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>, mut world: ResMut, ) { // 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::(); 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) } /// 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 = HashMap::new(); 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 } => { info!(id, name = %hello.player_identity.display_name, "client connected"); clients.insert(id, ClientStream::new(chunks)); } 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::().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::(); 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(); 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)); } } }