mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
cf73074e27
A second adversarial review of the previous fix commit found two of its nine fixes silently defeated each other: the seq-range guard (fix for a MEDIUM epoch-mismatch finding) capped the exact variable the ring-overflow resync (fix for the original CRITICAL finding) depends on, making the resync unreachable in production and recreating permanent input death at a lower failure threshold, reachable via ordinary server tick loss alone. - CRITICAL: rebind the seq-range guard to InputJitterBuffer's own highest_ingested_seq (now public) instead of the consumer-side last_applied_seq, so it tracks the client's send epoch rather than a value that can lag arbitrarily far behind during a stall. - HIGH: InputLeadController's release logic still ANDed the old `lead > LEAD_MIN` gate onto the new depth-driven condition, so a backlog the controller never caused still couldn't drain. Split into two independent decisions: the seq-duplicate action follows real depth alone; lead's own bookkeeping separately never drops below its floor. - MEDIUM: widen the CI driver's movement/stalled sampling margin (run_seconds - 2.0, was - 0.5) and assert the peer is still in multiplayer.get_peers() at sample time, since the old margin let the check pass on residual starvation grace after a bot had already disconnected. - LOW: measure horizontal-only displacement in the human smoke test's movement check — the old 3D-distance bar was beatable by pure gravity settling with fully dead input. - LOW: fix a real "clean stderr" violation (match_net.gd broadcasting a departure notice to a peer whose ENet channels are already torn down, including a second peer disconnecting in the same poll batch) by deferring the notification to the next idle frame. - Wire the server's per-slot stalled bit into the client debug overlay for real — a prior commit message claimed this already reached the overlay when only the CI gate actually read it. Re-verified end-to-end against the real production RPC path (not just unit tests in isolation, which is how the composition bug got past the first round): a 2-bot CI match with a 1.5s host SIGSTOP freeze injected mid-run, well past the 0.6s threshold the review reproduced the bug at, now recovers cleanly on repeated runs with zero stderr noise.
284 lines
11 KiB
GDScript
284 lines
11 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)
|
|
# 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)
|
|
|
|
|
|
# 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)
|