feat(server): add file and command line config
Add a dedicated server configuration file (`server.toml`) with clap-based command line overrides. The server now supports runtime config changes via terminal commands, and config edits are written back to the TOML file while preserving comments. Also let host mode feed the shared network startup through the same server config resource. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
afd3d3a537
commit
09ace2607c
|
|
@ -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
|
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
|
in the platform data directory. Autosaves run in the background. The server has
|
||||||
new `--database URL` and `--world NAME|UUID` options.
|
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
|
### Removed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,56 @@ dependencies = [
|
||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.104"
|
version = "1.0.104"
|
||||||
|
|
@ -2130,6 +2180,46 @@ dependencies = [
|
||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "clipboard-win"
|
name = "clipboard-win"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
|
|
@ -2186,6 +2276,12 @@ dependencies = [
|
||||||
"unicode-width 0.2.2",
|
"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]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.9"
|
version = "4.6.9"
|
||||||
|
|
@ -3033,6 +3129,7 @@ dependencies = [
|
||||||
"bevy",
|
"bevy",
|
||||||
"bevy_replicon",
|
"bevy_replicon",
|
||||||
"bevy_replicon_renet",
|
"bevy_replicon_renet",
|
||||||
|
"clap",
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
"game_core",
|
"game_core",
|
||||||
"rsn",
|
"rsn",
|
||||||
|
|
@ -3043,6 +3140,8 @@ dependencies = [
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
"tiled",
|
"tiled",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"toml",
|
||||||
|
"toml_edit",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -3648,6 +3747,12 @@ dependencies = [
|
||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
|
|
@ -4654,6 +4759,12 @@ dependencies = [
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opaque-debug"
|
name = "opaque-debug"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|
@ -5537,6 +5648,15 @@ dependencies = [
|
||||||
"zmij",
|
"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]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
|
|
@ -5935,6 +6055,12 @@ dependencies = [
|
||||||
"unicode-properties",
|
"unicode-properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
|
|
@ -6179,6 +6305,21 @@ dependencies = [
|
||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.2+spec-1.1.0"
|
version = "1.1.2+spec-1.1.0"
|
||||||
|
|
@ -6197,6 +6338,7 @@ dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"toml_datetime",
|
"toml_datetime",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -6209,6 +6351,12 @@ dependencies = [
|
||||||
"winnow",
|
"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]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
|
|
@ -6437,6 +6585,12 @@ version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.28.0"
|
version = "1.28.0"
|
||||||
|
|
|
||||||
|
|
@ -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"] }
|
sqlx = { version = "0.9", default-features = false, features = ["any", "sqlite", "postgres", "runtime-tokio", "tls-rustls-ring-webpki"] }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||||
directories = "6"
|
directories = "6"
|
||||||
|
clap = { version = "4.6", features = ["derive"] }
|
||||||
|
toml = "1.1"
|
||||||
|
toml_edit = "0.25"
|
||||||
game_core = { path = "crates/game_core" }
|
game_core = { path = "crates/game_core" }
|
||||||
game_server = { path = "crates/game_server" }
|
game_server = { path = "crates/game_server" }
|
||||||
|
|
||||||
|
|
|
||||||
26
README.md
26
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`.
|
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
|
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.
|
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,
|
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.
|
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
|
## Controls
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ use bevy::{prelude::*, window::WindowResolution};
|
||||||
use bevy_replicon::prelude::*;
|
use bevy_replicon::prelude::*;
|
||||||
use bevy_replicon_renet::RepliconRenetPlugins;
|
use bevy_replicon_renet::RepliconRenetPlugins;
|
||||||
use game_core::CorePlugin;
|
use game_core::CorePlugin;
|
||||||
use game_server::{GameServerPlugin, NetworkServerPlugin, ServerSettings};
|
use game_server::{GameServerPlugin, NetworkServerPlugin, ServerSettings, config::ServerConfig};
|
||||||
|
|
||||||
/// How this client runs.
|
/// How this client runs.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|
@ -75,7 +75,12 @@ fn main() -> AppExit {
|
||||||
Mode::Host { port } => {
|
Mode::Host { port } => {
|
||||||
app.add_plugins((
|
app.add_plugins((
|
||||||
GameServerPlugin { settings },
|
GameServerPlugin { settings },
|
||||||
NetworkServerPlugin { port: *port },
|
NetworkServerPlugin {
|
||||||
|
config: ServerConfig {
|
||||||
|
port: *port,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Mode::Join { .. } => {}
|
Mode::Join { .. } => {}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ serde_json = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
tokio = { 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.
|
# Enables graceful shutdown (and saving) on SIGTERM/SIGHUP via Bevy's Ctrl-C handler.
|
||||||
ctrlc = { version = "3.5", features = ["termination"] }
|
ctrlc = { version = "3.5", features = ["termination"] }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<String> {
|
||||||
|
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<String> = world
|
||||||
|
.query::<&Character>()
|
||||||
|
.iter(world)
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
.collect();
|
||||||
|
let count = world.resource::<Players>().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::Storage>() {
|
||||||
|
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<String> {
|
||||||
|
let mut parts = args.splitn(3, ' ');
|
||||||
|
match (parts.next(), parts.next(), parts.next()) {
|
||||||
|
(Some("get"), Some(key), None) => match world.resource::<config::ServerConfig>().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::<config::ServerConfig>()
|
||||||
|
.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()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String>,
|
||||||
|
/// Only players in `whitelist` may join.
|
||||||
|
pub whitelist_enabled: bool,
|
||||||
|
/// Player names or ids.
|
||||||
|
pub whitelist: Vec<String>,
|
||||||
|
/// Banned player names or ids.
|
||||||
|
pub banned: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, ConfigError> {
|
||||||
|
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<ServerConfig, String> {
|
||||||
|
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<Option<&'static str>, ConfigError> {
|
||||||
|
let mut config = world.resource::<ServerConfig>().clone();
|
||||||
|
config.set(key, value)?;
|
||||||
|
if let Some(mut file) = world.get_resource_mut::<ConfigFile>() {
|
||||||
|
file.set(key, value)?;
|
||||||
|
}
|
||||||
|
let restart = matches!(key.split('.').next(), Some("port" | "map" | "database"));
|
||||||
|
if config.autosave_secs != world.resource::<ServerConfig>().autosave_secs
|
||||||
|
&& let Some(mut timer) = world.get_resource_mut::<crate::persistence::AutosaveTimer>()
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Receiver<String>>);
|
||||||
|
|
||||||
|
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<String> = {
|
||||||
|
let input = world.resource::<ConsoleInput>();
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,9 @@
|
||||||
//! [`game_core::protocol`]); all game rules run here.
|
//! [`game_core::protocol`]); all game rules run here.
|
||||||
|
|
||||||
pub mod actions;
|
pub mod actions;
|
||||||
|
pub mod commands;
|
||||||
|
pub mod config;
|
||||||
|
pub mod console;
|
||||||
pub mod crafting;
|
pub mod crafting;
|
||||||
pub mod machines;
|
pub mod machines;
|
||||||
pub mod movement;
|
pub mod movement;
|
||||||
|
|
@ -54,6 +57,7 @@ impl Plugin for GameServerPlugin {
|
||||||
self.settings.autosave_interval,
|
self.settings.autosave_interval,
|
||||||
TimerMode::Repeating,
|
TimerMode::Repeating,
|
||||||
)))
|
)))
|
||||||
|
.init_resource::<config::ServerConfig>()
|
||||||
.init_resource::<players::Players>()
|
.init_resource::<players::Players>()
|
||||||
.init_resource::<players::CharacterStore>()
|
.init_resource::<players::CharacterStore>()
|
||||||
.init_resource::<survival::SurvivalTimer>()
|
.init_resource::<survival::SurvivalTimer>()
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,129 @@
|
||||||
//! Headless dedicated server.
|
//! Headless dedicated server.
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! Configured by a TOML file (default `server.toml`, created on first start) and
|
||||||
//! game-server [--port 5000] [--database URL | --no-save] [--world NAME|UUID] [--map maps/station.tmx]
|
//! 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::{app::ScheduleRunnerPlugin, log::LogPlugin, prelude::*, state::app::StatesPlugin};
|
||||||
use bevy_replicon::prelude::*;
|
use bevy_replicon::prelude::*;
|
||||||
use bevy_replicon_renet::RepliconRenetPlugins;
|
use bevy_replicon_renet::RepliconRenetPlugins;
|
||||||
|
use clap::Parser;
|
||||||
use game_core::CorePlugin;
|
use game_core::CorePlugin;
|
||||||
use game_server::{
|
use game_server::{
|
||||||
GameServerPlugin, NetworkServerPlugin, ServerSettings,
|
GameServerPlugin, NetworkServerPlugin,
|
||||||
persistence::{StorageSettings, WorldSelector},
|
config::{ConfigFile, DEFAULT_CONFIG_PATH, ServerConfig},
|
||||||
|
console::ConsolePlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_DATABASE: &str = "sqlite://saves/server.sqlite";
|
/// Station dedicated server.
|
||||||
const DEFAULT_WORLD: &str = "main";
|
#[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<u16>,
|
||||||
|
/// Password players need to join (empty for none).
|
||||||
|
#[arg(long)]
|
||||||
|
password: Option<String>,
|
||||||
|
#[arg(long, value_name = "N")]
|
||||||
|
max_players: Option<usize>,
|
||||||
|
/// Database URL: sqlite://FILE or postgres://USER:PASSWORD@HOST/DB.
|
||||||
|
#[arg(long, value_name = "URL")]
|
||||||
|
database: Option<String>,
|
||||||
|
/// World name (created if missing) or UUID.
|
||||||
|
#[arg(long, value_name = "NAME|UUID")]
|
||||||
|
world: Option<String>,
|
||||||
|
/// Don't persist the world.
|
||||||
|
#[arg(long)]
|
||||||
|
no_save: bool,
|
||||||
|
/// Tiled map for new worlds, relative to the assets directory.
|
||||||
|
#[arg(long)]
|
||||||
|
map: Option<String>,
|
||||||
|
/// Seconds between autosaves.
|
||||||
|
#[arg(long, value_name = "SECS")]
|
||||||
|
autosave: Option<u64>,
|
||||||
|
#[arg(long)]
|
||||||
|
motd: Option<String>,
|
||||||
|
/// Grant admin rights to a player id (repeatable).
|
||||||
|
#[arg(long, value_name = "ID")]
|
||||||
|
admin: Vec<String>,
|
||||||
|
/// 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 {
|
fn main() -> AppExit {
|
||||||
let mut port = game_core::DEFAULT_PORT;
|
let cli = Cli::parse();
|
||||||
let mut settings = ServerSettings::default();
|
let (file, mut config, created) = match ConfigFile::load_or_create(&cli.config) {
|
||||||
let mut database = Some(DEFAULT_DATABASE.to_owned());
|
Ok(loaded) => loaded,
|
||||||
let mut world = WorldSelector::Name(DEFAULT_WORLD.to_owned());
|
Err(e) => {
|
||||||
let mut args = std::env::args().skip(1);
|
eprintln!("error: {e}");
|
||||||
while let Some(arg) = args.next() {
|
return AppExit::error();
|
||||||
let mut value = |name: &str| {
|
}
|
||||||
args.next()
|
|
||||||
.unwrap_or_else(|| usage(&format!("{name} needs a value")))
|
|
||||||
};
|
};
|
||||||
match arg.as_str() {
|
let print = cli.print_config;
|
||||||
"--port" => {
|
cli.apply(&mut config);
|
||||||
port = value("--port")
|
if print {
|
||||||
.parse()
|
print!("{}", toml::to_string(&config).expect("config serializes"));
|
||||||
.unwrap_or_else(|_| usage("invalid port"))
|
return AppExit::Success;
|
||||||
}
|
}
|
||||||
"--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}`")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
settings.storage = database.map(|database_url| StorageSettings {
|
|
||||||
database_url,
|
|
||||||
world,
|
|
||||||
});
|
|
||||||
|
|
||||||
App::new()
|
let mut app = App::new();
|
||||||
.add_plugins((
|
app.add_plugins((
|
||||||
MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64(
|
MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64(
|
||||||
1.0 / 60.0,
|
1.0 / 60.0,
|
||||||
))),
|
))),
|
||||||
|
|
@ -59,25 +133,17 @@ fn main() -> AppExit {
|
||||||
RepliconPlugins,
|
RepliconPlugins,
|
||||||
RepliconRenetPlugins,
|
RepliconRenetPlugins,
|
||||||
CorePlugin,
|
CorePlugin,
|
||||||
GameServerPlugin { settings },
|
GameServerPlugin {
|
||||||
NetworkServerPlugin { port },
|
settings: config.settings(),
|
||||||
))
|
},
|
||||||
.run()
|
NetworkServerPlugin { config },
|
||||||
|
ConsolePlugin,
|
||||||
|
));
|
||||||
|
if created {
|
||||||
|
info!("created config file `{}`", file.path.display());
|
||||||
|
} else {
|
||||||
|
info!("loaded config file `{}`", file.path.display());
|
||||||
}
|
}
|
||||||
|
app.insert_resource(file);
|
||||||
fn usage(error: &str) -> ! {
|
app.run()
|
||||||
if !error.is_empty() {
|
|
||||||
eprintln!("error: {error}\n");
|
|
||||||
}
|
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,36 +9,43 @@ use bevy::prelude::*;
|
||||||
use bevy_replicon::prelude::*;
|
use bevy_replicon::prelude::*;
|
||||||
use bevy_replicon_renet::{
|
use bevy_replicon_renet::{
|
||||||
RenetChannelsExt, RenetServer,
|
RenetChannelsExt, RenetServer,
|
||||||
netcode::{NetcodeServerTransport, ServerAuthentication, ServerConfig},
|
netcode::{self, NetcodeServerTransport, ServerAuthentication},
|
||||||
renet::ConnectionConfig,
|
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 struct NetworkServerPlugin {
|
||||||
pub port: u16,
|
pub config: ServerConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Resource)]
|
|
||||||
struct ServerPort(u16);
|
|
||||||
|
|
||||||
impl Plugin for NetworkServerPlugin {
|
impl Plugin for NetworkServerPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.insert_resource(ServerPort(self.port))
|
app.insert_resource(self.config.clone())
|
||||||
.add_systems(Startup, start_server);
|
.add_systems(Startup, start_server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_server(mut commands: Commands, channels: Res<RepliconChannels>, port: Res<ServerPort>) {
|
fn start_server(
|
||||||
match create_server(&channels, port.0) {
|
mut commands: Commands,
|
||||||
|
channels: Res<RepliconChannels>,
|
||||||
|
config: Res<ServerConfig>,
|
||||||
|
) {
|
||||||
|
let port = config.port;
|
||||||
|
match create_server(&channels, port, config.max_players + EXTRA_SLOTS) {
|
||||||
Ok((server, transport)) => {
|
Ok((server, transport)) => {
|
||||||
info!("listening on UDP port {}", port.0);
|
info!("listening on UDP port {port}");
|
||||||
commands.insert_resource(server);
|
commands.insert_resource(server);
|
||||||
commands.insert_resource(transport);
|
commands.insert_resource(transport);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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());
|
commands.write_message(AppExit::error());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +54,7 @@ fn start_server(mut commands: Commands, channels: Res<RepliconChannels>, port: R
|
||||||
pub fn create_server(
|
pub fn create_server(
|
||||||
channels: &RepliconChannels,
|
channels: &RepliconChannels,
|
||||||
port: u16,
|
port: u16,
|
||||||
|
max_clients: usize,
|
||||||
) -> Result<(RenetServer, NetcodeServerTransport), Box<dyn std::error::Error>> {
|
) -> Result<(RenetServer, NetcodeServerTransport), Box<dyn std::error::Error>> {
|
||||||
let server = RenetServer::new(ConnectionConfig {
|
let server = RenetServer::new(ConnectionConfig {
|
||||||
server_channels_config: channels.server_configs(),
|
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 current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
|
||||||
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, port))?;
|
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, port))?;
|
||||||
let server_config = ServerConfig {
|
let server_config = netcode::ServerConfig {
|
||||||
current_time,
|
current_time,
|
||||||
max_clients: MAX_CLIENTS,
|
max_clients,
|
||||||
protocol_id: game_core::PROTOCOL_ID,
|
protocol_id: game_core::PROTOCOL_ID,
|
||||||
authentication: ServerAuthentication::Unsecure,
|
authentication: ServerAuthentication::Unsecure,
|
||||||
// Not used with unsecure authentication, but must be valid.
|
// Not used with unsecure authentication, but must be valid.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue