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]>
This commit is contained in:
Debian
2026-09-18 13:04:34 +02:00
co-authored by Sisyphus
parent 43758f980c
commit c636557849
3 changed files with 105 additions and 30 deletions
+70 -29
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
@@ -118,9 +127,7 @@ class EventParser:
"""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
@@ -163,9 +170,7 @@ class EventParser:
"""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
@@ -283,9 +288,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)
@@ -305,9 +308,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')
@@ -340,18 +341,35 @@ 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() # 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"
@@ -362,12 +380,7 @@ 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()
if not self.game_state.server_info.is_team_mode():
return None
# Restarted matches (server shutdown mid-game) have no winner; aborts # Restarted matches (server shutdown mid-game) have no winner; aborts
# and forfeits still have final scores that name one # and forfeits still have final scores that name one
@@ -377,16 +390,44 @@ class EventParser:
except (TypeError, ValueError): except (TypeError, ValueError):
return None 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')) 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"""
+10 -1
View File
@@ -243,7 +243,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}')
@@ -256,6 +261,10 @@ class EventDeduplicator:
return False return False
def reset(self):
"""Clear the signature buffer (match start: TIME restarts at 0)"""
self.recent_events = []
class VoteTracker: class VoteTracker:
"""Tracks cast subjects of the running vote; reset on each vote call""" """Tracks cast subjects of the running vote; reset on each vote call"""
+25
View File
@@ -431,6 +431,26 @@ def _draw_server_menu(screen, servers, names, selected):
screen.refresh() screen.refresh()
def format_damage_summary(server_info):
"""One-line damage summary (top 3 by damage dealt) from LIVESTATS counters;
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)
summary = ' '.join(f"{name} {dealt}/{taken}" for name, dealt, taken in entries[:3])
return f"^8^3DMG^7 {summary}\n"
class UIManager: class UIManager:
"""Manages curses windows and display""" """Manages curses windows and display"""
@@ -872,6 +892,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)
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")