mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
feat(multiplayer): Phase 5 tasks 5.6-5.10 - disconnects, spectators, replay log
Completes Phase 5's implementation. Every task is verified at 1v1; the 3v3 phase gate itself has not been run and remains outstanding. 5.6/5.7 disconnects: a ship is never despawned. The slot keeps it and swaps the controller (--fill-bots gives it a bot, the default leaves it inert per §1.4), sets `stalled` immediately so the nameplate greys out rather than waiting ~500ms for the abandoned jitter buffer to starve, and reserves the slot for 30s keyed by player name so a reconnect gets the same ship back. 5.7 was a real bug, found by the test rather than by review: SlotInfo.controller was declared RLShipController, but the takeover swaps in an AIShipController or the base controller - the narrower type makes that assignment fail its type check, leaving the field pointing at the controller set_controller() just queue_free()d. It surfaced as controller_valid=false on the first run. The per-tick action write is now also gated on `is RLShipController`, since a disconnected slot's bot drives itself and overwriting it from a starving buffer would pin it to the departed player's last input. §6.4's two rules conflict: reserve for 30s, but abort when the last human leaves. Applied naively the abort wins instantly in a 1v1 and the reservation can never be redeemed, making reconnect unreachable exactly when it matters. Abort now waits for no connections AND no outstanding reservations. 5.8 spectators: a slotless peer spawns no ship and receives the same snapshot broadcast. HUDController.spectator_mode keeps the clock, score and goal celebration and hides only the ship instrument cluster - it previously push_error'd and bailed, leaving a spectator with a dead HUD. Camera cycles ships in slot order then the ball. --max-spectators caps it, counted from the live peer list so a dropped spectator cannot leak a unit of the cap. 5.9 escape respawn: new GameMode._on_bodies_respawned() virtual; NetworkedMatch bumps reset_gen through Phase 2's deferred path so the bump and the respawned pose land in the same broadcast. Single-player modes are unaffected - the base is a no-op. 5.10 replay log: scripts/replay_log.gd, --replay-log=<path>, storing the wire bytes verbatim in both directions rather than re-serialising - a re-encode would launder away precisely the malformed payload being chased. A live 6s match recorded 1115 records (557 inputs / 558 snapshots) and a stored snapshot decodes back to server_tick=100 match_state=WARMUP bodies=2. Note for future work: --check-only --script is the only thing that catches a parse error in networked_match.gd, because the unit runner never loads it. Two separate breakages passed the full unit suite while breaking every two-process run. A new class_name also needs --import before it resolves. Test surface: --role=host-disconnect (three-process 5.6/5.7 scenario), --match-length=<s>, --replay-log, --fill-bots/--no-fill-bots, --max-spectators. The ball-contact scenario now steers at the ball with closed-loop real input instead of a hand-tuned fixed heading, which 5.3 broke by adding KICKOFF_YAW_JITTER; thrusting while turning took it from 2/3 to 5/5. Regression: 87 unit tests; free-flight LAN p99 0.094m with 0 hard snaps; transition gate 0.00%; ball contact 5/5; lifecycle goal cycle and full match to RESULTS/LOBBY; disconnect+reconnect; two-bot CI.
This commit is contained in:
@@ -30,6 +30,11 @@ class_name HUDController
|
||||
@onready var camera_mode_label = get_node_or_null("Control/Instruments/Cluster/CameraModeLabel")
|
||||
|
||||
var ship: Node
|
||||
# §6.3 (task 5.8). Set by the game mode BEFORE this node enters the tree when
|
||||
# the local peer has no ship of its own. Distinct from `ship == null` by
|
||||
# accident: a missing ship is still an error for a player, and silently
|
||||
# degrading to a spectator HUD would hide that.
|
||||
var spectator_mode := false
|
||||
var _last_score := {0: 0, 1: 0}
|
||||
var _goal_tween: Tween
|
||||
|
||||
@@ -43,6 +48,16 @@ func _initialize_hud():
|
||||
# this runs — not discovered via group, since the "ship" group can have
|
||||
# 2+ members and there's no reliable way to tell which one is "ours".
|
||||
if not ship:
|
||||
# §6.3 (task 5.8): a spectator legitimately has no ship of its own, and
|
||||
# must still get the score, clock and goal celebration. Only the
|
||||
# per-ship instrument cluster is meaningless without one, so hide that
|
||||
# and carry on wiring everything else — this used to push_error and
|
||||
# bail, which left a spectator with a completely dead HUD.
|
||||
if spectator_mode:
|
||||
print("HUDController: spectator mode — hiding ship instruments")
|
||||
_hide_ship_instruments()
|
||||
_connect_mode_signals()
|
||||
return
|
||||
push_error("HUDController: No ship assigned")
|
||||
return
|
||||
|
||||
@@ -59,6 +74,24 @@ func _initialize_hud():
|
||||
if camera_rig and camera_rig.has_signal("camera_mode_changed"):
|
||||
camera_rig.camera_mode_changed.connect(_on_ship_camera_mode_changed)
|
||||
|
||||
_connect_mode_signals()
|
||||
|
||||
|
||||
func _hide_ship_instruments() -> void:
|
||||
# The per-ship cluster (speed, altitude, thrust, boost, camera mode) has no
|
||||
# meaning without a ship. Everything else on the HUD still does.
|
||||
for node in [speed_gauge, altitude_gauge, camera_mode_label]:
|
||||
if node and is_instance_valid(node):
|
||||
node.visible = false
|
||||
var cluster := get_node_or_null("Control/Instruments/Cluster")
|
||||
if cluster and is_instance_valid(cluster):
|
||||
cluster.visible = false
|
||||
|
||||
|
||||
# Everything that depends on the MODE rather than on owning a ship: clock,
|
||||
# score, team identity, match-ended, kickoff countdown. A spectator gets all
|
||||
# of it.
|
||||
func _connect_mode_signals() -> void:
|
||||
# Connect to game manager's timer signal; modes without a timer
|
||||
# (e.g. free play) just don't show one
|
||||
var game_manager = get_tree().get_first_node_in_group("game")
|
||||
|
||||
@@ -297,13 +297,26 @@ func _physics_process(_delta: float) -> void:
|
||||
|
||||
|
||||
func _respawn_escaped_bodies() -> void:
|
||||
var respawned := false
|
||||
for ship in ships:
|
||||
if is_instance_valid(ship) and _is_escaped(ship.global_position):
|
||||
push_warning("GameMode: ship escaped the enclosed arena — check boundary colliders")
|
||||
_reset_body(ship, _ship_spawn_transforms[ship])
|
||||
respawned = true
|
||||
if is_instance_valid(ball) and _is_escaped(ball.global_position):
|
||||
push_warning("GameMode: ball escaped the enclosed arena — check boundary colliders")
|
||||
_reset_body(ball, arena.get_ball_spawn())
|
||||
respawned = true
|
||||
if respawned:
|
||||
_on_bodies_respawned()
|
||||
|
||||
|
||||
# Virtual (task 5.9). An escape respawn is a teleport, and a networked client
|
||||
# interpolating toward it would smoothly slide a body the width of the arena
|
||||
# and then fight the correction. NetworkedMatch overrides this to bump
|
||||
# reset_gen so clients hard-snap instead. Single-player modes need nothing.
|
||||
func _on_bodies_respawned() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func _is_escaped(position: Vector3) -> bool:
|
||||
|
||||
@@ -264,6 +264,11 @@ func _recv_input(bytes: PackedByteArray) -> void:
|
||||
return
|
||||
|
||||
var decoded := NetCodec.unpack_input(bytes)
|
||||
# Carry the verbatim wire bytes alongside the decode. Task 5.10's replay
|
||||
# log stores exactly what arrived rather than a re-serialisation, which is
|
||||
# the whole reason it can reproduce a reported snap: a re-encode would
|
||||
# launder away precisely the malformed or edge-case payload being chased.
|
||||
decoded["raw"] = bytes
|
||||
input_received.emit(peer_id, decoded)
|
||||
|
||||
|
||||
|
||||
@@ -107,7 +107,13 @@ class SlotInfo:
|
||||
var team: int
|
||||
var spawn_index: int
|
||||
var ship: Ship
|
||||
var controller: RLShipController # server only
|
||||
# Base type, NOT RLShipController: §6.4's takeover swaps in either an
|
||||
# AIShipController (--fill-bots) or the inert base controller, and a
|
||||
# narrower declared type makes that assignment fail its type check — which
|
||||
# leaves this field pointing at the controller set_controller() just
|
||||
# queue_free()d. Exactly task 5.7's dangling reference, and it showed up as
|
||||
# controller_valid=false the first time the disconnect test ran.
|
||||
var controller: ShipController # server only
|
||||
var jitter_buffer := InputJitterBuffer.new() # server only (§3.2)
|
||||
# Server only. Consecutive packets rejected by the seq-range guard, reset by
|
||||
# any accepted one. The guard's bound is derived from a value only an
|
||||
@@ -115,6 +121,12 @@ class SlotInfo:
|
||||
# permanently — see the guard's own comment in _on_input_received.
|
||||
var consecutive_seq_rejects := 0
|
||||
var last_client_send_ms := 0 # server only: echoed back per-peer next snapshot (§2.4)
|
||||
# §6.4 (tasks 5.6/5.7). A ship is NEVER despawned on disconnect — the slot
|
||||
# keeps its ship and swaps the controller, so body order (and therefore
|
||||
# every snapshot index) stays stable for the whole match.
|
||||
var player_name := "" # identity key for reconnect; peer_id changes across a reconnect
|
||||
var disconnected := false
|
||||
var reserved_until_tick := -1 # server only: slot held for this player until here
|
||||
var interpolator := NetInterpolator.new() # client only
|
||||
var visual_smoother_reset := true
|
||||
var visual_position_offset := Vector3.ZERO
|
||||
@@ -255,6 +267,14 @@ var _clock_running := false
|
||||
var _last_emitted_second := -1
|
||||
@export var match_length_seconds := 150.0
|
||||
|
||||
# §6.4's --fill-bots takeover controller. Mirrors match_mode.gd's exports so a
|
||||
# server operator configures the replacement bot exactly as a single-player
|
||||
# match configures its opponent, rather than through a second parallel scheme.
|
||||
@export_group("Disconnect fill bot")
|
||||
@export_file("*.json") var bot_model_path: String = ""
|
||||
@export_range(1, 60) var bot_reaction_ticks: int = 8
|
||||
@export_range(0.0, 1.0) var bot_action_noise: float = 0.0
|
||||
|
||||
# §6.2 step 6. The tick play resumes on — the countdown's own end. Both peers
|
||||
# derive the displayed count from this and their own server-tick estimate, so
|
||||
# nothing depends on a local Timer staying in step.
|
||||
@@ -265,6 +285,18 @@ var _pending_kickoff := {}
|
||||
# Freeze is applied on a strictly later tick than the kickoff teleport that
|
||||
# precedes it — see _apply_kickoff. -1 when nothing is pending.
|
||||
var _pending_freeze_tick := -1
|
||||
# §1.4: public servers default to leaving an abandoned ship inert rather than
|
||||
# handing it to a bot, so a disconnect cannot change the competitive balance
|
||||
# of a match in progress. --fill-bots opts in.
|
||||
var _fill_bots := false
|
||||
# Task 5.10, server only. null unless --replay-log= was passed.
|
||||
var _replay_log: ReplayLog = null
|
||||
# §6.3 (task 5.8), client only.
|
||||
var _is_spectator := false
|
||||
var _spectator_target_index := 0
|
||||
# §6.3's "cap with --max-spectators". Server only; 0 disables spectating
|
||||
# entirely, negative means unlimited.
|
||||
var _max_spectators := -1
|
||||
var _last_emitted_countdown := -1
|
||||
var _in_overtime := false
|
||||
var _match_over := false
|
||||
@@ -277,7 +309,24 @@ func _ready() -> void:
|
||||
_kickoff_rng.randomize()
|
||||
if multiplayer.is_server():
|
||||
for arg: String in OS.get_cmdline_user_args():
|
||||
if arg.begins_with("--match-length="):
|
||||
if arg == "--fill-bots":
|
||||
_fill_bots = true
|
||||
elif arg == "--no-fill-bots":
|
||||
_fill_bots = false
|
||||
elif arg.begins_with("--max-spectators="):
|
||||
_max_spectators = maxi(0, arg.get_slice("=", 1).to_int())
|
||||
elif arg.begins_with("--replay-log="):
|
||||
# Task 5.10. Diagnostic only: a log that cannot be opened must
|
||||
# never stop the server serving the match.
|
||||
var replay_path := arg.get_slice("=", 1)
|
||||
_replay_log = ReplayLog.new()
|
||||
var replay_err := _replay_log.open_for_write(replay_path)
|
||||
if replay_err != OK:
|
||||
push_warning("NetworkedMatch: could not open replay log %s (%s)" % [replay_path, error_string(replay_err)])
|
||||
_replay_log = null
|
||||
else:
|
||||
print("NetworkedMatch: recording replay log to %s" % replay_path)
|
||||
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
|
||||
# only — a client cannot shorten anyone's match.
|
||||
@@ -357,6 +406,7 @@ func _start_server() -> void:
|
||||
slot.peer_id = peer_id
|
||||
slot.team = info.team
|
||||
slot.spawn_index = spawn_index
|
||||
slot.player_name = info.player_name
|
||||
slot.controller = RLShipController.new()
|
||||
slot.ship = spawn_ship(info.team, spawn_index, slot.controller)
|
||||
_slots.append(slot)
|
||||
@@ -366,6 +416,8 @@ func _start_server() -> void:
|
||||
|
||||
MatchSim.send_match_config(arena_path, peer_ids, teams, spawn_indices)
|
||||
MatchSim.input_received.connect(_on_input_received)
|
||||
NetworkManager.client_disconnected.connect(_on_client_disconnected)
|
||||
MatchNet.player_joined.connect(_on_player_joined_midmatch)
|
||||
|
||||
# §6.1: the arena, ball and every slot's ship now exist and match_config is
|
||||
# out, so LOADING is genuinely over. Task 5.3 gates this on the clients'
|
||||
@@ -454,6 +506,8 @@ func _on_input_received(peer_id: int, decoded: Dictionary) -> void:
|
||||
# Fall through and accept: this is the escape hatch, not a
|
||||
# missing `return`.
|
||||
slot.consecutive_seq_rejects = 0
|
||||
if _replay_log != null:
|
||||
_replay_log.record_input(Engine.get_physics_frames(), peer_id, decoded.get("raw", PackedByteArray()))
|
||||
jb.ingest(seq, decoded["actions"])
|
||||
slot.last_client_send_ms = decoded["client_send_ms"]
|
||||
return
|
||||
@@ -876,6 +930,152 @@ func _on_goal_registered(conceding_team: int) -> void:
|
||||
_broadcast_clock_state()
|
||||
|
||||
|
||||
# Task 5.9. Server-only by construction: _respawn_escaped_bodies() is gated on
|
||||
# _owns_world_simulation(). The bump uses Phase 2's deferred path because the
|
||||
# respawn only QUEUES a teleport — bumping now would broadcast the new
|
||||
# generation alongside the still-escaped position, which is exactly the 27m
|
||||
# slide that fix exists to prevent.
|
||||
# --- §6.4 disconnects and reconnects (tasks 5.6/5.7) -----------------------
|
||||
|
||||
const SLOT_RESERVATION_SECONDS := 30.0
|
||||
|
||||
|
||||
func _on_client_disconnected(peer_id: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
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)
|
||||
_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
|
||||
])
|
||||
break
|
||||
_abort_if_abandoned()
|
||||
|
||||
|
||||
# §6.4 has two rules that pull against each other: reserve a departed player's
|
||||
# slot for 30s, and abort to the lobby once the last human leaves. Applied
|
||||
# naively the abort wins instantly in a 1v1 — the moment the only player drops,
|
||||
# the match is torn down and their reservation can never be redeemed, which
|
||||
# makes the reconnect path unreachable exactly when it matters most (a single
|
||||
# player whose connection blipped). The reservation therefore takes precedence:
|
||||
# abort only once nobody is connected AND nobody is still expected back.
|
||||
func _abort_if_abandoned() -> void:
|
||||
if MatchState.is_terminal(match_state):
|
||||
return
|
||||
var now := Engine.get_physics_frames()
|
||||
for slot in _slots:
|
||||
if not slot.disconnected:
|
||||
return # somebody is still playing
|
||||
if slot.reserved_until_tick >= 0 and now <= slot.reserved_until_tick:
|
||||
return # somebody may still come back
|
||||
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)
|
||||
|
||||
|
||||
# Task 5.7. Ship.set_controller() calls queue_free() on the OUTGOING
|
||||
# controller, so slot.controller is a dangling reference the instant the swap
|
||||
# happens — and _physics_process writes slot.controller.action every single
|
||||
# tick. Rebinding must therefore happen in the same transaction as the swap,
|
||||
# never as a follow-up statement that an early return or an await could skip.
|
||||
func _swap_slot_controller(slot: SlotInfo, replacement: ShipController) -> void:
|
||||
if not is_instance_valid(slot.ship):
|
||||
slot.controller = null
|
||||
return
|
||||
slot.ship.set_controller(replacement)
|
||||
slot.controller = replacement
|
||||
|
||||
|
||||
func _build_takeover_controller() -> ShipController:
|
||||
if _fill_bots:
|
||||
return _build_opponent(bot_model_path, bot_reaction_ticks, bot_action_noise, "NetworkedMatch")
|
||||
# §6.4's default for public servers: inert but still simulated, exactly the
|
||||
# placeholder GameMode already uses for an unfilled slot. An abandoned ship
|
||||
# that keeps flying on its last input would be worse than one that coasts.
|
||||
return ShipController.new()
|
||||
|
||||
|
||||
# Called when a peer joins while this match is already running. Returns true if
|
||||
# it reclaimed a reserved slot (§6.4's 30s identity-keyed reservation).
|
||||
func _try_reclaim_slot(peer_id: int, player_name: String) -> bool:
|
||||
if not multiplayer.is_server():
|
||||
return false
|
||||
var now := Engine.get_physics_frames()
|
||||
for slot in _slots:
|
||||
if not slot.disconnected or slot.player_name == "" or slot.player_name != player_name:
|
||||
continue
|
||||
if slot.reserved_until_tick >= 0 and now > slot.reserved_until_tick:
|
||||
continue # reservation lapsed; this is a fresh joiner, not a return
|
||||
slot.peer_id = peer_id
|
||||
slot.disconnected = false
|
||||
slot.reserved_until_tick = -1
|
||||
# Reset the input pipeline: the returning client starts its sequence
|
||||
# numbering from scratch, and the old buffer's cursor belongs to a
|
||||
# different epoch entirely (input_jitter_buffer.gd's seeding comment).
|
||||
slot.jitter_buffer = InputJitterBuffer.new()
|
||||
slot.consecutive_seq_rejects = 0
|
||||
_swap_slot_controller(slot, RLShipController.new())
|
||||
print("NetworkedMatch: peer %d reclaimed %s's reserved slot" % [peer_id, player_name])
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# §6.3/§6.4. A peer joining while this match runs is either a returning player
|
||||
# claiming their reserved slot, or a late joiner — who spectates until the next
|
||||
# kickoff, because swapping a controller at a kickoff boundary is free and
|
||||
# mid-play it is not.
|
||||
func _on_player_joined_midmatch(peer_id: int, player_name: String) -> void:
|
||||
if not multiplayer.is_server() or _slots.is_empty():
|
||||
return
|
||||
if _try_reclaim_slot(peer_id, player_name):
|
||||
return
|
||||
if _max_spectators >= 0 and _spectator_count() > _max_spectators:
|
||||
print("NetworkedMatch: spectator cap (%d) reached, disconnecting peer %d" % [_max_spectators, peer_id])
|
||||
# Same call the abuse paths use (match_sim.gd:285, match_net.gd:207) —
|
||||
# default force=false, so ENet flushes cleanly rather than leaving the
|
||||
# server's own peer bookkeeping inconsistent (§9 gotcha on force=true).
|
||||
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
|
||||
return
|
||||
print("NetworkedMatch: peer %d (%s) joined mid-match; spectating until the next kickoff" % [peer_id, player_name])
|
||||
|
||||
|
||||
# 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.
|
||||
func _spectator_count() -> int:
|
||||
var slotted := {}
|
||||
for slot in _slots:
|
||||
if not slot.disconnected:
|
||||
slotted[slot.peer_id] = true
|
||||
var count := 0
|
||||
for peer_id in multiplayer.get_peers():
|
||||
if not slotted.has(peer_id):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func _expire_slot_reservations() -> void:
|
||||
var now := Engine.get_physics_frames()
|
||||
for slot in _slots:
|
||||
if slot.disconnected and slot.reserved_until_tick >= 0 and now > slot.reserved_until_tick:
|
||||
slot.reserved_until_tick = -1
|
||||
print("NetworkedMatch: %s's slot reservation lapsed" % slot.player_name)
|
||||
# The abort was deferred while this reservation was live; now that
|
||||
# it has lapsed, re-check whether anyone is left at all.
|
||||
_abort_if_abandoned()
|
||||
|
||||
|
||||
func _on_bodies_respawned() -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
_pending_reset_gen_bump = true
|
||||
_pending_reset_gen_bump_tick = Engine.get_physics_frames()
|
||||
|
||||
|
||||
func _on_goal_scored(_conceding_team: int) -> void:
|
||||
# Deliberately empty. Before task 5.4 this reset the world the instant the
|
||||
# sensor fired, which is precisely the "server reset fires while clients
|
||||
@@ -897,7 +1097,10 @@ func _broadcast_snapshot() -> void:
|
||||
# total-garbage failure mode the moment that stops being true, and the
|
||||
# fix costs nothing.
|
||||
for slot in _slots:
|
||||
bodies.append(_ship_to_net_body_state(slot.ship, slot.jitter_buffer.stalled) if is_instance_valid(slot.ship) else NetBodyState.new())
|
||||
# §6.4: `stalled` is what greys out the nameplate, so a disconnected
|
||||
# player must set it immediately rather than waiting the ~500ms it
|
||||
# takes their abandoned jitter buffer to starve into the same state.
|
||||
bodies.append(_ship_to_net_body_state(slot.ship, slot.jitter_buffer.stalled or slot.disconnected) if is_instance_valid(slot.ship) else NetBodyState.new())
|
||||
if is_instance_valid(ball):
|
||||
bodies.append(_ball_to_net_body_state(ball))
|
||||
var segment := NetCodec.pack_snapshot_body_segment(server_tick, match_state, _reset_gen, bodies)
|
||||
@@ -924,6 +1127,8 @@ func _broadcast_snapshot() -> void:
|
||||
# genuine sustained server starvation event.
|
||||
var advertised_depth := -2 if slot.jitter_buffer.starved_ticks >= STARVATION_ADVERTISEMENT_TICKS else slot.jitter_buffer.depth()
|
||||
var bytes := NetCodec.pack_snapshot(last_input_seq, advertised_depth, slot.last_client_send_ms, segment)
|
||||
if _replay_log != null:
|
||||
_replay_log.record_snapshot(server_tick, bytes)
|
||||
MatchSim.send_snapshot(slot.peer_id, bytes)
|
||||
|
||||
|
||||
@@ -1026,7 +1231,15 @@ func _on_match_config_received(arena_path: String, peer_ids: PackedInt32Array, t
|
||||
if is_local:
|
||||
_my_slot = slot
|
||||
|
||||
# §6.3 (task 5.8): a peer with no slot is a spectator. It receives the
|
||||
# identical snapshot broadcast (zero extra server work), spawns no ship of
|
||||
# its own, and points a camera rig at somebody else's.
|
||||
_is_spectator = _my_slot == null
|
||||
_spawn_hud()
|
||||
if _is_spectator:
|
||||
_spectator_target_index = 0
|
||||
_point_spectator_camera()
|
||||
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)
|
||||
@@ -1062,9 +1275,70 @@ func _on_match_config_received(arena_path: String, peer_ids: PackedInt32Array, t
|
||||
|
||||
func _spawn_hud() -> void:
|
||||
hud = HUD_SCENE.instantiate()
|
||||
# BEFORE add_child: HUDController reads this in _initialize_hud(), which
|
||||
# runs one process frame after _ready(). Setting it afterwards would be a
|
||||
# race against that frame, and losing it means a spectator's HUD
|
||||
# push_error()s about a missing ship and wires up nothing at all.
|
||||
hud.spectator_mode = _is_spectator
|
||||
add_child(hud)
|
||||
|
||||
|
||||
# §6.3's "points a camera rig at a chosen ship or the ball", plus the target
|
||||
# cycling. Targets are every ship in slot order, then the ball.
|
||||
# §6.3's "cycle targets". Bound to the existing `reset_ball` action, which is
|
||||
# already a mode-level key and is meaningless to a spectator (it only fires in
|
||||
# Free Play), rather than adding a new binding to project.godot for one mode.
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not _is_spectator:
|
||||
return
|
||||
if event.is_action_pressed("reset_ball"):
|
||||
cycle_spectator_target(1)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _spectator_target_count() -> int:
|
||||
return _slots.size() + (1 if is_instance_valid(ball) else 0)
|
||||
|
||||
|
||||
func _point_spectator_camera() -> void:
|
||||
var count := _spectator_target_count()
|
||||
if count == 0:
|
||||
return
|
||||
_spectator_target_index = posmod(_spectator_target_index, count)
|
||||
var target: Node3D = null
|
||||
if _spectator_target_index < _slots.size():
|
||||
target = _slots[_spectator_target_index].ship
|
||||
else:
|
||||
target = ball
|
||||
if not is_instance_valid(target):
|
||||
return
|
||||
if not is_instance_valid(_camera_rig):
|
||||
# spawn_camera_rig types its parameter as Ship, so the ball can only
|
||||
# ever be a LATER target, never the one the rig is created with.
|
||||
var first_ship: Ship = null
|
||||
for slot in _slots:
|
||||
if is_instance_valid(slot.ship):
|
||||
first_ship = slot.ship
|
||||
break
|
||||
if first_ship == null:
|
||||
return
|
||||
spawn_camera_rig(first_ship)
|
||||
if is_instance_valid(_camera_rig):
|
||||
_camera_rig.target = target
|
||||
if is_instance_valid(hud):
|
||||
hud.ship = target if target is Ship else null
|
||||
|
||||
|
||||
func cycle_spectator_target(step: int = 1) -> void:
|
||||
if not _is_spectator:
|
||||
return
|
||||
var count := _spectator_target_count()
|
||||
if count == 0:
|
||||
return
|
||||
_spectator_target_index = posmod(_spectator_target_index + step, count)
|
||||
_point_spectator_camera()
|
||||
|
||||
|
||||
func _send_local_input(record_prediction: bool = true) -> void:
|
||||
if _slots.is_empty():
|
||||
return # match_config hasn't arrived yet
|
||||
@@ -1527,6 +1801,7 @@ func _physics_process(_delta: float) -> void:
|
||||
# Also before the broadcast, so a transition taken this tick ships in
|
||||
# this tick's own match_state byte rather than trailing it by one.
|
||||
_update_match_state()
|
||||
_expire_slot_reservations()
|
||||
# Countdown and clock are derived from absolute ticks on both peers, so
|
||||
# these run on the client too.
|
||||
_apply_pending_freeze()
|
||||
@@ -1543,7 +1818,17 @@ func _physics_process(_delta: float) -> void:
|
||||
# integration. This preserves the existing one-tick server input delay
|
||||
# while keeping snapshot.last_input_seq truthfully coupled to its body.
|
||||
for slot in _slots:
|
||||
slot.controller.action = slot.jitter_buffer.consume()
|
||||
# is_instance_valid, not a null check: set_controller() queue_free()s
|
||||
# the outgoing controller on every disconnect swap, and a freed
|
||||
# object is non-null right up until the frame it is collected.
|
||||
var consumed := slot.jitter_buffer.consume()
|
||||
# Only a live player's slot is driven by the wire. A slot whose
|
||||
# player disconnected now holds a bot or the inert base controller
|
||||
# (§6.4), which drives itself — overwriting its action every tick
|
||||
# from a permanently-starving jitter buffer would pin it to the
|
||||
# departed player's last input forever.
|
||||
if is_instance_valid(slot.controller) and slot.controller is RLShipController:
|
||||
(slot.controller as RLShipController).action = consumed
|
||||
if _pending_reset_gen_bump and Engine.get_physics_frames() > _pending_reset_gen_bump_tick:
|
||||
_reset_gen = (_reset_gen + 1) % 256
|
||||
_pending_reset_gen_bump = false
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
class_name ReplayLog
|
||||
extends RefCounted
|
||||
|
||||
# Append-only binary server replay log (multiplayer-todo.md task 5.10).
|
||||
#
|
||||
# The highest-value debuggability investment in Phase 5, and cheap precisely
|
||||
# because the packets are ALREADY flat bytes: this stores them verbatim rather
|
||||
# than re-serialising game state. Without it, "my ship snapped" is permanently
|
||||
# unreproducible from a field report — the CI gate catches regressions, but it
|
||||
# cannot debug a player's bad night.
|
||||
#
|
||||
# Deliberately a standalone RefCounted with no scene/RPC dependency, like
|
||||
# net_codec.gd and input_jitter_buffer.gd, so it can be unit-tested against a
|
||||
# scripted record/read cycle with no live match.
|
||||
#
|
||||
# Format. Little-endian throughout, matching StreamPeerBuffer's own defaults
|
||||
# and NetCodec's wire encoding:
|
||||
#
|
||||
# magic u32 'CCRP' (0x50524343)
|
||||
# version u16 FORMAT_VERSION
|
||||
# tick_hz u16 so a reader can convert ticks to seconds without guessing
|
||||
# then, repeated:
|
||||
# kind u8 RecordKind
|
||||
# tick u32 server tick (Engine.get_physics_frames())
|
||||
# peer_id u32 sender for INPUT, 0 for SNAPSHOT
|
||||
# length u16 payload byte count
|
||||
# payload length bytes, exactly as it went on the wire
|
||||
#
|
||||
# `length` is a u16 because both hot-path packets are far under 64KB (a 1v1
|
||||
# snapshot is ~59 bytes) and MatchSim.MAX_INPUT_LENGTH already rejects
|
||||
# anything larger on the way in.
|
||||
|
||||
const MAGIC := 0x50524343
|
||||
const FORMAT_VERSION := 1
|
||||
const HEADER_SIZE := 8
|
||||
const RECORD_HEADER_SIZE := 11
|
||||
|
||||
enum RecordKind {
|
||||
INPUT = 0, # client -> server, as received
|
||||
SNAPSHOT = 1, # server -> client, as sent
|
||||
}
|
||||
|
||||
var _file: FileAccess = null
|
||||
var records_written := 0
|
||||
var bytes_written := 0
|
||||
|
||||
|
||||
# Returns OK, or an error code. A replay log is diagnostic: a caller that
|
||||
# cannot open one should carry on serving the match, not refuse to start.
|
||||
func open_for_write(path: String) -> Error:
|
||||
_file = FileAccess.open(path, FileAccess.WRITE)
|
||||
if _file == null:
|
||||
return FileAccess.get_open_error()
|
||||
_file.store_32(MAGIC)
|
||||
_file.store_16(FORMAT_VERSION)
|
||||
_file.store_16(SimConstants.TICK_HZ)
|
||||
bytes_written = HEADER_SIZE
|
||||
return OK
|
||||
|
||||
|
||||
func is_open() -> bool:
|
||||
return _file != null
|
||||
|
||||
|
||||
func record_input(tick: int, peer_id: int, payload: PackedByteArray) -> void:
|
||||
_write(RecordKind.INPUT, tick, peer_id, payload)
|
||||
|
||||
|
||||
func record_snapshot(tick: int, payload: PackedByteArray) -> void:
|
||||
_write(RecordKind.SNAPSHOT, tick, 0, payload)
|
||||
|
||||
|
||||
func _write(kind: int, tick: int, peer_id: int, payload: PackedByteArray) -> void:
|
||||
if _file == null:
|
||||
return
|
||||
if payload.size() > 0xFFFF:
|
||||
# Cannot happen through the real ingress paths (see the header note),
|
||||
# but truncating silently would corrupt every later record's framing.
|
||||
push_warning("ReplayLog: dropping an oversized %d-byte payload" % payload.size())
|
||||
return
|
||||
_file.store_8(kind)
|
||||
_file.store_32(tick)
|
||||
_file.store_32(peer_id)
|
||||
_file.store_16(payload.size())
|
||||
if payload.size() > 0:
|
||||
_file.store_buffer(payload)
|
||||
records_written += 1
|
||||
bytes_written += RECORD_HEADER_SIZE + payload.size()
|
||||
|
||||
|
||||
func close() -> void:
|
||||
if _file == null:
|
||||
return
|
||||
_file.close()
|
||||
_file = null
|
||||
|
||||
|
||||
# Reads a whole log back. Returns {"tick_hz": int, "records": Array} or an
|
||||
# empty Dictionary if the file is missing/not a replay log. Static and
|
||||
# self-contained so an offline tool — or a test — can consume a log without
|
||||
# instantiating anything.
|
||||
static func read_all(path: String) -> Dictionary:
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
return {}
|
||||
if f.get_length() < HEADER_SIZE or f.get_32() != MAGIC:
|
||||
f.close()
|
||||
return {}
|
||||
var version := f.get_16()
|
||||
var tick_hz := f.get_16()
|
||||
var records: Array = []
|
||||
# Bound every read on the declared length rather than trusting EOF:
|
||||
# FileAccess silently zero-fills past the end, exactly as StreamPeerBuffer
|
||||
# does, so a truncated file would otherwise decode as an endless run of
|
||||
# zero-length records at tick 0.
|
||||
while f.get_position() + RECORD_HEADER_SIZE <= f.get_length():
|
||||
var kind := f.get_8()
|
||||
var tick := f.get_32()
|
||||
var peer_id := f.get_32()
|
||||
var length := f.get_16()
|
||||
if f.get_position() + length > f.get_length():
|
||||
push_warning("ReplayLog: truncated final record in %s" % path)
|
||||
break
|
||||
records.append({
|
||||
"kind": kind,
|
||||
"tick": tick,
|
||||
"peer_id": peer_id,
|
||||
"payload": f.get_buffer(length) if length > 0 else PackedByteArray(),
|
||||
})
|
||||
f.close()
|
||||
return {"version": version, "tick_hz": tick_hz, "records": records}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bfrexwrkq3cia
|
||||
Reference in New Issue
Block a user