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)
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_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
# UI dimensions
@@ -55,6 +57,12 @@ TEAM_MAP = {
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 = {
'RED': '^1(RED)^7',
'BLUE': '^4(BLUE)^7',
+146 -34
View File
@@ -32,6 +32,15 @@ class EventParser:
self.json_logger = json_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):
"""
Parse JSON event and return formatted message string
@@ -65,8 +74,8 @@ class EventParser:
'MATCH_STARTED': self._handle_match_started,
'MATCH_REPORT': self._handle_match_report,
'PLAYER_STATS': self._handle_player_stats,
'PLAYER_CONNECT': lambda d: None,
'PLAYER_DISCONNECT': lambda d: None,
'PLAYER_CONNECT': self._handle_player_connect,
'PLAYER_DISCONNECT': self._handle_player_disconnect,
'ROUND_OVER': self._handle_round_over,
}
@@ -92,13 +101,34 @@ class EventParser:
logger.debug(f'Error parsing event: {e}')
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):
"""Handle PLAYER_SWITCHTEAM event"""
# Get Match Time
if 'TIME' in data:
self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time()
self._sync_match_time(data)
if 'KILLER' not in data:
return None
@@ -137,13 +167,39 @@ class EventParser:
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"
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):
"""Handle PLAYER_DEATH and PLAYER_KILL events"""
# Get Match Time
if 'TIME' in data:
self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time()
self._sync_match_time(data)
if 'VICTIM' not in data:
return None
@@ -162,6 +218,11 @@ class EventParser:
if 'TEAM' in victim:
self.game_state.player_tracker.update_team(victim_name, victim['TEAM'])
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
if not data.get('WARMUP', False):
@@ -196,10 +257,17 @@ class EventParser:
killer = data['KILLER']
killer_name = killer.get('NAME', 'Unknown')
# Update killer team
if 'TEAM' in killer:
self.game_state.player_tracker.update_team(killer_name, killer['TEAM'])
# Team resolution with inference: prefer the event's TEAM fields; when
# a server omits them (some do during warmup), derive the missing side
# from the counterpart and the TEAMKILL/SUICIDE flags
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)
@@ -261,9 +329,7 @@ class EventParser:
"""Handle ROUND_OVER events for CA"""
# Get Match Time
if 'TIME' in data:
self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time()
self._sync_match_time(data)
team_won = data.get('TEAM_WON')
round_num = data.get('ROUND', 0)
@@ -283,9 +349,7 @@ class EventParser:
"""Handle PLAYER_MEDAL event"""
# Get Match Time
if 'TIME' in data:
self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time()
self._sync_match_time(data)
name = data.get('NAME', 'Unknown')
medal = data.get('MEDAL', 'UNKNOWN')
@@ -318,18 +382,41 @@ class EventParser:
"""Handle MATCH_STARTED event"""
# Get Match Time
if 'TIME' in data:
self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time()
self._sync_match_time(data)
# 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():
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:
formatted = "^0 vs. ^8".join(players)
return f"^8^2[GAME ON]^0 ^7Match has started - ^8^7{formatted}\n"
@@ -340,12 +427,7 @@ class EventParser:
"""Handle MATCH_REPORT event"""
# Get Match Time
if 'TIME' in 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
self._sync_match_time(data)
# Restarted matches (server shutdown mid-game) have no winner; aborts
# and forfeits still have final scores that name one
@@ -355,16 +437,44 @@ class EventParser:
except (TypeError, ValueError):
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'))
blue_score = int(data.get('TSCORE1', '0'))
report_prefix = "^8^1[GAME OVER]"
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:
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:
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):
"""Handle PLAYER_STATS event"""
@@ -378,6 +488,8 @@ class EventParser:
# 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'])
if 'TEAM' in data:
self.game_state.player_tracker.update_team(name, data['TEAM'])
weapon_data = data.get('WEAPONS', {})
accuracies = calculate_weapon_accuracies(weapon_data)
+40 -9
View File
@@ -5,7 +5,7 @@ Tracks server info, players, and teams
"""
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
logger = logging.getLogger('state')
@@ -18,6 +18,7 @@ class ServerInfo:
self.hostname = 'Unknown'
self.map = 'Unknown'
self.gametype = 'Unknown'
self.game_type_code = ''
self.timelimit = '0'
self.fraglimit = '0'
self.roundlimit = '0'
@@ -36,8 +37,9 @@ class ServerInfo:
self.match_time_last_sync = 0 # Timestamp of last TIME update from server
def is_team_mode(self):
"""Check if current gametype is a team mode"""
return self.gametype in TEAM_MODES
"""Check if current gametype is a team mode, by GAME_TYPE code from
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):
"""Reset round scores (for new matches)"""
@@ -101,9 +103,20 @@ class PlayerTracker:
return self.player_teams.get(name)
def add_player(self, name, score='0', ping='0'):
"""Add player to server's player dict if not exists"""
# Use original name with color codes as key
if name not in self.server_info.players:
"""Add player to server's player dict if not exists. A seeded clean-name
entry is upgraded to the color-coded name (preserving score/ping) instead
of duplicating the roster entry."""
if name in self.server_info.players:
return
clean_name = strip_color_codes(name)
for player_name in list(self.server_info.players.keys()):
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
@@ -136,16 +149,25 @@ class PlayerTracker:
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)"""
def update_livestats(self, clean_name, score, dealt, taken, ping, team=None):
"""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():
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)
if team is not None:
self.update_team(player_name, team)
return True
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):
"""Remove player from tracking"""
@@ -232,7 +254,12 @@ class EventDeduplicator:
if event_type not in ('PLAYER_DEATH', 'PLAYER_KILL'):
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:
logger.debug(f'Duplicate event: {signature}')
@@ -245,6 +272,10 @@ class EventDeduplicator:
return False
def reset(self):
"""Clear the signature buffer (match start: TIME restarts at 0)"""
self.recent_events = []
class VoteTracker:
"""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 .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 .formatter import strip_color_codes
from .formatter import get_team_prefix, strip_color_codes
logger = logging.getLogger('ui')
@@ -431,6 +432,33 @@ def _draw_server_menu(screen, servers, names, selected):
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:
"""Manages curses windows and display"""
@@ -872,6 +900,11 @@ class UIManager:
line = f"^8{col1}^0{' ' * col1_pad}^8{col2}^0\n"
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
try:
self.info_window.addstr("\n")
+134 -17
View File
@@ -15,7 +15,7 @@ import signal
import sys
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.network import RconConnection, StatsConnection
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_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+(-?\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')
VOTE_CAST_PATTERN = re.compile(r'^(.+?) voted for (.+)\.$')
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(
r'((?:timelimit|fraglimit|capturelimit|roundlimit)\s+hit'
r'|hit\s+the\s+(?:timelimit|fraglimit|capturelimit|roundlimit)'
@@ -70,6 +74,16 @@ quit_confirm_time = None
_QUIT_CONFIRM_TIMEOUT = QUIT_CONFIRM_TIMEOUT
_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)
shutdown_requested = False
@@ -100,21 +114,28 @@ def handle_ctrl_c():
def parse_cvar_response(message, game_state, ui):
"""
Parse server cvar responses and update state
Returns True if message should be suppressed from display
Parse server cvar responses: update state for known cvars, and display the
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:...)
cvar_match = CVAR_RESPONSE_PATTERN.search(message)
if cvar_match:
cvar_name = cvar_match.group(1)
value = cvar_match.group(2)
if game_state.server_info.update_from_cvar(cvar_name, value):
ui.update_server_info(game_state)
return True
match = CVAR_RESPONSE_PATTERN.search(message)
if not match:
return False
cvar_name = match.group(1)
value = match.group(2)
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):
"""True for the server's echo lines announcing that an RCON client sent a command"""
@@ -159,7 +180,9 @@ def handle_livestats(message, game_state, ui, verbose=0):
Consume every LIVESTATS heartbeat line (engine damage counters emitted
every 10s by the livedamage plugin, plus its summary variants). Lines
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.
"""
clean = strip_color_codes(message).strip()
@@ -167,8 +190,11 @@ def handle_livestats(message, game_state, ui, verbose=0):
return False
match = LIVESTATS_PATTERN.match(clean)
if match:
name, score, dealt, taken, ping = match.groups()
game_state.player_tracker.update_livestats(name, int(score), int(dealt), int(taken), int(ping))
name, score, dealt, taken, ping, team = match.groups()
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)
if verbose > 0:
timestamp = time.strftime('%H:%M:%S')
@@ -243,6 +269,70 @@ def is_real_content(message):
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):
"""
Handle stats connection info extraction
@@ -283,6 +373,18 @@ def handle_user_input(ui, rcon):
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):
"""Poll and process stats stream events"""
@@ -482,6 +584,7 @@ def main_loop(screen, args):
# Initialize components
game_state = GameState()
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,
player_names_provider=lambda: game_state.player_tracker.get_player_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")
rcon.send_command(b'zmq_stats_password')
rcon.send_command(b'net_port')
rcon.send_command(b'status')
status_seeder.start()
if args.learn_names:
name_capture.request(rcon)
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.server_info.reset_round_scores()
game_state.server_info.dead_players.clear()
rcon.send_command(b'status')
status_seeder.start()
ui.update_server_info(game_state)
# 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")
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)
if is_command_output(message):
logger.debug('Suppressed RCON command output')