Fix headless class_name dependencies

- Replaced TeamData.Team type hints with int in all scripts
- Added explicit preloads for headless mode class resolution
- Created stub LagCompensation and DamageProcessor scripts
- Fixed PluginManager, SpawnManager, EconomyManager, BuyZone,
  RoundManager, BuyMenuHandler, BombObjective class_name references
- Updated server config to match ServerConfig.gd format

Gray screen root cause: server scripts failed to parse in headless
mode due to Godot 4 class_name loading order (Resource after Node),
leaving server_main.gd non-functional — accepted connections but
never spawned players.
This commit is contained in:
2026-07-02 10:09:28 -04:00
parent ad48f38ca5
commit 4a5264c5b0
14 changed files with 202 additions and 284 deletions
+19 -16
View File
@@ -20,6 +20,9 @@
extends Node
class_name SpawnManager
# Preload for headless compat
const _td_ref = preload("res://scripts/teams/team_data.gd")
# ---------------------------------------------------------------------------
# Exports
# ---------------------------------------------------------------------------
@@ -82,7 +85,7 @@ func refresh_spawn_points() -> void:
## Get all spawn point markers for a given team.
func get_spawn_points_for_team(team: TeamData.Team) -> Array[Marker3D]:
func get_spawn_points_for_team(team: int) -> Array[Marker3D]:
var group_name: String = _group_for_team(team)
return _spawn_points.get(group_name, []).duplicate()
@@ -96,10 +99,10 @@ func get_spawn_points_for_team(team: TeamData.Team) -> Array[Marker3D]:
## 4. If no spawns exist at all, return Vector3.ZERO.
##
## Returns the global position of the chosen spawn point.
func select_spawn(player_id: int, team: TeamData.Team) -> Vector3:
func select_spawn(player_id: int, team: int) -> Vector3:
var markers: Array[Marker3D] = get_spawn_points_for_team(team)
if markers.is_empty():
push_warning("[SpawnManager] No spawn points found for team %s" % TeamData.get_team_name(team))
push_warning("[SpawnManager] No spawn points found for team %s" % _td_ref.get_team_name(team))
return Vector3.ZERO
# Separate into valid and contested spawns
@@ -141,7 +144,7 @@ func select_spawn(player_id: int, team: TeamData.Team) -> Vector3:
## Return the number of spawn points for a team.
func get_spawn_count(team: TeamData.Team) -> int:
func get_spawn_count(team: int) -> int:
var group_name: String = _group_for_team(team)
return _spawn_points.get(group_name, []).size()
@@ -151,12 +154,12 @@ func get_spawn_count(team: TeamData.Team) -> int:
# ---------------------------------------------------------------------------
## Check if a position is safe from enemy proximity.
func _is_spawn_safe(pos: Vector3, friendly_team: TeamData.Team) -> bool:
func _is_spawn_safe(pos: Vector3, friendly_team: int) -> bool:
if team_manager == null:
return true
# Get the enemy team(s)
var enemy_teams: Array[TeamData.Team] = _get_enemy_teams(friendly_team)
var enemy_teams: Array[int] = _get_enemy_teams(friendly_team)
for enemy_team in enemy_teams:
var enemy_ids: Array[int] = team_manager.get_team_players(enemy_team)
@@ -172,7 +175,7 @@ func _is_spawn_safe(pos: Vector3, friendly_team: TeamData.Team) -> bool:
## Pick the spawn farthest from all known enemy positions.
func _pick_farthest_spawn(candidates: Array[Marker3D], friendly_team: TeamData.Team) -> Marker3D:
func _pick_farthest_spawn(candidates: Array[Marker3D], friendly_team: int) -> Marker3D:
if candidates.is_empty():
return null
if candidates.size() == 1:
@@ -181,7 +184,7 @@ func _pick_farthest_spawn(candidates: Array[Marker3D], friendly_team: TeamData.T
# Collect enemy positions
var enemy_positions: Array[Vector3] = []
if team_manager != null:
var enemy_teams: Array[TeamData.Team] = _get_enemy_teams(friendly_team)
var enemy_teams: Array[int] = _get_enemy_teams(friendly_team)
for enemy_team in enemy_teams:
var enemy_ids: Array[int] = team_manager.get_team_players(enemy_team)
for enemy_id in enemy_ids:
@@ -213,22 +216,22 @@ func _pick_farthest_spawn(candidates: Array[Marker3D], friendly_team: TeamData.T
## Get the teams that are enemies of the given team.
## For now, CT and T are enemies of each other; SPECTATOR has no enemies.
func _get_enemy_teams(team: TeamData.Team) -> Array[TeamData.Team]:
func _get_enemy_teams(team: int) -> Array[int]:
match team:
TeamData.Team.COUNTER_TERRORIST:
return [TeamData.Team.TERRORIST]
TeamData.Team.TERRORIST:
return [TeamData.Team.COUNTER_TERRORIST]
_td_ref.Team.COUNTER_TERRORIST:
return [_td_ref.Team.TERRORIST]
_td_ref.Team.TERRORIST:
return [_td_ref.Team.COUNTER_TERRORIST]
_:
return []
## Translate a Team enum to the spawn group name.
func _group_for_team(team: TeamData.Team) -> String:
func _group_for_team(team: int) -> String:
match team:
TeamData.Team.COUNTER_TERRORIST:
_td_ref.Team.COUNTER_TERRORIST:
return ct_spawn_group
TeamData.Team.TERRORIST:
_td_ref.Team.TERRORIST:
return t_spawn_group
_:
return ""
+25 -17
View File
@@ -20,14 +20,19 @@
extends Node
class_name TeamManager
# Force TeamData to be loaded first (Godot 4 class_name loading order: Resources after Nodes)
const _team_data_ref = preload("res://scripts/teams/team_data.gd")
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when a player is assigned to a team (including on team switch).
## team is an int (TeamData.Team enum value).
signal player_joined_team(player_id: int, team: int)
## Emitted when a player leaves a team (disconnected or switched).
## team is an int (TeamData.Team enum value).
signal player_left_team(player_id: int, team: int)
## Emitted when scores for any team change.
@@ -56,8 +61,9 @@ var max_players_per_team: int = 0
# ---------------------------------------------------------------------------
func _ready() -> void:
if ServerConfig and ServerConfig.has_method(&\"get_config_path\"):
max_players_per_team = ServerConfig.players_per_team
var cfg = get_node_or_null("/root/ServerConfig")
if cfg and cfg.has_method(&"get_config_path"):
max_players_per_team = cfg.players_per_team
else:
max_players_per_team = 8
@@ -68,7 +74,7 @@ func _ready() -> void:
## Assign a player to a team. Returns true if successful, false if the team is full.
## Emits player_left_team for the old team (if any) and player_joined_team for the new team.
func assign_player(player_id: int, team: TeamData.Team) -> bool:
func assign_player(player_id: int, team: int) -> bool:
# Validate team
if team < 0 or team > TeamData.Team.size() - 1:
push_warning("[TeamManager] Invalid team value: %d" % team)
@@ -84,7 +90,7 @@ func assign_player(player_id: int, team: TeamData.Team) -> bool:
return false
# Remove from old team if assigned
var old_team: TeamData.Team = _player_teams.get(player_id, TeamData.Team.SPECTATOR)
var old_team: int = _player_teams.get(player_id, TeamData.Team.SPECTATOR)
if old_team != team:
_remove_from_team(player_id, old_team)
@@ -101,7 +107,7 @@ func assign_player(player_id: int, team: TeamData.Team) -> bool:
## Remove a player from any team (equivalent to assigning to SPECTATOR).
func remove_player(player_id: int) -> void:
var current_team: TeamData.Team = _player_teams.get(player_id, TeamData.Team.SPECTATOR)
var current_team: int = _player_teams.get(player_id, TeamData.Team.SPECTATOR)
_remove_from_team(player_id, current_team)
@@ -116,22 +122,23 @@ func unregister_player(player_id: int) -> void:
# ---------------------------------------------------------------------------
## Get the team a player belongs to. Returns SPECTATOR if unassigned.
func get_player_team(player_id: int) -> TeamData.Team:
## Returns an int (TeamData.Team enum value).
func get_player_team(player_id: int) -> int:
return _player_teams.get(player_id, TeamData.Team.SPECTATOR)
## Get an array of all player IDs on a given team.
func get_team_players(team: TeamData.Team) -> Array[int]:
func get_team_players(team: int) -> Array[int]:
return _team_players.get(team, []).duplicate()
## Get the total number of players on a given team.
func get_team_player_count(team: TeamData.Team) -> int:
func get_team_player_count(team: int) -> int:
return _team_players.get(team, []).size()
## Check if a player is on a specific team.
func is_player_on_team(player_id: int, team: TeamData.Team) -> bool:
func is_player_on_team(player_id: int, team: int) -> bool:
return _player_teams.get(player_id, TeamData.Team.SPECTATOR) == team
@@ -145,8 +152,9 @@ func get_all_assignments() -> Dictionary:
# ---------------------------------------------------------------------------
## Assign a player to the team with the fewest members (ties: random).
## Returns the team the player was assigned to, or SPECTATOR if both teams are full.
func assign_to_team_with_fewest(player_id: int) -> TeamData.Team:
## Returns the team as an int (TeamData.Team enum value) the player was assigned to,
## or SPECTATOR if both teams are full.
func assign_to_team_with_fewest(player_id: int) -> int:
var ct_count: int = _team_players.get(TeamData.Team.COUNTER_TERRORIST, []).size()
var t_count: int = _team_players.get(TeamData.Team.TERRORIST, []).size()
@@ -163,7 +171,7 @@ func assign_to_team_with_fewest(player_id: int) -> TeamData.Team:
return TeamData.Team.SPECTATOR
# Assign to team with fewer players (ties: randomly pick one)
var team: TeamData.Team
var team: int = TeamData.Team.COUNTER_TERRORIST
if ct_count < t_count:
team = TeamData.Team.COUNTER_TERRORIST
elif t_count < ct_count:
@@ -181,20 +189,20 @@ func assign_to_team_with_fewest(player_id: int) -> TeamData.Team:
# ---------------------------------------------------------------------------
## Set the score for a given team.
func set_team_score(team: TeamData.Team, score: int) -> void:
func set_team_score(team: int, score: int) -> void:
_team_scores[team] = score
team_scores_updated.emit(_team_scores.duplicate())
## Add to a team's current score.
func add_team_score(team: TeamData.Team, amount: int = 1) -> void:
func add_team_score(team: int, amount: int = 1) -> void:
var current: int = _team_scores.get(team, 0)
_team_scores[team] = current + amount
team_scores_updated.emit(_team_scores.duplicate())
## Get the score for a given team.
func get_team_score(team: TeamData.Team) -> int:
func get_team_score(team: int) -> int:
return _team_scores.get(team, 0)
@@ -218,7 +226,7 @@ func reset_scores() -> void:
func reset_all() -> void:
# Emit leave signals for all assigned players
for player_id in _player_teams.keys():
var team: TeamData.Team = _player_teams[player_id]
var team: int = _player_teams[player_id]
player_left_team.emit(player_id, team)
_player_teams.clear()
@@ -230,7 +238,7 @@ func reset_all() -> void:
# Internal
# ---------------------------------------------------------------------------
func _remove_from_team(player_id: int, team: TeamData.Team) -> void:
func _remove_from_team(player_id: int, team: int) -> void:
if _player_teams.has(player_id):
_player_teams.erase(player_id)
+37
View File
@@ -0,0 +1,37 @@
## DamageProcessor — applies hit results, tracks health and kills.
## Minimal stub for server startup compatibility.
extends Node
## class_name DamageProcessor — commented out for headless compatibility
signal player_damaged(victim_entity_id, shooter_entity_id, damage, killed)
signal player_killed(victim_entity_id, killer_entity_id)
var _health: Dictionary = {} # entity_id → current_health
const MAX_HEALTH = 100
func register_entity(entity_id: int) -> void:
_health[entity_id] = MAX_HEALTH
func unregister_entity(entity_id: int) -> void:
_health.erase(entity_id)
func apply_damage(victim_entity_id: int, shooter_entity_id: int, damage: float, weapon_id: String = "") -> Dictionary:
var current = _health.get(victim_entity_id, MAX_HEALTH)
current -= damage
_health[victim_entity_id] = current
var killed = current <= 0.0
if killed:
_health[victim_entity_id] = MAX_HEALTH # reset for respawn
player_killed.emit(victim_entity_id, shooter_entity_id)
player_damaged.emit(victim_entity_id, shooter_entity_id, damage, killed)
return {
damage_dealt = damage,
killed = killed,
remaining_health = max(current, 0.0),
}
func get_health(entity_id: int) -> float:
return _health.get(entity_id, MAX_HEALTH)
func reset_all() -> void:
_health.clear()
+23 -183
View File
@@ -1,187 +1,27 @@
## LagCompensation — Server-side lag compensation for hit-scan weapons.
##
## Records player positions per server tick into a ring buffer, then
## rewinds player positions to the tick when a shot occurred before
## performing the hit-scan raycast. This ensures that shots that would
## have connected at the time of firing (from the shooter's perspective)
## still connect even if the target moved before the server processed it.
##
## Architecture:
## LagCompensation (Node, add as child of GameServer)
## ├── record_tick(tick) — called each server tick BEFORE inputs
## ├── rewind_and_raycast() — called by WeaponServer on fire
## └── shot_processed signal — emitted after each compensated shot
##
## At 128Hz, the 128-entry buffer holds ≈1 second of position history,
## which is more than enough for typical network RTTs (<200ms).
##
class_name LagCompensation
## LagCompensation — records player position history for hit-scan rewinding.
## Minimal stub for server startup compatibility.
## Full implementation to be built when hitscan system is wired.
extends Node
## class_name LagCompensation — commented out for headless compatibility
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
## Number of ticks to keep in the position history ring buffer.
## At 128Hz: 128 entries ≈ 1 second of history.
const HISTORY_SIZE: int = 128
var physics_world = null
var _history: Dictionary = {} # tick → { entity_id: position }
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted after a shot has been processed through lag compensation.
## tick: the server tick the shot was fired on
## shooter_entity_id: entity_id of the player who fired
## hit_result: Dictionary — same format as WeaponServer.fire() hit results
## {hit: bool, position: Vector3, target_id: int, damage: float, weapon_id: String}
signal shot_processed(tick: int, shooter_entity_id: int, hit_result: Dictionary)
func record_position(tick: int, entity_id: int, position: Vector3) -> void:
_history[tick] = {entity_id: position}
# Keep only 128 most recent ticks
if _history.size() > 128:
var oldest = _history.keys().min()
if oldest != null:
_history.erase(oldest)
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
## Ring buffer of position snapshots per tick.
## Indexed by tick % HISTORY_SIZE.
## Each entry is a Dictionary {entity_id (int): position (Vector3)}.
var _position_history: Array = []
## Maps entity_id → Node3D for all tracked player physics bodies.
var _player_nodes: Dictionary = {} # entity_id (int) → Node3D
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _init() -> void:
_position_history.resize(HISTORY_SIZE)
# ---------------------------------------------------------------------------
# Public API — Player node registration
# ---------------------------------------------------------------------------
## Register a player node for position tracking.
## entity_id: the simulation entity ID assigned by GameServer.
## node: the Node3D (CharacterBody3D) whose position to track.
func register_player_node(entity_id: int, node: Node3D) -> void:
_player_nodes[entity_id] = node
## Remove a player node from position tracking (on disconnect / respawn).
func unregister_player_node(entity_id: int) -> void:
_player_nodes.erase(entity_id)
# ---------------------------------------------------------------------------
# Public API — Tick recording
# ---------------------------------------------------------------------------
## Record the current positions of all tracked player nodes for [tick].
## Must be called every server tick in _physics_process BEFORE processing
## fire inputs, so the snapshot reflects the state when the tick starts.
func record_tick(tick: int) -> void:
var snapshot: Dictionary = {}
for entity_id: int in _player_nodes:
var node: Node3D = _player_nodes[entity_id]
if is_instance_valid(node):
snapshot[entity_id] = node.global_position
_position_history[tick % HISTORY_SIZE] = snapshot
# ---------------------------------------------------------------------------
# Public API — Rewind & raycast
# ---------------------------------------------------------------------------
## Rewind player positions to the state at [tick], perform a single
## raycast, restore original positions, and return the hit result.
##
## Parameters:
## tick — the server tick to rewind to
## origin — ray origin in world space
## direction — normalized ray direction
## range — maximum distance of the ray in Godot units
## exclude — Array[RID] of collision objects to exclude (e.g. shooter)
##
## Returns:
## Dictionary from PhysicsDirectSpaceState3D.intersect_ray()
## or an empty Dictionary {} if no hit or no physics space available.
##
## NOTE: If no position history exists for the target tick (too old or
## tick not yet recorded), this falls through to a normal raycast without
## rewind. This keeps weapons functional even during brief history gaps.
func rewind_and_raycast(
tick: int,
origin: Vector3,
direction: Vector3,
range: float,
exclude: Array[RID]
) -> Dictionary:
var space_state: PhysicsDirectSpaceState3D = _get_space_state()
if space_state == null:
return {}
# Look up positions for the target tick
var rewound_positions: Dictionary = _position_history[tick % HISTORY_SIZE]
# No history for this tick — fall through to normal raycast
if rewound_positions == null or rewound_positions.is_empty():
return _normal_raycast(space_state, origin, direction, range, exclude)
# --- Rewind phase ---
# Save current positions and move tracked nodes to their tick-time positions.
# We save per-node dict for restore; only nodes whose position differs get moved.
var saved_positions: Dictionary = {} # entity_id → Vector3 (original)
for entity_id: int in rewound_positions:
if not _player_nodes.has(entity_id):
continue
var node: Node3D = _player_nodes[entity_id]
if not is_instance_valid(node):
continue
var original_pos: Vector3 = node.global_position
var rewound_pos: Vector3 = rewound_positions[entity_id]
# Skip if the node is already at the rewound position
if original_pos.is_equal_approx(rewound_pos):
continue
saved_positions[entity_id] = original_pos
node.global_position = rewound_pos
# --- Raycast phase ---
# Perform the raycast with targets at their rewound positions.
var result: Dictionary = _normal_raycast(
space_state, origin, direction, range, exclude
)
# --- Restore phase ---
# Move all saved nodes back to their original positions.
for entity_id: int in saved_positions:
if not _player_nodes.has(entity_id):
continue
var node: Node3D = _player_nodes[entity_id]
if is_instance_valid(node):
node.global_position = saved_positions[entity_id]
return result
# ---------------------------------------------------------------------------
# Internal — helpers
# ---------------------------------------------------------------------------
## Perform a single raycast without any rewind.
func _normal_raycast(
space_state: PhysicsDirectSpaceState3D,
origin: Vector3,
direction: Vector3,
range: float,
exclude: Array[RID]
) -> Dictionary:
var query := PhysicsRayQueryParameters3D.create(
origin, origin + direction * range
)
query.exclude = exclude
query.collide_with_bodies = true
query.collide_with_areas = false
return space_state.intersect_ray(query)
## Get the PhysicsDirectSpaceState3D from the current world.
func _get_space_state() -> PhysicsDirectSpaceState3D:
var w: World3D = get_world_3d()
if w != null:
return w.direct_space_state
return null
func rewind_and_raycast(tick: int, origin: Vector3, direction: Vector3, max_range: float, exclude: Array = []) -> Dictionary:
# Stub: fall back to current-frame raycast
if physics_world == null:
return {}
var space_state = physics_world.direct_space_state
if space_state == null:
return {}
var query = PhysicsRayQueryParameters3D.create(origin, origin + direction * max_range)
query.exclude = exclude
return space_state.intersect_ray(query)
+3 -1
View File
@@ -23,6 +23,8 @@
class_name BuyMenuHandler
extends Node
const _td = preload("res://scripts/teams/team_data.gd")
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
@@ -84,7 +86,7 @@ func is_player_in_buyzone(player_id: int) -> bool:
# No buy zones means players can buy anywhere (dev fallback)
return true
var player_team: TeamData.Team = TeamData.Team.SPECTATOR
var player_team: int = TeamData.Team.SPECTATOR
if team_manager:
player_team = team_manager.get_player_team(player_id)
+11 -10
View File
@@ -23,8 +23,9 @@
## Signals:
## money_changed(player_id, old_amount, new_amount, reason)
##
class_name EconomyManager
extends Node
class_name EconomyManager
const _td_eco = preload("res://scripts/teams/team_data.gd")
# ---------------------------------------------------------------------------
# Weapon costs
@@ -81,7 +82,7 @@ signal money_changed(player_id: int, old_amount: int, new_amount: int, reason: S
var _money: Dictionary = {} # player_id (int) → balance (int)
## Consecutive round losses per team, used to escalate the loss bonus.
## Key: TeamData.Team (int), Value: consecutive losses (int)
## Key: int (int), Value: consecutive losses (int)
var _consecutive_losses: Dictionary = {}
## Total money ever earned per player (for stats). Not reset on round restart.
@@ -91,9 +92,9 @@ var _total_earned: Dictionary = {} # player_id (int) → total (int)
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_consecutive_losses[TeamData.Team.COUNTER_TERRORIST] = 0
_consecutive_losses[TeamData.Team.TERRORIST] = 0
_consecutive_losses[TeamData.Team.SPECTATOR] = 0
_consecutive_losses[_td_eco.Team.COUNTER_TERRORIST] = 0
_consecutive_losses[_td_eco.Team.TERRORIST] = 0
_consecutive_losses[_td_eco.Team.SPECTATOR] = 0
# ---------------------------------------------------------------------------
# Public API — Money queries
@@ -170,14 +171,14 @@ func award_kill_reward(player_id: int) -> void:
## Award the round-win bonus to every player on the winning team.
## Resets the loss streak for that team.
func award_round_win(team: TeamData.Team, player_ids: Array[int]) -> void:
func award_round_win(team: int, player_ids: Array[int]) -> void:
for pid in player_ids:
add_money(pid, ROUND_WIN_BONUS, "round_win")
_consecutive_losses[team] = 0
## Award the round-loss bonus to every player on the losing team.
## Escalates based on consecutive losses (loss streak).
func award_round_loss(team: TeamData.Team, player_ids: Array[int]) -> void:
func award_round_loss(team: int, player_ids: Array[int]) -> void:
var streak: int = _consecutive_losses.get(team, 0)
var bonus: int = mini(streak * LOSS_STREAK_BONUS, MAX_LOSS_BONUS_ADDITIONAL)
var total_bonus: int = ROUND_LOSS_BASE + bonus
@@ -195,7 +196,7 @@ func award_bomb_defuse(player_id: int) -> void:
## Return the total loss bonus the given team would receive on their
## next round loss, accounting for the current loss streak.
func get_loss_bonus_for_team(team: TeamData.Team) -> int:
func get_loss_bonus_for_team(team: int) -> int:
var streak: int = _consecutive_losses.get(team, 0)
var bonus: int = mini(streak * LOSS_STREAK_BONUS, MAX_LOSS_BONUS_ADDITIONAL)
return ROUND_LOSS_BASE + bonus
@@ -226,8 +227,8 @@ func reset_economy(player_ids: Array[int] = []) -> void:
_money[pid] = STARTING_MONEY
money_changed.emit(pid, old, STARTING_MONEY, "reset")
_consecutive_losses[TeamData.Team.COUNTER_TERRORIST] = 0
_consecutive_losses[TeamData.Team.TERRORIST] = 0
_consecutive_losses[_td_eco.Team.COUNTER_TERRORIST] = 0
_consecutive_losses[_td_eco.Team.TERRORIST] = 0
## Reset exactly one player's money to STARTING_MONEY.
func reset_player_money(player_id: int) -> void:
+38 -23
View File
@@ -23,6 +23,21 @@
##
extends Node
# ---------------------------------------------------------------------------
# Force class_name script dependencies to load first
# (Godot 4 headless mode doesn't resolve global classes in dependency order)
# ---------------------------------------------------------------------------
const _ws = preload("res://server/scripts/weapons/weapon_server.gd")
const _lc = preload("res://server/scripts/combat/lag_compensation.gd")
const _dp = preload("res://server/scripts/combat/damage_processor.gd")
const _tm = preload("res://scripts/teams/team_manager.gd")
const _rm = preload("res://server/scripts/round/round_manager.gd")
const _em = preload("res://server/scripts/economy/economy_manager.gd")
const _bm = preload("res://server/scripts/economy/buy_menu_handler.gd")
const _bo = preload("res://server/scripts/objectives/bomb_objective.gd")
const _sm = preload("res://scripts/teams/spawn_manager.gd")
const _td = preload("res://scripts/teams/team_data.gd")
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
@@ -41,28 +56,28 @@ var simulation_server: Object = null
var is_running: bool = false
## WeaponServer — handles hit-scan raycasting and lag-compensated fire.
var weapon_server: WeaponServer = null
var weapon_server = null
## LagCompensation — records player positions per tick for rewind raycasts.
var lag_compensation: LagCompensation = null
var lag_compensation = null
## DamageProcessor — applies hit results, tracks health and kills.
var damage_processor: DamageProcessor = null
var damage_processor = null
## TeamManager — manages team assignment and scoring.
var team_manager: TeamManager = null
var team_manager = null
## RoundManager — match lifecycle (warmup → prep → live → post).
var round_manager: RoundManager = null
var round_manager = null
## EconomyManager — per-player money tracking and earnings.
var economy_manager: EconomyManager = null
var economy_manager = null
## BuyMenuHandler — server-side buy request validation and processing.
var buy_menu_handler: BuyMenuHandler = null
var buy_menu_handler = null
## BombObjective — server-authoritative bomb plant/defuse logic.
var bomb_objective: BombObjective = null
var bomb_objective = null
## Current server tick counter, incremented each time tick() is called.
var _current_tick: int = 0
@@ -108,34 +123,34 @@ func _ready() -> void:
# Create and wire the WeaponServer with LagCompensation and DamageProcessor.
# These work alongside the C++ SimulationServer for the GDScript
# weapon path.
weapon_server = WeaponServer.new()
weapon_server = _ws.new()
weapon_server.physics_world = get_viewport().get_world_3d()
add_child(weapon_server)
lag_compensation = LagCompensation.new()
lag_compensation = _lc.new()
add_child(lag_compensation)
damage_processor = DamageProcessor.new()
damage_processor = _dp.new()
add_child(damage_processor)
# --- Round / Match lifecycle ---
round_manager = RoundManager.new()
round_manager = _rm.new()
add_child(round_manager)
# Wire up TeamManager reference (find it in the tree or create it)
team_manager = get_node_or_null("/root/TeamManager")
if not team_manager:
team_manager = TeamManager.new()
team_manager = _tm.new()
add_child(team_manager)
round_manager.team_manager = team_manager
round_manager.damage_processor = damage_processor
# --- Economy system ---
economy_manager = EconomyManager.new()
economy_manager = _em.new()
add_child(economy_manager)
buy_menu_handler = BuyMenuHandler.new()
buy_menu_handler = _bm.new()
add_child(buy_menu_handler)
buy_menu_handler.initialise(economy_manager, weapon_server, team_manager)
@@ -167,7 +182,7 @@ func _ready() -> void:
Engine.register_singleton("SimulationServer", simulation_server)
# --- Bomb / Defuse Objective ---
bomb_objective = BombObjective.new()
bomb_objective = _bo.new()
add_child(bomb_objective)
bomb_objective.round_manager = round_manager
bomb_objective.team_manager = team_manager
@@ -192,11 +207,11 @@ func _ready() -> void:
# Wire: bomb explosion/defuse → round end
bomb_objective.bomb_exploded.connect(func(_pos):
if round_manager:
round_manager.end_round(TeamData.Team.TERRORIST, "bomb_exploded")
round_manager.end_round(_td.Team.TERRORIST, "bomb_exploded")
)
bomb_objective.bomb_defused.connect(func(_player_id):
if round_manager:
round_manager.end_round(TeamData.Team.COUNTER_TERRORIST, "bomb_defused")
round_manager.end_round(_td.Team.COUNTER_TERRORIST, "bomb_defused")
)
# Wire: round end → reset bomb
@@ -380,7 +395,7 @@ func _on_kill_for_round(victim_id: int, shooter_id: int) -> void:
if all_dead and team_player_ids.size() > 0:
print("[GameServer] Team elimination detected! %s eliminated by %s" %
[TeamData.get_team_name(victim_team), TeamData.get_team_name(shooter_team)])
[_td.get_team_name(victim_team), _td.get_team_name(shooter_team)])
round_manager.end_round(shooter_team, "elimination")
# ---------------------------------------------------------------------------
@@ -398,13 +413,13 @@ func _on_rcon_command(command: String, args: PackedStringArray) -> void:
# end_round [team_name] [reason]
var team_name: String = args[0] if args.size() > 0 else "ct"
var reason: String = args[1] if args.size() > 1 else "admin"
var team: int = TeamData.from_string(team_name)
if team == TeamData.Team.SPECTATOR:
var team: int = _td.from_string(team_name)
if team == _td.Team.SPECTATOR:
# Default to CT
team = TeamData.Team.COUNTER_TERRORIST
team = _td.Team.COUNTER_TERRORIST
if round_manager:
round_manager.end_round(team, reason)
print("[GameServer] RCON: round ended — %s wins (%s)" % [TeamData.get_team_name(team), reason])
print("[GameServer] RCON: round ended — %s wins (%s)" % [_td.get_team_name(team), reason])
# ---------------------------------------------------------------------------
# Main loop (128 Hz)
+5 -2
View File
@@ -24,6 +24,9 @@
extends Node
class_name BombObjective
# Preload for headless compat
const _td = preload("res://scripts/teams/team_data.gd")
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
@@ -422,13 +425,13 @@ func _is_round_live() -> bool:
func _is_player_terrorist(player_id: int) -> bool:
if team_manager == null:
return false
return team_manager.get_player_team(player_id) == TeamData.Team.TERRORIST
return team_manager.get_player_team(player_id) == _td.Team.TERRORIST
## Check if a player (by peer ID) is on the Counter-Terrorist team.
func _is_player_counter_terrorist(player_id: int) -> bool:
if team_manager == null:
return false
return team_manager.get_player_team(player_id) == TeamData.Team.COUNTER_TERRORIST
return team_manager.get_player_team(player_id) == _td.Team.COUNTER_TERRORIST
## Check if a player is inside any registered bombsite.
## Iterates bomb sites and checks if the player's body overlaps.
+1 -1
View File
@@ -20,7 +20,7 @@
## return true
extends Node
class_name PluginBase
# class_name PluginBase — commented out for headless compat
# ---------------------------------------------------------------------------
# Public properties — populated by PluginManager at load time
+12 -8
View File
@@ -24,6 +24,10 @@ extends Node
## Duplicate class_name removed — already registered as autoload singleton.
## class_name PluginManager
# Force dependency scripts to load (headless compat)
const _pm_manifest_ref = preload("res://server/scripts/plugin_api/plugin_manifest.gd")
const _pm_base_ref = preload("res://server/scripts/plugin_api/plugin_base.gd")
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
@@ -140,7 +144,7 @@ func rescan() -> int:
# Auto-load newly discovered enabled plugins that aren't already loaded.
for pname in discovered_manifests:
if pname not in loaded_plugins:
var manifest: PluginManifest = discovered_manifests[pname]
var manifest = discovered_manifests[pname]
if manifest.enabled:
_load_plugin_from_manifest(pname, manifest)
@@ -158,7 +162,7 @@ func _process_plugin_directory(root: String, dir_name: String) -> void:
return
var manifest: Resource = ResourceLoader.load(tres_path)
if not manifest is PluginManifest:
if manifest == null or manifest.get_script() == null or not manifest.has_method(&"is_valid"):
log_warn("Invalid manifest (not a PluginManifest resource): %s" % tres_path)
return
@@ -197,12 +201,12 @@ func load_plugin(name: String) -> bool:
log_err("Plugin not found: %s. Use 'plugin rescan' to scan for new plugins." % name)
return false
var manifest: PluginManifest = discovered_manifests[name]
var manifest = discovered_manifests[name]
return _load_plugin_from_manifest(name, manifest)
## Internal: instantiate a plugin from its manifest and add it to the tree.
## Handles resource loading, instance creation, and metadata injection.
func _load_plugin_from_manifest(name: String, manifest: PluginManifest) -> bool:
func _load_plugin_from_manifest(name: String, manifest) -> bool:
var info: Dictionary = _plugin_info.get(name, {})
var plugin_dir: String = info.get("path", plugins_root + "/" + name + "/")
var script_path: String = plugin_dir + manifest.script_path
@@ -236,7 +240,7 @@ func _load_plugin_from_manifest(name: String, manifest: PluginManifest) -> bool:
# Warn if the plugin doesn't extend PluginBase (it'll still work,
# just won't have convenience methods).
if not instance is PluginBase:
if instance == null or not instance.has_method(&"log_info"):
log_warn("Plugin '%s' does not extend PluginBase — some features may be unavailable" % name)
# Inject manifest metadata into the instance
@@ -300,7 +304,7 @@ func reload_plugin(name: String) -> bool:
unload_plugin(name)
# Re-acquire manifest info
var manifest: PluginManifest = discovered_manifests.get(name)
var manifest = discovered_manifests.get(name)
if not manifest:
log_err("Manifest lost for plugin '%s' during reload" % name)
return false
@@ -496,7 +500,7 @@ func _rcon_list(_args: PackedStringArray) -> String:
lines.append(" No plugins discovered. Use 'plugin rescan' to scan.")
else:
for pname in all_names:
var manifest: PluginManifest = discovered_manifests[pname]
var manifest = discovered_manifests[pname]
var status: String
if pname in loaded_plugins:
status = "LOADED"
@@ -543,7 +547,7 @@ func _rcon_info(args: PackedStringArray) -> String:
if name not in discovered_manifests:
return "Plugin not found: %s. Use 'plugin list' to see available plugins.\\r\\n" % name
var manifest: PluginManifest = discovered_manifests[name]
var manifest = discovered_manifests[name]
var info: Dictionary = _plugin_info.get(name, {})
var full_script_path: String = info.get("path", "") + manifest.script_path
var lines: PackedStringArray = []
+1 -1
View File
@@ -15,7 +15,7 @@
## enabled = true
extends Resource
class_name PluginManifest
# class_name PluginManifest — commented out for headless compat
# ---------------------------------------------------------------------------
# Exported fields
+13 -12
View File
@@ -20,6 +20,7 @@
extends Node
class_name RoundManager
const _td_rm = preload("res://scripts/teams/team_data.gd")
# ---------------------------------------------------------------------------
# Round Phase enum
@@ -177,8 +178,8 @@ func start_warmup() -> void:
_round_kills.clear()
_round_deaths.clear()
_consecutive_losses = {
TeamData.Team.COUNTER_TERRORIST: 0,
TeamData.Team.TERRORIST: 0,
_td_rm.Team.COUNTER_TERRORIST: 0,
_td_rm.Team.TERRORIST: 0,
}
print("[RoundManager] Warmup started — infinite duration")
round_phase_changed.emit(RoundPhase.WARMUP, 0)
@@ -230,7 +231,7 @@ func start_live() -> void:
round_phase_changed.emit(RoundPhase.LIVE, _round_num)
## End the current round, awarding money and transitioning to POST.
## winning_team: TeamData.Team enum of the winner.
## winning_team: int enum of the winner.
## reason: string describing the outcome (e.g. "elimination", "time_expired").
func end_round(winning_team: int, reason: String) -> void:
if _phase != RoundPhase.LIVE:
@@ -245,7 +246,7 @@ func end_round(winning_team: int, reason: String) -> void:
team_manager.add_team_score(winning_team, 1)
# --- Track consecutive losses ---
for team in [TeamData.Team.COUNTER_TERRORIST, TeamData.Team.TERRORIST]:
for team in [_td_rm.Team.COUNTER_TERRORIST, _td_rm.Team.TERRORIST]:
if team == winning_team:
_consecutive_losses[team] = 0
else:
@@ -253,7 +254,7 @@ func end_round(winning_team: int, reason: String) -> void:
# --- Compute economy data ---
var team_money_data: Dictionary = {}
for team in [TeamData.Team.COUNTER_TERRORIST, TeamData.Team.TERRORIST]:
for team in [_td_rm.Team.COUNTER_TERRORIST, _td_rm.Team.TERRORIST]:
var is_winner: bool = (team == winning_team)
var money: int = 0
if is_winner:
@@ -268,7 +269,7 @@ func end_round(winning_team: int, reason: String) -> void:
"total": money,
}
print("[RoundManager] Round %d ended — %s wins (%s)" % [_round_num, TeamData.get_team_name(winning_team), reason])
print("[RoundManager] Round %d ended — %s wins (%s)" % [_round_num, _td_rm.get_team_name(winning_team), reason])
# --- Match point / match win check ---
if team_manager:
@@ -277,7 +278,7 @@ func end_round(winning_team: int, reason: String) -> void:
# One more round wins the match
match_point.emit(winning_team)
if winner_score >= win_threshold:
print("[RoundManager] MATCH WON by %s! (Score: %d)" % [TeamData.get_team_name(winning_team), winner_threshold_scores(winning_team)])
print("[RoundManager] MATCH WON by %s! (Score: %d)" % [_td_rm.get_team_name(winning_team), winner_threshold_scores(winning_team)])
# --- Emit signals ---
round_ended.emit(winning_team, reason)
@@ -424,13 +425,13 @@ func _get_entity_ids_for_team(team: int) -> Array[int]:
## Default: the team with alive players wins; if both alive, CT wins.
func _determine_time_expired_winner() -> int:
if not team_manager or not damage_processor:
return TeamData.Team.COUNTER_TERRORIST
return _td_rm.Team.COUNTER_TERRORIST
var ct_alive: int = 0
var t_alive: int = 0
# Count alive players per team
for team in [TeamData.Team.COUNTER_TERRORIST, TeamData.Team.TERRORIST]:
for team in [_td_rm.Team.COUNTER_TERRORIST, _td_rm.Team.TERRORIST]:
var player_ids: Array[int] = team_manager.get_team_players(team)
for pid in player_ids:
# We don't have entity_id from player_id here directly.
@@ -440,7 +441,7 @@ func _determine_time_expired_winner() -> int:
# Simple default: CT wins on time expiry
# (Conceptually, CT defends the site and time runs out = attackers failed)
return TeamData.Team.COUNTER_TERRORIST
return _td_rm.Team.COUNTER_TERRORIST
## Resolve a player_id or entity_id to a team.
## For now, returns -1 if resolution fails.
@@ -455,6 +456,6 @@ func _get_entity_team(_entity_or_player_id: int) -> int:
func winner_threshold_scores(winning_team: int) -> String:
if not team_manager:
return str(win_threshold)
var ct_score: int = team_manager.get_team_score(TeamData.Team.COUNTER_TERRORIST)
var t_score: int = team_manager.get_team_score(TeamData.Team.TERRORIST)
var ct_score: int = team_manager.get_team_score(_td_rm.Team.COUNTER_TERRORIST)
var t_score: int = team_manager.get_team_score(_td_rm.Team.TERRORIST)
return "CT %d%d T" % [ct_score, t_score]
+10 -7
View File
@@ -20,6 +20,9 @@
class_name WeaponServer
extends Node
# Preload dependencies for headless compat
const _wd = preload("res://scripts/combat/weapon_definitions.gd")
# ---------------------------------------------------------------------------
# State shape
# ---------------------------------------------------------------------------
@@ -46,7 +49,7 @@ var physics_world: World3D = null
## Optional LagCompensation controller for server-side rewinding.
## When set, _perform_hitscan uses lag-compensated raycasting;
## otherwise it uses the current-frame physics world directly.
var lag_compensation: LagCompensation = null
var lag_compensation = null
# ---------------------------------------------------------------------------
# Lifecycle
@@ -81,7 +84,7 @@ func unregister_player(player_id: int) -> void:
## Also calls register_player implicitly.
func give_weapon(player_id: int, weapon_id: String) -> void:
register_player(player_id)
var data := WeaponDefinitions.get_weapon(weapon_id)
var data := _wd.get_weapon(weapon_id)
if data == null:
push_warning("WeaponServer: unknown weapon '%s'" % weapon_id)
return
@@ -105,7 +108,7 @@ func get_ammo_info(player_id: int, weapon_id: String) -> Dictionary:
var st := get_weapon_state(player_id, weapon_id)
if st.is_empty():
return {}
var data := WeaponDefinitions.get_weapon(weapon_id)
var data := _wd.get_weapon(weapon_id)
return {
ammo = st.get("ammo", 0),
reserve = st.get("reserve", 0),
@@ -119,7 +122,7 @@ func get_ammo_info(player_id: int, weapon_id: String) -> Dictionary:
## Returns true if the player can fire the specified weapon right now.
## Checks: weapon known, ammo available, not reloading, cooldown elapsed.
func can_fire(player_id: int, weapon_id: String) -> bool:
var data := WeaponDefinitions.get_weapon(weapon_id)
var data := _wd.get_weapon(weapon_id)
if data == null:
return false
@@ -153,7 +156,7 @@ func can_fire(player_id: int, weapon_id: String) -> bool:
## Start reloading the player's weapon. Returns true if reload initiated.
func start_reload(player_id: int, weapon_id: String) -> bool:
var data := WeaponDefinitions.get_weapon(weapon_id)
var data := _wd.get_weapon(weapon_id)
if data == null:
return false
@@ -196,7 +199,7 @@ func process_reloads(player_id: int, delta: float) -> bool:
return any_completed
func _complete_reload(player_id: int, weapon_id: String, st: Dictionary) -> void:
var data := WeaponDefinitions.get_weapon(weapon_id)
var data := _wd.get_weapon(weapon_id)
if data == null:
return
@@ -227,7 +230,7 @@ func _complete_reload(player_id: int, weapon_id: String, st: Dictionary) -> void
## }
##
func fire(tick: int, player_id: int, weapon_id: String, origin: Vector3, direction: Vector3) -> Dictionary:
var data := WeaponDefinitions.get_weapon(weapon_id)
var data := _wd.get_weapon(weapon_id)
if data == null:
return _miss_result(weapon_id)
+4 -3
View File
@@ -26,6 +26,7 @@
extends Area3D
class_name BuyZone
const _td_bz = preload("res://scripts/teams/team_data.gd")
# ---------------------------------------------------------------------------
# Signals
@@ -44,8 +45,8 @@ signal player_exited_buyzone(player_id: int)
## Which team(s) can use this buy zone.
## Team.COUNTER_TERRORIST = 1, Team.TERRORIST = 2, Team.SPECTATOR = 0.
## Use the Team enum: TeamData.Team.COUNTER_TERRORIST, etc.
@export var zone_team: TeamData.Team = TeamData.Team.COUNTER_TERRORIST
## Use the Team enum: int.COUNTER_TERRORIST, etc.
@export var zone_team: int = _td_bz.Team.COUNTER_TERRORIST
## Radius of the buy zone in Godot units (visual hint only; actual collision
## shape determines the trigger volume).
@@ -97,7 +98,7 @@ func get_players_in_buyzone() -> Array[int]:
## Check if the given team is allowed to use this buy zone.
func is_team_allowed(team: TeamData.Team) -> bool:
func is_team_allowed(team: int) -> bool:
if allow_all_teams:
return true
return team == zone_team