Files
CosmicClash/Game/scripts/match_net.gd
T
Josh Creek b43ad207c1 docs(multiplayer): split spec into MULTIPLAYER_SPEC.md, trim task doc to outstanding work
multiplayer-next.md was a 1662-line mix of standing architecture spec
and task-completion tracking, most of which was dense per-task DONE
evidence for finished Phases 0-6. Split it:

- MULTIPLAYER_SPEC.md (new): the locked architecture decisions, wire
  format, server-side input handling, prediction/reconciliation,
  latency/frame-rate budget, and match lifecycle state machine -
  standing design reference, not task-tracked.
- multiplayer-next.md (trimmed 1662 -> ~370 lines): only outstanding
  work remains - §0 status, §7 Phase 7/8 task tables condensed to
  "what's left" per task, §8-11 reference material (refactoring notes,
  gotchas, testing, flagged items). Phases 0-6 collapsed to a pointer
  at git history instead of ~500 lines of DONE evidence.

Also:
- Repointed every `multiplayer-next.md §N` code comment (N 1-6) across
  Game/scripts, Game/tools and Game/tests to MULTIPLAYER_SPEC.md, since
  those sections moved. Task-number references (`task N.N`, §7-11)
  correctly still point at multiplayer-next.md.
- Updated CLAUDE.md's doc index and docs/TECH_STACK.md's spec-section
  citations to match.
- TODO.md: added a "what's left to actually finish multiplayer
  (human-actionable)" checklist pulled from multiplayer-next.md §0 and
  docs/MATCHMAKING.md - things that need a person (hardware, a design
  decision, a Steam App ID, hands on a controller), not more agent code.
2026-09-04 22:43:13 +01:00

645 lines
26 KiB
GDScript

extends Node
# Autoload (project.godot [autoload] MatchNet). Handshake + roster layer on
# top of NetworkManager's raw transport (§2.5, §1.3 of MULTIPLAYER_SPEC.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)