diff --git a/lib/constants.py b/lib/constants.py index 0ae82ab..febbd9d 100644 --- a/lib/constants.py +++ b/lib/constants.py @@ -19,6 +19,7 @@ POLL_TIMEOUT = 100 QUIT_CONFIRM_TIMEOUT = 3.0 # Seconds to confirm quit (Ctrl-C twice) RESPAWN_DELAY = 3.0 # Seconds before players respawn after death STATUS_OUTPUT_WINDOW = 1.5 # Seconds a 'status' command echo suppresses its chunked table output +STATUS_SEED_WINDOW = 2.0 # Seconds the roster seeder collects the 'status' dump after connect STATS_CONNECTION_DELAY = 0.5 # Initial stats connection delay # UI dimensions diff --git a/lib/parser.py b/lib/parser.py index f80654f..1d14717 100644 --- a/lib/parser.py +++ b/lib/parser.py @@ -400,6 +400,8 @@ class EventParser: # in CA it is damage-based, so sync it instead of accumulating deltas if data.get('SCORE') is not None: self.game_state.player_tracker.set_score(name, data['SCORE']) + if 'TEAM' in data: + self.game_state.player_tracker.update_team(name, data['TEAM']) weapon_data = data.get('WEAPONS', {}) accuracies = calculate_weapon_accuracies(weapon_data) diff --git a/lib/state.py b/lib/state.py index 2f0b587..e83a05d 100644 --- a/lib/state.py +++ b/lib/state.py @@ -101,14 +101,25 @@ class PlayerTracker: return self.player_teams.get(name) def add_player(self, name, score='0', ping='0'): - """Add player to server's player dict if not exists""" - # Use original name with color codes as key - if name not in self.server_info.players: - self.server_info.players[name] = { - 'score': score, - 'ping': ping - } - logger.debug(f'Added player: {name}') + """Add player to server's player dict if not exists. A seeded clean-name + entry is upgraded to the color-coded name (preserving score/ping) instead + of duplicating the roster entry.""" + if name in self.server_info.players: + return + clean_name = strip_color_codes(name) + for player_name in list(self.server_info.players.keys()): + if player_name != name and strip_color_codes(player_name) == clean_name: + existing = self.server_info.players.pop(player_name) + existing.setdefault('score', score) + existing.setdefault('ping', ping) + self.server_info.players[name] = existing + logger.debug(f'Upgraded roster entry {player_name} -> {name}') + return + self.server_info.players[name] = { + 'score': score, + 'ping': ping + } + logger.debug(f'Added player: {name}') def get_players_by_team(self): """Get players organized by team""" diff --git a/main.py b/main.py index f02f423..db3e1e5 100644 --- a/main.py +++ b/main.py @@ -15,7 +15,7 @@ import signal import sys import os -from lib.constants import VERSION, DEFAULT_HOST, POLL_TIMEOUT, QUIT_CONFIRM_TIMEOUT, RESPAWN_DELAY, MAX_COMMAND_HISTORY, STATUS_OUTPUT_WINDOW +from lib.constants import VERSION, DEFAULT_HOST, POLL_TIMEOUT, QUIT_CONFIRM_TIMEOUT, RESPAWN_DELAY, MAX_COMMAND_HISTORY, STATUS_OUTPUT_WINDOW, STATUS_SEED_WINDOW from lib.state import GameState from lib.network import RconConnection, StatsConnection from lib.parser import EventParser @@ -38,6 +38,8 @@ STATUS_HEAD_PATTERN = re.compile(r'^\s*\d+\s+-?\d+\s+(bot|\d+)\s+\S', re.MULTILI STATUS_MAP_PATTERN = re.compile(r'^map:\s+\S+\s*$', re.MULTILINE) STEAMID_PATTERN = re.compile(r'\b\d{16,}\b') LIVESTATS_PATTERN = re.compile(r'^LIVESTATS\s+(.+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s*$') +STATUS_ROW_PATTERN = re.compile( + r'^\s*(\d+)\s+(-?\d+)\s+(\d+)\s+(.+?)\s+(\d+)\s+(\S+)\s+(\d+)\s+(\d+)(?:\s+(\d{15,}))?\s*$') ROSTER_CHAT_GUARD_PATTERN = re.compile(r'^.{1,40}:\s') VOTE_CAST_PATTERN = re.compile(r'^(.+?) voted for (.+)\.$') CHAT_SHAPE_PATTERN = re.compile(r'^[\w(][^:]*:\s') @@ -243,6 +245,70 @@ def is_real_content(message): return False +class StatusSeeder: + """ + Seeds the player roster from a 'status' command dump, sent on connect and + after map changes. The table arrives chunked across many messages (rows + split at arbitrary column boundaries, wrapped or raw), so raw text is + buffered and split on newlines; the tail is flushed when the window + closes. Rows carry no team column, so untracked names seed as SPECTATOR + (immediately visible; active players self-correct on their first stats + event) and known players only get their authoritative score/ping. + All seeding output stays suppressed. + """ + + def __init__(self, game_state): + self.game_state = game_state + self.buffer = '' + self.active_until = 0.0 + + def start(self): + self.buffer = '' + self.active_until = time.time() + STATUS_SEED_WINDOW + + def is_active(self): + if self.active_until and time.time() >= self.active_until: + self.finish() + return bool(self.active_until) and time.time() < self.active_until + + def feed(self, message): + """Buffer one RCON message and parse every completed line""" + text = strip_color_codes(message) + if text.startswith('broadcast:'): + text = text[10:].lstrip() + if text.startswith('print "'): + text = text[7:] + if text.endswith('"'): + text = text[:-1] + self.buffer += text.replace('\\n', '\n') + while '\n' in self.buffer: + line, self.buffer = self.buffer.split('\n', 1) + self._parse_line(line) + + def finish(self): + if self.buffer.strip(): + self._parse_line(self.buffer) + self.buffer = '' + + def _parse_line(self, line): + match = STATUS_ROW_PATTERN.match(line.strip()) + if not match: + return + num, score, ping, name, lastmsg, address, qport, rate, steamid = match.groups() + tracker = self.game_state.player_tracker + clean_name = strip_color_codes(name).strip() + if not clean_name: + return + player_data = self.game_state.server_info.players.get(clean_name) + if player_data is not None: + player_data['score'] = str(score) + player_data['ping'] = str(ping) + return + tracker.add_player(clean_name, score=str(score), ping=str(ping)) + if tracker.get_team(clean_name) is None: + tracker.update_team(clean_name, 'SPECTATOR') + + def handle_stats_connection(message): """ Handle stats connection info extraction @@ -482,6 +548,7 @@ def main_loop(screen, args): # Initialize components game_state = GameState() name_capture = NameCapture() # cvar/command names learned from the server for autocomplete + status_seeder = StatusSeeder(game_state) # seeds the roster from a status dump on connect ui = UIManager(screen, args.title, args.max_history, player_names_provider=lambda: game_state.player_tracker.get_player_names(), learned_names_provider=lambda: name_capture.names) @@ -557,6 +624,8 @@ def main_loop(screen, args): ui.print_message(f"^3[^7{timestamp}^3]^7 Requesting connection info...\n") rcon.send_command(b'zmq_stats_password') rcon.send_command(b'net_port') + rcon.send_command(b'status') + status_seeder.start() if args.learn_names: name_capture.request(rcon) elif monitor_event and monitor_event[0] == zmq.EVENT_DISCONNECTED: @@ -612,6 +681,13 @@ def main_loop(screen, args): if handle_vote_broadcast(message, game_state, ui): continue + # Seed the roster from the status dump after connect/map change; + # table chunks stay suppressed, real content flows normally + if status_seeder.is_active(): + if not is_real_content(message): + status_seeder.feed(message) + continue + # Command echoes only show at -v or higher verbosity; a # status echo opens a window that hides its chunked output if is_command_echo(message): @@ -652,6 +728,8 @@ def main_loop(screen, args): game_state.player_tracker.player_teams = {} game_state.server_info.reset_round_scores() game_state.server_info.dead_players.clear() + rcon.send_command(b'status') + status_seeder.start() ui.update_server_info(game_state) # Try to parse as cvar response