Compare commits
4
Commits
c08db27b57
...
17dd04857f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17dd04857f | ||
|
|
ca1666f3a5 | ||
|
|
be7c3aec56 | ||
|
|
38f1446cde |
@@ -1,4 +1,5 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
|
docs/
|
||||||
venv/
|
venv/
|
||||||
venv3/
|
venv3/
|
||||||
*test*
|
*test*
|
||||||
|
|||||||
+32
-5
@@ -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,14 @@ 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
|
||||||
|
|
||||||
|
# Restarted matches (server shutdown mid-game) have no winner; aborts
|
||||||
|
# and forfeits still have final scores that name one
|
||||||
|
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 +374,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)
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,29 @@ class PlayerTracker:
|
|||||||
"""Get clean (color-code-stripped) names of all tracked players"""
|
"""Get clean (color-code-stripped) names of all tracked players"""
|
||||||
return [strip_color_codes(name) for name in self.server_info.players]
|
return [strip_color_codes(name) for name in self.server_info.players]
|
||||||
|
|
||||||
|
def set_score(self, name, score):
|
||||||
|
"""Set a player's authoritative score (from PLAYER_STATS / LIVESTATS)"""
|
||||||
|
clean_name = strip_color_codes(name)
|
||||||
|
if name in self.server_info.players:
|
||||||
|
self.server_info.players[name]['score'] = str(score)
|
||||||
|
return True
|
||||||
|
for player_name in self.server_info.players:
|
||||||
|
if strip_color_codes(player_name) == clean_name:
|
||||||
|
self.server_info.players[player_name]['score'] = str(score)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_livestats(self, clean_name, score, dealt, taken, ping):
|
||||||
|
"""Update live score/damage/ping from a LIVESTATS console print (clean name)"""
|
||||||
|
for player_name, player_data in self.server_info.players.items():
|
||||||
|
if strip_color_codes(player_name) == clean_name:
|
||||||
|
player_data['score'] = str(score)
|
||||||
|
player_data['ping'] = str(ping)
|
||||||
|
player_data['dealt'] = str(dealt)
|
||||||
|
player_data['taken'] = str(taken)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def remove_player(self, name):
|
def remove_player(self, name):
|
||||||
"""Remove player from tracking"""
|
"""Remove player from tracking"""
|
||||||
clean_name = strip_color_codes(name)
|
clean_name = strip_color_codes(name)
|
||||||
@@ -223,6 +246,26 @@ class EventDeduplicator:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class VoteTracker:
|
||||||
|
"""Tracks cast subjects of the running vote; reset on each vote call"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.subjects = []
|
||||||
|
|
||||||
|
def on_call(self):
|
||||||
|
self.subjects = []
|
||||||
|
|
||||||
|
def on_cast(self, subject):
|
||||||
|
self.subjects.append(subject)
|
||||||
|
|
||||||
|
def result_annotation(self):
|
||||||
|
"""The cast subject when every cast agreed, else None (multi-option votes:
|
||||||
|
the winner is unknowable from broadcasts)"""
|
||||||
|
if self.subjects and all(s == self.subjects[0] for s in self.subjects):
|
||||||
|
return self.subjects[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class GameState:
|
class GameState:
|
||||||
"""Main game state container"""
|
"""Main game state container"""
|
||||||
|
|
||||||
@@ -230,3 +273,4 @@ class GameState:
|
|||||||
self.server_info = ServerInfo()
|
self.server_info = ServerInfo()
|
||||||
self.player_tracker = PlayerTracker(self.server_info)
|
self.player_tracker = PlayerTracker(self.server_info)
|
||||||
self.event_deduplicator = EventDeduplicator()
|
self.event_deduplicator = EventDeduplicator()
|
||||||
|
self.vote_tracker = VoteTracker()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user