mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
549 lines
22 KiB
GDScript
549 lines
22 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-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
|
|
|
|
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()
|
|
|
|
# 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()
|
|
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 String(context.get("match_id", "")).is_empty() or String(context.get("server_id", "")).is_empty() or int(context.get("protocol_version", 0)) < 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
|
|
if not admissions_open:
|
|
await _reject(multiplayer.get_remote_sender_id(), "server is draining")
|
|
return
|
|
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
|
|
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.
|
|
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"
|
|
|
|
|
|
# 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
|
|
|
|
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 = _reserve_join_authorisation(supplied_join_authorisation, peer_id)
|
|
if join_generation < 0:
|
|
await _reject(peer_id, "join authorisation reclaim expired")
|
|
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 _reserve_join_authorisation(token: String, peer_id: int) -> 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 > RECONNECT_GRACE_SECONDS:
|
|
return -1
|
|
var generation := int(history.get("generation", 0)) + 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 roster.has(peer_id) or team < 0 or team >= TEAM_COUNT:
|
|
return
|
|
var info: PlayerInfo = roster[peer_id]
|
|
if info.team == team:
|
|
return
|
|
info.team = team
|
|
info.ready = false # switching teams un-readies — the roster you were ready against just changed
|
|
player_state_changed.emit(peer_id, info.team, info.ready)
|
|
_state_changed.rpc(peer_id, info.team, info.ready)
|
|
|
|
|
|
@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)
|