mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
92 lines
3.4 KiB
GDScript
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("#"):
|
|
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("/")
|