Files
Josh Creek 4533da34e0 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.
2026-08-20 08:18:59 +01:00

113 lines
4.2 KiB
GDScript

extends Node
# Manual two-process smoke test for NetworkManager (task 1.2 acceptance:
# "two peers connect and disconnect cleanly"; also exercises task 1.3's
# manual-poll-only regime — NetworkManager disables automatic multiplayer
# polling, so this script's own _process() polling is what makes the
# connection progress at all). Deliberately not part of the pure-function
# suite in tests/test_runner.tscn — an ENet handshake needs two real
# processes. Run:
#
# godot --headless --path Game res://tests/net_smoke.tscn -- --role=host
# godot --headless --path Game res://tests/net_smoke.tscn -- --role=client
#
# (start the host first). Each process prints one "SMOKE PASS/FAIL: ..."
# line and exits 0/1.
#
# Adversarial-review regression: this test used to only confirm each
# process exits cleanly on its own initiative — it never confirmed the
# OTHER peer actually observes the disconnect. The host role now waits for
# BOTH client_connected and client_disconnected before passing; the client
# explicitly disconnects mid-test (rather than only on process exit) and
# gives it a beat before quitting, same reasoning as §9 gotcha 26 for
# connects: a clean disconnect notice still needs a few poll() cycles to
# reach the wire, or the other side falls back to its ~5s peer timeout
# (§9 gotcha 11) instead of a prompt, clean disconnect.
const DEFAULT_PORT := 7799
const TIMEOUT_SECONDS := 8.0
var _role := ""
var _port := DEFAULT_PORT
var _finished := false
func _ready() -> void:
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--role="):
_role = arg.substr("--role=".length())
elif arg.begins_with("--port="):
_port = int(arg.substr("--port=".length()))
if _role == "host":
NetworkManager.client_connected.connect(_on_host_client_connected)
NetworkManager.client_disconnected.connect(_on_host_client_disconnected)
var err := NetworkManager.host(_port)
if err != OK:
_finish(false, "host() failed: %s" % error_string(err))
return
print("SMOKE: hosting on port %d, waiting for a client..." % _port)
elif _role == "client":
NetworkManager.connected_to_server.connect(_on_client_connected)
NetworkManager.connection_failed.connect(_on_client_connection_failed)
var err := NetworkManager.join("127.0.0.1", _port)
if err != OK:
_finish(false, "join() failed: %s" % error_string(err))
return
print("SMOKE: joining 127.0.0.1:%d ..." % _port)
else:
_finish(false, "missing or unrecognised --role= (expected host|client)")
return
get_tree().create_timer(TIMEOUT_SECONDS).timeout.connect(_on_timeout)
func _process(_delta: float) -> void:
NetworkManager.poll()
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
func _on_host_client_connected(peer_id: int) -> void:
print("SMOKE INFO: host saw client_connected (peer_id=%d), waiting for client_disconnected too..." % peer_id)
func _on_host_client_disconnected(peer_id: int) -> void:
_finish(true, "host saw client_connected AND client_disconnected (peer_id=%d)" % peer_id)
func _on_client_connected() -> void:
print("SMOKE INFO: client connected to host")
# §9 gotcha 26: give the host a beat to fully settle the connect
# handshake before we turn around and disconnect again.
await get_tree().create_timer(0.5).timeout
NetworkManager.shutdown()
# Same class of issue as gotcha 26, the disconnect leg: closing the
# peer queues ENet's own disconnect notice, which still needs a few
# more poll() cycles to actually reach the wire before this process
# exits — quit immediately and the host would fall back to its ~5s
# peer timeout (§9 gotcha 11) instead of a prompt, clean disconnect.
await get_tree().create_timer(1.0).timeout
_finish(true, "client connected then disconnected cleanly")
func _on_client_connection_failed() -> void:
_finish(false, "client connection_failed")
func _on_timeout() -> void:
if not _finished:
_finish(false, "timed out waiting for connect+disconnect confirmation")
func _finish(success: bool, message: String) -> void:
if _finished:
return
_finished = true
print("SMOKE %s: %s" % ["PASS" if success else "FAIL", message])
await get_tree().create_timer(0.3).timeout
NetworkManager.shutdown()
get_tree().quit(0 if success else 1)