mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-15 08:22:06 +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,148 @@
|
||||
extends Node
|
||||
|
||||
# Manual two-process smoke test for NetworkManager's clock (task 1.8
|
||||
# acceptance: "offset converges within 2s and stays within ±1 tick on a
|
||||
# clean link"). Not part of tests/test_runner.tscn — needs real ENet peers
|
||||
# and real wall-clock ping/pong cadence. Run:
|
||||
#
|
||||
# godot --headless --path Game res://tests/clock_smoke.tscn -- --role=host
|
||||
# godot --headless --path Game res://tests/clock_smoke.tscn -- --role=client
|
||||
#
|
||||
# Adversarial-review regression: the original version only checked that
|
||||
# later samples agreed with the first one (self-consistency) — a
|
||||
# consistently-wrong offset (e.g. a missing /2 on RTT, or a sign flip)
|
||||
# would converge just as cleanly and still pass. Both roles now also write/
|
||||
# read an independent ground truth: each process's own OS wall-clock
|
||||
# (Time.get_unix_time_from_system(), shared hardware clock, same machine)
|
||||
# lets it compute "my Time.get_ticks_msec() minus real epoch time" — the
|
||||
# TRUE required offset is just the difference of those two numbers between
|
||||
# host and client, computed via a shared temp file since the two processes
|
||||
# can't otherwise see each other's local variables. This is independent of
|
||||
# NetworkManager's own ping/pong math entirely.
|
||||
|
||||
const PORT := 7801
|
||||
const RUN_SECONDS := 6.0
|
||||
const CONVERGE_BY_SEC := 2.0
|
||||
const TICK_MS := 1000.0 / 60.0 # SimConstants.TICK_HZ, kept literal to avoid pulling in the whole project for one constant in a throwaway diagnostic
|
||||
const EPOCH_FILE := "/tmp/cosmicclash_clock_smoke_epoch_offset.txt"
|
||||
# Ground-truth tolerance is looser than the ±1-tick self-consistency check:
|
||||
# Time.get_unix_time_from_system() itself is only second-resolution on some
|
||||
# platforms and the two processes sample it at slightly different instants,
|
||||
# so this bounds "is the offset even the right ballpark and sign" rather
|
||||
# than chasing sub-tick precision the way the self-consistency check does.
|
||||
const GROUND_TRUTH_TOLERANCE_MS := 250.0
|
||||
|
||||
var _role := ""
|
||||
var _start_ms := 0
|
||||
var _samples: Array[Dictionary] = [] # {t_sec, offset}
|
||||
var _finished := false
|
||||
|
||||
|
||||
func _epoch_offset_ms() -> float:
|
||||
return Time.get_unix_time_from_system() * 1000.0 - float(Time.get_ticks_msec())
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
for arg in OS.get_cmdline_user_args():
|
||||
if arg.begins_with("--role="):
|
||||
_role = arg.substr("--role=".length())
|
||||
|
||||
match _role:
|
||||
"host":
|
||||
var err := NetworkManager.host(PORT)
|
||||
if err != OK:
|
||||
_finish(false, "host() failed: %s" % error_string(err))
|
||||
return
|
||||
var f := FileAccess.open(EPOCH_FILE, FileAccess.WRITE)
|
||||
if f:
|
||||
f.store_string(str(_epoch_offset_ms()))
|
||||
f.close()
|
||||
print("SMOKE: hosting on port %d" % PORT)
|
||||
"client":
|
||||
NetworkManager.clock_updated.connect(_on_clock_updated)
|
||||
var err := NetworkManager.join("127.0.0.1", PORT)
|
||||
if err != OK:
|
||||
_finish(false, "join() failed: %s" % error_string(err))
|
||||
return
|
||||
print("SMOKE: joining ...")
|
||||
_:
|
||||
_finish(false, "missing or unrecognised --role=")
|
||||
return
|
||||
|
||||
_start_ms = Time.get_ticks_msec()
|
||||
get_tree().create_timer(RUN_SECONDS).timeout.connect(_on_run_complete)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
|
||||
|
||||
func _on_clock_updated(rtt_ms: float, offset_ms: float) -> void:
|
||||
var t_sec := float(Time.get_ticks_msec() - _start_ms) / 1000.0
|
||||
_samples.append({"t_sec": t_sec, "offset": offset_ms})
|
||||
print("SMOKE clock sample t=%.2fs rtt=%.2fms offset=%.2fms" % [t_sec, rtt_ms, offset_ms])
|
||||
|
||||
|
||||
func _on_run_complete() -> void:
|
||||
if _role != "client":
|
||||
_finish(true, "host ran for %.1fs" % RUN_SECONDS)
|
||||
return
|
||||
|
||||
if _samples.is_empty():
|
||||
_finish(false, "no clock samples received at all")
|
||||
return
|
||||
|
||||
var converged_sample: Dictionary = {}
|
||||
for s: Dictionary in _samples:
|
||||
if s["t_sec"] <= CONVERGE_BY_SEC:
|
||||
converged_sample = s
|
||||
if converged_sample.is_empty():
|
||||
_finish(false, "no sample landed by t=%.1fs (first sample at t=%.2fs)" % [CONVERGE_BY_SEC, _samples[0]["t_sec"]])
|
||||
return
|
||||
|
||||
var reference: float = converged_sample["offset"]
|
||||
var max_drift := 0.0
|
||||
for s: Dictionary in _samples:
|
||||
if s["t_sec"] < CONVERGE_BY_SEC:
|
||||
continue
|
||||
max_drift = maxf(max_drift, absf(s["offset"] - reference))
|
||||
|
||||
if max_drift > TICK_MS:
|
||||
_finish(false, "offset drifted %.2fms after t=%.1fs (> 1 tick = %.2fms)" % [max_drift, CONVERGE_BY_SEC, TICK_MS])
|
||||
return
|
||||
|
||||
# Self-consistency alone can't catch a systematically-wrong-but-stable
|
||||
# offset (§9 gotcha, adversarial review) — cross-check against the OS
|
||||
# wall clock, independent of NetworkManager's own math entirely.
|
||||
if not FileAccess.file_exists(EPOCH_FILE):
|
||||
_finish(false, "converged (%.2fms, drift %.2fms) but host's epoch-offset file was never found — ground truth unavailable" % [reference, max_drift])
|
||||
return
|
||||
var f := FileAccess.open(EPOCH_FILE, FileAccess.READ)
|
||||
var server_epoch_offset := f.get_as_text().to_float()
|
||||
f.close()
|
||||
var client_epoch_offset := _epoch_offset_ms()
|
||||
var true_offset := client_epoch_offset - server_epoch_offset
|
||||
var ground_truth_error := absf(reference - true_offset)
|
||||
|
||||
if ground_truth_error > GROUND_TRUTH_TOLERANCE_MS:
|
||||
_finish(false, "converged (%.2fms) but disagrees with OS-clock ground truth (%.2fms) by %.2fms (> %.1fms tolerance) — the offset math itself may be wrong, not just noisy" % [
|
||||
reference, true_offset, ground_truth_error, GROUND_TRUTH_TOLERANCE_MS
|
||||
])
|
||||
else:
|
||||
_finish(true, "offset converged by t=%.1fs (%.2fms), stayed within %.2fms (<= 1 tick = %.2fms) for the rest of the run across %d samples, AND agrees with independent OS-clock ground truth (%.2fms, error %.2fms <= %.1fms tolerance)" % [
|
||||
CONVERGE_BY_SEC, reference, max_drift, TICK_MS, _samples.size(), true_offset, ground_truth_error, GROUND_TRUTH_TOLERANCE_MS
|
||||
])
|
||||
|
||||
|
||||
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.3).timeout
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(0 if success else 1)
|
||||
Reference in New Issue
Block a user