mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
ff725e1ffa
"Spectate now, take the slot at the next kickoff" was a print statement. The server logged it and never acted; on the client, _is_spectator was assigned once in _on_match_config_received and never revisited - and that handler returns early whenever _slots is non-empty, so no rebroadcast could promote an in-match spectator. The reconnect path only worked because a returning player is a fresh process. Server: late joiners are queued in arrival order and the queue is drained from _begin_kickoff, before the reset transforms are read, so a promoted player's ship is placed by that same kickoff and the controller swap lands on an already-frozen body. A slot is available only once its player has gone AND their 30s reservation has lapsed - §6.4 outranks §6.3, since taking a reserved slot would quietly break the reconnect promise. _abort_if_abandoned now counts a waiting spectator as somebody present, or the one person queued for the slot that just opened is dumped to the lobby at the moment they were about to get it. Client: new broadcast slot_assigned (reliable, channel 0). Broadcast because every client holds its own slot list and one naming the wrong peer keeps flying somebody else's ship as a remote body; reliable because no per-snapshot field would re-converge a client that missed it. The promoted client undoes what made the body remote - fresh interpolator, physics interpolation back on, offsets cleared - and deliberately does not unfreeze, clearing _local_prediction_ready so the next snapshot teleports it to a real authoritative pose first. The controller-attach block moved to _take_local_ownership rather than being copied. New --role=host-latejoin/--role=client-latejoin and --slot-reservation-seconds=. Verified 4/4 both sides: queued, NOT promoted merely because the reservation lapsed, takes the slot at the kickoff, same ship instance, and both peers independently measure ~45.7m under its input. Control with a 90s reservation: kickoff fires, nothing is promoted, the slot still reads the departed player's name.
217 lines
9.2 KiB
GDScript
217 lines
9.2 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-latejoin":
|
|
# §6.3: a spectator takes a vacated slot at the next kickoff. Run
|
|
# with --slot-reservation-seconds= small, a plain `client` that
|
|
# leaves, and a `client-latejoin` watching.
|
|
var lerr := NetworkManager.host(PORT)
|
|
if lerr != OK:
|
|
print("SMOKE FAIL: host() failed: %s" % error_string(lerr))
|
|
get_tree().quit(1)
|
|
return
|
|
print("SMOKE: hosting (late-joiner promotion scenario) on port %d ..." % PORT)
|
|
MatchNet.player_joined.connect(_on_latejoin_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-reconnect":
|
|
# Same name as --role=client on purpose: §6.4 keys the reservation
|
|
# to it. Run this as the SECOND life against --role=host-disconnect,
|
|
# after a plain `client` has joined and dropped.
|
|
MatchNet.local_player_name = "NetTest"
|
|
var rerr := NetworkManager.join("127.0.0.1", PORT)
|
|
if rerr != OK:
|
|
print("SMOKE FAIL: join() failed: %s" % error_string(rerr))
|
|
get_tree().quit(1)
|
|
return
|
|
print("SMOKE: rejoining to reclaim a reserved slot ...")
|
|
MatchNet.welcomed.connect(_on_reconnect_welcomed)
|
|
"client-latejoin":
|
|
# A name nobody reserved, so it starts as a spectator and can only
|
|
# become a player via §6.3's kickoff promotion.
|
|
MatchNet.local_player_name = "LateComer"
|
|
var jerr := NetworkManager.join("127.0.0.1", PORT)
|
|
if jerr != OK:
|
|
print("SMOKE FAIL: join() failed: %s" % error_string(jerr))
|
|
get_tree().quit(1)
|
|
return
|
|
print("SMOKE: joining late, expecting to spectate then be promoted ...")
|
|
MatchNet.welcomed.connect(_on_latejoin_welcomed)
|
|
"client-spectator":
|
|
# A name nobody reserved, so the server has no slot for it.
|
|
MatchNet.local_player_name = "Watcher"
|
|
var serr := NetworkManager.join("127.0.0.1", PORT)
|
|
if serr != OK:
|
|
print("SMOKE FAIL: join() failed: %s" % error_string(serr))
|
|
get_tree().quit(1)
|
|
return
|
|
print("SMOKE: joining as a spectator ...")
|
|
MatchNet.welcomed.connect(_on_spectator_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_latejoin_host_player_joined(_peer_id: int, _name: String) -> void:
|
|
MatchNet.player_joined.disconnect(_on_latejoin_host_player_joined)
|
|
print("SMOKE: host loading networked_match.tscn (late-joiner 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_late_joiner_host_check.call_deferred(_drive_seconds)
|
|
|
|
|
|
func _on_latejoin_welcomed() -> void:
|
|
MatchNet.welcomed.disconnect(_on_latejoin_welcomed)
|
|
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_late_joiner_client_check.call_deferred(_drive_seconds)
|
|
|
|
|
|
func _on_reconnect_welcomed() -> void:
|
|
MatchNet.welcomed.disconnect(_on_reconnect_welcomed)
|
|
print("SMOKE: reconnecting 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_reconnect_client_check.call_deferred(_settle_seconds, _drive_seconds)
|
|
|
|
|
|
func _on_spectator_welcomed() -> void:
|
|
MatchNet.welcomed.disconnect(_on_spectator_welcomed)
|
|
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_spectator_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()
|