mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(multiplayer): Phase 1 transport, connection, and lobby
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.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
extends Node
|
||||
|
||||
# Headless dedicated server entry point (task 1.6). Parses CLI args, hosts
|
||||
# via NetworkManager, logs structured lines, and watches for physics-tick
|
||||
# overrun (§9 gotcha 9: Engine.max_physics_steps_per_frame defaults to 8;
|
||||
# a tick overrunning 16.7ms backs up the accumulator and the next frame
|
||||
# runs multiple ticks, spiking CPU further — worth logging, not just
|
||||
# silently absorbing).
|
||||
#
|
||||
# Run: godot --headless --path Game res://scenes/server_boot.tscn -- --port=7777
|
||||
#
|
||||
# Deliberately does not spawn a match yet — that's Phase 2's networked_match
|
||||
# scene. This is just the process shell: listen, log, idle cheaply.
|
||||
|
||||
const LOG_LEVELS := {"debug": 0, "info": 1, "warn": 2, "error": 3}
|
||||
|
||||
var _boot_ms := 0
|
||||
var _last_physics_frame := 0
|
||||
var _log_level := 1 # info
|
||||
var _watchdog_armed := false # skip the first _process(): engine startup scheduling can batch several physics frames before the first idle frame runs, which isn't a real overrun
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_boot_ms = Time.get_ticks_msec()
|
||||
Engine.max_fps = 60 # a server never renders; this just caps the idle-frame poll rate so it doesn't spin
|
||||
|
||||
var port := NetworkManager.DEFAULT_PORT
|
||||
var max_clients := NetworkManager.MAX_CLIENTS
|
||||
for arg in OS.get_cmdline_user_args():
|
||||
if arg.begins_with("--port="):
|
||||
port = int(arg.substr("--port=".length()))
|
||||
elif arg.begins_with("--max-clients="):
|
||||
max_clients = int(arg.substr("--max-clients=".length()))
|
||||
elif arg.begins_with("--log-level="):
|
||||
var level_name := arg.substr("--log-level=".length())
|
||||
if LOG_LEVELS.has(level_name):
|
||||
_log_level = LOG_LEVELS[level_name]
|
||||
else:
|
||||
_log("error", "bad_log_level", {"given": level_name, "valid": LOG_LEVELS.keys()})
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
NetworkManager.client_connected.connect(_on_client_connected)
|
||||
NetworkManager.client_disconnected.connect(_on_client_disconnected)
|
||||
MatchNet.player_joined.connect(_on_player_joined)
|
||||
MatchNet.player_left.connect(_on_player_left)
|
||||
|
||||
var err := NetworkManager.host(port, max_clients)
|
||||
if err != OK:
|
||||
_log("error", "server_boot_failed", {"port": port, "error": error_string(err)})
|
||||
get_tree().quit(1)
|
||||
return
|
||||
_log("info", "server_started", {"port": port, "max_clients": max_clients})
|
||||
_last_physics_frame = Engine.get_physics_frames()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
var current := Engine.get_physics_frames()
|
||||
var steps := current - _last_physics_frame
|
||||
_last_physics_frame = current
|
||||
# §9 gotcha 6: with physics_jitter_fix = 0.0, frames legitimately
|
||||
# alternate between 0 and 2 ticks even on an idle, healthy server —
|
||||
# that's expected quantisation, not backlog. A real overrun is the
|
||||
# accumulator failing to drain back down, i.e. 3+ ticks in one frame.
|
||||
if steps > 2 and _watchdog_armed:
|
||||
_log("warn", "physics_overrun", {"steps": steps})
|
||||
_watchdog_armed = true
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
|
||||
|
||||
func _on_client_connected(peer_id: int) -> void:
|
||||
_log("debug", "peer_connected", {"peer_id": peer_id})
|
||||
|
||||
|
||||
func _on_client_disconnected(peer_id: int) -> void:
|
||||
_log("debug", "peer_disconnected", {"peer_id": peer_id})
|
||||
|
||||
|
||||
func _on_player_joined(peer_id: int, player_name: String) -> void:
|
||||
_log("info", "player_joined", {"peer_id": peer_id, "name": player_name})
|
||||
|
||||
|
||||
func _on_player_left(peer_id: int) -> void:
|
||||
_log("info", "player_left", {"peer_id": peer_id})
|
||||
|
||||
|
||||
func _log(level: String, event: String, fields: Dictionary) -> void:
|
||||
if LOG_LEVELS.get(level, 1) < _log_level:
|
||||
return
|
||||
var parts := PackedStringArray()
|
||||
for key in fields:
|
||||
parts.append("%s=%s" % [key, str(fields[key])])
|
||||
var elapsed_sec := (Time.get_ticks_msec() - _boot_ms) / 1000.0
|
||||
print("[%.3f] %s %s %s" % [elapsed_sec, level.to_upper(), event, " ".join(parts)])
|
||||
Reference in New Issue
Block a user