mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-13 20:42:03 +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,81 @@
|
||||
extends Node
|
||||
|
||||
# Headless test runner (task 1.0). Discovers every *.gd under tests/cases/,
|
||||
# instances it, and calls every test_*() method by name — a test case is
|
||||
# picked up by dropping a file in that folder, not by registering it here.
|
||||
# Run with: godot --headless --path Game res://tests/test_runner.tscn
|
||||
|
||||
const CASES_DIR := "res://tests/cases"
|
||||
const TestCase = preload("res://tests/test_case.gd")
|
||||
|
||||
func _ready() -> void:
|
||||
var case_paths := _discover_case_paths()
|
||||
var total := 0
|
||||
var failed := 0
|
||||
var failure_messages: Array[String] = []
|
||||
|
||||
for path in case_paths:
|
||||
# Adversarial-review regression: a case file with a parse/compile
|
||||
# error used to hang the whole runner forever. load() on a broken
|
||||
# script does NOT return null here — it returns a non-null but
|
||||
# uninstantiable GDScript resource, so a plain null check doesn't
|
||||
# catch it; calling .new() on it throws "Invalid call: Nonexistent
|
||||
# function 'new'", severe enough to abort _ready() entirely without
|
||||
# ever reaching quit(). can_instantiate() is the real guard.
|
||||
var script: GDScript = load(path)
|
||||
if script == null or not script.can_instantiate():
|
||||
failed += 1
|
||||
failure_messages.append("%s: failed to load (parse/compile error — see SCRIPT ERROR above)" % path.get_file())
|
||||
continue
|
||||
var instance = script.new()
|
||||
if instance == null:
|
||||
failed += 1
|
||||
failure_messages.append("%s: script.new() returned null" % path.get_file())
|
||||
continue
|
||||
|
||||
for method in instance.get_method_list():
|
||||
var method_name: String = method["name"]
|
||||
if not method_name.begins_with("test_"):
|
||||
continue
|
||||
total += 1
|
||||
instance.failures.clear()
|
||||
instance.assertions_made = 0
|
||||
instance.call(method_name)
|
||||
# Adversarial-review regression: GDScript has no exceptions, so
|
||||
# a runtime error partway through a test (before it reaches its
|
||||
# first assert_*) just logs a SCRIPT ERROR and returns —
|
||||
# `failures` stays empty exactly as if every assertion passed,
|
||||
# and this used to count as a PASS. A test that completes
|
||||
# having made zero assertions is itself a failure: it proved
|
||||
# nothing, whether because it crashed early or was just never
|
||||
# written to assert anything.
|
||||
if instance.assertions_made == 0:
|
||||
failed += 1
|
||||
failure_messages.append("%s.%s: made no assertions (crashed before the first assert_*, or the test itself is incomplete)" % [path.get_file(), method_name])
|
||||
elif not instance.failures.is_empty():
|
||||
failed += 1
|
||||
for f in instance.failures:
|
||||
failure_messages.append("%s.%s: %s" % [path.get_file(), method_name, f])
|
||||
|
||||
print("Ran %d tests from %d case file(s), %d failed" % [total, case_paths.size(), failed])
|
||||
for message in failure_messages:
|
||||
print(" FAIL: " + message)
|
||||
|
||||
get_tree().quit(1 if failed > 0 else 0)
|
||||
|
||||
|
||||
func _discover_case_paths() -> Array[String]:
|
||||
var paths: Array[String] = []
|
||||
var dir := DirAccess.open(CASES_DIR)
|
||||
if dir == null:
|
||||
push_error("Cannot open " + CASES_DIR)
|
||||
return paths
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
while file_name != "":
|
||||
if file_name.ends_with(".gd") and not dir.current_is_dir():
|
||||
paths.append(CASES_DIR + "/" + file_name)
|
||||
file_name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
paths.sort()
|
||||
return paths
|
||||
Reference in New Issue
Block a user