feat(server): complete phase 6 local verification

This commit is contained in:
Josh Creek
2026-08-21 18:38:30 +01:00
parent ec896b27ac
commit f2b72394de
21 changed files with 604 additions and 4 deletions
+3 -3
View File
@@ -21,9 +21,9 @@ 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.
# Dedicated exports select the server boot scene before the interactive menu
# is loaded. This is the same project-setting feature override used above by
# the training export.
run/main_scene.dedicated_server="res://scenes/server_boot.tscn"
[autoload]
+23
View File
@@ -24,3 +24,26 @@ const ARENAS := [
static func random_path() -> String:
var candidates := ARENAS.filter(func(arena): return arena["random"])
return candidates[randi() % candidates.size()]["path"]
# The arenas a server may rotate through, in declaration order. Same filter as
# random_path(): an elevated-goal variant is Free-Play-only until a checkpoint
# trained on it is promoted, and a dedicated server rotating onto one would
# hand every bot-filled slot an arena it cannot score in.
static func rotation_paths() -> Array:
return ARENAS.filter(func(arena): return arena["random"]).map(func(arena): return arena["path"])
# Task 6.5's arena rotation, as pure arithmetic so it is unit-testable without
# a server: given how many matches have already been played, which arena is
# next. `random` deliberately still uses the global RNG (the caller wants
# variety, not reproducibility); `sequential` is a pure function of the count,
# which is what makes "the server cycles arenas" an assertable claim rather
# than an observation about luck.
static func path_for_match(match_index: int, mode: String) -> String:
var paths := rotation_paths()
if paths.is_empty():
return ARENAS[0]["path"]
if mode == "random":
return paths[randi() % paths.size()]
return paths[posmod(match_index, paths.size())]
+32 -1
View File
@@ -306,12 +306,20 @@ var _spectator_target_index := 0
# §6.3, server only. Peers that joined mid-match with no slot to reclaim, in
# arrival order, waiting for the next kickoff to hand them a vacated slot.
var _late_joiners: Array[Dictionary] = []
# Task 6.5, server only. Set by ServerMatchLoop immediately before it switches
# to this scene; static because the loop cannot hold a reference to a node that
# does not exist yet, and consumed on read so it cannot leak into a later match.
static var server_arena_override := ""
# §6.3's "cap with --max-spectators". Server only; 0 disables spectating
# entirely, negative means unlimited.
var _max_spectators := -1
var _last_emitted_countdown := -1
var _in_overtime := false
var _match_over := false
# Dedicated-export smoke hook (task 6.2). It is parsed only by the authoritative
# server, cannot be triggered by an RPC, and defaults to disabled.
var _smoke_force_goal_tick := -1
var _smoke_goal_forced := false
func _ready() -> void:
@@ -334,6 +342,9 @@ func _ready() -> void:
# 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 smoke_after := float(config.get_value("smoke-force-goal-after"))
if smoke_after >= 0.0:
_smoke_force_goal_tick = -2 # arm when PLAYING begins; -1 remains disabled
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
@@ -418,7 +429,11 @@ func _exit_tree() -> void:
# ============================================================
func _start_server() -> void:
var arena_path := ArenaRegistry.random_path()
# Task 6.5: the server match loop hands the arena down so rotation is a
# rotation rather than a coincidence. Consumed once, so a match started any
# other way (a test harness, a future lobby button) still picks at random.
var arena_path := server_arena_override if not server_arena_override.is_empty() else ArenaRegistry.random_path()
server_arena_override = ""
_arena_path = arena_path
arena = (load(arena_path) as PackedScene).instantiate()
add_child(arena)
@@ -1084,6 +1099,21 @@ func _on_goal_registered(conceding_team: int) -> void:
_broadcast_clock_state()
func _maybe_force_smoke_goal() -> void:
if _smoke_goal_forced or _smoke_force_goal_tick == -1 or match_state != MatchState.State.PLAYING:
return
if _smoke_force_goal_tick == -2:
var config := ServerConfig.parse(OS.get_cmdline_user_args(), false)
_smoke_force_goal_tick = Engine.get_physics_frames() + int(maxf(0.0, float(config.get_value("smoke-force-goal-after"))) * SimConstants.TICK_HZ)
ServerLog.info("smoke_goal_armed", {"tick": _smoke_force_goal_tick})
return
if Engine.get_physics_frames() < _smoke_force_goal_tick:
return
_smoke_goal_forced = true
ServerLog.info("smoke_goal_forced", {"tick": Engine.get_physics_frames()})
_on_goal_registered(0)
# Task 5.9. Server-only by construction: _respawn_escaped_bodies() is gated on
# _owns_world_simulation(). The bump uses Phase 2's deferred path because the
# respawn only QUEUES a teleport — bumping now would broadcast the new
@@ -2161,6 +2191,7 @@ func _physics_process(_delta: float) -> void:
# Also before the broadcast, so a transition taken this tick ships in
# this tick's own match_state byte rather than trailing it by one.
_update_match_state()
_maybe_force_smoke_goal()
_expire_slot_reservations()
# Countdown and clock are derived from absolute ticks on both peers, so
# these run on the client too.
+4
View File
@@ -5,3 +5,7 @@ const MAIN_MENU := "res://scenes/main_menu.tscn"
# a community server whose players are all dumped back to their own menus
# every 2.5 minutes has no way to keep a lobby together.
const LOBBY := "res://scenes/lobby.tscn"
# Task 6.5: the dedicated server's match loop needs this by name, and it was
# previously only ever reached by test harnesses hardcoding the string.
const NETWORKED_MATCH := "res://scenes/networked_match.tscn"
const SERVER_BOOT := "res://scenes/server_boot.tscn"
+15
View File
@@ -53,6 +53,7 @@ func _ready() -> void:
ServerLog.error("server_boot_failed", {"port": port, "error": error_string(err)})
get_tree().quit(1)
return
_install_match_loop()
ServerLog.info("server_started", {
"port": port, "max_clients": max_clients, "log_level": ServerLog.level_name(),
"min_players": int(config.get_value("min-players")),
@@ -62,6 +63,20 @@ func _ready() -> void:
_last_physics_frame = Engine.get_physics_frames()
# Task 6.5. Parented to the ROOT rather than to this node: the loop calls
# change_scene_to_file, which frees the current scene — and this boot scene IS
# the current scene, so a loop parented here would be freed by the first match
# it started. Same constraint the smoke-test hooks document.
func _install_match_loop() -> void:
var loop := ServerMatchLoop.new()
loop.name = "ServerMatchLoop"
loop.min_players = int(config.get_value("min-players"))
loop.start_countdown_seconds = float(config.get_value("start-countdown"))
loop.max_matches = int(config.get_value("max-matches"))
loop.rotation_mode = String(config.get_value("arena-rotation"))
get_tree().root.add_child.call_deferred(loop)
func _process(_delta: float) -> void:
NetworkManager.poll()
var current := Engine.get_physics_frames()
+3
View File
@@ -60,6 +60,7 @@ static func specs() -> Array[Spec]:
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("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"))
@@ -240,6 +241,8 @@ func _validate() -> void:
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)
+122
View File
@@ -0,0 +1,122 @@
class_name ServerMatchLoop
extends Node
# The dedicated server's match loop (multiplayer-todo.md task 6.5).
#
# THIS CLOSES A GAP NO TASK OWNED. Task 6.2 asks for "the exported binary runs
# a full match headless", but nothing in the product ever started a match:
# lobby.gd has no start path, and every match in this project's history was
# begun by a test harness calling change_scene_to_file directly. The dedicated
# server booted, listened, and could never play anything. 6.5 was written as
# "arena rotation between matches", which presumes a first match that nothing
# produced — so the whole loop lives here, not just the rotation.
#
# Lifecycle:
#
# wait for --min-players (roster, not raw peers: a peer that has
# connected but not completed the hello
# handshake is not a player yet)
# -> --start-countdown seconds (so a second player joining 200ms later
# is in THIS match, not the next one)
# -> networked_match.tscn on the arena --arena-rotation picked
# -> the match runs itself and returns to the lobby at RESULTS
# -> repeat, or exit(0) once --max-matches have completed
#
# Parented to the scene tree ROOT, never to current_scene: change_scene_to_file
# frees whatever scene is live, and an orchestrator that gets freed by the
# transition it just requested cannot orchestrate the next one. This is the
# same constraint tests/networked_match_test_hooks.gd documents, arrived at the
# same way — it is a property of Godot's scene switching, not of testing.
#
# The countdown is deliberately NOT a Timer: §6.1's tick-derived-clock rule
# applies to anything whose timing a client can observe, and the wait before a
# match is exactly that.
signal match_starting(arena_path: String, match_index: int)
const POLL_INTERVAL_MS := 250
var min_players := 1
var start_countdown_seconds := 5.0
var max_matches := 0 # 0 = run forever
var rotation_mode := "sequential"
var matches_completed := 0
var _countdown_started_ms := -1
var _match_active := false
var _next_poll_ms := 0
var _shutting_down := false
func _process(_delta: float) -> void:
if _shutting_down or not multiplayer.is_server():
return
var now := Time.get_ticks_msec()
if now < _next_poll_ms:
return
_next_poll_ms = now + POLL_INTERVAL_MS
if _match_active:
_poll_match_end()
else:
_poll_match_start(now)
# A match is over when the match scene is gone. NetworkedMatch returns both
# peers to the lobby itself at RESULTS (§6.2 step 10) and aborts to the lobby
# when everyone has left (§6.4), so "the scene we started is no longer the
# current scene" covers the clean end and the abandoned one identically —
# without this node having to duplicate either rule or reach into match state.
func _poll_match_end() -> void:
var scene := get_tree().current_scene
if is_instance_valid(scene) and scene.is_in_group("game"):
return
_match_active = false
matches_completed += 1
ServerLog.info("match_completed", {
"completed": matches_completed, "of": max_matches if max_matches > 0 else "unlimited",
})
if max_matches > 0 and matches_completed >= max_matches:
# §6's drain-and-exit: the point of --max-matches is that a supervisor
# can restart the process on a new build between matches instead of
# killing players mid-game. Exiting anywhere else would defeat it.
_shutting_down = true
ServerLog.info("server_draining", {"reason": "max_matches_reached", "matches": matches_completed})
get_tree().quit(0)
return
# Straight back to waiting. The countdown restarts from scratch rather than
# carrying over, so players who left during the last match are not counted
# toward starting the next one.
_countdown_started_ms = -1
func _poll_match_start(now: int) -> void:
var players := MatchNet.roster.size()
if players < min_players:
if _countdown_started_ms >= 0:
ServerLog.info("match_start_cancelled", {"players": players, "needed": min_players})
_countdown_started_ms = -1
return
if _countdown_started_ms < 0:
_countdown_started_ms = now
ServerLog.info("match_start_countdown", {
"players": players, "seconds": start_countdown_seconds,
})
return
if now - _countdown_started_ms < int(start_countdown_seconds * 1000.0):
return
_start_match()
func _start_match() -> void:
var arena_path := ArenaRegistry.path_for_match(matches_completed, rotation_mode)
# The match scene picks its own arena at random by default. Handing it one
# explicitly is what makes rotation a rotation rather than a coincidence.
NetworkedMatch.server_arena_override = arena_path
_match_active = true
_countdown_started_ms = -1
ServerLog.info("match_starting", {
"index": matches_completed + 1, "arena": arena_path,
"players": MatchNet.roster.size(), "rotation": rotation_mode,
})
match_starting.emit(arena_path, matches_completed)
get_tree().change_scene_to_file.call_deferred(ScenePaths.NETWORKED_MATCH)
+1
View File
@@ -0,0 +1 @@
uid://xnvwnqushvvt
+60
View File
@@ -0,0 +1,60 @@
extends "res://tests/test_case.gd"
# Task 6.5. "The server cycles arenas" has to be an assertable claim rather
# than an observation about luck, which is why sequential rotation is a pure
# function of the completed-match count.
func test_sequential_rotation_visits_every_arena_before_repeating() -> void:
var paths := ArenaRegistry.rotation_paths()
assert_true(paths.size() >= 2, "rotation needs at least two arenas to mean anything")
var seen := {}
for i in paths.size():
seen[ArenaRegistry.path_for_match(i, "sequential")] = true
assert_eq(seen.size(), paths.size(), "every rotation arena appears in the first cycle")
func test_sequential_rotation_wraps_rather_than_running_out() -> void:
var paths := ArenaRegistry.rotation_paths()
var first: String = ArenaRegistry.path_for_match(0, "sequential")
var wrapped: String = ArenaRegistry.path_for_match(paths.size(), "sequential")
assert_eq(wrapped, first, "match N wraps back to the first arena")
# And a long-running server must not drift or fault at large counts.
assert_eq(ArenaRegistry.path_for_match(paths.size() * 1000, "sequential"), first, "still correct after a thousand cycles")
func test_consecutive_matches_are_never_the_same_arena_in_sequential_mode() -> void:
# The point of rotation is that players do not play the same arena twice in
# a row; wrapping must not produce a repeat at the seam either.
var paths := ArenaRegistry.rotation_paths()
for i in paths.size() * 2:
var current: String = ArenaRegistry.path_for_match(i, "sequential")
var next: String = ArenaRegistry.path_for_match(i + 1, "sequential")
assert_true(current != next, "match %d and %d differ" % [i, i + 1])
func test_rotation_never_offers_an_arena_bots_cannot_score_in() -> void:
# Elevated-goal variants are Free-Play-only until a checkpoint trained on
# them is promoted. A server rotating onto one would hand every bot-filled
# slot an arena it cannot score in.
var rotation := ArenaRegistry.rotation_paths()
for arena in ArenaRegistry.ARENAS:
if not arena["random"]:
assert_true(not (arena["path"] in rotation), "%s is excluded from rotation" % arena["name"])
assert_true(rotation.size() > 0, "and something is left to rotate through")
func test_random_mode_stays_inside_the_rotation_set() -> void:
for i in 50:
var path: String = ArenaRegistry.path_for_match(i, "random")
assert_true(path in ArenaRegistry.rotation_paths(), "random picks are still rotation-eligible")
func test_an_unknown_mode_falls_back_to_sequential_rather_than_faulting() -> void:
# The CLI already rejects an undeclared mode, so this is the belt to that's
# braces — but a server must not crash between matches over a string.
assert_eq(
ArenaRegistry.path_for_match(1, "spiral"),
ArenaRegistry.path_for_match(1, "sequential"),
"an unrecognised mode behaves as sequential"
)
+1
View File
@@ -100,6 +100,7 @@ func test_out_of_range_values_are_rejected_with_their_own_message() -> void:
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")
assert_true(not _parse(["--smoke-force-goal-after=-2"]).is_valid(), "only -1 disables the deterministic smoke goal")
# 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))
+1
View File
@@ -0,0 +1 @@
uid://cr0hqi7fwac2e
+97
View File
@@ -0,0 +1,97 @@
extends Node
# Task 6.2 / 6.7: black-box client for the exported dedicated-server smoke.
# It contains no in-process server hook: two copies run in distinct containers,
# join through ENet, and pass only after an authoritative score RPC arrives.
const DEFAULT_PORT := 7777
const TIMEOUT_SECONDS := 55.0
var _address := "server"
var _port := DEFAULT_PORT
var _name := "ExportSmoke"
var _expected_goals := 1
var _goals_observed := 0
@onready var _network_manager: Node = get_node("/root/NetworkManager")
@onready var _match_net: Node = get_node("/root/MatchNet")
@onready var _match_sim: Node = get_node("/root/MatchSim")
func _ready() -> void:
# The client enters networked_match.tscn after the hello handshake. Keep
# this observer outside that scene so the scene transition cannot free it
# before the authoritative score RPC arrives. Deferring avoids reparenting
# while Godot is still adding the smoke scene to the tree.
call_deferred("_move_to_root")
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--address="):
_address = arg.get_slice("=", 1)
elif arg.begins_with("--port="):
_port = int(arg.get_slice("=", 1))
elif arg.begins_with("--name="):
_name = arg.get_slice("=", 1)
elif arg.begins_with("--expected-goals="):
_expected_goals = maxi(1, int(arg.get_slice("=", 1)))
# Use node paths instead of autoload identifiers: this test deliberately
# runs from a clean source tree, before Godot has an editor-generated cache.
_match_net.set("local_player_name", _name)
_match_net.connect("welcomed", _on_welcomed)
_match_sim.connect("score_update_received", _on_score_update)
get_tree().create_timer(TIMEOUT_SECONDS).timeout.connect(_on_timeout)
# ENet's connection state machine handles a server still booting. Joining
# immediately also ensures MatchSim never runs a physics tick on an inactive
# MultiplayerPeer while a timer waits to make the first connection attempt.
_connect()
func _move_to_root() -> void:
var parent := get_parent()
if parent == null:
return
var tree := get_tree()
parent.remove_child(self)
tree.root.add_child(self)
func _process(_delta: float) -> void:
_network_manager.call("poll")
func _physics_process(_delta: float) -> void:
_network_manager.call("poll")
func _connect() -> void:
var err: int = _network_manager.call("join", _address, _port)
if err != OK:
_fail("join(%s:%d) failed: %s" % [_address, _port, error_string(err)])
func _on_welcomed() -> void:
_match_net.disconnect("welcomed", _on_welcomed)
get_tree().change_scene_to_file.call_deferred(ScenePaths.NETWORKED_MATCH)
func _on_score_update(score: Dictionary) -> void:
if int(score.get(0, 0)) + int(score.get(1, 0)) < 1:
return
_goals_observed += 1
if _goals_observed < _expected_goals:
print("EXPORT SMOKE: %s observed match %d/%d score %s" % [_name, _goals_observed, _expected_goals, str(score)])
return
print("EXPORT SMOKE PASS: %s observed %d authoritative goals" % [_name, _goals_observed])
# Give the reliable goal/state messages one beat to settle before this peer
# leaves, then let the server's zero-reservation test config abort/drain.
get_tree().create_timer(0.5).timeout.connect(func() -> void: get_tree().quit(0))
func _on_timeout() -> void:
if _goals_observed < _expected_goals:
_fail("%s timed out without an authoritative goal" % _name)
func _fail(message: String) -> void:
printerr("EXPORT SMOKE FAIL: " + message)
_network_manager.call("shutdown")
get_tree().quit(1)
+8
View File
@@ -0,0 +1,8 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/export_server_smoke.gd" id="1_smoke"]
[node name="ExportServerSmokeScene" type="Node"]
[node name="ExportServerSmoke" type="Node" parent="."]
script = ExtResource("1_smoke")