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 <[email protected]>
This commit is contained in:
Debian
2026-09-18 12:19:23 +02:00
co-authored by Sisyphus
parent c08db27b57
commit 38f1446cde
2 changed files with 125 additions and 8 deletions
+36 -5
View File
@@ -171,10 +171,14 @@ class EventParser:
warmup = " ^8^3(Warmup)^0" if data.get('WARMUP', False) else "" warmup = " ^8^3(Warmup)^0" if data.get('WARMUP', False) else ""
score_prefix = "" 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) # Environmental death (no killer)
if 'KILLER' not in data or not data['KILLER']: if 'KILLER' not in data or not data['KILLER']:
# -1 for environmental death # -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) self.game_state.player_tracker.update_score(victim_name, -1)
score_prefix = "^8^1[-1]^7^0 " 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) killer_prefix = get_team_prefix(killer_name, self.game_state.player_tracker)
# Suicide # Suicide: names can differ cosmetically (color codes/whitespace) between
if killer_name == victim_name: # 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 # -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) self.game_state.player_tracker.update_score(victim_name, -1)
score_prefix = "^8^1[-1]^7^0 " 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" return f"{score_prefix}{killer_prefix}^8{killer_name}^0 ^7committed suicide with the ^7{weapon_name}{warmup}\n"
# Regular kill: +1 for killer # 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) self.game_state.player_tracker.update_score(killer_name, 1)
score_prefix = "^8^2[+1]^7^0 " score_prefix = "^8^2[+1]^7^0 "
@@ -333,6 +347,18 @@ class EventParser:
if not self.game_state.server_info.is_team_mode(): if not self.game_state.server_info.is_team_mode():
return None 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')) red_score = int(data.get('TSCORE0', '0'))
blue_score = int(data.get('TSCORE1', '0')) blue_score = int(data.get('TSCORE1', '0'))
report_prefix = "^8^1[GAME OVER]" report_prefix = "^8^1[GAME OVER]"
@@ -352,6 +378,11 @@ class EventParser:
kills = int(data.get('KILLS', '0')) kills = int(data.get('KILLS', '0'))
deaths = int(data.get('DEATHS', '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', {}) weapon_data = data.get('WEAPONS', {})
accuracies = calculate_weapon_accuracies(weapon_data) accuracies = calculate_weapon_accuracies(weapon_data)
+89 -3
View File
@@ -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_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) STATUS_MAP_PATTERN = re.compile(r'^map:\s+\S+\s*$', re.MULTILINE)
STEAMID_PATTERN = re.compile(r'\b\d{16,}\b') 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 # Configure logging
logger = logging.getLogger('main') logger = logging.getLogger('main')
@@ -134,7 +147,62 @@ def is_command_output(message):
return False 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): def is_real_content(message):
@@ -165,6 +233,8 @@ def is_real_content(message):
clean = clean[:-1] clean = clean[:-1]
if 'Game Initialization' in clean: if 'Game Initialization' in clean:
return True return True
if GAME_END_PATTERN.search(clean):
return True
if CHAT_SHAPE_PATTERN.match(clean): if CHAT_SHAPE_PATTERN.match(clean):
return True return True
return False return False
@@ -281,6 +351,12 @@ def parse_player_events(message, game_state, ui):
# Strip color codes for matching # Strip color codes for matching
clean_msg = strip_color_codes(msg) 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" # Match connects: "NAME connected" or "NAME connected with Steam ID"
connect_match = CONNECT_PATTERN.match(clean_msg) connect_match = CONNECT_PATTERN.match(clean_msg)
if connect_match: if connect_match:
@@ -524,6 +600,15 @@ def main_loop(screen, args):
if parse_player_events(message, game_state, ui): if parse_player_events(message, game_state, ui):
continue 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 # Command echoes only show at -v or higher verbosity; a
# status echo opens a window that hides its chunked output # status echo opens a window that hides its chunked output
if is_command_echo(message): if is_command_echo(message):
@@ -647,8 +732,9 @@ def main_loop(screen, args):
logger.debug(f'Filtered bot debug: {message[:50]}') logger.debug(f'Filtered bot debug: {message[:50]}')
continue continue
# Check if it's a chat message # Check if it's a chat message; engine output shares the shape
if ':' in message and not message.startswith(('print', 'broadcast', 'zmq')): 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) message = format_chat_message(message, game_state.player_tracker)
# Format and display message # Format and display message