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:
@@ -38,6 +38,8 @@ signal kickoff_received(positions: PackedVector3Array, rotations: PackedFloat32A
|
||||
signal goal_scored_received(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int)
|
||||
signal clock_state_received(running: bool, end_tick: int, remaining_ticks: int, at_tick: int)
|
||||
signal match_bootstrap_received(state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int)
|
||||
# §6.3 task 5.8: a spectator has been given a vacated slot at a kickoff.
|
||||
signal slot_assigned_received(peer_id: int, slot_index: int)
|
||||
|
||||
# Input validation (multiplayer-todo.md §3.1 steps 2-3, task 3.4). Deliberately
|
||||
# lives here rather than in NetworkedMatch: framing/rate abuse is a protocol-
|
||||
@@ -314,11 +316,26 @@ func send_match_bootstrap(peer_id: int, state: int, at_tick: int, score: Diction
|
||||
_match_bootstrap.rpc_id(peer_id, state, at_tick, score, end_tick, clock_running, reset_gen, remaining_ticks)
|
||||
|
||||
|
||||
# §6.3's late-joiner promotion. BROADCAST, not addressed to the new owner
|
||||
# alone: every client holds its own copy of the slot list, and a peer_id that
|
||||
# only the promoted client learns about leaves everyone else's copy naming a
|
||||
# player who is no longer in that seat. Reliable channel 0 — a client that
|
||||
# misses this keeps flying somebody else's ship as a remote body forever, and
|
||||
# unlike match_state there is no per-snapshot field that would re-converge it.
|
||||
func send_slot_assigned(peer_id: int, slot_index: int) -> void:
|
||||
_slot_assigned.rpc(peer_id, slot_index)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func _match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void:
|
||||
match_config_received.emit(arena_path, peer_ids, teams, spawn_indices)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func _slot_assigned(peer_id: int, slot_index: int) -> void:
|
||||
slot_assigned_received.emit(peer_id, slot_index)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", 0)
|
||||
func _request_match_config() -> void:
|
||||
if not multiplayer.is_server() or _last_match_config.is_empty():
|
||||
|
||||
+179
-28
@@ -303,6 +303,9 @@ var _client_goal_resume_tick := -1
|
||||
# §6.3 (task 5.8), client only.
|
||||
var _is_spectator := false
|
||||
var _spectator_target_index := 0
|
||||
# §6.3, server only. Peers that joined mid-match with no slot to reclaim, in
|
||||
# arrival order, waiting for the next kickoff to hand them a vacated slot.
|
||||
var _late_joiners: Array[Dictionary] = []
|
||||
# §6.3's "cap with --max-spectators". Server only; 0 disables spectating
|
||||
# entirely, negative means unlimited.
|
||||
var _max_spectators := -1
|
||||
@@ -335,6 +338,8 @@ func _ready() -> void:
|
||||
_replay_log = null
|
||||
else:
|
||||
print("NetworkedMatch: recording replay log to %s" % replay_path)
|
||||
elif arg.begins_with("--slot-reservation-seconds="):
|
||||
_slot_reservation_seconds = maxf(0.0, arg.get_slice("=", 1).to_float())
|
||||
elif arg.begins_with("--match-length="):
|
||||
# Regulation is 150s; a smoke test cannot wait that long to see
|
||||
# FULL_TIME/OVERTIME/RESULTS, so allow an override. Server-side
|
||||
@@ -363,6 +368,7 @@ func _ready() -> void:
|
||||
MatchSim.goal_scored_received.connect(_on_goal_scored_received)
|
||||
MatchSim.clock_state_received.connect(_on_clock_state_received)
|
||||
MatchSim.match_bootstrap_received.connect(_on_match_bootstrap_received)
|
||||
MatchSim.slot_assigned_received.connect(_on_slot_assigned)
|
||||
# lobby.gd does this; the match scene never did. Without it a client
|
||||
# whose host exits stays in a dead match forever, emitting thousands of
|
||||
# "multiplayer instance isn't currently active" / "RPC via a peer which
|
||||
@@ -676,6 +682,9 @@ func _apply_match_state(new_state: int, at_tick: int) -> void:
|
||||
# needs both sides to consume the stream in identical order forever and the
|
||||
# first randf() anyone adds to the reset path desyncs kickoff silently.
|
||||
func _begin_kickoff() -> void:
|
||||
# §6.3: before the reset, so a promoted player's ship is placed by this very
|
||||
# kickoff rather than left wherever its previous owner abandoned it.
|
||||
_promote_late_joiners()
|
||||
reset_ball()
|
||||
reset_ships()
|
||||
# Bump before the broadcast so the kickoff and the reset_gen it announces
|
||||
@@ -1077,19 +1086,28 @@ func _on_goal_registered(conceding_team: int) -> void:
|
||||
# --- §6.4 disconnects and reconnects (tasks 5.6/5.7) -----------------------
|
||||
|
||||
const SLOT_RESERVATION_SECONDS := 30.0
|
||||
# Server only, --slot-reservation-seconds=. §6.3's promotion can only happen at
|
||||
# a kickoff AFTER the departed player's reservation lapses, so a smoke test of
|
||||
# it would otherwise have to run for over half a minute before the interesting
|
||||
# moment. Same rationale and same shape as --match-length: a server-side
|
||||
# override, never something a client can shorten for anyone.
|
||||
var _slot_reservation_seconds := SLOT_RESERVATION_SECONDS
|
||||
|
||||
|
||||
func _on_client_disconnected(peer_id: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
# A spectator waiting for a slot can leave too, and a queue entry for a
|
||||
# departed peer would hand the next free slot to nobody.
|
||||
_forget_late_joiner(peer_id)
|
||||
for slot in _slots:
|
||||
if slot.peer_id != peer_id or slot.disconnected:
|
||||
continue
|
||||
slot.disconnected = true
|
||||
slot.reserved_until_tick = Engine.get_physics_frames() + int(SLOT_RESERVATION_SECONDS * SimConstants.TICK_HZ)
|
||||
slot.reserved_until_tick = Engine.get_physics_frames() + int(_slot_reservation_seconds * SimConstants.TICK_HZ)
|
||||
_swap_slot_controller(slot, _build_takeover_controller())
|
||||
print("NetworkedMatch: peer %d (%s) disconnected; ship kept, slot reserved for %.0fs" % [
|
||||
peer_id, slot.player_name, SLOT_RESERVATION_SECONDS
|
||||
peer_id, slot.player_name, _slot_reservation_seconds
|
||||
])
|
||||
break
|
||||
_abort_if_abandoned()
|
||||
@@ -1111,6 +1129,14 @@ func _abort_if_abandoned() -> void:
|
||||
return # somebody is still playing
|
||||
if slot.reserved_until_tick >= 0 and now <= slot.reserved_until_tick:
|
||||
return # somebody may still come back
|
||||
# §6.3's queue counts as "somebody is still here" for the same reason the
|
||||
# reservation does. Without this, a spectator waiting for the slot that just
|
||||
# opened up is dumped back to the lobby at the exact moment they were about
|
||||
# to get it — and they are a connected human watching a live match, which is
|
||||
# not what "abandoned" means.
|
||||
for entry in _late_joiners:
|
||||
if int(entry["peer_id"]) in multiplayer.get_peers():
|
||||
return
|
||||
print("NetworkedMatch: no players left and no reservations outstanding, aborting to lobby")
|
||||
_set_match_state(MatchState.State.LOBBY)
|
||||
get_tree().change_scene_to_file.call_deferred(ScenePaths.LOBBY)
|
||||
@@ -1192,9 +1218,75 @@ func _on_player_joined_midmatch(peer_id: int, player_name: String) -> void:
|
||||
# §6.3: a spectator/late joiner reconstructs from this, since match_config
|
||||
# carries arena and roster only — no score, clock or match state.
|
||||
_send_match_bootstrap(peer_id)
|
||||
# "Spectate now, take the slot at the next kickoff" — queued here, acted on
|
||||
# in _promote_late_joiners(). Queued in arrival order and consumed from the
|
||||
# front, so waiting is first-come-first-served rather than whichever slot
|
||||
# index happens to free up first.
|
||||
_late_joiners.append({"peer_id": peer_id, "player_name": player_name})
|
||||
print("NetworkedMatch: peer %d (%s) joined mid-match; spectating until the next kickoff" % [peer_id, player_name])
|
||||
|
||||
|
||||
# §6.3's "free slot mid-match → spectate now, take the slot at the next
|
||||
# kickoff". Called from _begin_kickoff BEFORE the reset transforms are read, so
|
||||
# a promoted player's ship is placed by the same kickoff everyone else gets and
|
||||
# the controller swap lands on an already-frozen body — which is the whole
|
||||
# reason the spec puts it at a kickoff boundary rather than mid-play.
|
||||
#
|
||||
# A slot is available when its player has gone AND their 30s reservation has
|
||||
# lapsed (§6.4). Taking a still-reserved slot would quietly break the reconnect
|
||||
# promise, so the reservation always outranks the queue.
|
||||
func _promote_late_joiners() -> void:
|
||||
if not multiplayer.is_server() or _late_joiners.is_empty():
|
||||
return
|
||||
var connected := multiplayer.get_peers()
|
||||
# A queued joiner may have left again while waiting. Drop them here rather
|
||||
# than handing a slot to a peer that no longer exists — which would look
|
||||
# exactly like an occupied slot nobody is playing.
|
||||
var waiting: Array[Dictionary] = []
|
||||
for entry in _late_joiners:
|
||||
if int(entry["peer_id"]) in connected:
|
||||
waiting.append(entry)
|
||||
_late_joiners = waiting
|
||||
|
||||
var now := Engine.get_physics_frames()
|
||||
var promoted := false
|
||||
for index in _slots.size():
|
||||
if _late_joiners.is_empty():
|
||||
break
|
||||
var slot := _slots[index]
|
||||
if not slot.disconnected:
|
||||
continue
|
||||
if slot.reserved_until_tick >= 0 and now <= slot.reserved_until_tick:
|
||||
continue
|
||||
var joiner: Dictionary = _late_joiners.pop_front()
|
||||
var joiner_peer := int(joiner["peer_id"])
|
||||
slot.peer_id = joiner_peer
|
||||
slot.player_name = String(joiner["player_name"])
|
||||
slot.disconnected = false
|
||||
slot.reserved_until_tick = -1
|
||||
# Same reasoning as the reclaim path: the arriving client numbers its
|
||||
# input sequence from scratch, and the old cursor belongs to a different
|
||||
# epoch entirely (see input_jitter_buffer.gd's seeding comment).
|
||||
slot.jitter_buffer = InputJitterBuffer.new()
|
||||
slot.consecutive_seq_rejects = 0
|
||||
_swap_slot_controller(slot, RLShipController.new())
|
||||
MatchSim.send_slot_assigned(joiner_peer, index)
|
||||
promoted = true
|
||||
print("NetworkedMatch: peer %d (%s) took slot %d at the kickoff" % [joiner_peer, slot.player_name, index])
|
||||
if promoted:
|
||||
# Same cache hazard the reclaim path documents: MatchSim replays the
|
||||
# last match_config to anyone who asks, and it now names the wrong peer
|
||||
# for this slot.
|
||||
_rebroadcast_match_config()
|
||||
|
||||
|
||||
func _forget_late_joiner(peer_id: int) -> void:
|
||||
for i in _late_joiners.size():
|
||||
if int(_late_joiners[i]["peer_id"]) == peer_id:
|
||||
_late_joiners.remove_at(i)
|
||||
return
|
||||
|
||||
|
||||
# Connected peers that hold no slot. Counted from the live peer list rather
|
||||
# than tracked incrementally, so a spectator that drops cannot leak a unit of
|
||||
# the cap permanently.
|
||||
@@ -1437,37 +1529,96 @@ func _on_match_config_received(arena_path: String, peer_ids: PackedInt32Array, t
|
||||
print("NetworkedMatch: no slot for this peer — spectating (%d ship(s) + ball)" % _slots.size())
|
||||
if is_instance_valid(_my_slot) and is_instance_valid(_my_slot.ship):
|
||||
spawn_camera_rig(_my_slot.ship)
|
||||
_my_slot.ship.ball_contact.connect(_on_local_ball_contact)
|
||||
# Headless training ships intentionally do not install Ship's render-side
|
||||
# body_entered signal. Attach this client-only callback only to the
|
||||
# locally predicted match ship so contact QA sees the same event without
|
||||
# changing training instances.
|
||||
if DisplayServer.get_name() == "headless":
|
||||
_my_slot.ship.body_entered.connect(_on_local_ship_body_entered)
|
||||
if not _test_bot_model_path.is_empty():
|
||||
# --test-bot (task 3.6): attach a real
|
||||
# AIShipController. Unlike PlayerShipController, this one needs
|
||||
# real scene context (get_parent() as Ship for itself, plus
|
||||
# ball/teammate/opponent discovery via groups) — Ship.set_controller()
|
||||
# parents it correctly, satisfying that. Known limitation: this
|
||||
# local bot controller to the genuinely simulated local ship.
|
||||
var bot := AIShipController.new()
|
||||
bot.model_path = _test_bot_model_path
|
||||
_my_slot.ship.add_child(bot)
|
||||
_local_input_timeline = LocalInputTimeline.new()
|
||||
_local_net_controller = LocalNetShipController.new(bot, _local_input_timeline)
|
||||
_my_slot.ship.set_controller(_local_net_controller)
|
||||
else:
|
||||
var player := PlayerShipController.new()
|
||||
_local_input_timeline = LocalInputTimeline.new()
|
||||
_local_net_controller = LocalNetShipController.new(player, _local_input_timeline)
|
||||
_local_net_controller.add_child(player)
|
||||
_my_slot.ship.set_controller(_local_net_controller)
|
||||
_take_local_ownership(_my_slot)
|
||||
# The roster now exists, so a kickoff that raced ahead of match_config can
|
||||
# finally be placed against the right bodies.
|
||||
_apply_pending_kickoff()
|
||||
|
||||
|
||||
# Client only. Everything that makes one of the spawned ships THIS peer's own:
|
||||
# contact hooks and the local input controller. Factored out of
|
||||
# _on_match_config_received because §6.3's late-joiner promotion needs the
|
||||
# identical setup at a completely different moment, and a second copy of it
|
||||
# would be a copy that silently drifts.
|
||||
func _take_local_ownership(slot: SlotInfo) -> void:
|
||||
slot.ship.ball_contact.connect(_on_local_ball_contact)
|
||||
# Headless training ships intentionally do not install Ship's render-side
|
||||
# body_entered signal. Attach this client-only callback only to the
|
||||
# locally predicted match ship so contact QA sees the same event without
|
||||
# changing training instances.
|
||||
if DisplayServer.get_name() == "headless":
|
||||
slot.ship.body_entered.connect(_on_local_ship_body_entered)
|
||||
if not _test_bot_model_path.is_empty():
|
||||
# --test-bot (task 3.6): attach a real
|
||||
# AIShipController. Unlike PlayerShipController, this one needs
|
||||
# real scene context (get_parent() as Ship for itself, plus
|
||||
# ball/teammate/opponent discovery via groups) — Ship.set_controller()
|
||||
# parents it correctly, satisfying that. Known limitation: this
|
||||
# local bot controller to the genuinely simulated local ship.
|
||||
var bot := AIShipController.new()
|
||||
bot.model_path = _test_bot_model_path
|
||||
slot.ship.add_child(bot)
|
||||
_local_input_timeline = LocalInputTimeline.new()
|
||||
_local_net_controller = LocalNetShipController.new(bot, _local_input_timeline)
|
||||
slot.ship.set_controller(_local_net_controller)
|
||||
else:
|
||||
var player := PlayerShipController.new()
|
||||
_local_input_timeline = LocalInputTimeline.new()
|
||||
_local_net_controller = LocalNetShipController.new(player, _local_input_timeline)
|
||||
_local_net_controller.add_child(player)
|
||||
slot.ship.set_controller(_local_net_controller)
|
||||
|
||||
|
||||
# §6.3 (task 5.8), client only: the server has handed this peer a vacated slot
|
||||
# at a kickoff. Broadcast, so every client runs the first half — their own copy
|
||||
# of the slot list must name the new owner — and only the promoted peer runs
|
||||
# the second.
|
||||
func _on_slot_assigned(peer_id: int, slot_index: int) -> void:
|
||||
if multiplayer.is_server() or slot_index < 0 or slot_index >= _slots.size():
|
||||
return
|
||||
var slot := _slots[slot_index]
|
||||
slot.peer_id = peer_id
|
||||
if peer_id != multiplayer.get_unique_id() or not _is_spectator:
|
||||
return
|
||||
|
||||
# This body has been a REMOTE one until now: driven by transform writes from
|
||||
# the interpolator, with Godot's own physics interpolation switched off so
|
||||
# those writes could not fight it (§4.6). Both have to be undone, and the
|
||||
# interpolator emptied — its buffered samples describe the previous owner's
|
||||
# flight and would otherwise be smoothed into the first predicted frames.
|
||||
_my_slot = slot
|
||||
_is_spectator = false
|
||||
slot.interpolator = NetInterpolator.new()
|
||||
slot.visual_smoother_reset = true
|
||||
slot.visual_position_offset = Vector3.ZERO
|
||||
slot.visual_rotation_offset = Quaternion.IDENTITY
|
||||
if is_instance_valid(slot.ship) and is_instance_valid(slot.ship.visual):
|
||||
slot.ship.visual.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_INHERIT
|
||||
# Stay frozen until the first authoritative pose arrives, exactly as a fresh
|
||||
# client does — _on_snapshot_received teleports to it, unfreezes, and starts
|
||||
# prediction. Unfreezing here instead would predict from whatever pose the
|
||||
# interpolator last wrote, which is a render-side approximation.
|
||||
_local_prediction_ready = false
|
||||
_input_seq = 0
|
||||
# Same call the reset path uses: everything recorded so far belongs to a
|
||||
# peer that was not simulating anything.
|
||||
_local_prediction_history.begin_epoch()
|
||||
_pending_local_reconciliation = {}
|
||||
_take_local_ownership(slot)
|
||||
# The HUD was built in spectator mode, which hides the ship instruments and
|
||||
# wires nothing to a ship. It reads spectator_mode once, a frame after
|
||||
# _ready, so flipping the flag on the live instance does nothing — rebuild.
|
||||
if is_instance_valid(hud):
|
||||
hud.queue_free()
|
||||
_spawn_hud()
|
||||
if is_instance_valid(_camera_rig):
|
||||
_camera_rig.target = slot.ship
|
||||
hud.ship = slot.ship
|
||||
else:
|
||||
spawn_camera_rig(slot.ship)
|
||||
print("NetworkedMatch: promoted from spectator to player in slot %d" % slot_index)
|
||||
|
||||
|
||||
func _spawn_hud() -> void:
|
||||
hud = HUD_SCENE.instantiate()
|
||||
# BEFORE add_child: HUDController reads this in _initialize_hud(), which
|
||||
|
||||
Reference in New Issue
Block a user