This commit is contained in:
xbl
2025-12-16 00:38:49 +01:00
parent 5567a5be8c
commit d60627e2d2

View File

@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# Version: 1.14.0
import sys import sys
import re import re
@ -76,6 +77,15 @@ def _readSocketEvent( msg ):
event_value = struct.unpack( '<I', msg[2:] )[0] event_value = struct.unpack( '<I', msg[2:] )[0]
return ( event_id, event_name, event_value ) return ( event_id, event_name, event_value )
def calculate_weapon_accuracies(weapon_data):
accuracies = {}
for weapon, stats in weapon_data.items():
shots_fired = int(stats.get('S', 0))
shots_hit = int(stats.get('H', 0))
accuracy = shots_hit / shots_fired if shots_fired > 0 else 0
accuracies[weapon] = accuracy
return accuracies
def _checkMonitor( monitor ): def _checkMonitor( monitor ):
try: try:
event_monitor = monitor.recv( zmq.NOBLOCK ) event_monitor = monitor.recv( zmq.NOBLOCK )
@ -162,8 +172,59 @@ def PrintMessageFormatted(window, message, add_timestamp=True):
if message[:7] == "print \"": if message[:7] == "print \"":
message = message[7:-2] + "\n" message = message[7:-2] + "\n"
# Add timestamp if requested # Don't add timestamp to server responses, especially status command
if add_timestamp and not message.startswith('***'): # 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') timestamp = time.strftime('%H:%M:%S')
message = f"^3[^7{timestamp}^3]^7 {message}" message = f"^3[^7{timestamp}^3]^7 {message}"
@ -268,12 +329,12 @@ def ParseGameEvent(message):
if 'KILLER' not in data or not data['KILLER']: if 'KILLER' not in data or not data['KILLER']:
mod = data.get('MOD', 'UNKNOWN') mod = data.get('MOD', 'UNKNOWN')
death_msgs = { death_msgs = {
'FALLING': "%s%s ^1CRATERED^7", 'FALLING': "%s%s ^7cratered.",
'HURT': "%s%s ^1WAS IN THE WRONG PLACE^7", 'HURT': "%s%s ^7was in the wrong place.",
'LAVA': "%s%s ^1DOES A BACKFLIP INTO THE LAVA^7", 'LAVA': "%s%s ^7does a backflip into the lava.",
'WATER': "%s%s ^1SANK LIKE A ROCK^7", 'WATER': "%s%s ^7sank like a rock.",
'SLIME': "%s%s ^1MELTED^7", 'SLIME': "%s%s ^7melted.",
'CRUSH': "%s%s ^1WAS CRUSHED^7" 'CRUSH': "%s%s ^7was crushed."
} }
msg_template = death_msgs.get(mod, "%s%s ^1DIED FROM %s^7") msg_template = death_msgs.get(mod, "%s%s ^1DIED FROM %s^7")
if mod in death_msgs: if mod in death_msgs:
@ -305,7 +366,7 @@ def ParseGameEvent(message):
} }
weapon_name = weapon_names.get(weapon, 'the %s' % weapon) 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, killer_team_prefix, killer_name,
victim_team_prefix, victim_name, victim_team_prefix, victim_name,
weapon_name, warmup_suffix weapon_name, warmup_suffix
@ -321,7 +382,7 @@ def ParseGameEvent(message):
'GRENADE': 'Grenade Launcher' 'GRENADE': 'Grenade Launcher'
} }
weapon_name = weapon_names.get(weapon, weapon) 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 killer_team_prefix, killer_name, weapon_name, warmup_suffix
) )
@ -329,9 +390,51 @@ def ParseGameEvent(message):
name = data.get('NAME', 'Unknown') name = data.get('NAME', 'Unknown')
medal = data.get('MEDAL', 'UNKNOWN') medal = data.get('MEDAL', 'UNKNOWN')
warmup = data.get('WARMUP', False) 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) 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: else:
# Unknown event type - log at debug level and to file # Unknown event type - log at debug level and to file
@ -433,7 +536,7 @@ def main(screen):
input_window, output_window = InitWindows(screen, args) 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())) PrintMessageFormatted(output_window, "zmq python bindings {}, libzmq version {}\n".format(repr(zmq.__version__), zmq.zmq_version()))
stats_port = None stats_port = None
@ -477,6 +580,11 @@ def main(screen):
while ( not q.empty() ): while ( not q.empty() ):
l = q.get() l = q.get()
logger.info( 'Sending command: %s' % repr( l.strip() ) ) 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') ) socket.send( l.encode('utf-8') )
if stats_connected and stats_socket: if stats_connected and stats_socket:
@ -519,6 +627,8 @@ def main(screen):
logger.debug( 'Read %d message(s) from socket.' % msg_count ) logger.debug( 'Read %d message(s) from socket.' % msg_count )
break break
except Exception as e: 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 ) logger.error( 'Error receiving message: %s' % e )
break break
else: else:
@ -580,6 +690,8 @@ def main(screen):
stats_connected = True stats_connected = True
PrintMessageFormatted(output_window, "Stats stream connected - ready for game events\n") PrintMessageFormatted(output_window, "Stats stream connected - ready for game events\n")
except Exception as e: 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) logger.error('Stats connection failed: %s' % e)
import traceback import traceback
logger.debug(traceback.format_exc()) 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 # It's JSON but we didn't parse it - already logged to file
logger.debug('Unparsed JSON event') logger.debug('Unparsed JSON event')
else: 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')): if ':' in msg_str and not msg_str.startswith(('print', 'broadcast', 'zmq')):
# Strip the special character first to get clean player name # Strip the special character first to get clean player name
clean_msg = msg_str.replace(chr(25), '') clean_msg = msg_str.replace(chr(25), '')
# Check if it's team chat (PlayerName): or regular chat PlayerName: # Check if it's team chat (PlayerName): or regular chat PlayerName:
if clean_msg.strip().startswith('(') and ')' in clean_msg: if clean_msg.strip().startswith('(') and ')' in clean_msg:
# Team chat: (PlayerName): message # Team chat: (PlayerName) (Location): message or (PlayerName): message
match = re.match(r'^(\([^)]+\):)(\s*.*)', clean_msg) # Location can contain nested parens like (Lower Floor (Near Yellow Armour))
if match: # We need to match the first (PlayerName), then optionally a second parenthetical with potential nesting
player_part = match.group(1) # (PlayerName):
message_part = match.group(2) # message text
# Extract just the player name for team lookup # First, extract player name (first parenthetical)
name_match = re.match(r'\(([^)]+)\)', player_part) player_match = re.match(r'^(\([^)]+\))', clean_msg)
if name_match: if player_match:
player_name = name_match.group(1).strip() player_part = player_match.group(1)
player_name_clean = re.sub(r'\^\d', '', player_name) rest_of_msg = clean_msg[len(player_part):].lstrip()
# Look up player by clean name and add team prefix # Check if there's a location (starts with another opening paren)
team_prefix = '' if rest_of_msg.startswith('('):
if player_name_clean in player_teams: # Find the matching closing paren for the location
team_prefix = get_team_color(player_name_clean) # 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
# Reconstruct with team prefix and colored message (^5 for team chat) if location_end > 0 and location_end < len(rest_of_msg) and rest_of_msg[location_end] == ':':
msg_str = f"{team_prefix}{player_part}^5{message_part}\n" # 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: else:
# Regular chat: PlayerName: message # Regular chat: PlayerName: message
parts = clean_msg.split(':', 1) parts = clean_msg.split(':', 1)
@ -674,6 +849,8 @@ def main(screen):
except KeyboardInterrupt: except KeyboardInterrupt:
PrintMessageFormatted(output_window, "\nShutting down...\n") PrintMessageFormatted(output_window, "\nShutting down...\n")
except Exception as e: 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 ) logger.error( 'Fatal error: %s' % e )
import traceback import traceback
logger.error( traceback.format_exc() ) logger.error( traceback.format_exc() )
@ -686,4 +863,4 @@ def main(screen):
if ( __name__ == '__main__' ): if ( __name__ == '__main__' ):
curses.wrapper(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