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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user