Files
CosmicClash/Game/scripts/server_config.gd
T

366 lines
17 KiB
GDScript

class_name ServerConfig
extends RefCounted
# Dedicated-server configuration (multiplayer-next.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-overtime-seconds", Kind.FLOAT, 900.0, "match", "Safety cap for sudden death; expiry records a REVIEW result without rating changes"))
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("arena-path", Kind.STRING, "", "match", "Allocated arena scene path; empty uses rotation"))
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("playlist", Kind.STRING, "", "allocation", "Allocated playlist: casual or ranked"))
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"))
out.append(Spec.new("join-authorisations-key-file", Kind.STRING, "", "allocation", "HMAC-SHA256 key file for verifying mounted join envelopes"))
out.append(Spec.new("readiness-port", Kind.INT, 7780, "allocation", "Loopback HTTP port for allocated process-ready and drain control"))
out.append(Spec.new("drain-token-env", Kind.STRING, "COSMIC_CLASH_DRAIN_TOKEN", "allocation", "Environment variable containing the allocated drain bearer token"))
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)
var readiness_port := int(values["readiness-port"])
if readiness_port < 1 or readiness_port > 65535:
errors.append("--readiness-port must be 1-65535, got %d" % readiness_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 float(values["max-overtime-seconds"]) <= 0.0:
errors.append("--max-overtime-seconds must be positive, got %s" % str(values["max-overtime-seconds"]))
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)
var arena_path := String(values["arena-path"])
if not arena_path.is_empty() and not arena_path in ArenaRegistry.rotation_paths():
errors.append("--arena-path must be a ranked-eligible ArenaRegistry path, got '%s'" % arena_path)
if bool(values["allocated-mode"]):
for key in ["match-id", "server-id", "playlist-version", "playlist", "client-build", "assignment-expiry-unix", "server-image-digest", "transport", "region"]:
if str(values[key]).is_empty():
errors.append("--allocated-mode requires --%s" % key)
if not _is_opaque_id(String(values["match-id"])):
errors.append("--match-id must be an opaque ID of 16-128 safe characters")
if not _is_opaque_id(String(values["server-id"])):
errors.append("--server-id must be an opaque ID of 16-128 safe characters")
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")
if String(values["join-authorisations-key-file"]).is_empty():
errors.append("--join-authorisations-key-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)
var playlist := String(values["playlist"])
if not playlist in ["casual", "ranked"]:
errors.append("--playlist must be casual or ranked, got '%s'" % playlist)
if playlist == "ranked" and arena_path.is_empty():
errors.append("--allocated-mode ranked matches require --arena-path")
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 _is_opaque_id(value: String) -> bool:
if value.length() < 16 or value.length() > 128:
return false
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
return resource_pattern.search(value) != null
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)