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=, 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- 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-, 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)