diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d0a6ab..9079bb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,9 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). UUID so many worlds can share one database. Singleplayer saves are SQLite files in the platform data directory. Autosaves run in the background. The server has new `--database URL` and `--world NAME|UUID` options. +- The dedicated server is configured by `server.toml` plus command-line + overrides, and it accepts terminal commands for save, shutdown and live config + changes. ### Removed diff --git a/Cargo.lock b/Cargo.lock index f615dfe..3d05d45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,6 +196,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -2130,6 +2180,46 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -2186,6 +2276,12 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.9" @@ -3033,6 +3129,7 @@ dependencies = [ "bevy", "bevy_replicon", "bevy_replicon_renet", + "clap", "ctrlc", "game_core", "rsn", @@ -3043,6 +3140,8 @@ dependencies = [ "thiserror 2.0.21", "tiled", "tokio", + "toml", + "toml_edit", "uuid", ] @@ -3648,6 +3747,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -4654,6 +4759,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -5537,6 +5648,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + [[package]] name = "sha1" version = "0.11.0" @@ -5935,6 +6055,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -6179,6 +6305,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.2+spec-1.1.0" @@ -6197,6 +6338,7 @@ dependencies = [ "indexmap", "toml_datetime", "toml_parser", + "toml_writer", "winnow", ] @@ -6209,6 +6351,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + [[package]] name = "tracing" version = "0.1.44" @@ -6437,6 +6585,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.28.0" diff --git a/Cargo.toml b/Cargo.toml index 2ece7ae..89d0a0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,9 @@ uuid = { version = "1", features = ["v4", "serde"] } sqlx = { version = "0.9", default-features = false, features = ["any", "sqlite", "postgres", "runtime-tokio", "tls-rustls-ring-webpki"] } tokio = { version = "1", features = ["rt-multi-thread"] } directories = "6" +clap = { version = "4.6", features = ["derive"] } +toml = "1.1" +toml_edit = "0.25" game_core = { path = "crates/game_core" } game_server = { path = "crates/game_server" } diff --git a/README.md b/README.md index 4957921..dbecb1d 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,9 @@ cargo run -p game_server --bin game-server # dedicated server (saves/server.sql ``` Client options: `--name NAME`, `--save NAME`, `--no-save`, `--map MAP`. -Server options: `--port PORT`, `--database URL`, `--world NAME|UUID`, `--no-save`, `--map MAP`. +Server options: `--config FILE`, `--port PORT`, `--password TEXT`, +`--max-players N`, `--database URL`, `--world NAME|UUID`, `--no-save`, +`--map MAP`, `--autosave SECS`, `--motd TEXT`, `--admin ID`, `--whitelist`. On Windows, release builds of the client open no console window, so command-line output such as `--help` and logs isn't shown. Use a debug build to see it. @@ -68,6 +70,28 @@ game-server --database postgres://station:secret@db/station --world europa The schema is created and migrated automatically. To run the PostgreSQL tests, set `STATION_TEST_POSTGRES_URL` to a database URL; without it they are skipped. +### Dedicated server configuration + +The dedicated server reads `server.toml` by default and creates it on first +start. Command-line options override the file for one run only: + +```sh +game-server --config prod.toml --port 5001 --database sqlite://saves/prod.sqlite +``` + +The config file also stores the server password, MOTD, max players, the +database URL and world, plus access lists (admins, whitelist, bans). While the +server is running, type commands into its terminal: + +```text +help +players +save +config get database.world +config set autosave_secs 30 +stop +``` + ## Controls | Key | Action | diff --git a/crates/game_client/src/main.rs b/crates/game_client/src/main.rs index 325cf35..ce5a95d 100644 --- a/crates/game_client/src/main.rs +++ b/crates/game_client/src/main.rs @@ -22,7 +22,7 @@ use bevy::{prelude::*, window::WindowResolution}; use bevy_replicon::prelude::*; use bevy_replicon_renet::RepliconRenetPlugins; use game_core::CorePlugin; -use game_server::{GameServerPlugin, NetworkServerPlugin, ServerSettings}; +use game_server::{GameServerPlugin, NetworkServerPlugin, ServerSettings, config::ServerConfig}; /// How this client runs. #[derive(Clone, Debug)] @@ -75,7 +75,12 @@ fn main() -> AppExit { Mode::Host { port } => { app.add_plugins(( GameServerPlugin { settings }, - NetworkServerPlugin { port: *port }, + NetworkServerPlugin { + config: ServerConfig { + port: *port, + ..default() + }, + }, )); } Mode::Join { .. } => {} diff --git a/crates/game_server/Cargo.toml b/crates/game_server/Cargo.toml index 617ff6c..97c5860 100644 --- a/crates/game_server/Cargo.toml +++ b/crates/game_server/Cargo.toml @@ -21,6 +21,9 @@ serde_json = { workspace = true } uuid = { workspace = true } sqlx = { workspace = true } tokio = { workspace = true } +clap = { workspace = true } +toml = { workspace = true } +toml_edit = { workspace = true } # Enables graceful shutdown (and saving) on SIGTERM/SIGHUP via Bevy's Ctrl-C handler. ctrlc = { version = "3.5", features = ["termination"] } diff --git a/crates/game_server/src/commands.rs b/crates/game_server/src/commands.rs new file mode 100644 index 0000000..6824f0e --- /dev/null +++ b/crates/game_server/src/commands.rs @@ -0,0 +1,103 @@ +//! Server commands, typed into the server console. + +use bevy::prelude::*; +use bevy_replicon::prelude::*; +use game_core::{components::Character, protocol::GameLog}; + +use crate::{config, persistence, players::Players}; + +/// Who issued a command. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommandSource { + /// The dedicated server's terminal. + Console, +} + +pub const HELP: &[(&str, &str)] = &[ + ("help", "list commands"), + ("players", "list online players"), + ("say MESSAGE", "message all players"), + ("save", "save the world now"), + ( + "config get KEY", + "show a setting, e.g. `config get access.whitelist`", + ), + ( + "config set KEY VALUE", + "change a setting and write it to the config file", + ), + ("stop", "save and shut down the server"), +]; + +/// Runs a command line and returns the reply. +pub fn execute(world: &mut World, _source: CommandSource, line: &str) -> Vec { + let line = line.trim().trim_start_matches('/'); + let (command, rest) = line.split_once(' ').unwrap_or((line, "")); + let rest = rest.trim(); + match command { + "" => vec![], + "help" => HELP + .iter() + .map(|(usage, what)| format!("{usage:<22} {what}")) + .collect(), + "players" => { + let names: Vec = world + .query::<&Character>() + .iter(world) + .map(|c| c.name.clone()) + .collect(); + let count = world.resource::().by_client.len(); + vec![format!("{count} online: {}", names.join(", "))] + } + "say" if !rest.is_empty() => { + let text = format!("[server] {rest}"); + world.commands().server_trigger(ToClients { + targets: SendTargets::All, + message: GameLog { text: text.clone() }, + }); + world.flush(); + vec![text] + } + "save" => { + if world.contains_resource::() { + persistence::save_world_blocking(world); + vec!["World saved.".into()] + } else { + vec!["Saving is disabled.".into()] + } + } + "config" => config_command(world, rest), + "stop" => { + world.write_message(AppExit::Success); + vec!["Shutting down.".into()] + } + _ => vec![format!( + "Unknown command `{command}`. Type `help` for a list." + )], + } +} + +fn config_command(world: &mut World, args: &str) -> Vec { + let mut parts = args.splitn(3, ' '); + match (parts.next(), parts.next(), parts.next()) { + (Some("get"), Some(key), None) => match world.resource::().get(key) { + Ok(value) => vec![format!("{key} = {value}")], + Err(e) => vec![e.to_string()], + }, + (Some("set"), Some(key), Some(value)) => match config::set_runtime(world, key, value) { + Ok(note) => { + let value = world + .resource::() + .get(key) + .unwrap_or_default(); + let mut reply = format!("{key} = {value}"); + if let Some(note) = note { + reply.push_str(&format!(" ({note})")); + } + vec![reply] + } + Err(e) => vec![e.to_string()], + }, + _ => vec!["Usage: config get KEY | config set KEY VALUE".into()], + } +} diff --git a/crates/game_server/src/config.rs b/crates/game_server/src/config.rs new file mode 100644 index 0000000..73ee3a4 --- /dev/null +++ b/crates/game_server/src/config.rs @@ -0,0 +1,374 @@ +//! Server configuration: a TOML file, overridable from the command line and +//! editable at runtime by admins (`config set`), which also updates the file +//! while preserving its comments and layout. + +use std::{ + path::{Path, PathBuf}, + time::Duration, +}; + +use bevy::prelude::*; +use serde::{Deserialize, Serialize}; +use toml_edit::DocumentMut; + +use crate::{ + ServerSettings, + persistence::{StorageSettings, WorldSelector}, +}; + +/// Everything a server operator can configure. +#[derive(Resource, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ServerConfig { + /// Shown to players when they join. + pub name: String, + pub motd: String, + /// UDP port. + pub port: u16, + pub max_players: usize, + /// Required to join when not empty. + pub password: String, + /// Tiled map used to create new worlds. + pub map: String, + /// Seconds between autosaves. + pub autosave_secs: u64, + pub database: DatabaseConfig, + pub access: AccessConfig, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct DatabaseConfig { + /// Persist the world. When false, the world is reset on every start. + pub save: bool, + /// sqlx URL: `sqlite://path` or `postgres://user:password@host/db`. + pub url: String, + /// World name (created if missing) or UUID. + pub world: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct AccessConfig { + /// Player ids with admin rights. + pub admins: Vec, + /// Only players in `whitelist` may join. + pub whitelist_enabled: bool, + /// Player names or ids. + pub whitelist: Vec, + /// Banned player names or ids. + pub banned: Vec, +} + +pub const DEFAULT_CONFIG_PATH: &str = "server.toml"; + +impl Default for ServerConfig { + fn default() -> Self { + Self { + name: "Station".into(), + motd: String::new(), + port: game_core::DEFAULT_PORT, + max_players: 32, + password: String::new(), + map: game_core::DEFAULT_MAP.into(), + autosave_secs: 60, + database: DatabaseConfig::default(), + access: AccessConfig::default(), + } + } +} + +impl Default for DatabaseConfig { + fn default() -> Self { + Self { + save: true, + url: "sqlite://saves/server.sqlite".into(), + world: "main".into(), + } + } +} + +/// Written when the configuration file does not exist yet. Must match [`ServerConfig::default`]. +pub const DEFAULT_CONFIG: &str = r#"# Station server configuration. +# Command line options override these values for a single run. +# Admins can change values at runtime with `config set KEY VALUE`, which also updates this file. + +# Server name and message of the day, shown to players when they join. +name = "Station" +motd = "" + +# UDP port to listen on. +port = 5000 +max_players = 32 + +# Players must enter this password to join. Empty = no password. +# Note: connections are not encrypted. +password = "" + +# Tiled map (relative to the assets directory) used to create new worlds. +map = "maps/station.tmx" + +# Seconds between autosaves. +autosave_secs = 60 + +[database] +# Persist the world. When false, the world is reset on every start. +save = true +# sqlx URL: "sqlite://path/to/file.sqlite" or "postgres://user:password@host/database". +url = "sqlite://saves/server.sqlite" +# World name (created if missing) or world UUID. Many worlds can share one database. +world = "main" + +[access] +# Player ids (shown in the server log when a player joins) with admin rights. +admins = [] +# When enabled, only whitelisted player names or ids may join. +whitelist_enabled = false +whitelist = [] +# Banned player names or ids. +banned = [] +"#; + +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("{0}: {1}")] + Io(PathBuf, std::io::Error), + #[error("{0}: {1}")] + Parse(PathBuf, String), + #[error("unknown setting `{0}`")] + UnknownKey(String), + #[error("invalid value for `{0}`: {1}")] + InvalidValue(String, String), +} + +impl ServerConfig { + /// Simulation settings derived from this configuration. + pub fn settings(&self) -> ServerSettings { + ServerSettings { + map: self.map.clone(), + storage: self.database.save.then(|| StorageSettings { + database_url: self.database.url.clone(), + world: WorldSelector::parse(&self.database.world), + }), + autosave_interval: Duration::from_secs(self.autosave_secs.max(1)), + } + } + + fn to_document(&self) -> DocumentMut { + toml::to_string(self) + .expect("config serializes") + .parse() + .expect("serialized config parses") + } + + /// Current value of a dotted key (e.g. `database.world`) in TOML syntax. + pub fn get(&self, key: &str) -> Result { + let doc = self.to_document(); + let mut item = doc.as_item(); + for part in key.split('.') { + item = item + .get(part) + .ok_or_else(|| ConfigError::UnknownKey(key.into()))?; + } + Ok(match item { + toml_edit::Item::Value(value) => value.to_string().trim().to_owned(), + other => other.to_string().trim().to_owned(), + }) + } + + /// Sets a dotted key. `value` is TOML (`42`, `true`, `["a"]`); anything that + /// isn't valid TOML is taken as a string. + pub fn set(&mut self, key: &str, value: &str) -> Result<(), ConfigError> { + let value = parse_value(value); + let mut doc = self.to_document(); + set_in_document(&mut doc, key, value)?; + *self = from_document(&doc).map_err(|e| invalid(key, e))?; + Ok(()) + } +} + +fn invalid(key: &str, e: String) -> ConfigError { + if e.contains("unknown field") { + ConfigError::UnknownKey(key.into()) + } else { + ConfigError::InvalidValue(key.into(), e) + } +} + +fn parse_value(value: &str) -> toml_edit::Value { + value + .trim() + .parse() + .unwrap_or_else(|_| toml_edit::Value::from(value)) +} + +fn from_document(doc: &DocumentMut) -> Result { + toml::from_str(&doc.to_string()).map_err(|e| e.message().to_owned()) +} + +fn set_in_document( + doc: &mut DocumentMut, + key: &str, + value: toml_edit::Value, +) -> Result<(), ConfigError> { + let parts: Vec<&str> = key.split('.').collect(); + let (last, tables) = parts.split_last().expect("split yields one part"); + let mut table = doc.as_table_mut(); + for part in tables { + table = table + .entry(part) + .or_insert_with(toml_edit::table) + .as_table_mut() + .ok_or_else(|| ConfigError::UnknownKey(key.into()))?; + } + match table.get_mut(last) { + // Keeps the comments around the existing value. + Some(toml_edit::Item::Value(old)) => { + let decor = old.decor().clone(); + *old = value; + *old.decor_mut() = decor; + } + Some(_) => return Err(ConfigError::UnknownKey(key.into())), + None => { + table.insert(last, toml_edit::value(value)); + } + } + Ok(()) +} + +/// The configuration file backing a [`ServerConfig`]. +#[derive(Resource, Debug)] +pub struct ConfigFile { + pub path: PathBuf, + doc: DocumentMut, +} + +impl ConfigFile { + /// Loads the file, creating it with [`DEFAULT_CONFIG`] if it does not exist. + pub fn load_or_create(path: &Path) -> Result<(Self, ServerConfig, bool), ConfigError> { + let io = |e| ConfigError::Io(path.to_owned(), e); + let (text, created) = match std::fs::read_to_string(path) { + Ok(text) => (text, false), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + if let Some(dir) = path.parent() + && !dir.as_os_str().is_empty() + { + std::fs::create_dir_all(dir).map_err(io)?; + } + std::fs::write(path, DEFAULT_CONFIG).map_err(io)?; + (DEFAULT_CONFIG.to_owned(), true) + } + Err(e) => return Err(io(e)), + }; + let parse = |e: String| ConfigError::Parse(path.to_owned(), e); + let doc: DocumentMut = text + .parse() + .map_err(|e: toml_edit::TomlError| parse(e.to_string()))?; + let config = toml::from_str(&text).map_err(|e: toml::de::Error| parse(e.to_string()))?; + Ok(( + Self { + path: path.to_owned(), + doc, + }, + config, + created, + )) + } + + /// Sets a key in the file and writes it. + pub fn set(&mut self, key: &str, value: &str) -> Result<(), ConfigError> { + let mut doc = self.doc.clone(); + set_in_document(&mut doc, key, parse_value(value))?; + from_document(&doc).map_err(|e| invalid(key, e))?; + std::fs::write(&self.path, doc.to_string()) + .map_err(|e| ConfigError::Io(self.path.clone(), e))?; + self.doc = doc; + Ok(()) + } +} + +/// Changes a setting at runtime (and in the config file, if there is one). +/// Returns a note if the change only takes effect after a restart. +pub fn set_runtime( + world: &mut World, + key: &str, + value: &str, +) -> Result, ConfigError> { + let mut config = world.resource::().clone(); + config.set(key, value)?; + if let Some(mut file) = world.get_resource_mut::() { + file.set(key, value)?; + } + let restart = matches!(key.split('.').next(), Some("port" | "map" | "database")); + if config.autosave_secs != world.resource::().autosave_secs + && let Some(mut timer) = world.get_resource_mut::() + { + timer + .0 + .set_duration(Duration::from_secs(config.autosave_secs.max(1))); + } + world.insert_resource(config); + Ok(restart.then_some("takes effect after a restart")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_file_matches_defaults() { + let config: ServerConfig = toml::from_str(DEFAULT_CONFIG).unwrap(); + assert_eq!(config, ServerConfig::default()); + } + + #[test] + fn get_and_set() { + let mut config = ServerConfig::default(); + config.set("max_players", "4").unwrap(); + config.set("database.world", "europa").unwrap(); + config.set("motd", "hello there").unwrap(); + config.set("access.admins", r#"["abc"]"#).unwrap(); + assert_eq!(config.max_players, 4); + assert_eq!(config.database.world, "europa"); + assert_eq!(config.motd, "hello there"); + assert_eq!(config.access.admins, ["abc"]); + assert_eq!(config.get("database.world").unwrap(), "\"europa\""); + assert_eq!(config.get("max_players").unwrap(), "4"); + assert!(matches!( + config.set("nope", "1"), + Err(ConfigError::UnknownKey(_)) + )); + assert!(matches!( + config.set("database.nope", "1"), + Err(ConfigError::UnknownKey(_)) + )); + assert!(matches!( + config.get("nope"), + Err(ConfigError::UnknownKey(_)) + )); + assert!(matches!( + config.set("port", "-1"), + Err(ConfigError::InvalidValue(..)) + )); + assert_eq!(config.port, game_core::DEFAULT_PORT); + } + + #[test] + fn file_edits_keep_comments() { + let dir = std::env::temp_dir().join(format!("station_config_test_{}", std::process::id())); + let path = dir.join("server.toml"); + let (mut file, config, created) = ConfigFile::load_or_create(&path).unwrap(); + assert!(created); + assert_eq!(config, ServerConfig::default()); + file.set("password", "secret").unwrap(); + file.set("access.banned", r#"["griefer"]"#).unwrap(); + assert!(file.set("port", "\"x\"").is_err()); + let (_, config, created) = ConfigFile::load_or_create(&path).unwrap(); + assert!(!created); + assert_eq!(config.password, "secret"); + assert_eq!(config.access.banned, ["griefer"]); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("# Players must enter this password to join.")); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/crates/game_server/src/console.rs b/crates/game_server/src/console.rs new file mode 100644 index 0000000..7f3b952 --- /dev/null +++ b/crates/game_server/src/console.rs @@ -0,0 +1,50 @@ +//! Reads commands from the dedicated server's standard input. + +use std::{ + io::BufRead, + sync::{ + Mutex, + mpsc::{Receiver, channel}, + }, +}; + +use bevy::prelude::*; + +use crate::commands::{CommandSource, execute}; + +pub struct ConsolePlugin; + +#[derive(Resource)] +struct ConsoleInput(Mutex>); + +impl Plugin for ConsolePlugin { + fn build(&self, app: &mut App) { + let (tx, rx) = channel(); + std::thread::Builder::new() + .name("console".into()) + .spawn(move || { + for line in std::io::stdin().lock().lines() { + let Ok(line) = line else { break }; + if tx.send(line).is_err() { + break; + } + } + }) + .expect("failed to spawn the console thread"); + app.insert_resource(ConsoleInput(Mutex::new(rx))) + .add_systems(Update, run_console_commands); + } +} + +fn run_console_commands(world: &mut World) { + let lines: Vec = { + let input = world.resource::(); + let rx = input.0.lock().expect("console lock"); + rx.try_iter().collect() + }; + for line in lines { + for reply in execute(world, CommandSource::Console, &line) { + info!(target: "console", "{reply}"); + } + } +} diff --git a/crates/game_server/src/lib.rs b/crates/game_server/src/lib.rs index 3b57c76..075849a 100644 --- a/crates/game_server/src/lib.rs +++ b/crates/game_server/src/lib.rs @@ -5,6 +5,9 @@ //! [`game_core::protocol`]); all game rules run here. pub mod actions; +pub mod commands; +pub mod config; +pub mod console; pub mod crafting; pub mod machines; pub mod movement; @@ -54,6 +57,7 @@ impl Plugin for GameServerPlugin { self.settings.autosave_interval, TimerMode::Repeating, ))) + .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/game_server/src/main.rs b/crates/game_server/src/main.rs index 8b60d37..de62e26 100644 --- a/crates/game_server/src/main.rs +++ b/crates/game_server/src/main.rs @@ -1,83 +1,149 @@ //! Headless dedicated server. //! -//! ```text -//! game-server [--port 5000] [--database URL | --no-save] [--world NAME|UUID] [--map maps/station.tmx] -//! ``` +//! Configured by a TOML file (default `server.toml`, created on first start) and +//! command line options, which override the file for one run. Run with `--help` +//! for the options. Commands (`help`, `save`, `config set`, ...) can be typed into +//! the terminal. -use std::time::Duration; +use std::{path::PathBuf, time::Duration}; use bevy::{app::ScheduleRunnerPlugin, log::LogPlugin, prelude::*, state::app::StatesPlugin}; use bevy_replicon::prelude::*; use bevy_replicon_renet::RepliconRenetPlugins; +use clap::Parser; use game_core::CorePlugin; use game_server::{ - GameServerPlugin, NetworkServerPlugin, ServerSettings, - persistence::{StorageSettings, WorldSelector}, + GameServerPlugin, NetworkServerPlugin, + config::{ConfigFile, DEFAULT_CONFIG_PATH, ServerConfig}, + console::ConsolePlugin, }; -const DEFAULT_DATABASE: &str = "sqlite://saves/server.sqlite"; -const DEFAULT_WORLD: &str = "main"; +/// Station dedicated server. +#[derive(Parser, Debug)] +#[command( + version, + about, + after_help = "Options override the config file for this run only." +)] +struct Cli { + /// Configuration file, created with defaults if missing. + #[arg(short, long, value_name = "FILE", default_value = DEFAULT_CONFIG_PATH)] + config: PathBuf, + /// UDP port to listen on. + #[arg(short, long)] + port: Option, + /// Password players need to join (empty for none). + #[arg(long)] + password: Option, + #[arg(long, value_name = "N")] + max_players: Option, + /// Database URL: sqlite://FILE or postgres://USER:PASSWORD@HOST/DB. + #[arg(long, value_name = "URL")] + database: Option, + /// World name (created if missing) or UUID. + #[arg(long, value_name = "NAME|UUID")] + world: Option, + /// Don't persist the world. + #[arg(long)] + no_save: bool, + /// Tiled map for new worlds, relative to the assets directory. + #[arg(long)] + map: Option, + /// Seconds between autosaves. + #[arg(long, value_name = "SECS")] + autosave: Option, + #[arg(long)] + motd: Option, + /// Grant admin rights to a player id (repeatable). + #[arg(long, value_name = "ID")] + admin: Vec, + /// Only allow whitelisted players. + #[arg(long)] + whitelist: bool, + /// Print the effective configuration and exit. + #[arg(long)] + print_config: bool, +} + +impl Cli { + fn apply(self, config: &mut ServerConfig) { + let Cli { + port, + password, + max_players, + database, + world, + no_save, + map, + autosave, + motd, + admin, + whitelist, + .. + } = self; + macro_rules! set { + ($($field:ident).+ = $value:expr) => { + if let Some(value) = $value { + config.$($field).+ = value; + } + }; + } + set!(port = port); + set!(password = password); + set!(max_players = max_players); + set!(database.url = database); + set!(database.world = world); + set!(map = map); + set!(autosave_secs = autosave); + set!(motd = motd); + if no_save { + config.database.save = false; + } + if whitelist { + config.access.whitelist_enabled = true; + } + config.access.admins.extend(admin); + } +} fn main() -> AppExit { - let mut port = game_core::DEFAULT_PORT; - let mut settings = ServerSettings::default(); - let mut database = Some(DEFAULT_DATABASE.to_owned()); - let mut world = WorldSelector::Name(DEFAULT_WORLD.to_owned()); - let mut args = std::env::args().skip(1); - while let Some(arg) = args.next() { - let mut value = |name: &str| { - args.next() - .unwrap_or_else(|| usage(&format!("{name} needs a value"))) - }; - match arg.as_str() { - "--port" => { - port = value("--port") - .parse() - .unwrap_or_else(|_| usage("invalid port")) - } - "--database" => database = Some(value("--database")), - "--world" => world = WorldSelector::parse(&value("--world")), - "--no-save" => database = None, - "--map" => settings.map = value("--map"), - "-h" | "--help" => usage(""), - other => usage(&format!("unknown argument `{other}`")), + let cli = Cli::parse(); + let (file, mut config, created) = match ConfigFile::load_or_create(&cli.config) { + Ok(loaded) => loaded, + Err(e) => { + eprintln!("error: {e}"); + return AppExit::error(); } + }; + let print = cli.print_config; + cli.apply(&mut config); + if print { + print!("{}", toml::to_string(&config).expect("config serializes")); + return AppExit::Success; } - settings.storage = database.map(|database_url| StorageSettings { - database_url, - world, - }); - App::new() - .add_plugins(( - MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64( - 1.0 / 60.0, - ))), - StatesPlugin, - LogPlugin::default(), - bevy::app::TerminalCtrlCHandlerPlugin, - RepliconPlugins, - RepliconRenetPlugins, - CorePlugin, - GameServerPlugin { settings }, - NetworkServerPlugin { port }, - )) - .run() -} - -fn usage(error: &str) -> ! { - if !error.is_empty() { - eprintln!("error: {error}\n"); + let mut app = App::new(); + app.add_plugins(( + MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64( + 1.0 / 60.0, + ))), + StatesPlugin, + LogPlugin::default(), + bevy::app::TerminalCtrlCHandlerPlugin, + RepliconPlugins, + RepliconRenetPlugins, + CorePlugin, + GameServerPlugin { + settings: config.settings(), + }, + NetworkServerPlugin { config }, + ConsolePlugin, + )); + if created { + info!("created config file `{}`", file.path.display()); + } else { + info!("loaded config file `{}`", file.path.display()); } - eprintln!( - "usage: game-server [--port PORT] [--database URL | --no-save] [--world NAME|UUID] [--map MAP]\n\n\ - --port UDP port to listen on (default {})\n\ - --database sqlite:// or postgres:// URL (default {DEFAULT_DATABASE})\n\ - --world world name or UUID in the database, created if missing (default {DEFAULT_WORLD})\n\ - --no-save don't persist the world\n\ - --map Tiled map relative to the assets directory (default {})", - game_core::DEFAULT_PORT, - game_core::DEFAULT_MAP - ); - std::process::exit(if error.is_empty() { 0 } else { 2 }) + app.insert_resource(file); + app.run() } diff --git a/crates/game_server/src/network.rs b/crates/game_server/src/network.rs index 13fa9c7..39aeffe 100644 --- a/crates/game_server/src/network.rs +++ b/crates/game_server/src/network.rs @@ -9,36 +9,43 @@ use bevy::prelude::*; use bevy_replicon::prelude::*; use bevy_replicon_renet::{ RenetChannelsExt, RenetServer, - netcode::{NetcodeServerTransport, ServerAuthentication, ServerConfig}, + netcode::{self, NetcodeServerTransport, ServerAuthentication}, renet::ConnectionConfig, }; -pub const MAX_CLIENTS: usize = 32; +use crate::config::ServerConfig; -/// Opens a UDP server on `port` once all replicon channels are registered. +/// Connections accepted beyond `max_players`, so that a full server can still +/// tell clients why they can't join. +const EXTRA_SLOTS: usize = 4; + +/// Opens a UDP server once all replicon channels are registered and makes +/// `config` the server's [`ServerConfig`] resource. pub struct NetworkServerPlugin { - pub port: u16, + pub config: ServerConfig, } -#[derive(Resource)] -struct ServerPort(u16); - impl Plugin for NetworkServerPlugin { fn build(&self, app: &mut App) { - app.insert_resource(ServerPort(self.port)) + app.insert_resource(self.config.clone()) .add_systems(Startup, start_server); } } -fn start_server(mut commands: Commands, channels: Res, port: Res) { - match create_server(&channels, port.0) { +fn start_server( + mut commands: Commands, + channels: Res, + config: Res, +) { + let port = config.port; + match create_server(&channels, port, config.max_players + EXTRA_SLOTS) { Ok((server, transport)) => { - info!("listening on UDP port {}", port.0); + info!("listening on UDP port {port}"); commands.insert_resource(server); commands.insert_resource(transport); } Err(e) => { - error!("failed to start server on port {}: {e}", port.0); + error!("failed to start server on port {port}: {e}"); commands.write_message(AppExit::error()); } } @@ -47,6 +54,7 @@ fn start_server(mut commands: Commands, channels: Res, port: R pub fn create_server( channels: &RepliconChannels, port: u16, + max_clients: usize, ) -> Result<(RenetServer, NetcodeServerTransport), Box> { let server = RenetServer::new(ConnectionConfig { server_channels_config: channels.server_configs(), @@ -55,9 +63,9 @@ pub fn create_server( }); let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?; let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, port))?; - let server_config = ServerConfig { + let server_config = netcode::ServerConfig { current_time, - max_clients: MAX_CLIENTS, + max_clients, protocol_id: game_core::PROTOCOL_ID, authentication: ServerAuthentication::Unsecure, // Not used with unsecure authentication, but must be valid.