diff --git a/.gitignore b/.gitignore index 0efa2175..cba71200 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,10 @@ training/checkpoints/*/ppo_*_steps.zip # export_linux.sh / run_training.sh), not a training result. training/build/ +# Exported dedicated server binary (task 6.1): same reasoning — an 85MB +# regenerable artifact, rebuilt by `godot --headless --path Game +# --export-release "Linux Dedicated Server"`. +server/build/ + # Texture generator scripts: throwaway env, not the scripts themselves. tools/textures/.venv/ diff --git a/Game/export_presets.cfg b/Game/export_presets.cfg index 44113d2a..14f5f6a3 100644 --- a/Game/export_presets.cfg +++ b/Game/export_presets.cfg @@ -26,3 +26,32 @@ texture_format/s3tc=true texture_format/etc=false texture_format/etc2=false binary_format/architecture="x86_64" + +[preset.1] + +name="Linux Dedicated Server" +platform="Linux" +runnable=true +dedicated_server=true +custom_features="dedicated_server" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="../server/build/CosmicClashServer.x86_64" +encryption_include_filters="" +encryption_exclude_filters="" +encrypt_pck=false +encrypt_directory=false +script_encryption_key="" + +[preset.1.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_script=1 +binary_format/embed_pck=true +texture_format/bptc=false +texture_format/s3tc=false +texture_format/etc=false +texture_format/etc2=false +binary_format/architecture="x86_64" diff --git a/Game/project.godot b/Game/project.godot index 8ee38052..b18c0e66 100644 --- a/Game/project.godot +++ b/Game/project.godot @@ -21,6 +21,10 @@ run/main_scene="uid://bcq14356s3e2i" config/features=PackedStringArray("4.7", "Forward Plus") config/icon="res://icon.svg" run/main_scene.training="res://scenes/training.tscn" +# Task 6.1: the dedicated_server export feature swaps the boot scene the same +# way the training export does, so the server binary needs no CLI flag to reach +# its own entry point. +run/main_scene.dedicated_server="res://scenes/server_boot.tscn" [autoload] diff --git a/Game/scripts/networked_match.gd b/Game/scripts/networked_match.gd index 22fbf8f6..40667bd3 100644 --- a/Game/scripts/networked_match.gd +++ b/Game/scripts/networked_match.gd @@ -320,31 +320,31 @@ func _ready() -> void: if kickoff_rng_seed == 0: _kickoff_rng.randomize() if multiplayer.is_server(): - for arg: String in OS.get_cmdline_user_args(): - if arg == "--fill-bots": - _fill_bots = true - elif arg == "--no-fill-bots": - _fill_bots = false - elif arg.begins_with("--max-spectators="): - _max_spectators = maxi(0, arg.get_slice("=", 1).to_int()) - elif arg.begins_with("--replay-log="): - # Task 5.10. Diagnostic only: a log that cannot be opened must - # never stop the server serving the match. - var replay_path := arg.get_slice("=", 1) - _replay_log = ReplayLog.new() - var replay_err := _replay_log.open_for_write(replay_path) - if replay_err != OK: - push_warning("NetworkedMatch: could not open replay log %s (%s)" % [replay_path, error_string(replay_err)]) - _replay_log = null - else: - print("NetworkedMatch: recording replay log to %s" % replay_path) - elif arg.begins_with("--slot-reservation-seconds="): - _slot_reservation_seconds = maxf(0.0, arg.get_slice("=", 1).to_float()) - elif arg.begins_with("--match-length="): - # Regulation is 150s; a smoke test cannot wait that long to see - # FULL_TIME/OVERTIME/RESULTS, so allow an override. Server-side - # only — a client cannot shorten anyone's match. - match_length_seconds = maxf(1.0, arg.get_slice("=", 1).to_float()) + # Task 6.3: the same declaration server_boot.gd validated, re-read here + # LENIENTLY — this scene is one consumer of an argv the smoke harnesses + # also fill with --role=, --drive-seconds= and client-side flags. The + # strict pass at the process entry point already rejected any typo in a + # server flag, so nothing is lost by ignoring what is not ours. + var config := ServerConfig.parse(OS.get_cmdline_user_args(), false) + _fill_bots = bool(config.get_value("fill-bots")) + var spectator_cap := int(config.get_value("max-spectators")) + _max_spectators = spectator_cap if spectator_cap < 0 else maxi(0, spectator_cap) + _slot_reservation_seconds = maxf(0.0, float(config.get_value("slot-reservation-seconds"))) + # Regulation is 150s; a smoke test cannot wait that long to see + # FULL_TIME/OVERTIME/RESULTS, so allow an override. Server-side only — + # a client cannot shorten anyone's match. + match_length_seconds = maxf(1.0, float(config.get_value("match-length"))) + var replay_path := String(config.get_value("replay-log")) + if not replay_path.is_empty(): + # Task 5.10. Diagnostic only: a log that cannot be opened must + # never stop the server serving the match. + _replay_log = ReplayLog.new() + var replay_err := _replay_log.open_for_write(replay_path) + if replay_err != OK: + push_warning("NetworkedMatch: could not open replay log %s (%s)" % [replay_path, error_string(replay_err)]) + _replay_log = null + else: + print("NetworkedMatch: recording replay log to %s" % replay_path) _start_server() else: for arg: String in OS.get_cmdline_user_args(): diff --git a/Game/scripts/server_boot.gd b/Game/scripts/server_boot.gd index 71a17fd1..0d3155b8 100644 --- a/Game/scripts/server_boot.gd +++ b/Game/scripts/server_boot.gd @@ -17,6 +17,7 @@ const LOG_LEVELS := {"debug": 0, "info": 1, "warn": 2, "error": 3} var _boot_ms := 0 var _last_physics_frame := 0 var _log_level := 1 # info +var config: ServerConfig = null var _watchdog_armed := false # skip the first _process(): engine startup scheduling can batch several physics frames before the first idle frame runs, which isn't a real overrun @@ -24,21 +25,28 @@ func _ready() -> void: _boot_ms = Time.get_ticks_msec() Engine.max_fps = 60 # a server never renders; this just caps the idle-frame poll rate so it doesn't spin - var port := NetworkManager.DEFAULT_PORT - var max_clients := NetworkManager.MAX_CLIENTS - for arg in OS.get_cmdline_user_args(): - if arg.begins_with("--port="): - port = int(arg.substr("--port=".length())) - elif arg.begins_with("--max-clients="): - max_clients = int(arg.substr("--max-clients=".length())) - elif arg.begins_with("--log-level="): - var level_name := arg.substr("--log-level=".length()) - if LOG_LEVELS.has(level_name): - _log_level = LOG_LEVELS[level_name] - else: - _log("error", "bad_log_level", {"given": level_name, "valid": LOG_LEVELS.keys()}) - get_tree().quit(1) - return + # Task 6.3. This process owns the whole command line, so it parses STRICTLY: + # an unknown flag or an out-of-range value stops the server with a message + # rather than starting one that silently ignores half of what it was told. + config = ServerConfig.parse(OS.get_cmdline_user_args()) + if config.help_requested: + print(ServerConfig.help_text()) + get_tree().quit(0) + return + if not config.is_valid(): + # Straight to stderr-ish plain print rather than through _log: the log + # level itself may be one of the things that failed to parse, and an + # operator running this by hand needs to see every problem at once, not + # the first one. + printerr("cosmic-clash-server: refusing to start") + for problem in config.errors: + printerr(" %s" % problem) + printerr("try --help") + get_tree().quit(1) + return + var port := int(config.get_value("port")) + var max_clients := int(config.get_value("max-clients")) + _log_level = LOG_LEVELS[String(config.get_value("log-level"))] NetworkManager.client_connected.connect(_on_client_connected) NetworkManager.client_disconnected.connect(_on_client_disconnected) diff --git a/Game/scripts/server_config.gd b/Game/scripts/server_config.gd new file mode 100644 index 00000000..685fa02c --- /dev/null +++ b/Game/scripts/server_config.gd @@ -0,0 +1,292 @@ +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=, 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("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")) + 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) + 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"])) + 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) + + +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"] + 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) diff --git a/Game/scripts/server_config.gd.uid b/Game/scripts/server_config.gd.uid new file mode 100644 index 00000000..eb99d0d3 --- /dev/null +++ b/Game/scripts/server_config.gd.uid @@ -0,0 +1 @@ +uid://ddo2ye666o0am diff --git a/Game/tests/cases/test_server_config.gd b/Game/tests/cases/test_server_config.gd new file mode 100644 index 00000000..eee45f23 --- /dev/null +++ b/Game/tests/cases/test_server_config.gd @@ -0,0 +1,127 @@ +extends "res://tests/test_case.gd" + +# Task 6.3. The behaviour that matters is not "it parses a port" — it is the +# precedence order an operator relies on, and the refusal to run on a typo. + +const ServerConfigScript = preload("res://scripts/server_config.gd") + + +func _parse(args: Array) -> Variant: + var argv := PackedStringArray() + for a in args: + argv.append(a) + return ServerConfigScript.parse(argv) + + +func _temp_config(body: String) -> String: + var path := "user://test_server_%d.cfg" % Time.get_ticks_usec() + var f := FileAccess.open(path, FileAccess.WRITE) + f.store_string(body) + f.close() + return path + + +func test_defaults_apply_when_nothing_is_given() -> void: + var config = _parse([]) + assert_true(config.is_valid(), "an empty command line is valid") + assert_eq(config.get_value("port"), 7777, "default port") + assert_eq(config.get_value("max-matches"), 0, "0 means run forever") + assert_eq(config.get_value("log-level"), "info", "default log level") + + +func test_command_line_values_are_typed_not_strings() -> void: + var config = _parse(["--port=7000", "--match-length=90.5", "--fill-bots"]) + assert_true(config.is_valid(), "valid: %s" % str(config.errors)) + assert_eq(config.get_value("port"), 7000, "int stays an int") + assert_almost_eq(config.get_value("match-length"), 90.5, 0.001, "float stays a float") + assert_eq(config.get_value("fill-bots"), true, "a bare bool flag is true") + + +func test_an_unknown_flag_is_an_error_not_a_shrug() -> void: + # The whole reason this class exists: `--max-clientss=8` used to run a + # server on the default cap and say nothing at all. + var config = _parse(["--max-clientss=8"]) + assert_true(not config.is_valid(), "a typo'd flag is rejected") + assert_true("max-clientss" in " ".join(config.errors), "and the error names it: %s" % str(config.errors)) + + +func test_a_value_that_is_not_the_declared_type_is_rejected() -> void: + var config = _parse(["--port=seven"]) + assert_true(not config.is_valid(), "a non-numeric port is rejected") + var config_ok = _parse(["--port=7000"]) + assert_true(config_ok.is_valid(), "control: a numeric port is accepted") + + +func test_a_non_bool_flag_without_a_value_is_rejected() -> void: + # Silently defaulting here would hide a shell-quoting mistake. + var config = _parse(["--port"]) + assert_true(not config.is_valid(), "--port with no value is an error") + + +func test_no_prefix_turns_a_bool_off() -> void: + var config = _parse(["--no-fill-bots"]) + assert_true(config.is_valid(), "valid: %s" % str(config.errors)) + assert_eq(config.get_value("fill-bots"), false, "--no- inverts it") + # And it must not be listed separately, or --help doubles in length. + var help: String = ServerConfigScript.help_text() + assert_eq(help.count("--no-fill-bots"), 1, "--no- form appears once, in the default hint") + + +func test_the_command_line_beats_the_config_file() -> void: + var path := _temp_config("[server]\nport=8100\nmax-clients=4\n") + var config = _parse(["--config=%s" % path, "--port=9200"]) + assert_true(config.is_valid(), "valid: %s" % str(config.errors)) + assert_eq(config.get_value("port"), 9200, "the command line wins") + assert_eq(config.get_value("max-clients"), 4, "the file still supplies what the command line omits") + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + + +func test_the_config_file_beats_the_default() -> void: + var path := _temp_config("[server]\nport=8100\n") + var config = _parse(["--config=%s" % path]) + assert_true(config.is_valid(), "valid: %s" % str(config.errors)) + assert_eq(config.get_value("port"), 8100, "the file overrides the default") + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + + +func test_a_missing_or_malformed_config_file_is_an_error() -> void: + var config = _parse(["--config=user://definitely_not_here_%d.cfg" % Time.get_ticks_usec()]) + assert_true(not config.is_valid(), "a config file that cannot be read is an error, not silence") + var path := _temp_config("[server]\nnonsense=1\n") + var unknown_key = _parse(["--config=%s" % path]) + assert_true(not unknown_key.is_valid(), "an unknown key in the file is rejected too") + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + + +func test_out_of_range_values_are_rejected_with_their_own_message() -> void: + assert_true(not _parse(["--port=0"]).is_valid(), "port 0 is out of range") + assert_true(not _parse(["--port=70000"]).is_valid(), "port 70000 is out of range") + assert_true(not _parse(["--max-clients=0"]).is_valid(), "a server for nobody is rejected") + assert_true(not _parse(["--match-length=0"]).is_valid(), "a zero-length match is rejected") + assert_true(not _parse(["--log-level=chatty"]).is_valid(), "an undefined log level is rejected") + assert_true(not _parse(["--arena-rotation=spiral"]).is_valid(), "an undefined rotation mode is rejected") + # Control: the same flags at legal values all pass together. + var ok = _parse(["--port=7000", "--max-clients=6", "--match-length=90", "--log-level=warn", "--arena-rotation=random"]) + assert_true(ok.is_valid(), "control: legal values pass (%s)" % str(ok.errors)) + + +func test_a_repeated_flag_is_rejected_rather_than_last_wins() -> void: + # Last-wins hides a duplicated line in a generated systemd unit. + var config = _parse(["--port=7000", "--port=8000"]) + assert_true(not config.is_valid(), "the same flag twice is an error") + + +func test_help_documents_every_declared_flag() -> void: + # The acceptance criterion is literally "--help documents every flag", so + # assert it against the declaration rather than against a hand-kept list. + var help: String = ServerConfigScript.help_text() + for spec in ServerConfigScript.specs(): + assert_true("--%s" % spec.key in help, "--%s appears in --help" % spec.key) + assert_true(spec.help in help, "and so does its description") + + +func test_help_is_requested_without_needing_a_valid_command_line() -> void: + var config = _parse(["--help"]) + assert_true(config.help_requested, "--help is recognised") + var short = _parse(["-h"]) + assert_true(short.help_requested, "-h too") diff --git a/Game/tools/replay_dump.gd.uid b/Game/tools/replay_dump.gd.uid new file mode 100644 index 00000000..44d6021d --- /dev/null +++ b/Game/tools/replay_dump.gd.uid @@ -0,0 +1 @@ +uid://0vc4j0uivnqr