lesssgo
This commit is contained in:
245
qlpycon.py
245
qlpycon.py
@ -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,18 +382,60 @@ 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
|
||||||
)
|
)
|
||||||
|
|
||||||
elif event_type == 'PLAYER_MEDAL':
|
elif event_type == 'PLAYER_MEDAL':
|
||||||
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
|
||||||
logger.debug('Unknown event type: %s' % event_type)
|
logger.debug('Unknown event type: %s' % event_type)
|
||||||
@ -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
|
# 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
|
# Check if there's a location (starts with another opening paren)
|
||||||
name_match = re.match(r'\(([^)]+)\)', player_part)
|
if rest_of_msg.startswith('('):
|
||||||
if name_match:
|
# Find the matching closing paren for the location
|
||||||
player_name = name_match.group(1).strip()
|
# We need to count parens to handle nesting like (Floor (Near Armor))
|
||||||
player_name_clean = re.sub(r'\^\d', '', player_name)
|
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
|
if location_end > 0 and location_end < len(rest_of_msg) and rest_of_msg[location_end] == ':':
|
||||||
team_prefix = ''
|
# We found a valid location with closing paren followed by colon
|
||||||
if player_name_clean in player_teams:
|
location_part = rest_of_msg[:location_end]
|
||||||
team_prefix = get_team_color(player_name_clean)
|
message_part = rest_of_msg[location_end + 1:] # After the colon
|
||||||
|
|
||||||
# Reconstruct with team prefix and colored message (^5 for team chat)
|
# Extract player name for team lookup
|
||||||
msg_str = f"{team_prefix}{player_part}^5{message_part}\n"
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user