mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
test(multiplayer): grade §6.4's reconnect from the returning player's side
The disconnect scenario only ever asserted the server's bookkeeping, and the client's half was failing every run. run_disconnect_host_check ticked 60 physics frames past the reclaim and then shut the server down, so the reconnecting client - whose wiring check waits a 2.0s settle before it looks at anything - had its peer torn out from under it and reported "current_scene is not NetworkedMatch after 2.0s". The host printed PASS throughout, and the host was the side anyone read. The hold is now a real window (8s), and the host also asserts that the reconnected player's input reaches the server and moves the ship the server owns - every other assertion there is slot bookkeeping that would hold identically for a client whose input pipeline came back dead. Both position and connection state are sampled while the peer is still connected: the client leaves on its own schedule, and an end-of-hold sample reported still_connected=false for a good run. New --role=client-reconnect asserts the returning player is not a spectator, owns a slot with its own peer_id, has a real ship, rejoined a live match with the clock already known (§6.2 step 2's bootstrap), and can still drive. That set is chosen because a stale _last_match_config once made a reconnecting player a spectator, and that bug was visible in this scenario's own logs while it reported PASS. Verified 3/3 both sides. Control: rejoining while the slot is still occupied fails on is_player=false - and since the first control run reported it as the generic "lost its ship mid-drive", the spectator case is now diagnosed before the drive rather than after.
This commit is contained in:
@@ -67,6 +67,18 @@ func _ready() -> void:
|
||||
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-spectator":
|
||||
# A name nobody reserved, so the server has no slot for it.
|
||||
MatchNet.local_player_name = "Watcher"
|
||||
@@ -136,6 +148,15 @@ func _on_disconnect_host_player_joined(_peer_id: int, _name: String) -> void:
|
||||
hooks.run_disconnect_host_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")
|
||||
|
||||
@@ -645,7 +645,14 @@ func _run_free_flight_trace(ship: Ship, start_position: Vector3, duration_second
|
||||
# disconnect and reconnect and asserts the documented contract: the ship is
|
||||
# never despawned, the controller is swapped rather than left dangling, the
|
||||
# slot is reserved by identity, and a returning player gets it back.
|
||||
func run_disconnect_host_check(lifetime_seconds: float) -> void:
|
||||
# hold_after_reclaim_seconds is not padding. The original version ticked 60
|
||||
# physics frames (1.0s) after the reclaim and then shut the server down, which
|
||||
# meant the reconnecting client — whose own wiring check waits a 2.0s settle
|
||||
# before it looks at anything — had its peer torn out from under it every time
|
||||
# and reported "current_scene is not NetworkedMatch after 2.0s". The server side
|
||||
# passed throughout, so the harness looked green from the only side anyone read.
|
||||
# The reconnecting player is half of what §6.4 promises; it gets a real window.
|
||||
func run_disconnect_host_check(lifetime_seconds: float, hold_after_reclaim_seconds: float = 8.0) -> void:
|
||||
await get_tree().create_timer(2.0).timeout
|
||||
var match_scene := get_tree().current_scene
|
||||
if not _is_networked_match(match_scene):
|
||||
@@ -694,15 +701,48 @@ func run_disconnect_host_check(lifetime_seconds: float) -> void:
|
||||
var same_ship: bool = is_instance_valid(match_scene._slots[0].ship) and match_scene._slots[0].ship == ship_before
|
||||
# Ticking on past the swap proves task 5.7: _physics_process writes
|
||||
# slot.controller.action every tick, so a dangling reference from
|
||||
# set_controller()'s queue_free() would have crashed by now.
|
||||
for i in 60:
|
||||
if not _is_networked_match(match_scene):
|
||||
break
|
||||
# set_controller()'s queue_free() would have crashed by now. It also keeps
|
||||
# the server alive long enough for the reconnected client to run its own
|
||||
# checks and actually play — see this function's header.
|
||||
var reclaim_position := Vector3.ZERO
|
||||
if is_instance_valid(match_scene._slots[0].ship):
|
||||
reclaim_position = match_scene._slots[0].ship.global_position
|
||||
# The reconnected player's input must reach the server and move the ship the
|
||||
# server owns. Every other assertion here is about slot bookkeeping and
|
||||
# would hold identically for a client whose input pipeline came back dead —
|
||||
# which is the failure §6.4's reservation exists to prevent.
|
||||
#
|
||||
# Both the position and the connection state are sampled WHILE the peer is
|
||||
# still connected, not once at the end of the hold. The client finishes its
|
||||
# own checks and leaves on its own schedule, so an end-of-hold sample reads
|
||||
# a legitimately departed peer and reports "still_connected=false" for a
|
||||
# perfectly good run — the same mis-timed sampling a Phase 3 review caught
|
||||
# in the CI gate.
|
||||
var saw_connected := false
|
||||
var last_connected_position := reclaim_position
|
||||
var hold_deadline := Time.get_ticks_msec() + int(hold_after_reclaim_seconds * 1000.0)
|
||||
while Time.get_ticks_msec() < hold_deadline and _is_networked_match(match_scene):
|
||||
if match_scene._slots[0].peer_id in multiplayer.get_peers():
|
||||
saw_connected = true
|
||||
if is_instance_valid(match_scene._slots[0].ship):
|
||||
last_connected_position = match_scene._slots[0].ship.global_position
|
||||
await get_tree().physics_frame
|
||||
|
||||
var success := saw_disconnect and ship_survived and controller_valid and reserved and reclaimed and same_ship and is_instance_valid(match_scene._slots[0].controller)
|
||||
print("SMOKE %s: disconnect kept the ship and the reconnect reclaimed the slot (disconnect=%s ship_kept=%s reserved=%s reclaimed=%s same_ship=%s)" % [
|
||||
"PASS" if success else "FAIL", str(saw_disconnect), str(ship_survived), str(reserved), str(reclaimed), str(same_ship)
|
||||
var still_live := _is_networked_match(match_scene)
|
||||
var server_side_movement := Vector2(
|
||||
last_connected_position.x - reclaim_position.x,
|
||||
last_connected_position.z - reclaim_position.z
|
||||
).length()
|
||||
var drove_after_reclaim := saw_connected and server_side_movement > 1.0
|
||||
print("SMOKE INFO: reconnected player moved %.2fm horizontally server-side while connected, over a %.1fs hold (saw_connected=%s)" % [
|
||||
server_side_movement, hold_after_reclaim_seconds, str(saw_connected),
|
||||
])
|
||||
|
||||
var success := saw_disconnect and ship_survived and controller_valid and reserved and reclaimed and same_ship \
|
||||
and still_live and drove_after_reclaim and is_instance_valid(match_scene._slots[0].controller)
|
||||
print("SMOKE %s: disconnect kept the ship and the reconnect reclaimed the slot (disconnect=%s ship_kept=%s reserved=%s reclaimed=%s same_ship=%s still_live=%s drove_after_reclaim=%s)" % [
|
||||
"PASS" if success else "FAIL", str(saw_disconnect), str(ship_survived), str(reserved), str(reclaimed), str(same_ship),
|
||||
str(still_live), str(drove_after_reclaim),
|
||||
])
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(0 if success else 1)
|
||||
@@ -757,6 +797,90 @@ func run_spectator_check(run_seconds: float) -> void:
|
||||
get_tree().quit(0 if success else 1)
|
||||
|
||||
|
||||
# §6.4's reconnect, graded from the RECONNECTING PLAYER's side. The
|
||||
# host-disconnect scenario already asserts the server's bookkeeping — slot
|
||||
# reserved, ship kept, reclaimed by name — but every one of those assertions
|
||||
# holds identically for a client that came back as a spectator, or came back
|
||||
# owning a slot whose input pipeline is dead. Both have happened: a stale
|
||||
# _last_match_config made a reconnecting player a spectator, and that bug was
|
||||
# visible in this scenario's own logs while it reported PASS.
|
||||
#
|
||||
# So this asserts what the returning player actually cares about: I am a
|
||||
# player and not a spectator, I own a slot with a real ship, the match I
|
||||
# rejoined is live with a clock already running (§6.2 step 2's bootstrap — a
|
||||
# reconnecting player must not have to wait for the next goal to learn the
|
||||
# score), and my input still moves my ship.
|
||||
func run_reconnect_client_check(settle_seconds: float, drive_seconds: float) -> void:
|
||||
var deadline := Time.get_ticks_msec() + int(maxf(settle_seconds, 2.0) * 1000.0)
|
||||
while Time.get_ticks_msec() < deadline and not _is_networked_match(get_tree().current_scene):
|
||||
await get_tree().process_frame
|
||||
var match_scene := get_tree().current_scene
|
||||
if not _is_networked_match(match_scene):
|
||||
print("SMOKE FAIL: reconnecting client never loaded the match scene")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
# Play may legitimately be paused for a kickoff or a goal when a client
|
||||
# rejoins, and a frozen ship cannot be driven — wait for live rather than
|
||||
# grading the reconnect on whichever moment it happened to land in.
|
||||
var live_deadline := Time.get_ticks_msec() + 15000
|
||||
while Time.get_ticks_msec() < live_deadline and _is_networked_match(match_scene) and not MatchState.is_live(match_scene.match_state):
|
||||
await get_tree().physics_frame
|
||||
if not _is_networked_match(match_scene):
|
||||
print("SMOKE FAIL: match scene torn down before the reconnecting client could play")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
var my_slot = match_scene._my_slot
|
||||
var is_player: bool = my_slot != null and not match_scene._is_spectator
|
||||
var ship_ok: bool = is_player and is_instance_valid(my_slot.ship)
|
||||
var owns_slot: bool = is_player and my_slot.peer_id == multiplayer.get_unique_id()
|
||||
# Reported before the drive, not after. Coming back as a spectator is the
|
||||
# specific bug this role exists to catch (a stale _last_match_config caused
|
||||
# exactly that), and falling through to the drive would report it as the
|
||||
# generic "lost its ship mid-drive" — which is what a control run, rejoining
|
||||
# while the slot was still occupied, actually printed.
|
||||
if not (is_player and ship_ok and owns_slot):
|
||||
print("SMOKE FAIL: reconnecting player did NOT reclaim a slot — is_player=%s owns_slot=%s ship_ok=%s (came back as a spectator?)" % [
|
||||
str(is_player), str(owns_slot), str(ship_ok)
|
||||
])
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(1)
|
||||
return
|
||||
var state_ok: bool = MatchState.is_live(match_scene.match_state)
|
||||
# The bootstrap half (§6.2 step 2). _end_tick stays -1 on a client nobody
|
||||
# told about the clock, so this is exactly "did my rejoin carry the live
|
||||
# match with it" — a reconnecting player that has to wait for the next goal
|
||||
# to learn the clock and score has not really rejoined the match.
|
||||
var clock_ok: bool = match_scene._end_tick >= 0
|
||||
|
||||
var start_position: Vector3 = my_slot.ship.global_position if ship_ok else Vector3.ZERO
|
||||
Input.action_press("move_forward")
|
||||
await get_tree().create_timer(drive_seconds).timeout
|
||||
Input.action_release("move_forward")
|
||||
|
||||
if not _is_networked_match(match_scene) or not (ship_ok and is_instance_valid(my_slot.ship)):
|
||||
print("SMOKE FAIL: reconnecting client lost its ship or scene mid-drive")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
var end_position: Vector3 = my_slot.ship.global_position
|
||||
# Horizontal only: forward thrust is a horizontal force, and full 3D
|
||||
# distance is satisfiable by gravity alone from the spawn height.
|
||||
var moved := Vector2(end_position.x - start_position.x, end_position.z - start_position.z).length()
|
||||
var moved_ok := moved > 1.0
|
||||
|
||||
print("SMOKE INFO: reconnect is_player=%s owns_slot=%s ship_ok=%s state=%s end_tick=%d moved=%.2fm" % [
|
||||
str(is_player), str(owns_slot), str(ship_ok), MatchState.to_name(match_scene.match_state),
|
||||
match_scene._end_tick, moved,
|
||||
])
|
||||
var success := is_player and owns_slot and ship_ok and state_ok and clock_ok and moved_ok
|
||||
print("SMOKE %s: reconnecting player rejoined as a player and can still drive (player=%s owns_slot=%s clock=%s moved=%.2fm)" % [
|
||||
"PASS" if success else "FAIL", str(is_player), str(owns_slot), str(clock_ok), moved,
|
||||
])
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(0 if success else 1)
|
||||
|
||||
|
||||
func run_malformed_abuse_check() -> void:
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
# A single-element Array, not a plain bool: GDScript lambdas capture
|
||||
|
||||
+5
-1
@@ -1032,9 +1032,13 @@ Fixed by not policing a backlog the server caused: `MatchSim._physics_process` w
|
||||
|
||||
Abuse detection is unweakened and this was checked rather than argued: all three abuse roles still disconnect, and **no flood induced a server stall in any run**, so the grace cannot be farmed by flooding. An attacker who *can* induce server stalls to earn budget already has a strictly worse capability than sending extra input packets.
|
||||
|
||||
**§6.4's reconnect was only ever graded from the server's side, and the client's side was failing the whole time.** `run_disconnect_host_check` ticked 60 physics frames (1.0s) past the reclaim and then shut the server down — so the reconnecting client, whose wiring check waits a 2.0s settle before it looks at anything, had its peer torn out from under it every single run and reported `current_scene is not NetworkedMatch after 2.0s`. The host printed PASS throughout, and the host was the side anyone read. The hold is now a real window (default 8s), and the host additionally asserts that the reconnected player's input reaches the server and moves the ship the server owns — every other assertion there is slot bookkeeping that would hold identically for a client whose input pipeline came back dead, which is the exact failure the reservation exists to prevent. Both the position and the connection state are sampled *while the peer is still connected*, not once at the end of the hold: the client leaves on its own schedule, and an end-of-hold sample reported `still_connected=false` for a perfectly good run — the same mis-timed sampling a Phase 3 review caught in the CI gate.
|
||||
|
||||
New `--role=client-reconnect` grades the returning player: not a spectator, owns a slot whose `peer_id` is its own, has a real ship, rejoined a live match with the clock already known (`_end_tick >= 0` — §6.2 step 2's bootstrap, since a player who must wait for the next goal to learn the score has not really rejoined), and its input still moves its ship. That set is chosen because a stale `_last_match_config` once made a reconnecting player a spectator, and *that bug was visible in this scenario's own logs while it reported PASS*. Verified 3/3 both sides, with a control that rejoins while the slot is still occupied and correctly fails on `is_player=false`. The first version of that control failed with the generic "lost its ship mid-drive", so the spectator case is now reported before the drive rather than after.
|
||||
|
||||
`tools/replay_dump.gd` reads a log back — record counts by kind, plus how much of the input sequence stream actually reached the server once redundancy is counted. It is committed rather than left in a scratch directory because it is what turned "the server dropped some input" into the numbers above, and a log nobody can read is half a feature.
|
||||
|
||||
**New/changed test surface:** `--exercise-match-state` (both roles; host forces a goal, client validates the whole observed sequence and the wire byte), `--role=host-disconnect` for the 5.6/5.7 three-process scenario, `--match-length=<s>` to reach `FULL_TIME` in a short run, `--replay-log=<path>`, `--fill-bots`/`--no-fill-bots`, `--max-spectators=<n>`. The ball-contact scenario now **steers at the ball with closed-loop real input** instead of a hand-tuned fixed-heading burst, which 5.3 broke by adding `KICKOFF_YAW_JITTER` (0 contacts in 3/3 runs); it thrusts while turning rather than hovering to aim, which took it from 2/3 to 5/5.
|
||||
**New/changed test surface:** `--exercise-match-state` (both roles; host forces a goal, client validates the whole observed sequence and the wire byte), `--role=host-disconnect` for the 5.6/5.7 three-process scenario (paired with `--role=client-reconnect`, which grades the returning player), `--match-length=<s>` to reach `FULL_TIME` in a short run, `--replay-log=<path>`, `--fill-bots`/`--no-fill-bots`, `--max-spectators=<n>`. The ball-contact scenario now **steers at the ball with closed-loop real input** instead of a hand-tuned fixed-heading burst, which 5.3 broke by adding `KICKOFF_YAW_JITTER` (0 contacts in 3/3 runs); it thrusts while turning rather than hovering to aim, which took it from 2/3 to 5/5.
|
||||
|
||||
**Phase gate:** a full 3v3 start-to-finish including a mid-match disconnect and a late joiner. **Not yet run** — every scenario above was verified at 1v1 (plus a two-bot CI match). The 3v3 gate needs a real multi-client session and is the outstanding item for this phase, alongside Phase 4's own un-run human playtest.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user