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)
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
STATS_CONNECTION_DELAY = 0.5 # Initial stats connection delay
# UI dimensions
+2
View File
@@ -400,6 +400,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)
+19 -8
View File
@@ -101,14 +101,25 @@ 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:
self.server_info.players[name] = {
'score': score,
'ping': ping
}
logger.debug(f'Added player: {name}')
"""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
}
logger.debug(f'Added player: {name}')
def get_players_by_team(self):
"""Get players organized by team"""
+79 -1
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
from lib.state import GameState
from lib.network import RconConnection, StatsConnection
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)
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*$')
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')
@@ -243,6 +245,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
@@ -482,6 +548,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 +624,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:
@@ -612,6 +681,13 @@ def main_loop(screen, args):
if handle_vote_broadcast(message, game_state, ui):
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
# status echo opens a window that hides its chunked output
if is_command_echo(message):
@@ -652,6 +728,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