extends Node # Autoload (project.godot [autoload] NetSim). Debug-only, seeded # latency/jitter/loss/duplicate decorator around outgoing RPC dispatch — # task 2.8. A pure passthrough (send() calls dispatch.call() immediately) # unless CLI flags are given, so every existing test and the real game are # byte-for-byte unaffected by this autoload merely existing. # # CLI (read once, in this process's own OS.get_cmdline_user_args()): # --net-sim-latency= one-way delay added before each wrapped send # --net-sim-jitter= extra uniform-random 0..jitter added per send # --net-sim-loss=<0..1> fraction of sends dropped entirely (never sent) # --net-sim-dup=<0..1> probability a send is ALSO sent a second time # --net-sim-seed= RNG seed (default fixed, so a bad run reproduces # unless a CI/local run deliberately wants a # different one — same "seeded so failures # reproduce" bar as §11's testing section sets) # # "Asymmetric-capable" per §7 task 2.8 is not a separate feature: each # process reads only its own CLI args and only delays its own outgoing # sends, so running the host and client with different flags (e.g. a # lossy-upload client against a clean host) already produces asymmetric # behaviour with no extra plumbing. # # Call sites build a zero-argument Callable that performs the actual # rpc_id()/rpc() dispatch, so NetSim never needs to know per-call argument # shapes. IMPORTANT for callers that embed a timestamp in the call (e.g. # NetworkManager's _ping/_pong): capture Time.get_ticks_msec() *before* # calling send(), not inside the wrapped Callable — the delay is meant to # simulate wire transit *after* the packet is "sent", so a timestamp taken # inside the delayed closure would silently absorb this process's own # outbound leg out of any round-trip measurement built on top of it. # # Wraps MatchSim.send_input / send_snapshot per the doc's task 2.8 scope, # plus NetworkManager's _ping/_pong dispatch — the latter is a deliberate # addition beyond the literal task text: it's the only RTT measurement that # already exists and is already tested (tests/clock_smoke.gd, task 1.8), so # routing it through NetSim is what makes "`--net-sim-latency 80` measurably # raises observed RTT" (this task's own stated acceptance criterion) # checkable today, without waiting on Phase 3's per-peer snapshot echo. const DEFAULT_SEED := 20260820 var latency_ms := 0.0 var jitter_ms := 0.0 var loss_fraction := 0.0 var dup_fraction := 0.0 var _rng := RandomNumberGenerator.new() # owned instance — never the global RNG, task 0.7's rule func _ready() -> void: var seed_value := DEFAULT_SEED for arg: String in OS.get_cmdline_user_args(): if arg.begins_with("--net-sim-latency="): latency_ms = maxf(0.0, arg.get_slice("=", 1).to_float()) elif arg.begins_with("--net-sim-jitter="): jitter_ms = maxf(0.0, arg.get_slice("=", 1).to_float()) elif arg.begins_with("--net-sim-loss="): loss_fraction = clampf(arg.get_slice("=", 1).to_float(), 0.0, 1.0) elif arg.begins_with("--net-sim-dup="): dup_fraction = clampf(arg.get_slice("=", 1).to_float(), 0.0, 1.0) elif arg.begins_with("--net-sim-seed="): seed_value = arg.get_slice("=", 1).to_int() _rng.seed = seed_value func is_active() -> bool: return latency_ms > 0.0 or jitter_ms > 0.0 or loss_fraction > 0.0 or dup_fraction > 0.0 # target_peer_id: the specific remote peer this dispatch is addressed to # (rpc_id's target), or -1 for a broadcast / not a targeted send. Only used # to re-validate a delayed send right before it actually fires — see _fire. func send(dispatch: Callable, target_peer_id: int = -1) -> void: if not is_active(): dispatch.call() return if _rng.randf() < loss_fraction: return _schedule(dispatch, target_peer_id, (latency_ms + _rng.randf() * jitter_ms) / 1000.0) if _rng.randf() < dup_fraction: _schedule(dispatch, target_peer_id, (latency_ms + _rng.randf() * jitter_ms) / 1000.0) func _schedule(dispatch: Callable, target_peer_id: int, delay_sec: float) -> void: if delay_sec <= 0.0: dispatch.call() return # 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-next.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. # NetSim's whole point is to hold a packet in flight past the moment it was # queued, and in that window the target peer (or this process's own # connection) can legitimately be gone — a disconnect mid-match, or this # process's own shutdown() already having reset multiplayer_peer to a fresh # OfflineMultiplayerPeer. Firing anyway reproduced two real bugs while # building this task: "Attempt to call RPC with unknown peer ID" (stale # remote target — networked_match.gd's own get_peers() filter on # _broadcast_snapshot only checked validity at *schedule* time, and the # target had disconnected by the time the delayed send actually fired) and # "'_recv_input' on yourself is not allowed by selected mode" (this # process's own peer was already torn down, so peer id 1 now refers to # itself instead of the server). The synchronous (delay_sec <= 0 / NetSim # inactive) path is deliberately NOT re-validated here — nothing has had # 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()