mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 18:03:43 +00:00
fix(multiplayer): Phase 5 adversarial review fixes - reconnect, spectators
An adversarial review found five real defects in the Phase 5 lifecycle
work. Two were critical and both were verified against controls.
CRITICAL - a reconnecting client silently became a spectator.
_try_reclaim_slot() swapped slot.peer_id, but MatchSim caches the last
match_config and replays THAT to whoever asks. A reconnecting client in
a fresh process requested config, received the pre-disconnect peer-id
array, could not find itself, left _my_slot null and fell through to the
spectator path - no ship, no input, for the rest of the match. The
evidence was already in my own disconnect-test logs ("no slot for this
peer - spectating", my_slot_ok=false) and I dismissed it: the host-side
check only asserted the SERVER reclaimed the slot, never that the
returning client owned it. Config is now rebroadcast on reclaim.
Verified: my_slot_ok=false -> true.
CRITICAL - spectators received no snapshots at all. §6.3 says a
spectator "receives identical snapshots (the snapshot is already a
broadcast - zero extra server work)". That was only ever true of the
body SEGMENT: _broadcast_snapshot unicasts one packet per SLOT, so a
peer without a slot got nothing - no poses, no reset_gen, no
match_state byte. Spectating was entirely non-functional. The segment is
still shared, so this is one extra send per spectator. Verified against
a control: 0 snapshots and state stuck at LOADING before, 361 snapshots
and PLAYING after.
HIGH - cycling the spectator camera to the ball was a type error.
ShipCameraRig.target is declared `var target: Ship` and the rig reaches
into ship-only API, so it would have fired the moment anyone cycled past
the last ship. Cycling is ships-only; the rig already has its own
ball-cam mode for watching the ball.
MEDIUM - clients never received match_ended or overtime_started. Both
emitted only inside server-side logic, so a client froze and returned to
the lobby without a result and its timer never switched to overtime.
Derived from replicated state instead of adding two more RPCs: the
client already has the authoritative score, and the transition is the
event.
MEDIUM - the goal cinematic ignored its authoritative window. goal_tick
and resume_tick arrived and were unused; the client started a fresh
fixed-length timer on RPC receipt, so a reliable retransmit could run
the celebration past the server's window and into the next kickoff.
_goal_pause_seconds() now returns the time actually remaining, clamped
so an elapsed window cannot produce a non-positive timer.
Also added: a match_bootstrap RPC carrying state, score, clock and
reset_gen to one peer. match_config alone carries arena and roster only,
so a late joiner or reconnecting player had no score or clock until the
next goal happened to fire. It is sent on join AND on every
request_match_config retry - the join-time send has exactly the same
race match_config already had (the server sends it before the peer has
loaded the match scene and connected its listeners), which the control
run exposed: state was reaching PLAYING via the snapshot byte, not the
bootstrap.
New test: --role=client-spectator asserts a slotless peer receives the
snapshot stream, follows the lifecycle, agrees with the wire byte, and
can cycle targets without ever handing the camera a non-Ship. Verified
non-vacuous. The ball-contact steering now closes all the way to 1.2m
instead of coasting from 3m, which was missing the ball outright in
roughly 1 run in 4.
Not fixed, and still open: the 30s slot reservation is keyed on the
player's display name, so any peer can claim a departed player's ship by
choosing their name. §6.2 step 1 reserves auth_ticket for Phase 7; this
needs a real identity token, not a name.
Regression: 87 unit tests; free-flight LAN; transition gate 0.00%; ball
contact 4/4; goal cycle; full match to RESULTS/LOBBY; disconnect and
reconnect; spectator; two-bot CI.
This commit is contained in:
@@ -67,6 +67,16 @@ func _ready() -> void:
|
||||
return
|
||||
print("SMOKE: joining ...")
|
||||
MatchNet.welcomed.connect(_on_client_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
|
||||
@@ -126,6 +136,14 @@ func _on_disconnect_host_player_joined(_peer_id: int, _name: String) -> void:
|
||||
hooks.run_disconnect_host_check.call_deferred(_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()
|
||||
|
||||
@@ -505,8 +505,11 @@ func _drive_at_ball(ship: Ship, ball_body: Node3D, timeout_seconds: float) -> vo
|
||||
if not is_instance_valid(ship) or not is_instance_valid(ball_body):
|
||||
break
|
||||
var to_ball := ball_body.global_position - ship.global_position
|
||||
if to_ball.length() < 3.0:
|
||||
break # close enough that the existing thrust carries it in
|
||||
if to_ball.length() < 1.2:
|
||||
break # touching distance; momentum carries it the rest of the way
|
||||
# Deliberately keeps steering all the way in rather than breaking off
|
||||
# early and coasting: breaking at 3m let the ship sail past the ball
|
||||
# without ever touching it (0 contacts in 1 run of 3).
|
||||
# Bearing in the ship's own frame: -Z is forward, +X is right.
|
||||
var local := ship.global_transform.basis.inverse() * to_ball
|
||||
var yaw_error := atan2(local.x, -local.z)
|
||||
@@ -632,6 +635,55 @@ func run_disconnect_host_check(lifetime_seconds: float) -> void:
|
||||
get_tree().quit(0 if success else 1)
|
||||
|
||||
|
||||
# §6.3 (task 5.8). A peer that joins mid-match with a name nobody reserved is a
|
||||
# spectator: no slot, no ship, but it MUST still receive the snapshot stream
|
||||
# and follow the lifecycle. An adversarial review found spectators received no
|
||||
# snapshots at all, because _broadcast_snapshot unicasts per SLOT.
|
||||
func run_spectator_check(run_seconds: float) -> void:
|
||||
var snapshot_count := [0]
|
||||
MatchSim.snapshot_received.connect(func(_d: Dictionary) -> void: snapshot_count[0] += 1)
|
||||
|
||||
var deadline := Time.get_ticks_msec() + 10000
|
||||
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: spectator never loaded the match scene")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
await get_tree().create_timer(run_seconds).timeout
|
||||
if not _is_networked_match(match_scene):
|
||||
print("SMOKE FAIL: match scene torn down during the spectator run")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
var is_spectator: bool = match_scene._my_slot == null
|
||||
var got_snapshots: bool = snapshot_count[0] > int(run_seconds * 20.0)
|
||||
var camera_ok: bool = is_instance_valid(match_scene._camera_rig)
|
||||
var state_ok: bool = MatchState.is_valid(match_scene.match_state) and match_scene.match_state != MatchState.State.LOBBY
|
||||
# Bootstrap: a late joiner must know the live clock, not wait for a goal.
|
||||
var stats: Dictionary = match_scene.get_net_debug_stats()
|
||||
var wire_state := int(stats.get("snapshot_match_state", -1))
|
||||
var wire_ok: bool = wire_state == int(stats.get("match_state", -2))
|
||||
# Cycling must be safe and must never hand the camera a non-Ship.
|
||||
match_scene.cycle_spectator_target(1)
|
||||
match_scene.cycle_spectator_target(1)
|
||||
match_scene.cycle_spectator_target(-1)
|
||||
var cycle_ok: bool = is_instance_valid(match_scene._camera_rig) and (match_scene._camera_rig.target == null or match_scene._camera_rig.target is Ship)
|
||||
|
||||
print("SMOKE INFO: spectator is_spectator=%s snapshots=%d camera_ok=%s state=%s wire_state=%s cycle_ok=%s" % [
|
||||
str(is_spectator), snapshot_count[0], str(camera_ok), MatchState.to_name(match_scene.match_state),
|
||||
MatchState.to_name(wire_state), str(cycle_ok)
|
||||
])
|
||||
var success := is_spectator and got_snapshots and camera_ok and state_ok and wire_ok and cycle_ok
|
||||
print("SMOKE %s: spectator received the snapshot stream and followed the match (snapshots=%d, want > %d)" % [
|
||||
"PASS" if success else "FAIL", snapshot_count[0], int(run_seconds * 20.0)
|
||||
])
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user