Files
tactical-shooter/server/scripts/net_sim.gd
T
shawn e7299b17e9 Phase 7: netfox + godot-jolt stack upgrade
Stack installed:
- netfox v1.35.3 (core + extras + noray + internals)
- godot-jolt v0.16.0-stable

Architecture:
- Server: ENet transport (works headless, no netfox deps)
- Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator)

New/modified:
- docs/migration-netfox-plan.md — migration architecture
- scripts/network/network_manager.gd — netfox-aware ENet fallback
- scripts/network/player.gd — clean base player
- client/characters/player_netfox.gd — rollback player w/ WeaponManager
- client/characters/input/player_net_input.gd — BaseNetInput subclass
- client/characters/character/fps_character_controller.gd — netfox input feed
- client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager
- client/scripts/round_replicator.gd — client-side round state bridge
- server/scripts/round_manager.gd — improved state machine
- server/scripts/plugin_api/plugin_manager.gd — refined plugin system
- config: enemy_tag, ally_tag for meatball targeting

Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
2026-07-02 17:39:22 -04:00

520 lines
18 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends Node
class_name NetSim
# ═══════════════════════════════════════════════════════════════════════════
# NetSim — Network Condition Simulator for ENet Traffic
#
# Wraps any MultiplayerPeer (ENet, WebSocket, Steam) and injects simulated
# adverse network conditions:
# - Packet loss (random % drop on send/receive)
# - Latency (fixed delay on all packets)
# - Jitter (variable added delay on each packet)
# - Packet reordering (swap delivery order within a window)
# - Packet duplication (random % duplicate on send)
# - Bandwidth throttling (cap bytes per second)
# - Burst loss (drop N consecutive packets every M packets)
#
# Architecture:
# NetSim sits BETWEEN the game and the real MultiplayerPeer.
# It wraps the peer object and intercepts put_packet() / get_packet(),
# applying simulated conditions before forwarding to/from the real peer.
#
# Usage:
# var real_peer = ENetMultiplayerPeer.new()
# real_peer.create_server(7777, 16)
# var sim = NetSim.new()
# sim.wrap_peer(real_peer)
# # configure conditions
# sim.packet_loss = 0.1 # 10% loss
# sim.latency_ms = 150 # 150ms round-trip
# sim.jitter_ms = 30 # ±30ms variable delay
# get_tree().set_multiplayer(sim.multiplayer_api, "/root/SimMp")
#
# For test/diagnostic use only — NEVER enable on production servers.
# ═══════════════════════════════════════════════════════════════════════════
# ─── Configuration ────────────────────────────────────────────────────────
# All probabilities are 0.01.0. All times in milliseconds.
## Probability a packet is silently dropped on send (0.0 = no loss, 1.0 = all lost)
@export var packet_loss: float = 0.0
## Additional fixed one-way latency applied to every packet (ms)
@export var latency_ms: float = 0.0
## Additional random jitter on top of latency (±jitter_ms, ms).
## Actual per-packet delay = latency_ms + randf_range(-jitter_ms, +jitter_ms)
@export var jitter_ms: float = 0.0
## Packet reorder window: number of consecutive packets buffered and shuffled
## before delivery. 0 = no reordering. Higher values = more aggressive reorder.
## (e.g., 3 means shuffle every 3-pack group on the receive side).
@export var reorder_window: int = 0
## Probability a packet is duplicated on send (0.0 = no duplicates, 1.0 = every packet doubled)
@export var duplicate_rate: float = 0.0
## Max outbound bandwidth in bytes/second (0 = unlimited)
@export var bandwidth_limit: int = 0
## Burst loss: drop N consecutive packets every M packets sent.
## Set to 0 to disable burst loss.
@export var burst_loss_count: int = 0
@export var burst_loss_interval: int = 0
## Enable verbose logging of every simulated condition hit
@export var verbose: bool = false
## Seed for deterministic simulation (useful for reproducible tests). -1 = random seed.
@export var seed_value: int = -1
# ─── Constants ────────────────────────────────────────────────────────────
const CHANNEL_UNRELIABLE: int = 0
const CHANNEL_RELIABLE: int = 1
const CHANNEL_ORDERED: int = 2
# ─── Internal State ───────────────────────────────────────
var _wrapped_peer: MultiplayerPeer = null
var _rng: RandomNumberGenerator
# Queues of {time: float, packet: PackedByteArray, channel: int}
# _outbound_queue: packets from game → network (delayed send)
# _inbound_queue: packets from network → game (delayed receive)
var _outbound_queue: Array[Dictionary] = []
var _inbound_queue: Array[Dictionary] = []
# Reorder buffer
var _reorder_buffer: Array[Dictionary] = []
# Bandwidth tracking
var _bandwidth_used_this_second: int = 0
var _bandwidth_timer: float = 0.0
# Burst loss tracking
var _packets_sent_since_burst: int = 0
var _burst_dropping: bool = false
var _burst_drop_remaining: int = 0
# Debug stats
var _total_sent: int = 0
var _total_received: int = 0
var _total_lost: int = 0
var _total_duplicated: int = 0
var _total_reordered: int = 0
var _total_delayed: int = 0
var _total_burst_dropped: int = 0
var _total_bandwidth_dropped: int = 0
# ─── Public API ───────────────────────────────────────────────────────────
func _init() -> void:
_rng = RandomNumberGenerator.new()
if seed_value >= 0:
_rng.seed = seed_value
else:
_rng.randomize()
## Wrap an existing MultiplayerPeer to inject simulated conditions.
## The wrapped peer's connection state (multiplayer_api) is replaced
## transparently — all game code interacts with NetSim as it would the real peer.
func wrap_peer(peer: MultiplayerPeer) -> void:
_wrapped_peer = peer
print("[NetSim] Wrapped peer: %s" % peer.get_class())
# Configure a custom MultiplayerAPI that delegates to us
if not Engine.has_singleton("NetSimDebug"):
# Just for logging
pass
reset_stats()
## Reset all stats counters (keeps configuration intact).
func reset_stats() -> void:
_total_sent = 0
_total_received = 0
_total_lost = 0
_total_duplicated = 0
_total_reordered = 0
_total_delayed = 0
_total_burst_dropped = 0
_total_bandwidth_dropped = 0
_outbound_queue.clear()
_inbound_queue.clear()
_reorder_buffer.clear()
_bandwidth_used_this_second = 0
_bandwidth_timer = 0.0
_packets_sent_since_burst = 0
_burst_dropping = false
_burst_drop_remaining = 0
## Get debug stats as a formatted string.
func get_stats_string() -> String:
var lines: PackedStringArray = []
lines.append("=== NetSim Stats ===")
lines.append(" Sent: %d" % _total_sent)
lines.append(" Received: %d" % _total_received)
lines.append(" Lost (loss): %d" % _total_lost)
lines.append(" Lost (burst): %d" % _total_burst_dropped)
lines.append(" Lost (bw): %d" % _total_bandwidth_dropped)
lines.append(" Duplicated: %d" % _total_duplicated)
lines.append(" Reordered: %d" % _total_reordered)
lines.append(" Delayed: %d" % _total_delayed)
var effective_loss = 0.0
var total_accounted = _total_sent + _total_duplicated
if total_accounted > 0:
effective_loss = float(_total_lost + _total_burst_dropped + _total_bandwidth_dropped) / float(total_accounted) * 100.0
lines.append(" Effective loss: %.1f%%" % effective_loss)
lines.append(" Config: loss=%.1f%% lat=%dms jitter=±%dms reorder=%d dupe=%.1f%% bw=%d/s burst=%d/%d" % [
packet_loss * 100.0, int(latency_ms), int(jitter_ms),
reorder_window, duplicate_rate * 100.0, bandwidth_limit,
burst_loss_count, burst_loss_interval
])
return "\n".join(lines)
## Apply a quick-set of common network profiles.
## "lan" — 0% loss, 1ms latency, 0 jitter
## "dsl" — 0.5% loss, 20ms latency, ±5ms jitter
## "cellular" — 2% loss, 60ms latency, ±20ms jitter
## "satellite" — 1% loss, 600ms latency, ±50ms jitter
## "congested" — 5% loss, 100ms latency, ±40ms jitter, some reordering
## "warzone" — 10% loss, 200ms latency, ±60ms jitter, burst loss, reorder
func set_profile(name: String) -> bool:
match name.to_lower():
"lan":
packet_loss = 0.0; latency_ms = 1; jitter_ms = 0
reorder_window = 0; duplicate_rate = 0.0; bandwidth_limit = 0
burst_loss_count = 0; burst_loss_interval = 0
return true
"dsl":
packet_loss = 0.005; latency_ms = 20; jitter_ms = 5
reorder_window = 0; duplicate_rate = 0.0; bandwidth_limit = 0
burst_loss_count = 0; burst_loss_interval = 0
return true
"cellular":
packet_loss = 0.02; latency_ms = 60; jitter_ms = 20
reorder_window = 0; duplicate_rate = 0.0; bandwidth_limit = 0
burst_loss_count = 0; burst_loss_interval = 0
return true
"satellite":
packet_loss = 0.01; latency_ms = 600; jitter_ms = 50
reorder_window = 0; duplicate_rate = 0.0; bandwidth_limit = 0
burst_loss_count = 0; burst_loss_interval = 0
return true
"congested":
packet_loss = 0.05; latency_ms = 100; jitter_ms = 40
reorder_window = 3; duplicate_rate = 0.01; bandwidth_limit = 0
burst_loss_count = 0; burst_loss_interval = 0
return true
"warzone":
packet_loss = 0.10; latency_ms = 200; jitter_ms = 60
reorder_window = 4; duplicate_rate = 0.02; bandwidth_limit = 50000
burst_loss_count = 3; burst_loss_interval = 100
return true
_:
push_error("[NetSim] Unknown profile: '%s'" % name)
return false
# ─── Per-Frame Processing ─────────────────────────────────
func _process(delta: float) -> void:
if not _wrapped_peer:
return
var now = Time.get_ticks_msec()
# --- Process delayed outbound packets ---
# Forward expired packets to the real peer
var to_send: Array[int] = []
for i in _outbound_queue.size():
if now >= _outbound_queue[i].time:
to_send.append(i)
# Send in reverse index order so removal doesn't shift earlier indices
for i in to_send.size():
var idx = to_send[to_send.size() - 1 - i]
var entry = _outbound_queue[idx]
_send_to_real_peer(entry.packet, entry.channel)
_outbound_queue.remove_at(idx)
# --- Process real peer inbound ---
# Read packets from the real peer and queue them for the game
# with inbound simulation
while _wrapped_peer.get_available_packet_count() > 0:
var packet = _wrapped_peer.get_packet()
_total_received += 1
_queue_delayed_inbound(packet, CHANNEL_RELIABLE) # actual channel unknown
# --- Process delayed inbound packets ---
# Reorder buffer flush
_flush_reorder_buffer()
# Note: delayed inbound packets are consumed by receive_packet() directly
# from _inbound_queue — no separate delivery step needed
## Must be called from the game's _process (not physics) to keep latency
## timing accurate. Alternative: connect to SceneTree's idle signal.
func process_conditions(delta: float) -> void:
_process(delta)
# ─── Packet Interception (call from game code) ────────────────────────────
# The game should call these instead of directly calling put_packet/get_packet
# on the multiplayer peer. Alternatively, implement a full MultiplayerPeer
# override — see the companion `net_sim_peer.gd` for the full adapter.
## Simulate sending a packet. Returns true if the packet was queued/dropped.
func send_packet(data: PackedByteArray, channel: int = CHANNEL_UNRELIABLE) -> bool:
if not _wrapped_peer:
return false
_total_sent += 1
# ── 1. Burst loss check ──
if _check_burst_loss():
_total_burst_dropped += 1
if verbose:
print("[NetSim] BURST DROP (packet #%d)" % _total_sent)
return false # packet dropped
# ── 2. Random loss ──
if _rng.randf() < packet_loss:
_total_lost += 1
if verbose:
print("[NetSim] LOST (packet #%d, seq=%d, loss=%.1f%%)" % [_total_sent, _total_sent, packet_loss * 100.0])
return false # packet dropped
# ── 3. Bandwidth check ──
if bandwidth_limit > 0 and _check_bandwidth(data.size()):
_total_bandwidth_dropped += 1
if verbose:
print("[NetSim] BW DROP (packet #%d, %d bytes, limit=%d/s)" % [_total_sent, data.size(), bandwidth_limit])
return false # packet dropped
# ── 4. Duplication ──
if _rng.randf() < duplicate_rate:
_total_duplicated += 1
if verbose:
print("[NetSim] DUPLICATE (packet #%d)" % _total_sent)
# Send the duplicate (with same delay treatment)
_queue_delayed_outbound(data, channel)
# ── 5. Queue for latency/jitter ──
_queue_delayed_outbound(data, channel)
return true
## Simulate receiving a packet. Returns PackedByteArray or empty if none available.
## First checks the inbound delay queue for expired packets, then falls
## through to the real peer for immediate (non-simulated) delivery.
func receive_packet() -> PackedByteArray:
if not _wrapped_peer:
return PackedByteArray()
var now = Time.get_ticks_msec()
# Check delayed inbound queue first (simulated receive)
if _inbound_queue.size() > 0 and _inbound_queue[0].time <= now:
var pkt = _inbound_queue[0].packet
_inbound_queue.remove_at(0)
return pkt
# Fall through to real peer for immediate delivery
if _wrapped_peer.get_available_packet_count() > 0:
return _wrapped_peer.get_packet()
return PackedByteArray()
## Check if any packet is available for receiving.
## Checks delayed inbound queue, then real peer.
func has_packet() -> bool:
if not _wrapped_peer:
return false
var now = Time.get_ticks_msec()
# Check delayed inbound queue
for pkt in _inbound_queue:
if pkt.time <= now:
return true
# Check real peer
if _wrapped_peer.get_available_packet_count() > 0:
return true
return false
## Get total pending packet count (inbound and outbound).
func get_pending_count() -> int:
return _inbound_queue.size() + _outbound_queue.size()
## Check if the wrapped peer is properly connected.
func is_peer_connected() -> bool:
if _wrapped_peer:
return _wrapped_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
return false
# ─── Bandwidth tracking ───────────────────────────────────────────────────
func _check_bandwidth(packet_size: int) -> bool:
var now = Time.get_ticks_msec() / 1000.0
if now - _bandwidth_timer >= 1.0:
_bandwidth_timer = now
_bandwidth_used_this_second = 0
if _bandwidth_used_this_second + packet_size > bandwidth_limit:
return true # drop
_bandwidth_used_this_second += packet_size
return false
# ─── Burst loss ──────────────────────────────────────────────────────────
func _check_burst_loss() -> bool:
if burst_loss_count <= 0 or burst_loss_interval <= 0:
return false
if _burst_dropping:
_burst_drop_remaining -= 1
if _burst_drop_remaining <= 0:
_burst_dropping = false
return true
_packets_sent_since_burst += 1
if _packets_sent_since_burst >= burst_loss_interval:
_packets_sent_since_burst = 0
_burst_dropping = true
_burst_drop_remaining = burst_loss_count - 1 # current packet is also dropped
return true
return false
# ─── Reorder buffer ──────────────────────────────────────────────────────
return a.time < b.time
# ─── Reorder buffer ──────────────────────────────────────────────────────
func _flush_reorder_buffer() -> void:
if reorder_window <= 0:
return
if _reorder_buffer.size() >= reorder_window:
_total_reordered += _reorder_buffer.size()
_reorder_buffer.shuffle()
for pkt in _reorder_buffer:
# Re-inject shuffled packets at front of inbound queue
_inbound_queue.push_front(pkt)
_inbound_queue.sort_custom(_sort_by_time)
_reorder_buffer.clear()
# ─── Direct peer passthrough ──────────────────────────────────────────────
## Forward a packet to the real peer immediately (no simulation).
func _send_to_real_peer(data: PackedByteArray, channel: int) -> void:
if _wrapped_peer and _wrapped_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED:
_wrapped_peer.put_packet(data)
# ─── Latency queue (outbound) ────────────────────────────
## Queue a packet for delayed delivery to the real network peer.
func _queue_delayed_outbound(data: PackedByteArray, channel: int) -> void:
var delay_ms = latency_ms
if jitter_ms > 0.0:
delay_ms += _rng.randf_range(-jitter_ms, jitter_ms)
delay_ms = max(0.0, delay_ms)
var deliver_at = Time.get_ticks_msec() + delay_ms
var entry := {
"time": deliver_at,
"packet": data,
"channel": channel,
}
if delay_ms > 0:
_total_delayed += 1
_outbound_queue.append(entry)
_outbound_queue.sort_custom(_sort_by_time)
# ─── Latency queue (inbound) ─────────────────────────────
## Queue a packet for delayed delivery to the game (from the real network).
func _queue_delayed_inbound(data: PackedByteArray, channel: int) -> void:
var delay_ms = 0.0
if jitter_ms > 0.0:
delay_ms = _rng.randf_range(-jitter_ms, jitter_ms)
delay_ms = max(0.0, delay_ms)
var deliver_at = Time.get_ticks_msec() + delay_ms
var entry := {
"time": deliver_at,
"packet": data,
"channel": channel,
}
if delay_ms > 0:
_total_delayed += 1
_inbound_queue.append(entry)
_inbound_queue.sort_custom(_sort_by_time)
# ─── Utility ──────────────────────────────────────────────────────────────
## Generate a test packet with the given sequence data.
static func make_test_packet(seq: int, data: String = "") -> PackedByteArray:
var payload = { "seq": seq, "data": data, "time": Time.get_ticks_usec() }
var json_str = JSON.stringify(payload)
return json_str.to_utf8_buffer()
## Decode a test packet from the simulator.
static func decode_test_packet(data: PackedByteArray) -> Dictionary:
var text = data.get_string_from_utf8()
var json = JSON.new()
var err = json.parse(text)
if err == OK and json.data is Dictionary:
return json.data
return {}
## Generate a packet loss report as a formatted string.
static func format_test_report(results: Array) -> String:
var lines: PackedStringArray = []
var total = results.size()
var received = 0
var min_seq = 0
var max_seq = 0
var gaps: Array[int] = []
for r in results:
if r.get("received", false):
received += 1
var loss_pct = 0.0 if total == 0 else (1.0 - float(received) / float(total)) * 100.0
lines.append("=== Test Report ===")
lines.append(" Sent: %d" % total)
lines.append(" Received: %d (%.1f%%)" % [received, 100.0 - loss_pct])
lines.append(" Lost: %d" % (total - received))
lines.append(" Loss rate: %.1f%%" % loss_pct)
return "\n".join(lines)