fix(multiplayer): submit allocated match results

This commit is contained in:
Josh Creek
2026-09-03 21:24:07 +01:00
parent 759dbe2b65
commit 8507472635
7 changed files with 169 additions and 5 deletions
+15
View File
@@ -19,6 +19,8 @@ signal player_state_changed(peer_id: int, team: int, ready: bool)
signal rejected(reason: String) # client-side only: the server refused our hello
signal welcomed() # client-side only: our hello was accepted
signal server_shutdown(reason: String) # client-side notification before planned close
signal result_submission_accepted
signal result_submission_retrying(http_code: int)
const TEAM_COUNT := 2
const RECONNECT_GRACE_SECONDS := 60.0
@@ -68,6 +70,7 @@ var _join_authorisation_context: Dictionary = {}
var _join_signing_key := PackedByteArray()
var _connection_lease_claim := Callable()
var _connection_lease_disconnect := Callable()
var _result_submit := Callable()
# Test hook (tests/match_net_smoke.gd): set false before connecting to
# suppress the automatic real hello, so a test can send a deliberately
@@ -109,6 +112,7 @@ func _on_shutting_down() -> void:
_join_signing_key = PackedByteArray()
_connection_lease_claim = Callable()
_connection_lease_disconnect = Callable()
_result_submit = Callable()
require_join_authorisation = false
admissions_open = true
@@ -443,6 +447,17 @@ func configure_connection_lease_callbacks(claim: Callable, disconnect: Callable)
_connection_lease_disconnect = disconnect
func configure_result_submission(callback: Callable) -> void:
_result_submit = callback
func submit_authoritative_result(score: Dictionary) -> bool:
if not _result_submit.is_valid() or not score.has(0) or not score.has(1):
return false
_result_submit.call(int(score[0]), int(score[1]))
return true
func _claim_join_authorisation(token: String, peer_id: int) -> int:
var expected_generation := _available_join_generation(token)
if expected_generation < 0:
+21
View File
@@ -322,6 +322,7 @@ var _last_emitted_countdown := -1
var _in_overtime := false
var _match_over := false
var _planned_server_shutdown := false
var _awaiting_result_submission := 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
@@ -362,6 +363,10 @@ func _ready() -> void:
_replay_log = null
else:
print("NetworkedMatch: recording replay log to %s" % replay_path)
# Result acknowledgement is relevant only to the authority. Clients move
# to their lobby on the replicated RESULTS -> LOBBY transition.
MatchNet.result_submission_accepted.connect(_on_result_submission_accepted)
MatchNet.result_submission_retrying.connect(_on_result_submission_retrying)
_start_server()
else:
for arg: String in OS.get_cmdline_user_args():
@@ -1055,6 +1060,8 @@ func _enter_results(winning_team: int) -> void:
_clock_running = false
_set_bodies_frozen(true)
match_ended.emit(winning_team, score.duplicate())
if multiplayer.is_server() and MatchNet.submit_authoritative_result(score):
_awaiting_result_submission = true
ServerLog.info("match_ended", {"score_0": score.get(0, 0), "score_1": score.get(1, 0), "overtime": _in_overtime})
_set_match_state(MatchState.State.RESULTS)
@@ -1107,6 +1114,8 @@ func _update_match_state() -> void:
_set_match_state(MatchState.State.WARMUP)
_begin_kickoff()
MatchState.State.RESULTS:
if _awaiting_result_submission:
return
# §6.2 step 10: clients return to the LOBBY, never the main menu —
# a community server that empties every 2.5 minutes is dead on
# arrival. The state change is what moves both sides; the server
@@ -1115,6 +1124,18 @@ func _update_match_state() -> void:
get_tree().change_scene_to_file.call_deferred(ScenePaths.LOBBY)
func _on_result_submission_accepted() -> void:
if not multiplayer.is_server() or not _awaiting_result_submission:
return
_awaiting_result_submission = false
_state_deadline_tick = Engine.get_physics_frames()
func _on_result_submission_retrying(http_code: int) -> void:
if multiplayer.is_server() and _awaiting_result_submission:
ServerLog.warn("result_submission_retrying", {"http_code": http_code})
func _on_state_change_received(state: int, at_tick: int) -> void:
# Client path. MatchSim already rejected an unknown state value, and the
# server is the only peer allowed to send this (rpc "authority").
+12
View File
@@ -5,6 +5,7 @@ const ServerControlScript = preload("res://scripts/server_control.gd")
const AgonesSDKScript = preload("res://scripts/agones_sdk.gd")
const AssignmentState = preload("res://scripts/assignment_state.gd")
const ConnectionLeaseClientScript = preload("res://scripts/connection_lease_client.gd")
const ServerResultClientScript = preload("res://scripts/server_result_client.gd")
# Headless dedicated server entry point (task 1.6). Parses CLI args, hosts
# via NetworkManager, logs structured lines, and watches for physics-tick
@@ -34,6 +35,7 @@ var _control: ServerControl = null
var _match_loop: ServerMatchLoop = null
var _agones = null
var _connection_leases = null
var _result_client = null
var _drain_requested := false
@@ -123,6 +125,16 @@ func _ready() -> void:
printerr("cosmic-clash-server: refusing allocated startup without connection-lease configuration")
get_tree().quit(1)
return
_result_client = ServerResultClientScript.new()
_result_client.name = "ServerResults"
if not _result_client.configure(lease_url, lease_token, String(config.get_value("match-id")), String(config.get_value("server-id"))):
printerr("cosmic-clash-server: refusing allocated startup without result-submission configuration")
get_tree().quit(1)
return
_result_client.accepted.connect(func(): MatchNet.result_submission_accepted.emit())
_result_client.retrying.connect(func(http_code): MatchNet.result_submission_retrying.emit(http_code))
get_tree().root.add_child.call_deferred(_result_client)
MatchNet.configure_result_submission(_result_client.submit)
NetworkManager.client_connected.connect(_on_client_connected)
NetworkManager.client_disconnected.connect(_on_client_disconnected)
+91
View File
@@ -0,0 +1,91 @@
class_name ServerResultClient
extends Node
# The allocated server is the sole authority able to finish a match. Keep the
# match in RESULTS until the control plane has durably acknowledged this exact,
# idempotent payload: exiting first would strand the match in LIVE forever.
signal accepted
signal retrying(http_code: int)
const RETRY_SECONDS := 1.0
var _base_url := ""
var _workload_token := ""
var _match_id := ""
var _server_id := ""
var _submitting := false
func configure(base_url: String, workload_token: String, match_id: String, server_id: String) -> bool:
base_url = base_url.strip_edges().trim_suffix("/")
workload_token = workload_token.strip_edges()
if not valid_configuration(base_url, workload_token, match_id, server_id):
return false
_base_url = base_url
_workload_token = workload_token
_match_id = match_id
_server_id = server_id
return true
func submit(team_0: int, team_1: int, integrity_state := "CERTIFIED") -> void:
if _submitting or team_0 < 0 or team_1 < 0 or integrity_state != "CERTIFIED":
return
_submitting = true
var nonce := result_nonce(_match_id, _server_id, team_0, team_1, integrity_state)
var key := "server-result-" + nonce
var payload := {
"match_id": _match_id,
"result_nonce": nonce,
"score": {"team_0": team_0, "team_1": team_1},
"integrity_state": integrity_state,
}
while is_inside_tree():
var response := await _send(payload, key)
if response_is_accepted(int(response.get("code", 0))):
_submitting = false
accepted.emit()
return
retrying.emit(int(response.get("code", 0)))
await get_tree().create_timer(RETRY_SECONDS).timeout
_submitting = false
func _send(payload: Dictionary, key: String) -> Dictionary:
var request := HTTPRequest.new()
request.timeout = 5.0
add_child(request)
var err := request.request("%s/v1/servers/%s/result" % [_base_url, _server_id.uri_encode()], [
"Authorization: Bearer " + _workload_token,
"Content-Type: application/json",
"Idempotency-Key: " + key,
], HTTPClient.METHOD_POST, JSON.stringify(payload))
if err != OK:
request.queue_free()
return {"code": 0}
var raw: Array = await request.request_completed
request.queue_free()
if int(raw[0]) != HTTPRequest.RESULT_SUCCESS:
return {"code": 0}
return {"code": int(raw[1])}
static func result_nonce(match_id: String, server_id: String, team_0: int, team_1: int, integrity_state: String) -> String:
# Result score is immutable once NetworkedMatch enters RESULTS. A deterministic
# nonce makes retries after a lost response provably the same submission.
return "result-" + (match_id + "\n" + server_id + "\n" + str(team_0) + "\n" + str(team_1) + "\n" + integrity_state).sha256_text()
static func response_is_accepted(http_code: int) -> bool:
# The documented endpoint acknowledges only after its serializable result
# transaction commits. Do not treat a generic 2xx as proof of completion.
return http_code == 202
static func valid_configuration(base_url: String, workload_token: String, match_id: String, server_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 match_id.length() >= 8 and server_id.length() >= 8 and not match_id.contains("/") and not server_id.contains("/")