diff --git a/lib/parser.py b/lib/parser.py index 1d14717..c34cd6a 100644 --- a/lib/parser.py +++ b/lib/parser.py @@ -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 @@ -118,9 +127,7 @@ class EventParser: """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 @@ -163,9 +170,7 @@ class EventParser: """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 @@ -283,9 +288,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) @@ -305,9 +308,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') @@ -340,18 +341,35 @@ 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) + + # 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" @@ -362,12 +380,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 @@ -377,16 +390,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""" diff --git a/lib/state.py b/lib/state.py index e83a05d..4f3f7ac 100644 --- a/lib/state.py +++ b/lib/state.py @@ -243,7 +243,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}') @@ -256,6 +261,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""" diff --git a/lib/ui.py b/lib/ui.py index c55922e..bf1b707 100644 --- a/lib/ui.py +++ b/lib/ui.py @@ -431,6 +431,26 @@ def _draw_server_menu(screen, servers, names, selected): 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: """Manages curses windows and display""" @@ -872,6 +892,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) + if damage_line: + print_colored(self.info_window, damage_line, 0) + # Blank lines to fill try: self.info_window.addstr("\n")