diff --git a/qlpycon.py b/qlpycon.py index 6315cf5..8cb9a89 100644 --- a/qlpycon.py +++ b/qlpycon.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# Version: 1.14.0 import sys import re @@ -76,6 +77,15 @@ def _readSocketEvent( msg ): event_value = struct.unpack( ' 0 else 0 + accuracies[weapon] = accuracy + return accuracies + def _checkMonitor( monitor ): try: event_monitor = monitor.recv( zmq.NOBLOCK ) @@ -162,8 +172,59 @@ def PrintMessageFormatted(window, message, add_timestamp=True): if message[:7] == "print \"": message = message[7:-2] + "\n" - # Add timestamp if requested - if add_timestamp and not message.startswith('***'): + # Don't add timestamp to server responses, especially status command + # But DO add timestamps to "zmq RCON command" lines (server acknowledgments) + skip_timestamp_keywords = [ + 'map:', 'num score', '---', 'bot' + ] + + # Special handling: don't skip "zmq RCON" lines - they should get timestamps + # But skip everything else from status output + is_status_keyword = any(keyword in message for keyword in skip_timestamp_keywords) + + # Also don't timestamp "status" when it appears in isolation (status table context) + # But DO timestamp it when it's part of "zmq RCON command...status" (the echo line) + if 'status' in message and 'zmq RCON' not in message: + is_status_keyword = True + + # Skip very short messages or messages with leading spaces (status fragments) + is_likely_fragment = ( + len(message.strip()) <= 2 or # Very short messages + (message.startswith(' ') and len(message.strip()) < 50) or # Messages with leading space + message.strip().isdigit() # Pure numbers (scores, pings, ports, etc) + ) + + # Skip single words that are less than 20 chars (likely player names or status fragments) + is_short_word = len(message.strip()) < 20 and ' ' not in message.strip() + + # Skip IP addresses (pattern: xxx.xxx.xxx.xxx or xxx.xxx.xxx.xxx:port) + is_ip_address = False + stripped = message.strip() + allowed_chars = set('0123456789.:') + if stripped.count('.') == 3 and all(c in allowed_chars for c in stripped): + is_ip_address = True + + should_skip_timestamp = ( + message.startswith('***') or + is_likely_fragment or + is_short_word or + is_ip_address or + is_status_keyword + ) + + # ADD THIS DEBUG BLOCK HERE: + if 'K/D:' in message or 'WINS' in message or 'TEAM' in message: + logger.debug(f'=== TIMESTAMP DEBUG ===') + logger.debug(f'Message: {message[:100]}') + logger.debug(f'is_status_keyword: {is_status_keyword}') + logger.debug(f'is_likely_fragment: {is_likely_fragment}') + logger.debug(f'is_short_word: {is_short_word}') + logger.debug(f'is_ip_address: {is_ip_address}') + logger.debug(f'should_skip_timestamp: {should_skip_timestamp}') + logger.debug(f'add_timestamp: {add_timestamp}') + # Add timestamp if requested and not a special message type + + if add_timestamp and not should_skip_timestamp: timestamp = time.strftime('%H:%M:%S') message = f"^3[^7{timestamp}^3]^7 {message}" @@ -268,12 +329,12 @@ def ParseGameEvent(message): if 'KILLER' not in data or not data['KILLER']: mod = data.get('MOD', 'UNKNOWN') death_msgs = { - 'FALLING': "%s%s ^1CRATERED^7", - 'HURT': "%s%s ^1WAS IN THE WRONG PLACE^7", - 'LAVA': "%s%s ^1DOES A BACKFLIP INTO THE LAVA^7", - 'WATER': "%s%s ^1SANK LIKE A ROCK^7", - 'SLIME': "%s%s ^1MELTED^7", - 'CRUSH': "%s%s ^1WAS CRUSHED^7" + 'FALLING': "%s%s ^7cratered.", + 'HURT': "%s%s ^7was in the wrong place.", + 'LAVA': "%s%s ^7does a backflip into the lava.", + 'WATER': "%s%s ^7sank like a rock.", + 'SLIME': "%s%s ^7melted.", + 'CRUSH': "%s%s ^7was crushed." } msg_template = death_msgs.get(mod, "%s%s ^1DIED FROM %s^7") if mod in death_msgs: @@ -305,7 +366,7 @@ def ParseGameEvent(message): } weapon_name = weapon_names.get(weapon, 'the %s' % weapon) - return "%s%s ^5killed %s%s ^7with %s%s\n" % ( + return "%s%s ^8fragged^7 %s%s ^7with %s%s\n" % ( killer_team_prefix, killer_name, victim_team_prefix, victim_name, weapon_name, warmup_suffix @@ -321,18 +382,60 @@ def ParseGameEvent(message): 'GRENADE': 'Grenade Launcher' } weapon_name = weapon_names.get(weapon, weapon) - return "%s%s ^7committed suicide with the ^1%s%s\n" % ( + return "%s%s ^7committed suicide with the ^7%s%s\n" % ( killer_team_prefix, killer_name, weapon_name, warmup_suffix ) - + elif event_type == 'PLAYER_MEDAL': name = data.get('NAME', 'Unknown') medal = data.get('MEDAL', 'UNKNOWN') warmup = data.get('WARMUP', False) - warmup_suffix = " ^7(^3warmup^7)" if warmup else "" + warmup_suffix = " ^3(warmup)^7" if warmup else "" team_prefix = get_team_color(name) - return "%s%s ^7got a ^6%s ^7medal%s\n" % (team_prefix, name, medal, warmup_suffix) - + return "%s%s ^7got a medal: ^6%s%s\n" % (team_prefix, name, medal, warmup_suffix) + + elif event_type == 'MATCH_REPORT': + if current_gametype in team_modes: + redscore = int(data.get('TSCORE0', '0')) + bluscore = int(data.get('TSCORE1', '0')) + if redscore > bluscore: + return "^1RED TEAM ^7WINS by a score of %d to %d\n" % (redscore, bluscore) + elif bluscore > redscore: + return "^4BLUE TEAM ^7WINS by a score of %d to %d\n" % (bluscore, redscore) + else: + return "^7The match is a TIE with a score of %d to %d\n" % (redscore, bluscore) + else: + return None + + elif event_type == 'PLAYER_STATS': + name = data.get('NAME', 'Unknown') + team_prefix = get_team_color(name) + kills = int(data.get('KILLS', '0')) + deaths = int(data.get('DEATHS', '0')) + weapon_data = data.get('WEAPONS', {}) + accuracies = calculate_weapon_accuracies(weapon_data) + if accuracies: + best_weapon = max(accuracies, key=accuracies.get) + best_accuracy = accuracies[best_weapon] * 100 + weapon_stats = weapon_data.get(best_weapon, {}) + best_weapon_kills = int(weapon_stats.get('K', 0)) + best_weapon_stats = f"{best_weapon}: {best_accuracy:.2f}% (Kills: {best_weapon_kills})" + else: + best_weapon_stats = "No weapon stats available." + weapon_names = { + 'ROCKET': 'Rocket Launcher', + 'LIGHTNING': 'Lightning Gun', + 'RAILGUN': 'Railgun', + 'SHOTGUN': 'Shotgun', + 'GAUNTLET': 'Gauntlet', + 'GRENADE': 'Grenade Launcher', + 'PLASMA': 'Plasma Gun', + 'MACHINEGUN': 'Machine Gun' + } + weapon_name = weapon_names.get(best_weapon) + return "^7%s%s K/D: %d/%d | Best Weapon: %s - Acc: %.2f%% - Kills: %d\n" % (team_prefix, name, kills, deaths, weapon_name, best_accuracy, best_weapon_kills) + #return "^7%s%s K/D: %d/%d\n" % (team_prefix, name, kills, deaths) + else: # Unknown event type - log at debug level and to file logger.debug('Unknown event type: %s' % event_type) @@ -433,7 +536,7 @@ def main(screen): input_window, output_window = InitWindows(screen, args) - PrintMessageFormatted(output_window, "*** QL pyCon Version 1.3.0 starting ***\n") + PrintMessageFormatted(output_window, "*** QL pyCon Version 1.13.0 starting ***\n") PrintMessageFormatted(output_window, "zmq python bindings {}, libzmq version {}\n".format(repr(zmq.__version__), zmq.zmq_version())) stats_port = None @@ -477,6 +580,11 @@ def main(screen): while ( not q.empty() ): l = q.get() logger.info( 'Sending command: %s' % repr( l.strip() ) ) + + # Display the command being sent with timestamp (in cyan to differentiate) + timestamp = time.strftime('%H:%M:%S') + PrintMessageFormatted(output_window, f"^5[^7{timestamp}^5] >>> {l.strip()}^7\n", add_timestamp=False) + socket.send( l.encode('utf-8') ) if stats_connected and stats_socket: @@ -519,6 +627,8 @@ def main(screen): logger.debug( 'Read %d message(s) from socket.' % msg_count ) break except Exception as e: + timestamp = time.strftime('%H:%M:%S') + PrintMessageFormatted(output_window, f"^1[^7{timestamp}^1] Error: {e}^7\n", add_timestamp=False) logger.error( 'Error receiving message: %s' % e ) break else: @@ -580,6 +690,8 @@ def main(screen): stats_connected = True PrintMessageFormatted(output_window, "Stats stream connected - ready for game events\n") except Exception as e: + timestamp = time.strftime('%H:%M:%S') + PrintMessageFormatted(output_window, f"^1[^7{timestamp}^1] Error: Stats connection failed: {e}^7\n", add_timestamp=False) logger.error('Stats connection failed: %s' % e) import traceback logger.debug(traceback.format_exc()) @@ -616,32 +728,95 @@ def main(screen): # It's JSON but we didn't parse it - already logged to file logger.debug('Unparsed JSON event') else: - # Not JSON - check if it's a chat message + # Not JSON - check if it's a bot debug message (filter unless verbose) + is_bot_debug = ' entered ' in msg_str and (' seek ' in msg_str or ' battle ' in msg_str or ' chase' in msg_str or ' fight' in msg_str) + + if is_bot_debug and args.verbose == 0: + # Skip bot debug messages in default mode + logger.debug('Filtered bot debug message: %s' % msg_str[:50]) + continue + + # Check if it's a chat message if ':' in msg_str and not msg_str.startswith(('print', 'broadcast', 'zmq')): # Strip the special character first to get clean player name clean_msg = msg_str.replace(chr(25), '') # Check if it's team chat (PlayerName): or regular chat PlayerName: if clean_msg.strip().startswith('(') and ')' in clean_msg: - # Team chat: (PlayerName): message - match = re.match(r'^(\([^)]+\):)(\s*.*)', clean_msg) - if match: - player_part = match.group(1) # (PlayerName): - message_part = match.group(2) # message text + # Team chat: (PlayerName) (Location): message or (PlayerName): message + # Location can contain nested parens like (Lower Floor (Near Yellow Armour)) + # We need to match the first (PlayerName), then optionally a second parenthetical with potential nesting + + # First, extract player name (first parenthetical) + player_match = re.match(r'^(\([^)]+\))', clean_msg) + if player_match: + player_part = player_match.group(1) + rest_of_msg = clean_msg[len(player_part):].lstrip() - # Extract just the player name for team lookup - name_match = re.match(r'\(([^)]+)\)', player_part) - if name_match: - player_name = name_match.group(1).strip() - player_name_clean = re.sub(r'\^\d', '', player_name) + # Check if there's a location (starts with another opening paren) + if rest_of_msg.startswith('('): + # Find the matching closing paren for the location + # We need to count parens to handle nesting like (Floor (Near Armor)) + paren_count = 0 + location_end = -1 + for i, char in enumerate(rest_of_msg): + if char == '(': + paren_count += 1 + elif char == ')': + paren_count -= 1 + if paren_count == 0: + location_end = i + 1 + break - # Look up player by clean name and add team prefix - team_prefix = '' - if player_name_clean in player_teams: - team_prefix = get_team_color(player_name_clean) - - # Reconstruct with team prefix and colored message (^5 for team chat) - msg_str = f"{team_prefix}{player_part}^5{message_part}\n" + if location_end > 0 and location_end < len(rest_of_msg) and rest_of_msg[location_end] == ':': + # We found a valid location with closing paren followed by colon + location_part = rest_of_msg[:location_end] + message_part = rest_of_msg[location_end + 1:] # After the colon + + # Extract player name for team lookup + name_match = re.match(r'\(([^)]+)\)', player_part) + if name_match: + player_name = name_match.group(1).strip() + player_name_clean = re.sub(r'\^\d', '', player_name) + + team_prefix = '' + if player_name_clean in player_teams: + team_prefix = get_team_color(player_name_clean) + + # Strip color codes from location for consistent yellow coloring + location_clean = re.sub(r'\^\d', '', location_part) + msg_str = f"{team_prefix}{player_part} ^3{location_clean}^7:^5{message_part}" + else: + # No valid location, treat as regular team chat + if rest_of_msg.startswith(':'): + message_part = rest_of_msg[1:] + name_match = re.match(r'\(([^)]+)\)', player_part) + if name_match: + player_name = name_match.group(1).strip() + player_name_clean = re.sub(r'\^\d', '', player_name) + + team_prefix = '' + if player_name_clean in player_teams: + team_prefix = get_team_color(player_name_clean) + + msg_str = f"{team_prefix}{player_part}^5:{message_part}" + else: + # No location, check if it's just (PlayerName): message + colon_match = re.match(r'^(\([^)]+\)):(\s*.*)', clean_msg) + if colon_match: + player_part = colon_match.group(1) + ':' + message_part = colon_match.group(2) + + name_match = re.match(r'\(([^)]+)\)', player_part) + if name_match: + player_name = name_match.group(1).strip() + player_name_clean = re.sub(r'\^\d', '', player_name) + + team_prefix = '' + if player_name_clean in player_teams: + team_prefix = get_team_color(player_name_clean) + + msg_str = f"{team_prefix}{player_part}^5{message_part}\n" else: # Regular chat: PlayerName: message parts = clean_msg.split(':', 1) @@ -674,6 +849,8 @@ def main(screen): except KeyboardInterrupt: PrintMessageFormatted(output_window, "\nShutting down...\n") except Exception as e: + timestamp = time.strftime('%H:%M:%S') + PrintMessageFormatted(output_window, f"^1[^7{timestamp}^1] Fatal Error: {e}^7\n", add_timestamp=False) logger.error( 'Fatal error: %s' % e ) import traceback logger.error( traceback.format_exc() ) @@ -686,4 +863,4 @@ def main(screen): if ( __name__ == '__main__' ): curses.wrapper(main) -# Version: 1.8.0 - Added color coding to chat messages (^2 for regular, ^5 for team chat) +# Version: 1.13.0