Files
2026-08-21 19:57:59 +01:00

154 lines
6.1 KiB
GDScript

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
# The client starts after the host, so its identical run window ends later.
# Keep the host alive through that tail to avoid polling a deliberately
# closed transport during a successful clock test.
const HOST_GRACE_SECONDS := 1.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":
await get_tree().create_timer(HOST_GRACE_SECONDS).timeout
_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)