Files
CosmicClash/Game/tests/networked_match_smoke.gd
T
Josh Creek a5cbc977b5 feat(multiplayer): Phase 5 tasks 5.6-5.10 - disconnects, spectators, replay log
Completes Phase 5's implementation. Every task is verified at 1v1; the
3v3 phase gate itself has not been run and remains outstanding.

5.6/5.7 disconnects: a ship is never despawned. The slot keeps it and
swaps the controller (--fill-bots gives it a bot, the default leaves it
inert per §1.4), sets `stalled` immediately so the nameplate greys out
rather than waiting ~500ms for the abandoned jitter buffer to starve,
and reserves the slot for 30s keyed by player name so a reconnect gets
the same ship back.

5.7 was a real bug, found by the test rather than by review:
SlotInfo.controller was declared RLShipController, but the takeover
swaps in an AIShipController or the base controller - the narrower type
makes that assignment fail its type check, leaving the field pointing at
the controller set_controller() just queue_free()d. It surfaced as
controller_valid=false on the first run. The per-tick action write is
now also gated on `is RLShipController`, since a disconnected slot's bot
drives itself and overwriting it from a starving buffer would pin it to
the departed player's last input.

§6.4's two rules conflict: reserve for 30s, but abort when the last
human leaves. Applied naively the abort wins instantly in a 1v1 and the
reservation can never be redeemed, making reconnect unreachable exactly
when it matters. Abort now waits for no connections AND no outstanding
reservations.

5.8 spectators: a slotless peer spawns no ship and receives the same
snapshot broadcast. HUDController.spectator_mode keeps the clock, score
and goal celebration and hides only the ship instrument cluster - it
previously push_error'd and bailed, leaving a spectator with a dead HUD.
Camera cycles ships in slot order then the ball. --max-spectators caps
it, counted from the live peer list so a dropped spectator cannot leak a
unit of the cap.

5.9 escape respawn: new GameMode._on_bodies_respawned() virtual;
NetworkedMatch bumps reset_gen through Phase 2's deferred path so the
bump and the respawned pose land in the same broadcast. Single-player
modes are unaffected - the base is a no-op.

5.10 replay log: scripts/replay_log.gd, --replay-log=<path>, storing the
wire bytes verbatim in both directions rather than re-serialising - a
re-encode would launder away precisely the malformed payload being
chased. A live 6s match recorded 1115 records (557 inputs / 558
snapshots) and a stored snapshot decodes back to server_tick=100
match_state=WARMUP bodies=2.

Note for future work: --check-only --script is the only thing that
catches a parse error in networked_match.gd, because the unit runner
never loads it. Two separate breakages passed the full unit suite while
breaking every two-process run. A new class_name also needs --import
before it resolves.

Test surface: --role=host-disconnect (three-process 5.6/5.7 scenario),
--match-length=<s>, --replay-log, --fill-bots/--no-fill-bots,
--max-spectators. The ball-contact scenario now steers at the ball with
closed-loop real input instead of a hand-tuned fixed heading, which 5.3
broke by adding KICKOFF_YAW_JITTER; thrusting while turning took it from
2/3 to 5/5.

Regression: 87 unit tests; free-flight LAN p99 0.094m with 0 hard snaps;
transition gate 0.00%; ball contact 5/5; lifecycle goal cycle and full
match to RESULTS/LOBBY; disconnect+reconnect; two-bot CI.
2026-08-21 10:25:15 +01:00

139 lines
5.7 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 DEFAULT_SETTLE_SECONDS := 2.0 # time to let match_config + a few snapshots land before checking spawn state
const DEFAULT_DRIVE_SECONDS := 2.0 # time to hold forward thrust and let the ship actually move
var _role := ""
var _settle_seconds := DEFAULT_SETTLE_SECONDS
var _drive_seconds := DEFAULT_DRIVE_SECONDS
var _exercise_ball_contact := false
var _exercise_free_flight := false
var _exercise_input_transitions := false
var _exercise_match_state := false
var _warmup_seconds := 0.0
func _ready() -> void:
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--role="):
_role = arg.substr("--role=".length())
elif arg.begins_with("--settle-seconds="):
_settle_seconds = maxf(0.5, arg.get_slice("=", 1).to_float())
elif arg.begins_with("--drive-seconds="):
_drive_seconds = maxf(0.5, arg.get_slice("=", 1).to_float())
elif arg == "--exercise-ball-contact":
_exercise_ball_contact = true
elif arg == "--exercise-free-flight":
_exercise_free_flight = true
elif arg == "--exercise-input-transitions":
_exercise_input_transitions = true
elif arg == "--exercise-match-state":
_exercise_match_state = true
elif arg.begins_with("--warmup-seconds="):
_warmup_seconds = maxf(0.0, arg.get_slice("=", 1).to_float())
match _role:
"host-disconnect":
var derr := NetworkManager.host(PORT)
if derr != OK:
print("SMOKE FAIL: host() failed: %s" % error_string(derr))
get_tree().quit(1)
return
print("SMOKE: hosting (disconnect/reconnect scenario) on port %d ..." % PORT)
MatchNet.player_joined.connect(_on_disconnect_host_player_joined)
"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", "client-abuse-flood-dutycycle":
# 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)
# The host must outlive client settle + drive, plus connection/shutdown
# slack. This keeps --drive-seconds useful for sustained prediction QA.
hooks.run_host_check.call_deferred(_settle_seconds + _warmup_seconds + _drive_seconds + 4.0, _exercise_match_state)
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, _exercise_ball_contact, _exercise_free_flight, _warmup_seconds, _exercise_input_transitions, _exercise_match_state)
func _on_disconnect_host_player_joined(_peer_id: int, _name: String) -> void:
MatchNet.player_joined.disconnect(_on_disconnect_host_player_joined)
print("SMOKE: host loading networked_match.tscn (disconnect scenario) ...")
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_disconnect_host_check.call_deferred(_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()
elif _role == "client-abuse-flood-dutycycle":
hooks.run_duty_cycle_flood_abuse_check.call_deferred()
else:
hooks.run_rate_limit_abuse_check.call_deferred()