Files
CosmicClash/Game/tests/match_net_smoke.gd
T
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

168 lines
6.0 KiB
GDScript

extends Node
# Manual two/three-process smoke test for MatchNet (task 1.4 acceptance:
# "a mismatched client is rejected with a readable reason", plus the happy
# path: hello/welcome, roster sees player_joined on both sides). Not part of
# tests/test_runner.tscn — needs real ENet peers. Run:
#
# godot --headless --path Game res://tests/match_net_smoke.tscn -- --role=host
# godot --headless --path Game res://tests/match_net_smoke.tscn -- --role=client --name=Alice
# godot --headless --path Game res://tests/match_net_smoke.tscn -- --role=client-badversion
# godot --headless --path Game res://tests/match_net_smoke.tscn -- --role=host_recycle
# godot --headless --path Game res://tests/match_net_smoke.tscn -- --role=client --name=Bob (run once against host_recycle, then let it disconnect)
const PORT := 7800
const TIMEOUT_SECONDS := 5.0
var _role := ""
var _player_name := "TestPlayer"
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("--name="):
_player_name = arg.substr("--name=".length())
match _role:
"host":
MatchNet.player_joined.connect(_on_player_joined)
var err := NetworkManager.host(PORT)
if err != OK:
_finish(false, "host() failed: %s" % error_string(err))
return
print("SMOKE: hosting on port %d" % PORT)
"host_recycle":
_run_host_recycle()
return
"client":
MatchNet.local_player_name = _player_name
MatchNet.welcomed.connect(_on_welcomed)
MatchNet.rejected.connect(_on_rejected)
var err := NetworkManager.join("127.0.0.1", PORT)
if err != OK:
_finish(false, "join() failed: %s" % error_string(err))
return
print("SMOKE: joining as '%s' ..." % _player_name)
"client-badversion":
MatchNet._auto_hello = false
MatchNet.rejected.connect(_on_rejected)
MatchNet.welcomed.connect(_on_welcomed)
var err := NetworkManager.join("127.0.0.1", PORT)
if err != OK:
_finish(false, "join() failed: %s" % error_string(err))
return
NetworkManager.connected_to_server.connect(func():
var NetCodec = load("res://scripts/net_codec.gd")
MatchNet._hello.rpc_id(1, NetCodec.PROTOCOL_VERSION + 99, 60, "BadVersion")
)
print("SMOKE: joining with a deliberately wrong protocol version ...")
"client-longname":
# Adversarial-review regression: a client sending an oversized
# player_name used to be broadcast verbatim to every peer,
# head-of-line-blocking the reliable channel. Confirm it's
# rejected outright before ever reaching a broadcast.
MatchNet._auto_hello = false
MatchNet.rejected.connect(_on_rejected)
MatchNet.welcomed.connect(_on_welcomed)
var err := NetworkManager.join("127.0.0.1", PORT)
if err != OK:
_finish(false, "join() failed: %s" % error_string(err))
return
NetworkManager.connected_to_server.connect(func():
var NetCodec = load("res://scripts/net_codec.gd")
var SimConstants = load("res://scripts/sim_constants.gd")
var huge_name := "X".repeat(500000) # 500 KB, well past MAX_INPUT_LENGTH
MatchNet._hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, huge_name)
)
print("SMOKE: joining with a deliberately oversized player name ...")
_:
_finish(false, "missing or unrecognised --role=")
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_player_joined(peer_id: int, player_name: String) -> void:
_finish(true, "host saw player_joined (peer_id=%d, name=%s)" % [peer_id, player_name])
# Adversarial-review regression (multiplayer-todo.md §9): MatchNet.roster
# used to have no path that cleared it when a HOST itself called
# NetworkManager.shutdown() — only the client-side disconnect signal did.
# Host -> client joins -> host leaves (shutdown) -> host again used to
# leave the first client permanently in roster. Run this role, then run
# `--role=client` once against it while it's up.
var _host_recycle_joined := false
func _on_host_recycle_player_joined(_peer_id: int, _name: String) -> void:
_host_recycle_joined = true
func _run_host_recycle() -> void:
MatchNet.player_joined.connect(_on_host_recycle_player_joined)
var err := NetworkManager.host(PORT)
if err != OK:
_finish(false, "host() failed: %s" % error_string(err))
return
print("SMOKE: host_recycle hosting on port %d, waiting for a client..." % PORT)
var deadline := Time.get_ticks_msec() + int(TIMEOUT_SECONDS * 1000.0)
while not _host_recycle_joined and Time.get_ticks_msec() < deadline:
await get_tree().process_frame
if not _host_recycle_joined:
_finish(false, "no client joined within %.1fs" % TIMEOUT_SECONDS)
return
print("SMOKE: host_recycle got a joiner (roster size=%d), now leaving and re-hosting..." % MatchNet.roster.size())
NetworkManager.shutdown()
err = NetworkManager.host(PORT)
if err != OK:
_finish(false, "re-host() failed: %s" % error_string(err))
return
await get_tree().process_frame
await get_tree().process_frame
var ok := MatchNet.roster.is_empty()
_finish(ok, "roster after re-host: size=%d (expected 0)" % MatchNet.roster.size())
func _on_welcomed() -> void:
if _role == "client-badversion" or _role == "client-longname":
_finish(false, "%s was welcomed, expected rejection" % _role)
else:
_finish(true, "client was welcomed")
func _on_rejected(reason: String) -> void:
if _role == "client-badversion" or _role == "client-longname":
_finish(true, "%s correctly rejected: %s" % [_role, reason])
else:
_finish(false, "client was rejected unexpectedly: %s" % reason)
func _on_timeout() -> void:
if not _finished:
_finish(false, "timed out")
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.5).timeout
NetworkManager.shutdown()
get_tree().quit(0 if success else 1)