mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(multiplayer): task 2.8 net_sim.gd, close out Phase 2
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.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
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=<ms> one-way delay added before each wrapped send
|
||||
# --net-sim-jitter=<ms> 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=<int> 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
|
||||
get_tree().create_timer(delay_sec, false).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.
|
||||
func _fire(dispatch: Callable, target_peer_id: int) -> void:
|
||||
var peer := multiplayer.multiplayer_peer
|
||||
if peer == null or peer is OfflineMultiplayerPeer:
|
||||
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