feat: connect clients from validated assignments

This commit is contained in:
Josh Creek
2026-09-01 08:37:00 +01:00
parent 82bb9baec6
commit d8ea2f4ac7
4 changed files with 58 additions and 3 deletions
+43
View File
@@ -9,6 +9,8 @@ signal session_expired()
signal session_changed(player_id: String)
signal websocket_event(event: Dictionary)
signal websocket_status_changed(status: String)
signal assignment_connection_started(assignment: AssignmentState)
signal assignment_connection_failed(detail: String)
const DEFAULT_BASE_URL := "http://127.0.0.1:8080"
const PERSIST_PATH := "user://matchmaking_state.cfg"
@@ -178,6 +180,47 @@ func fetch_assignment(match_id: String) -> Error:
return _start_request("assignment", HTTPClient.METHOD_GET, "/v1/assignments/" + match_id, {}, "")
# Starts the assigned game transport only after AssignmentState has validated
# the complete player-scoped manifest. The signed authorisation is passed to
# MatchNet's hello RPC, never appended to the endpoint URL or logged. Server
# admission remains authoritative; this method only owns the client-side
# readiness/transport boundary.
func connect_to_assignment() -> Error:
if assignment == null or not assignment.available or not _assignment_is_fresh(assignment):
var unavailable_detail := "Match assignment is unavailable or expired"
assignment_connection_failed.emit(unavailable_detail)
return ERR_UNAUTHORIZED
var endpoint := _split_assignment_endpoint(assignment.endpoint)
if endpoint.is_empty():
var invalid_detail := "Match assignment endpoint is invalid"
assignment_connection_failed.emit(invalid_detail)
return ERR_INVALID_PARAMETER
var transport := NetworkManager.TRANSPORT_ENET if assignment.transport == "enet" else NetworkManager.TRANSPORT_STEAM
MatchNet.join_authorisation = assignment.join_authorisation
state.mark_connecting()
var err := NetworkManager.join(String(endpoint["host"]), int(endpoint["port"]), transport)
if err != OK:
MatchNet.join_authorisation = ""
assignment_connection_failed.emit("Unable to connect to match server")
return err
assignment_connection_started.emit(assignment)
return OK
static func _assignment_is_fresh(value: AssignmentState) -> bool:
if value == null or value.expires_at.is_empty():
return false
var expiry := Time.get_unix_time_from_datetime_string(value.expires_at)
return expiry > Time.get_unix_time_from_system()
static func _split_assignment_endpoint(value: String) -> Dictionary:
if not AssignmentState._valid_endpoint(value):
return {}
var separator := value.rfind(":")
return {"host": value.substr(0, separator), "port": int(value.substr(separator + 1))}
func heartbeat(ticket_id: String, expected_revision: int) -> Error:
if ticket_id.is_empty() or expected_revision < 0:
return ERR_INVALID_PARAMETER
+6 -2
View File
@@ -48,6 +48,10 @@ class PlayerInfo:
var roster: Dictionary = {} # peer_id (int) -> PlayerInfo. Never contains peer 1 (the server; §1.1 decision 2 — dedicated servers are never a player).
var local_player_name := "Player"
# Set by the assignment connection path. Direct-IP/community-server joins keep
# this empty for backwards compatibility; allocated matches carry the opaque
# signed authorisation in hello rather than putting it in the endpoint URL.
var join_authorisation := ""
# Test hook (tests/match_net_smoke.gd): set false before connecting to
# suppress the automatic real hello, so a test can send a deliberately
@@ -65,7 +69,7 @@ func _ready() -> void:
func _on_connected_to_server() -> void:
roster.clear()
if _auto_hello:
_hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name)
_hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name, join_authorisation)
func _on_disconnected_from_server() -> void:
@@ -145,7 +149,7 @@ func _pick_balanced_team() -> int:
@rpc("any_peer", "call_remote", "reliable")
func _hello(protocol_version: int, tick_hz: int, player_name: String) -> void:
func _hello(protocol_version: int, tick_hz: int, player_name: String, supplied_join_authorisation: String = "") -> void:
if not multiplayer.is_server():
return
var peer_id := multiplayer.get_remote_sender_id()
@@ -50,6 +50,14 @@ func test_websocket_event_validation_requires_contract_specific_fields() -> void
assert_true(not ControlPlaneClient._valid_websocket_event(assignment), "incomplete assignment event is rejected")
func test_assignment_endpoint_split_never_accepts_url_or_bad_port() -> void:
var endpoint := ControlPlaneClient._split_assignment_endpoint("127.0.0.1:31001")
assert_eq(endpoint["host"], "127.0.0.1", "assignment host is separated from the port")
assert_eq(endpoint["port"], 31001, "assignment port is parsed as an integer")
for unsafe in ["127.0.0.1", "127.0.0.1:0", "127.0.0.1:65536", "127.0.0.1:31001/path", "https://127.0.0.1:31001"]:
assert_true(ControlPlaneClient._split_assignment_endpoint(unsafe).is_empty(), "unsafe endpoint is rejected: %s" % unsafe)
func test_ranked_profile_is_backend_display_data_and_rejects_unsafe_values() -> void:
var profile := RankedProfileState.new()
assert_true(profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": true, "season_id": "s1"}), "valid profile applies")