mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
feat(multiplayer): §6.3 late joiners take a vacated slot at the next kickoff
"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.
This commit is contained in:
@@ -50,6 +50,17 @@ func _ready() -> void:
|
||||
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:
|
||||
@@ -79,6 +90,17 @@ func _ready() -> void:
|
||||
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"
|
||||
@@ -148,6 +170,23 @@ func _on_disconnect_host_player_joined(_peer_id: int, _name: String) -> void:
|
||||
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 ...")
|
||||
|
||||
@@ -797,6 +797,206 @@ func run_spectator_check(run_seconds: float) -> void:
|
||||
get_tree().quit(0 if success else 1)
|
||||
|
||||
|
||||
# §6.3's "free slot mid-match → spectate now, take the slot at the next
|
||||
# kickoff", server side. The sequence this drives: a player leaves, their §6.4
|
||||
# reservation lapses (run with --slot-reservation-seconds= small, or this waits
|
||||
# 30 real seconds for the interesting moment), a goal is forced to produce a
|
||||
# kickoff, and the waiting spectator must be holding the slot afterwards.
|
||||
#
|
||||
# The forced goal is the same deterministic trick the CI driver and the
|
||||
# match-state check use — waiting for two peers to score naturally inside a
|
||||
# short run is not something to gate on.
|
||||
func run_late_joiner_host_check(lifetime_seconds: float) -> void:
|
||||
await get_tree().create_timer(2.0).timeout
|
||||
var match_scene := get_tree().current_scene
|
||||
if not _is_networked_match(match_scene):
|
||||
print("SMOKE FAIL: host scene is not NetworkedMatch")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
if match_scene._slots.is_empty():
|
||||
print("SMOKE FAIL: host has no slots — the first client never joined")
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(1)
|
||||
return
|
||||
var original_peer: int = match_scene._slots[0].peer_id
|
||||
var original_name: String = match_scene._slots[0].player_name
|
||||
var ship_before = match_scene._slots[0].ship
|
||||
|
||||
# Wait for the seated player to drop.
|
||||
var drop_deadline := Time.get_ticks_msec() + int(lifetime_seconds * 1000.0)
|
||||
while Time.get_ticks_msec() < drop_deadline and _is_networked_match(match_scene) and not match_scene._slots[0].disconnected:
|
||||
await get_tree().physics_frame
|
||||
if not _is_networked_match(match_scene) or not match_scene._slots[0].disconnected:
|
||||
print("SMOKE FAIL: the seated player never dropped")
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(1)
|
||||
return
|
||||
# A spectator must be queued by now, or the rest of this proves nothing.
|
||||
var queued: int = match_scene._late_joiners.size()
|
||||
|
||||
# Then for the reservation to lapse. Until it does, the slot belongs to the
|
||||
# player who left — §6.4 outranks §6.3, and taking it early would quietly
|
||||
# break the reconnect promise.
|
||||
var lapse_deadline := Time.get_ticks_msec() + int(lifetime_seconds * 1000.0) + 32000
|
||||
while Time.get_ticks_msec() < lapse_deadline and _is_networked_match(match_scene) \
|
||||
and match_scene._slots[0].reserved_until_tick >= 0 \
|
||||
and Engine.get_physics_frames() <= match_scene._slots[0].reserved_until_tick:
|
||||
await get_tree().physics_frame
|
||||
if not _is_networked_match(match_scene):
|
||||
print("SMOKE FAIL: match aborted while the spectator was waiting for the slot")
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(1)
|
||||
return
|
||||
# Nothing may have promoted yet: the reservation lapsing is not a kickoff.
|
||||
var promoted_before_kickoff: bool = not match_scene._slots[0].disconnected
|
||||
var goals: Array = match_scene.arena.get_goals() if match_scene.arena else []
|
||||
if is_instance_valid(match_scene.ball) and not goals.is_empty():
|
||||
match_scene.ball.linear_velocity = Vector3.ZERO
|
||||
match_scene.ball.global_position = goals[0].global_position
|
||||
print("SMOKE INFO: host forced a goal to produce a kickoff")
|
||||
|
||||
var promote_deadline := Time.get_ticks_msec() + 10000
|
||||
while Time.get_ticks_msec() < promote_deadline and _is_networked_match(match_scene) and match_scene._slots[0].disconnected:
|
||||
await get_tree().physics_frame
|
||||
if not _is_networked_match(match_scene):
|
||||
print("SMOKE FAIL: match aborted before the kickoff could promote anyone")
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
var slot = match_scene._slots[0]
|
||||
var took_slot: bool = not slot.disconnected and slot.peer_id != original_peer
|
||||
var renamed: bool = slot.player_name != original_name and slot.player_name != ""
|
||||
var same_ship: bool = is_instance_valid(slot.ship) and slot.ship == ship_before
|
||||
var controller_valid: bool = is_instance_valid(slot.controller)
|
||||
# Not load-bearing on its own: the queue also empties when a waiting peer
|
||||
# gives up and leaves, which is exactly what a control run with a long
|
||||
# reservation showed. took_slot plus the name change is the real evidence.
|
||||
var queue_drained: bool = match_scene._late_joiners.is_empty()
|
||||
print("SMOKE INFO: late joiner queued=%d promoted_before_kickoff=%s took_slot=%s new_name=%s same_ship=%s queue_drained=%s" % [
|
||||
queued, str(promoted_before_kickoff), str(took_slot), slot.player_name, str(same_ship), str(queue_drained)
|
||||
])
|
||||
|
||||
# It must be a real seat, not just a relabelled one: hold on and require
|
||||
# the new owner's input to move the ship the server owns, sampled while
|
||||
# they are still connected.
|
||||
var start_position: Vector3 = slot.ship.global_position if is_instance_valid(slot.ship) else Vector3.ZERO
|
||||
var last_connected_position := start_position
|
||||
var saw_connected := false
|
||||
var hold_deadline := Time.get_ticks_msec() + 8000
|
||||
while Time.get_ticks_msec() < hold_deadline and _is_networked_match(match_scene):
|
||||
if slot.peer_id in multiplayer.get_peers():
|
||||
saw_connected = true
|
||||
if is_instance_valid(slot.ship):
|
||||
last_connected_position = slot.ship.global_position
|
||||
await get_tree().physics_frame
|
||||
var moved := Vector2(last_connected_position.x - start_position.x, last_connected_position.z - start_position.z).length()
|
||||
var drove: bool = saw_connected and moved > 1.0
|
||||
|
||||
var success := queued > 0 and not promoted_before_kickoff and took_slot and renamed \
|
||||
and same_ship and controller_valid and queue_drained and drove
|
||||
print("SMOKE %s: late joiner took the vacated slot at the kickoff (queued=%d waited_for_kickoff=%s took_slot=%s same_ship=%s drove=%.2fm)" % [
|
||||
"PASS" if success else "FAIL", queued, str(not promoted_before_kickoff), str(took_slot), str(same_ship), moved
|
||||
])
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(0 if success else 1)
|
||||
|
||||
|
||||
# The same promotion from the SPECTATOR's side. It must start with no slot,
|
||||
# gain one without reloading the scene, and be able to fly it — the client's
|
||||
# _is_spectator was assigned once at match_config time and never revisited,
|
||||
# so "the server promoted me" and "I can actually play" are separate claims.
|
||||
func run_late_joiner_client_check(lifetime_seconds: float) -> void:
|
||||
var load_deadline := Time.get_ticks_msec() + 10000
|
||||
while Time.get_ticks_msec() < load_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: late joiner never loaded the match scene")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
var started_spectating: bool = match_scene._my_slot == null and match_scene._is_spectator
|
||||
if not started_spectating:
|
||||
print("SMOKE FAIL: late joiner was given a slot immediately — it should spectate until a kickoff (my_slot=%s is_spectator=%s)" % [
|
||||
str(match_scene._my_slot != null), str(match_scene._is_spectator)
|
||||
])
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
var promote_deadline := Time.get_ticks_msec() + int(lifetime_seconds * 1000.0) + 42000
|
||||
while Time.get_ticks_msec() < promote_deadline and _is_networked_match(match_scene) and match_scene._my_slot == null:
|
||||
await get_tree().physics_frame
|
||||
if not _is_networked_match(match_scene):
|
||||
print("SMOKE FAIL: match scene torn down before the late joiner was promoted")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
var promoted: bool = match_scene._my_slot != null and not match_scene._is_spectator
|
||||
if not promoted:
|
||||
print("SMOKE FAIL: late joiner never got a slot (my_slot=%s is_spectator=%s)" % [
|
||||
str(match_scene._my_slot != null), str(match_scene._is_spectator)
|
||||
])
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
# Wait for live play — a promotion lands at a kickoff, so the very next
|
||||
# thing is a countdown with every body frozen.
|
||||
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
|
||||
var my_slot = match_scene._my_slot
|
||||
var owns_slot: bool = my_slot != null and my_slot.peer_id == multiplayer.get_unique_id()
|
||||
var ship_ok: bool = my_slot != null and is_instance_valid(my_slot.ship)
|
||||
# The promoted ship was a REMOTE body a moment ago: frozen kinematic and fed
|
||||
# by the interpolator. Promotion deliberately does NOT unfreeze it on the
|
||||
# spot — it waits for the first authoritative pose, exactly as a fresh
|
||||
# client does — so this WAITS for prediction to start rather than sampling
|
||||
# at whichever frame the state happened to go live. Sampling immediately is
|
||||
# a race the run loses about half the time, reporting predicting=false on a
|
||||
# client that then flew 45m perfectly well.
|
||||
# Poll the whole condition, not _local_prediction_ready alone. Unfreezing is
|
||||
# QUEUED and applied on the body's own next _integrate_forces (task 0.15),
|
||||
# so there is a real window where the state is PLAYING and the flag is set
|
||||
# but ship.freeze has not flipped yet — sampling on that frame reported
|
||||
# predicting=false for a client that then flew 45m, twice in five runs.
|
||||
var predicting := false
|
||||
var predict_deadline := Time.get_ticks_msec() + 5000
|
||||
while Time.get_ticks_msec() < predict_deadline and _is_networked_match(match_scene):
|
||||
predicting = ship_ok and match_scene._local_prediction_ready \
|
||||
and not my_slot.ship.freeze and not my_slot.interpolator.has_samples()
|
||||
if predicting:
|
||||
break
|
||||
await get_tree().physics_frame
|
||||
var controller_ok: bool = ship_ok and my_slot.ship.controller != null and my_slot.ship.controller.get_parent() == my_slot.ship
|
||||
|
||||
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(3.0).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: promoted client lost its ship or scene mid-drive")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
var end_position: Vector3 = my_slot.ship.global_position
|
||||
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: promotion spectated_first=%s owns_slot=%s ship_ok=%s predicting=%s (ready=%s frozen=%s interp_samples=%s state=%s) controller_ok=%s moved=%.2fm" % [
|
||||
str(started_spectating), str(owns_slot), str(ship_ok), str(predicting),
|
||||
str(match_scene._local_prediction_ready), str(ship_ok and my_slot.ship.freeze),
|
||||
str(ship_ok and my_slot.interpolator.has_samples()), MatchState.to_name(match_scene.match_state),
|
||||
str(controller_ok), moved
|
||||
])
|
||||
var success := started_spectating and promoted and owns_slot and ship_ok and predicting and controller_ok and moved_ok
|
||||
print("SMOKE %s: spectator was promoted to player and can fly the slot it inherited (moved=%.2fm)" % [
|
||||
"PASS" if success else "FAIL", moved
|
||||
])
|
||||
NetworkManager.shutdown()
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user