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:
+70
-29
@@ -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
@@ -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"""
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user