mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
fix(multiplayer): adversarial review fixes for Phase 2
An Opus subagent's adversarial review of Phase 2 found real bugs the smoke tests couldn't catch, since constant-velocity dead reckoning still moves a ship far enough to pass a "moved > 1.0" check: - The interpolator never actually interpolated. NetInterpolator.to_tick() assumes physics_frame * TICK_MS == Time.get_ticks_msec() on the server, which is off by a steady ~45-55ms in practice (real startup work before the first physics step, widened by any dropped tick). Every sample_at() call took the extrapolation branch, 100% of the time, defeating the interpolation buffer entirely. Fixed with a shared, min-filtered rolling bias estimate in networked_match.gd, applied before every to_tick() call. - Goals caused a ~27m visual slide: _reset_gen was bumped before the queued teleport actually landed, so the client's buffer-clear kept exactly the stale in-goal sample and lerped a slide to the next, real one. Fixed by tracking the tick the goal was detected on and only bumping the generation once strictly later ticks confirm the teleport has landed - a naive "next _physics_process" boolean flag doesn't work, since a goal Area's body_entered fires before that same tick's _physics_process runs, not on the next one. - _local_input_sampler (a Node, never added to the tree) was never freed - this was the unexplained "3 resources still in use at exit" warning on every Phase 2 test run. - Ball angular velocity decoded 8x too small (rescale_avel was never called); get_server_time_estimate_ms() was used before the clock had synced; net_sim.gd's delayed-send timer stopped ticking while the tree was paused and didn't check connection status before firing; _broadcast_snapshot's ball index could silently break if a ship were ever despawned; declared-but-unemitted HUD lifecycle signals showed a permanently frozen timer widget. Also confirmed, empirically, several things the review checked and found fine: a hostile client sending malformed input cannot crash the server, skipping GameMode's super() drops nothing load-bearing, deterministic slot assignment is correct with 2 real simultaneous clients, and RPC authority enforcement genuinely rejects a forging client. All fixes verified with real two-process runs (including forcing an actual goal and reading the server's own broadcast stream) and temporary instrumentation, removed once each fix was confirmed. Full Phase 1 + Phase 2 regression suite, including the net-sim-latency milestone gate, re-run clean after every fix.
This commit is contained in:
+19
-1
@@ -87,7 +87,14 @@ func _schedule(dispatch: Callable, target_peer_id: int, delay_sec: float) -> voi
|
||||
if delay_sec <= 0.0:
|
||||
dispatch.call()
|
||||
return
|
||||
get_tree().create_timer(delay_sec, false).timeout.connect(func() -> void: _fire(dispatch, target_peer_id))
|
||||
# process_always = true: a simulated wire delay must keep counting down
|
||||
# even if the local SceneTree pauses (match_mode.gd's goal-pause does
|
||||
# this today; multiplayer-todo.md §8 already flags get_tree().paused
|
||||
# stopping the client's own send/receive loop as a separate refactor
|
||||
# item). Pausing this timer too would let a paused client's in-flight
|
||||
# packets pile up and arrive in a burst on unpause instead of on their
|
||||
# simulated schedule.
|
||||
get_tree().create_timer(delay_sec, true).timeout.connect(func() -> void: _fire(dispatch, target_peer_id))
|
||||
|
||||
|
||||
# Re-validates the target right before a DELAYED send actually fires.
|
||||
@@ -107,10 +114,21 @@ func _schedule(dispatch: Callable, target_peer_id: int, delay_sec: float) -> voi
|
||||
# time to change since the caller's own validation, and matching the
|
||||
# pre-NetSim behaviour exactly there is what keeps NetSim a true no-op when
|
||||
# no CLI flags are given.
|
||||
#
|
||||
# Known residual gap, judged not worth the complexity for debug-only
|
||||
# tooling: if this process shuts down AND reconnects (a fresh host()/join())
|
||||
# within one delayed send's hold time, multiplayer_peer is a real peer again
|
||||
# and get_peers() may coincidentally contain the same target_peer_id from
|
||||
# the new session, so a stale send from the old session could slip through.
|
||||
# Closing that fully would need a generation counter bumped on every
|
||||
# shutdown/host/join and stamped on each scheduled send — disproportionate
|
||||
# for a latency simulator that only ever runs in manual/CI testing.
|
||||
func _fire(dispatch: Callable, target_peer_id: int) -> void:
|
||||
var peer := multiplayer.multiplayer_peer
|
||||
if peer == null or peer is OfflineMultiplayerPeer:
|
||||
return
|
||||
if peer is ENetMultiplayerPeer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
|
||||
return
|
||||
if target_peer_id != -1 and target_peer_id not in multiplayer.get_peers():
|
||||
return
|
||||
dispatch.call()
|
||||
|
||||
Reference in New Issue
Block a user