extends Node # Autoload (project.godot [autoload] MatchNet). Handshake + roster layer on # top of NetworkManager's raw transport (§2.5, §1.3 of multiplayer-next.md). # hello/welcome, strict protocol_version and physics_ticks_per_second # gating, player_joined/player_left, and — since lobby.tscn (task 1.5) needs # somewhere durable to keep it across the lobby→match scene transition — # each player's team and ready state. Slot assignment (fixed spawn index # within a team) is NOT here; that's match spawn's job in Phase 2, derived # from this roster's team field at spawn time, not stored redundantly here. const NetCodec = preload("res://scripts/net_codec.gd") const SimConstants = preload("res://scripts/sim_constants.gd") const AssignmentState = preload("res://scripts/assignment_state.gd") signal player_joined(peer_id: int, player_name: String) signal player_left(peer_id: int) signal player_state_changed(peer_id: int, team: int, ready: bool) signal rejected(reason: String) # client-side only: the server refused our hello signal welcomed() # client-side only: our hello was accepted signal server_shutdown(reason: String) # client-side notification before planned close signal result_submission_accepted signal result_submission_retrying(http_code: int) const TEAM_COUNT := 2 const RECONNECT_GRACE_SECONDS := 60.0 # player_name is the one client-supplied value in _hello that gets broadcast # verbatim to every other peer (protocol_version/tick_hz are checked, never # relayed). MAX_INPUT_LENGTH is a reject threshold, checked before touching # the string at all — a legitimate client only ever sends local_player_name, # which the UI already keeps short, so anything past this is a bug or an # attacker, not a real name to truncate politely. Adversarial review found # an unbounded name relayed to every peer head-of-line-blocks the reliable # control channel hard enough that a concurrently-joining client's own # _welcome never arrived — this is what closes that. const MAX_INPUT_LENGTH := 256 const MAX_PLAYER_NAME_LENGTH := 24 class PlayerInfo: var peer_id: int var player_name: String var player_identity: String var team: int = 0 var spawn_index: int = -1 var ready: bool = false func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false, p_player_identity: String = "") -> void: peer_id = p_peer_id player_name = p_player_name player_identity = p_player_identity team = p_team ready = p_ready 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" var last_server_shutdown_reason := "" # 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 := "" var require_join_authorisation := false var admissions_open := true var _allowed_join_authorisations: Dictionary = {} var _active_join_peers: Dictionary = {} # opaque authorisation -> peer_id var _join_history: Dictionary = {} # token -> {generation, lost_at} var _join_authorisation_context: Dictionary = {} var _join_signing_key := PackedByteArray() var _connection_lease_claim := Callable() var _connection_lease_disconnect := Callable() var _result_submit := Callable() # Test hook (tests/match_net_smoke.gd): set false before connecting to # suppress the automatic real hello, so a test can send a deliberately # mismatched one instead to exercise the rejection path. var _auto_hello := true func _ready() -> void: NetworkManager.client_disconnected.connect(_on_peer_disconnected) NetworkManager.connected_to_server.connect(_on_connected_to_server) NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server) NetworkManager.shutting_down.connect(_on_shutting_down) func _on_connected_to_server() -> void: roster.clear() last_server_shutdown_reason = "" if _auto_hello: _hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name, join_authorisation) func _on_disconnected_from_server() -> void: roster.clear() # Covers the case _on_disconnected_from_server doesn't: a HOST calling # NetworkManager.shutdown() itself (Leave, or hosting again after already # hosting) never fires disconnected_from_server — that signal only fires # from an incoming multiplayer.server_disconnected event, which a server # never receives about itself. Without this, roster (and every peer's team/ # ready state in it) would persist forever across a host/re-host cycle in # the same process. func _on_shutting_down() -> void: roster.clear() _allowed_join_authorisations.clear() _active_join_peers.clear() _join_history.clear() _join_authorisation_context.clear() _join_signing_key = PackedByteArray() _connection_lease_claim = Callable() _connection_lease_disconnect = Callable() _result_submit = Callable() require_join_authorisation = false admissions_open = true func configure_join_authorisations(tokens: Array, context: Dictionary, signing_key: PackedByteArray = PackedByteArray()) -> bool: var allowed := {} for token in tokens: if not token is String or String(token).is_empty(): return false allowed[String(token)] = true if allowed.is_empty() or not context.has("match_id") or not context["match_id"] is String or String(context["match_id"]).is_empty() or not context.has("server_id") or not context["server_id"] is String or String(context["server_id"]).is_empty() or not context.has("protocol_version") or not _valid_integer_claim(context["protocol_version"]) or int(context["protocol_version"]) < 1: return false _allowed_join_authorisations = allowed _join_authorisation_context = context.duplicate(true) _join_signing_key = signing_key.duplicate() require_join_authorisation = true return true func assigned_player_slots() -> Array: var result: Array = [] var seen_identities := {} var seen_slots := {} for token in _allowed_join_authorisations.keys(): var claims := _join_claims(String(token)) if claims.is_empty(): return [] var identity := str(claims.get("PlayerID", "")) var team := int(claims.get("Team", -1)) var slot := int(claims.get("Slot", -1)) if identity.is_empty() or team < 0 or team >= TEAM_COUNT or slot < 0 or slot > 5 or slot / 3 != team or seen_identities.has(identity) or seen_slots.has(slot): return [] seen_identities[identity] = true seen_slots[slot] = true result.append({ "player_identity": identity, "team": team, "slot": slot, }) result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a["slot"]) < int(b["slot"])) return result func player_identity(peer_id: int) -> String: if not roster.has(peer_id): return "" return String((roster[peer_id] as PlayerInfo).player_identity) static func reservation_identity_matches(slot_identity: String, incoming_identity: String, slot_name: String, incoming_name: String) -> bool: # Authenticated allocations must never fall back to a client-chosen display # name. The name fallback exists only for direct, unauthenticated servers. if not slot_identity.is_empty() or not incoming_identity.is_empty(): return not slot_identity.is_empty() and slot_identity == incoming_identity return slot_name == incoming_name # Server only: a raw ENet disconnect (crash, timeout) that never sent a # proper hello just needs its (possibly absent) roster entry cleaned up. # The normal leave path also goes through here after the server erases it, # guarded by roster.erase()'s own has-check below. func _on_peer_disconnected(peer_id: int) -> void: if not multiplayer.is_server(): return _cleanup_disconnected_peer(peer_id) func _cleanup_disconnected_peer(peer_id: int) -> void: NetworkManager.invalidate_peer(peer_id) _remove_player(peer_id) func _remove_player(peer_id: int) -> void: for token in _active_join_peers.keys(): if int(_active_join_peers[token]) == peer_id: _active_join_peers.erase(token) var history: Dictionary = _join_history.get(token, {}) history["lost_at"] = Time.get_unix_time_from_system() _join_history[token] = history if _connection_lease_disconnect.is_valid(): _connection_lease_disconnect.call(_join_identity(token), int(history.get("generation", 0))) break if not roster.has(peer_id): return roster.erase(peer_id) player_left.emit(peer_id) # rpc() broadcasts to every peer in multiplayer.get_peers() — including, # transiently, the very peer that just disconnected: this fires from # NetworkManager's client_disconnected signal, and empirically that # peer's own ENetConnection can still be momentarily present in the # broadcast's target set with its channels already torn down, which # logs "Unable to send packet on channel 0, max channels: 0" on every # single disconnect (found by a second adversarial review — harmless to # the game, since the departing peer obviously doesn't need to hear # about its own departure, but it meant "clean stderr" wasn't actually # clean for any test in this project). # # A first attempt filtered the broadcast down to rpc_id() calls that # explicitly skip `peer_id`. That's necessary but not sufficient: when # two peers disconnect within the same poll() batch (both bots quitting # at the end of a CI run land within the same tick), get_peers() here # can still list the SECOND peer as connected while its own disconnect # event just hasn't been dispatched yet in this same batch — sending to # it hits the identical error, one hop later. Defer the whole # notification to the next idle frame instead of sending synchronously # from inside signal-handling: by then poll() has fully returned, every # disconnect event in this batch has been dispatched, and get_peers() # reflects the settled, genuinely-still-connected set. if is_inside_tree(): call_deferred("_broadcast_player_left", peer_id) func _broadcast_player_left(peer_id: int) -> void: for other_peer_id in multiplayer.get_peers(): if other_peer_id != peer_id: _player_left.rpc_id(other_peer_id, peer_id) func broadcast_server_shutdown(reason: String) -> void: if not multiplayer.is_server(): return var safe_reason := _sanitize_shutdown_reason(reason) for peer_id in multiplayer.get_peers(): _server_shutdown.rpc_id(peer_id, safe_reason) static func _sanitize_shutdown_reason(raw: String) -> String: var clean := "" for c in raw: var code := c.unicode_at(0) if code >= 0x20 and code != 0x7F: clean += c clean = clean.strip_edges() if clean.length() > 96: clean = clean.substr(0, 96) return clean if not clean.is_empty() else "server_shutdown" static func admission_rejection(is_open: bool) -> String: return "" if is_open else "server is draining" # Balances a new joiner onto whichever team currently has fewer players # (ties go to team 0). Server only. func _pick_balanced_team() -> int: var counts := [] counts.resize(TEAM_COUNT) counts.fill(0) for info: PlayerInfo in roster.values(): counts[info.team] += 1 var best_team := 0 for team in range(TEAM_COUNT): if counts[team] < counts[best_team]: best_team = team return best_team @rpc("any_peer", "call_remote", "reliable") 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() if roster.has(peer_id): return # duplicate hello from an already-accepted peer; ignore var admission_error := admission_rejection(admissions_open) if not admission_error.is_empty(): await _reject(peer_id, admission_error) return if protocol_version != NetCodec.PROTOCOL_VERSION: await _reject(peer_id, "protocol version mismatch: server=%d client=%d" % [NetCodec.PROTOCOL_VERSION, protocol_version]) return if tick_hz != SimConstants.TICK_HZ: await _reject(peer_id, "physics tick rate mismatch: server=%d client=%d" % [SimConstants.TICK_HZ, tick_hz]) return if require_join_authorisation and not _valid_join_authorisation(supplied_join_authorisation): await _reject(peer_id, "join authorisation rejected") return if require_join_authorisation and _active_join_peers.has(supplied_join_authorisation): await _reject(peer_id, "join authorisation already in use") return var join_generation := 1 if require_join_authorisation: join_generation = await _claim_join_authorisation(supplied_join_authorisation, peer_id) if join_generation < 0: await _reject(peer_id, "join authorisation lease rejected") return if player_name.length() > MAX_INPUT_LENGTH: await _reject(peer_id, "player name too long") return var clean_name := _sanitize_player_name(player_name) var identity := _join_identity(supplied_join_authorisation) if require_join_authorisation else clean_name if identity.is_empty(): await _reject(peer_id, "join authorisation rejected") return # Tell the new peer about everyone already here before anyone is told # about them, so no client ever observes an unknown peer_id in a # player_joined it didn't get a prior player_joined for. for existing_id: int in roster.keys(): var existing: PlayerInfo = roster[existing_id] _player_joined.rpc_id(peer_id, existing_id, existing.player_name, existing.team, existing.ready) var team := _pick_balanced_team() var spawn_index := -1 if require_join_authorisation: var claims := _join_claims(supplied_join_authorisation) var assigned_slot := int(claims.get("Slot", -1)) var assigned_team := int(claims.get("Team", -1)) if assigned_slot < 0 or assigned_slot > 5 or assigned_team < 0 or assigned_team >= TEAM_COUNT or assigned_slot / 3 != assigned_team: await _reject(peer_id, "join authorisation rejected") return team = assigned_team spawn_index = assigned_slot % 3 var info := PlayerInfo.new(peer_id, clean_name, team, false, identity) info.spawn_index = spawn_index roster[peer_id] = info if require_join_authorisation: # _reserve_join_authorisation already owns the active peer reservation; # keeping the generation in the history makes fencing auditable without # exposing it to the client. _join_history[supplied_join_authorisation]["generation"] = join_generation player_joined.emit(peer_id, clean_name) # local: the broadcast below is call_remote, never loops back to the server itself _welcome.rpc_id(peer_id) _player_joined.rpc(peer_id, clean_name, team, false) # broadcast, includes the new peer itself func _valid_join_authorisation(token: String) -> bool: if token.is_empty() or not _allowed_join_authorisations.has(token): return false var standard_token := token.replace("-", "+").replace("_", "/") while standard_token.length() % 4 != 0: standard_token += "=" var decoded := Marshalls.base64_to_raw(standard_token) if decoded.is_empty(): return false var envelope = JSON.parse_string(decoded.get_string_from_utf8()) if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope.has("Signature") or str(envelope["Signature"]).is_empty(): return false var claims = envelope["Authorisation"] if not claims is Dictionary: return false for string_claim in ["MatchID", "ServerID", "PlayerID", "SteamID", "Protocol", "ExpiresAt"]: if not claims.has(string_claim) or not claims[string_claim] is String or String(claims[string_claim]).is_empty(): return false for integer_claim in ["Slot", "Team", "Generation"]: if not claims.has(integer_claim) or not _valid_integer_claim(claims[integer_claim]): return false if not envelope["Signature"] is String or String(envelope["Signature"]).is_empty(): return false var claimed_team := int(claims.get("Team", -1)) var claimed_slot := int(claims.get("Slot", -1)) if claimed_team < 0 or claimed_team >= TEAM_COUNT or claimed_slot < 0 or claimed_slot > 5 or claimed_slot / 3 != claimed_team: return false var protocol := str(claims.get("Protocol", "")) var expires_at := str(claims.get("ExpiresAt", "")) if not AssignmentState.is_valid_expiry_timestamp(expires_at): return false var expiry := Time.get_unix_time_from_datetime_string(expires_at) if not _join_signing_key.is_empty(): var signature_token := str(envelope["Signature"]) var signature := Marshalls.base64_to_raw(signature_token) if signature.size() != 32: return false var canonical := PackedByteArray() var fields := [ str(claims.get("MatchID", "")), str(claims.get("ServerID", "")), str(claims.get("PlayerID", "")), str(claims.get("SteamID", "")), str(int(claims.get("Slot", -1))), str(int(claims.get("Team", -1))), protocol, str(int(claims.get("Generation", 0))), expires_at, ] for index in fields.size(): canonical.append_array(String(fields[index]).to_utf8_buffer()) if index < fields.size() - 1: canonical.append(0) var hmac := HMACContext.new() hmac.start(HashingContext.HASH_SHA256, _join_signing_key) hmac.update(canonical) if hmac.finish() != signature: return false return str(claims.get("MatchID", "")) == str(_join_authorisation_context.get("match_id", "")) \ and str(claims.get("ServerID", "")) == str(_join_authorisation_context.get("server_id", "")) \ and protocol == str(_join_authorisation_context.get("protocol", "")) \ and int(claims.get("Slot", -1)) >= 0 and int(claims.get("Slot", -1)) <= 5 \ and expiry > Time.get_unix_time_from_system() static func _valid_integer_claim(value: Variant) -> bool: if value is int: return int(value) >= 0 if value is float: return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value)) return false func _join_identity(token: String) -> String: if token.is_empty(): return "" var standard_token := token.replace("-", "+").replace("_", "/") while standard_token.length() % 4 != 0: standard_token += "=" var decoded := Marshalls.base64_to_raw(standard_token) if decoded.is_empty(): return "" var envelope = JSON.parse_string(decoded.get_string_from_utf8()) if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope["Authorisation"] is Dictionary: return "" return str(envelope["Authorisation"].get("PlayerID", "")) func _join_claims(token: String) -> Dictionary: if token.is_empty(): return {} var standard_token := token.replace("-", "+").replace("_", "/") while standard_token.length() % 4 != 0: standard_token += "=" var decoded := Marshalls.base64_to_raw(standard_token) if decoded.is_empty(): return {} var envelope = JSON.parse_string(decoded.get_string_from_utf8()) if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope["Authorisation"] is Dictionary: return {} return envelope["Authorisation"] func is_join_authorisation_active(token: String) -> bool: return not token.is_empty() and _active_join_peers.has(token) func configure_connection_lease_callbacks(claim: Callable, disconnect: Callable) -> void: _connection_lease_claim = claim _connection_lease_disconnect = disconnect func configure_result_submission(callback: Callable) -> void: _result_submit = callback func submit_authoritative_result(score: Dictionary, integrity_state := "CERTIFIED") -> bool: if not _result_submit.is_valid() or not score.has(0) or not score.has(1): return false _result_submit.call(int(score[0]), int(score[1]), integrity_state) return true func _claim_join_authorisation(token: String, peer_id: int) -> int: var expected_generation := _available_join_generation(token) if expected_generation < 0: return -1 var generation := expected_generation + 1 if _connection_lease_claim.is_valid(): var response = await _connection_lease_claim.call(_join_identity(token), expected_generation) generation = lease_claim_generation(response, expected_generation) if generation < 0: return -1 # The await above deliberately allows one bounded control-plane request. # Re-evaluate every local fact that can change during that suspension before # publishing the reservation. If a durable claim succeeded, close it again. # A concurrent same-token hello can receive the same idempotent claim; its # loser must not close the generation now owned by the local winner. if _active_join_peers.has(token): return -1 if not admissions_open or not _valid_join_authorisation(token) or peer_id not in multiplayer.get_peers(): if _connection_lease_disconnect.is_valid(): _connection_lease_disconnect.call(_join_identity(token), generation) return -1 _join_history[token] = {"generation": generation, "lost_at": 0.0} _active_join_peers[token] = peer_id return generation func _available_join_generation(token: String) -> int: if token.is_empty() or _active_join_peers.has(token): return -1 var now := Time.get_unix_time_from_system() var history: Dictionary = _join_history.get(token, {}) var lost_at := float(history.get("lost_at", 0.0)) if lost_at > 0.0 and (now < lost_at or now - lost_at > RECONNECT_GRACE_SECONDS): return -1 return int(history.get("generation", 0)) static func lease_claim_generation(response, expected_generation: int) -> int: if not response is Dictionary or expected_generation < 0: return -1 var status := String(response.get("status", "")) if status not in ["claimed", "unavailable"] or not response.get("generation") is int: return -1 var generation := int(response["generation"]) if status == "unavailable": return generation if expected_generation > 0 and generation == expected_generation + 1 else -1 # A durable backend may return a later generation only to a fresh process # recovering an already-disconnected lease. Locally known generations never # skip, and outage fallback never invents a jump. return generation if generation == expected_generation + 1 or (expected_generation == 0 and generation > 1) else -1 func _reserve_join_authorisation(token: String, peer_id: int) -> int: var expected_generation := _available_join_generation(token) if expected_generation < 0: return -1 var generation := expected_generation + 1 _join_history[token] = {"generation": generation, "lost_at": 0.0} _active_join_peers[token] = peer_id return generation # Strips control/formatting characters (so a name can't corrupt a log line # or blow out UI layout with e.g. embedded newlines) and clamps to display # length. Input is already bounded to MAX_INPUT_LENGTH by the caller before # this runs, so this never iterates an attacker-sized string. static: pure # function of its argument, doesn't touch roster/multiplayer — also lets # tests/cases/test_match_net.gd call it with no Node instantiation. static func _sanitize_player_name(raw: String) -> String: var clean := "" for c in raw: var code := c.unicode_at(0) if code >= 0x20 and code != 0x7F: clean += c clean = clean.strip_edges() if clean.length() > MAX_PLAYER_NAME_LENGTH: clean = clean.substr(0, MAX_PLAYER_NAME_LENGTH) if clean.is_empty(): clean = "Player" return clean func _reject(peer_id: int, reason: String) -> void: _rejected.rpc_id(peer_id, reason) # §9 gotcha 26: a reliable RPC just queued still needs a beat of polling # to actually reach the wire before we pull the connection out from # under it. await get_tree().create_timer(0.3).timeout if multiplayer.multiplayer_peer is ENetMultiplayerPeer: multiplayer.multiplayer_peer.disconnect_peer(peer_id) # Client-callable requests. Both are fire-and-forget: the authoritative # change comes back through _state_changed once the server applies it, same # as everyone else's — a client never mutates its own roster entry directly. func request_set_team(team: int) -> void: _set_team.rpc_id(1, team) func request_set_ready(ready: bool) -> void: _set_ready.rpc_id(1, ready) @rpc("any_peer", "call_remote", "reliable") func _set_team(team: int) -> void: if not multiplayer.is_server(): return var peer_id := multiplayer.get_remote_sender_id() if not _apply_team_change(peer_id, team): return var info: PlayerInfo = roster[peer_id] player_state_changed.emit(peer_id, info.team, info.ready) _state_changed.rpc(peer_id, info.team, info.ready) func _apply_team_change(peer_id: int, team: int) -> bool: # In allocated matches team and global slot are signed together. Changing # only team would produce a roster that disagrees with the assignment and # leave spawn_index anchored to the old team. if require_join_authorisation: return false if not roster.has(peer_id) or team < 0 or team >= TEAM_COUNT: return false var info: PlayerInfo = roster[peer_id] if info.team == team: return false info.team = team info.ready = false # switching teams un-readies — the roster you were ready against just changed return true @rpc("any_peer", "call_remote", "reliable") func _set_ready(ready: bool) -> void: if not multiplayer.is_server(): return var peer_id := multiplayer.get_remote_sender_id() if not roster.has(peer_id): return var info: PlayerInfo = roster[peer_id] if info.ready == ready: return info.ready = ready player_state_changed.emit(peer_id, info.team, info.ready) _state_changed.rpc(peer_id, info.team, info.ready) @rpc("authority", "call_remote", "reliable") func _state_changed(peer_id: int, team: int, ready: bool) -> void: if not roster.has(peer_id): return var info: PlayerInfo = roster[peer_id] info.team = team info.ready = ready player_state_changed.emit(peer_id, team, ready) @rpc("authority", "call_remote", "reliable") func _welcome() -> void: welcomed.emit() @rpc("authority", "call_remote", "reliable") func _rejected(reason: String) -> void: rejected.emit(reason) @rpc("authority", "call_remote", "reliable") func _server_shutdown(reason: String) -> void: last_server_shutdown_reason = _sanitize_shutdown_reason(reason) server_shutdown.emit(last_server_shutdown_reason) @rpc("authority", "call_remote", "reliable") func _player_joined(peer_id: int, player_name: String, team: int, ready: bool) -> void: roster[peer_id] = PlayerInfo.new(peer_id, player_name, team, ready) player_joined.emit(peer_id, player_name) @rpc("authority", "call_remote", "reliable") func _player_left(peer_id: int) -> void: if not roster.has(peer_id): return roster.erase(peer_id) player_left.emit(peer_id)