mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
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:
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddo2ye666o0am
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://0vc4j0uivnqr
|
||||
Reference in New Issue
Block a user