Compare commits
9
Commits
b69cc38cce
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec64804fec | ||
|
|
c1b5f6d86a | ||
|
|
cbeab1ae45 | ||
|
|
016384ef12 | ||
|
|
f8a71e7906 | ||
|
|
c636557849 | ||
|
|
43758f980c | ||
|
|
c9487b954a | ||
|
|
c5e0bc3f01 |
@@ -19,6 +19,8 @@ POLL_TIMEOUT = 100
|
||||
QUIT_CONFIRM_TIMEOUT = 3.0 # Seconds to confirm quit (Ctrl-C twice)
|
||||
RESPAWN_DELAY = 3.0 # Seconds before players respawn after death
|
||||
STATUS_OUTPUT_WINDOW = 1.5 # Seconds a 'status' command echo suppresses its chunked table output
|
||||
STATUS_SEED_WINDOW = 2.0 # Seconds the roster seeder collects the 'status' dump after connect
|
||||
CVAR_RESPONSE_TTL = 10.0 # Seconds a user-queried cvar response may arrive after the query
|
||||
STATS_CONNECTION_DELAY = 0.5 # Initial stats connection delay
|
||||
|
||||
# UI dimensions
|
||||
@@ -55,6 +57,12 @@ TEAM_MAP = {
|
||||
3: 'SPECTATOR'
|
||||
}
|
||||
|
||||
# GAME_TYPE codes from MATCH_STARTED that are team-based; matching on the code
|
||||
# is immune to customized factory titles (g_factoryTitle)
|
||||
TEAM_GAME_TYPE_CODES = {
|
||||
'TDM', 'CA', 'CTF', '1FLAG', 'HARVESTER', 'FT', 'DOM', 'RR'
|
||||
}
|
||||
|
||||
TEAM_COLORS = {
|
||||
'RED': '^1(RED)^7',
|
||||
'BLUE': '^4(BLUE)^7',
|
||||
|
||||
+147
-35
@@ -32,6 +32,15 @@ class EventParser:
|
||||
self.json_logger = json_logger
|
||||
self.unknown_logger = unknown_logger
|
||||
|
||||
def _sync_match_time(self, data):
|
||||
"""Sync the live match timer; a malformed TIME value must not discard
|
||||
the whole event (the sync runs inside the handler try blocks)"""
|
||||
try:
|
||||
self.game_state.server_info.match_time = int(data['TIME'])
|
||||
self.game_state.server_info.match_time_last_sync = time.time()
|
||||
except (TypeError, ValueError):
|
||||
logger.debug(f'Malformed TIME value: {data.get("TIME")!r}')
|
||||
|
||||
def parse_event(self, message):
|
||||
"""
|
||||
Parse JSON event and return formatted message string
|
||||
@@ -65,8 +74,8 @@ class EventParser:
|
||||
'MATCH_STARTED': self._handle_match_started,
|
||||
'MATCH_REPORT': self._handle_match_report,
|
||||
'PLAYER_STATS': self._handle_player_stats,
|
||||
'PLAYER_CONNECT': lambda d: None,
|
||||
'PLAYER_DISCONNECT': lambda d: None,
|
||||
'PLAYER_CONNECT': self._handle_player_connect,
|
||||
'PLAYER_DISCONNECT': self._handle_player_disconnect,
|
||||
'ROUND_OVER': self._handle_round_over,
|
||||
}
|
||||
|
||||
@@ -92,13 +101,34 @@ class EventParser:
|
||||
logger.debug(f'Error parsing event: {e}')
|
||||
return None
|
||||
|
||||
def _handle_player_connect(self, data):
|
||||
"""Track a connecting player so the roster is complete; connects to the
|
||||
server land as spectators until they join a team — except reconnects of
|
||||
already-tracked players, whose known team is kept. No feed line: the
|
||||
RCON connect broadcast already displays one."""
|
||||
name = data.get('NAME')
|
||||
if not name:
|
||||
return None
|
||||
team = data.get('TEAM')
|
||||
if team is None:
|
||||
team = self.game_state.player_tracker.get_team(name) or 'SPECTATOR'
|
||||
self.game_state.player_tracker.update_team(name, team)
|
||||
self.game_state.player_tracker.add_player(name)
|
||||
return None
|
||||
|
||||
def _handle_player_disconnect(self, data):
|
||||
"""Remove a disconnecting player from the roster. No feed line: the
|
||||
RCON disconnect broadcast already displays one."""
|
||||
name = data.get('NAME')
|
||||
if name:
|
||||
self.game_state.player_tracker.remove_player(name)
|
||||
return None
|
||||
|
||||
def _handle_switchteam(self, data):
|
||||
"""Handle PLAYER_SWITCHTEAM event"""
|
||||
|
||||
# Get Match Time
|
||||
if 'TIME' in data:
|
||||
self.game_state.server_info.match_time = int(data['TIME'])
|
||||
self.game_state.server_info.match_time_last_sync = time.time()
|
||||
self._sync_match_time(data)
|
||||
|
||||
if 'KILLER' not in data:
|
||||
return None
|
||||
@@ -137,13 +167,39 @@ class EventParser:
|
||||
team_prefix = get_team_prefix(name, self.game_state.player_tracker)
|
||||
return f"^8^5[SWITCH]^7 {team_prefix}^8{name}^0{team_msg} from {old_team_msg}{warmup}\n"
|
||||
|
||||
def _opposite_team(self, team):
|
||||
"""The other active team in a two-team mode; None when not applicable"""
|
||||
return {1: 2, 2: 1, 'RED': 'BLUE', 'BLUE': 'RED'}.get(team)
|
||||
|
||||
def _resolve_event_teams(self, data, killer, victim, killer_name, victim_name):
|
||||
"""
|
||||
Resolve (killer_team, victim_team) for a kill/death event. Event TEAM
|
||||
fields win; when a server omits them (some do during warmup), the
|
||||
missing side is derived from the counterpart player and the
|
||||
TEAMKILL/SUICIDE flags. Solo modes always resolve to FREE — every
|
||||
participant is FREE by definition. None means unknowable from this
|
||||
event (leave the tracked team untouched).
|
||||
"""
|
||||
if not self.game_state.server_info.is_team_mode():
|
||||
return ('FREE', 'FREE')
|
||||
|
||||
killer_team = killer.get('TEAM') if killer else None
|
||||
victim_team = victim.get('TEAM') if victim else None
|
||||
same_team = (bool(data.get('SUICIDE', False))
|
||||
or strip_color_codes(killer_name).strip() == strip_color_codes(victim_name).strip()
|
||||
or bool(data.get('TEAMKILL', False)))
|
||||
|
||||
if killer_team is not None and victim_team is None:
|
||||
victim_team = killer_team if same_team else self._opposite_team(killer_team)
|
||||
elif victim_team is not None and killer_team is None:
|
||||
killer_team = victim_team if same_team else self._opposite_team(victim_team)
|
||||
return (killer_team, victim_team)
|
||||
|
||||
def _handle_death(self, data):
|
||||
"""Handle PLAYER_DEATH and PLAYER_KILL events"""
|
||||
|
||||
# Get Match Time
|
||||
if 'TIME' in data:
|
||||
self.game_state.server_info.match_time = int(data['TIME'])
|
||||
self.game_state.server_info.match_time_last_sync = time.time()
|
||||
self._sync_match_time(data)
|
||||
|
||||
if 'VICTIM' not in data:
|
||||
return None
|
||||
@@ -162,6 +218,11 @@ class EventParser:
|
||||
if 'TEAM' in victim:
|
||||
self.game_state.player_tracker.update_team(victim_name, victim['TEAM'])
|
||||
self.game_state.player_tracker.add_player(victim_name)
|
||||
elif not self.game_state.server_info.is_team_mode():
|
||||
# Solo modes: every participant is FREE by definition, even when
|
||||
# the event carries no TEAM field
|
||||
self.game_state.player_tracker.update_team(victim_name, 'FREE')
|
||||
self.game_state.player_tracker.add_player(victim_name)
|
||||
|
||||
# Mark as dead
|
||||
if not data.get('WARMUP', False):
|
||||
@@ -196,10 +257,17 @@ class EventParser:
|
||||
killer = data['KILLER']
|
||||
killer_name = killer.get('NAME', 'Unknown')
|
||||
|
||||
# Update killer team
|
||||
if 'TEAM' in killer:
|
||||
self.game_state.player_tracker.update_team(killer_name, killer['TEAM'])
|
||||
self.game_state.player_tracker.add_player(killer_name)
|
||||
# Team resolution with inference: prefer the event's TEAM fields; when
|
||||
# a server omits them (some do during warmup), derive the missing side
|
||||
# from the counterpart and the TEAMKILL/SUICIDE flags
|
||||
killer_team, victim_team = self._resolve_event_teams(
|
||||
data, killer, victim, killer_name, victim_name)
|
||||
if killer_team is not None:
|
||||
self.game_state.player_tracker.update_team(killer_name, killer_team)
|
||||
if victim_team is not None:
|
||||
self.game_state.player_tracker.update_team(victim_name, victim_team)
|
||||
self.game_state.player_tracker.add_player(killer_name)
|
||||
self.game_state.player_tracker.add_player(victim_name)
|
||||
|
||||
killer_prefix = get_team_prefix(killer_name, self.game_state.player_tracker)
|
||||
|
||||
@@ -261,9 +329,7 @@ class EventParser:
|
||||
"""Handle ROUND_OVER events for CA"""
|
||||
|
||||
# Get Match Time
|
||||
if 'TIME' in data:
|
||||
self.game_state.server_info.match_time = int(data['TIME'])
|
||||
self.game_state.server_info.match_time_last_sync = time.time()
|
||||
self._sync_match_time(data)
|
||||
|
||||
team_won = data.get('TEAM_WON')
|
||||
round_num = data.get('ROUND', 0)
|
||||
@@ -283,9 +349,7 @@ class EventParser:
|
||||
"""Handle PLAYER_MEDAL event"""
|
||||
|
||||
# Get Match Time
|
||||
if 'TIME' in data:
|
||||
self.game_state.server_info.match_time = int(data['TIME'])
|
||||
self.game_state.server_info.match_time_last_sync = time.time()
|
||||
self._sync_match_time(data)
|
||||
|
||||
name = data.get('NAME', 'Unknown')
|
||||
medal = data.get('MEDAL', 'UNKNOWN')
|
||||
@@ -318,18 +382,41 @@ class EventParser:
|
||||
"""Handle MATCH_STARTED event"""
|
||||
|
||||
# Get Match Time
|
||||
if 'TIME' in data:
|
||||
self.game_state.server_info.match_time = int(data['TIME'])
|
||||
self.game_state.server_info.match_time_last_sync = time.time()
|
||||
self._sync_match_time(data)
|
||||
|
||||
# GAME_TYPE/FACTORY_TITLE are the authoritative gametype for this match;
|
||||
# the title alone can be customized and defeat exact team-mode matching
|
||||
self.game_state.server_info.game_type_code = data.get('GAME_TYPE', '')
|
||||
if data.get('FACTORY_TITLE'):
|
||||
self.game_state.server_info.gametype = data['FACTORY_TITLE']
|
||||
|
||||
# The PLAYERS array is the authoritative roster at match start: sync
|
||||
# teams (numeric codes via TEAM_MAP) and reset scores, clear dead
|
||||
# markers, and drop stale dedup signatures (TIME restarts at 0)
|
||||
players = []
|
||||
for player in data.get('PLAYERS', []):
|
||||
name = player.get('NAME')
|
||||
if not name:
|
||||
continue
|
||||
players.append(name)
|
||||
team = player.get('TEAM')
|
||||
if team is None:
|
||||
team = 'SPECTATOR'
|
||||
self.game_state.player_tracker.update_team(name, team)
|
||||
self.game_state.player_tracker.add_player(name, score='0', ping='0')
|
||||
self.game_state.player_tracker.set_score(name, '0')
|
||||
# The array is the complete match roster: drop tracked players absent
|
||||
# from it (they left before the match started)
|
||||
roster_names = {strip_color_codes(name) for name in players}
|
||||
for tracked in list(self.game_state.server_info.players.keys()):
|
||||
if strip_color_codes(tracked) not in roster_names:
|
||||
self.game_state.player_tracker.remove_player(tracked)
|
||||
self.game_state.server_info.dead_players.clear()
|
||||
self.game_state.event_deduplicator.reset()
|
||||
|
||||
if self.game_state.server_info.is_team_mode():
|
||||
return f"^8^2[GAME ON]^0 ^7Match has started - ^1^8RED ^0^7vs. ^4^8BLUE\n"
|
||||
|
||||
players = []
|
||||
for player in data.get('PLAYERS', []):
|
||||
name = player.get('NAME', 'Unknown')
|
||||
players.append(name)
|
||||
|
||||
if players:
|
||||
formatted = "^0 vs. ^8".join(players)
|
||||
return f"^8^2[GAME ON]^0 ^7Match has started - ^8^7{formatted}\n"
|
||||
@@ -340,12 +427,7 @@ class EventParser:
|
||||
"""Handle MATCH_REPORT event"""
|
||||
|
||||
# Get Match Time
|
||||
if 'TIME' in data:
|
||||
self.game_state.server_info.match_time = int(data['TIME'])
|
||||
self.game_state.server_info.match_time_last_sync = time.time()
|
||||
|
||||
if not self.game_state.server_info.is_team_mode():
|
||||
return None
|
||||
self._sync_match_time(data)
|
||||
|
||||
# Restarted matches (server shutdown mid-game) have no winner; aborts
|
||||
# and forfeits still have final scores that name one
|
||||
@@ -355,16 +437,44 @@ class EventParser:
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
# Final standings from the per-player STATS array
|
||||
stats_rows = []
|
||||
for entry in data.get('STATS') or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
entry_name = entry.get('NAME')
|
||||
if not entry_name:
|
||||
continue
|
||||
try:
|
||||
entry_score = int(entry.get('SCORE', 0))
|
||||
except (TypeError, ValueError):
|
||||
entry_score = 0
|
||||
stats_rows.append((strip_color_codes(entry_name), entry_score,
|
||||
entry.get('KILLS', '?'), entry.get('DEATHS', '?')))
|
||||
stats_rows.sort(key=lambda x: x[1], reverse=True)
|
||||
scoreboard = ''
|
||||
if stats_rows:
|
||||
formatted = ' '.join(f"{name} {score} ({kills}/{deaths})"
|
||||
for name, score, kills, deaths in stats_rows[:5])
|
||||
scoreboard = f"^8^1[SCORES]^7 {formatted}\n"
|
||||
|
||||
if not self.game_state.server_info.is_team_mode():
|
||||
return scoreboard or None
|
||||
|
||||
red_score = int(data.get('TSCORE0', '0'))
|
||||
blue_score = int(data.get('TSCORE1', '0'))
|
||||
report_prefix = "^8^1[GAME OVER]"
|
||||
|
||||
if red_score > blue_score:
|
||||
return f"{report_prefix} ^7The ^1RED TEAM ^7WINS^0 by a score of ^8^1{red_score} ^0^7to ^8^4{blue_score}\n"
|
||||
winner = f"{report_prefix} ^7The ^1RED TEAM ^7WINS^0 by a score of ^8^1{red_score} ^0^7to ^8^4{blue_score}\n"
|
||||
elif blue_score > red_score:
|
||||
return f"{report_prefix} ^7The ^4BLUE TEAM ^7WINS^0 by a score of ^8^4{blue_score} ^0^7to ^8^1{red_score}\n"
|
||||
winner = f"{report_prefix} ^7The ^4BLUE TEAM ^7WINS^0 by a score of ^8^4{blue_score} ^0^7to ^8^1{red_score}\n"
|
||||
else:
|
||||
return f"{report_prefix} ^7The match is a TIE^0 with a score of ^8^1{red_score} ^0^7to ^8^4{blue_score}\n"
|
||||
winner = f"{report_prefix} ^7The match is a TIE^0 with a score of ^8^1{red_score} ^0^7to ^8^4{blue_score}\n"
|
||||
|
||||
if scoreboard:
|
||||
winner = f"{winner.rstrip(chr(10))}\n{scoreboard}"
|
||||
return winner
|
||||
|
||||
def _handle_player_stats(self, data):
|
||||
"""Handle PLAYER_STATS event"""
|
||||
@@ -378,6 +488,8 @@ class EventParser:
|
||||
# in CA it is damage-based, so sync it instead of accumulating deltas
|
||||
if data.get('SCORE') is not None:
|
||||
self.game_state.player_tracker.set_score(name, data['SCORE'])
|
||||
if 'TEAM' in data:
|
||||
self.game_state.player_tracker.update_team(name, data['TEAM'])
|
||||
|
||||
weapon_data = data.get('WEAPONS', {})
|
||||
accuracies = calculate_weapon_accuracies(weapon_data)
|
||||
|
||||
+46
-15
@@ -5,7 +5,7 @@ Tracks server info, players, and teams
|
||||
"""
|
||||
|
||||
import logging
|
||||
from .constants import TEAM_MODES, TEAM_MAP, MAX_RECENT_EVENTS
|
||||
from .constants import TEAM_MODES, TEAM_MAP, MAX_RECENT_EVENTS, TEAM_GAME_TYPE_CODES
|
||||
from .formatter import strip_color_codes
|
||||
|
||||
logger = logging.getLogger('state')
|
||||
@@ -18,6 +18,7 @@ class ServerInfo:
|
||||
self.hostname = 'Unknown'
|
||||
self.map = 'Unknown'
|
||||
self.gametype = 'Unknown'
|
||||
self.game_type_code = ''
|
||||
self.timelimit = '0'
|
||||
self.fraglimit = '0'
|
||||
self.roundlimit = '0'
|
||||
@@ -36,8 +37,9 @@ class ServerInfo:
|
||||
self.match_time_last_sync = 0 # Timestamp of last TIME update from server
|
||||
|
||||
def is_team_mode(self):
|
||||
"""Check if current gametype is a team mode"""
|
||||
return self.gametype in TEAM_MODES
|
||||
"""Check if current gametype is a team mode, by GAME_TYPE code from
|
||||
MATCH_STARTED or by factory title"""
|
||||
return self.gametype in TEAM_MODES or self.game_type_code in TEAM_GAME_TYPE_CODES
|
||||
|
||||
def reset_round_scores(self):
|
||||
"""Reset round scores (for new matches)"""
|
||||
@@ -101,14 +103,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"""
|
||||
@@ -136,16 +149,25 @@ class PlayerTracker:
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_livestats(self, clean_name, score, dealt, taken, ping):
|
||||
"""Update live score/damage/ping from a LIVESTATS console print (clean name)"""
|
||||
def update_livestats(self, clean_name, score, dealt, taken, ping, team=None):
|
||||
"""Update live score/damage/ping from a LIVESTATS console print (clean name).
|
||||
With a team token (extended livedamage format) the team is authoritative —
|
||||
it sorts tracked players and adds unknown ones to the roster, which is
|
||||
what populates teams correctly during warmup."""
|
||||
for player_name, player_data in self.server_info.players.items():
|
||||
if strip_color_codes(player_name) == clean_name:
|
||||
player_data['score'] = str(score)
|
||||
player_data['ping'] = str(ping)
|
||||
player_data['dealt'] = str(dealt)
|
||||
player_data['taken'] = str(taken)
|
||||
if team is not None:
|
||||
self.update_team(player_name, team)
|
||||
return True
|
||||
return False
|
||||
if team is None:
|
||||
return False
|
||||
self.add_player(clean_name, score=str(score), ping=str(ping))
|
||||
self.update_team(clean_name, team)
|
||||
return True
|
||||
|
||||
def remove_player(self, name):
|
||||
"""Remove player from tracking"""
|
||||
@@ -232,7 +254,12 @@ class EventDeduplicator:
|
||||
if event_type not in ('PLAYER_DEATH', 'PLAYER_KILL'):
|
||||
return False
|
||||
|
||||
signature = f"KILL:{time_val}:{killer_name}:{victim_name}"
|
||||
# Signatures use stripped names: the double-emitted PLAYER_KILL and
|
||||
# PLAYER_DEATH for one frag can carry cosmetically different name
|
||||
# strings (color codes/whitespace), which would defeat the match
|
||||
signature = (f"KILL:{time_val}:"
|
||||
f"{strip_color_codes(killer_name).strip()}:"
|
||||
f"{strip_color_codes(victim_name).strip()}")
|
||||
|
||||
if signature in self.recent_events:
|
||||
logger.debug(f'Duplicate event: {signature}')
|
||||
@@ -245,6 +272,10 @@ class EventDeduplicator:
|
||||
|
||||
return False
|
||||
|
||||
def reset(self):
|
||||
"""Clear the signature buffer (match start: TIME restarts at 0)"""
|
||||
self.recent_events = []
|
||||
|
||||
|
||||
class VoteTracker:
|
||||
"""Tracks cast subjects of the running vote; reset on each vote call"""
|
||||
|
||||
@@ -18,9 +18,10 @@ import time
|
||||
from collections import deque
|
||||
|
||||
from .constants import (INFO_WINDOW_HEIGHT, INFO_WINDOW_Y, OUTPUT_WINDOW_Y, INPUT_WINDOW_HEIGHT,
|
||||
MIN_ROWS, MIN_COLS, OUTPUT_SCROLLBACK_LINES, TEAM_MODES, MAX_COMMAND_HISTORY)
|
||||
MIN_ROWS, MIN_COLS, OUTPUT_SCROLLBACK_LINES, TEAM_MODES, MAX_COMMAND_HISTORY,
|
||||
TEAM_COLORS)
|
||||
from .cvars import autocomplete, COMMAND_SIGNATURES, get_signature_with_highlight, get_argument_suggestions, COMMAND_ARGUMENTS
|
||||
from .formatter import strip_color_codes
|
||||
from .formatter import get_team_prefix, strip_color_codes
|
||||
|
||||
logger = logging.getLogger('ui')
|
||||
|
||||
@@ -431,6 +432,33 @@ def _draw_server_menu(screen, servers, names, selected):
|
||||
screen.refresh()
|
||||
|
||||
|
||||
def format_damage_summary(server_info, player_tracker=None):
|
||||
"""One-line damage summary (top 3 by damage dealt) from LIVESTATS counters,
|
||||
tagged with the tracked team of each player (data-driven, so tags work
|
||||
during warmup too); None when no damage data exists yet. Dealt/taken are
|
||||
stored as strings."""
|
||||
entries = []
|
||||
for player_name, player_data in server_info.players.items():
|
||||
try:
|
||||
dealt = int(player_data.get('dealt', 0))
|
||||
taken = int(player_data.get('taken', 0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if dealt <= 0 and taken <= 0:
|
||||
continue
|
||||
entries.append((strip_color_codes(player_name), dealt, taken))
|
||||
if not entries:
|
||||
return None
|
||||
entries.sort(key=lambda x: x[1], reverse=True)
|
||||
parts = []
|
||||
for name, dealt, taken in entries[:3]:
|
||||
prefix = ''
|
||||
if player_tracker is not None:
|
||||
prefix = TEAM_COLORS.get(player_tracker.get_team(name), '')
|
||||
parts.append(f"{prefix}{name} {dealt}/{taken}")
|
||||
return f"^8^3DMG^7 {' '.join(parts)}\n"
|
||||
|
||||
|
||||
class UIManager:
|
||||
"""Manages curses windows and display"""
|
||||
|
||||
@@ -872,6 +900,11 @@ class UIManager:
|
||||
line = f"^8{col1}^0{' ' * col1_pad}^8{col2}^0\n"
|
||||
print_colored(self.info_window, line, 0)
|
||||
|
||||
# Damage summary from LIVESTATS counters (top 3 by damage dealt)
|
||||
damage_line = format_damage_summary(server_info, game_state.player_tracker)
|
||||
if damage_line:
|
||||
print_colored(self.info_window, damage_line, 0)
|
||||
|
||||
# Blank lines to fill
|
||||
try:
|
||||
self.info_window.addstr("\n")
|
||||
|
||||
@@ -15,7 +15,7 @@ import signal
|
||||
import sys
|
||||
import os
|
||||
|
||||
from lib.constants import VERSION, DEFAULT_HOST, POLL_TIMEOUT, QUIT_CONFIRM_TIMEOUT, RESPAWN_DELAY, MAX_COMMAND_HISTORY, STATUS_OUTPUT_WINDOW
|
||||
from lib.constants import VERSION, DEFAULT_HOST, POLL_TIMEOUT, QUIT_CONFIRM_TIMEOUT, RESPAWN_DELAY, MAX_COMMAND_HISTORY, STATUS_OUTPUT_WINDOW, STATUS_SEED_WINDOW, CVAR_RESPONSE_TTL
|
||||
from lib.state import GameState
|
||||
from lib.network import RconConnection, StatsConnection
|
||||
from lib.parser import EventParser
|
||||
@@ -37,10 +37,14 @@ RENAME_PATTERN = re.compile(r'^(.+?)\s+renamed to\s+(.+?)$')
|
||||
STATUS_HEAD_PATTERN = re.compile(r'^\s*\d+\s+-?\d+\s+(bot|\d+)\s+\S', re.MULTILINE)
|
||||
STATUS_MAP_PATTERN = re.compile(r'^map:\s+\S+\s*$', re.MULTILINE)
|
||||
STEAMID_PATTERN = re.compile(r'\b\d{16,}\b')
|
||||
LIVESTATS_PATTERN = re.compile(r'^LIVESTATS\s+(.+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s*$')
|
||||
LIVESTATS_PATTERN = re.compile(
|
||||
r'^LIVESTATS\s+(.+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)(?:\s+([A-Za-z]+|\d))?\s*$')
|
||||
STATUS_ROW_PATTERN = re.compile(
|
||||
r'^\s*(\d+)\s+(-?\d+)\s+(\d+)\s+(.+?)\s+(\d+)\s+(\S+)\s+(\d+)\s+(\d+)(?:\s+(\d{15,}))?\s*$')
|
||||
ROSTER_CHAT_GUARD_PATTERN = re.compile(r'^.{1,40}:\s')
|
||||
VOTE_CAST_PATTERN = re.compile(r'^(.+?) voted for (.+)\.$')
|
||||
CHAT_SHAPE_PATTERN = re.compile(r'^[\w(][^:]*:\s')
|
||||
CVAR_QUERY_PATTERN = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
|
||||
GAME_END_PATTERN = re.compile(
|
||||
r'((?:timelimit|fraglimit|capturelimit|roundlimit)\s+hit'
|
||||
r'|hit\s+the\s+(?:timelimit|fraglimit|capturelimit|roundlimit)'
|
||||
@@ -70,6 +74,16 @@ quit_confirm_time = None
|
||||
_QUIT_CONFIRM_TIMEOUT = QUIT_CONFIRM_TIMEOUT
|
||||
_RESPAWN_DELAY = RESPAWN_DELAY
|
||||
|
||||
# Cvars the user queried from the console (lowercased name -> request time):
|
||||
# their responses display verbatim; everything else stays suppressed
|
||||
expected_cvar_responses = {}
|
||||
|
||||
|
||||
def consume_expected_cvar(cvar_name):
|
||||
"""True when the user queried this cvar from the console recently"""
|
||||
requested_at = expected_cvar_responses.pop(cvar_name.lower(), None)
|
||||
return requested_at is not None and (time.time() - requested_at) < CVAR_RESPONSE_TTL
|
||||
|
||||
# Global shutdown flag (set by handle_ctrl_c, checked by main_loop)
|
||||
shutdown_requested = False
|
||||
|
||||
@@ -100,20 +114,27 @@ def handle_ctrl_c():
|
||||
|
||||
def parse_cvar_response(message, game_state, ui):
|
||||
"""
|
||||
Parse server cvar responses and update state
|
||||
Returns True if message should be suppressed from display
|
||||
Parse server cvar responses: update state for known cvars, and display the
|
||||
response when the user queried it from the console. Returns True when the
|
||||
message is consumed (known-cvar update or a displayed query result).
|
||||
"""
|
||||
# Parse cvar responses (format: "cvar_name" is:"value" default:...)
|
||||
cvar_match = CVAR_RESPONSE_PATTERN.search(message)
|
||||
if cvar_match:
|
||||
cvar_name = cvar_match.group(1)
|
||||
value = cvar_match.group(2)
|
||||
match = CVAR_RESPONSE_PATTERN.search(message)
|
||||
if not match:
|
||||
return False
|
||||
|
||||
if game_state.server_info.update_from_cvar(cvar_name, value):
|
||||
ui.update_server_info(game_state)
|
||||
return True
|
||||
cvar_name = match.group(1)
|
||||
value = match.group(2)
|
||||
|
||||
return False
|
||||
state_updated = game_state.server_info.update_from_cvar(cvar_name, value)
|
||||
if state_updated:
|
||||
ui.update_server_info(game_state)
|
||||
|
||||
displayed = consume_expected_cvar(cvar_name)
|
||||
if displayed:
|
||||
timestamp = time.strftime('%H:%M:%S')
|
||||
ui.print_message(f"^3[^7{timestamp}^3]^7 {message.rstrip()}\n")
|
||||
|
||||
return state_updated or displayed
|
||||
|
||||
|
||||
def is_command_echo(message):
|
||||
@@ -159,7 +180,9 @@ def handle_livestats(message, game_state, ui, verbose=0):
|
||||
Consume every LIVESTATS heartbeat line (engine damage counters emitted
|
||||
every 10s by the livedamage plugin, plus its summary variants). Lines
|
||||
matching the full player shape update live score/damage/ping; names may
|
||||
contain spaces, so the counters anchor from the end. The raw lines
|
||||
contain spaces, so the counters anchor from the end. An optional trailing
|
||||
team token (extended livedamage format) is authoritative and populates
|
||||
the roster with correct teams — including during warmup. The raw lines
|
||||
display only at -v or higher verbosity; they are never treated as chat.
|
||||
"""
|
||||
clean = strip_color_codes(message).strip()
|
||||
@@ -167,8 +190,11 @@ def handle_livestats(message, game_state, ui, verbose=0):
|
||||
return False
|
||||
match = LIVESTATS_PATTERN.match(clean)
|
||||
if match:
|
||||
name, score, dealt, taken, ping = match.groups()
|
||||
game_state.player_tracker.update_livestats(name, int(score), int(dealt), int(taken), int(ping))
|
||||
name, score, dealt, taken, ping, team = match.groups()
|
||||
if team is not None and team.isdigit():
|
||||
team = int(team)
|
||||
game_state.player_tracker.update_livestats(
|
||||
name, int(score), int(dealt), int(taken), int(ping), team)
|
||||
ui.update_server_info(game_state)
|
||||
if verbose > 0:
|
||||
timestamp = time.strftime('%H:%M:%S')
|
||||
@@ -243,6 +269,70 @@ def is_real_content(message):
|
||||
return False
|
||||
|
||||
|
||||
class StatusSeeder:
|
||||
"""
|
||||
Seeds the player roster from a 'status' command dump, sent on connect and
|
||||
after map changes. The table arrives chunked across many messages (rows
|
||||
split at arbitrary column boundaries, wrapped or raw), so raw text is
|
||||
buffered and split on newlines; the tail is flushed when the window
|
||||
closes. Rows carry no team column, so untracked names seed as SPECTATOR
|
||||
(immediately visible; active players self-correct on their first stats
|
||||
event) and known players only get their authoritative score/ping.
|
||||
All seeding output stays suppressed.
|
||||
"""
|
||||
|
||||
def __init__(self, game_state):
|
||||
self.game_state = game_state
|
||||
self.buffer = ''
|
||||
self.active_until = 0.0
|
||||
|
||||
def start(self):
|
||||
self.buffer = ''
|
||||
self.active_until = time.time() + STATUS_SEED_WINDOW
|
||||
|
||||
def is_active(self):
|
||||
if self.active_until and time.time() >= self.active_until:
|
||||
self.finish()
|
||||
return bool(self.active_until) and time.time() < self.active_until
|
||||
|
||||
def feed(self, message):
|
||||
"""Buffer one RCON message and parse every completed line"""
|
||||
text = strip_color_codes(message)
|
||||
if text.startswith('broadcast:'):
|
||||
text = text[10:].lstrip()
|
||||
if text.startswith('print "'):
|
||||
text = text[7:]
|
||||
if text.endswith('"'):
|
||||
text = text[:-1]
|
||||
self.buffer += text.replace('\\n', '\n')
|
||||
while '\n' in self.buffer:
|
||||
line, self.buffer = self.buffer.split('\n', 1)
|
||||
self._parse_line(line)
|
||||
|
||||
def finish(self):
|
||||
if self.buffer.strip():
|
||||
self._parse_line(self.buffer)
|
||||
self.buffer = ''
|
||||
|
||||
def _parse_line(self, line):
|
||||
match = STATUS_ROW_PATTERN.match(line.strip())
|
||||
if not match:
|
||||
return
|
||||
num, score, ping, name, lastmsg, address, qport, rate, steamid = match.groups()
|
||||
tracker = self.game_state.player_tracker
|
||||
clean_name = strip_color_codes(name).strip()
|
||||
if not clean_name:
|
||||
return
|
||||
player_data = self.game_state.server_info.players.get(clean_name)
|
||||
if player_data is not None:
|
||||
player_data['score'] = str(score)
|
||||
player_data['ping'] = str(ping)
|
||||
return
|
||||
tracker.add_player(clean_name, score=str(score), ping=str(ping))
|
||||
if tracker.get_team(clean_name) is None:
|
||||
tracker.update_team(clean_name, 'SPECTATOR')
|
||||
|
||||
|
||||
def handle_stats_connection(message):
|
||||
"""
|
||||
Handle stats connection info extraction
|
||||
@@ -283,6 +373,18 @@ def handle_user_input(ui, rcon):
|
||||
|
||||
rcon.send_command(command)
|
||||
|
||||
# The server does not echo rcon-originated says back on the console
|
||||
# stream, so render locally what players see in-game
|
||||
stripped = command.strip()
|
||||
if stripped.lower().startswith('say '):
|
||||
timestamp = time.strftime('%H:%M:%S')
|
||||
ui.print_message(f"^3[^7{timestamp}^3]^7 Server: {stripped[4:]}\n")
|
||||
|
||||
# A bare cvar name queries its value; the response displays verbatim
|
||||
# when it arrives (other clients' query output stays suppressed)
|
||||
if CVAR_QUERY_PATTERN.match(stripped):
|
||||
expected_cvar_responses[stripped.lower()] = time.time()
|
||||
|
||||
|
||||
def handle_stats_stream(stats_conn, stats_check_counter, event_parser, ui, game_state):
|
||||
"""Poll and process stats stream events"""
|
||||
@@ -482,6 +584,7 @@ def main_loop(screen, args):
|
||||
# Initialize components
|
||||
game_state = GameState()
|
||||
name_capture = NameCapture() # cvar/command names learned from the server for autocomplete
|
||||
status_seeder = StatusSeeder(game_state) # seeds the roster from a status dump on connect
|
||||
ui = UIManager(screen, args.title, args.max_history,
|
||||
player_names_provider=lambda: game_state.player_tracker.get_player_names(),
|
||||
learned_names_provider=lambda: name_capture.names)
|
||||
@@ -557,6 +660,8 @@ def main_loop(screen, args):
|
||||
ui.print_message(f"^3[^7{timestamp}^3]^7 Requesting connection info...\n")
|
||||
rcon.send_command(b'zmq_stats_password')
|
||||
rcon.send_command(b'net_port')
|
||||
rcon.send_command(b'status')
|
||||
status_seeder.start()
|
||||
if args.learn_names:
|
||||
name_capture.request(rcon)
|
||||
elif monitor_event and monitor_event[0] == zmq.EVENT_DISCONNECTED:
|
||||
@@ -652,6 +757,8 @@ def main_loop(screen, args):
|
||||
game_state.player_tracker.player_teams = {}
|
||||
game_state.server_info.reset_round_scores()
|
||||
game_state.server_info.dead_players.clear()
|
||||
rcon.send_command(b'status')
|
||||
status_seeder.start()
|
||||
ui.update_server_info(game_state)
|
||||
|
||||
# Try to parse as cvar response
|
||||
@@ -699,6 +806,16 @@ def main_loop(screen, args):
|
||||
ui.print_message(f"^1[^7{timestamp}^1] Error: Stats connection failed: {e}^7\n")
|
||||
logger.error(f'Stats connection failed: {e}')
|
||||
|
||||
# Seed the roster from the status dump after connect/map change.
|
||||
# Must run after the cvar/credential handlers above (their
|
||||
# responses share the window and are never table rows) and
|
||||
# before the shape-based suppressor (rows must buffer, not
|
||||
# vanish). Table chunks stay suppressed, real content flows.
|
||||
if status_seeder.is_active():
|
||||
if not is_real_content(message):
|
||||
status_seeder.feed(message)
|
||||
continue
|
||||
|
||||
# Hide output of RCON commands (cvar dumps, status tables)
|
||||
if is_command_output(message):
|
||||
logger.debug('Suppressed RCON command output')
|
||||
|
||||
Reference in New Issue
Block a user