mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(server): complete phase 6 local verification
This commit is contained in:
@@ -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
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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())]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://xnvwnqushvvt
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cr0hqi7fwac2e
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.PHONY: verify-phase6
|
||||||
|
|
||||||
|
verify-phase6:
|
||||||
|
bash scripts/verify_phase6.sh
|
||||||
@@ -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 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.
|
||||||
@@ -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"]
|
||||||
@@ -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 -- "$@"
|
||||||
@@ -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
|
||||||
Executable
+36
@@ -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"
|
||||||
Reference in New Issue
Block a user