mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
4533da34e0
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.
127 lines
5.8 KiB
GDScript
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)
|