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

127 lines
5.8 KiB
GDScript

extends Node
# Test-only helper (tests/lobby_smoke.gd). Not a project autoload —
# production code never references this. Exists because lobby.tscn is
# loaded via change_scene_to_file() in the real flow (matching
# main_menu.gd's future Host/Join UI), which frees whatever node initiated
# the load — a test driver can't keep orchestrating from a node that just
# got freed. The driver instead add_child()s this directly under
# get_tree().root (a sibling of current_scene, not a descendant of it), so
# it survives the scene swap and can drive the check from outside lobby.gd,
# which stays untouched by test concerns.
signal finished(success: bool, message: String)
const SETTLE_SECONDS := 3.0
const AFTER_PRESS_SECONDS := 1.0
# The host process starts ~1.5s before the client (see the shell
# invocation in both roles' header comments) and the client doesn't finish
# its own SETTLE_SECONDS + AFTER_PRESS_SECONDS flow (plus its own 0.3s
# _finish delay) until roughly 1.5 + 3.0 + 1.0 + 0.3 ≈ 5.8s into the
# host's own timeline. Verifying and quitting the instant the host's own
# checks pass (~2-2.5s in) would drop the connection out from under the
# client mid-flow. Print the result as soon as it's known, but hold the
# actual quit() open past the client's expected finish time.
const MIN_HOST_LIFETIME_SECONDS := 7.0
# Adversarial-review regression: the host role in lobby_smoke.gd used to
# never load lobby.tscn at all, so lobby.gd's is_server branch (the
# read-only view main_menu.gd's own Host button routes a self-hosting
# player into) had never actually run under this task's own test suite —
# only main_menu_test_hooks.gd's separate, non-permanent task-1.7 test had
# exercised it. This closes that gap for good.
func run_host_test() -> void:
var start_ms := Time.get_ticks_msec()
var deadline := start_ms + int((SETTLE_SECONDS + 5.0) * 1000.0)
while MatchNet.roster.is_empty() and Time.get_ticks_msec() < deadline:
await get_tree().process_frame
var lobby := get_tree().current_scene
if lobby == null or not lobby.has_method("_refresh"):
await _finish_and_quit(false, "current_scene is not the lobby scene", start_ms)
return
if MatchNet.roster.is_empty():
await _finish_and_quit(false, "no client joined before timeout", start_ms)
return
# Give _refresh() a beat to process the player_joined signal it just got.
await get_tree().create_timer(0.3).timeout
var controls_row: Control = lobby.get_node("%ControlsRow")
var team0: VBoxContainer = lobby.get_node("%Team0List")
var team1: VBoxContainer = lobby.get_node("%Team1List")
var status: Label = lobby.get_node("%StatusLabel")
var total_rows := team0.get_child_count() + team1.get_child_count()
# The server is never a roster member (§1.1 decision 2) — its own
# lobby.gd instance must show a read-only view, no team/ready controls.
var controls_hidden := not controls_row.visible
var rows_match_roster := total_rows == MatchNet.roster.size()
var status_ok := status.text.begins_with("Hosting")
var success := controls_hidden and rows_match_roster and status_ok
await _finish_and_quit(success, "controls_hidden=%s rows=%d roster=%d status='%s'" % [
str(controls_hidden), total_rows, MatchNet.roster.size(), status.text
], start_ms)
func _finish_and_quit(success: bool, message: String, start_ms: int) -> void:
finished.emit(success, message)
print("SMOKE %s: %s" % ["PASS" if success else "FAIL", message])
var elapsed_sec := float(Time.get_ticks_msec() - start_ms) / 1000.0
var remaining := MIN_HOST_LIFETIME_SECONDS - elapsed_sec
if remaining > 0.0:
await get_tree().create_timer(remaining).timeout
NetworkManager.shutdown()
get_tree().quit(0 if success else 1)
func run_client_test() -> void:
await get_tree().create_timer(SETTLE_SECONDS).timeout
var lobby := get_tree().current_scene
if lobby == null or not lobby.has_method("_refresh"):
finished.emit(false, "current_scene is not the lobby scene")
return
var switch_btn: Button = lobby.get_node("%SwitchTeamButton")
var ready_btn: CheckButton = lobby.get_node("%ReadyButton")
switch_btn.emit_signal("pressed")
ready_btn.button_pressed = true
ready_btn.emit_signal("toggled", true)
await get_tree().create_timer(AFTER_PRESS_SECONDS).timeout
var team0: VBoxContainer = lobby.get_node("%Team0List")
var team1: VBoxContainer = lobby.get_node("%Team1List")
var status: Label = lobby.get_node("%StatusLabel")
var total_rows := team0.get_child_count() + team1.get_child_count()
var my_id := multiplayer.get_unique_id()
var info: MatchNet.PlayerInfo = MatchNet.roster.get(my_id)
var info_str := "roster_size=%d team0_rows=%d team1_rows=%d status='%s'" % [
MatchNet.roster.size(), team0.get_child_count(), team1.get_child_count(), status.text
]
var team_ready_ok := false
if info != null:
info_str += " my_team=%d my_ready=%s" % [info.team, str(info.ready)]
# Started on whatever team balancing picked (0, since first
# joiner), pressed Switch Team once -> should now be on team 1,
# and pressed Ready -> should be true.
team_ready_ok = info.team == 1 and info.ready == true
print("SMOKE INFO: " + info_str)
var rows_match_roster := total_rows == MatchNet.roster.size()
var success := rows_match_roster and team_ready_ok
var message := "rows=%d roster=%d team_ready_ok=%s" % [total_rows, MatchNet.roster.size(), str(team_ready_ok)]
finished.emit(success, message)
# The driver that called run_client_test() is gone by now — it was
# get_tree().current_scene before the change_scene_to_file() that
# loaded the lobby, so it got freed in the swap, taking its signal
# connection to `finished` down with it (Godot auto-disconnects when
# either end of a connection is freed). This autoload outlives that
# swap, so it's what actually ends the process.
print("SMOKE %s: %s" % ["PASS" if success else "FAIL", message])
get_tree().quit(0 if success else 1)