Files
CosmicClash/Game/tests/networked_match_smoke.gd
T
Josh Creek b290f49143 feat(multiplayer): Phase 3 task 3.4 - input validation, rate limiting, disconnect policy
MatchSim._recv_input now validates before decoding (§3.1 steps 2-3):
per-peer rolling-1s rate limiting (packet count AND byte budget, dropping
over-budget packets and disconnecting after 3 consecutive over-budget
seconds), and framing validation (redundancy count and payload size
checked against NetCodec's own layout before unpack_input ever runs,
disconnecting after 20 malformed packets). Framing has to be validated
explicitly rather than relying on decode failure: StreamPeerBuffer
silently zero-fills past EOF instead of erroring, a finding from Phase
2's adversarial review.

networked_match.gd's _on_input_received now rejects any seq claiming to
be more than 20 ticks ahead of the current server tick (§3.1 step 4) and
counts (rather than silently ignoring) input from a peer with no slot,
for observability.

Verified with two new permanent regression tests (networked_match_smoke.gd
--role=client-abuse-malformed / client-abuse-flood) that call
MatchSim._recv_input directly with garbage bytes and a legitimate-but-
too-frequent flood, respectively, bypassing the honest client encoder
entirely - the same thing a hostile custom client sending raw ENet
packets would look like. Both confirm real disconnection, not just that
the server tolerates the abuse.

Two bugs surfaced by getting these tests to actually pass cleanly: a
GDScript lambda-capture-by-value mistake in the tests themselves (a
plain `var disconnected := false` mutated inside a signal-handler lambda
never became visible to the enclosing function - fixed by capturing a
single-element Array instead, which is captured by reference); and a
narrow real race where NetworkManager's own ping/pong reply could target
a peer that a concurrent abuse-triggered disconnect had just removed
from the same poll() batch, now guarded. (Passing disconnect_peer's
`force` parameter as an attempted fix for a related one-off benign error
was tried and reverted - it made Godot's own peer-list bookkeeping
inconsistent, producing hundreds of errors instead of one; verified
empirically rather than assumed.)

Full regression suite, including the net-sim-latency milestone gate,
re-run clean.
2026-08-20 13:27:03 +01:00

98 lines
3.6 KiB
GDScript

extends Node
# Manual two-process smoke test for Phase 2 (tasks 2.1-2.5): match_config,
# server-authoritative simulation, snapshot broadcast, client interpolation.
# Not part of tests/test_runner.tscn — needs real ENet peers and a real
# physics-driven ship. Run:
#
# godot --headless --path Game res://tests/networked_match_smoke.tscn -- --role=host
# godot --headless --path Game res://tests/networked_match_smoke.tscn -- --role=client
const PORT := 7812
const SETTLE_SECONDS := 2.0 # time to let match_config + a few snapshots land before checking spawn state
const DRIVE_SECONDS := 2.0 # time to hold forward thrust and let the ship actually move
const HOST_LIFETIME_SECONDS := 10.0
var _role := ""
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:
print("SMOKE FAIL: host() failed: %s" % error_string(err))
get_tree().quit(1)
return
print("SMOKE: hosting on port %d, waiting for a client to join the roster..." % PORT)
MatchNet.player_joined.connect(_on_host_player_joined)
"client":
MatchNet.local_player_name = "NetTest"
var err := NetworkManager.join("127.0.0.1", PORT)
if err != OK:
print("SMOKE FAIL: join() failed: %s" % error_string(err))
get_tree().quit(1)
return
print("SMOKE: joining ...")
MatchNet.welcomed.connect(_on_client_welcomed)
"client-abuse-malformed", "client-abuse-flood":
# task 3.4's disconnect-abusive-peer paths: joins normally (so
# it's a real connected peer, exactly like a hostile custom
# client would be — the validation doesn't get to assume
# anything about who's on the other end of an authenticated
# connection), then deliberately abuses MatchSim._recv_input
# directly rather than going through networked_match.gd's own
# honest encoder.
MatchNet.local_player_name = "Abuser"
var err := NetworkManager.join("127.0.0.1", PORT)
if err != OK:
print("SMOKE FAIL: join() failed: %s" % error_string(err))
get_tree().quit(1)
return
print("SMOKE: joining to abuse (%s) ..." % _role)
MatchNet.welcomed.connect(_on_abuser_welcomed)
_:
print("SMOKE FAIL: missing or unrecognised --role=")
get_tree().quit(1)
return
func _process(_delta: float) -> void:
NetworkManager.poll()
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
func _on_host_player_joined(_peer_id: int, _name: String) -> void:
MatchNet.player_joined.disconnect(_on_host_player_joined)
print("SMOKE: host loading networked_match.tscn ...")
get_tree().change_scene_to_file.call_deferred("res://scenes/networked_match.tscn")
var hooks := preload("res://tests/networked_match_test_hooks.gd").new()
get_tree().root.add_child.call_deferred(hooks)
hooks.run_host_check.call_deferred(HOST_LIFETIME_SECONDS)
func _on_client_welcomed() -> void:
MatchNet.welcomed.disconnect(_on_client_welcomed)
print("SMOKE: client loading networked_match.tscn ...")
get_tree().change_scene_to_file.call_deferred("res://scenes/networked_match.tscn")
var hooks := preload("res://tests/networked_match_test_hooks.gd").new()
get_tree().root.add_child.call_deferred(hooks)
hooks.run_client_check.call_deferred(SETTLE_SECONDS, DRIVE_SECONDS)
func _on_abuser_welcomed() -> void:
MatchNet.welcomed.disconnect(_on_abuser_welcomed)
var hooks := preload("res://tests/networked_match_test_hooks.gd").new()
get_tree().root.add_child.call_deferred(hooks)
if _role == "client-abuse-malformed":
hooks.run_malformed_abuse_check.call_deferred()
else:
hooks.run_rate_limit_abuse_check.call_deferred()