mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
fix(multiplayer): reconcile authoritative initial connections
This commit is contained in:
@@ -3,6 +3,7 @@ extends Node
|
||||
const NetCodec = preload("res://scripts/net_codec.gd")
|
||||
const ServerControlScript = preload("res://scripts/server_control.gd")
|
||||
const AgonesSDKScript = preload("res://scripts/agones_sdk.gd")
|
||||
const AssignmentState = preload("res://scripts/assignment_state.gd")
|
||||
|
||||
# Headless dedicated server entry point (task 1.6). Parses CLI args, hosts
|
||||
# via NetworkManager, logs structured lines, and watches for physics-tick
|
||||
@@ -29,8 +30,11 @@ var _last_physics_frame := 0
|
||||
var config: ServerConfig = null
|
||||
var _watchdog_armed := false # skip the first _process(): engine startup scheduling can batch several physics frames before the first idle frame runs, which isn't a real overrun
|
||||
var _control: ServerControl = null
|
||||
var _match_loop: ServerMatchLoop = null
|
||||
var _agones = null
|
||||
var _drain_requested := false
|
||||
var _connection_reports_inflight: Dictionary = {}
|
||||
var _connection_reports_complete: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -89,6 +93,7 @@ func _ready() -> void:
|
||||
config.values["min-players"] = required_min_players(true, roster_tokens.size(), int(config.get_value("min-players")))
|
||||
_control.name = "ServerControl"
|
||||
_control.drain_requested.connect(_on_drain_requested)
|
||||
_control.initial_connect_ready.connect(_on_initial_connect_ready)
|
||||
get_tree().root.add_child.call_deferred(_control)
|
||||
var control_err := _control.start(int(config.get_value("readiness-port")), OS.get_environment(String(config.get_value("drain-token-env"))))
|
||||
if control_err != OK:
|
||||
@@ -134,6 +139,7 @@ func _ready() -> void:
|
||||
# it started. Same constraint the smoke-test hooks document.
|
||||
func _install_match_loop() -> void:
|
||||
var loop := ServerMatchLoop.new()
|
||||
_match_loop = loop
|
||||
loop.name = "ServerMatchLoop"
|
||||
loop.min_players = int(config.get_value("min-players"))
|
||||
loop.start_countdown_seconds = float(config.get_value("start-countdown"))
|
||||
@@ -143,6 +149,10 @@ func _install_match_loop() -> void:
|
||||
loop.allocated_playlist = String(config.get_value("playlist"))
|
||||
loop.allocated_roster_size = MatchNet.assigned_player_slots().size() if loop.allocated_mode else 0
|
||||
loop.allocated_arena_path = String(config.get_value("arena-path"))
|
||||
# The backend's fair timeout starts only after durable assignment-ready.
|
||||
# When a control plane is present, the supervisor arms this loop through
|
||||
# the authenticated local control endpoint after that transition commits.
|
||||
loop.allocated_admission_armed = not loop.allocated_mode or OS.get_environment("COSMIC_CLASH_INITIAL_CONNECT_SIGNAL_REQUIRED") != "1"
|
||||
get_tree().root.add_child.call_deferred(loop)
|
||||
|
||||
|
||||
@@ -178,6 +188,8 @@ func _on_client_disconnected(peer_id: int) -> void:
|
||||
|
||||
func _on_player_joined(peer_id: int, player_name: String) -> void:
|
||||
ServerLog.info("player_joined", {"peer_id": peer_id, "name": player_name, "roster": MatchNet.roster.size()})
|
||||
if config != null and bool(config.get_value("allocated-mode")):
|
||||
_report_player_connected(MatchNet.player_identity(peer_id))
|
||||
|
||||
|
||||
func _on_player_left(peer_id: int) -> void:
|
||||
@@ -191,6 +203,60 @@ func _on_drain_requested() -> void:
|
||||
ServerLog.info("server_draining", {"reason": "control_request"})
|
||||
|
||||
|
||||
func _on_initial_connect_ready() -> void:
|
||||
if _match_loop != null and is_instance_valid(_match_loop):
|
||||
_match_loop.arm_allocated_admission()
|
||||
ServerLog.info("initial_connect_window_started", {"match_id": String(config.get_value("match-id"))})
|
||||
|
||||
|
||||
func _report_player_connected(player_id: String) -> void:
|
||||
var base_url := OS.get_environment("COSMIC_CLASH_CONTROL_PLANE_URL").strip_edges().trim_suffix("/")
|
||||
var workload_token := OS.get_environment("COSMIC_CLASH_WORKLOAD_TOKEN").strip_edges()
|
||||
var server_id := String(config.get_value("server-id"))
|
||||
var match_id := String(config.get_value("match-id"))
|
||||
if not valid_connection_report_configuration(base_url, workload_token, match_id, server_id, player_id) or _connection_reports_inflight.has(player_id) or _connection_reports_complete.has(player_id):
|
||||
return
|
||||
_connection_reports_inflight[player_id] = true
|
||||
var endpoint := "%s/v1/servers/%s/connect" % [base_url, server_id.uri_encode()]
|
||||
# A player can join many matches. Scope the durable key to this match so a
|
||||
# later valid report cannot conflict with an earlier match's stored digest.
|
||||
var idempotency_key := "server-connect-" + (match_id + "\n" + player_id).sha256_text()
|
||||
var payload := JSON.stringify({"player_id": player_id})
|
||||
for attempt in range(5):
|
||||
var request := HTTPRequest.new()
|
||||
request.timeout = 5.0
|
||||
add_child(request)
|
||||
var start_error := request.request(endpoint, [
|
||||
"Authorization: Bearer " + workload_token,
|
||||
"Content-Type: application/json",
|
||||
"Idempotency-Key: " + idempotency_key,
|
||||
], HTTPClient.METHOD_POST, payload)
|
||||
var response_code := 0
|
||||
if start_error == OK:
|
||||
var response: Array = await request.request_completed
|
||||
response_code = int(response[1])
|
||||
request.queue_free()
|
||||
if response_code == 204:
|
||||
_connection_reports_complete[player_id] = true
|
||||
_connection_reports_inflight.erase(player_id)
|
||||
ServerLog.debug("player_connection_recorded", {"player_id": player_id})
|
||||
return
|
||||
if response_code in [400, 401, 404, 409, 422]:
|
||||
break
|
||||
if attempt < 4 and is_inside_tree():
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
_connection_reports_inflight.erase(player_id)
|
||||
ServerLog.warn("player_connection_report_failed", {"player_id": player_id})
|
||||
|
||||
|
||||
static func valid_connection_report_configuration(base_url: String, workload_token: String, match_id: String, server_id: String, player_id: String) -> bool:
|
||||
if not (base_url.begins_with("http://") or base_url.begins_with("https://")) or base_url.contains("\n") or base_url.contains("\r") or base_url.contains("?") or base_url.contains("#"):
|
||||
return false
|
||||
if workload_token.is_empty() or workload_token.contains("\n") or workload_token.contains("\r"):
|
||||
return false
|
||||
return AssignmentState.is_valid_opaque_id(match_id) and AssignmentState.is_valid_opaque_id(server_id) and AssignmentState.is_valid_opaque_id(player_id)
|
||||
|
||||
|
||||
static func required_min_players(allocated: bool, roster_size: int, configured: int) -> int:
|
||||
if allocated and roster_size > 0:
|
||||
return roster_size
|
||||
|
||||
Reference in New Issue
Block a user