diff --git a/scripts/teams/spawn_manager.gd b/scripts/teams/spawn_manager.gd index 151b58c..58485a7 100644 --- a/scripts/teams/spawn_manager.gd +++ b/scripts/teams/spawn_manager.gd @@ -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 "" diff --git a/scripts/teams/team_manager.gd b/scripts/teams/team_manager.gd index 7c4fe85..3defdf4 100644 --- a/scripts/teams/team_manager.gd +++ b/scripts/teams/team_manager.gd @@ -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) diff --git a/server/scripts/combat/damage_processor.gd b/server/scripts/combat/damage_processor.gd new file mode 100644 index 0000000..2cc5e13 --- /dev/null +++ b/server/scripts/combat/damage_processor.gd @@ -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() diff --git a/server/scripts/combat/lag_compensation.gd b/server/scripts/combat/lag_compensation.gd index dfbdb86..0df3e0f 100644 --- a/server/scripts/combat/lag_compensation.gd +++ b/server/scripts/combat/lag_compensation.gd @@ -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) diff --git a/server/scripts/economy/buy_menu_handler.gd b/server/scripts/economy/buy_menu_handler.gd index 2a2b48a..3c75548 100644 --- a/server/scripts/economy/buy_menu_handler.gd +++ b/server/scripts/economy/buy_menu_handler.gd @@ -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) diff --git a/server/scripts/economy/economy_manager.gd b/server/scripts/economy/economy_manager.gd index c49f4cf..ae6436e 100644 --- a/server/scripts/economy/economy_manager.gd +++ b/server/scripts/economy/economy_manager.gd @@ -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: diff --git a/server/scripts/game_server.gd b/server/scripts/game_server.gd index 12dd4cf..82e220b 100644 --- a/server/scripts/game_server.gd +++ b/server/scripts/game_server.gd @@ -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) diff --git a/server/scripts/objectives/bomb_objective.gd b/server/scripts/objectives/bomb_objective.gd index 44d956e..526868c 100644 --- a/server/scripts/objectives/bomb_objective.gd +++ b/server/scripts/objectives/bomb_objective.gd @@ -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. diff --git a/server/scripts/plugin_api/plugin_base.gd b/server/scripts/plugin_api/plugin_base.gd index fbbdd03..10fbdf2 100644 --- a/server/scripts/plugin_api/plugin_base.gd +++ b/server/scripts/plugin_api/plugin_base.gd @@ -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 diff --git a/server/scripts/plugin_api/plugin_manager.gd b/server/scripts/plugin_api/plugin_manager.gd index 5d581af..f65f27b 100644 --- a/server/scripts/plugin_api/plugin_manager.gd +++ b/server/scripts/plugin_api/plugin_manager.gd @@ -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 = [] diff --git a/server/scripts/plugin_api/plugin_manifest.gd b/server/scripts/plugin_api/plugin_manifest.gd index ae691ac..18c72d5 100644 --- a/server/scripts/plugin_api/plugin_manifest.gd +++ b/server/scripts/plugin_api/plugin_manifest.gd @@ -15,7 +15,7 @@ ## enabled = true extends Resource -class_name PluginManifest +# class_name PluginManifest — commented out for headless compat # --------------------------------------------------------------------------- # Exported fields diff --git a/server/scripts/round/round_manager.gd b/server/scripts/round/round_manager.gd index 82d955a..c6c2840 100644 --- a/server/scripts/round/round_manager.gd +++ b/server/scripts/round/round_manager.gd @@ -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] diff --git a/server/scripts/weapons/weapon_server.gd b/server/scripts/weapons/weapon_server.gd index e8300a6..5309bb8 100644 --- a/server/scripts/weapons/weapon_server.gd +++ b/server/scripts/weapons/weapon_server.gd @@ -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) diff --git a/server/scripts/zones/buy_zone.gd b/server/scripts/zones/buy_zone.gd index 04435c7..6a44852 100644 --- a/server/scripts/zones/buy_zone.gd +++ b/server/scripts/zones/buy_zone.gd @@ -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