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