mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
154 lines
5.8 KiB
GDScript
154 lines
5.8 KiB
GDScript
class_name ConnectionLeaseClient
|
|
extends Node
|
|
|
|
const AssignmentState = preload("res://scripts/assignment_state.gd")
|
|
|
|
signal reconciliation_failed(reason: String)
|
|
|
|
const CLAIMED := "claimed"
|
|
const UNAVAILABLE := "unavailable"
|
|
const REJECTED := "rejected"
|
|
|
|
var _base_url := ""
|
|
var _workload_token := ""
|
|
var _match_id := ""
|
|
var _server_id := ""
|
|
var _pending: Array[Dictionary] = []
|
|
var _processing := 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
|
|
|
|
|
|
# Admission awaits one bounded request only. If the control plane is down, the
|
|
# same process may continue using its local generation and this exact event is
|
|
# retained ahead of every later disconnect/reconnect for ordered reconciliation.
|
|
func claim(player_id: String, expected_generation: int) -> Dictionary:
|
|
if not AssignmentState.is_valid_opaque_id(player_id) or expected_generation < 0:
|
|
return {"status": REJECTED}
|
|
var event := _connect_event(player_id, expected_generation)
|
|
if _processing or not _pending.is_empty():
|
|
if expected_generation == 0:
|
|
return {"status": REJECTED}
|
|
_pending.append(event)
|
|
_start_processing()
|
|
return {"status": UNAVAILABLE, "generation": expected_generation + 1}
|
|
var response := await _send(event, true)
|
|
if String(response.get("status", "")) == UNAVAILABLE:
|
|
if expected_generation == 0:
|
|
return {"status": REJECTED}
|
|
_pending.append(event)
|
|
_start_processing()
|
|
return {"status": UNAVAILABLE, "generation": expected_generation + 1}
|
|
return response
|
|
|
|
|
|
func record_disconnect(player_id: String, generation: int) -> void:
|
|
if not AssignmentState.is_valid_opaque_id(player_id) or generation < 1:
|
|
return
|
|
_pending.append(_disconnect_event(player_id, generation))
|
|
_start_processing()
|
|
|
|
|
|
func _start_processing() -> void:
|
|
if _processing or _pending.is_empty() or not is_inside_tree():
|
|
return
|
|
_process_pending()
|
|
|
|
|
|
func _process_pending() -> void:
|
|
_processing = true
|
|
while not _pending.is_empty() and is_inside_tree():
|
|
var event := _pending[0]
|
|
var response := await _send(event)
|
|
var status := String(response.get("status", ""))
|
|
if status == CLAIMED:
|
|
_pending.pop_front()
|
|
continue
|
|
if status == REJECTED:
|
|
reconciliation_failed.emit("durable connection lease conflict")
|
|
_processing = false
|
|
return
|
|
await get_tree().create_timer(1.0).timeout
|
|
_processing = false
|
|
|
|
|
|
func _send(event: Dictionary, allow_recovery := false) -> Dictionary:
|
|
var request := HTTPRequest.new()
|
|
request.timeout = 1.0
|
|
add_child(request)
|
|
var operation := String(event["operation"])
|
|
var endpoint := "%s/v1/servers/%s/%s" % [_base_url, _server_id.uri_encode(), operation]
|
|
var start_error := request.request(endpoint, [
|
|
"Authorization: Bearer " + _workload_token,
|
|
"Content-Type: application/json",
|
|
"Idempotency-Key: " + String(event["key"]),
|
|
], HTTPClient.METHOD_POST, JSON.stringify(event["payload"]))
|
|
if start_error != OK:
|
|
request.queue_free()
|
|
return {"status": UNAVAILABLE}
|
|
var raw: Array = await request.request_completed
|
|
request.queue_free()
|
|
return classify_response(operation, int(event["generation"]), int(raw[0]), int(raw[1]), raw[3], allow_recovery)
|
|
|
|
|
|
func _connect_event(player_id: String, expected_generation: int) -> Dictionary:
|
|
return {
|
|
"operation": "connect",
|
|
"generation": expected_generation,
|
|
"key": event_key(_match_id, player_id, "connect", expected_generation),
|
|
"payload": {"player_id": player_id, "expected_generation": expected_generation},
|
|
}
|
|
|
|
|
|
func _disconnect_event(player_id: String, generation: int) -> Dictionary:
|
|
return {
|
|
"operation": "disconnect",
|
|
"generation": generation,
|
|
"key": event_key(_match_id, player_id, "disconnect", generation),
|
|
"payload": {"player_id": player_id, "generation": generation},
|
|
}
|
|
|
|
|
|
static func classify_response(operation: String, generation: int, request_result: int, response_code: int, body: PackedByteArray, allow_recovery := false) -> Dictionary:
|
|
if request_result != HTTPRequest.RESULT_SUCCESS or response_code == 0 or response_code == 429 or response_code >= 500:
|
|
return {"status": UNAVAILABLE}
|
|
if operation == "disconnect" and response_code == 204:
|
|
return {"status": CLAIMED, "generation": generation}
|
|
if operation == "connect" and response_code == 200:
|
|
var decoded = JSON.parse_string(body.get_string_from_utf8())
|
|
if decoded is Dictionary and _valid_generation(decoded.get("generation")):
|
|
var claimed_generation := int(decoded["generation"])
|
|
if claimed_generation == generation + 1 or (allow_recovery and generation == 0 and claimed_generation > 1):
|
|
return {"status": CLAIMED, "generation": claimed_generation}
|
|
return {"status": REJECTED}
|
|
|
|
|
|
static func _valid_generation(value: Variant) -> bool:
|
|
if value is int:
|
|
return int(value) >= 1
|
|
if value is float:
|
|
return is_finite(float(value)) and float(value) >= 1.0 and float(value) == floor(float(value)) and float(value) <= 9007199254740991.0
|
|
return false
|
|
|
|
|
|
static func event_key(match_id: String, player_id: String, operation: String, generation: int) -> String:
|
|
return "server-lease-" + (match_id + "\n" + player_id + "\n" + operation + "\n" + str(generation)).sha256_text()
|
|
|
|
|
|
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 AssignmentState.is_valid_opaque_id(match_id) and AssignmentState.is_valid_opaque_id(server_id)
|