Compare commits
25
Commits
3d1dc19d9b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec64804fec | ||
|
|
c1b5f6d86a | ||
|
|
cbeab1ae45 | ||
|
|
016384ef12 | ||
|
|
f8a71e7906 | ||
|
|
c636557849 | ||
|
|
43758f980c | ||
|
|
c9487b954a | ||
|
|
c5e0bc3f01 | ||
|
|
b69cc38cce | ||
|
|
17dd04857f | ||
|
|
ca1666f3a5 | ||
|
|
be7c3aec56 | ||
|
|
38f1446cde | ||
|
|
c08db27b57 | ||
|
|
73e503e863 | ||
|
|
3bc84ba3f2 | ||
|
|
d188c6a506 | ||
|
|
8eccfefc75 | ||
|
|
e834b44ba6 | ||
|
|
9ea4aa122a | ||
|
|
190152733e | ||
|
|
b8864ca8dd | ||
|
|
b641eb4e45 | ||
|
|
71bd740d78 |
@@ -1,4 +1,5 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
|
docs/
|
||||||
venv/
|
venv/
|
||||||
venv3/
|
venv3/
|
||||||
*test*
|
*test*
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ POLL_TIMEOUT = 100
|
|||||||
# Timing constants
|
# Timing constants
|
||||||
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_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
|
||||||
@@ -54,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',
|
||||||
|
|||||||
+17
-4
@@ -22,6 +22,11 @@ logger = logging.getLogger('namelist')
|
|||||||
# from the request: a large cvarlist (5000+ messages) takes seconds to process.
|
# from the request: a large cvarlist (5000+ messages) takes seconds to process.
|
||||||
CAPTURE_TIMEOUT = 5.0
|
CAPTURE_TIMEOUT = 5.0
|
||||||
|
|
||||||
|
# Absolute cap on a capture, measured from the request. The timeout above is
|
||||||
|
# refreshed by every incoming message, so regular server output can keep a
|
||||||
|
# stuck capture (count line never matches) alive indefinitely without this bound.
|
||||||
|
CAPTURE_MAX_DURATION = 30.0
|
||||||
|
|
||||||
# Cvar line: the name is the word directly before the quoted value.
|
# Cvar line: the name is the word directly before the quoted value.
|
||||||
# Example: 'S A sv_hostname "My Server"' -> sv_hostname
|
# Example: 'S A sv_hostname "My Server"' -> sv_hostname
|
||||||
CVARLIST_LINE = re.compile(r'([A-Za-z_]\w*)\s+"')
|
CVARLIST_LINE = re.compile(r'([A-Za-z_]\w*)\s+"')
|
||||||
@@ -31,8 +36,8 @@ CVARLIST_LINE = re.compile(r'([A-Za-z_]\w*)\s+"')
|
|||||||
CMDLIST_LINE = re.compile(r'^\s*([A-Za-z_]\w*)\s*$')
|
CMDLIST_LINE = re.compile(r'^\s*([A-Za-z_]\w*)\s*$')
|
||||||
|
|
||||||
# Count line that ends a listing.
|
# Count line that ends a listing.
|
||||||
# Examples: '1234 total cvars', '321 commands'
|
# Examples: '1234 total cvars', '321 commands', '321 total commands'
|
||||||
END_LINE = re.compile(r'^\s*\d+\s+(total cvars|commands)\s*$')
|
END_LINE = re.compile(r'^\s*\d+\s+(total cvars|total commands|commands)\s*$')
|
||||||
|
|
||||||
|
|
||||||
def unwrap(message):
|
def unwrap(message):
|
||||||
@@ -55,12 +60,14 @@ class NameCapture:
|
|||||||
self.names = set()
|
self.names = set()
|
||||||
self.listings_pending = 0 # count lines still expected; 0 = not capturing
|
self.listings_pending = 0 # count lines still expected; 0 = not capturing
|
||||||
self.deadline = 0
|
self.deadline = 0
|
||||||
|
self.deadline_max = 0
|
||||||
self.partial_line = ''
|
self.partial_line = ''
|
||||||
|
|
||||||
def request(self, rcon):
|
def request(self, rcon):
|
||||||
"""Send cvarlist and cmdlist; their output is captured until both count lines arrived"""
|
"""Send cvarlist and cmdlist; their output is captured until both count lines arrived"""
|
||||||
self.listings_pending = 2
|
self.listings_pending = 2
|
||||||
self.deadline = time.time() + CAPTURE_TIMEOUT
|
self.deadline = time.time() + CAPTURE_TIMEOUT
|
||||||
|
self.deadline_max = time.time() + CAPTURE_MAX_DURATION
|
||||||
self.partial_line = ''
|
self.partial_line = ''
|
||||||
rcon.send_command(b'cvarlist')
|
rcon.send_command(b'cvarlist')
|
||||||
rcon.send_command(b'cmdlist')
|
rcon.send_command(b'cmdlist')
|
||||||
@@ -73,12 +80,18 @@ class NameCapture:
|
|||||||
"""
|
"""
|
||||||
if self.listings_pending == 0:
|
if self.listings_pending == 0:
|
||||||
return False
|
return False
|
||||||
if time.time() > self.deadline:
|
now = time.time()
|
||||||
|
if now > self.deadline_max:
|
||||||
|
logger.warning(f'Capture exceeded {CAPTURE_MAX_DURATION:.0f}s, '
|
||||||
|
f'stopping capture with {len(self.names)} names')
|
||||||
|
self.listings_pending = 0
|
||||||
|
return False
|
||||||
|
if now > self.deadline:
|
||||||
logger.warning(f'No listing output for {CAPTURE_TIMEOUT:.0f}s, '
|
logger.warning(f'No listing output for {CAPTURE_TIMEOUT:.0f}s, '
|
||||||
f'stopping capture with {len(self.names)} names')
|
f'stopping capture with {len(self.names)} names')
|
||||||
self.listings_pending = 0
|
self.listings_pending = 0
|
||||||
return False
|
return False
|
||||||
self.deadline = time.time() + CAPTURE_TIMEOUT
|
self.deadline = now + CAPTURE_TIMEOUT
|
||||||
|
|
||||||
# A line can arrive in several messages (the server sends each flag column of
|
# A line can arrive in several messages (the server sends each flag column of
|
||||||
# a cvarlist row as its own 1-byte message), so keep the unfinished tail
|
# a cvarlist row as its own 1-byte message), so keep the unfinished tail
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ def check_monitor(monitor):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
logger.debug(f'Monitor: {event_name} {event_value} endpoint {event_endpoint}')
|
logger.debug(f'Monitor: {event_name} {event_value} endpoint {event_endpoint}')
|
||||||
return (event_id, event_value)
|
return (event_id, event_value, event_endpoint.decode('utf-8', 'replace'))
|
||||||
|
|
||||||
|
|
||||||
class RconConnection:
|
class RconConnection:
|
||||||
|
|||||||
+182
-39
@@ -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,14 +74,18 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
handler = handler_map.get(event_type)
|
handler = handler_map.get(event_type)
|
||||||
if handler:
|
if handler:
|
||||||
return handler(data)
|
formatted = handler(data)
|
||||||
|
if formatted:
|
||||||
|
timestamp = time.strftime('%H:%M:%S')
|
||||||
|
return f"^3[^7{timestamp}^3]^7 {formatted}"
|
||||||
|
return None
|
||||||
else:
|
else:
|
||||||
# Unknown event
|
# Unknown event
|
||||||
logger.debug(f'Unknown event type: {event_type}')
|
logger.debug(f'Unknown event type: {event_type}')
|
||||||
@@ -88,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
|
||||||
@@ -133,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
|
||||||
@@ -158,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):
|
||||||
@@ -167,10 +232,14 @@ class EventParser:
|
|||||||
warmup = " ^8^3(Warmup)^0" if data.get('WARMUP', False) else ""
|
warmup = " ^8^3(Warmup)^0" if data.get('WARMUP', False) else ""
|
||||||
score_prefix = ""
|
score_prefix = ""
|
||||||
|
|
||||||
|
# CA scores are damage-based server-side (kills + damage/100): frag
|
||||||
|
# deltas would corrupt them, so only PLAYER_STATS/LIVESTATS sync there
|
||||||
|
ca_scoring = self.game_state.server_info.gametype == 'Clan Arena'
|
||||||
|
|
||||||
# Environmental death (no killer)
|
# Environmental death (no killer)
|
||||||
if 'KILLER' not in data or not data['KILLER']:
|
if 'KILLER' not in data or not data['KILLER']:
|
||||||
# -1 for environmental death
|
# -1 for environmental death
|
||||||
if not data.get('WARMUP', False):
|
if not data.get('WARMUP', False) and not ca_scoring:
|
||||||
self.game_state.player_tracker.update_score(victim_name, -1)
|
self.game_state.player_tracker.update_score(victim_name, -1)
|
||||||
score_prefix = "^8^1[-1]^7^0 "
|
score_prefix = "^8^1[-1]^7^0 "
|
||||||
|
|
||||||
@@ -188,17 +257,34 @@ 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)
|
||||||
|
|
||||||
# Suicide
|
# Suicide: names can differ cosmetically (color codes/whitespace) between
|
||||||
if killer_name == victim_name:
|
# the KILLER and VICTIM objects, so compare stripped. Steamid equality is
|
||||||
|
# only trusted for humans - bot ids are shared and would flag every
|
||||||
|
# bot-vs-bot frag as a suicide.
|
||||||
|
killer_steamid = str(killer.get('STEAM_ID', '') or '')
|
||||||
|
victim_steamid = str(victim.get('STEAM_ID', '') or '')
|
||||||
|
suicide = (strip_color_codes(killer_name).strip()
|
||||||
|
== strip_color_codes(victim_name).strip())
|
||||||
|
if (not suicide and killer_steamid.startswith('7656119')
|
||||||
|
and victim_steamid.startswith('7656119')):
|
||||||
|
suicide = killer_steamid == victim_steamid
|
||||||
|
if suicide:
|
||||||
# -1 for suicide
|
# -1 for suicide
|
||||||
if not data.get('WARMUP', False):
|
if not data.get('WARMUP', False) and not ca_scoring:
|
||||||
self.game_state.player_tracker.update_score(victim_name, -1)
|
self.game_state.player_tracker.update_score(victim_name, -1)
|
||||||
score_prefix = "^8^1[-1]^7^0 "
|
score_prefix = "^8^1[-1]^7^0 "
|
||||||
|
|
||||||
@@ -214,7 +300,7 @@ class EventParser:
|
|||||||
return f"{score_prefix}{killer_prefix}^8{killer_name}^0 ^7committed suicide with the ^7{weapon_name}{warmup}\n"
|
return f"{score_prefix}{killer_prefix}^8{killer_name}^0 ^7committed suicide with the ^7{weapon_name}{warmup}\n"
|
||||||
|
|
||||||
# Regular kill: +1 for killer
|
# Regular kill: +1 for killer
|
||||||
if not data.get('WARMUP', False):
|
if not data.get('WARMUP', False) and not ca_scoring:
|
||||||
self.game_state.player_tracker.update_score(killer_name, 1)
|
self.game_state.player_tracker.update_score(killer_name, 1)
|
||||||
score_prefix = "^8^2[+1]^7^0 "
|
score_prefix = "^8^2[+1]^7^0 "
|
||||||
|
|
||||||
@@ -243,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)
|
||||||
@@ -265,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')
|
||||||
@@ -300,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"
|
||||||
@@ -322,23 +427,54 @@ 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()
|
# Restarted matches (server shutdown mid-game) have no winner; aborts
|
||||||
|
# and forfeits still have final scores that name one
|
||||||
|
try:
|
||||||
|
if int(data.get('RESTARTED', 0) or 0) != 0:
|
||||||
|
return None
|
||||||
|
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():
|
if not self.game_state.server_info.is_team_mode():
|
||||||
return None
|
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"""
|
||||||
@@ -348,6 +484,13 @@ class EventParser:
|
|||||||
kills = int(data.get('KILLS', '0'))
|
kills = int(data.get('KILLS', '0'))
|
||||||
deaths = int(data.get('DEATHS', '0'))
|
deaths = int(data.get('DEATHS', '0'))
|
||||||
|
|
||||||
|
# PLAYER_STATS SCORE is authoritative (the server's own counters) and
|
||||||
|
# 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', {})
|
weapon_data = data.get('WEAPONS', {})
|
||||||
accuracies = calculate_weapon_accuracies(weapon_data)
|
accuracies = calculate_weapon_accuracies(weapon_data)
|
||||||
|
|
||||||
|
|||||||
+87
-12
@@ -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"""
|
||||||
@@ -124,6 +137,38 @@ class PlayerTracker:
|
|||||||
"""Get clean (color-code-stripped) names of all tracked players"""
|
"""Get clean (color-code-stripped) names of all tracked players"""
|
||||||
return [strip_color_codes(name) for name in self.server_info.players]
|
return [strip_color_codes(name) for name in self.server_info.players]
|
||||||
|
|
||||||
|
def set_score(self, name, score):
|
||||||
|
"""Set a player's authoritative score (from PLAYER_STATS / LIVESTATS)"""
|
||||||
|
clean_name = strip_color_codes(name)
|
||||||
|
if name in self.server_info.players:
|
||||||
|
self.server_info.players[name]['score'] = str(score)
|
||||||
|
return True
|
||||||
|
for player_name in self.server_info.players:
|
||||||
|
if strip_color_codes(player_name) == clean_name:
|
||||||
|
self.server_info.players[player_name]['score'] = str(score)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
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):
|
def remove_player(self, name):
|
||||||
"""Remove player from tracking"""
|
"""Remove player from tracking"""
|
||||||
clean_name = strip_color_codes(name)
|
clean_name = strip_color_codes(name)
|
||||||
@@ -209,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}')
|
||||||
@@ -222,6 +272,30 @@ class EventDeduplicator:
|
|||||||
|
|
||||||
return False
|
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"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.subjects = []
|
||||||
|
|
||||||
|
def on_call(self):
|
||||||
|
self.subjects = []
|
||||||
|
|
||||||
|
def on_cast(self, subject):
|
||||||
|
self.subjects.append(subject)
|
||||||
|
|
||||||
|
def result_annotation(self):
|
||||||
|
"""The cast subject when every cast agreed, else None (multi-option votes:
|
||||||
|
the winner is unknowable from broadcasts)"""
|
||||||
|
if self.subjects and all(s == self.subjects[0] for s in self.subjects):
|
||||||
|
return self.subjects[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class GameState:
|
class GameState:
|
||||||
"""Main game state container"""
|
"""Main game state container"""
|
||||||
@@ -230,3 +304,4 @@ class GameState:
|
|||||||
self.server_info = ServerInfo()
|
self.server_info = ServerInfo()
|
||||||
self.player_tracker = PlayerTracker(self.server_info)
|
self.player_tracker = PlayerTracker(self.server_info)
|
||||||
self.event_deduplicator = EventDeduplicator()
|
self.event_deduplicator = EventDeduplicator()
|
||||||
|
self.vote_tracker = VoteTracker()
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|
||||||
@@ -586,6 +614,9 @@ class UIManager:
|
|||||||
key_seen = True
|
key_seen = True
|
||||||
if key == curses.KEY_RESIZE:
|
if key == curses.KEY_RESIZE:
|
||||||
self.handle_resize()
|
self.handle_resize()
|
||||||
|
elif self.too_small:
|
||||||
|
# Warning screen: ignore typing and scrolling, only resizes act
|
||||||
|
continue
|
||||||
elif key == curses.KEY_PPAGE:
|
elif key == curses.KEY_PPAGE:
|
||||||
self.scroll_output(self._page_rows())
|
self.scroll_output(self._page_rows())
|
||||||
elif key == curses.KEY_NPAGE:
|
elif key == curses.KEY_NPAGE:
|
||||||
@@ -625,6 +656,7 @@ class UIManager:
|
|||||||
return
|
return
|
||||||
self._draw_output()
|
self._draw_output()
|
||||||
self._draw_divider()
|
self._draw_divider()
|
||||||
|
self._flush()
|
||||||
|
|
||||||
# --- output ---
|
# --- output ---
|
||||||
|
|
||||||
@@ -646,9 +678,18 @@ class UIManager:
|
|||||||
pieces.pop()
|
pieces.pop()
|
||||||
|
|
||||||
new_lines = []
|
new_lines = []
|
||||||
|
continued_delta = 0
|
||||||
for piece in pieces:
|
for piece in pieces:
|
||||||
if self.line_open:
|
if self.line_open:
|
||||||
self.output_lines[-1] += piece
|
if not self.too_small and self.scroll_offset > 0:
|
||||||
|
# The continued line can gain wrapped rows; count them so a
|
||||||
|
# scrolled-back view stays still
|
||||||
|
_, width = self.output_window.getmaxyx()
|
||||||
|
rows_before = len(wrap_colored(self.output_lines[-1], width))
|
||||||
|
self.output_lines[-1] += piece
|
||||||
|
continued_delta += len(wrap_colored(self.output_lines[-1], width)) - rows_before
|
||||||
|
else:
|
||||||
|
self.output_lines[-1] += piece
|
||||||
self.line_open = False
|
self.line_open = False
|
||||||
else:
|
else:
|
||||||
self.output_lines.append(piece)
|
self.output_lines.append(piece)
|
||||||
@@ -663,6 +704,7 @@ class UIManager:
|
|||||||
_, width = self.output_window.getmaxyx()
|
_, width = self.output_window.getmaxyx()
|
||||||
for line in new_lines:
|
for line in new_lines:
|
||||||
self.scroll_offset += len(wrap_colored(line, width))
|
self.scroll_offset += len(wrap_colored(line, width))
|
||||||
|
self.scroll_offset += continued_delta
|
||||||
|
|
||||||
self._draw_output()
|
self._draw_output()
|
||||||
self._draw_divider()
|
self._draw_divider()
|
||||||
@@ -858,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")
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
@@ -34,6 +34,26 @@ DISCONNECT_PATTERN = re.compile(r'^(.+?)\s+disconnected')
|
|||||||
KICK_PATTERN = re.compile(r'^(.+?)\s+was kicked')
|
KICK_PATTERN = re.compile(r'^(.+?)\s+was kicked')
|
||||||
INACTIVITY_PATTERN = re.compile(r'^(.+?)\s+Dropped due to inactivity')
|
INACTIVITY_PATTERN = re.compile(r'^(.+?)\s+Dropped due to inactivity')
|
||||||
RENAME_PATTERN = re.compile(r'^(.+?)\s+renamed to\s+(.+?)$')
|
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+([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)'
|
||||||
|
r'|game\s+has\s+been\s+forfeited'
|
||||||
|
r'|wins\s+the\s+round)', re.IGNORECASE)
|
||||||
|
ENGINE_OUTPUT_PREFIXES = (
|
||||||
|
'warning:', 'server:', 'error:', 'fatal:', 'livestats',
|
||||||
|
'gamename:', 'gamedate:', 'protocol:', 'cheats:', 'sv_tags:', 'client ',
|
||||||
|
)
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logger = logging.getLogger('main')
|
logger = logging.getLogger('main')
|
||||||
@@ -54,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
|
||||||
|
|
||||||
@@ -84,29 +114,225 @@ 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).
|
||||||
"""
|
"""
|
||||||
# Suppress command echo lines
|
match = CVAR_RESPONSE_PATTERN.search(message)
|
||||||
if message.startswith('zmq RCON command') and 'from' in message:
|
if not match:
|
||||||
suppress_cmds = ['status', 'roundlimit', 'qlx_serverBrandName', 'g_factoryTitle',
|
return False
|
||||||
'mapname', 'timelimit', 'fraglimit', 'capturelimit', 'sv_maxclients']
|
|
||||||
if any(f': {cmd}' in message for cmd in suppress_cmds):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Parse cvar responses (format: "cvar_name" is:"value" default:...)
|
cvar_name = match.group(1)
|
||||||
cvar_match = CVAR_RESPONSE_PATTERN.search(message)
|
value = match.group(2)
|
||||||
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):
|
state_updated = game_state.server_info.update_from_cvar(cvar_name, value)
|
||||||
ui.update_server_info(game_state)
|
if state_updated:
|
||||||
return True
|
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"""
|
||||||
|
return message.startswith('zmq RCON command')
|
||||||
|
|
||||||
|
|
||||||
|
def is_command_output(message):
|
||||||
|
"""
|
||||||
|
Detect console output produced by RCON commands: cvar dumps and status
|
||||||
|
tables. These are hidden; the 'zmq RCON command' echo lines still show
|
||||||
|
which client ran what. Shape checks run on color-stripped text because
|
||||||
|
the server sprinkles Quake color codes into status rows. Rows also arrive
|
||||||
|
broadcast-wrapped or split across several messages, so the checks are
|
||||||
|
containment-based and a row fragment (head without steamid, tail without
|
||||||
|
row prefix) is caught too. JSON stats events are exempt: they contain
|
||||||
|
16+ digit STEAM_IDs and are handled by the event parser.
|
||||||
|
"""
|
||||||
|
clean = strip_color_codes(message)
|
||||||
|
stripped = clean.lstrip()
|
||||||
|
if stripped[:1] in ('{', '['):
|
||||||
|
return False
|
||||||
|
if CVAR_RESPONSE_PATTERN.search(clean):
|
||||||
|
return True
|
||||||
|
if 'num score ping' in clean or '--- -----' in clean:
|
||||||
|
return True
|
||||||
|
if STEAMID_PATTERN.search(clean) or STATUS_HEAD_PATTERN.search(clean):
|
||||||
|
return True
|
||||||
|
if STATUS_MAP_PATTERN.search(clean):
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_engine_output(message):
|
||||||
|
"""Engine/banner prints share the chat shape but are never chat
|
||||||
|
(they would surface as fake chat from players named WARNING or Server)"""
|
||||||
|
clean = strip_color_codes(message).lstrip()
|
||||||
|
return clean.lower().startswith(ENGINE_OUTPUT_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
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. 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()
|
||||||
|
if not clean.lower().startswith('livestats'):
|
||||||
|
return False
|
||||||
|
match = LIVESTATS_PATTERN.match(clean)
|
||||||
|
if match:
|
||||||
|
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')
|
||||||
|
ui.print_message(f"^3[^7{timestamp}^3]^7 {message.rstrip()}\n")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def handle_vote_broadcast(message, game_state, ui):
|
||||||
|
"""
|
||||||
|
Track vote lifecycle broadcasts and display them verbatim (color codes
|
||||||
|
intact), annotating the outcome with the cast subject when every cast
|
||||||
|
agreed. Returns True when the message is a vote line and has been
|
||||||
|
handled; chat-shaped lines are never vote events.
|
||||||
|
"""
|
||||||
|
clean = strip_color_codes(message).strip()
|
||||||
|
if not clean or ROSTER_CHAT_GUARD_PATTERN.match(clean):
|
||||||
|
return False
|
||||||
|
tracker = game_state.vote_tracker
|
||||||
|
timestamp = time.strftime('%H:%M:%S')
|
||||||
|
if clean.endswith('called a vote.'):
|
||||||
|
tracker.on_call()
|
||||||
|
ui.print_message(f"^3[^7{timestamp}^3]^7 {message.rstrip()}\n")
|
||||||
|
return True
|
||||||
|
cast = VOTE_CAST_PATTERN.match(clean)
|
||||||
|
if cast:
|
||||||
|
tracker.on_cast(cast.group(2))
|
||||||
|
ui.print_message(f"^3[^7{timestamp}^3]^7 {message.rstrip()}\n")
|
||||||
|
return True
|
||||||
|
if clean in ('Vote passed.', 'Vote failed.'):
|
||||||
|
line = message.rstrip()
|
||||||
|
subject = tracker.result_annotation()
|
||||||
|
if subject:
|
||||||
|
line = f"{line} — {subject}"
|
||||||
|
ui.print_message(f"^3[^7{timestamp}^3]^7 {line}\n")
|
||||||
|
tracker.on_call()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_real_content(message):
|
||||||
|
"""
|
||||||
|
True for messages that are definitely not status table debris: JSON,
|
||||||
|
chat-shaped lines and game initialization. Used to end the status
|
||||||
|
suppression window early; everything else inside the window is treated
|
||||||
|
as table debris, because the server splits rows into arbitrary chunks
|
||||||
|
(names, flag columns, '0 16384', bare steamids) that no shape check
|
||||||
|
can distinguish from real content. The status table's own 'map:' line
|
||||||
|
is chat-shaped and explicitly excluded, or it would end the window
|
||||||
|
on the first chunk.
|
||||||
|
"""
|
||||||
|
clean = strip_color_codes(message).strip()
|
||||||
|
if not clean:
|
||||||
|
return False
|
||||||
|
if clean[:1] in ('{', '['):
|
||||||
|
return True
|
||||||
|
if clean.startswith('zmq'):
|
||||||
|
return False
|
||||||
|
if clean.startswith('map:'):
|
||||||
|
return False
|
||||||
|
if clean.startswith('broadcast:'):
|
||||||
|
clean = clean[10:].lstrip()
|
||||||
|
if clean.startswith('print "'):
|
||||||
|
clean = clean[7:]
|
||||||
|
if clean.endswith('"'):
|
||||||
|
clean = clean[:-1]
|
||||||
|
if 'Game Initialization' in clean:
|
||||||
|
return True
|
||||||
|
if GAME_END_PATTERN.search(clean):
|
||||||
|
return True
|
||||||
|
if CHAT_SHAPE_PATTERN.match(clean):
|
||||||
|
return True
|
||||||
|
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
|
||||||
@@ -147,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"""
|
||||||
@@ -162,12 +400,10 @@ def handle_stats_stream(stats_conn, stats_check_counter, event_parser, ui, game_
|
|||||||
if stats_msg:
|
if stats_msg:
|
||||||
logger.info(f'Stats event received ({len(stats_msg)} bytes)')
|
logger.info(f'Stats event received ({len(stats_msg)} bytes)')
|
||||||
|
|
||||||
# Parse game event
|
# Parse game event (parse_event returns a display-ready string)
|
||||||
parsed = event_parser.parse_event(stats_msg)
|
parsed = event_parser.parse_event(stats_msg)
|
||||||
if parsed:
|
if parsed:
|
||||||
# Format with timestamp before displaying
|
ui.print_message(parsed)
|
||||||
formatted_msg, attributes = format_message(parsed)
|
|
||||||
ui.print_message(formatted_msg)
|
|
||||||
ui.update_server_info(game_state)
|
ui.update_server_info(game_state)
|
||||||
|
|
||||||
return stats_check_counter
|
return stats_check_counter
|
||||||
@@ -220,6 +456,12 @@ def parse_player_events(message, game_state, ui):
|
|||||||
# Strip color codes for matching
|
# Strip color codes for matching
|
||||||
clean_msg = strip_color_codes(msg)
|
clean_msg = strip_color_codes(msg)
|
||||||
|
|
||||||
|
# Roster events are only roster events when the line is not chat-shaped:
|
||||||
|
# "Foo: I just connected my router" must not spawn a phantom join for
|
||||||
|
# a player named "Foo: I just"
|
||||||
|
if ROSTER_CHAT_GUARD_PATTERN.match(clean_msg):
|
||||||
|
return False
|
||||||
|
|
||||||
# Match connects: "NAME connected" or "NAME connected with Steam ID"
|
# Match connects: "NAME connected" or "NAME connected with Steam ID"
|
||||||
connect_match = CONNECT_PATTERN.match(clean_msg)
|
connect_match = CONNECT_PATTERN.match(clean_msg)
|
||||||
if connect_match:
|
if connect_match:
|
||||||
@@ -342,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)
|
||||||
@@ -363,7 +606,8 @@ def main_loop(screen, args):
|
|||||||
|
|
||||||
# Initialize network connections
|
# Initialize network connections
|
||||||
password_display = '***' if args.password else '(none)'
|
password_display = '***' if args.password else '(none)'
|
||||||
ui.print_message(f"Connecting with host={args.host} password={password_display}\n")
|
timestamp = time.strftime('%H:%M:%S')
|
||||||
|
ui.print_message(f"^3[^7{timestamp}^3]^7 Connecting with host={args.host} password={password_display}\n")
|
||||||
rcon = RconConnection(args.host, args.password, args.identity)
|
rcon = RconConnection(args.host, args.password, args.identity)
|
||||||
rcon.connect()
|
rcon.connect()
|
||||||
|
|
||||||
@@ -377,6 +621,7 @@ def main_loop(screen, args):
|
|||||||
stats_port = None
|
stats_port = None
|
||||||
stats_password = None
|
stats_password = None
|
||||||
stats_check_counter = 0
|
stats_check_counter = 0
|
||||||
|
status_suppress_until = 0.0
|
||||||
|
|
||||||
# Timer refresh tracking (update UI once per second)
|
# Timer refresh tracking (update UI once per second)
|
||||||
last_ui_update = 0
|
last_ui_update = 0
|
||||||
@@ -405,17 +650,24 @@ def main_loop(screen, args):
|
|||||||
# Check monitor for connection events
|
# Check monitor for connection events
|
||||||
monitor_event = rcon.check_monitor()
|
monitor_event = rcon.check_monitor()
|
||||||
if monitor_event and monitor_event[0] == zmq.EVENT_CONNECTED:
|
if monitor_event and monitor_event[0] == zmq.EVENT_CONNECTED:
|
||||||
ui.print_message("Connected to server\n")
|
timestamp = time.strftime('%H:%M:%S')
|
||||||
|
ui.print_message(f"^3[^7{timestamp}^3]^7 Connected to server ({monitor_event[2]}) "
|
||||||
|
f"as rcon client {rcon.identity}\n")
|
||||||
rcon.send_command(b'register')
|
rcon.send_command(b'register')
|
||||||
logger.info('Registration message sent')
|
logger.info('Registration message sent')
|
||||||
|
|
||||||
ui.print_message("Requesting connection info...\n")
|
timestamp = time.strftime('%H:%M:%S')
|
||||||
|
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')
|
||||||
name_capture.request(rcon)
|
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:
|
elif monitor_event and monitor_event[0] == zmq.EVENT_DISCONNECTED:
|
||||||
timestamp = time.strftime('%H:%M:%S')
|
timestamp = time.strftime('%H:%M:%S')
|
||||||
ui.print_message(f"^3[^7{timestamp}^3] ^8^1Disconnected from server - waiting for reconnect...^0^7\n")
|
ui.print_message(f"^3[^7{timestamp}^3] ^8^1Disconnected from server ({monitor_event[2]}) "
|
||||||
|
f"as rcon client {rcon.identity} - waiting for reconnect...^0^7\n")
|
||||||
if stats_conn is not None:
|
if stats_conn is not None:
|
||||||
poller.unregister(stats_conn.socket)
|
poller.unregister(stats_conn.socket)
|
||||||
stats_conn.close()
|
stats_conn.close()
|
||||||
@@ -456,6 +708,35 @@ def main_loop(screen, args):
|
|||||||
if parse_player_events(message, game_state, ui):
|
if parse_player_events(message, game_state, ui):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# LIVESTATS prints feed live score/damage/ping; the raw
|
||||||
|
# line only displays at -v or higher verbosity
|
||||||
|
if handle_livestats(message, game_state, ui, args.verbose):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Vote lifecycle broadcasts are tracked and shown verbatim
|
||||||
|
if handle_vote_broadcast(message, game_state, ui):
|
||||||
|
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):
|
||||||
|
if message.rstrip().endswith(': status'):
|
||||||
|
status_suppress_until = time.time() + STATUS_OUTPUT_WINDOW
|
||||||
|
if args.verbose == 0:
|
||||||
|
logger.debug('Suppressed RCON command echo')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Suppress everything after a 'status' echo until real
|
||||||
|
# content arrives (chat, JSON, game init) or the window closes
|
||||||
|
if status_suppress_until:
|
||||||
|
if time.time() >= status_suppress_until:
|
||||||
|
status_suppress_until = 0.0
|
||||||
|
elif is_real_content(message):
|
||||||
|
status_suppress_until = 0.0
|
||||||
|
else:
|
||||||
|
logger.debug('Suppressed status output chunk')
|
||||||
|
continue
|
||||||
|
|
||||||
if 'Game Initialization' in message:
|
if 'Game Initialization' in message:
|
||||||
logger.info('Game initialization detected - refreshing server info')
|
logger.info('Game initialization detected - refreshing server info')
|
||||||
|
|
||||||
@@ -476,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
|
||||||
@@ -493,14 +776,16 @@ def main_loop(screen, args):
|
|||||||
# Connect to stats if we have both credentials
|
# Connect to stats if we have both credentials
|
||||||
if stats_port and stats_password and stats_conn is None:
|
if stats_port and stats_password and stats_conn is None:
|
||||||
try:
|
try:
|
||||||
ui.print_message("Connecting to stats stream...\n")
|
timestamp = time.strftime('%H:%M:%S')
|
||||||
|
ui.print_message(f"^3[^7{timestamp}^3]^7 Connecting to stats stream...\n")
|
||||||
host_ip = args.host.split('//')[1].split(':')[0]
|
host_ip = args.host.split('//')[1].split(':')[0]
|
||||||
|
|
||||||
stats_conn = StatsConnection(host_ip, stats_port, stats_password)
|
stats_conn = StatsConnection(host_ip, stats_port, stats_password)
|
||||||
stats_conn.connect()
|
stats_conn.connect()
|
||||||
poller.register(stats_conn.socket, zmq.POLLIN)
|
poller.register(stats_conn.socket, zmq.POLLIN)
|
||||||
|
|
||||||
ui.print_message("Stats stream connected - ready for game events\n")
|
timestamp = time.strftime('%H:%M:%S')
|
||||||
|
ui.print_message(f"^3[^7{timestamp}^3]^7 Stats stream connected - ready for game events\n")
|
||||||
|
|
||||||
# Request initial server info
|
# Request initial server info
|
||||||
logger.info('Sending initial server info queries')
|
logger.info('Sending initial server info queries')
|
||||||
@@ -521,6 +806,21 @@ 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)
|
||||||
|
if is_command_output(message):
|
||||||
|
logger.debug('Suppressed RCON command output')
|
||||||
|
continue
|
||||||
|
|
||||||
# Hide the cvarlist/cmdlist output while learning names from it.
|
# Hide the cvarlist/cmdlist output while learning names from it.
|
||||||
# Runs after the cvar handling above so the stats credentials and
|
# Runs after the cvar handling above so the stats credentials and
|
||||||
# server info responses are never swallowed.
|
# server info responses are never swallowed.
|
||||||
@@ -552,12 +852,13 @@ def main_loop(screen, args):
|
|||||||
logger.debug(f'Filtered bot debug: {message[:50]}')
|
logger.debug(f'Filtered bot debug: {message[:50]}')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if it's a chat message
|
# Check if it's a chat message; engine output shares the shape
|
||||||
if ':' in message and not message.startswith(('print', 'broadcast', 'zmq')):
|
if (':' in message and not message.startswith(('print', 'broadcast', 'zmq'))
|
||||||
|
and not is_engine_output(message)):
|
||||||
message = format_chat_message(message, game_state.player_tracker)
|
message = format_chat_message(message, game_state.player_tracker)
|
||||||
|
|
||||||
# Format and display message
|
# Format and display message
|
||||||
formatted_msg, attributes = format_message(message)
|
formatted_msg, _ = format_message(message)
|
||||||
ui.print_message(formatted_msg)
|
ui.print_message(formatted_msg)
|
||||||
|
|
||||||
# Update server info panel every second (for live timer display)
|
# Update server info panel every second (for live timer display)
|
||||||
@@ -609,6 +910,7 @@ if __name__ == '__main__':
|
|||||||
# Apply config settings to args
|
# Apply config settings to args
|
||||||
args.config_log_level = config.get_log_level()
|
args.config_log_level = config.get_log_level()
|
||||||
args.max_history = config.get_max_history(MAX_COMMAND_HISTORY)
|
args.max_history = config.get_max_history(MAX_COMMAND_HISTORY)
|
||||||
|
args.learn_names = config.get_bool('behavior', 'learn_names', False)
|
||||||
|
|
||||||
# Handle --list
|
# Handle --list
|
||||||
if args.list:
|
if args.list:
|
||||||
|
|||||||
@@ -44,5 +44,9 @@ max_history = 10
|
|||||||
[behavior]
|
[behavior]
|
||||||
# Seconds to confirm quit (press Ctrl-C twice within this window)
|
# Seconds to confirm quit (press Ctrl-C twice within this window)
|
||||||
quit_timeout = 3.0
|
quit_timeout = 3.0
|
||||||
|
# Learn extra cvar/command names for autocomplete from the server.
|
||||||
|
# Sends cvarlist + cmdlist on connect: a large burst of messages that can
|
||||||
|
# lag the game server. Off by default; the built-in names always work.
|
||||||
|
learn_names = false
|
||||||
# Seconds before players respawn after death
|
# Seconds before players respawn after death
|
||||||
respawn_delay = 3.0
|
respawn_delay = 3.0
|
||||||
|
|||||||
Reference in New Issue
Block a user