## RoundManager — Match lifecycle singleton. ## ## Orchestrates warmup → live rounds → round end → next round. ## Owned by GameServer; wired to TeamManager + DamageProcessor. ## ## Architecture: ## GameServer ## ├── RoundManager (child) — round lifecycle, timers, phase tracking ## ├── SimulationServer (C++) — 128Hz tick loop ## ├── WeaponServer / LagCompensation / DamageProcessor ## └── (TeamManager / SpawnManager live outside but are referenced) ## ## Usage (inside GameServer._ready()): ## var rm = RoundManager.new() ## add_child(rm) ## rm.team_manager = team_manager_ref ## rm.damage_processor = damage_processor_ref ## ## Signals connect round events to economy, HUD, scoreboard, etc. extends Node const _td_rm = preload("res://scripts/teams/team_data.gd") # --------------------------------------------------------------------------- # Round Phase enum # --------------------------------------------------------------------------- enum RoundPhase { WARMUP = 0, # Pre-match free-move, no scoring, no economy PREP = 1, # Freezetime / buy phase LIVE = 2, # Round active POST = 3, # Post-round scoreboard + transition } # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- ## Emitted whenever the round phase transitions. ## phase: the new RoundPhase value (int for cross-script compatibility). ## round_num: the current round number. signal round_phase_changed(phase: int, round_num: int) ## Emitted when a PREP round begins. signal round_started(round_num: int) ## Emitted when a LIVE round ends. ## winning_team: the Team enum of the winning team. ## reason: string describing the reason (e.g. "elimination", "time_expired"). signal round_ended(winning_team: int, reason: String) ## Emitted when one team reaches match-win threshold. ## team: the Team enum of the team at match point. signal match_point(team: int) ## Emitted when warmup ends and the server is ready for competitive play. signal warmup_ended() ## Emitted to relay round end economy data to the economy system. ## winning_team: which team won the round. ## team_money: Dictionary mapping team enum → {win: int, loss: int} signal round_economy_data(winning_team: int, team_money: Dictionary) # --------------------------------------------------------------------------- # Constants — defaults # --------------------------------------------------------------------------- ## Default PREP (freeze-time + buy) duration in seconds. const DEFAULT_PREP_DURATION: float = 15.0 ## Default LIVE round duration in seconds. const DEFAULT_LIVE_DURATION: float = 105.0 ## Default POST (scoreboard) duration in seconds. const DEFAULT_POST_DURATION: float = 8.0 ## Default win money awarded to each player on the winning team. const DEFAULT_WIN_MONEY: int = 3250 ## Default base loss money. const DEFAULT_LOSS_MONEY: int = 1900 ## Default max loss money after consecutive losses. const DEFAULT_LOSS_MONEY_MAX: int = 2900 ## Default kill bonus awarded to the killer at round end. const DEFAULT_KILL_BONUS: int = 300 ## Default number of round wins required to win the match. const DEFAULT_WIN_THRESHOLD: int = 16 # --------------------------------------------------------------------------- # Dependencies (set by GameServer before use) # --------------------------------------------------------------------------- ## Reference to the TeamManager node/singleton. var team_manager = null ## Reference to the DamageProcessor node/singleton. var damage_processor = null # DamageProcessor — untyped for headless compat # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- ## Duration of the PREP (freeze-time + buy) phase in seconds. var prep_duration: float = DEFAULT_PREP_DURATION ## Duration of the LIVE round phase in seconds. var live_duration: float = DEFAULT_LIVE_DURATION ## Duration of the POST round (scoreboard) phase in seconds. var post_duration: float = DEFAULT_POST_DURATION ## Money awarded to each winning player at round end. var win_money: int = DEFAULT_WIN_MONEY ## Base money awarded to each losing player at round end. var loss_money_base: int = DEFAULT_LOSS_MONEY ## Maximum loss money after consecutive losses. var loss_money_max: int = DEFAULT_LOSS_MONEY_MAX ## Kill bonus per kill at round end. var kill_bonus: int = DEFAULT_KILL_BONUS ## Rounds needed to win the match (first to this many). var win_threshold: int = DEFAULT_WIN_THRESHOLD # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- ## Current round phase. var _phase: RoundPhase = RoundPhase.WARMUP ## Current round number (1-indexed, increments each round). var _round_num: int = 0 ## Timer tracking remaining seconds in the current phase. var _phase_timer: float = 0.0 ## Whether the match has started (exited warmup). var _match_started: bool = false ## Consecutive losses per team (team enum → int). ## Used for loss-money escalation. var _consecutive_losses: Dictionary = {} # Team enum → int ## Track kills per shooter per round (for round-end kill bonus). ## shooter_id (int) → kill_count (int) for the current round. var _round_kills: Dictionary = {} ## Track deaths per entity per round (for stats). var _round_deaths: Dictionary = {} # entity_id (int) → death_count (int) # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: start_warmup() func _process(delta: float) -> void: _advance_timer(delta) # --------------------------------------------------------------------------- # Initialization # --------------------------------------------------------------------------- ## Start the server in WARMUP mode — players can move/shoot, ## no scoring, no economy. Called automatically on _ready(). func start_warmup() -> void: _phase = RoundPhase.WARMUP _phase_timer = INF # Runs forever until admin starts match or enough players join _round_num = 0 _match_started = false _round_kills.clear() _round_deaths.clear() _consecutive_losses = { _td_rm.Team.COUNTER_TERRORIST: 0, _td_rm.Team.TERRORIST: 0, } print("[RoundManager] Warmup started — infinite duration") round_phase_changed.emit(RoundPhase.WARMUP, 0) # --------------------------------------------------------------------------- # Match / Round Control # --------------------------------------------------------------------------- ## Begin a competitive match. Transitions from WARMUP to PREP for round 1. ## Called automatically when enough players join, or via RCON `start_match`. func start_match() -> void: if _phase != RoundPhase.WARMUP: push_warning("[RoundManager] start_match called but not in WARMUP (phase=%d)" % _phase) return print("[RoundManager] Match starting!") warmup_ended.emit() start_round(1) ## Start a new round at the given round number. ## Transitions to PREP phase (freezetime + buy phase). func start_round(round_num: int) -> void: if not _match_started: _match_started = true _round_num = round_num _phase = RoundPhase.PREP _phase_timer = prep_duration # Reset round-specific tracking _round_kills.clear() _round_deaths.clear() print("[RoundManager] Round %d started — PREP phase (% .1fs freezetime)" % [_round_num, _phase_timer]) round_phase_changed.emit(RoundPhase.PREP, _round_num) round_started.emit(_round_num) ## Transition from PREP to LIVE. ## Called automatically when the PREP timer expires. func start_live() -> void: if _phase != RoundPhase.PREP: push_warning("[RoundManager] start_live called but not in PREP (phase=%d)" % _phase) return _phase = RoundPhase.LIVE _phase_timer = live_duration print("[RoundManager] Round %d is LIVE — % .1fs remaining" % [_round_num, _phase_timer]) round_phase_changed.emit(RoundPhase.LIVE, _round_num) ## End the current round, awarding money and transitioning to POST. ## 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: push_warning("[RoundManager] end_round called but not in LIVE (phase=%d)" % _phase) return _phase = RoundPhase.POST _phase_timer = post_duration # --- Scoring --- if team_manager: team_manager.add_team_score(winning_team, 1) # --- Track consecutive losses --- for team in [_td_rm.Team.COUNTER_TERRORIST, _td_rm.Team.TERRORIST]: if team == winning_team: _consecutive_losses[team] = 0 else: _consecutive_losses[team] = _consecutive_losses.get(team, 0) + 1 # --- Compute economy data --- var team_money_data: Dictionary = {} 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: money = win_money else: var loss_streak: int = _consecutive_losses.get(team, 1) money = min(loss_money_base + (loss_streak - 1) * 500, loss_money_max) team_money_data[team] = { "win": win_money if is_winner else 0, "loss": money if not is_winner else 0, "kill_bonus": kill_bonus, "total": money, } 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: var winner_score: int = team_manager.get_team_score(winning_team) if winner_score >= win_threshold - 1: # One more round wins the match match_point.emit(winning_team) if winner_score >= win_threshold: 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) round_economy_data.emit(winning_team, team_money_data) round_phase_changed.emit(RoundPhase.POST, _round_num) # --------------------------------------------------------------------------- # Query # --------------------------------------------------------------------------- ## Get the current round phase as a RoundPhase enum value. func get_phase() -> RoundPhase: return _phase ## Get the remaining time in seconds for the current phase. ## Returns INF for WARMUP, 0.0 if no active timer. func get_round_time_remaining() -> float: if _phase == RoundPhase.WARMUP: return INF return max(0.0, _phase_timer) ## Get the current round number (1-indexed). func get_round_num() -> int: return _round_num ## Check if the match has started (exited warmup). func is_match_started() -> bool: return _match_started ## Get kill counts for the current round. func get_round_kills() -> Dictionary: return _round_kills.duplicate() ## Get death counts for the current round. func get_round_deaths() -> Dictionary: return _round_deaths.duplicate() ## Get consecutive losses for a team. func get_consecutive_losses(team: int) -> int: return _consecutive_losses.get(team, 0) # --------------------------------------------------------------------------- # Internal — Timer / phase advancement # --------------------------------------------------------------------------- func _advance_timer(delta: float) -> void: match _phase: RoundPhase.WARMUP: # WARMUP runs indefinitely — no timer advancement pass RoundPhase.PREP: _phase_timer -= delta if _phase_timer <= 0.0: start_live() RoundPhase.LIVE: _phase_timer -= delta if _phase_timer <= 0.0: # Time ran out — CT wins by default (bomb not planted / hostages not rescued) # In CS-style, CT wins on time expire if bomb not planted. # For now, the team with most alive players wins, else CT. var time_expired_winner: int = _determine_time_expired_winner() end_round(time_expired_winner, "time_expired") RoundPhase.POST: _phase_timer -= delta if _phase_timer <= 0.0: # Auto-start next round var next_round: int = _round_num + 1 start_round(next_round) # --------------------------------------------------------------------------- # Internal — Kill tracking # --------------------------------------------------------------------------- ## Called by GameServer when a kill occurs (connected to DamageProcessor.player_killed). func on_player_killed(victim_id: int, shooter_id: int) -> void: if _phase != RoundPhase.LIVE: return # Track kills for round-end bonus if shooter_id >= 0: _round_kills[shooter_id] = _round_kills.get(shooter_id, 0) + 1 # Track deaths _round_deaths[victim_id] = _round_deaths.get(victim_id, 0) + 1 # Check for team elimination if team_manager and _is_team_eliminated_by_kill(shooter_id, victim_id): var eliminated_team: int = _get_entity_team(victim_id) if eliminated_team >= 0: var winning_team: int = _get_entity_team(shooter_id) if winning_team >= 0: end_round(winning_team, "elimination") # --------------------------------------------------------------------------- # Internal — Team elimination detection # --------------------------------------------------------------------------- ## Check if a kill has eliminated the victim's entire team. func _is_team_eliminated_by_kill(_shooter_id: int, victim_id: int) -> bool: if not team_manager or not damage_processor: return false var victim_team: int = _get_entity_team(victim_id) if victim_team < 0: return false # Get all players on the victim's team var team_entity_ids: Array[int] = _get_entity_ids_for_team(victim_team) # Check if all of them are dead (health <= 0 or not registered) for eid in team_entity_ids: var health: float = damage_processor.get_health(eid) if health > 0.0: return false # At least one teammate is alive return true ## Get all entity IDs belonging to a given team. func _get_entity_ids_for_team(team: int) -> Array[int]: if not team_manager: return [] var player_ids: Array[int] = team_manager.get_team_players(team) var result: Array[int] = [] # We need a mapping from player_id → entity_id. # This is stored in GameServer.entity_to_peer (inverted). # For now, we check DamageProcessor directly if entities are tracked. if damage_processor: # We don't have a direct player→entity mapping here, so we iterate # the damage processor's health dictionary and infer by entity_id # naming convention or stored mapping. # Since we don't have access to entity_to_peer here, we rely on # the caller to check elimination via GameServer. # For Phase 1, we return empty and elimination is checked externally. pass return result ## Determine the winner when time expires. ## 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 _td_rm.Team.COUNTER_TERRORIST var ct_alive: int = 0 var t_alive: int = 0 # Count alive players per team 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. # Fallback: just check if team has any players alive # This will be refined in future phases with proper entity mapping. pass # Simple default: CT wins on time expiry # (Conceptually, CT defends the site and time runs out = attackers failed) return _td_rm.Team.COUNTER_TERRORIST ## Resolve a player_id or entity_id to a team. ## For now, returns -1 if resolution fails. func _get_entity_team(_entity_or_player_id: int) -> int: # In Phase 1, this requires access to entity→peer→player→team mapping. # GameServer.entity_to_peer maps entity_id → peer_id. # We need peer_id → team from TeamManager. # For now, return -1 and rely on GameServer-level elimination checks. return -1 ## Helper to format winner score description. 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(_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]