diff --git a/qlpycon.py b/qlpycon.py index e0e707e..e87f92f 100644 --- a/qlpycon.py +++ b/qlpycon.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Version: 0.6.9 +# Version: 0.7.0 import sys import re @@ -33,13 +33,35 @@ import unittest import locale locale.setlocale(locale.LC_ALL, '') +# Server info state +server_info = { + 'hostname': 'Unknown', + 'map': 'Unknown', + 'gametype': 'Unknown', + 'timelimit': '0', + 'fraglimit': '0', + 'roundlimit': '0', + 'capturelimit': '0', + 'players': [], + 'red_score': 0, + 'blue_score': 0, + 'last_update': 0 +} + +# Message buffering for status output +status_buffer = [] +in_status_output = False + +# Pending background commands +pending_background_commands = set() + current_gametype = None # Track recent events to avoid duplicates recent_events = [] MAX_RECENT_EVENTS = 10 -team_modes = ["TDM", "CA", "CTF", "ONEFLAG", "OVERLOAD", "HARVESTER", "FT" ] +team_modes = ["Team Deathmatch", "Clan Arena", "Capture The Flag", "One Flag CTF", "Overload", "Harvester", "Freeze Tag" ] player_teams = {} @@ -311,6 +333,153 @@ def format_powerup_message(msg_str): # Not a powerup message return None +def parse_server_response(msg_str, info_window): + """Parse server responses to update server_info, returns True if message should be suppressed""" + global server_info, pending_background_commands, status_buffer, in_status_output, current_gametype + + # Suppress only the command echo lines + if msg_str.startswith('zmq RCON command') and 'from' in msg_str: + if any(f': {cmd}' in msg_str for cmd in ['status', 'roundlimit', 'qlx_serverBrandName', 'g_factoryTitle', 'mapname', 'timelimit', 'fraglimit', 'roundlimit', 'capturelimit', 'sv_maxclients']): + return True + + # Parse qlx_serverBrandName + if '"qlx_serverbrandname"' in msg_str and ' is:' in msg_str: + match = re.search(r' is:"(.+?)" default:', msg_str) + if match: + brandname = match.group(1).strip() + server_info['hostname'] = brandname + logger.info(f'Got hostname: {server_info["hostname"]}') + UpdateServerInfoWindow(info_window) + + # Parse g_factoryTitle + if '"g_factoryTitle"' in msg_str and ' is:' in msg_str: + match = re.search(r' is:"([^"]*)"', msg_str) # Changed [^"]+ to [^"]* + if match: + gametype = match.group(1).strip() + current_gametype = re.sub(r'\^\d', '', gametype) # Strip color codes + server_info['gametype'] = current_gametype + logger.info(f'Got gametype: {current_gametype}') + UpdateServerInfoWindow(info_window) + + # Parse Map + if '"mapname"' in msg_str and ' is:' in msg_str: + match = re.search(r' is:"([^"]*)"', msg_str) + if match: + mapname = match.group(1).strip() + server_info['map'] = re.sub(r'\^\d', '', mapname) + logger.info(f'Got mapname: {server_info["map"]}') + UpdateServerInfoWindow(info_window) + + # Parse timelimit + if '"timelimit"' in msg_str and ' is:' in msg_str: + match = re.search(r' is:"([^"]*)"', msg_str) + if match: + server_info['timelimit'] = match.group(1).strip() + logger.info(f'Got Timelimit: {server_info["timelimit"]}') + UpdateServerInfoWindow(info_window) + + # Parse fraglimit + if '"fraglimit"' in msg_str and ' is:' in msg_str: + match = re.search(r' is:"([^"]*)"', msg_str) + if match: + server_info['fraglimit'] = match.group(1).strip() + logger.info(f'Got Fraglimit: {server_info["fraglimit"]}') + UpdateServerInfoWindow(info_window) + + # Parse roundlimit + if '"roundlimit"' in msg_str and ' is:' in msg_str: + match = re.search(r' is:"([^"]*)"', msg_str) + if match: + server_info['roundlimit'] = match.group(1).strip() + logger.info(f'Got Roundlimit: {server_info["roundlimit"]}') + UpdateServerInfoWindow(info_window) + + # Parse caplimit + if '"capturelimit"' in msg_str and ' is:' in msg_str: + match = re.search(r' is:"([^"]*)"', msg_str) + if match: + server_info['capturelimit'] = match.group(1).strip() + logger.info(f'Got Caplimit: {server_info["capturelimit"]}') + UpdateServerInfoWindow(info_window) + + # Parse Max Clients + if '"sv_maxclients"' in msg_str and ' is:' in msg_str: + match = re.search(r' is:"([^"]*)"', msg_str) + if match: + server_info['maxclients'] = match.group(1).strip() + logger.info(f'Got Max Clients: {server_info["maxclients"]}') + UpdateServerInfoWindow(info_window) + + # Handle status command output buffering + if 'status' in pending_background_commands: + # Buffer ALL short messages (likely parts of status output) + if len(msg_str) < 100: # Status fields are short + status_buffer.append(msg_str) + in_status_output = True + return True # Suppress + + # When we get an empty message or long message, process the buffer + if msg_str.strip() == '' or len(msg_str) > 100: + if status_buffer: + # Reconstruct the line + full_line = ' '.join(status_buffer) + logger.info(f'Reconstructed status line: {full_line[:100]}') + + # Parse if it's a player line + parts = full_line.split() + if len(parts) >= 8 and parts[0].isdigit(): + server_info['players'].append({ + 'name': parts[3], + 'score': parts[1], + 'ping': parts[2] + }) + + status_buffer = [] + + if msg_str.strip() == '': + pending_background_commands.discard('status') + in_status_output = False + + return True + + return False + +def UpdateServerInfoWindow(info_window): + """Update the server info window with current data""" + info_window.clear() + + max_y, max_x = info_window.getmaxyx() + + # Line 1: Hostname with decorative border + hostname_display = server_info.get('hostname', 'Unknown Server') + PrintMessageColored(info_window, f"^6═══ {hostname_display} ^6═══^7\n", 0) + + # Line 2: Type / Map / Limits / Players + gametype_display = server_info.get('gametype', 'Unknown Type') + mapname_display = server_info.get('map', 'Unknown Map') + timelimit_display = server_info.get('timelimit', '0') + fraglimit_display = server_info.get('fraglimit', '0') + roundlimit_display = server_info.get('roundlimit', '0') + caplimit_display = server_info.get('capturelimit', '0') + curclients_display = server_info.get('curclients', '0') + maxclients_display = server_info.get('maxclients', '0') + PrintMessageColored(info_window, f"^3Type:^7 {gametype_display} ^3Map:^7 {mapname_display} ^3Players:^7 {curclients_display}/{maxclients_display} ^3Limits (T/F/R/C):^7 {timelimit_display}/{fraglimit_display}/{roundlimit_display}/{caplimit_display}\n", 0) + + # Line 4: Teams, if any + if current_gametype in team_modes: + PrintMessageColored(info_window, f"^1(RED) ^4(BLUE)\n", 0) + else: + PrintMessageColored(info_window, f"^3(FREE)\n", 0) + + # Lines 5-10: Reserved for future use (empty for now) + info_window.addstr("\n\n\n\n\n\n") + + # Line 11: Separator + separator = "^6" + "═" * (max_x - 1) + "^7" + PrintMessageColored(info_window, separator, 0) + + info_window.refresh() + def ParseGameEvent(message): """Parse JSON game events and return formatted message""" global current_gametype, recent_events @@ -395,6 +564,14 @@ def ParseGameEvent(message): # Update victim team from event data if 'TEAM' in victim: update_player_team(victim_name, victim['TEAM']) + # ADD THIS: Update server_info players list + global server_info + if not any(p['name'] == victim_name for p in server_info['players']): + server_info['players'].append({ + 'name': victim_name, + 'score': '0', + 'ping': '0' + }) victim_team_prefix = get_team_color(victim_name) @@ -421,6 +598,12 @@ def ParseGameEvent(message): # Update killer team from event data if 'TEAM' in killer: update_player_team(killer_name, killer['TEAM']) + if not any(p['name'] == killer_name for p in server_info['players']): + server_info['players'].append({ + 'name': killer_name, + 'score': '0', + 'ping': '0' + }) killer_team_prefix = get_team_color(killer_name) @@ -557,7 +740,7 @@ def InitWindows(screen, args): curses.use_default_colors() curses.cbreak() curses.setsyx(-1, -1) - screen.addstr("Quake Live rcon: %s" % args.host) + screen.addstr("Quake Live PyCon: %s" % args.host) screen.refresh() maxy, maxx = screen.getmaxyx() @@ -567,8 +750,19 @@ def InitWindows(screen, args): curses.init_pair(5, 6, 0) curses.init_pair(6, 5, 0) + # Server info window at top begin_x = 2; width = maxx - 4 - begin_y = 2; height = maxy - 5 + begin_y = 2; height = 10 + info_window = curses.newwin(height, width, begin_y, begin_x) + screen.refresh() + info_window.scrollok(False) + info_window.idlok(False) + info_window.leaveok(True) + info_window.refresh() + + # Output window (main chat/events) + begin_x = 2; width = maxx - 4 + begin_y = 12; height = maxy - 15 output_window = curses.newwin(height, width, begin_y, begin_x) screen.refresh() output_window.scrollok(True) @@ -576,15 +770,17 @@ def InitWindows(screen, args): output_window.leaveok(True) output_window.refresh() + # Input window begin_x = 4; width = maxx - 6 begin_y = maxy - 2; height = 1 input_window = curses.newwin(height, width, begin_y, begin_x) - screen.addstr(begin_y, begin_x - 2, '>') + screen.addstr(begin_y, begin_x - 2, '$ ') screen.refresh() input_window.idlok(True) input_window.leaveok(False) input_window.refresh() + # Divider window begin_x = 2; width = maxx - 4 begin_y = maxy - 3; height = 1 divider_window = curses.newwin(height, width, begin_y, begin_x) @@ -599,7 +795,7 @@ def InitWindows(screen, args): screen.refresh() - return input_window, output_window + return input_window, output_window, info_window def main(screen): global current_gametype @@ -631,9 +827,11 @@ def main(screen): unknown_json_logger.addHandler(file_handler) unknown_json_logger.propagate = False # Don't send to parent logger - input_window, output_window = InitWindows(screen, args) + input_window, output_window, info_window = InitWindows(screen, args) - PrintMessageFormatted(output_window, "*** QL pyCon Version 0.6.9 starting ***\n") + UpdateServerInfoWindow(info_window) + + PrintMessageFormatted(output_window, "*** QL pyCon Version 0.7.0 starting ***\n") PrintMessageFormatted(output_window, "zmq python bindings {}, libzmq version {}\n".format(repr(zmq.__version__), zmq.zmq_version())) stats_port = None @@ -668,12 +866,10 @@ def main(screen): socket.send( b'register' ) logger.info( 'Registration message sent.' ) - PrintMessageFormatted(output_window, "Requesting game info...\n") + PrintMessageFormatted(output_window, "Requesting connection info...\n") socket.send( b'zmq_stats_password' ) socket.send( b'net_port' ) - logger.info('Requesting Gametype...') - socket.send(b'cvarlist g_gametype') - + while ( not q.empty() ): l = q.get() logger.info( 'Sending command: %s' % repr( l.strip() ) ) @@ -734,6 +930,10 @@ def main(screen): y,x = curses.getsyx() msg_str = msg.decode('utf-8', errors='replace') + is_background_response = parse_server_response(msg_str, info_window) + if is_background_response: + logger.debug('Suppressing background command response') + continue # Skip printing this message if 'net_port' in msg_str and ' is:' in msg_str and '"net_port"' in msg_str: match = re.search(r' is:"([^"]+)"', msg_str) if match: @@ -786,6 +986,17 @@ def main(screen): stats_connected = True PrintMessageFormatted(output_window, "Stats stream connected - ready for game events\n") + + # Send initial server info queries (once only) + logger.info('Sending initial server info queries') + socket.send(b'qlx_serverBrandName') + socket.send(b'g_factoryTitle') + socket.send(b'mapname') + socket.send(b'timelimit') + socket.send(b'fraglimit') + socket.send(b'roundlimit') + socket.send(b'capturelimit') + socket.send(b'sv_maxclients') # Set up file handler for all JSON events if --json flag is provided if args.json_log: @@ -801,26 +1012,6 @@ def main(screen): logger.error('Stats connection failed: %s' % e) import traceback logger.debug(traceback.format_exc()) - - if 'g_gametype' in msg_str: - match = re.search(r'\bg_gametype\s+"(\d+)"', msg_str) - if match: - gametypes = { - "0": "FFA", - "1": "DUEL", - "2": "RACE", - "3": "TDM", - "4": "CA", - "5": "CTF", - "6": "ONEFLAG", - "7": "OVERLOAD", - "8": "HARVESTER", - "9": "FT" - } - gametypes_num = match.group(1).strip() - current_gametype = gametypes.get(gametypes_num, "UNKNOWN") - logger.info('Got gametype: %s' % current_gametype) - PrintMessageFormatted(output_window, f'Detected gametype: {current_gametype}\n') # Try to parse as JSON game event parsed_event = ParseGameEvent(msg_str) @@ -976,4 +1167,4 @@ def main(screen): if ( __name__ == '__main__' ): curses.wrapper(main) -# Version: 0.6.9 +# Version: 0.7.0