mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-14 06:52:05 +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,114 @@
|
||||
extends Node
|
||||
|
||||
# Test-only helper (tests/main_menu_smoke.gd). Not referenced by production
|
||||
# code. Registered as a temporary project autoload only while running this
|
||||
# test — see the test's own header for why an autoload (rather than a
|
||||
# scene-child driver) is needed: main_menu.tscn IS the real current_scene
|
||||
# here (run directly via --path Game res://scenes/main_menu.tscn, exactly
|
||||
# like production), so unlike lobby_smoke.gd's driver this doesn't even
|
||||
# need to survive a scene swap — it just needs to exist independently of
|
||||
# main_menu.gd so main_menu.gd itself stays untouched by test concerns.
|
||||
|
||||
var _role := ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
for arg in OS.get_cmdline_user_args():
|
||||
if arg.begins_with("--role="):
|
||||
_role = arg.substr("--role=".length())
|
||||
if _role.is_empty():
|
||||
return
|
||||
# main_menu.gd's own _ready() (which builds %-unique-name refs) must run
|
||||
# before we touch its nodes; autoloads run first, so wait a frame.
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
match _role:
|
||||
"host":
|
||||
_run_host()
|
||||
"join_ok":
|
||||
_run_join_ok()
|
||||
"join_refused":
|
||||
_run_join_refused()
|
||||
"join_cancel":
|
||||
_run_join_cancel()
|
||||
|
||||
|
||||
func _run_host() -> void:
|
||||
var menu := get_tree().current_scene
|
||||
var host_btn: Button = menu.get_node("CenterContainer/VBoxContainer/HostButton")
|
||||
host_btn.emit_signal("pressed")
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
var scene := get_tree().current_scene
|
||||
var ok := scene != null and scene.scene_file_path == "res://scenes/lobby.tscn"
|
||||
print("SMOKE %s: host -> current_scene=%s" % ["PASS" if ok else "FAIL", scene.scene_file_path if scene else "null"])
|
||||
if not ok:
|
||||
_finish(false, "host transition check failed")
|
||||
return
|
||||
# Stay up long enough for a separate join_ok/join_refused/join_cancel
|
||||
# process (started after this one) to actually exercise the host —
|
||||
# unlike _finish()'s normal 0.3s beat, this test's whole point is being
|
||||
# a live target for a while.
|
||||
await get_tree().create_timer(6.0).timeout
|
||||
_finish(true, "host ran and stayed up for a joiner")
|
||||
|
||||
|
||||
func _run_join_ok() -> void:
|
||||
# Give the host process (started first by the shell script) time to be listening.
|
||||
await get_tree().create_timer(1.5).timeout
|
||||
var menu := get_tree().current_scene
|
||||
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
|
||||
address_edit.text = "127.0.0.1"
|
||||
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
join_btn.emit_signal("pressed")
|
||||
var overlay: Control = menu.get_node("%ConnectingOverlay")
|
||||
print("SMOKE INFO: overlay visible right after Join press = %s" % str(overlay.visible))
|
||||
await get_tree().create_timer(2.0).timeout
|
||||
var scene := get_tree().current_scene
|
||||
var ok := scene != null and scene.scene_file_path == "res://scenes/lobby.tscn"
|
||||
_finish(ok, "join_ok -> current_scene=%s" % (scene.scene_file_path if scene else "null"))
|
||||
|
||||
|
||||
func _run_join_refused() -> void:
|
||||
var menu := get_tree().current_scene
|
||||
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
|
||||
address_edit.text = "127.0.0.1"
|
||||
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
join_btn.emit_signal("pressed")
|
||||
var overlay: Control = menu.get_node("%ConnectingOverlay")
|
||||
print("SMOKE INFO: overlay visible right after Join press (no server) = %s" % str(overlay.visible))
|
||||
# main_menu.gd's own CONNECT_TIMEOUT_SECONDS (6.0) is what actually
|
||||
# bounds this now — ENet's own connection_failed proved unbounded in
|
||||
# practice against a genuinely refused loopback connection.
|
||||
await get_tree().create_timer(8.0).timeout
|
||||
var error_label: Label = menu.get_node("%MultiplayerErrorLabel")
|
||||
var still_on_menu := get_tree().current_scene == menu
|
||||
var ok := still_on_menu and not overlay.visible and error_label.visible
|
||||
_finish(ok, "join_refused -> still_on_menu=%s overlay_visible=%s error_visible=%s error_text='%s'" % [
|
||||
str(still_on_menu), str(overlay.visible), str(error_label.visible), error_label.text
|
||||
])
|
||||
|
||||
|
||||
func _run_join_cancel() -> void:
|
||||
var menu := get_tree().current_scene
|
||||
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
|
||||
address_edit.text = "10.255.255.1" # non-routable; connect attempt just hangs until timeout/cancel
|
||||
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
join_btn.emit_signal("pressed")
|
||||
var overlay: Control = menu.get_node("%ConnectingOverlay")
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
var overlay_shown := overlay.visible
|
||||
var cancel_btn: Button = menu.get_node("%ConnectingCancelButton")
|
||||
cancel_btn.emit_signal("pressed")
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
var still_on_menu := get_tree().current_scene == menu
|
||||
var ok := overlay_shown and not overlay.visible and still_on_menu and not NetworkManager.is_client
|
||||
_finish(ok, "join_cancel -> overlay_shown=%s overlay_now=%s still_on_menu=%s is_client=%s" % [
|
||||
str(overlay_shown), str(overlay.visible), str(still_on_menu), str(NetworkManager.is_client)
|
||||
])
|
||||
|
||||
|
||||
func _finish(success: bool, message: String) -> void:
|
||||
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)
|
||||
Reference in New Issue
Block a user