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("#"): 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("/")