feat(server): task 6.1/6.3 — dedicated server export preset and a real CLI surface

6.1: "Linux Dedicated Server" preset (dedicated_server=true,
custom_features="dedicated_server") mirroring the existing training
preset, plus run/main_scene.dedicated_server so the server binary reaches
its own entry point with no flag. Builds: an 85MB Linux x86_64 binary,
gitignored like the training one.

6.3: scripts/server_config.gd declares every server flag once - name,
type, default, section, help - and one parser turns that into parsing,
type checking, range validation, config-file backing and --help. The
flags had grown to ~30 across server_boot.gd and networked_match.gd, each
parsed inline with begins_with, none documented, and an unrecognised flag
was SILENTLY IGNORED: --max-clientss=8 ran a server on the default cap
and said nothing. Unknown flags, missing values, wrong types, duplicates
and out-of-range values are now hard errors, reported all at once.

Precedence is command line > config file > default. server_boot.gd parses
strictly because it owns the whole command line; networked_match.gd reads
the same declaration leniently because it is one consumer of an argv the
smoke harnesses also fill with --role= and --drive-seconds=. Nothing is
lost - every server flag is declared, so the strict pass already caught
any typo before the match scene re-reads its own.

13 unit tests covering the precedence order, the typo rejection that
motivated this, --no-<bool> not double-listing in --help, and --help
documenting every flag asserted against the declaration rather than a
hand-kept list. Verified end to end: --help prints, a typo'd flag refuses
to start, and the plain/replay-log/late-joiner smoke scenarios still pass.
This commit is contained in:
Josh Creek
2026-08-21 17:03:08 +01:00
parent 624d1c6b78
commit 06881f05ca
9 changed files with 507 additions and 40 deletions
+5
View File
@@ -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/
+29
View File
@@ -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"
+4
View File
@@ -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]
+25 -25
View File
@@ -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():
+23 -15
View File
@@ -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)
+292
View File
@@ -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=<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("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-<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"]))
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)
+1
View File
@@ -0,0 +1 @@
uid://ddo2ye666o0am
+127
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://0vc4j0uivnqr