class_name NetSim extends RefCounted ## A client-side network-condition simulator for the snapshot stream. ## ## The listen-server's snapshots reach a client over the local machine or a clean ## LAN with almost no latency, jitter, or loss — so the smoothing that exists to ## ride out a bad connection (interpolation and its adaptive delay) is never ## actually exercised in a playtest. This shapes the received snapshot stream as if ## it had crossed a worse link: it holds each snapshot for a base `latency` plus a ## random `jitter`, and drops a `loss` fraction outright. The result is irregular ## arrival gaps and holes the rest of the netcode then has to absorb, so the ## adaptive interpolation delay can be *seen* growing and the interpolation can be ## seen covering dropped snapshots. ## ## It conditions opaque payloads (it never inspects the snapshot) and takes arrival ## and release times as plain milliseconds rather than reading a clock, so — like ## the simulation, protocol, and interpolation cores — it is pure and unit-tested ## headlessly. Its randomness is seeded, so a given seed yields the same drop and ## jitter pattern every run, which keeps the tests deterministic and a playtest ## reproducible. ## ## It lives on the client's snapshot intake only: it shapes nothing the server ## sends out and changes no wire bytes, so PROTOCOL_VERSION is unaffected. The ## whole stream — what interpolation buffers and what prediction reconciles against ## alike — passes through it, so a simulated bad link degrades the client honestly ## rather than only cosmetically. ## Base hold applied to every accepted snapshot, in milliseconds: its release time ## is at least this far past its arrival. var _latency_ms: float ## Extra random hold on top of `_latency_ms`, in milliseconds: each snapshot is ## held an additional uniform `[0, _jitter_ms)`. This is what makes consecutive ## arrival gaps irregular, which is what the adaptive delay responds to. var _jitter_ms: float ## Fraction of snapshots dropped on arrival, in `[0, 1]` — a dropped snapshot is ## never queued and never released, leaving a hole the interpolation must cover. var _loss: float ## Seeded so the drop and jitter pattern is reproducible: deterministic for the ## tests and repeatable in a playtest. var _rng := RandomNumberGenerator.new() ## Snapshots accepted but not yet due, each `{release: float, data: Variant}` where ## `release` is the arrival time plus the hold. Not kept sorted; `drain` orders the ## packets it releases. var _pending: Array[Dictionary] = [] func _init(latency_ms: float, jitter_ms: float, loss: float, rng_seed: int) -> void: _latency_ms = maxf(0.0, latency_ms) _jitter_ms = maxf(0.0, jitter_ms) _loss = clampf(loss, 0.0, 1.0) _rng.seed = rng_seed ## Offers a freshly received snapshot, stamped with its arrival time in ## milliseconds. Returns false if the loss roll drops it (it is discarded), or true ## if it was queued to be released later at `arrival + latency + random jitter`. func receive(data: Variant, recv_msec: float) -> bool: if _rng.randf() < _loss: return false var release := recv_msec + _latency_ms + _rng.randf() * _jitter_ms _pending.append({"release": release, "data": data}) return true ## Releases every queued snapshot whose hold has elapsed by `now_msec`, oldest ## release first, and removes them from the queue. Each returned entry is ## `{release: float, data: Variant}`; the caller stamps the downstream buffer with ## `release` so the injected latency and jitter show up as real arrival timing. ## Jitter can reorder releases relative to arrival; the snapshot buffer downstream ## already drops a stale tick, so an overtaken snapshot is handled there. func drain(now_msec: float) -> Array: var due: Array = [] var kept: Array[Dictionary] = [] for packet in _pending: if packet["release"] <= now_msec: due.append(packet) else: kept.append(packet) _pending = kept due.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a["release"] < b["release"]) return due ## Whether any snapshot is held back waiting for its release time. func has_pending() -> bool: return not _pending.is_empty()