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
+14
View File
@@ -0,0 +1,14 @@
name: Phase 6 dedicated server verification
on:
push:
pull_request:
jobs:
local-equivalent-smoke:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Build and verify exported dedicated server
run: make verify-phase6
+29
View File
@@ -0,0 +1,29 @@
# Local-only dedicated-server build and verification image. Pin the Godot
# release family used by project.godot; no image is pushed by this repository.
FROM --platform=linux/amd64 barichello/godot-ci:4.7.1 AS exporter
WORKDIR /workspace
RUN apt-get update \
&& apt-get install -y --no-install-recommends libfontconfig1 \
&& rm -rf /var/lib/apt/lists/*
COPY Game /workspace/Game
# Godot dedicated exports disallow command-line scene overrides. Bake the
# server scene into this export (the interactive project's source stays
# unchanged), then generate the global-script/autoload metadata it needs.
RUN sed -i 's|^run/main_scene=.*$|run/main_scene="res://scenes/server_boot.tscn"|' Game/project.godot \
&& godot --headless --editor --path Game --import --quit \
&& mkdir -p /opt/cosmic-clash \
&& godot --headless --path Game --export-release "Linux Dedicated Server" /opt/cosmic-clash/CosmicClashServer.x86_64
FROM --platform=linux/amd64 ubuntu:24.04 AS server
RUN apt-get update && apt-get install -y --no-install-recommends libfontconfig1 libgl1 libstdc++6 && rm -rf /var/lib/apt/lists/*
COPY --from=exporter /opt/cosmic-clash/ /opt/cosmic-clash/
COPY deploy/cosmic-clash-server /opt/cosmic-clash/cosmic-clash-server
RUN chmod 0755 /opt/cosmic-clash/cosmic-clash-server
WORKDIR /opt/cosmic-clash
EXPOSE 7777/udp
ENTRYPOINT ["/opt/cosmic-clash/cosmic-clash-server"]
# Test-only target: runs the source client harness against the exported server.
FROM exporter AS smoke-client
WORKDIR /workspace
ENTRYPOINT ["godot", "--headless", "--path", "Game", "res://tests/export_server_smoke.tscn", "--"]
+3 -3
View File
@@ -21,9 +21,9 @@ run/main_scene="uid://bcq14356s3e2i"
config/features=PackedStringArray("4.7", "Forward Plus") config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://icon.svg" config/icon="res://icon.svg"
run/main_scene.training="res://scenes/training.tscn" run/main_scene.training="res://scenes/training.tscn"
# Task 6.1: the dedicated_server export feature swaps the boot scene the same # Dedicated exports select the server boot scene before the interactive menu
# way the training export does, so the server binary needs no CLI flag to reach # is loaded. This is the same project-setting feature override used above by
# its own entry point. # the training export.
run/main_scene.dedicated_server="res://scenes/server_boot.tscn" run/main_scene.dedicated_server="res://scenes/server_boot.tscn"
[autoload] [autoload]
+23
View File
@@ -24,3 +24,26 @@ const ARENAS := [
static func random_path() -> String: static func random_path() -> String:
var candidates := ARENAS.filter(func(arena): return arena["random"]) var candidates := ARENAS.filter(func(arena): return arena["random"])
return candidates[randi() % candidates.size()]["path"] 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 # §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. # arrival order, waiting for the next kickoff to hand them a vacated slot.
var _late_joiners: Array[Dictionary] = [] 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 # §6.3's "cap with --max-spectators". Server only; 0 disables spectating
# entirely, negative means unlimited. # entirely, negative means unlimited.
var _max_spectators := -1 var _max_spectators := -1
var _last_emitted_countdown := -1 var _last_emitted_countdown := -1
var _in_overtime := false var _in_overtime := false
var _match_over := 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: func _ready() -> void:
@@ -334,6 +342,9 @@ func _ready() -> void:
# FULL_TIME/OVERTIME/RESULTS, so allow an override. Server-side only — # FULL_TIME/OVERTIME/RESULTS, so allow an override. Server-side only —
# a client cannot shorten anyone's match. # a client cannot shorten anyone's match.
match_length_seconds = maxf(1.0, float(config.get_value("match-length"))) 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")) var replay_path := String(config.get_value("replay-log"))
if not replay_path.is_empty(): if not replay_path.is_empty():
# Task 5.10. Diagnostic only: a log that cannot be opened must # Task 5.10. Diagnostic only: a log that cannot be opened must
@@ -418,7 +429,11 @@ func _exit_tree() -> void:
# ============================================================ # ============================================================
func _start_server() -> 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_path = arena_path
arena = (load(arena_path) as PackedScene).instantiate() arena = (load(arena_path) as PackedScene).instantiate()
add_child(arena) add_child(arena)
@@ -1084,6 +1099,21 @@ func _on_goal_registered(conceding_team: int) -> void:
_broadcast_clock_state() _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 # 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 # _owns_world_simulation(). The bump uses Phase 2's deferred path because the
# respawn only QUEUES a teleport — bumping now would broadcast the new # 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 # 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. # this tick's own match_state byte rather than trailing it by one.
_update_match_state() _update_match_state()
_maybe_force_smoke_goal()
_expire_slot_reservations() _expire_slot_reservations()
# Countdown and clock are derived from absolute ticks on both peers, so # Countdown and clock are derived from absolute ticks on both peers, so
# these run on the client too. # 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 # 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. # every 2.5 minutes has no way to keep a lobby together.
const LOBBY := "res://scenes/lobby.tscn" 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)}) ServerLog.error("server_boot_failed", {"port": port, "error": error_string(err)})
get_tree().quit(1) get_tree().quit(1)
return return
_install_match_loop()
ServerLog.info("server_started", { ServerLog.info("server_started", {
"port": port, "max_clients": max_clients, "log_level": ServerLog.level_name(), "port": port, "max_clients": max_clients, "log_level": ServerLog.level_name(),
"min_players": int(config.get_value("min-players")), "min_players": int(config.get_value("min-players")),
@@ -62,6 +63,20 @@ func _ready() -> void:
_last_physics_frame = Engine.get_physics_frames() _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: func _process(_delta: float) -> void:
NetworkManager.poll() NetworkManager.poll()
var current := Engine.get_physics_frames() 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("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("start-countdown", Kind.FLOAT, 5.0, "match", "Seconds to wait after min-players is met before starting"))
out.append(Spec.new("arena-rotation", Kind.STRING, "sequential", "match", "How the next arena is picked: sequential or random")) out.append(Spec.new("arena-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("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("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")) 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"])) errors.append("--min-players must be at least 1, got %d" % int(values["min-players"]))
if float(values["slot-reservation-seconds"]) < 0.0: if float(values["slot-reservation-seconds"]) < 0.0:
errors.append("--slot-reservation-seconds cannot be negative, got %s" % str(values["slot-reservation-seconds"])) 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"]) var level := String(values["log-level"])
if not level in ["debug", "info", "warn", "error"]: if not level in ["debug", "info", "warn", "error"]:
errors.append("--log-level must be one of debug, info, warn, error; got '%s'" % level) 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(["--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(["--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(["--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. # 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"]) 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)) 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")
+4
View File
@@ -0,0 +1,4 @@
.PHONY: verify-phase6
verify-phase6:
bash scripts/verify_phase6.sh
+93
View File
@@ -0,0 +1,93 @@
# Dedicated server
Phase 6 packages a self-hosted ENet server. It does not publish an image or
binary: build from this checkout and run the generated image locally or on a
VPS. Direct-IP ENet uses UDP only; the default port is `7777`.
## Local build and verification
Docker is the primary path. It builds the stripped `Linux Dedicated Server`
export, runs it in one container, joins two independent headless clients from
two other containers, forces one server-owned goal in each of two matches,
checks both clients observed both scores and arena rotation, then drains.
```bash
make verify-phase6
```
The command prints the temporary log directory even on failure and always
removes its Compose containers. It neither pushes an image nor uploads an
artifact. The same command is the only operation in the Phase 6 GitHub Actions
workflow. Its pinned Godot build image is about 2.4 GB, so leave several GB of
Docker disk space free for its layers and the exported project.
To build and run a server manually:
```bash
docker build --target server -t cosmic-clash-server .
docker run --rm -p 7777:7777/udp cosmic-clash-server \
--port=7777 --min-players=2 --start-countdown=5
```
All server output is structured stdout/stderr. Use Docker's logging driver for
rotation; for example, configure `json-file` with `max-size` and `max-file` on
the host. Do not add in-process log rotation.
## Configuration
Every flag is printed by `--help`; unknown flags fail startup. Command-line
values override a Godot config file's `[server]` values, which override
defaults. Mount one into the container when needed:
```ini
[server]
port=7777
max-clients=12
min-players=2
start-countdown=5
arena-rotation=sequential
log-level=info
```
```bash
docker run --rm -p 7777:7777/udp \
-v "$PWD/server.cfg:/etc/cosmic-clash/server.cfg:ro" \
cosmic-clash-server --config=/etc/cosmic-clash/server.cfg
```
`--max-matches=N` drains only after match `N` ends, then exits `0`; use it for
planned restarts under a process supervisor. `--smoke-force-goal-after=<seconds>`
is a documented local-verification switch; its default `-1` disables it, and it
must not be used for normal matches.
## Native systemd deployment
Copy the exported binary and assets to `/opt/cosmic-clash`, create the
`cosmicclash` service user, place configuration at
`/etc/cosmic-clash/server.cfg`, then install
`deploy/cosmic-clash-server.service` as
`/etc/systemd/system/cosmic-clash-server.service` and enable it:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now cosmic-clash-server
sudo journalctl -u cosmic-clash-server -f
```
Godot does not provide a GDScript SIGTERM hook. `systemctl stop`, Ctrl-C, or a
container stop terminates immediately and connected ENet clients will time out
after roughly five seconds. Prefer `--max-matches` for planned drains.
## Network and sizing
Open and forward **UDP 7777** (or the configured `--port`) in the host firewall
and any cloud security group. TCP is not used. The Phase 1 sizing estimate is
roughly 610 simultaneous match processes per modern core, 150250 MB RSS per
process, and about 630 kbit/s upstream for a full six-player match; use those
as a starting point and monitor actual CPU, RSS, and egress.
This build must not be exposed to strangers yet. Slot reclaim is still keyed
by display name, so a player who knows a disconnected player's name can claim
their reserved slot. Phase 7 Steam-auth identity is the required fix. Local,
LAN, and controlled VPS verification are in scope; the public-internet phase
gate remains blocked on that identity work.
+35
View File
@@ -0,0 +1,35 @@
services:
server:
platform: linux/amd64
build:
context: .
target: server
command:
- --port=7777
- --min-players=2
- --start-countdown=0
- --match-length=1
- --max-matches=2
- --slot-reservation-seconds=0
- --smoke-force-goal-after=0
- --log-level=debug
ports:
- "7777:7777/udp"
client-one:
platform: linux/amd64
build:
context: .
target: smoke-client
depends_on:
- server
command: ["--address=server", "--port=7777", "--name=ExportSmokeOne", "--expected-goals=2"]
client-two:
platform: linux/amd64
build:
context: .
target: smoke-client
depends_on:
- server
command: ["--address=server", "--port=7777", "--name=ExportSmokeTwo", "--expected-goals=2"]
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env sh
# Native and container launcher for the dedicated export. Its server boot scene
# is baked into the dedicated artifact during the Docker export stage.
set -eu
exec "$(dirname "$0")/CosmicClashServer.x86_64" --headless -- "$@"
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=Cosmic Clash dedicated server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=cosmicclash
WorkingDirectory=/opt/cosmic-clash
ExecStart=/opt/cosmic-clash/cosmic-clash-server --config=/etc/cosmic-clash/server.cfg
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root_dir"
logs_dir="$(mktemp -d "${TMPDIR:-/tmp}/cosmic-clash-phase6.XXXXXX")"
cleanup() {
docker compose -f compose.phase6-smoke.yml down --volumes --remove-orphans >"$logs_dir/compose-down.log" 2>&1 || true
echo "Phase 6 logs: $logs_dir"
}
trap cleanup EXIT
docker build --target exporter -t cosmic-clash-phase6-exporter .
docker run --rm cosmic-clash-phase6-exporter bash -lc 'godot --headless --path Game --import && godot --headless --path Game res://tests/test_runner.tscn'
docker compose -f compose.phase6-smoke.yml up --build -d
docker compose -f compose.phase6-smoke.yml wait client-one client-two server
docker compose -f compose.phase6-smoke.yml logs --no-color >"$logs_dir/compose.log"
cat "$logs_dir/compose.log"
if grep -E "(SCRIPT ERROR|ERROR:|EXPORT SMOKE FAIL)" "$logs_dir/compose.log"; then
echo "Phase 6 verification found engine or smoke errors" >&2
exit 1
fi
for name in ExportSmokeOne ExportSmokeTwo; do
grep -q "EXPORT SMOKE PASS: $name" "$logs_dir/compose.log"
done
test "$(grep -c "smoke_goal_forced" "$logs_dir/compose.log")" -eq 2
grep -q "server_draining" "$logs_dir/compose.log"
arena_lines="$(grep "match_starting" "$logs_dir/compose.log" | sed -n 's/.*arena=\([^ ]*\).*/\1/p')"
first_arena="$(printf '%s\n' "$arena_lines" | sed -n '1p')"
second_arena="$(printf '%s\n' "$arena_lines" | sed -n '2p')"
test -n "$first_arena"
test -n "$second_arena"
test "$first_arena" != "$second_arena"
echo "Phase 6 Docker verification passed"