Files
CosmicClash/Game/scripts/server_result_client.gd
T
2026-09-03 21:31:09 +01:00

92 lines
3.4 KiB
GDScript

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 not integrity_state in ["CERTIFIED", "REVIEW"]:
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("#") 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("/")