mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
334 lines
15 KiB
GDScript
334 lines
15 KiB
GDScript
class_name ServerConfig
|
|
extends RefCounted
|
|
|
|
# Dedicated-server configuration (multiplayer-todo.md task 6.3): one
|
|
# declaration of every server flag, one parser, one `--help`.
|
|
#
|
|
# Standalone RefCounted with no scene or RPC dependency — same reason as
|
|
# net_codec.gd, match_state.gd and input_jitter_buffer.gd — so the precedence
|
|
# rules and every validation path are unit-testable against a scripted argv
|
|
# with no live server.
|
|
#
|
|
# Why this exists rather than more `arg.begins_with(...)` chains: the flags had
|
|
# grown to roughly thirty across server_boot.gd and networked_match.gd, each
|
|
# parsed inline, none documented anywhere, and — the part that actually bites —
|
|
# **an unrecognised flag was silently ignored**. `--max-clientss=8` ran a server
|
|
# on the default player cap and said nothing about it. A dedicated server whose
|
|
# operator cannot tell a typo from a working setting is the wrong kind of quiet,
|
|
# so unknown flags and unparseable values are hard errors here.
|
|
#
|
|
# Precedence, highest first:
|
|
# 1. the command line
|
|
# 2. the config file (--config=<path>, a Godot ConfigFile under [server])
|
|
# 3. the declared default
|
|
#
|
|
# That order is the conventional one and it is the one an operator expects when
|
|
# they override a mounted config file for a single run.
|
|
|
|
enum Kind { BOOL, INT, FLOAT, STRING }
|
|
|
|
|
|
class Spec:
|
|
var key: String # canonical name, without the leading dashes
|
|
var kind: int
|
|
var default_value: Variant
|
|
var help: String
|
|
# Flags the match scene reads rather than the boot scene. Recorded so
|
|
# `--help` can group them honestly instead of implying one consumer.
|
|
var section: String
|
|
|
|
func _init(p_key: String, p_kind: int, p_default: Variant, p_section: String, p_help: String) -> void:
|
|
key = p_key
|
|
kind = p_kind
|
|
default_value = p_default
|
|
section = p_section
|
|
help = p_help
|
|
|
|
|
|
# The single source of truth. A flag that is not here does not exist, and
|
|
# adding one here is all that is needed for it to be parsed, validated,
|
|
# type-checked, config-file-backed and documented.
|
|
static func specs() -> Array[Spec]:
|
|
var out: Array[Spec] = []
|
|
out.append(Spec.new("port", Kind.INT, 7777, "network", "UDP port to listen on"))
|
|
out.append(Spec.new("max-clients", Kind.INT, 12, "network", "Maximum simultaneous connected peers"))
|
|
out.append(Spec.new("max-spectators", Kind.INT, -1, "network", "Spectator cap; 0 disables spectating, negative means unlimited"))
|
|
out.append(Spec.new("log-level", Kind.STRING, "info", "logging", "One of debug, info, warn, error"))
|
|
out.append(Spec.new("replay-log", Kind.STRING, "", "logging", "Path to record a binary replay log to; empty disables (see tools/replay_dump.gd)"))
|
|
out.append(Spec.new("match-length", Kind.FLOAT, 150.0, "match", "Regulation length in seconds"))
|
|
out.append(Spec.new("max-matches", Kind.INT, 0, "match", "Exit cleanly after this many completed matches; 0 runs forever"))
|
|
out.append(Spec.new("min-players", Kind.INT, 1, "match", "Players required before a match starts"))
|
|
out.append(Spec.new("start-countdown", Kind.FLOAT, 5.0, "match", "Seconds to wait after min-players is met before starting"))
|
|
out.append(Spec.new("arena-rotation", Kind.STRING, "sequential", "match", "How the next arena is picked: sequential or random"))
|
|
out.append(Spec.new("smoke-force-goal-after", Kind.FLOAT, -1.0, "match", "LOCAL TEST ONLY: force one server-authoritative goal this many seconds after play starts; -1 disables"))
|
|
out.append(Spec.new("fill-bots", Kind.BOOL, false, "match", "Give a disconnected player's ship to a bot instead of leaving it inert"))
|
|
out.append(Spec.new("slot-reservation-seconds", Kind.FLOAT, 30.0, "match", "How long a departed player's slot is held for their return"))
|
|
out.append(Spec.new("config", Kind.STRING, "", "general", "Path to a config file supplying defaults for any flag above"))
|
|
# Allocated-mode fields are opt-in. Empty defaults intentionally preserve
|
|
# the direct-IP/community-server path and its existing CLI/config surface.
|
|
out.append(Spec.new("allocated-mode", Kind.BOOL, false, "allocation", "Enable match-scoped allocation admission and lifecycle"))
|
|
out.append(Spec.new("match-id", Kind.STRING, "", "allocation", "Opaque allocated match identifier"))
|
|
out.append(Spec.new("server-id", Kind.STRING, "", "allocation", "Opaque allocated server identifier"))
|
|
out.append(Spec.new("playlist-version", Kind.STRING, "", "allocation", "Matchmaking playlist contract version"))
|
|
out.append(Spec.new("client-build", Kind.STRING, "", "allocation", "Expected immutable client build identifier"))
|
|
out.append(Spec.new("assignment-expiry-unix", Kind.INT, 0, "allocation", "Unix expiry for the allocated assignment; must be in the future"))
|
|
out.append(Spec.new("server-image-digest", Kind.STRING, "", "allocation", "Expected immutable server image digest (sha256:...)"))
|
|
out.append(Spec.new("transport", Kind.STRING, "", "allocation", "Assigned transport: steam_sdr or enet"))
|
|
out.append(Spec.new("region", Kind.STRING, "", "allocation", "Assigned region: EU or NA"))
|
|
out.append(Spec.new("join-authorisations-file", Kind.STRING, "", "allocation", "JSON array of control-plane signed join envelopes mounted for this match"))
|
|
return out
|
|
|
|
|
|
var values: Dictionary = {} # key -> parsed value
|
|
var errors: PackedStringArray = [] # human-readable, in the order encountered
|
|
var help_requested := false
|
|
var config_path := ""
|
|
|
|
|
|
func is_valid() -> bool:
|
|
return errors.is_empty()
|
|
|
|
|
|
func get_value(key: String) -> Variant:
|
|
return values.get(key)
|
|
|
|
|
|
# `argv` is OS.get_cmdline_user_args() in production. Taking it as a parameter
|
|
# is what makes every branch below testable without a process.
|
|
#
|
|
# `strict` controls what an unrecognised flag means, and the distinction is
|
|
# load-bearing rather than a convenience. server_boot.gd owns the whole command
|
|
# line, so an unknown flag there is an operator error and must stop the process.
|
|
# networked_match.gd is ONE CONSUMER of a shared argv — the smoke harnesses put
|
|
# --role=, --drive-seconds= and a dozen client-side flags on the same line — so
|
|
# it reads leniently. Nothing is lost: every server flag is declared here, so
|
|
# the strict pass in server_boot.gd already validated all of them before the
|
|
# match scene ever re-reads its own.
|
|
static func parse(argv: PackedStringArray, strict: bool = true) -> ServerConfig:
|
|
var config := ServerConfig.new()
|
|
var by_key := {}
|
|
for spec in specs():
|
|
by_key[spec.key] = spec
|
|
config.values[spec.key] = spec.default_value
|
|
|
|
# Two passes. --config has to be resolved before the file can be read, and
|
|
# the file must be applied UNDER the command line rather than over it, so
|
|
# the file cannot be loaded lazily as flags stream past.
|
|
var seen: Array[String] = []
|
|
var pending: Array = []
|
|
for arg in argv:
|
|
if arg == "--help" or arg == "-h":
|
|
config.help_requested = true
|
|
continue
|
|
if not arg.begins_with("--"):
|
|
if strict:
|
|
config.errors.append("unrecognised argument '%s' (flags start with --)" % arg)
|
|
continue
|
|
var body := arg.substr(2)
|
|
var key := body
|
|
var raw := ""
|
|
var has_value := false
|
|
var eq := body.find("=")
|
|
if eq >= 0:
|
|
key = body.substr(0, eq)
|
|
raw = body.substr(eq + 1)
|
|
has_value = true
|
|
# --no-<bool> is the conventional off switch and is NOT declared as its
|
|
# own Spec, or `--help` would list every boolean twice. Rewrite it into
|
|
# the positive flag with an inverted value before anything else looks
|
|
# at it.
|
|
var negated := _is_negation(key, by_key)
|
|
if not negated.is_empty():
|
|
if has_value:
|
|
config.errors.append("flag '--%s' does not take a value" % key)
|
|
continue
|
|
key = negated
|
|
raw = "false"
|
|
has_value = true
|
|
if not by_key.has(key):
|
|
if strict:
|
|
config.errors.append("unknown flag '--%s' (see --help)" % key)
|
|
continue
|
|
var spec: Spec = by_key[key]
|
|
# A bare --flag is only meaningful for a bool, and --no-flag is the
|
|
# conventional way to turn one off. Every other kind needs a value, and
|
|
# a missing one is an error rather than a silent default.
|
|
if not has_value:
|
|
if spec.kind == Kind.BOOL:
|
|
raw = "true"
|
|
elif strict:
|
|
config.errors.append("flag '--%s' needs a value (--%s=<%s>)" % [key, key, _kind_name(spec.kind)])
|
|
continue
|
|
else:
|
|
continue
|
|
if key in seen:
|
|
if strict:
|
|
config.errors.append("flag '--%s' given more than once" % key)
|
|
continue
|
|
seen.append(key)
|
|
if key == "config":
|
|
config.config_path = raw
|
|
continue
|
|
pending.append([spec, raw])
|
|
|
|
# Config file first, so the command line lands on top of it.
|
|
if not config.config_path.is_empty():
|
|
config._apply_config_file(by_key)
|
|
for entry in pending:
|
|
var spec: Spec = entry[0]
|
|
var parsed = _coerce(spec, entry[1])
|
|
if parsed == null:
|
|
config.errors.append("flag '--%s' expects %s, got '%s'" % [spec.key, _kind_name(spec.kind), entry[1]])
|
|
continue
|
|
config.values[spec.key] = parsed
|
|
config._validate()
|
|
return config
|
|
|
|
|
|
# --no-<bool-flag>, handled by declaring the negation as a synonym rather than
|
|
# as its own Spec — otherwise `--help` lists every boolean twice.
|
|
static func _is_negation(key: String, by_key: Dictionary) -> String:
|
|
if not key.begins_with("no-"):
|
|
return ""
|
|
var positive := key.substr(3)
|
|
if by_key.has(positive) and (by_key[positive] as Spec).kind == Kind.BOOL:
|
|
return positive
|
|
return ""
|
|
|
|
|
|
func _apply_config_file(by_key: Dictionary) -> void:
|
|
var file := ConfigFile.new()
|
|
var err := file.load(config_path)
|
|
if err != OK:
|
|
errors.append("could not read config file '%s' (%s)" % [config_path, error_string(err)])
|
|
return
|
|
for key in file.get_section_keys("server") if file.has_section("server") else []:
|
|
if not by_key.has(key):
|
|
errors.append("unknown key '%s' in config file '%s'" % [key, config_path])
|
|
continue
|
|
var spec: Spec = by_key[key]
|
|
var raw = file.get_value("server", key)
|
|
var parsed = _coerce(spec, str(raw))
|
|
if parsed == null:
|
|
errors.append("config file key '%s' expects %s, got '%s'" % [key, _kind_name(spec.kind), str(raw)])
|
|
continue
|
|
values[key] = parsed
|
|
|
|
|
|
# Returns null on failure — deliberately, so "unparseable" is distinguishable
|
|
# from a legitimately falsy 0/false/"" result.
|
|
static func _coerce(spec: Spec, raw: String) -> Variant:
|
|
match spec.kind:
|
|
Kind.BOOL:
|
|
var lowered := raw.to_lower()
|
|
if lowered in ["true", "1", "yes", "on"]:
|
|
return true
|
|
if lowered in ["false", "0", "no", "off"]:
|
|
return false
|
|
return null
|
|
Kind.INT:
|
|
return int(raw) if raw.is_valid_int() else null
|
|
Kind.FLOAT:
|
|
# is_valid_float() accepts integers too, which is what an operator
|
|
# writing --match-length=150 expects.
|
|
return float(raw) if raw.is_valid_float() else null
|
|
Kind.STRING:
|
|
return raw
|
|
return null
|
|
|
|
|
|
# Range and enum checks the type system cannot express. Kept separate from
|
|
# coercion so an error says "out of range" rather than "expects int".
|
|
func _validate() -> void:
|
|
var port := int(values["port"])
|
|
if port < 1 or port > 65535:
|
|
errors.append("--port must be 1-65535, got %d" % port)
|
|
if int(values["max-clients"]) < 1:
|
|
errors.append("--max-clients must be at least 1, got %d" % int(values["max-clients"]))
|
|
if float(values["match-length"]) <= 0.0:
|
|
errors.append("--match-length must be positive, got %s" % str(values["match-length"]))
|
|
if int(values["max-matches"]) < 0:
|
|
errors.append("--max-matches must be 0 or more, got %d" % int(values["max-matches"]))
|
|
if int(values["min-players"]) < 1:
|
|
errors.append("--min-players must be at least 1, got %d" % int(values["min-players"]))
|
|
if float(values["slot-reservation-seconds"]) < 0.0:
|
|
errors.append("--slot-reservation-seconds cannot be negative, got %s" % str(values["slot-reservation-seconds"]))
|
|
if float(values["smoke-force-goal-after"]) < -1.0:
|
|
errors.append("--smoke-force-goal-after must be -1 (disabled) or 0 or more, got %s" % str(values["smoke-force-goal-after"]))
|
|
var level := String(values["log-level"])
|
|
if not level in ["debug", "info", "warn", "error"]:
|
|
errors.append("--log-level must be one of debug, info, warn, error; got '%s'" % level)
|
|
var rotation := String(values["arena-rotation"])
|
|
if not rotation in ["sequential", "random"]:
|
|
errors.append("--arena-rotation must be sequential or random, got '%s'" % rotation)
|
|
if bool(values["allocated-mode"]):
|
|
for key in ["match-id", "server-id", "playlist-version", "client-build", "assignment-expiry-unix", "server-image-digest", "transport", "region"]:
|
|
if str(values[key]).is_empty():
|
|
errors.append("--allocated-mode requires --%s" % key)
|
|
if int(values["assignment-expiry-unix"]) <= int(Time.get_unix_time_from_system()):
|
|
errors.append("--assignment-expiry-unix must be in the future")
|
|
if String(values["join-authorisations-file"]).is_empty():
|
|
errors.append("--join-authorisations-file is required in allocated mode")
|
|
var digest := String(values["server-image-digest"])
|
|
if not _is_sha256_digest(digest):
|
|
errors.append("--server-image-digest must be sha256:<64 hex characters>")
|
|
var transport := String(values["transport"])
|
|
if not transport in ["steam_sdr", "enet"]:
|
|
errors.append("--transport must be steam_sdr or enet, got '%s'" % transport)
|
|
var region := String(values["region"])
|
|
if not region in ["EU", "NA"]:
|
|
errors.append("--region must be EU or NA, got '%s'" % region)
|
|
|
|
|
|
static func _is_sha256_digest(value: String) -> bool:
|
|
if not value.begins_with("sha256:") or value.length() != 71:
|
|
return false
|
|
for c in value.substr(7):
|
|
if not c.to_lower() in "0123456789abcdef":
|
|
return false
|
|
return true
|
|
|
|
|
|
static func _kind_name(kind: int) -> String:
|
|
match kind:
|
|
Kind.BOOL: return "bool"
|
|
Kind.INT: return "int"
|
|
Kind.FLOAT: return "number"
|
|
Kind.STRING: return "string"
|
|
return "value"
|
|
|
|
|
|
static func help_text() -> String:
|
|
var lines := PackedStringArray()
|
|
lines.append("Cosmic Clash dedicated server")
|
|
lines.append("")
|
|
lines.append(" CosmicClashServer.x86_64 -- --port=7777 --max-clients=6")
|
|
lines.append("")
|
|
lines.append("Flags may also be supplied by a config file:")
|
|
lines.append("")
|
|
lines.append(" --config=/etc/cosmicclash/server.cfg")
|
|
lines.append("")
|
|
lines.append(" [server]")
|
|
lines.append(" port=7777")
|
|
lines.append(" max-clients=6")
|
|
lines.append("")
|
|
lines.append("The command line overrides the config file, which overrides the defaults")
|
|
lines.append("shown below. An unknown flag is an error, not a warning.")
|
|
var sections := ["general", "network", "match", "logging", "allocation"]
|
|
var all := specs()
|
|
for section in sections:
|
|
lines.append("")
|
|
lines.append("%s:" % section)
|
|
for spec in all:
|
|
if spec.section != section:
|
|
continue
|
|
var value_hint := "" if spec.kind == Kind.BOOL else "=<%s>" % _kind_name(spec.kind)
|
|
var flag := "--%s%s" % [spec.key, value_hint]
|
|
var default_hint := ""
|
|
if spec.kind == Kind.BOOL:
|
|
default_hint = " [default: %s, disable with --no-%s]" % [str(spec.default_value), spec.key]
|
|
elif not str(spec.default_value).is_empty():
|
|
default_hint = " [default: %s]" % str(spec.default_value)
|
|
lines.append(" %-34s %s%s" % [flag, spec.help, default_hint])
|
|
return "\n".join(lines)
|