Compare commits

..
14 Commits
Author SHA1 Message Date
DebianandSisyphus ec64804fec Infer kill event teams without relying on the livedamage plugin
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 14:39:21 +02:00
DebianandSisyphus c1b5f6d86a Populate team rosters from a LIVESTATS team token and tag the damage list
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 14:26:06 +02:00
DebianandSisyphus cbeab1ae45 Detect team modes by GAME_TYPE code and sync gametype at match start
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 14:19:37 +02:00
DebianandSisyphus 016384ef12 Display responses to cvars the user queries from the console
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 13:57:19 +02:00
DebianandSisyphus f8a71e7906 Render locally what rcon say broadcasts print on the server
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 13:32:49 +02:00
DebianandSisyphus c636557849 Sync rosters at match start, harden dedup and TIME handling, add scoreboard and damage summary
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 13:04:34 +02:00
DebianandSisyphus 43758f980c Run the roster seeder after cvar handling so credential responses survive
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 12:43:19 +02:00
DebianandSisyphus c9487b954a Seed the player roster from a status dump on connect and map change
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 12:33:59 +02:00
DebianandSisyphus c5e0bc3f01 Track roster connects and disconnects from the stats stream
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 12:30:12 +02:00
DebianandSisyphus b69cc38cce Consume every LIVESTATS heartbeat variant and show them all only with -v
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 12:26:42 +02:00
DebianandSisyphus 17dd04857f Announce the winner of aborted matches too, only restarts stay silent
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 12:20:46 +02:00
DebianandSisyphus ca1666f3a5 Ignore docs build artifacts
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 12:19:29 +02:00
DebianandSisyphus be7c3aec56 Track vote subjects and authoritative player scores in game state
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 12:19:29 +02:00
DebianandSisyphus 38f1446cde Announce forfeited match winners and show LIVESTATS lines only with -v
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-09-18 12:19:23 +02:00
6 changed files with 529 additions and 67 deletions
+1
View File
@@ -1,4 +1,5 @@
__pycache__/ __pycache__/
docs/
venv/ venv/
venv3/ venv3/
*test* *test*
+8
View File
@@ -19,6 +19,8 @@ POLL_TIMEOUT = 100
QUIT_CONFIRM_TIMEOUT = 3.0 # Seconds to confirm quit (Ctrl-C twice) QUIT_CONFIRM_TIMEOUT = 3.0 # Seconds to confirm quit (Ctrl-C twice)
RESPAWN_DELAY = 3.0 # Seconds before players respawn after death RESPAWN_DELAY = 3.0 # Seconds before players respawn after death
STATUS_OUTPUT_WINDOW = 1.5 # Seconds a 'status' command echo suppresses its chunked table output STATUS_OUTPUT_WINDOW = 1.5 # Seconds a 'status' command echo suppresses its chunked table output
STATUS_SEED_WINDOW = 2.0 # Seconds the roster seeder collects the 'status' dump after connect
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
@@ -55,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',
+177 -38
View File
@@ -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,8 +74,8 @@ 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,
} }
@@ -92,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
@@ -137,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
@@ -162,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):
@@ -171,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 "
@@ -192,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 "
@@ -218,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 "
@@ -247,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)
@@ -269,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')
@@ -304,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"
@@ -326,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"""
@@ -352,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
View File
@@ -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()
+35 -2
View File
@@ -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"""
@@ -872,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")
+221 -15
View File
@@ -15,7 +15,7 @@ import signal
import sys import sys
import os import os
from lib.constants import VERSION, DEFAULT_HOST, POLL_TIMEOUT, QUIT_CONFIRM_TIMEOUT, RESPAWN_DELAY, MAX_COMMAND_HISTORY, STATUS_OUTPUT_WINDOW from lib.constants import VERSION, DEFAULT_HOST, POLL_TIMEOUT, QUIT_CONFIRM_TIMEOUT, RESPAWN_DELAY, MAX_COMMAND_HISTORY, STATUS_OUTPUT_WINDOW, STATUS_SEED_WINDOW, 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
@@ -37,6 +37,23 @@ 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_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) STATUS_MAP_PATTERN = re.compile(r'^map:\s+\S+\s*$', re.MULTILINE)
STEAMID_PATTERN = re.compile(r'\b\d{16,}\b') STEAMID_PATTERN = re.compile(r'\b\d{16,}\b')
LIVESTATS_PATTERN = re.compile(
r'^LIVESTATS\s+(.+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)(?:\s+([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')
@@ -57,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
@@ -87,20 +114,27 @@ 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).
""" """
# Parse cvar responses (format: "cvar_name" is:"value" default:...) match = CVAR_RESPONSE_PATTERN.search(message)
cvar_match = CVAR_RESPONSE_PATTERN.search(message) if not match:
if cvar_match: return False
cvar_name = cvar_match.group(1)
value = cvar_match.group(2)
if game_state.server_info.update_from_cvar(cvar_name, value): cvar_name = match.group(1)
ui.update_server_info(game_state) value = match.group(2)
return True
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): def is_command_echo(message):
@@ -134,7 +168,70 @@ def is_command_output(message):
return False return False
CHAT_SHAPE_PATTERN = re.compile(r'^[\w(][^:]*:\s') 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): def is_real_content(message):
@@ -165,11 +262,77 @@ def is_real_content(message):
clean = clean[:-1] clean = clean[:-1]
if 'Game Initialization' in clean: if 'Game Initialization' in clean:
return True return True
if GAME_END_PATTERN.search(clean):
return True
if CHAT_SHAPE_PATTERN.match(clean): if CHAT_SHAPE_PATTERN.match(clean):
return True return True
return False return False
class StatusSeeder:
"""
Seeds the player roster from a 'status' command dump, sent on connect and
after map changes. The table arrives chunked across many messages (rows
split at arbitrary column boundaries, wrapped or raw), so raw text is
buffered and split on newlines; the tail is flushed when the window
closes. Rows carry no team column, so untracked names seed as SPECTATOR
(immediately visible; active players self-correct on their first stats
event) and known players only get their authoritative score/ping.
All seeding output stays suppressed.
"""
def __init__(self, game_state):
self.game_state = game_state
self.buffer = ''
self.active_until = 0.0
def start(self):
self.buffer = ''
self.active_until = time.time() + STATUS_SEED_WINDOW
def is_active(self):
if self.active_until and time.time() >= self.active_until:
self.finish()
return bool(self.active_until) and time.time() < self.active_until
def feed(self, message):
"""Buffer one RCON message and parse every completed line"""
text = strip_color_codes(message)
if text.startswith('broadcast:'):
text = text[10:].lstrip()
if text.startswith('print "'):
text = text[7:]
if text.endswith('"'):
text = text[:-1]
self.buffer += text.replace('\\n', '\n')
while '\n' in self.buffer:
line, self.buffer = self.buffer.split('\n', 1)
self._parse_line(line)
def finish(self):
if self.buffer.strip():
self._parse_line(self.buffer)
self.buffer = ''
def _parse_line(self, line):
match = STATUS_ROW_PATTERN.match(line.strip())
if not match:
return
num, score, ping, name, lastmsg, address, qport, rate, steamid = match.groups()
tracker = self.game_state.player_tracker
clean_name = strip_color_codes(name).strip()
if not clean_name:
return
player_data = self.game_state.server_info.players.get(clean_name)
if player_data is not None:
player_data['score'] = str(score)
player_data['ping'] = str(ping)
return
tracker.add_player(clean_name, score=str(score), ping=str(ping))
if tracker.get_team(clean_name) is None:
tracker.update_team(clean_name, 'SPECTATOR')
def handle_stats_connection(message): def handle_stats_connection(message):
""" """
Handle stats connection info extraction Handle stats connection info extraction
@@ -210,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"""
@@ -281,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:
@@ -403,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)
@@ -478,6 +660,8 @@ def main_loop(screen, args):
ui.print_message(f"^3[^7{timestamp}^3]^7 Requesting connection info...\n") ui.print_message(f"^3[^7{timestamp}^3]^7 Requesting connection info...\n")
rcon.send_command(b'zmq_stats_password') rcon.send_command(b'zmq_stats_password')
rcon.send_command(b'net_port') rcon.send_command(b'net_port')
rcon.send_command(b'status')
status_seeder.start()
if args.learn_names: if args.learn_names:
name_capture.request(rcon) name_capture.request(rcon)
elif monitor_event and monitor_event[0] == zmq.EVENT_DISCONNECTED: elif monitor_event and monitor_event[0] == zmq.EVENT_DISCONNECTED:
@@ -524,6 +708,15 @@ 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 # Command echoes only show at -v or higher verbosity; a
# status echo opens a window that hides its chunked output # status echo opens a window that hides its chunked output
if is_command_echo(message): if is_command_echo(message):
@@ -564,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
@@ -611,6 +806,16 @@ 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) # Hide output of RCON commands (cvar dumps, status tables)
if is_command_output(message): if is_command_output(message):
logger.debug('Suppressed RCON command output') logger.debug('Suppressed RCON command output')
@@ -647,8 +852,9 @@ 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