From f2b72394de4cf6cc3e4c16eb2c883b8352b1f4e2 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:38:30 +0100 Subject: [PATCH] feat(server): complete phase 6 local verification --- .github/workflows/phase6.yml | 14 +++ Dockerfile | 29 ++++++ Game/project.godot | 6 +- Game/scripts/arena_registry.gd | 23 +++++ Game/scripts/networked_match.gd | 33 ++++++- Game/scripts/scene_paths.gd | 4 + Game/scripts/server_boot.gd | 15 +++ Game/scripts/server_config.gd | 3 + Game/scripts/server_match_loop.gd | 122 ++++++++++++++++++++++++ Game/scripts/server_match_loop.gd.uid | 1 + Game/tests/cases/test_arena_rotation.gd | 60 ++++++++++++ Game/tests/cases/test_server_config.gd | 1 + Game/tests/cases/test_server_log.gd.uid | 1 + Game/tests/export_server_smoke.gd | 97 +++++++++++++++++++ Game/tests/export_server_smoke.tscn | 8 ++ Makefile | 4 + SERVER.md | 93 ++++++++++++++++++ compose.phase6-smoke.yml | 35 +++++++ deploy/cosmic-clash-server | 6 ++ deploy/cosmic-clash-server.service | 17 ++++ scripts/verify_phase6.sh | 36 +++++++ 21 files changed, 604 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/phase6.yml create mode 100644 Dockerfile create mode 100644 Game/scripts/server_match_loop.gd create mode 100644 Game/scripts/server_match_loop.gd.uid create mode 100644 Game/tests/cases/test_arena_rotation.gd create mode 100644 Game/tests/cases/test_server_log.gd.uid create mode 100644 Game/tests/export_server_smoke.gd create mode 100644 Game/tests/export_server_smoke.tscn create mode 100644 Makefile create mode 100644 SERVER.md create mode 100644 compose.phase6-smoke.yml create mode 100644 deploy/cosmic-clash-server create mode 100644 deploy/cosmic-clash-server.service create mode 100755 scripts/verify_phase6.sh diff --git a/.github/workflows/phase6.yml b/.github/workflows/phase6.yml new file mode 100644 index 00000000..f88bb4ea --- /dev/null +++ b/.github/workflows/phase6.yml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..f4e56d52 --- /dev/null +++ b/Dockerfile @@ -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", "--"] diff --git a/Game/project.godot b/Game/project.godot index b18c0e66..d3b7c93c 100644 --- a/Game/project.godot +++ b/Game/project.godot @@ -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] diff --git a/Game/scripts/arena_registry.gd b/Game/scripts/arena_registry.gd index d6855227..d7d60766 100644 --- a/Game/scripts/arena_registry.gd +++ b/Game/scripts/arena_registry.gd @@ -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())] diff --git a/Game/scripts/networked_match.gd b/Game/scripts/networked_match.gd index a20fda60..4981e32a 100644 --- a/Game/scripts/networked_match.gd +++ b/Game/scripts/networked_match.gd @@ -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. diff --git a/Game/scripts/scene_paths.gd b/Game/scripts/scene_paths.gd index 9369b971..5d20cd88 100644 --- a/Game/scripts/scene_paths.gd +++ b/Game/scripts/scene_paths.gd @@ -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" diff --git a/Game/scripts/server_boot.gd b/Game/scripts/server_boot.gd index d08cdc78..e17f093a 100644 --- a/Game/scripts/server_boot.gd +++ b/Game/scripts/server_boot.gd @@ -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() diff --git a/Game/scripts/server_config.gd b/Game/scripts/server_config.gd index 685fa02c..99fb5a98 100644 --- a/Game/scripts/server_config.gd +++ b/Game/scripts/server_config.gd @@ -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) diff --git a/Game/scripts/server_match_loop.gd b/Game/scripts/server_match_loop.gd new file mode 100644 index 00000000..bd05ca92 --- /dev/null +++ b/Game/scripts/server_match_loop.gd @@ -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) diff --git a/Game/scripts/server_match_loop.gd.uid b/Game/scripts/server_match_loop.gd.uid new file mode 100644 index 00000000..b96b50fe --- /dev/null +++ b/Game/scripts/server_match_loop.gd.uid @@ -0,0 +1 @@ +uid://xnvwnqushvvt diff --git a/Game/tests/cases/test_arena_rotation.gd b/Game/tests/cases/test_arena_rotation.gd new file mode 100644 index 00000000..14bb556c --- /dev/null +++ b/Game/tests/cases/test_arena_rotation.gd @@ -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" + ) diff --git a/Game/tests/cases/test_server_config.gd b/Game/tests/cases/test_server_config.gd index eee45f23..9962661b 100644 --- a/Game/tests/cases/test_server_config.gd +++ b/Game/tests/cases/test_server_config.gd @@ -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)) diff --git a/Game/tests/cases/test_server_log.gd.uid b/Game/tests/cases/test_server_log.gd.uid new file mode 100644 index 00000000..87b158d1 --- /dev/null +++ b/Game/tests/cases/test_server_log.gd.uid @@ -0,0 +1 @@ +uid://cr0hqi7fwac2e diff --git a/Game/tests/export_server_smoke.gd b/Game/tests/export_server_smoke.gd new file mode 100644 index 00000000..3d579c54 --- /dev/null +++ b/Game/tests/export_server_smoke.gd @@ -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) diff --git a/Game/tests/export_server_smoke.tscn b/Game/tests/export_server_smoke.tscn new file mode 100644 index 00000000..6270d89d --- /dev/null +++ b/Game/tests/export_server_smoke.tscn @@ -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") diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..781a2f01 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: verify-phase6 + +verify-phase6: + bash scripts/verify_phase6.sh diff --git a/SERVER.md b/SERVER.md new file mode 100644 index 00000000..f52cc75b --- /dev/null +++ b/SERVER.md @@ -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=` +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 6–10 simultaneous match processes per modern core, 150–250 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. diff --git a/compose.phase6-smoke.yml b/compose.phase6-smoke.yml new file mode 100644 index 00000000..e17d79c8 --- /dev/null +++ b/compose.phase6-smoke.yml @@ -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"] diff --git a/deploy/cosmic-clash-server b/deploy/cosmic-clash-server new file mode 100644 index 00000000..bb0152a0 --- /dev/null +++ b/deploy/cosmic-clash-server @@ -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 -- "$@" diff --git a/deploy/cosmic-clash-server.service b/deploy/cosmic-clash-server.service new file mode 100644 index 00000000..0647c71d --- /dev/null +++ b/deploy/cosmic-clash-server.service @@ -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 diff --git a/scripts/verify_phase6.sh b/scripts/verify_phase6.sh new file mode 100755 index 00000000..27778f37 --- /dev/null +++ b/scripts/verify_phase6.sh @@ -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"