mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
7b150ef72e
New NetSim autoload: seeded, CLI-driven (--net-sim-latency/-jitter/-loss/-dup) latency/jitter/loss/duplicate decorator, a true no-op passthrough unless a flag is set. Wraps MatchSim.send_input/send_snapshot per the design doc's scope, plus NetworkManager's ping/pong so the already-tested RTT/clock measurement becomes the acceptance signal for "raises observed RTT" without waiting on Phase 3's per-peer snapshot echo. Two real bugs found while building and verifying this against Phase 2's own milestone gate (a real match under --net-sim-latency 80 --net-sim-jitter 20, not just LAN): a timestamp captured inside a delayed RPC closure silently ate that side's own added delay out of the round-trip measurement instead of adding to it; and a delayed send whose target disconnected (or whose own process had already shut down) during the hold threw RPC errors, since the existing get_peers() filtering only checked validity at schedule time. Fixed by capturing timestamps before handing off to NetSim, and by having NetSim re-validate the target at fire time. Phase 2's milestone gate now passes for real: a full 1v1 under simulated 80ms latency / 20ms jitter still shows clean server-authoritative movement and zero RPC errors. Full Phase 1 + Phase 2 regression suite re-verified clean with NetSim present but inactive.
220 lines
9.0 KiB
GDScript
220 lines
9.0 KiB
GDScript
extends Node
|
|
|
|
# Autoload (project.godot [autoload] NetworkManager). Owns the ENet
|
|
# transport: hosting, joining, shutdown, and connection-state signals. Lives
|
|
# at a fixed autoload path so RPC NodePaths never depend on which scene is
|
|
# loaded (§1.3 of multiplayer-todo.md's derived decisions).
|
|
#
|
|
# server_relay = false is set the moment a peer exists: the default `true`
|
|
# lets any client rpc() any other client *through the server*, which this
|
|
# project's server-authoritative model must never allow — §2.1 calls this
|
|
# out as the single highest-value one-line security change in the document.
|
|
#
|
|
# IMPORTANT, learned the hard way (tests/net_smoke.gd): don't call
|
|
# shutdown()/close the peer the instant connected_to_server or peer_connected
|
|
# fires. ENet's connect handshake isn't fully settled on the *other* side the
|
|
# moment your own side's signal fires — the final ACK still needs a couple
|
|
# more poll() cycles to actually reach the wire. Closing immediately drops
|
|
# it and leaves the other side's handshake permanently incomplete (it will
|
|
# never see peer_connected/connected_to_server at all). Callers that shut
|
|
# down right after a fresh connection should let a frame or two pass first.
|
|
#
|
|
# Manual polling (task 1.3): SceneTree's automatic multiplayer poll runs on
|
|
# the *idle* frame, so an rpc() issued from _physics_process waits up to a
|
|
# full frame before it's actually pushed onto the wire — and the return leg
|
|
# pays the same tax again. set_multiplayer_poll_enabled(false) below turns
|
|
# that off; every caller that sends or expects to receive on a tight cadence
|
|
# must now call NetworkManager.poll() itself. The intended placement per
|
|
# multiplayer-todo.md §7 task 1.3 (client: end of _physics_process after
|
|
# sending input, plus top of both _process and _physics_process for receive;
|
|
# server: tick start to drain, tick end to flush) has no real per-tick caller
|
|
# yet — that lands with the input/snapshot pipeline (tasks 1.4+, Phase 2-3).
|
|
# Until then, anything driving a connection (tests/net_smoke.gd included)
|
|
# must poll() every frame itself or nothing will ever be sent or received.
|
|
|
|
signal client_connected(peer_id: int)
|
|
signal client_disconnected(peer_id: int)
|
|
signal connected_to_server()
|
|
signal connection_failed()
|
|
signal disconnected_from_server()
|
|
signal clock_updated(rtt_ms: float, offset_ms: float)
|
|
# Fires at the top of every shutdown() call, whether this process was
|
|
# hosting, joined, or already offline, and regardless of *why* (deliberate
|
|
# Leave/Cancel, or an incoming disconnect from the other side). Adversarial
|
|
# review found MatchNet.roster had no path that cleared it when a HOST
|
|
# stopped hosting — connected_to_server/disconnected_from_server only cover
|
|
# the client side — so a host -> lobby -> leave -> host-again cycle left a
|
|
# permanent phantom player. Listeners that need per-role cleanup should
|
|
# still use the more specific signals above; this one exists so "something
|
|
# is about to reset the connection, drop anything you were keeping" has
|
|
# exactly one place to hook regardless of role.
|
|
signal shutting_down()
|
|
|
|
const DEFAULT_PORT := 7777
|
|
const MAX_CLIENTS := 32
|
|
|
|
# Clock (task 1.8, §4.7): client pings the server once a second on the
|
|
# reliable control channel; clock_offset_ms is the min-RTT sample in a
|
|
# rolling window, because the lowest-RTT sample has the least queueing
|
|
# error. get_server_time_estimate_ms() is the thing every later phase
|
|
# (interpolation delay, tick_offset seeding) actually wants — everything
|
|
# else here exists to produce it.
|
|
const PING_INTERVAL_SEC := 1.0
|
|
const CLOCK_WINDOW_SEC := 5.0
|
|
|
|
var is_server := false
|
|
var is_client := false
|
|
var _peer: ENetMultiplayerPeer # keep a strong ref alongside multiplayer.multiplayer_peer
|
|
|
|
var rtt_ms := -1.0 # min-RTT sample currently in the window; -1 = no sample yet
|
|
var clock_offset_ms := 0.0 # add to a local Time.get_ticks_msec() reading to estimate the server's clock
|
|
var _clock_samples: Array[Dictionary] = []
|
|
var _ping_accum_sec := 0.0
|
|
|
|
|
|
func _ready() -> void:
|
|
get_tree().set_multiplayer_poll_enabled(false)
|
|
multiplayer.peer_connected.connect(_on_peer_connected)
|
|
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
|
multiplayer.connected_to_server.connect(_on_connected_to_server)
|
|
multiplayer.connection_failed.connect(_on_connection_failed)
|
|
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
# is_client turns true the instant join() is called, before the ENet
|
|
# handshake actually completes (or fails) — a slow or refused connect
|
|
# attempt would otherwise leave this trying to rpc_id() on a peer
|
|
# that's still CONNECTING (or already failed), which Godot logs as
|
|
# "Trying to call an RPC via a multiplayer peer which is not
|
|
# connected." every single frame. Require the real transport state.
|
|
if not is_client or _peer == null or _peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
|
|
return
|
|
_ping_accum_sec += delta
|
|
if _ping_accum_sec >= PING_INTERVAL_SEC:
|
|
_ping_accum_sec = 0.0
|
|
# Capture the timestamp now, before NetSim (task 2.8) can add any
|
|
# simulated delay — see net_sim.gd's header comment for why.
|
|
var send_ms := Time.get_ticks_msec()
|
|
NetSim.send(func() -> void: _ping.rpc_id(1, send_ms), 1)
|
|
|
|
|
|
# Estimate of what the server's Time.get_ticks_msec() reads right now.
|
|
# Meaningless before the first pong lands (clock_offset_ms is 0.0 until then
|
|
# — callers needing round-trip-confirmed freshness should check rtt_ms >= 0).
|
|
func get_server_time_estimate_ms() -> float:
|
|
return float(Time.get_ticks_msec()) + clock_offset_ms
|
|
|
|
|
|
# The single entry point every per-tick caller uses instead of relying on
|
|
# SceneTree's (now disabled) automatic poll. Safe to call with no peer set —
|
|
# polling the default OfflineMultiplayerPeer is a no-op.
|
|
func poll() -> void:
|
|
multiplayer.poll()
|
|
|
|
|
|
func host(port: int = DEFAULT_PORT, max_clients: int = MAX_CLIENTS) -> Error:
|
|
shutdown()
|
|
var peer := ENetMultiplayerPeer.new()
|
|
var err := peer.create_server(port, max_clients)
|
|
if err != OK:
|
|
push_error("NetworkManager.host: create_server failed (%s)" % error_string(err))
|
|
return err
|
|
_peer = peer
|
|
multiplayer.multiplayer_peer = peer
|
|
multiplayer.server_relay = false
|
|
is_server = true
|
|
is_client = false
|
|
return OK
|
|
|
|
|
|
func join(address: String, port: int = DEFAULT_PORT) -> Error:
|
|
shutdown()
|
|
var peer := ENetMultiplayerPeer.new()
|
|
var err := peer.create_client(address, port)
|
|
if err != OK:
|
|
push_error("NetworkManager.join: create_client failed (%s)" % error_string(err))
|
|
return err
|
|
_peer = peer
|
|
multiplayer.multiplayer_peer = peer
|
|
multiplayer.server_relay = false
|
|
is_server = false
|
|
is_client = true
|
|
return OK
|
|
|
|
|
|
func shutdown() -> void:
|
|
shutting_down.emit()
|
|
# MultiplayerAPI's default multiplayer_peer is an OfflineMultiplayerPeer
|
|
# sentinel, never null — closing that sentinel is a no-op, but assigning
|
|
# multiplayer_peer = null (rather than a fresh OfflineMultiplayerPeer)
|
|
# leaves the API in a state distinct from its own default, which is a
|
|
# known source of confusing follow-on bugs (godotengine/godot#81540).
|
|
# Always reset to a real OfflineMultiplayerPeer, never raw null.
|
|
var peer := multiplayer.multiplayer_peer
|
|
if peer != null and not (peer is OfflineMultiplayerPeer):
|
|
peer.close()
|
|
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
|
_peer = null
|
|
is_server = false
|
|
is_client = false
|
|
rtt_ms = -1.0
|
|
clock_offset_ms = 0.0
|
|
_clock_samples.clear()
|
|
_ping_accum_sec = 0.0
|
|
|
|
|
|
@rpc("any_peer", "call_remote", "reliable")
|
|
func _ping(client_send_ms: int) -> void:
|
|
if not multiplayer.is_server():
|
|
return
|
|
# Same rule as the client's send above: read the server's clock now, at
|
|
# true receipt time, before NetSim can delay the reply — otherwise the
|
|
# server's own outbound leg would be silently absorbed out of both the
|
|
# RTT sample and the offset estimate instead of adding to them.
|
|
var server_now := Time.get_ticks_msec()
|
|
var sender_id := multiplayer.get_remote_sender_id()
|
|
NetSim.send(func() -> void: _pong.rpc_id(sender_id, client_send_ms, server_now), sender_id)
|
|
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _pong(client_send_ms: int, server_now_ms: int) -> void:
|
|
var now_ms := Time.get_ticks_msec()
|
|
var sample_rtt := float(now_ms - client_send_ms)
|
|
var sample_offset := float(server_now_ms) + sample_rtt / 2.0 - float(now_ms)
|
|
_clock_samples.append({"t": now_ms, "rtt": sample_rtt, "offset": sample_offset})
|
|
|
|
var cutoff := now_ms - int(CLOCK_WINDOW_SEC * 1000.0)
|
|
_clock_samples = _clock_samples.filter(func(s: Dictionary) -> bool: return s["t"] >= cutoff)
|
|
|
|
var best: Dictionary = _clock_samples[0]
|
|
for sample: Dictionary in _clock_samples:
|
|
if sample["rtt"] < best["rtt"]:
|
|
best = sample
|
|
rtt_ms = best["rtt"]
|
|
clock_offset_ms = best["offset"]
|
|
clock_updated.emit(rtt_ms, clock_offset_ms)
|
|
|
|
|
|
func _on_peer_connected(peer_id: int) -> void:
|
|
client_connected.emit(peer_id)
|
|
|
|
|
|
func _on_peer_disconnected(peer_id: int) -> void:
|
|
client_disconnected.emit(peer_id)
|
|
|
|
|
|
func _on_connected_to_server() -> void:
|
|
connected_to_server.emit()
|
|
|
|
|
|
func _on_connection_failed() -> void:
|
|
is_client = false
|
|
connection_failed.emit()
|
|
|
|
|
|
func _on_server_disconnected() -> void:
|
|
is_server = false
|
|
is_client = false
|
|
disconnected_from_server.emit()
|