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
+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"