mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
4533da34e0
Lands tasks 1.0-1.8 of multiplayer-todo.md: the pure-function test runner, net_codec (wire format quantizers/pack-unpack), NetworkManager (ENet transport, manual polling, min-RTT clock sync), MatchNet (handshake, protocol/tick-rate gating, roster with team+ready state), lobby.tscn (team columns, switch team, ready toggle), server_boot.tscn (headless dedicated server with structured logging and an overrun watchdog), and main_menu.gd's Host/Join-by-IP UI (connecting overlay, cancel, bounded failure path). Followed by an adversarial review (Opus subagent) that found and fixed two real bugs - an unvalidated player_name broadcast that let one client's oversized name head-of-line-block the reliable channel for everyone, and a server-side roster leak across a host/re-host cycle - plus three gaps in the test suite itself where a claim of "verified" wasn't actually backed by what the test checked. All five two-process smoke tests plus the pure-function suite are green with the strengthened assertions in place.
256 lines
9.3 KiB
GDScript
256 lines
9.3 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-todo.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")
|
|
|
|
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
|
|
|
|
const TEAM_COUNT := 2
|
|
|
|
# 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 team: int = 0
|
|
var ready: bool = false
|
|
|
|
func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false) -> void:
|
|
peer_id = p_peer_id
|
|
player_name = p_player_name
|
|
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"
|
|
|
|
# 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()
|
|
if _auto_hello:
|
|
_hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name)
|
|
|
|
|
|
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()
|
|
|
|
|
|
# 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
|
|
_remove_player(peer_id)
|
|
|
|
|
|
func _remove_player(peer_id: int) -> void:
|
|
if not roster.has(peer_id):
|
|
return
|
|
roster.erase(peer_id)
|
|
player_left.emit(peer_id)
|
|
_player_left.rpc(peer_id)
|
|
|
|
|
|
# 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) -> 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 player_name.length() > MAX_INPUT_LENGTH:
|
|
await _reject(peer_id, "player name too long")
|
|
return
|
|
var clean_name := _sanitize_player_name(player_name)
|
|
|
|
# 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()
|
|
roster[peer_id] = PlayerInfo.new(peer_id, clean_name, team, false)
|
|
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
|
|
|
|
|
|
# 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 _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)
|