mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-12 02:23:44 +00:00
fix(multiplayer): submit allocated match results
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("/")
|
||||
Reference in New Issue
Block a user