Compare commits

..
9 Commits
Author SHA1 Message Date
DebianandSisyphus ec64804fec Infer kill event teams without relying on the livedamage plugin
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 14:39:21 +02:00
DebianandSisyphus c1b5f6d86a Populate team rosters from a LIVESTATS team token and tag the damage list
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 14:26:06 +02:00
DebianandSisyphus cbeab1ae45 Detect team modes by GAME_TYPE code and sync gametype at match start
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 14:19:37 +02:00
DebianandSisyphus 016384ef12 Display responses to cvars the user queries from the console
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 13:57:19 +02:00
DebianandSisyphus f8a71e7906 Render locally what rcon say broadcasts print on the server
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 13:32:49 +02:00
DebianandSisyphus c636557849 Sync rosters at match start, harden dedup and TIME handling, add scoreboard and damage summary
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 13:04:34 +02:00
DebianandSisyphus 43758f980c Run the roster seeder after cvar handling so credential responses survive
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 12:43:19 +02:00
DebianandSisyphus c9487b954a Seed the player roster from a status dump on connect and map change
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 12:33:59 +02:00
DebianandSisyphus c5e0bc3f01 Track roster connects and disconnects from the stats stream
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 12:30:12 +02:00
5 changed files with 369 additions and 68 deletions
+8
View File
@@ -19,6 +19,8 @@ POLL_TIMEOUT = 100
QUIT_CONFIRM_TIMEOUT = 3.0 # Seconds to confirm quit (Ctrl-C twice) QUIT_CONFIRM_TIMEOUT = 3.0 # Seconds to confirm quit (Ctrl-C twice)
RESPAWN_DELAY = 3.0 # Seconds before players respawn after death RESPAWN_DELAY = 3.0 # Seconds before players respawn after death
STATUS_OUTPUT_WINDOW = 1.5 # Seconds a 'status' command echo suppresses its chunked table output STATUS_OUTPUT_WINDOW = 1.5 # Seconds a 'status' command echo suppresses its chunked table output
STATUS_SEED_WINDOW = 2.0 # Seconds the roster seeder collects the 'status' dump after connect
CVAR_RESPONSE_TTL = 10.0 # Seconds a user-queried cvar response may arrive after the query
STATS_CONNECTION_DELAY = 0.5 # Initial stats connection delay STATS_CONNECTION_DELAY = 0.5 # Initial stats connection delay
# UI dimensions # UI dimensions
@@ -55,6 +57,12 @@ TEAM_MAP = {
3: 'SPECTATOR' 3: 'SPECTATOR'
} }
# GAME_TYPE codes from MATCH_STARTED that are team-based; matching on the code
# is immune to customized factory titles (g_factoryTitle)
TEAM_GAME_TYPE_CODES = {
'TDM', 'CA', 'CTF', '1FLAG', 'HARVESTER', 'FT', 'DOM', 'RR'
}
TEAM_COLORS = { TEAM_COLORS = {
'RED': '^1(RED)^7', 'RED': '^1(RED)^7',
'BLUE': '^4(BLUE)^7', 'BLUE': '^4(BLUE)^7',
+147 -35
View File
@@ -32,6 +32,15 @@ class EventParser:
self.json_logger = json_logger self.json_logger = json_logger
self.unknown_logger = unknown_logger self.unknown_logger = unknown_logger
def _sync_match_time(self, data):
"""Sync the live match timer; a malformed TIME value must not discard
the whole event (the sync runs inside the handler try blocks)"""
try:
self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time()
except (TypeError, ValueError):
logger.debug(f'Malformed TIME value: {data.get("TIME")!r}')
def parse_event(self, message): def parse_event(self, message):
""" """
Parse JSON event and return formatted message string Parse JSON event and return formatted message string
@@ -65,8 +74,8 @@ class EventParser:
'MATCH_STARTED': self._handle_match_started, 'MATCH_STARTED': self._handle_match_started,
'MATCH_REPORT': self._handle_match_report, 'MATCH_REPORT': self._handle_match_report,
'PLAYER_STATS': self._handle_player_stats, 'PLAYER_STATS': self._handle_player_stats,
'PLAYER_CONNECT': lambda d: None, 'PLAYER_CONNECT': self._handle_player_connect,
'PLAYER_DISCONNECT': lambda d: None, 'PLAYER_DISCONNECT': self._handle_player_disconnect,
'ROUND_OVER': self._handle_round_over, 'ROUND_OVER': self._handle_round_over,
} }
@@ -92,13 +101,34 @@ class EventParser:
logger.debug(f'Error parsing event: {e}') logger.debug(f'Error parsing event: {e}')
return None return None
def _handle_player_connect(self, data):
"""Track a connecting player so the roster is complete; connects to the
server land as spectators until they join a team — except reconnects of
already-tracked players, whose known team is kept. No feed line: the
RCON connect broadcast already displays one."""
name = data.get('NAME')
if not name:
return None
team = data.get('TEAM')
if team is None:
team = self.game_state.player_tracker.get_team(name) or 'SPECTATOR'
self.game_state.player_tracker.update_team(name, team)
self.game_state.player_tracker.add_player(name)
return None
def _handle_player_disconnect(self, data):
"""Remove a disconnecting player from the roster. No feed line: the
RCON disconnect broadcast already displays one."""
name = data.get('NAME')
if name:
self.game_state.player_tracker.remove_player(name)
return None
def _handle_switchteam(self, data): def _handle_switchteam(self, data):
"""Handle PLAYER_SWITCHTEAM event""" """Handle PLAYER_SWITCHTEAM event"""
# Get Match Time # Get Match Time
if 'TIME' in data: self._sync_match_time(data)
self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time()
if 'KILLER' not in data: if 'KILLER' not in data:
return None return None
@@ -137,13 +167,39 @@ class EventParser:
team_prefix = get_team_prefix(name, self.game_state.player_tracker) team_prefix = get_team_prefix(name, self.game_state.player_tracker)
return f"^8^5[SWITCH]^7 {team_prefix}^8{name}^0{team_msg} from {old_team_msg}{warmup}\n" return f"^8^5[SWITCH]^7 {team_prefix}^8{name}^0{team_msg} from {old_team_msg}{warmup}\n"
def _opposite_team(self, team):
"""The other active team in a two-team mode; None when not applicable"""
return {1: 2, 2: 1, 'RED': 'BLUE', 'BLUE': 'RED'}.get(team)
def _resolve_event_teams(self, data, killer, victim, killer_name, victim_name):
"""
Resolve (killer_team, victim_team) for a kill/death event. Event TEAM
fields win; when a server omits them (some do during warmup), the
missing side is derived from the counterpart player and the
TEAMKILL/SUICIDE flags. Solo modes always resolve to FREE — every
participant is FREE by definition. None means unknowable from this
event (leave the tracked team untouched).
"""
if not self.game_state.server_info.is_team_mode():
return ('FREE', 'FREE')
killer_team = killer.get('TEAM') if killer else None
victim_team = victim.get('TEAM') if victim else None
same_team = (bool(data.get('SUICIDE', False))
or strip_color_codes(killer_name).strip() == strip_color_codes(victim_name).strip()
or bool(data.get('TEAMKILL', False)))
if killer_team is not None and victim_team is None:
victim_team = killer_team if same_team else self._opposite_team(killer_team)
elif victim_team is not None and killer_team is None:
killer_team = victim_team if same_team else self._opposite_team(victim_team)
return (killer_team, victim_team)
def _handle_death(self, data): def _handle_death(self, data):
"""Handle PLAYER_DEATH and PLAYER_KILL events""" """Handle PLAYER_DEATH and PLAYER_KILL events"""
# Get Match Time # Get Match Time
if 'TIME' in data: self._sync_match_time(data)
self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time()
if 'VICTIM' not in data: if 'VICTIM' not in data:
return None return None
@@ -162,6 +218,11 @@ class EventParser:
if 'TEAM' in victim: if 'TEAM' in victim:
self.game_state.player_tracker.update_team(victim_name, victim['TEAM']) self.game_state.player_tracker.update_team(victim_name, victim['TEAM'])
self.game_state.player_tracker.add_player(victim_name) self.game_state.player_tracker.add_player(victim_name)
elif not self.game_state.server_info.is_team_mode():
# Solo modes: every participant is FREE by definition, even when
# the event carries no TEAM field
self.game_state.player_tracker.update_team(victim_name, 'FREE')
self.game_state.player_tracker.add_player(victim_name)
# Mark as dead # Mark as dead
if not data.get('WARMUP', False): if not data.get('WARMUP', False):
@@ -196,10 +257,17 @@ class EventParser:
killer = data['KILLER'] killer = data['KILLER']
killer_name = killer.get('NAME', 'Unknown') killer_name = killer.get('NAME', 'Unknown')
# Update killer team # Team resolution with inference: prefer the event's TEAM fields; when
if 'TEAM' in killer: # a server omits them (some do during warmup), derive the missing side
self.game_state.player_tracker.update_team(killer_name, killer['TEAM']) # from the counterpart and the TEAMKILL/SUICIDE flags
self.game_state.player_tracker.add_player(killer_name) killer_team, victim_team = self._resolve_event_teams(
data, killer, victim, killer_name, victim_name)
if killer_team is not None:
self.game_state.player_tracker.update_team(killer_name, killer_team)
if victim_team is not None:
self.game_state.player_tracker.update_team(victim_name, victim_team)
self.game_state.player_tracker.add_player(killer_name)
self.game_state.player_tracker.add_player(victim_name)
killer_prefix = get_team_prefix(killer_name, self.game_state.player_tracker) killer_prefix = get_team_prefix(killer_name, self.game_state.player_tracker)
@@ -261,9 +329,7 @@ class EventParser:
"""Handle ROUND_OVER events for CA""" """Handle ROUND_OVER events for CA"""
# Get Match Time # Get Match Time
if 'TIME' in data: self._sync_match_time(data)
self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time()
team_won = data.get('TEAM_WON') team_won = data.get('TEAM_WON')
round_num = data.get('ROUND', 0) round_num = data.get('ROUND', 0)
@@ -283,9 +349,7 @@ class EventParser:
"""Handle PLAYER_MEDAL event""" """Handle PLAYER_MEDAL event"""
# Get Match Time # Get Match Time
if 'TIME' in data: self._sync_match_time(data)
self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time()
name = data.get('NAME', 'Unknown') name = data.get('NAME', 'Unknown')
medal = data.get('MEDAL', 'UNKNOWN') medal = data.get('MEDAL', 'UNKNOWN')
@@ -318,18 +382,41 @@ class EventParser:
"""Handle MATCH_STARTED event""" """Handle MATCH_STARTED event"""
# Get Match Time # Get Match Time
if 'TIME' in data: self._sync_match_time(data)
self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time() # GAME_TYPE/FACTORY_TITLE are the authoritative gametype for this match;
# the title alone can be customized and defeat exact team-mode matching
self.game_state.server_info.game_type_code = data.get('GAME_TYPE', '')
if data.get('FACTORY_TITLE'):
self.game_state.server_info.gametype = data['FACTORY_TITLE']
# The PLAYERS array is the authoritative roster at match start: sync
# teams (numeric codes via TEAM_MAP) and reset scores, clear dead
# markers, and drop stale dedup signatures (TIME restarts at 0)
players = []
for player in data.get('PLAYERS', []):
name = player.get('NAME')
if not name:
continue
players.append(name)
team = player.get('TEAM')
if team is None:
team = 'SPECTATOR'
self.game_state.player_tracker.update_team(name, team)
self.game_state.player_tracker.add_player(name, score='0', ping='0')
self.game_state.player_tracker.set_score(name, '0')
# The array is the complete match roster: drop tracked players absent
# from it (they left before the match started)
roster_names = {strip_color_codes(name) for name in players}
for tracked in list(self.game_state.server_info.players.keys()):
if strip_color_codes(tracked) not in roster_names:
self.game_state.player_tracker.remove_player(tracked)
self.game_state.server_info.dead_players.clear()
self.game_state.event_deduplicator.reset()
if self.game_state.server_info.is_team_mode(): if self.game_state.server_info.is_team_mode():
return f"^8^2[GAME ON]^0 ^7Match has started - ^1^8RED ^0^7vs. ^4^8BLUE\n" return f"^8^2[GAME ON]^0 ^7Match has started - ^1^8RED ^0^7vs. ^4^8BLUE\n"
players = []
for player in data.get('PLAYERS', []):
name = player.get('NAME', 'Unknown')
players.append(name)
if players: if players:
formatted = "^0 vs. ^8".join(players) formatted = "^0 vs. ^8".join(players)
return f"^8^2[GAME ON]^0 ^7Match has started - ^8^7{formatted}\n" return f"^8^2[GAME ON]^0 ^7Match has started - ^8^7{formatted}\n"
@@ -340,12 +427,7 @@ class EventParser:
"""Handle MATCH_REPORT event""" """Handle MATCH_REPORT event"""
# Get Match Time # Get Match Time
if 'TIME' in data: self._sync_match_time(data)
self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time()
if not self.game_state.server_info.is_team_mode():
return None
# Restarted matches (server shutdown mid-game) have no winner; aborts # Restarted matches (server shutdown mid-game) have no winner; aborts
# and forfeits still have final scores that name one # and forfeits still have final scores that name one
@@ -355,16 +437,44 @@ class EventParser:
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
# Final standings from the per-player STATS array
stats_rows = []
for entry in data.get('STATS') or []:
if not isinstance(entry, dict):
continue
entry_name = entry.get('NAME')
if not entry_name:
continue
try:
entry_score = int(entry.get('SCORE', 0))
except (TypeError, ValueError):
entry_score = 0
stats_rows.append((strip_color_codes(entry_name), entry_score,
entry.get('KILLS', '?'), entry.get('DEATHS', '?')))
stats_rows.sort(key=lambda x: x[1], reverse=True)
scoreboard = ''
if stats_rows:
formatted = ' '.join(f"{name} {score} ({kills}/{deaths})"
for name, score, kills, deaths in stats_rows[:5])
scoreboard = f"^8^1[SCORES]^7 {formatted}\n"
if not self.game_state.server_info.is_team_mode():
return scoreboard or 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]"
if red_score > blue_score: if red_score > blue_score:
return f"{report_prefix} ^7The ^1RED TEAM ^7WINS^0 by a score of ^8^1{red_score} ^0^7to ^8^4{blue_score}\n" winner = f"{report_prefix} ^7The ^1RED TEAM ^7WINS^0 by a score of ^8^1{red_score} ^0^7to ^8^4{blue_score}\n"
elif blue_score > red_score: elif blue_score > red_score:
return f"{report_prefix} ^7The ^4BLUE TEAM ^7WINS^0 by a score of ^8^4{blue_score} ^0^7to ^8^1{red_score}\n" winner = f"{report_prefix} ^7The ^4BLUE TEAM ^7WINS^0 by a score of ^8^4{blue_score} ^0^7to ^8^1{red_score}\n"
else: else:
return f"{report_prefix} ^7The match is a TIE^0 with a score of ^8^1{red_score} ^0^7to ^8^4{blue_score}\n" winner = f"{report_prefix} ^7The match is a TIE^0 with a score of ^8^1{red_score} ^0^7to ^8^4{blue_score}\n"
if scoreboard:
winner = f"{winner.rstrip(chr(10))}\n{scoreboard}"
return winner
def _handle_player_stats(self, data): def _handle_player_stats(self, data):
"""Handle PLAYER_STATS event""" """Handle PLAYER_STATS event"""
@@ -378,6 +488,8 @@ class EventParser:
# in CA it is damage-based, so sync it instead of accumulating deltas # in CA it is damage-based, so sync it instead of accumulating deltas
if data.get('SCORE') is not None: if data.get('SCORE') is not None:
self.game_state.player_tracker.set_score(name, data['SCORE']) self.game_state.player_tracker.set_score(name, data['SCORE'])
if 'TEAM' in data:
self.game_state.player_tracker.update_team(name, data['TEAM'])
weapon_data = data.get('WEAPONS', {}) weapon_data = data.get('WEAPONS', {})
accuracies = calculate_weapon_accuracies(weapon_data) accuracies = calculate_weapon_accuracies(weapon_data)
+46 -15
View File
@@ -5,7 +5,7 @@ Tracks server info, players, and teams
""" """
import logging import logging
from .constants import TEAM_MODES, TEAM_MAP, MAX_RECENT_EVENTS from .constants import TEAM_MODES, TEAM_MAP, MAX_RECENT_EVENTS, TEAM_GAME_TYPE_CODES
from .formatter import strip_color_codes from .formatter import strip_color_codes
logger = logging.getLogger('state') logger = logging.getLogger('state')
@@ -18,6 +18,7 @@ class ServerInfo:
self.hostname = 'Unknown' self.hostname = 'Unknown'
self.map = 'Unknown' self.map = 'Unknown'
self.gametype = 'Unknown' self.gametype = 'Unknown'
self.game_type_code = ''
self.timelimit = '0' self.timelimit = '0'
self.fraglimit = '0' self.fraglimit = '0'
self.roundlimit = '0' self.roundlimit = '0'
@@ -36,8 +37,9 @@ class ServerInfo:
self.match_time_last_sync = 0 # Timestamp of last TIME update from server self.match_time_last_sync = 0 # Timestamp of last TIME update from server
def is_team_mode(self): def is_team_mode(self):
"""Check if current gametype is a team mode""" """Check if current gametype is a team mode, by GAME_TYPE code from
return self.gametype in TEAM_MODES MATCH_STARTED or by factory title"""
return self.gametype in TEAM_MODES or self.game_type_code in TEAM_GAME_TYPE_CODES
def reset_round_scores(self): def reset_round_scores(self):
"""Reset round scores (for new matches)""" """Reset round scores (for new matches)"""
@@ -101,14 +103,25 @@ class PlayerTracker:
return self.player_teams.get(name) return self.player_teams.get(name)
def add_player(self, name, score='0', ping='0'): def add_player(self, name, score='0', ping='0'):
"""Add player to server's player dict if not exists""" """Add player to server's player dict if not exists. A seeded clean-name
# Use original name with color codes as key entry is upgraded to the color-coded name (preserving score/ping) instead
if name not in self.server_info.players: of duplicating the roster entry."""
self.server_info.players[name] = { if name in self.server_info.players:
'score': score, return
'ping': ping clean_name = strip_color_codes(name)
} for player_name in list(self.server_info.players.keys()):
logger.debug(f'Added player: {name}') if player_name != name and strip_color_codes(player_name) == clean_name:
existing = self.server_info.players.pop(player_name)
existing.setdefault('score', score)
existing.setdefault('ping', ping)
self.server_info.players[name] = existing
logger.debug(f'Upgraded roster entry {player_name} -> {name}')
return
self.server_info.players[name] = {
'score': score,
'ping': ping
}
logger.debug(f'Added player: {name}')
def get_players_by_team(self): def get_players_by_team(self):
"""Get players organized by team""" """Get players organized by team"""
@@ -136,16 +149,25 @@ class PlayerTracker:
return True return True
return False return False
def update_livestats(self, clean_name, score, dealt, taken, ping): def update_livestats(self, clean_name, score, dealt, taken, ping, team=None):
"""Update live score/damage/ping from a LIVESTATS console print (clean name)""" """Update live score/damage/ping from a LIVESTATS console print (clean name).
With a team token (extended livedamage format) the team is authoritative —
it sorts tracked players and adds unknown ones to the roster, which is
what populates teams correctly during warmup."""
for player_name, player_data in self.server_info.players.items(): for player_name, player_data in self.server_info.players.items():
if strip_color_codes(player_name) == clean_name: if strip_color_codes(player_name) == clean_name:
player_data['score'] = str(score) player_data['score'] = str(score)
player_data['ping'] = str(ping) player_data['ping'] = str(ping)
player_data['dealt'] = str(dealt) player_data['dealt'] = str(dealt)
player_data['taken'] = str(taken) player_data['taken'] = str(taken)
if team is not None:
self.update_team(player_name, team)
return True return True
return False if team is None:
return False
self.add_player(clean_name, score=str(score), ping=str(ping))
self.update_team(clean_name, team)
return True
def remove_player(self, name): def remove_player(self, name):
"""Remove player from tracking""" """Remove player from tracking"""
@@ -232,7 +254,12 @@ class EventDeduplicator:
if event_type not in ('PLAYER_DEATH', 'PLAYER_KILL'): if event_type not in ('PLAYER_DEATH', 'PLAYER_KILL'):
return False return False
signature = f"KILL:{time_val}:{killer_name}:{victim_name}" # Signatures use stripped names: the double-emitted PLAYER_KILL and
# PLAYER_DEATH for one frag can carry cosmetically different name
# strings (color codes/whitespace), which would defeat the match
signature = (f"KILL:{time_val}:"
f"{strip_color_codes(killer_name).strip()}:"
f"{strip_color_codes(victim_name).strip()}")
if signature in self.recent_events: if signature in self.recent_events:
logger.debug(f'Duplicate event: {signature}') logger.debug(f'Duplicate event: {signature}')
@@ -245,6 +272,10 @@ class EventDeduplicator:
return False return False
def reset(self):
"""Clear the signature buffer (match start: TIME restarts at 0)"""
self.recent_events = []
class VoteTracker: class VoteTracker:
"""Tracks cast subjects of the running vote; reset on each vote call""" """Tracks cast subjects of the running vote; reset on each vote call"""
+35 -2
View File
@@ -18,9 +18,10 @@ import time
from collections import deque from collections import deque
from .constants import (INFO_WINDOW_HEIGHT, INFO_WINDOW_Y, OUTPUT_WINDOW_Y, INPUT_WINDOW_HEIGHT, from .constants import (INFO_WINDOW_HEIGHT, INFO_WINDOW_Y, OUTPUT_WINDOW_Y, INPUT_WINDOW_HEIGHT,
MIN_ROWS, MIN_COLS, OUTPUT_SCROLLBACK_LINES, TEAM_MODES, MAX_COMMAND_HISTORY) MIN_ROWS, MIN_COLS, OUTPUT_SCROLLBACK_LINES, TEAM_MODES, MAX_COMMAND_HISTORY,
TEAM_COLORS)
from .cvars import autocomplete, COMMAND_SIGNATURES, get_signature_with_highlight, get_argument_suggestions, COMMAND_ARGUMENTS from .cvars import autocomplete, COMMAND_SIGNATURES, get_signature_with_highlight, get_argument_suggestions, COMMAND_ARGUMENTS
from .formatter import strip_color_codes from .formatter import get_team_prefix, strip_color_codes
logger = logging.getLogger('ui') logger = logging.getLogger('ui')
@@ -431,6 +432,33 @@ def _draw_server_menu(screen, servers, names, selected):
screen.refresh() screen.refresh()
def format_damage_summary(server_info, player_tracker=None):
"""One-line damage summary (top 3 by damage dealt) from LIVESTATS counters,
tagged with the tracked team of each player (data-driven, so tags work
during warmup too); None when no damage data exists yet. Dealt/taken are
stored as strings."""
entries = []
for player_name, player_data in server_info.players.items():
try:
dealt = int(player_data.get('dealt', 0))
taken = int(player_data.get('taken', 0))
except (TypeError, ValueError):
continue
if dealt <= 0 and taken <= 0:
continue
entries.append((strip_color_codes(player_name), dealt, taken))
if not entries:
return None
entries.sort(key=lambda x: x[1], reverse=True)
parts = []
for name, dealt, taken in entries[:3]:
prefix = ''
if player_tracker is not None:
prefix = TEAM_COLORS.get(player_tracker.get_team(name), '')
parts.append(f"{prefix}{name} {dealt}/{taken}")
return f"^8^3DMG^7 {' '.join(parts)}\n"
class UIManager: class UIManager:
"""Manages curses windows and display""" """Manages curses windows and display"""
@@ -872,6 +900,11 @@ class UIManager:
line = f"^8{col1}^0{' ' * col1_pad}^8{col2}^0\n" line = f"^8{col1}^0{' ' * col1_pad}^8{col2}^0\n"
print_colored(self.info_window, line, 0) print_colored(self.info_window, line, 0)
# Damage summary from LIVESTATS counters (top 3 by damage dealt)
damage_line = format_damage_summary(server_info, game_state.player_tracker)
if damage_line:
print_colored(self.info_window, damage_line, 0)
# Blank lines to fill # Blank lines to fill
try: try:
self.info_window.addstr("\n") self.info_window.addstr("\n")
+133 -16
View File
@@ -15,7 +15,7 @@ import signal
import sys import sys
import os import os
from lib.constants import VERSION, DEFAULT_HOST, POLL_TIMEOUT, QUIT_CONFIRM_TIMEOUT, RESPAWN_DELAY, MAX_COMMAND_HISTORY, STATUS_OUTPUT_WINDOW from lib.constants import VERSION, DEFAULT_HOST, POLL_TIMEOUT, QUIT_CONFIRM_TIMEOUT, RESPAWN_DELAY, MAX_COMMAND_HISTORY, STATUS_OUTPUT_WINDOW, STATUS_SEED_WINDOW, CVAR_RESPONSE_TTL
from lib.state import GameState from lib.state import GameState
from lib.network import RconConnection, StatsConnection from lib.network import RconConnection, StatsConnection
from lib.parser import EventParser from lib.parser import EventParser
@@ -37,10 +37,14 @@ 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+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s*$') LIVESTATS_PATTERN = re.compile(
r'^LIVESTATS\s+(.+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)(?:\s+([A-Za-z]+|\d))?\s*$')
STATUS_ROW_PATTERN = re.compile(
r'^\s*(\d+)\s+(-?\d+)\s+(\d+)\s+(.+?)\s+(\d+)\s+(\S+)\s+(\d+)\s+(\d+)(?:\s+(\d{15,}))?\s*$')
ROSTER_CHAT_GUARD_PATTERN = re.compile(r'^.{1,40}:\s') ROSTER_CHAT_GUARD_PATTERN = re.compile(r'^.{1,40}:\s')
VOTE_CAST_PATTERN = re.compile(r'^(.+?) voted for (.+)\.$') VOTE_CAST_PATTERN = re.compile(r'^(.+?) voted for (.+)\.$')
CHAT_SHAPE_PATTERN = re.compile(r'^[\w(][^:]*:\s') CHAT_SHAPE_PATTERN = re.compile(r'^[\w(][^:]*:\s')
CVAR_QUERY_PATTERN = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
GAME_END_PATTERN = re.compile( GAME_END_PATTERN = re.compile(
r'((?:timelimit|fraglimit|capturelimit|roundlimit)\s+hit' r'((?:timelimit|fraglimit|capturelimit|roundlimit)\s+hit'
r'|hit\s+the\s+(?:timelimit|fraglimit|capturelimit|roundlimit)' r'|hit\s+the\s+(?:timelimit|fraglimit|capturelimit|roundlimit)'
@@ -70,6 +74,16 @@ quit_confirm_time = None
_QUIT_CONFIRM_TIMEOUT = QUIT_CONFIRM_TIMEOUT _QUIT_CONFIRM_TIMEOUT = QUIT_CONFIRM_TIMEOUT
_RESPAWN_DELAY = RESPAWN_DELAY _RESPAWN_DELAY = RESPAWN_DELAY
# Cvars the user queried from the console (lowercased name -> request time):
# their responses display verbatim; everything else stays suppressed
expected_cvar_responses = {}
def consume_expected_cvar(cvar_name):
"""True when the user queried this cvar from the console recently"""
requested_at = expected_cvar_responses.pop(cvar_name.lower(), None)
return requested_at is not None and (time.time() - requested_at) < CVAR_RESPONSE_TTL
# Global shutdown flag (set by handle_ctrl_c, checked by main_loop) # Global shutdown flag (set by handle_ctrl_c, checked by main_loop)
shutdown_requested = False shutdown_requested = False
@@ -100,20 +114,27 @@ def handle_ctrl_c():
def parse_cvar_response(message, game_state, ui): def parse_cvar_response(message, game_state, ui):
""" """
Parse server cvar responses and update state Parse server cvar responses: update state for known cvars, and display the
Returns True if message should be suppressed from display response when the user queried it from the console. Returns True when the
message is consumed (known-cvar update or a displayed query result).
""" """
# Parse cvar responses (format: "cvar_name" is:"value" default:...) match = CVAR_RESPONSE_PATTERN.search(message)
cvar_match = CVAR_RESPONSE_PATTERN.search(message) if not match:
if cvar_match: return False
cvar_name = cvar_match.group(1)
value = cvar_match.group(2)
if game_state.server_info.update_from_cvar(cvar_name, value): cvar_name = match.group(1)
ui.update_server_info(game_state) value = match.group(2)
return True
return False state_updated = game_state.server_info.update_from_cvar(cvar_name, value)
if state_updated:
ui.update_server_info(game_state)
displayed = consume_expected_cvar(cvar_name)
if displayed:
timestamp = time.strftime('%H:%M:%S')
ui.print_message(f"^3[^7{timestamp}^3]^7 {message.rstrip()}\n")
return state_updated or displayed
def is_command_echo(message): def is_command_echo(message):
@@ -159,7 +180,9 @@ def handle_livestats(message, game_state, ui, verbose=0):
Consume every LIVESTATS heartbeat line (engine damage counters emitted Consume every LIVESTATS heartbeat line (engine damage counters emitted
every 10s by the livedamage plugin, plus its summary variants). Lines every 10s by the livedamage plugin, plus its summary variants). Lines
matching the full player shape update live score/damage/ping; names may matching the full player shape update live score/damage/ping; names may
contain spaces, so the counters anchor from the end. The raw lines contain spaces, so the counters anchor from the end. An optional trailing
team token (extended livedamage format) is authoritative and populates
the roster with correct teams — including during warmup. The raw lines
display only at -v or higher verbosity; they are never treated as chat. display only at -v or higher verbosity; they are never treated as chat.
""" """
clean = strip_color_codes(message).strip() clean = strip_color_codes(message).strip()
@@ -167,8 +190,11 @@ def handle_livestats(message, game_state, ui, verbose=0):
return False return False
match = LIVESTATS_PATTERN.match(clean) match = LIVESTATS_PATTERN.match(clean)
if match: if match:
name, score, dealt, taken, ping = match.groups() name, score, dealt, taken, ping, team = match.groups()
game_state.player_tracker.update_livestats(name, int(score), int(dealt), int(taken), int(ping)) if team is not None and team.isdigit():
team = int(team)
game_state.player_tracker.update_livestats(
name, int(score), int(dealt), int(taken), int(ping), team)
ui.update_server_info(game_state) ui.update_server_info(game_state)
if verbose > 0: if verbose > 0:
timestamp = time.strftime('%H:%M:%S') timestamp = time.strftime('%H:%M:%S')
@@ -243,6 +269,70 @@ def is_real_content(message):
return False return False
class StatusSeeder:
"""
Seeds the player roster from a 'status' command dump, sent on connect and
after map changes. The table arrives chunked across many messages (rows
split at arbitrary column boundaries, wrapped or raw), so raw text is
buffered and split on newlines; the tail is flushed when the window
closes. Rows carry no team column, so untracked names seed as SPECTATOR
(immediately visible; active players self-correct on their first stats
event) and known players only get their authoritative score/ping.
All seeding output stays suppressed.
"""
def __init__(self, game_state):
self.game_state = game_state
self.buffer = ''
self.active_until = 0.0
def start(self):
self.buffer = ''
self.active_until = time.time() + STATUS_SEED_WINDOW
def is_active(self):
if self.active_until and time.time() >= self.active_until:
self.finish()
return bool(self.active_until) and time.time() < self.active_until
def feed(self, message):
"""Buffer one RCON message and parse every completed line"""
text = strip_color_codes(message)
if text.startswith('broadcast:'):
text = text[10:].lstrip()
if text.startswith('print "'):
text = text[7:]
if text.endswith('"'):
text = text[:-1]
self.buffer += text.replace('\\n', '\n')
while '\n' in self.buffer:
line, self.buffer = self.buffer.split('\n', 1)
self._parse_line(line)
def finish(self):
if self.buffer.strip():
self._parse_line(self.buffer)
self.buffer = ''
def _parse_line(self, line):
match = STATUS_ROW_PATTERN.match(line.strip())
if not match:
return
num, score, ping, name, lastmsg, address, qport, rate, steamid = match.groups()
tracker = self.game_state.player_tracker
clean_name = strip_color_codes(name).strip()
if not clean_name:
return
player_data = self.game_state.server_info.players.get(clean_name)
if player_data is not None:
player_data['score'] = str(score)
player_data['ping'] = str(ping)
return
tracker.add_player(clean_name, score=str(score), ping=str(ping))
if tracker.get_team(clean_name) is None:
tracker.update_team(clean_name, 'SPECTATOR')
def handle_stats_connection(message): def handle_stats_connection(message):
""" """
Handle stats connection info extraction Handle stats connection info extraction
@@ -283,6 +373,18 @@ def handle_user_input(ui, rcon):
rcon.send_command(command) rcon.send_command(command)
# The server does not echo rcon-originated says back on the console
# stream, so render locally what players see in-game
stripped = command.strip()
if stripped.lower().startswith('say '):
timestamp = time.strftime('%H:%M:%S')
ui.print_message(f"^3[^7{timestamp}^3]^7 Server: {stripped[4:]}\n")
# A bare cvar name queries its value; the response displays verbatim
# when it arrives (other clients' query output stays suppressed)
if CVAR_QUERY_PATTERN.match(stripped):
expected_cvar_responses[stripped.lower()] = time.time()
def handle_stats_stream(stats_conn, stats_check_counter, event_parser, ui, game_state): def handle_stats_stream(stats_conn, stats_check_counter, event_parser, ui, game_state):
"""Poll and process stats stream events""" """Poll and process stats stream events"""
@@ -482,6 +584,7 @@ def main_loop(screen, args):
# Initialize components # Initialize components
game_state = GameState() game_state = GameState()
name_capture = NameCapture() # cvar/command names learned from the server for autocomplete name_capture = NameCapture() # cvar/command names learned from the server for autocomplete
status_seeder = StatusSeeder(game_state) # seeds the roster from a status dump on connect
ui = UIManager(screen, args.title, args.max_history, ui = UIManager(screen, args.title, args.max_history,
player_names_provider=lambda: game_state.player_tracker.get_player_names(), player_names_provider=lambda: game_state.player_tracker.get_player_names(),
learned_names_provider=lambda: name_capture.names) learned_names_provider=lambda: name_capture.names)
@@ -557,6 +660,8 @@ def main_loop(screen, args):
ui.print_message(f"^3[^7{timestamp}^3]^7 Requesting connection info...\n") ui.print_message(f"^3[^7{timestamp}^3]^7 Requesting connection info...\n")
rcon.send_command(b'zmq_stats_password') rcon.send_command(b'zmq_stats_password')
rcon.send_command(b'net_port') rcon.send_command(b'net_port')
rcon.send_command(b'status')
status_seeder.start()
if args.learn_names: if args.learn_names:
name_capture.request(rcon) name_capture.request(rcon)
elif monitor_event and monitor_event[0] == zmq.EVENT_DISCONNECTED: elif monitor_event and monitor_event[0] == zmq.EVENT_DISCONNECTED:
@@ -652,6 +757,8 @@ def main_loop(screen, args):
game_state.player_tracker.player_teams = {} game_state.player_tracker.player_teams = {}
game_state.server_info.reset_round_scores() game_state.server_info.reset_round_scores()
game_state.server_info.dead_players.clear() game_state.server_info.dead_players.clear()
rcon.send_command(b'status')
status_seeder.start()
ui.update_server_info(game_state) ui.update_server_info(game_state)
# Try to parse as cvar response # Try to parse as cvar response
@@ -699,6 +806,16 @@ def main_loop(screen, args):
ui.print_message(f"^1[^7{timestamp}^1] Error: Stats connection failed: {e}^7\n") ui.print_message(f"^1[^7{timestamp}^1] Error: Stats connection failed: {e}^7\n")
logger.error(f'Stats connection failed: {e}') logger.error(f'Stats connection failed: {e}')
# Seed the roster from the status dump after connect/map change.
# Must run after the cvar/credential handlers above (their
# responses share the window and are never table rows) and
# before the shape-based suppressor (rows must buffer, not
# vanish). Table chunks stay suppressed, real content flows.
if status_seeder.is_active():
if not is_real_content(message):
status_seeder.feed(message)
continue
# Hide output of RCON commands (cvar dumps, status tables) # Hide output of RCON commands (cvar dumps, status tables)
if is_command_output(message): if is_command_output(message):
logger.debug('Suppressed RCON command output') logger.debug('Suppressed RCON command output')