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)
This commit is contained in:
@@ -0,0 +1,677 @@
|
||||
extends Node
|
||||
class_name AntiCheat
|
||||
|
||||
# ═════════════════════════════════════════════════════════
|
||||
# Server-authoritative anti-cheat validation coordinator
|
||||
#
|
||||
# Per-player state tracking and input validation pipeline.
|
||||
# Called from the server's _physics_process per player per tick.
|
||||
#
|
||||
# Validation pipeline order:
|
||||
# 1. Input sequence (timestamp monotonic, seq# dedup)
|
||||
# 2. Movement (speed limit, teleport, multi-jump)
|
||||
# 3. Aim (max snap rotation per tick)
|
||||
# 4. Fire rate (interval between shots, ammo consistency)
|
||||
# 5. Command rate (commands/second limit)
|
||||
# 6. Enforcement (log / correct / warn / kick)
|
||||
#
|
||||
# Designed to be loaded once on the server and called per tick
|
||||
# from the game's player state update loop.
|
||||
# ═════════════════════════════════════════════════════════
|
||||
|
||||
# ─── Enforcement Levels ──────────────────────────────────
|
||||
enum AcLevel {
|
||||
OFF = 0, # Disabled — no validation performed
|
||||
LOG = 1, # Log violations to server console only
|
||||
CORRECT = 2, # Correct invalid input + warn player (default)
|
||||
KICK = 3, # Kick player on threshold breach
|
||||
}
|
||||
|
||||
# ─── Violation Categories ────────────────────────────────
|
||||
enum ViolationCategory {
|
||||
INPUT_SEQUENCE, # Bad sequence / replay / timestamp
|
||||
MOVEMENT_SPEED, # Exceeded speed limit
|
||||
MOVEMENT_TELEPORT, # Instant position change
|
||||
MOVEMENT_MULTI_JUMP, # Jump without ground contact
|
||||
AIM_SNAP, # View-angle delta exceeds limit
|
||||
FIRE_RATE_INTERVAL, # Shots too close together
|
||||
FIRE_RATE_AMMO, # Ammo delta inconsistent with shot
|
||||
COMMAND_RATE, # > max_commands/sec from client
|
||||
}
|
||||
|
||||
# ─── Cvars (exported for inspector; override via config system) ──
|
||||
|
||||
## Enforcement level (0=off, 1=log, 2=correct, 3=kick)
|
||||
@export_range(0, 3, 1) var sv_ac_level: int = 2
|
||||
|
||||
## Speed multiplier before flagging (1.2 = 20% over max)
|
||||
@export_range(1.0, 3.0, 0.05) var sv_ac_speed_tolerance: float = 1.2
|
||||
|
||||
## Max view-angle delta in degrees per tick
|
||||
@export_range(10.0, 360.0, 1.0) var sv_ac_max_aim_snap: float = 180.0
|
||||
|
||||
## Fire interval tolerance in ms above weapon's fire rate
|
||||
@export_range(0, 100, 1) var sv_ac_fire_tolerance_ms: int = 10
|
||||
|
||||
## Max commands from one client per second
|
||||
@export_range(64, 254, 1) var sv_ac_command_rate_max: int = 128
|
||||
|
||||
## Max distance units per tick before flagged as teleport
|
||||
@export_range(10.0, 500.0, 1.0) var sv_ac_teleport_threshold: float = 100.0
|
||||
|
||||
## Violations before kick (only at AcLevel.KICK)
|
||||
@export_range(1, 50, 1) var sv_ac_kick_threshold: int = 5
|
||||
|
||||
## Enable/disable multi-jump check
|
||||
@export var sv_ac_check_multi_jump: bool = true
|
||||
|
||||
## Enable/disable ammo consistency check
|
||||
@export var sv_ac_check_ammo: bool = true
|
||||
|
||||
# ─── Signals ─────────────────────────────────────────────
|
||||
|
||||
## Emitted when a violation triggers a kick decision.
|
||||
signal player_kicked(peer_id: int, reason: String, violation_count: int)
|
||||
|
||||
## Emitted on every validated violation (at all enforcement levels).
|
||||
signal violation_recorded(peer_id: int, category: int, details: String)
|
||||
|
||||
## Emitted when the anti-cheat corrects an input field.
|
||||
signal input_corrected(peer_id: int, field: String, original_value, clamped_value)
|
||||
|
||||
# ─── Per-Player Tracked State ────────────────────────────
|
||||
|
||||
class PlayerState:
|
||||
var peer_id: int
|
||||
var last_seq: int = -1
|
||||
var last_timestamp: float = 0.0
|
||||
var last_position: Vector3 = Vector3.ZERO
|
||||
var last_view_angles: Vector2 = Vector2.ZERO
|
||||
var last_fire_time: float = 0.0
|
||||
var last_ammo: int = -1
|
||||
var on_ground: bool = true # server-authoritative (set via set_ground_contact)
|
||||
var jump_count: int = 0 # consecutive airborne jumps
|
||||
var last_fire_pressed: bool = false
|
||||
var _aim_initialized: bool = false # has aim been recorded at least once?
|
||||
var command_times: Array[float] = []
|
||||
var violation_count: int = 0
|
||||
var violations_log: Array[Dictionary] = []
|
||||
var connected_at: float = 0.0
|
||||
|
||||
func _init(id: int, time: float):
|
||||
peer_id = id
|
||||
connected_at = time
|
||||
|
||||
func reset(time: float) -> void:
|
||||
last_seq = -1
|
||||
last_timestamp = 0.0
|
||||
last_position = Vector3.ZERO
|
||||
last_view_angles = Vector2.ZERO
|
||||
last_fire_time = 0.0
|
||||
last_ammo = -1
|
||||
on_ground = true
|
||||
jump_count = 0
|
||||
last_fire_pressed = false
|
||||
_aim_initialized = false
|
||||
command_times.clear()
|
||||
violation_count = 0
|
||||
violations_log.clear()
|
||||
connected_at = time
|
||||
|
||||
# ─── Internal State ──────────────────────────────────────
|
||||
|
||||
var _player_states: Dictionary = {} # peer_id → PlayerState
|
||||
|
||||
# ─── Public API ──────────────────────────────────────────
|
||||
|
||||
func register_player(peer_id: int) -> void:
|
||||
"""Register a new player for anti-cheat tracking.
|
||||
|
||||
Call when a player fully connects to the server.
|
||||
The player's state is initialized fresh.
|
||||
"""
|
||||
var state = PlayerState.new(peer_id, Time.get_ticks_usec() / 1_000_000.0)
|
||||
_player_states[peer_id] = state
|
||||
|
||||
|
||||
func unregister_player(peer_id: int) -> void:
|
||||
"""Remove a player from anti-cheat tracking.
|
||||
|
||||
Call on player disconnect to clean up state.
|
||||
"""
|
||||
_player_states.erase(peer_id)
|
||||
|
||||
|
||||
func is_tracking(peer_id: int) -> bool:
|
||||
"""Returns true if peer_id is being tracked."""
|
||||
return _player_states.has(peer_id)
|
||||
|
||||
|
||||
func validate_input(peer_id: int, input_packet: Dictionary, delta: float) -> Dictionary:
|
||||
"""Run full validation pipeline on a player's input packet.
|
||||
|
||||
Parameters:
|
||||
peer_id: Network peer ID of the player
|
||||
input_packet: Dict with keys:
|
||||
seq (int) — monotonic sequence number
|
||||
time (float) — client timestamp (seconds)
|
||||
movement (Vector3) — movement direction
|
||||
view_angles (Vector2) — camera pitch/yaw
|
||||
buttons (int) — bitmask: 1=jump, 2=fire, 4=crouch, 8=use
|
||||
ammo (int) — current weapon ammo (-1 if unknown/melee)
|
||||
position (Vector3) — player world position
|
||||
max_speed (float, optional) — current max player speed
|
||||
weapon_fire_rate_ms (float, optional) — current fire interval
|
||||
burst_count (int, optional) — ammo per burst (default: 1)
|
||||
delta: Physics tick delta (e.g. 1/128 ≈ 0.0078)
|
||||
|
||||
Integration note:
|
||||
After calling validate_input() and applying corrected input
|
||||
to physics, call set_ground_contact(peer_id, on_ground) to
|
||||
furnish server-authoritative ground-state for the next tick's
|
||||
multi-jump validation.
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
corrected (Dictionary) — cleaned input packet (may have clamped fields)
|
||||
violations (Array[Dictionary]) — violations detected this tick
|
||||
action_taken (String) — "" / "warned" / "kicked"
|
||||
"""
|
||||
var result = {
|
||||
"corrected": input_packet.duplicate(),
|
||||
"violations": [],
|
||||
"action_taken": "",
|
||||
}
|
||||
|
||||
if sv_ac_level == AcLevel.OFF or not _player_states.has(peer_id):
|
||||
return result
|
||||
|
||||
var state: PlayerState = _player_states[peer_id]
|
||||
|
||||
# ── Pipeline ──
|
||||
|
||||
# 1. Input sequence validation (always runs)
|
||||
_result_seq(_validate_sequence(input_packet, state, result["corrected"]), peer_id, state, result)
|
||||
|
||||
# 2. Movement validation
|
||||
_result_mv(_validate_movement(input_packet, state, delta, result["corrected"]), peer_id, state, result)
|
||||
|
||||
# 3. Aim validation
|
||||
_result_aim(_validate_aim(input_packet, state, result["corrected"]), peer_id, state, result)
|
||||
|
||||
# 4. Fire rate validation
|
||||
_result_fire(_validate_fire_rate(input_packet, state, result["corrected"]), peer_id, state, result)
|
||||
|
||||
# 5. Command rate validation
|
||||
_result_cmd(_validate_command_rate(input_packet, state), peer_id, state, result)
|
||||
|
||||
# 6. Ammo validation
|
||||
if sv_ac_check_ammo:
|
||||
_result_ammo(_validate_ammo(input_packet, state, result["corrected"]), peer_id, state, result)
|
||||
|
||||
# ── Enforcement (post-pipeline) ──
|
||||
if state.violation_count > 0 and result["violations"].size() > 0:
|
||||
# Set warned flag at any level above OFF
|
||||
if sv_ac_level >= AcLevel.CORRECT:
|
||||
result["action_taken"] = "warned"
|
||||
|
||||
# Kick decision — at KICK level when threshold breached
|
||||
if sv_ac_level >= AcLevel.KICK and state.violation_count >= sv_ac_kick_threshold:
|
||||
var reason: String = "Anti-cheat violation threshold reached (%d/%d)" % [
|
||||
state.violation_count, sv_ac_kick_threshold
|
||||
]
|
||||
player_kicked.emit(peer_id, reason, state.violation_count)
|
||||
result["action_taken"] = "kicked"
|
||||
|
||||
# ── Post-validation bookkeeping ──
|
||||
if input_packet.has("position") and typeof(input_packet.position) == TYPE_VECTOR3:
|
||||
state.last_position = result["corrected"].get("position", input_packet.position)
|
||||
|
||||
if input_packet.has("time"):
|
||||
state.last_timestamp = input_packet.time
|
||||
|
||||
if input_packet.has("view_angles"):
|
||||
state.last_view_angles = result["corrected"].get("view_angles", input_packet.view_angles)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func get_player_violations(peer_id: int) -> Array:
|
||||
"""Return a copy of the violation log for a player."""
|
||||
if not _player_states.has(peer_id):
|
||||
return []
|
||||
return _player_states[peer_id].violations_log.duplicate()
|
||||
|
||||
|
||||
func get_player_violation_count(peer_id: int) -> int:
|
||||
"""Return the total violation count for a player."""
|
||||
if not _player_states.has(peer_id):
|
||||
return 0
|
||||
return _player_states[peer_id].violation_count
|
||||
|
||||
|
||||
func reset_player_violations(peer_id: int) -> void:
|
||||
"""Reset violation counter for a player (e.g. after admin pardon)."""
|
||||
if _player_states.has(peer_id):
|
||||
var s: PlayerState = _player_states[peer_id]
|
||||
s.violation_count = 0
|
||||
s.violations_log.clear()
|
||||
|
||||
|
||||
func clear_all() -> void:
|
||||
"""Remove all tracked players."""
|
||||
_player_states.clear()
|
||||
|
||||
|
||||
func set_ground_contact(peer_id: int, on_ground: bool) -> void:
|
||||
"""Set the server-authoritative ground-contact state for a player.
|
||||
|
||||
Must be called by the game server AFTER physics simulation each tick,
|
||||
so the anti-cheat uses authoritative ground state for multi-jump
|
||||
detection on the next tick.
|
||||
"""
|
||||
if _player_states.has(peer_id):
|
||||
_player_states[peer_id].on_ground = on_ground
|
||||
|
||||
|
||||
# ─── Individual Validators ───────────────────────────────
|
||||
|
||||
func _validate_sequence(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary:
|
||||
"""Check sequence monotonicity and timestamp monotonicity.
|
||||
|
||||
Returns violation Dictionaries or null.
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
var seq: int = packet.get("seq", -1)
|
||||
var time: float = packet.get("time", 0.0)
|
||||
|
||||
# First packet always accepted
|
||||
if state.last_seq == -1:
|
||||
state.last_seq = seq
|
||||
state.last_timestamp = time
|
||||
return {"violations": violations, "invalid": false}
|
||||
|
||||
var invalid := false
|
||||
|
||||
# Sequence must increase monotonically
|
||||
if seq <= state.last_seq:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.INPUT_SEQUENCE,
|
||||
"Non-monotonic sequence: %d → %d" % [state.last_seq, seq],
|
||||
))
|
||||
invalid = true
|
||||
|
||||
# Timestamp must increase monotonically
|
||||
if time < state.last_timestamp:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.INPUT_SEQUENCE,
|
||||
"Timestamp regression: %.4f → %.4f" % [state.last_timestamp, time],
|
||||
))
|
||||
invalid = true
|
||||
|
||||
# Update state on valid (or lightly correct)
|
||||
if not invalid:
|
||||
state.last_seq = seq
|
||||
state.last_timestamp = time
|
||||
else:
|
||||
# Correct: bump seq forward so we don't accumulate stale state
|
||||
if seq <= state.last_seq:
|
||||
corrected["seq"] = state.last_seq + 1
|
||||
|
||||
return {"violations": violations, "invalid": invalid}
|
||||
|
||||
|
||||
func _validate_movement(packet: Dictionary, state: PlayerState, delta: float, corrected: Dictionary) -> Dictionary:
|
||||
"""Check speed limit, teleport, and multi-jump.
|
||||
|
||||
The player's max speed is defined by the movement controller.
|
||||
We use a configurable tolerance multiplier.
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
if not packet.has("position") or typeof(packet.position) != TYPE_VECTOR3:
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
var pos: Vector3 = packet.position
|
||||
var prev_pos: Vector3 = state.last_position
|
||||
|
||||
var displacement: Vector3 = pos - prev_pos
|
||||
var distance: float = displacement.length()
|
||||
|
||||
# ── Teleport check ──
|
||||
if distance > sv_ac_teleport_threshold and state.last_seq != -1:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.MOVEMENT_TELEPORT,
|
||||
"Teleport: moved %.1f units in one tick (threshold %.1f)" % [distance, sv_ac_teleport_threshold],
|
||||
{"distance": distance, "threshold": sv_ac_teleport_threshold},
|
||||
))
|
||||
# Correct: reset position to previous
|
||||
corrected["position"] = prev_pos
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
# ── Speed check ──
|
||||
# Max speed varies by weapon/game state; use a configurable tolerance
|
||||
# over the assumed base speed. The actual game should pass its max_speed
|
||||
# to validate_input(...) or we use a cvars-driven estimate.
|
||||
var max_speed_units_per_sec: float = _resolve_max_speed(packet)
|
||||
var max_speed_this_tick: float = max_speed_units_per_sec * sv_ac_speed_tolerance * delta
|
||||
|
||||
var speed: float = distance / max(delta, 0.001)
|
||||
|
||||
if distance > max_speed_this_tick and state.last_seq != -1:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.MOVEMENT_SPEED,
|
||||
"Speed: %.1f u/s (max %.1f u/s × %.1f tolerance)" % [speed, max_speed_units_per_sec, sv_ac_speed_tolerance],
|
||||
{"speed": speed, "max_allowed": max_speed_this_tick / delta},
|
||||
))
|
||||
|
||||
# Correct: clamp displacement to max permitted
|
||||
if distance > 0.001:
|
||||
var clamped_displacement: Vector3 = displacement.normalized() * min(distance, max_speed_this_tick)
|
||||
corrected["position"] = prev_pos + clamped_displacement
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
# ── Multi-jump check ──
|
||||
# jump_count only resets on ground contact, NOT on button release,
|
||||
# to prevent tap-jump bypass (pressing jump repeatedly while airborne).
|
||||
if sv_ac_check_multi_jump:
|
||||
var jump_pressed: bool = (packet.get("buttons", 0) & 1) != 0
|
||||
var was_on_ground: bool = state.on_ground
|
||||
|
||||
if was_on_ground:
|
||||
state.jump_count = 0
|
||||
elif jump_pressed:
|
||||
state.jump_count += 1
|
||||
|
||||
var max_jumps: int = 1 # only one jump allowed without ground contact
|
||||
if not was_on_ground and state.jump_count > max_jumps:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.MOVEMENT_MULTI_JUMP,
|
||||
"Multi-jump: %d jumps without ground contact" % state.jump_count,
|
||||
{"jump_count": state.jump_count, "max_jumps": max_jumps},
|
||||
))
|
||||
# Correct: suppress the jump flag
|
||||
corrected["buttons"] = packet.get("buttons", 0) & ~1
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
|
||||
func _validate_aim(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary:
|
||||
"""Check view-angle delta per tick doesn't exceed max snap.
|
||||
|
||||
view_angles is Vector2(pitch, yaw) in degrees.
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
if not packet.has("view_angles"):
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
var angles: Vector2 = packet.view_angles
|
||||
|
||||
# First tick — just record (no aim-initialized state yet)
|
||||
if not state._aim_initialized:
|
||||
state._aim_initialized = true
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
# Compute shortest angular distance per axis, handling yaw wraparound
|
||||
var delta_angles := Vector2()
|
||||
for axis in [0, 1]:
|
||||
var raw_delta: float = angles[axis] - state.last_view_angles[axis]
|
||||
var wrapped_delta: float = fmod(raw_delta, 360.0)
|
||||
if wrapped_delta > 180.0:
|
||||
wrapped_delta -= 360.0
|
||||
elif wrapped_delta < -180.0:
|
||||
wrapped_delta += 360.0
|
||||
delta_angles[axis] = abs(wrapped_delta)
|
||||
var max_delta: float = max(delta_angles.x, delta_angles.y)
|
||||
|
||||
if max_delta > sv_ac_max_aim_snap and state.last_seq != -1:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.AIM_SNAP,
|
||||
"Aim snap: %.1f° in one tick (max %.1f°)" % [max_delta, sv_ac_max_aim_snap],
|
||||
{"delta": max_delta, "max_allowed": sv_ac_max_aim_snap},
|
||||
))
|
||||
|
||||
# Correct: clamp delta per axis, handling yaw wraparound
|
||||
var clamped_angles := state.last_view_angles
|
||||
for axis in [0, 1]:
|
||||
var diff: float = angles[axis] - state.last_view_angles[axis]
|
||||
# Shortest-path diff, handling wraparound
|
||||
var wrapped_diff: float = fmod(diff, 360.0)
|
||||
if wrapped_diff > 180.0:
|
||||
wrapped_diff -= 360.0
|
||||
elif wrapped_diff < -180.0:
|
||||
wrapped_diff += 360.0
|
||||
# If the shortest path is within snap limit, allow it; otherwise clamp raw diff
|
||||
if abs(wrapped_diff) > sv_ac_max_aim_snap:
|
||||
var clamped_diff: float = clamp(diff, -sv_ac_max_aim_snap, sv_ac_max_aim_snap)
|
||||
clamped_angles[axis] = state.last_view_angles[axis] + clamped_diff
|
||||
|
||||
corrected["view_angles"] = clamped_angles
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
|
||||
func _validate_fire_rate(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary:
|
||||
"""Check minimum interval between shots.
|
||||
|
||||
Weapon fire rate is represented as min_interval_ms.
|
||||
If the game provides it in the packet, great; otherwise use a
|
||||
reasonable minimum (based on fastest weapon in the game: ~100ms = 600rpm).
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
var buttons: int = packet.get("buttons", 0)
|
||||
var fire_pressed: bool = (buttons & 2) != 0
|
||||
|
||||
if not fire_pressed:
|
||||
state.last_fire_pressed = false
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
var now: float = packet.get("time", Time.get_ticks_usec() / 1_000_000.0)
|
||||
var elapsed_ms: float = (now - state.last_fire_time) * 1000.0
|
||||
|
||||
# If weapon provides its fire interval, use it; else use a reasonable min
|
||||
var weapon_min_interval_ms: float = packet.get("weapon_fire_rate_ms", 80.0)
|
||||
var allowed_interval_ms: float = weapon_min_interval_ms - sv_ac_fire_tolerance_ms
|
||||
|
||||
# First fire always accepted
|
||||
if state.last_fire_time <= 0.0:
|
||||
state.last_fire_time = now
|
||||
state.last_fire_pressed = true
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
if elapsed_ms < allowed_interval_ms:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.FIRE_RATE_INTERVAL,
|
||||
"Fire rate: %.1fms interval (min allowed %.1fms)" % [elapsed_ms, allowed_interval_ms],
|
||||
{"elapsed_ms": elapsed_ms, "min_allowed_ms": allowed_interval_ms},
|
||||
))
|
||||
|
||||
# Correct: suppress fire button
|
||||
corrected["buttons"] = buttons & ~2
|
||||
state.last_fire_pressed = false
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
state.last_fire_time = now
|
||||
state.last_fire_pressed = true
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
|
||||
func _validate_command_rate(packet: Dictionary, state: PlayerState) -> Dictionary:
|
||||
"""Limit commands per second from a client.
|
||||
|
||||
Commands include any client→server packet. We track a sliding
|
||||
window of recent command timestamps.
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
var now: float = packet.get("time", Time.get_ticks_usec() / 1_000_000.0)
|
||||
var window_start: float = now - 1.0 # 1 second window
|
||||
|
||||
# Prune old entries
|
||||
var i: int = 0
|
||||
while i < state.command_times.size():
|
||||
if state.command_times[i] < window_start:
|
||||
state.command_times.remove_at(i)
|
||||
else:
|
||||
i += 1
|
||||
|
||||
state.command_times.append(now)
|
||||
|
||||
# Allow a small burst at the start of a connection
|
||||
var is_new_connection: bool = (now - state.connected_at) < 3.0
|
||||
var burst_allowed: int = 4
|
||||
var limit: int = sv_ac_command_rate_max + (burst_allowed if is_new_connection else 0)
|
||||
|
||||
if state.command_times.size() > limit:
|
||||
# Log one violation per excess over the limit
|
||||
var excess: int = state.command_times.size() - limit
|
||||
if excess > 0:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.COMMAND_RATE,
|
||||
"Command rate: %d/s (max %d/s)" % [state.command_times.size(), sv_ac_command_rate_max],
|
||||
{"actual_rate": state.command_times.size(), "max_rate": sv_ac_command_rate_max},
|
||||
))
|
||||
|
||||
# Keep window bounded — single cleanup pass
|
||||
while state.command_times.size() > limit + 8:
|
||||
state.command_times.remove_at(0)
|
||||
|
||||
return {"violations": violations}
|
||||
|
||||
|
||||
func _validate_ammo(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary:
|
||||
"""Check ammo delta consistency.
|
||||
|
||||
If the player fired last tick, ammo should decrease by at most 1
|
||||
(or by burst count if provided).
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
if not packet.has("ammo"):
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
var ammo: int = packet.ammo
|
||||
if state.last_ammo == -1:
|
||||
state.last_ammo = ammo
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
var buttons: int = packet.get("buttons", 0)
|
||||
var fire_this: bool = (buttons & 2) != 0
|
||||
|
||||
var delta_ammo: int = ammo - state.last_ammo
|
||||
|
||||
# Expected: negative if fired, 0 if not fired, positive if picking up ammo
|
||||
if state.last_fire_time > 0 and fire_this:
|
||||
# Should have decreased by 1 (or burst count)
|
||||
var expected_delta: int = -packet.get("burst_count", 1)
|
||||
if delta_ammo > 0:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.FIRE_RATE_AMMO,
|
||||
"Ammo increased while firing: %d → %d" % [state.last_ammo, ammo],
|
||||
{"last_ammo": state.last_ammo, "new_ammo": ammo},
|
||||
))
|
||||
# Correct: keep old ammo value
|
||||
corrected["ammo"] = state.last_ammo
|
||||
return {"violations": violations, "corrected": true}
|
||||
elif delta_ammo > expected_delta:
|
||||
# Less ammo used than expected (e.g., 0 instead of -1)
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.FIRE_RATE_AMMO,
|
||||
"Ammo mismatch while firing: delta %d (expected %d)" % [delta_ammo, expected_delta],
|
||||
{"delta": delta_ammo, "expected": expected_delta},
|
||||
))
|
||||
corrected["ammo"] = max(0, state.last_ammo + expected_delta)
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
state.last_ammo = ammo
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
|
||||
# ─── Enforcement Helpers ────────────────────────────────
|
||||
|
||||
func _make_violation(category: int, details: String, extra: Dictionary = {}) -> Dictionary:
|
||||
var v := {
|
||||
"category": category,
|
||||
"category_name": ViolationCategory.keys()[category],
|
||||
"details": details,
|
||||
"time": Time.get_ticks_usec() / 1_000_000.0,
|
||||
"extra": extra,
|
||||
}
|
||||
return v
|
||||
|
||||
|
||||
func _record_violation(state: PlayerState, v: Dictionary) -> void:
|
||||
state.violation_count += 1
|
||||
state.violations_log.append(v)
|
||||
|
||||
# Keep log bounded
|
||||
if state.violations_log.size() > 100:
|
||||
state.violations_log.pop_front()
|
||||
|
||||
|
||||
func _resolve_max_speed(packet: Dictionary) -> float:
|
||||
"""Get max player speed from packet or default.
|
||||
|
||||
The game should set 'max_speed' in the input packet.
|
||||
If not provided, assume a standard FPS walking speed.
|
||||
"""
|
||||
if packet.has("max_speed"):
|
||||
return packet.max_speed
|
||||
return 320.0 # Default: CS-style max speed
|
||||
|
||||
|
||||
# ─── Pipeline Result Handlers ──────────────────────────
|
||||
|
||||
func _result_seq(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
|
||||
|
||||
func _result_mv(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
if r.get("corrected", false):
|
||||
input_corrected.emit(peer_id, "position", null, null)
|
||||
|
||||
|
||||
func _result_aim(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
if r.get("corrected", false):
|
||||
input_corrected.emit(peer_id, "view_angles", null, null)
|
||||
|
||||
|
||||
func _result_fire(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
if r.get("corrected", false):
|
||||
input_corrected.emit(peer_id, "fire", null, null)
|
||||
|
||||
|
||||
func _result_cmd(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
|
||||
|
||||
func _result_ammo(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
if r.get("corrected", false):
|
||||
input_corrected.emit(peer_id, "ammo", null, null)
|
||||
@@ -0,0 +1 @@
|
||||
uid://1l2t1yn1gegr
|
||||
@@ -0,0 +1,519 @@
|
||||
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.0–1.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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://booj2l3cle8xp
|
||||
@@ -0,0 +1,539 @@
|
||||
extends Node
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# PluginManager — Server plugin lifecycle & hook dispatch
|
||||
#
|
||||
# Architecture (SourceMod-inspired):
|
||||
# Plugins extend PluginBase and provide hook overrides. The PluginManager
|
||||
# discovers .tres manifests in the plugin directory, loads the referenced
|
||||
# GDScripts, and dispatches hook events to all loaded plugins.
|
||||
#
|
||||
# Setup — Add as an autoload in project.godot:
|
||||
# [autoload]
|
||||
# PluginManager="*res://server/scripts/plugin_manager.gd"
|
||||
#
|
||||
# Game Integration — Connect signals to forward game events:
|
||||
# PluginManager.signal_map_start.connect(_on_map_start)
|
||||
# --- After calling your game logic, dispatch to plugins:
|
||||
# PluginManager.dispatch_map_start("de_dust2")
|
||||
#
|
||||
# Hook Dispatch Flow:
|
||||
# Game loop → (calls plugin_manager.dispatch_*) → PluginManager iterates
|
||||
# loaded plugins → calls virtual method on each → collects results
|
||||
#
|
||||
# Thread Safety:
|
||||
# Hook dispatch runs on the main thread. Plugin _ready() and _exit_tree()
|
||||
# also run on the main thread. Plugins must not create threads without
|
||||
# proper mutex handling.
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ─── Signals (for game-logic integration) ────────────────────────────────
|
||||
# Game logic connects these to learn when plugins request actions.
|
||||
|
||||
## Emitted when a plugin wants to broadcast a chat message.
|
||||
signal plugin_broadcast_chat(message: String)
|
||||
|
||||
## Emitted when a plugin requests a player be kicked.
|
||||
signal plugin_request_kick(peer_id: int, reason: String)
|
||||
|
||||
## Emitted when a plugin explicitly asks the server to change map.
|
||||
signal plugin_request_changelevel(map_name: String)
|
||||
|
||||
|
||||
# ─── Exports / Cvars ─────────────────────────────────────────────────────
|
||||
|
||||
## Master enable/disable. When false, no plugins are loaded.
|
||||
@export var sv_plugin_enabled: bool = true:
|
||||
set = set_plugin_enabled
|
||||
|
||||
## Directory to scan for plugin .tres manifests.
|
||||
## Relative to user:// for runtime discovery (admins can FTP plugins in).
|
||||
## Set to "res://plugins" for development / built-in plugins.
|
||||
@export var sv_plugin_dir: String = "user://plugins"
|
||||
|
||||
## Path separator pattern — subdirectories under sv_plugin_dir are scanned.
|
||||
## Each subdirectory should contain a plugin.tres file.
|
||||
@export var sv_plugin_rescan_on_map: bool = false
|
||||
|
||||
|
||||
# ─── Internal State ──────────────────────────────────────────────────────
|
||||
|
||||
## Loaded plugins keyed by plugin_name (lowercase).
|
||||
var loaded_plugins: Dictionary = {} # "plugin_name_lower" → PluginBase
|
||||
|
||||
## Registration order (for deterministic dispatch).
|
||||
var _load_order: Array[PluginBase] = []
|
||||
|
||||
## Whether the server is currently running (set by dispatch_server_start).
|
||||
var _server_running: bool = false
|
||||
|
||||
## Reference to the CvarRegistry singleton (cached).
|
||||
var _cvar_registry = null
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Initialization & Lifecycle
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
func _ready() -> void:
|
||||
"""Called when the PluginManager is added to the scene tree (autoload)."""
|
||||
# Cache CvarRegistry reference
|
||||
if Engine.has_singleton("CvarRegistry"):
|
||||
_cvar_registry = Engine.get_singleton("CvarRegistry")
|
||||
|
||||
# Read cvar overrides
|
||||
_apply_cvar_overrides()
|
||||
|
||||
# Discover and load plugins
|
||||
if sv_plugin_enabled:
|
||||
_discover_plugins()
|
||||
else:
|
||||
print("[PluginManager] Plugin system disabled (sv_plugin_enabled=false)")
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
"""Clean shutdown — dispatch server_stop if not already done."""
|
||||
if _server_running:
|
||||
dispatch_server_stop()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Cvar Integration
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
func _apply_cvar_overrides() -> void:
|
||||
"""Read overrides from the CvarRegistry (if available)."""
|
||||
if not _cvar_registry:
|
||||
return
|
||||
|
||||
if _cvar_registry.has_method("get"):
|
||||
var val = _cvar_registry.get("sv_plugin_enabled")
|
||||
if val != null:
|
||||
sv_plugin_enabled = bool(val)
|
||||
|
||||
val = _cvar_registry.get("sv_plugin_dir")
|
||||
if val != null and not str(val).is_empty():
|
||||
sv_plugin_dir = str(val)
|
||||
|
||||
|
||||
func set_plugin_enabled(new_val: bool) -> void:
|
||||
"""Setter for sv_plugin_enabled — triggers load/unload if state changes."""
|
||||
if new_val == sv_plugin_enabled:
|
||||
return
|
||||
sv_plugin_enabled = new_val
|
||||
|
||||
if sv_plugin_enabled:
|
||||
print("[PluginManager] Enabling plugin system")
|
||||
_discover_plugins()
|
||||
else:
|
||||
print("[PluginManager] Disabling plugin system — unloading all")
|
||||
_unload_all()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Plugin Discovery & Loading
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
func discover_plugins() -> Dictionary:
|
||||
"""Force a re-scan of the plugin directory. Returns {loaded, failed, skipped} counts."""
|
||||
return _discover_plugins()
|
||||
|
||||
|
||||
func _discover_plugins() -> Dictionary:
|
||||
"""Scan sv_plugin_dir for plugin.tres manifests and load new plugins.
|
||||
|
||||
Returns a dict with:
|
||||
loaded: int — plugins successfully loaded
|
||||
failed: int — manifests found but failed to load
|
||||
skipped: int — manifests skipped (already loaded, disabled, or invalid)
|
||||
"""
|
||||
var result = {"loaded": 0, "failed": 0, "skipped": 0}
|
||||
|
||||
# Resolve the plugin directory path
|
||||
var dir_path = _resolve_plugin_dir()
|
||||
if dir_path.is_empty():
|
||||
push_warning("[PluginManager] Could not resolve plugin directory: " + sv_plugin_dir)
|
||||
return result
|
||||
|
||||
var dir = DirAccess.open(dir_path)
|
||||
if not dir:
|
||||
push_warning("[PluginManager] Plugin directory not found: " + dir_path)
|
||||
return result
|
||||
|
||||
# List subdirectories (each subdir = one plugin)
|
||||
dir.list_dir_begin()
|
||||
var entry = dir.get_next()
|
||||
|
||||
while entry:
|
||||
if entry != "." and entry != ".." and dir.current_is_dir():
|
||||
var sub_path = dir_path.path_join(entry)
|
||||
var manifest_path = sub_path.path_join("plugin.tres")
|
||||
|
||||
if FileAccess.file_exists(manifest_path):
|
||||
var load_result = _load_plugin(manifest_path)
|
||||
if load_result == OK:
|
||||
result["loaded"] += 1
|
||||
elif load_result == ERR_SKIP:
|
||||
result["skipped"] += 1
|
||||
else:
|
||||
result["failed"] += 1
|
||||
else:
|
||||
result["skipped"] += 1
|
||||
|
||||
entry = dir.get_next()
|
||||
|
||||
dir.list_dir_end()
|
||||
|
||||
print("[PluginManager] Discovery complete — loaded: " + str(result["loaded"]) + \
|
||||
", failed: " + str(result["failed"]) + ", skipped: " + str(result["skipped"]))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func _load_plugin(manifest_path: String) -> int:
|
||||
"""Load a single plugin from its manifest path.
|
||||
|
||||
Returns:
|
||||
OK — loaded successfully
|
||||
ERR_SKIP — skipped (already loaded, disabled, or invalid manifest)
|
||||
ERR_FILE_CORRUPT — manifest couldn't be loaded
|
||||
ERR_SCRIPT_FAILED — script failed to instantiate
|
||||
"""
|
||||
# Load the manifest Resource
|
||||
var res = ResourceLoader.load(manifest_path, "Resource", ResourceLoader.CACHE_MODE_IGNORE)
|
||||
if not res:
|
||||
push_error("[PluginManager] Failed to load manifest: " + manifest_path)
|
||||
return ERR_FILE_CORRUPT
|
||||
|
||||
# The manifest is a PluginManifest if we loaded a custom Resource, or a
|
||||
# plain Resource with export properties.
|
||||
var manifest = res as PluginManifest
|
||||
|
||||
# Validate
|
||||
if not manifest or not manifest.is_valid():
|
||||
push_warning("[PluginManager] Skipping invalid manifest: " + manifest_path)
|
||||
return ERR_SKIP
|
||||
|
||||
if not manifest.plugin_enabled:
|
||||
print("[PluginManager] Plugin '" + manifest.plugin_name + "' is disabled — skipping")
|
||||
return ERR_SKIP
|
||||
|
||||
var name_key = manifest.plugin_name.to_lower()
|
||||
|
||||
# Check if already loaded
|
||||
if loaded_plugins.has(name_key):
|
||||
print("[PluginManager] Plugin '" + manifest.plugin_name + "' already loaded — skipping")
|
||||
return ERR_SKIP
|
||||
|
||||
# Check version compatibility
|
||||
if not _check_version_compatibility(manifest):
|
||||
push_warning("[PluginManager] Plugin '" + manifest.plugin_name + \
|
||||
"' version requirement not met — skipping")
|
||||
return ERR_SKIP
|
||||
|
||||
# Load the plugin script
|
||||
if manifest.plugin_script_path.is_empty():
|
||||
push_error("[PluginManager] Plugin '" + manifest.plugin_name + "' has no script path")
|
||||
return ERR_FILE_CORRUPT
|
||||
|
||||
# Resolve the script path relative to the plugin directory
|
||||
var script_path = manifest.plugin_script_path
|
||||
if not script_path.begins_with("res://") and not script_path.begins_with("user://"):
|
||||
# If relative, resolve relative to manifest location
|
||||
var manifest_dir = manifest_path.get_base_dir()
|
||||
script_path = manifest_dir.path_join(script_path)
|
||||
|
||||
if not ResourceLoader.exists(script_path):
|
||||
push_error("[PluginManager] Plugin script not found: " + script_path)
|
||||
return ERR_FILE_CORRUPT
|
||||
|
||||
var script_res = load(script_path)
|
||||
if not script_res or not (script_res is GDScript):
|
||||
push_error("[PluginManager] Plugin script is not a valid GDScript: " + script_path)
|
||||
return ERR_SCRIPT_FAILED
|
||||
|
||||
# Instantiate the plugin
|
||||
var plugin_instance = script_res.new()
|
||||
if not plugin_instance or not (plugin_instance is PluginBase):
|
||||
push_error("[PluginManager] Plugin script must extend PluginBase: " + script_path)
|
||||
if plugin_instance:
|
||||
plugin_instance.free()
|
||||
return ERR_SCRIPT_FAILED
|
||||
|
||||
# Apply metadata from manifest
|
||||
plugin_instance.plugin_name = manifest.plugin_name
|
||||
plugin_instance.plugin_version = manifest.plugin_version
|
||||
plugin_instance.plugin_author = manifest.plugin_author
|
||||
plugin_instance.plugin_description = manifest.plugin_description
|
||||
plugin_instance.plugin_manifest_path = manifest_path
|
||||
|
||||
# Register
|
||||
loaded_plugins[name_key] = plugin_instance
|
||||
_load_order.append(plugin_instance)
|
||||
|
||||
# Add to scene tree to enable _ready(), _process(), etc.
|
||||
add_child(plugin_instance)
|
||||
|
||||
print("[PluginManager] Loaded plugin: " + manifest.plugin_name + " v" + manifest.plugin_version)
|
||||
return OK
|
||||
|
||||
|
||||
func unload_plugin(name: String) -> bool:
|
||||
"""Unload a specific plugin by name. Returns true on success."""
|
||||
var name_key = name.to_lower()
|
||||
if not loaded_plugins.has(name_key):
|
||||
return false
|
||||
|
||||
var plugin = loaded_plugins[name_key] as PluginBase
|
||||
|
||||
# Remove from tracking first so dispatch doesn't hit it mid-unload
|
||||
loaded_plugins.erase(name_key)
|
||||
var idx = _load_order.find(plugin)
|
||||
if idx >= 0:
|
||||
_load_order.remove_at(idx)
|
||||
|
||||
# Save state (call on_server_stop if server is running)
|
||||
if _server_running:
|
||||
plugin.on_server_stop()
|
||||
|
||||
# Remove from scene tree
|
||||
remove_child(plugin)
|
||||
plugin.queue_free()
|
||||
|
||||
print("[PluginManager] Unloaded plugin: " + plugin.plugin_name)
|
||||
return true
|
||||
|
||||
|
||||
func reload_plugin(name: String) -> bool:
|
||||
"""Reload a plugin by unloading it, then re-loading from its manifest.
|
||||
Returns true if both unload and load succeeded.
|
||||
"""
|
||||
var name_key = name.to_lower()
|
||||
if not loaded_plugins.has(name_key):
|
||||
return false
|
||||
|
||||
var plugin = loaded_plugins[name_key] as PluginBase
|
||||
var manifest_path = plugin.plugin_manifest_path
|
||||
|
||||
if not unload_plugin(name):
|
||||
return false
|
||||
|
||||
if manifest_path.is_empty():
|
||||
push_warning("[PluginManager] No manifest path to reload from for '" + name + "'")
|
||||
return false
|
||||
|
||||
var result = _load_plugin(manifest_path)
|
||||
if result != OK:
|
||||
push_error("[PluginManager] Failed to reload plugin '" + name + "'")
|
||||
return false
|
||||
|
||||
# If server is running, call on_server_start on the new instance
|
||||
if _server_running:
|
||||
var new_plugin = loaded_plugins.get(name_key)
|
||||
if new_plugin:
|
||||
new_plugin.on_server_start()
|
||||
|
||||
return true
|
||||
|
||||
|
||||
func _unload_all() -> void:
|
||||
"""Unload every loaded plugin."""
|
||||
for plugin in _load_order.duplicate():
|
||||
unload_plugin(plugin.plugin_name)
|
||||
|
||||
|
||||
func get_plugin(name: String) -> PluginBase:
|
||||
"""Get a loaded plugin by name. Returns null if not found."""
|
||||
return loaded_plugins.get(name.to_lower())
|
||||
|
||||
|
||||
func get_plugin_count() -> int:
|
||||
"""Number of currently loaded plugins."""
|
||||
return _load_order.size()
|
||||
|
||||
|
||||
func list_plugins() -> Array[Dictionary]:
|
||||
"""Return metadata for all loaded plugins (for admin display)."""
|
||||
var result: Array[Dictionary] = []
|
||||
for plugin in _load_order:
|
||||
result.append({
|
||||
"name": plugin.plugin_name,
|
||||
"version": plugin.plugin_version,
|
||||
"author": plugin.plugin_author,
|
||||
"description": plugin.plugin_description,
|
||||
"manifest_path": plugin.plugin_manifest_path,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Hook Dispatch Methods
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Each dispatch_* method iterates all loaded plugins and calls the
|
||||
# corresponding virtual hook method. For hooks that return a bool,
|
||||
# the method collects return values and decides: if ANY plugin returns
|
||||
# true, the action is considered blocked.
|
||||
#
|
||||
# The game loop should call these at the appropriate points.
|
||||
|
||||
func dispatch_server_start() -> void:
|
||||
"""Notify all plugins that the server has started."""
|
||||
_server_running = true
|
||||
for plugin in _load_order:
|
||||
plugin.on_server_start()
|
||||
|
||||
|
||||
func dispatch_server_stop() -> void:
|
||||
"""Notify all plugins that the server is shutting down."""
|
||||
_server_running = false
|
||||
# Iterate in reverse for clean shutdown
|
||||
for i in range(_load_order.size() - 1, -1, -1):
|
||||
_load_order[i].on_server_stop()
|
||||
|
||||
|
||||
func dispatch_map_start(map_name: String) -> void:
|
||||
"""Notify plugins a new map has loaded."""
|
||||
for plugin in _load_order:
|
||||
plugin.on_map_start(map_name)
|
||||
|
||||
|
||||
func dispatch_map_end(map_name: String) -> void:
|
||||
"""Notify plugins the current map is ending."""
|
||||
for plugin in _load_order:
|
||||
plugin.on_map_end(map_name)
|
||||
|
||||
|
||||
func dispatch_player_connect(peer_id: int, name: String, steam_id: String) -> bool:
|
||||
"""Ask plugins if a player connection should be blocked.
|
||||
|
||||
Returns true if ANY plugin blocked the connection.
|
||||
"""
|
||||
var blocked: bool = false
|
||||
for plugin in _load_order:
|
||||
if plugin.on_player_connect(peer_id, name, steam_id):
|
||||
blocked = true
|
||||
return blocked
|
||||
|
||||
|
||||
func dispatch_player_disconnect(peer_id: int, reason: String) -> void:
|
||||
"""Notify plugins a player has disconnected."""
|
||||
for plugin in _load_order:
|
||||
plugin.on_player_disconnect(peer_id, reason)
|
||||
|
||||
|
||||
func dispatch_player_say(peer_id: int, message: String) -> bool:
|
||||
"""Ask plugins if a chat message should be blocked.
|
||||
|
||||
Returns true if ANY plugin blocked the message.
|
||||
"""
|
||||
var blocked: bool = false
|
||||
for plugin in _load_order:
|
||||
if plugin.on_player_say(peer_id, message):
|
||||
blocked = true
|
||||
return blocked
|
||||
|
||||
|
||||
func dispatch_player_spawn(peer_id: int) -> void:
|
||||
"""Notify plugins a player has spawned."""
|
||||
for plugin in _load_order:
|
||||
plugin.on_player_spawn(peer_id)
|
||||
|
||||
|
||||
func dispatch_player_killed(victim_id: int, killer_id: int, weapon: String) -> void:
|
||||
"""Notify plugins a player has been killed."""
|
||||
for plugin in _load_order:
|
||||
plugin.on_player_killed(victim_id, killer_id, weapon)
|
||||
|
||||
|
||||
func dispatch_round_start(round_number: int) -> void:
|
||||
"""Notify plugins a new round has started."""
|
||||
for plugin in _load_order:
|
||||
plugin.on_round_start(round_number)
|
||||
|
||||
|
||||
func dispatch_round_end(winner_team: int, reason: String) -> void:
|
||||
"""Notify plugins the current round has ended."""
|
||||
for plugin in _load_order:
|
||||
plugin.on_round_end(winner_team, reason)
|
||||
|
||||
|
||||
func dispatch_tick(delta: float) -> void:
|
||||
"""Dispatch per-tick hook. Call from _physics_process.
|
||||
|
||||
This is potentially expensive with many plugins. Consider using
|
||||
a pooling/tick budget approach if you have >20 plugins.
|
||||
"""
|
||||
for plugin in _load_order:
|
||||
plugin.on_tick(delta)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Helpers
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
func _resolve_plugin_dir() -> String:
|
||||
"""Resolve the plugin directory path, handling res:// and user:// prefixes."""
|
||||
var path = sv_plugin_dir
|
||||
|
||||
if path.begins_with("res://"):
|
||||
# Convert to absolute filesystem path (DirAccess needs absolute paths)
|
||||
return ProjectSettings.globalize_path(path)
|
||||
elif path.begins_with("user://"):
|
||||
return ProjectSettings.globalize_path(path)
|
||||
else:
|
||||
# Assume relative — prepend user://
|
||||
path = "user://" + path.trim_prefix("/")
|
||||
return ProjectSettings.globalize_path(path)
|
||||
|
||||
|
||||
func _check_version_compatibility(manifest: PluginManifest) -> bool:
|
||||
"""Check if this server version satisfies the plugin's version requirements."""
|
||||
# Get server version
|
||||
var server_version = ProjectSettings.get_setting("application/config/version", "0.0.0")
|
||||
|
||||
if not manifest.min_server_version.is_empty():
|
||||
if not _version_gte(server_version, manifest.min_server_version):
|
||||
push_warning("[PluginManager] Plugin '" + manifest.plugin_name + \
|
||||
"' requires server >= " + manifest.min_server_version + \
|
||||
" (server: " + server_version + ")")
|
||||
return false
|
||||
|
||||
if not manifest.max_server_version.is_empty():
|
||||
if not _version_lte(server_version, manifest.max_server_version):
|
||||
push_warning("[PluginManager] Plugin '" + manifest.plugin_name + \
|
||||
"' requires server <= " + manifest.max_server_version + \
|
||||
" (server: " + server_version + ")")
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
static func _version_gte(version: String, min_version: String) -> bool:
|
||||
"""Simple semver comparison: version >= min_version."""
|
||||
var v_parts = version.split(".")
|
||||
var m_parts = min_version.split(".")
|
||||
|
||||
for i in range(max(v_parts.size(), m_parts.size())):
|
||||
var v = int(v_parts[i]) if i < v_parts.size() else 0
|
||||
var m = int(m_parts[i]) if i < m_parts.size() else 0
|
||||
if v > m:
|
||||
return true
|
||||
if v < m:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _version_lte(version: String, max_version: String) -> bool:
|
||||
"""Simple semver comparison: version <= max_version."""
|
||||
var v_parts = version.split(".")
|
||||
var m_parts = max_version.split(".")
|
||||
|
||||
for i in range(max(v_parts.size(), m_parts.size())):
|
||||
var v = int(v_parts[i]) if i < v_parts.size() else 0
|
||||
var m = int(m_parts[i]) if i < m_parts.size() else 0
|
||||
if v < m:
|
||||
return true
|
||||
if v > m:
|
||||
return false
|
||||
return true
|
||||
@@ -0,0 +1 @@
|
||||
uid://bt7le1vq5wvl4
|
||||
@@ -0,0 +1,694 @@
|
||||
## RoundManager — Server-authoritative round state machine.
|
||||
##
|
||||
## Controls round lifecycle: INACTIVE → WAITING_FOR_PLAYERS → WARMUP → LIVE
|
||||
## → POST_ROUND → (next round: WARMUP) or MATCH_END → INACTIVE.
|
||||
##
|
||||
## Win condition (elimination):
|
||||
## The round ends when only one team has alive players.
|
||||
## If the round timer expires, the team with more alive players wins.
|
||||
## A draw occurs if both teams are eliminated simultaneously or have equal
|
||||
## alive count at time limit.
|
||||
##
|
||||
## Match scoring:
|
||||
## Best-of-N configured by ServerConfig.win_limit.
|
||||
## First team to reach win_limit rounds wins the match.
|
||||
##
|
||||
## Respawn:
|
||||
## Elimination mode: no mid-round respawns. All players respawn at round start.
|
||||
## (Future: TDM/deathmatch modes may use respawn_time_seconds config.)
|
||||
##
|
||||
## Spectate:
|
||||
## Dead players are moved to spectator mode. They can watch alive teammates
|
||||
## via the spectate system. Spectating state is tracked per-player.
|
||||
##
|
||||
## Integration with PluginManager:
|
||||
## RoundManager emits signals that PluginManager dispatches to all loaded
|
||||
## plugins via their round hooks (on_round_start, on_round_end, etc.).
|
||||
##
|
||||
## =============================================================================
|
||||
|
||||
extends Node
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Emitted when the round state machine transitions to a new state.
|
||||
## state: the new RoundState enum value.
|
||||
signal round_state_changed(state: int)
|
||||
|
||||
## Emitted at the start of each round.
|
||||
## round_number: 1-based round number.
|
||||
## warmup_time: warmup duration in seconds.
|
||||
signal round_started(round_number: int)
|
||||
|
||||
## Emitted when the round ends.
|
||||
## winner_team: 0=draw, 1=TeamA, 2=TeamB.
|
||||
## reason: string describing the end condition.
|
||||
signal round_ended(winner_team: int, reason: String)
|
||||
|
||||
## Emitted when a player dies.
|
||||
## victim_id: peer id of the killed player.
|
||||
## killer_id: peer id of the killer (may equal victim_id for suicide).
|
||||
## weapon: weapon name string.
|
||||
signal player_died(victim_id: int, killer_id: int, weapon: String)
|
||||
|
||||
## Emitted when a player respawns at round start.
|
||||
signal player_respawned(peer_id: int)
|
||||
|
||||
## Emitted when a player enters spectator mode.
|
||||
signal player_spectating(peer_id: int)
|
||||
|
||||
## Emitted when a player exits spectator mode (respawned).
|
||||
signal player_unspectated(peer_id: int)
|
||||
|
||||
## Emitted when the match ends.
|
||||
## winner_team: 0=draw, 1=TeamA, 2=TeamB.
|
||||
signal match_ended(winner_team: int)
|
||||
|
||||
## Emitted when team scores change.
|
||||
signal score_changed(team_a_score: int, team_b_score: int)
|
||||
|
||||
## Emitted when the spectate target for a player changes.
|
||||
signal spectate_target_changed(viewer_id: int, target_id: int)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# netfox Self-RPC methods — called via rpc() from server, execute on all peers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Replicate round state transition to all peers.
|
||||
## Self-RPC pattern: server calls .rpc(), executes locally (call_local) and on clients.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _rpc_round_state(state: int) -> void:
|
||||
round_state = state
|
||||
round_state_changed.emit(state)
|
||||
|
||||
## Replicate round start to all peers.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _rpc_round_started(round_num: int, time_seconds: int) -> void:
|
||||
round_number = round_num
|
||||
round_time_seconds = time_seconds
|
||||
round_started.emit(round_num)
|
||||
|
||||
## Replicate round end to all peers. Includes score snapshot for client UI.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _rpc_round_ended(winner_team: int, reason: String, a_score: int, b_score: int) -> void:
|
||||
team_a_score = a_score
|
||||
team_b_score = b_score
|
||||
round_ended.emit(winner_team, reason)
|
||||
|
||||
## Replicate match end to all peers.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _rpc_match_ended(winner_team: int, a_score: int, b_score: int) -> void:
|
||||
team_a_score = a_score
|
||||
team_b_score = b_score
|
||||
match_ended.emit(winner_team)
|
||||
|
||||
## Replicate score change to all peers.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _rpc_score_changed(a_score: int, b_score: int) -> void:
|
||||
team_a_score = a_score
|
||||
team_b_score = b_score
|
||||
score_changed.emit(team_a_score, team_b_score)
|
||||
|
||||
## Replicate player death to all peers.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _rpc_player_died(victim_id: int, killer_id: int, weapon: String) -> void:
|
||||
player_died.emit(victim_id, killer_id, weapon)
|
||||
|
||||
## Replicate player respawn to all peers.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _rpc_player_respawned(peer_id: int) -> void:
|
||||
player_respawned.emit(peer_id)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round state enum
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
enum RoundState {
|
||||
INACTIVE = 0, # Server starting up, no game running
|
||||
WAITING_FOR_PLAYERS = 1, # Not enough players to start
|
||||
WARMUP = 2, # Pre-round warmup period
|
||||
LIVE = 3, # Round is active, players fighting
|
||||
POST_ROUND = 4, # Brief result display period
|
||||
MATCH_END = 5, # A team reached win_limit
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Default post-round delay before next round starts (seconds).
|
||||
const DEFAULT_POST_ROUND_DELAY: float = 5.0
|
||||
|
||||
## Minimum players per team required to start a round.
|
||||
const MIN_PLAYERS_PER_TEAM: int = 1
|
||||
|
||||
## Minimum round number before match-end check activates.
|
||||
const MIN_ROUNDS_FOR_MATCH_END: int = 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var round_state: int = RoundState.INACTIVE
|
||||
|
||||
## Current round number (1-based, increments each round).
|
||||
var round_number: int = 0
|
||||
|
||||
## Team A score (rounds won).
|
||||
var team_a_score: int = 0
|
||||
## Team B score (rounds won).
|
||||
var team_b_score: int = 0
|
||||
|
||||
## Dictionary of peer_id → player state dictionary.
|
||||
## Each entry: { "team": int (0=A, 1=B), "alive": bool, "spectating": bool,
|
||||
## "spectate_target": int (peer_id or 0), "spawn_pos": Vector3 }
|
||||
var players: Dictionary = {}
|
||||
|
||||
## Sequence of spawn positions for team A (cycled round-robin).
|
||||
var spawn_points_a: Array[Vector3] = []
|
||||
## Sequence of spawn positions for team B (cycled round-robin).
|
||||
var spawn_points_b: Array[Vector3] = []
|
||||
|
||||
## Internal timers
|
||||
var _warmup_timer: float = 0.0
|
||||
var _round_timer: float = 0.0
|
||||
var _post_round_timer: float = 0.0
|
||||
var _elapsed_seconds: float = 0.0
|
||||
|
||||
## Config values cached from ServerConfig at init.
|
||||
var round_time_seconds: int = 600
|
||||
var _warmup_time_seconds: int = 60
|
||||
var _win_limit: int = 3
|
||||
var _respawn_time_seconds: float = 5.0
|
||||
var _spectate_enabled: bool = true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
_cache_config()
|
||||
print("[RoundManager] Initialised. Win limit: %d, Round time: %ds, Warmup: %ds" % [
|
||||
_win_limit, round_time_seconds, _warmup_time_seconds
|
||||
])
|
||||
|
||||
func _exit_tree() -> void:
|
||||
stop()
|
||||
|
||||
## Cache config values from ServerConfig (if available).
|
||||
func _cache_config() -> void:
|
||||
if ServerConfig and ServerConfig.has_method(&"get_config_path"):
|
||||
round_time_seconds = ServerConfig.round_time_seconds
|
||||
_warmup_time_seconds = ServerConfig.warmup_time_seconds
|
||||
_win_limit = ServerConfig.win_limit
|
||||
_respawn_time_seconds = ServerConfig.respawn_time_seconds
|
||||
_spectate_enabled = ServerConfig.spectate_enabled
|
||||
else:
|
||||
push_warning("[RoundManager] ServerConfig not available — using defaults")
|
||||
|
||||
## Start the round manager. Must be called after spawn points are set.
|
||||
func start() -> void:
|
||||
_reset_all_state()
|
||||
_set_state(RoundState.WAITING_FOR_PLAYERS)
|
||||
print("[RoundManager] Started — waiting for players")
|
||||
|
||||
## Stop the round manager and reset all state.
|
||||
func stop() -> void:
|
||||
_set_state(RoundState.INACTIVE)
|
||||
_reset_all_state()
|
||||
print("[RoundManager] Stopped")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Player management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Register a player with the round manager.
|
||||
## team: 0 = Team A, 1 = Team B.
|
||||
func register_player(peer_id: int, team: int) -> void:
|
||||
if peer_id in players:
|
||||
push_warning("[RoundManager] Player %d already registered, updating team" % peer_id)
|
||||
|
||||
var spawn_pos: Vector3 = _get_spawn_position(peer_id, team)
|
||||
players[peer_id] = {
|
||||
"team": team,
|
||||
"alive": true,
|
||||
"spectating": false,
|
||||
"spectate_target": 0,
|
||||
"spawn_pos": spawn_pos,
|
||||
}
|
||||
print("[RoundManager] Registered player %d on team %s at (%.1f, %.1f)" % [
|
||||
peer_id, "A" if team == 0 else "B", spawn_pos.x, spawn_pos.z
|
||||
])
|
||||
|
||||
_check_start_condition()
|
||||
|
||||
## Unregister a player (on disconnect).
|
||||
func unregister_player(peer_id: int) -> void:
|
||||
if peer_id in players:
|
||||
var was_alive: bool = players[peer_id]["alive"]
|
||||
players.erase(peer_id)
|
||||
print("[RoundManager] Unregistered player %d" % peer_id)
|
||||
|
||||
# If the round is LIVE and a team just lost all members, check win
|
||||
if round_state == RoundState.LIVE and was_alive:
|
||||
if _check_elimination():
|
||||
return # round ended, no further action
|
||||
else:
|
||||
push_warning("[RoundManager] Cannot unregister unknown player %d" % peer_id)
|
||||
|
||||
## Set spawn points from the map.
|
||||
func set_spawn_points(spawns_a: Array[Vector3], spawns_b: Array[Vector3]) -> void:
|
||||
spawn_points_a = spawns_a.duplicate()
|
||||
spawn_points_b = spawns_b.duplicate()
|
||||
print("[RoundManager] Spawn points set: Team A=%d, Team B=%d" % [spawn_points_a.size(), spawn_points_b.size()])
|
||||
|
||||
## Get the spawn position for a player on a given team.
|
||||
func _get_spawn_position(peer_id: int, team: int) -> Vector3:
|
||||
var pool: Array[Vector3] = spawn_points_a if team == 0 else spawn_points_b
|
||||
if pool.size() == 0:
|
||||
return Vector3.ZERO
|
||||
# Round-robin assignment based on peer_id for distribution
|
||||
var idx: int = peer_id % pool.size()
|
||||
var pos: Vector3 = pool[idx]
|
||||
pos.y = 0.0
|
||||
return pos
|
||||
|
||||
## Return the number of alive players on a given team.
|
||||
func get_alive_count(team: int) -> int:
|
||||
var count: int = 0
|
||||
for p in players.values():
|
||||
if p["team"] == team and p["alive"]:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
## Return the total number of players registered.
|
||||
func get_player_count() -> int:
|
||||
return players.size()
|
||||
|
||||
## Return the number of players on a given team.
|
||||
func get_team_player_count(team: int) -> int:
|
||||
var count: int = 0
|
||||
for p in players.values():
|
||||
if p["team"] == team:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
## Return the team of a player (0=A, 1=B), or -1 if not registered.
|
||||
func get_player_team(peer_id: int) -> int:
|
||||
if peer_id in players:
|
||||
return players[peer_id]["team"]
|
||||
return -1
|
||||
|
||||
## Return whether a player is alive.
|
||||
func is_player_alive(peer_id: int) -> bool:
|
||||
if peer_id in players:
|
||||
return players[peer_id]["alive"]
|
||||
return false
|
||||
|
||||
## Return whether a player is spectating.
|
||||
func is_player_spectating(peer_id: int) -> bool:
|
||||
if peer_id in players:
|
||||
return players[peer_id]["spectating"]
|
||||
return false
|
||||
|
||||
## Return a list of alive peer IDs on a given team.
|
||||
func get_alive_players_on_team(team: int) -> Array[int]:
|
||||
var result: Array[int] = []
|
||||
for pid in players:
|
||||
if players[pid]["team"] == team and players[pid]["alive"]:
|
||||
result.append(pid)
|
||||
return result
|
||||
|
||||
## Return the spectate target for a player.
|
||||
func get_spectate_target(peer_id: int) -> int:
|
||||
if peer_id in players:
|
||||
return players[peer_id]["spectate_target"]
|
||||
return 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Death / Elimination
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Called when a player dies.
|
||||
## This is the main elimination detection entry point.
|
||||
## Returns true if the round ended as a result.
|
||||
func on_player_death(victim_id: int, killer_id: int, weapon: String) -> bool:
|
||||
if not victim_id in players:
|
||||
push_warning("[RoundManager] Death event for unknown player %d" % victim_id)
|
||||
return false
|
||||
|
||||
var victim_team: int = players[victim_id]["team"]
|
||||
players[victim_id]["alive"] = false
|
||||
|
||||
print("[RoundManager] Player %d (team %s) killed by %s with '%s'" % [
|
||||
victim_id, "A" if victim_team == 0 else "B",
|
||||
"world" if killer_id == 0 else str(killer_id),
|
||||
weapon
|
||||
])
|
||||
|
||||
# Self-RPC death notification (call_local ensures server signal too)
|
||||
_rpc_player_died.rpc(victim_id, killer_id, weapon)
|
||||
|
||||
# If spectate enabled, put the player in spectator mode
|
||||
if _spectate_enabled:
|
||||
_start_spectating(victim_id)
|
||||
|
||||
# Check elimination — only when round is LIVE
|
||||
if round_state == RoundState.LIVE:
|
||||
return _check_elimination()
|
||||
|
||||
return false
|
||||
|
||||
## Check if a team has been eliminated. Ends the round if so.
|
||||
## Returns true if the round ended.
|
||||
func _check_elimination() -> bool:
|
||||
var alive_a: int = get_alive_count(0)
|
||||
var alive_b: int = get_alive_count(1)
|
||||
|
||||
# Both teams alive — no win yet
|
||||
if alive_a > 0 and alive_b > 0:
|
||||
return false
|
||||
|
||||
# Eliminate the possibility of "nobody alive" edge case
|
||||
if alive_a == 0 and alive_b == 0:
|
||||
_end_round(0, "all_dead_draw")
|
||||
return true
|
||||
|
||||
# One team is eliminated
|
||||
if alive_a == 0 and alive_b > 0:
|
||||
_end_round(2, "elimination") # Team B wins
|
||||
return true
|
||||
|
||||
if alive_b == 0 and alive_a > 0:
|
||||
_end_round(1, "elimination") # Team A wins
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spectate system
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Start spectating for a dead player. Finds the best alive teammate to watch.
|
||||
func _start_spectating(viewer_id: int) -> void:
|
||||
if not _spectate_enabled:
|
||||
return
|
||||
if not viewer_id in players:
|
||||
return
|
||||
|
||||
var viewer_team: int = players[viewer_id]["team"]
|
||||
var alive_teammates: Array[int] = get_alive_players_on_team(viewer_team)
|
||||
|
||||
players[viewer_id]["spectating"] = true
|
||||
|
||||
if alive_teammates.size() > 0:
|
||||
# Target the first alive teammate (simple cycling later via RCON/UI)
|
||||
var target_id: int = alive_teammates[0]
|
||||
players[viewer_id]["spectate_target"] = target_id
|
||||
spectate_target_changed.emit(viewer_id, target_id)
|
||||
print("[RoundManager] Player %d now spectating %d" % [viewer_id, target_id])
|
||||
else:
|
||||
# No alive teammates — spectate the map / overview
|
||||
players[viewer_id]["spectate_target"] = 0
|
||||
spectate_target_changed.emit(viewer_id, 0)
|
||||
print("[RoundManager] Player %d spectating (no alive teammates)" % viewer_id)
|
||||
|
||||
player_spectating.emit(viewer_id)
|
||||
|
||||
## Cycle to the next spectate target for a player.
|
||||
func cycle_spectate_target(viewer_id: int) -> void:
|
||||
if not viewer_id in players or not players[viewer_id]["spectating"]:
|
||||
return
|
||||
|
||||
var viewer_team: int = players[viewer_id]["team"]
|
||||
var alive_teammates: Array[int] = get_alive_players_on_team(viewer_team)
|
||||
|
||||
if alive_teammates.size() == 0:
|
||||
players[viewer_id]["spectate_target"] = 0
|
||||
spectate_target_changed.emit(viewer_id, 0)
|
||||
return
|
||||
|
||||
# Find current target index and advance
|
||||
var current_target: int = players[viewer_id]["spectate_target"]
|
||||
var idx: int = alive_teammates.find(current_target)
|
||||
var next_idx: int = (idx + 1) % alive_teammates.size() if idx >= 0 else 0
|
||||
var new_target: int = alive_teammates[next_idx]
|
||||
|
||||
players[viewer_id]["spectate_target"] = new_target
|
||||
spectate_target_changed.emit(viewer_id, new_target)
|
||||
print("[RoundManager] Player %d now spectating %d (cycled)" % [viewer_id, new_target])
|
||||
|
||||
## Stop spectating for a player (on respawn).
|
||||
func _stop_spectating(peer_id: int) -> void:
|
||||
if peer_id in players:
|
||||
players[peer_id]["spectating"] = false
|
||||
players[peer_id]["spectate_target"] = 0
|
||||
player_unspectated.emit(peer_id)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Respawn system
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Respawn all dead players on both teams at round start.
|
||||
## Sets their position to their assigned spawn point and marks them alive.
|
||||
func respawn_all_players() -> void:
|
||||
for pid in players:
|
||||
var state = players[pid]
|
||||
state["alive"] = true
|
||||
state["spawn_pos"] = _get_spawn_position(pid, state["team"])
|
||||
|
||||
if state["spectating"]:
|
||||
_stop_spectating(pid)
|
||||
|
||||
_rpc_player_respawned.rpc(pid)
|
||||
|
||||
print("[RoundManager] Respawned all %d players for round %d" % [players.size(), round_number])
|
||||
|
||||
## Get the spawn position for a player (for the current round).
|
||||
func get_player_spawn(peer_id: int) -> Vector3:
|
||||
if peer_id in players:
|
||||
return players[peer_id]["spawn_pos"]
|
||||
return Vector3.ZERO
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Check if we have enough players to start. Transitions from WAITING to WARMUP.
|
||||
func _check_start_condition() -> void:
|
||||
if round_state != RoundState.WAITING_FOR_PLAYERS:
|
||||
return
|
||||
|
||||
var team_a_count: int = get_team_player_count(0)
|
||||
var team_b_count: int = get_team_player_count(1)
|
||||
|
||||
if team_a_count >= MIN_PLAYERS_PER_TEAM and team_b_count >= MIN_PLAYERS_PER_TEAM:
|
||||
_begin_warmup()
|
||||
else:
|
||||
print("[RoundManager] Waiting for players: A=%d, B=%d (need %d each)" % [
|
||||
team_a_count, team_b_count, MIN_PLAYERS_PER_TEAM
|
||||
])
|
||||
|
||||
## Begin the warmup phase.
|
||||
func _begin_warmup() -> void:
|
||||
round_number += 1
|
||||
_warmup_timer = float(_warmup_time_seconds)
|
||||
_set_state(RoundState.WARMUP)
|
||||
print("[RoundManager] Warmup started — round %d will begin in %ds" % [round_number, _warmup_time_seconds])
|
||||
|
||||
## Begin the live round.
|
||||
func _begin_round() -> void:
|
||||
# Respawn all players
|
||||
respawn_all_players()
|
||||
|
||||
_round_timer = float(round_time_seconds)
|
||||
_elapsed_seconds = 0.0
|
||||
_set_state(RoundState.LIVE)
|
||||
|
||||
_rpc_round_started.rpc(round_number, round_time_seconds)
|
||||
print("[RoundManager] Round %d started! %ds time limit" % [round_number, round_time_seconds])
|
||||
|
||||
## End the current round with the given winner and reason.
|
||||
func _end_round(winner_team: int, reason: String) -> void:
|
||||
_set_state(RoundState.POST_ROUND)
|
||||
|
||||
# Update score
|
||||
if winner_team == 1:
|
||||
team_a_score += 1
|
||||
elif winner_team == 2:
|
||||
team_b_score += 1
|
||||
|
||||
_post_round_timer = DEFAULT_POST_ROUND_DELAY
|
||||
|
||||
_rpc_round_ended.rpc(winner_team, reason, team_a_score, team_b_score)
|
||||
_rpc_score_changed.rpc(team_a_score, team_b_score)
|
||||
|
||||
var winner_name: String = "Draw"
|
||||
if winner_team == 1: winner_name = "Team A"
|
||||
elif winner_team == 2: winner_name = "Team B"
|
||||
|
||||
print("[RoundManager] Round %d ended: %s wins (%s). Score: A=%d, B=%d" % [
|
||||
round_number, winner_name, reason, team_a_score, team_b_score
|
||||
])
|
||||
|
||||
# Check match end
|
||||
if _check_match_end():
|
||||
return
|
||||
|
||||
# Schedule next round via post-round timer (handled in _process)
|
||||
# Actually we handle this in the state machine below — no need for separate logic
|
||||
|
||||
## Check if a team has reached the win limit. Ends the match if so.
|
||||
## Returns true if match ended.
|
||||
func _check_match_end() -> bool:
|
||||
if _win_limit <= 0:
|
||||
return false # No win limit = infinite play
|
||||
|
||||
if team_a_score >= _win_limit:
|
||||
_end_match(1)
|
||||
return true
|
||||
elif team_b_score >= _win_limit:
|
||||
_end_match(2)
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
## End the match with the given winning team.
|
||||
func _end_match(winner_team: int) -> void:
|
||||
_set_state(RoundState.MATCH_END)
|
||||
_rpc_match_ended.rpc(winner_team, team_a_score, team_b_score)
|
||||
|
||||
var winner: String = "Team A" if winner_team == 1 else "Team B"
|
||||
print("[RoundManager] MATCH OVER! %s wins %d-%d" % [winner, team_a_score, team_b_score])
|
||||
|
||||
## Reset all per-match state (keeps the round manager alive for a new match).
|
||||
func reset_for_new_match() -> void:
|
||||
_reset_all_state()
|
||||
_set_state(RoundState.WAITING_FOR_PLAYERS)
|
||||
print("[RoundManager] Reset for new match")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-frame tick
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
match round_state:
|
||||
RoundState.WARMUP:
|
||||
_warmup_timer -= delta
|
||||
if _warmup_timer <= 0.0:
|
||||
_warmup_timer = 0.0
|
||||
_begin_round()
|
||||
|
||||
RoundState.LIVE:
|
||||
_elapsed_seconds += delta
|
||||
_round_timer -= delta
|
||||
|
||||
# Time limit reached — end round
|
||||
if _round_timer <= 0.0 and round_time_seconds > 0:
|
||||
_round_timer = 0.0
|
||||
_handle_round_time_expiry()
|
||||
|
||||
RoundState.POST_ROUND:
|
||||
_post_round_timer -= delta
|
||||
if _post_round_timer <= 0.0:
|
||||
_post_round_timer = 0.0
|
||||
# Check if match ended (already checked in _end_round, but safeguard)
|
||||
if _check_match_end():
|
||||
return
|
||||
# Start next round
|
||||
_begin_warmup()
|
||||
|
||||
## Handle round time expiry — determine winner based on alive count.
|
||||
func _handle_round_time_expiry() -> void:
|
||||
var alive_a: int = get_alive_count(0)
|
||||
var alive_b: int = get_alive_count(1)
|
||||
|
||||
if alive_a > alive_b:
|
||||
_end_round(1, "time_limit")
|
||||
elif alive_b > alive_a:
|
||||
_end_round(2, "time_limit")
|
||||
else:
|
||||
# Equal alive count or both zero — stalemate
|
||||
_end_round(0, "time_limit_draw")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Set the round state and emit the change signal.
|
||||
func _set_state(new_state: int) -> void:
|
||||
if round_state != new_state:
|
||||
_rpc_round_state.rpc(new_state)
|
||||
|
||||
## Reset all per-round state (scores, timers, player states).
|
||||
func _reset_all_state() -> void:
|
||||
round_number = 0
|
||||
team_a_score = 0
|
||||
team_b_score = 0
|
||||
_warmup_timer = 0.0
|
||||
_round_timer = 0.0
|
||||
_post_round_timer = 0.0
|
||||
_elapsed_seconds = 0.0
|
||||
|
||||
# Reset player states (but keep registrations)
|
||||
for pid in players:
|
||||
var state = players[pid]
|
||||
state["alive"] = false
|
||||
state["spectating"] = false
|
||||
state["spectate_target"] = 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public query API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Get the current round time remaining in seconds (0 if not LIVE).
|
||||
func get_time_remaining() -> float:
|
||||
if round_state == RoundState.LIVE:
|
||||
return max(_round_timer, 0.0)
|
||||
return 0.0
|
||||
|
||||
## Get the elapsed round time in seconds.
|
||||
func get_elapsed_time() -> float:
|
||||
return _elapsed_seconds
|
||||
|
||||
## Get the formatted score string.
|
||||
func get_score_string() -> String:
|
||||
return "%d - %d" % [team_a_score, team_b_score]
|
||||
|
||||
## Return a dictionary of current round state for network broadcast / RCON status.
|
||||
func get_round_state_snapshot() -> Dictionary:
|
||||
var state_name: String
|
||||
match round_state:
|
||||
RoundState.INACTIVE: state_name = "inactive"
|
||||
RoundState.WAITING_FOR_PLAYERS: state_name = "waiting"
|
||||
RoundState.WARMUP: state_name = "warmup"
|
||||
RoundState.LIVE: state_name = "live"
|
||||
RoundState.POST_ROUND: state_name = "post_round"
|
||||
RoundState.MATCH_END: state_name = "match_end"
|
||||
_: state_name = "unknown"
|
||||
|
||||
return {
|
||||
"state": state_name,
|
||||
"round_number": round_number,
|
||||
"team_a_score": team_a_score,
|
||||
"team_b_score": team_b_score,
|
||||
"win_limit": _win_limit,
|
||||
"time_remaining": get_time_remaining(),
|
||||
"elapsed": _elapsed_seconds,
|
||||
"alive_a": get_alive_count(0),
|
||||
"alive_b": get_alive_count(1),
|
||||
"total_players": get_player_count(),
|
||||
}
|
||||
|
||||
## Return the current state as a human-readable string.
|
||||
func get_state_name() -> String:
|
||||
match round_state:
|
||||
RoundState.INACTIVE: return "inactive"
|
||||
RoundState.WAITING_FOR_PLAYERS: return "waiting_for_players"
|
||||
RoundState.WARMUP: return "warmup"
|
||||
RoundState.LIVE: return "live"
|
||||
RoundState.POST_ROUND: return "post_round"
|
||||
RoundState.MATCH_END: return "match_end"
|
||||
_: return "unknown"
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjs167lb51k1q
|
||||
@@ -0,0 +1,213 @@
|
||||
extends Node
|
||||
|
||||
# =============================================================================
|
||||
# Server Browser — Heartbeat Sender
|
||||
# =============================================================================
|
||||
# Periodically POSTs server state to the configured master server so the
|
||||
# server appears in the public server browser.
|
||||
#
|
||||
# Relies on cvars defined by the config system:
|
||||
# sv_master_server — Master server URL (e.g. "http://master.example.com:28961")
|
||||
# sv_server_id — Unique server identifier (auto-generated if empty)
|
||||
# sv_heartbeat_interval — Seconds between heartbeats (default 60, range [10,300])
|
||||
# sv_server_tags — Comma-separated tags for filtering
|
||||
# sv_server_password — Whether the server requires a password
|
||||
#
|
||||
# Signals:
|
||||
# heartbeat_sent(success: bool, server_id: String, http_code: int)
|
||||
# master_connection_failed(error_message: String)
|
||||
# =============================================================================
|
||||
|
||||
signal heartbeat_sent(success: bool, server_id: String, http_code: int)
|
||||
signal master_connection_failed(error_message: String)
|
||||
|
||||
const DEFAULT_HEARTBEAT_INTERVAL: float = 60.0
|
||||
const MIN_INTERVAL: float = 10.0
|
||||
const MAX_INTERVAL: float = 300.0
|
||||
const DEFAULT_MASTER_SERVER: String = "http://127.0.0.1:28961"
|
||||
|
||||
# HTTP request node for async requests
|
||||
var _http: HTTPRequest
|
||||
var _timer: Timer
|
||||
var _server_id: String = ""
|
||||
var _enabled: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Create HTTP request node
|
||||
_http = HTTPRequest.new()
|
||||
_http.name = "MasterServerHTTP"
|
||||
_http.timeout = 10
|
||||
_http.use_threads = true
|
||||
add_child(_http)
|
||||
_http.request_completed.connect(_on_heartbeat_completed)
|
||||
|
||||
# Create timer for periodic heartbeats
|
||||
_timer = Timer.new()
|
||||
_timer.name = "HeartbeatTimer"
|
||||
_timer.one_shot = false
|
||||
add_child(_timer)
|
||||
_timer.timeout.connect(_send_heartbeat)
|
||||
|
||||
|
||||
func start() -> void:
|
||||
"""Begin the heartbeat loop. Call after cvars are loaded and server is ready."""
|
||||
var master_url = _get_cvar_string("sv_master_server", "")
|
||||
if master_url.is_empty():
|
||||
push_warning("[ServerBrowser] sv_master_server is empty — heartbeat disabled")
|
||||
return
|
||||
|
||||
# Strip trailing slash
|
||||
master_url = master_url.trim_suffix("/")
|
||||
if not master_url.begins_with("http"):
|
||||
master_url = "http://" + master_url
|
||||
|
||||
if not master_url.begins_with("http://") and not master_url.begins_with("https://"):
|
||||
push_warning("[ServerBrowser] Invalid master server URL: ", master_url)
|
||||
return
|
||||
|
||||
_server_id = _get_cvar_string("sv_server_id", "")
|
||||
_enabled = true
|
||||
|
||||
var interval = _get_cvar_float("sv_heartbeat_interval", DEFAULT_HEARTBEAT_INTERVAL)
|
||||
interval = clamp(interval, MIN_INTERVAL, MAX_INTERVAL)
|
||||
|
||||
_timer.wait_time = interval
|
||||
_timer.start()
|
||||
|
||||
print("[ServerBrowser] Heartbeat enabled — master: ", master_url, " interval: ", interval, "s")
|
||||
|
||||
# Send initial heartbeat immediately
|
||||
_send_heartbeat()
|
||||
|
||||
|
||||
func stop() -> void:
|
||||
"""Stop sending heartbeats."""
|
||||
_enabled = false
|
||||
_timer.stop()
|
||||
print("[ServerBrowser] Heartbeat disabled")
|
||||
|
||||
|
||||
func set_server_id(new_id: String) -> void:
|
||||
"""Update the server id (e.g., after first heartbeat response)."""
|
||||
_server_id = new_id
|
||||
|
||||
|
||||
func _get_master_url() -> String:
|
||||
var raw = _get_cvar_string("sv_master_server", DEFAULT_MASTER_SERVER).trim_suffix("/")
|
||||
if raw.is_empty():
|
||||
return ""
|
||||
if not raw.begins_with("http"):
|
||||
raw = "http://" + raw
|
||||
return raw
|
||||
|
||||
|
||||
func _send_heartbeat() -> void:
|
||||
if not _enabled:
|
||||
return
|
||||
|
||||
var master_url = _get_master_url()
|
||||
if master_url.is_empty():
|
||||
return
|
||||
|
||||
# Build heartbeat payload
|
||||
var payload = {
|
||||
"server_id": _server_id,
|
||||
"name": _get_cvar_string("sv_server_name", "Tactical Shooter Server"),
|
||||
"map": _get_cvar_string("sv_map", "unknown"),
|
||||
"players": _get_player_count(),
|
||||
"max_players": _get_cvar_int("sv_max_players", 16),
|
||||
"game_mode": _get_cvar_string("sv_game_mode", "deathmatch"),
|
||||
"version": _get_cvar_string("sv_version", "1.0.0"),
|
||||
"tags": _get_cvar_string("sv_server_tags", ""),
|
||||
"password": _get_cvar_int("sv_server_password", 0) != 0,
|
||||
"host": _get_external_ip(),
|
||||
"port": _get_cvar_int("sv_port", 0),
|
||||
"steam_id": _get_cvar_string("sv_steam_id", ""),
|
||||
}
|
||||
|
||||
var json_string = JSON.stringify(payload)
|
||||
var headers = ["Content-Type: application/json"]
|
||||
|
||||
var endpoint = master_url + "/api/v1/heartbeat"
|
||||
var err = _http.request(endpoint, headers, HTTPClient.METHOD_POST, json_string)
|
||||
if err != OK:
|
||||
push_error("[ServerBrowser] Failed to send heartbeat: HTTP request error ", err)
|
||||
master_connection_failed.emit("HTTP request failed (code " + str(err) + ")")
|
||||
|
||||
|
||||
func _on_heartbeat_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
var msg = "Heartbeat failed — network error " + str(result)
|
||||
push_warning("[ServerBrowser] ", msg)
|
||||
master_connection_failed.emit(msg)
|
||||
heartbeat_sent.emit(false, _server_id, response_code)
|
||||
return
|
||||
|
||||
if response_code != 200:
|
||||
var msg = "Heartbeat failed — HTTP " + str(response_code)
|
||||
push_warning("[ServerBrowser] ", msg)
|
||||
master_connection_failed.emit(msg)
|
||||
heartbeat_sent.emit(false, _server_id, response_code)
|
||||
return
|
||||
|
||||
# Parse response for server_id (first heartbeat may get one assigned)
|
||||
var body_text = body.get_string_from_utf8()
|
||||
var json = JSON.new()
|
||||
var parse_err = json.parse(body_text)
|
||||
if parse_err == OK and json.data is Dictionary:
|
||||
if json.data.has("server_id") and not json.data["server_id"].is_empty():
|
||||
_server_id = json.data["server_id"]
|
||||
|
||||
heartbeat_sent.emit(true, _server_id, response_code)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Helper: read cvars from the config system (if available)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func _get_cvar_string(name: String, default_value: String) -> String:
|
||||
"""Try the CvarRegistry autoload first, fall back to ProjectSettings."""
|
||||
if Engine.has_singleton("CvarRegistry"):
|
||||
var reg = Engine.get_singleton("CvarRegistry")
|
||||
if reg.has_method("get_string"):
|
||||
return reg.get_string(name, default_value)
|
||||
# Fallback to ProjectSettings if no cvar registry exists
|
||||
if ProjectSettings.has_setting(name):
|
||||
return ProjectSettings.get_setting(name)
|
||||
return default_value
|
||||
|
||||
|
||||
func _get_cvar_int(name: String, default_value: int) -> int:
|
||||
if Engine.has_singleton("CvarRegistry"):
|
||||
var reg = Engine.get_singleton("CvarRegistry")
|
||||
if reg.has_method("get_int"):
|
||||
return reg.get_int(name, default_value)
|
||||
if ProjectSettings.has_setting(name):
|
||||
return ProjectSettings.get_setting(name)
|
||||
return default_value
|
||||
|
||||
|
||||
func _get_cvar_float(name: String, default_value: float) -> float:
|
||||
if Engine.has_singleton("CvarRegistry"):
|
||||
var reg = Engine.get_singleton("CvarRegistry")
|
||||
if reg.has_method("get_float"):
|
||||
return reg.get_float(name, default_value)
|
||||
if ProjectSettings.has_setting(name):
|
||||
return ProjectSettings.get_setting(name)
|
||||
return default_value
|
||||
|
||||
|
||||
func _get_player_count() -> int:
|
||||
"""Get current connected player count."""
|
||||
# Try to read from game's player manager singleton
|
||||
if Engine.has_singleton("PlayerManager"):
|
||||
var pm = Engine.get_singleton("PlayerManager")
|
||||
if pm.has_method("get_player_count"):
|
||||
return pm.get_player_count()
|
||||
return 0
|
||||
|
||||
|
||||
func _get_external_ip() -> String:
|
||||
"""Return our public/external IP if known, or empty for auto-detect."""
|
||||
return _get_cvar_string("sv_external_ip", "")
|
||||
@@ -0,0 +1 @@
|
||||
uid://pisglwxvlp6c
|
||||
Reference in New Issue
Block a user