From 38f1446cde69d9fb4de7624fc8b845ef554e0e3d Mon Sep 17 00:00:00 2001 From: Debian Date: Fri, 18 Sep 2026 12:19:23 +0200 Subject: [PATCH] Announce forfeited match winners and show LIVESTATS lines only with -v Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- lib/parser.py | 41 ++++++++++++++++++++--- main.py | 92 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 125 insertions(+), 8 deletions(-) diff --git a/lib/parser.py b/lib/parser.py index 3931ee5..a42daf5 100644 --- a/lib/parser.py +++ b/lib/parser.py @@ -171,10 +171,14 @@ class EventParser: warmup = " ^8^3(Warmup)^0" if data.get('WARMUP', False) else "" score_prefix = "" + # CA scores are damage-based server-side (kills + damage/100): frag + # deltas would corrupt them, so only PLAYER_STATS/LIVESTATS sync there + ca_scoring = self.game_state.server_info.gametype == 'Clan Arena' + # Environmental death (no killer) if 'KILLER' not in data or not data['KILLER']: # -1 for environmental death - if not data.get('WARMUP', False): + if not data.get('WARMUP', False) and not ca_scoring: self.game_state.player_tracker.update_score(victim_name, -1) score_prefix = "^8^1[-1]^7^0 " @@ -199,10 +203,20 @@ class EventParser: killer_prefix = get_team_prefix(killer_name, self.game_state.player_tracker) - # Suicide - if killer_name == victim_name: + # Suicide: names can differ cosmetically (color codes/whitespace) between + # the KILLER and VICTIM objects, so compare stripped. Steamid equality is + # only trusted for humans - bot ids are shared and would flag every + # bot-vs-bot frag as a suicide. + killer_steamid = str(killer.get('STEAM_ID', '') or '') + victim_steamid = str(victim.get('STEAM_ID', '') or '') + suicide = (strip_color_codes(killer_name).strip() + == strip_color_codes(victim_name).strip()) + if (not suicide and killer_steamid.startswith('7656119') + and victim_steamid.startswith('7656119')): + suicide = killer_steamid == victim_steamid + if suicide: # -1 for suicide - if not data.get('WARMUP', False): + if not data.get('WARMUP', False) and not ca_scoring: self.game_state.player_tracker.update_score(victim_name, -1) score_prefix = "^8^1[-1]^7^0 " @@ -218,7 +232,7 @@ class EventParser: return f"{score_prefix}{killer_prefix}^8{killer_name}^0 ^7committed suicide with the ^7{weapon_name}{warmup}\n" # Regular kill: +1 for killer - if not data.get('WARMUP', False): + if not data.get('WARMUP', False) and not ca_scoring: self.game_state.player_tracker.update_score(killer_name, 1) score_prefix = "^8^2[+1]^7^0 " @@ -333,6 +347,18 @@ class EventParser: if not self.game_state.server_info.is_team_mode(): return None + # Aborted/restarted matches have no winner - except forfeits, where + # the remaining scores are final and name a winner + exit_msg = str(data.get('EXIT_MSG', '') or '') + is_forfeit = 'forfeit' in exit_msg.lower() + if data.get('ABORTED') in (True, 'true', '1', 1) and not is_forfeit: + return None + try: + if int(data.get('RESTARTED', 0) or 0) != 0: + return None + except (TypeError, ValueError): + return None + red_score = int(data.get('TSCORE0', '0')) blue_score = int(data.get('TSCORE1', '0')) report_prefix = "^8^1[GAME OVER]" @@ -352,6 +378,11 @@ class EventParser: kills = int(data.get('KILLS', '0')) deaths = int(data.get('DEATHS', '0')) + # PLAYER_STATS SCORE is authoritative (the server's own counters) and + # 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']) + weapon_data = data.get('WEAPONS', {}) accuracies = calculate_weapon_accuracies(weapon_data) diff --git a/main.py b/main.py index abd7e8d..0cf59dc 100644 --- a/main.py +++ b/main.py @@ -37,6 +37,19 @@ RENAME_PATTERN = re.compile(r'^(.+?)\s+renamed to\s+(.+?)$') STATUS_HEAD_PATTERN = re.compile(r'^\s*\d+\s+-?\d+\s+(bot|\d+)\s+\S', re.MULTILINE) 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+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\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') +GAME_END_PATTERN = re.compile( + r'((?:timelimit|fraglimit|capturelimit|roundlimit)\s+hit' + r'|hit\s+the\s+(?:timelimit|fraglimit|capturelimit|roundlimit)' + r'|game\s+has\s+been\s+forfeited' + r'|wins\s+the\s+round)', re.IGNORECASE) +ENGINE_OUTPUT_PREFIXES = ( + 'warning:', 'server:', 'error:', 'fatal:', 'livestats', + 'gamename:', 'gamedate:', 'protocol:', 'cheats:', 'sv_tags:', 'client ', +) # Configure logging logger = logging.getLogger('main') @@ -134,7 +147,62 @@ def is_command_output(message): return False -CHAT_SHAPE_PATTERN = re.compile(r'^[\w(][^:]*:\s') +def is_engine_output(message): + """Engine/banner prints share the chat shape but are never chat + (they would surface as fake chat from players named WARNING or Server)""" + clean = strip_color_codes(message).lstrip() + return clean.lower().startswith(ENGINE_OUTPUT_PREFIXES) + + +def handle_livestats(message, game_state, ui, verbose=0): + """ + Parse a LIVESTATS print (engine damage counters, emitted every 10s by the + livedamage plugin) and update the player's live score/damage/ping. + Returns True when the line is consumed. The raw line displays only at -v + or higher verbosity; it is never treated as chat. + """ + match = LIVESTATS_PATTERN.match(strip_color_codes(message).strip()) + if not match: + return False + name, score, dealt, taken, ping = match.groups() + game_state.player_tracker.update_livestats(name, int(score), int(dealt), int(taken), int(ping)) + ui.update_server_info(game_state) + if verbose > 0: + timestamp = time.strftime('%H:%M:%S') + ui.print_message(f"^3[^7{timestamp}^3]^7 {message.rstrip()}\n") + return True + + +def handle_vote_broadcast(message, game_state, ui): + """ + Track vote lifecycle broadcasts and display them verbatim (color codes + intact), annotating the outcome with the cast subject when every cast + agreed. Returns True when the message is a vote line and has been + handled; chat-shaped lines are never vote events. + """ + clean = strip_color_codes(message).strip() + if not clean or ROSTER_CHAT_GUARD_PATTERN.match(clean): + return False + tracker = game_state.vote_tracker + timestamp = time.strftime('%H:%M:%S') + if clean.endswith('called a vote.'): + tracker.on_call() + ui.print_message(f"^3[^7{timestamp}^3]^7 {message.rstrip()}\n") + return True + cast = VOTE_CAST_PATTERN.match(clean) + if cast: + tracker.on_cast(cast.group(2)) + ui.print_message(f"^3[^7{timestamp}^3]^7 {message.rstrip()}\n") + return True + if clean in ('Vote passed.', 'Vote failed.'): + line = message.rstrip() + subject = tracker.result_annotation() + if subject: + line = f"{line} — {subject}" + ui.print_message(f"^3[^7{timestamp}^3]^7 {line}\n") + tracker.on_call() + return True + return False def is_real_content(message): @@ -165,6 +233,8 @@ def is_real_content(message): clean = clean[:-1] if 'Game Initialization' in clean: return True + if GAME_END_PATTERN.search(clean): + return True if CHAT_SHAPE_PATTERN.match(clean): return True return False @@ -281,6 +351,12 @@ def parse_player_events(message, game_state, ui): # Strip color codes for matching clean_msg = strip_color_codes(msg) + # Roster events are only roster events when the line is not chat-shaped: + # "Foo: I just connected my router" must not spawn a phantom join for + # a player named "Foo: I just" + if ROSTER_CHAT_GUARD_PATTERN.match(clean_msg): + return False + # Match connects: "NAME connected" or "NAME connected with Steam ID" connect_match = CONNECT_PATTERN.match(clean_msg) if connect_match: @@ -524,6 +600,15 @@ def main_loop(screen, args): if parse_player_events(message, game_state, ui): continue + # LIVESTATS prints feed live score/damage/ping; the raw + # line only displays at -v or higher verbosity + if handle_livestats(message, game_state, ui, args.verbose): + continue + + # Vote lifecycle broadcasts are tracked and shown verbatim + if handle_vote_broadcast(message, game_state, ui): + 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): @@ -647,8 +732,9 @@ def main_loop(screen, args): logger.debug(f'Filtered bot debug: {message[:50]}') continue - # Check if it's a chat message - if ':' in message and not message.startswith(('print', 'broadcast', 'zmq')): + # Check if it's a chat message; engine output shares the shape + if (':' in message and not message.startswith(('print', 'broadcast', 'zmq')) + and not is_engine_output(message)): message = format_chat_message(message, game_state.player_tracker) # Format and display message