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]>
This commit is contained in:
Debian
2026-09-18 12:33:59 +02:00
co-authored by Sisyphus
parent c5e0bc3f01
commit c9487b954a
4 changed files with 101 additions and 9 deletions
+1
View File
@@ -19,6 +19,7 @@ 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
STATS_CONNECTION_DELAY = 0.5 # Initial stats connection delay STATS_CONNECTION_DELAY = 0.5 # Initial stats connection delay
# UI dimensions # UI dimensions
+2
View File
@@ -400,6 +400,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)
+19 -8
View File
@@ -101,14 +101,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"""
+79 -1
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
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
@@ -38,6 +38,8 @@ STATUS_HEAD_PATTERN = re.compile(r'^\s*\d+\s+-?\d+\s+(bot|\d+)\s+\S', re.MULTILI
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*$')
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')
@@ -243,6 +245,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
@@ -482,6 +548,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 +624,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:
@@ -612,6 +681,13 @@ def main_loop(screen, args):
if handle_vote_broadcast(message, game_state, ui): if handle_vote_broadcast(message, game_state, ui):
continue continue
# Seed the roster from the status dump after connect/map change;
# table chunks stay suppressed, real content flows normally
if status_seeder.is_active():
if not is_real_content(message):
status_seeder.feed(message)
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):
@@ -652,6 +728,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