From 57fb6ce1f4ee16585e64b20cfd5ffcd2ec0ac85f Mon Sep 17 00:00:00 2001 From: Jacky Yang Date: Mon, 26 Jan 2026 17:08:43 +0800 Subject: [PATCH] 1.0.3: Enhanced match detail - Added h2h and roundhistory. --- check_round_data.py | 45 +++ web/routes/matches.py | 29 +- web/services/stats_service.py | 176 +++++++++-- web/templates/matches/detail.html | 481 ++++++++++++++++++++++-------- 4 files changed, 574 insertions(+), 157 deletions(-) create mode 100644 check_round_data.py diff --git a/check_round_data.py b/check_round_data.py new file mode 100644 index 0000000..658f545 --- /dev/null +++ b/check_round_data.py @@ -0,0 +1,45 @@ + +import sqlite3 +import pandas as pd + +match_id = 'g161-n-20251222204652101389654' + +def check_data(): + conn = sqlite3.connect('database/L2/L2_Main.sqlite') + + print(f"--- Check Match: {match_id} ---") + + # 1. Source Type + c = conn.cursor() + c.execute("SELECT data_source_type FROM fact_matches WHERE match_id = ?", (match_id,)) + row = c.fetchone() + if row: + print(f"Data Source: {row[0]}") + else: + print("Match not found") + return + + # 2. Round Events (Sample) + print("\n--- Round Events Sample ---") + try: + df = pd.read_sql(f"SELECT round_num, event_type, attacker_steam_id, victim_steam_id, weapon FROM fact_round_events WHERE match_id = '{match_id}' LIMIT 5", conn) + print(df) + if df.empty: + print("WARNING: No events found.") + except Exception as e: + print(e) + + # 3. Economy (Sample) + print("\n--- Economy Sample ---") + try: + df_eco = pd.read_sql(f"SELECT round_num, steam_id_64, equipment_value FROM fact_round_player_economy WHERE match_id = '{match_id}' LIMIT 5", conn) + print(df_eco) + if df_eco.empty: + print("Info: No economy data (Likely Classic source).") + except Exception as e: + print(e) + + conn.close() + +if __name__ == "__main__": + check_data() diff --git a/web/routes/matches.py b/web/routes/matches.py index 3fdafed..a5c606a 100644 --- a/web/routes/matches.py +++ b/web/routes/matches.py @@ -84,9 +84,36 @@ def detail(match_id): team1_players.sort(key=lambda x: x.get('rating', 0) or 0, reverse=True) team2_players.sort(key=lambda x: x.get('rating', 0) or 0, reverse=True) + # New Data for Enhanced Detail View + h2h_stats = StatsService.get_head_to_head_stats(match_id) + round_details = StatsService.get_match_round_details(match_id) + + # Convert H2H stats to a more usable format (nested dict) + # h2h_matrix[attacker_id][victim_id] = kills + h2h_matrix = {} + if h2h_stats: + for row in h2h_stats: + a_id = row['attacker_steam_id'] + v_id = row['victim_steam_id'] + kills = row['kills'] + if a_id not in h2h_matrix: h2h_matrix[a_id] = {} + h2h_matrix[a_id][v_id] = kills + + # Create a mapping of SteamID -> Username for the template + # We can use the players list we already have + player_name_map = {} + for p in players: + sid = p.get('steam_id_64') + name = p.get('username') + if sid and name: + player_name_map[str(sid)] = name + return render_template('matches/detail.html', match=match, team1_players=team1_players, team2_players=team2_players, - rounds=rounds) + rounds=rounds, + h2h_matrix=h2h_matrix, + round_details=round_details, + player_name_map=player_name_map) @bp.route('//raw') def raw_json(match_id): diff --git a/web/services/stats_service.py b/web/services/stats_service.py index 0c70ec3..aefa487 100644 --- a/web/services/stats_service.py +++ b/web/services/stats_service.py @@ -93,52 +93,87 @@ class StatsService: if not active_roster_ids: result_map = {} else: + # 1. Get UIDs for Roster Members involved in these matches + # We query fact_match_players to ensure we get the UIDs actually used in these matches roster_placeholders = ','.join('?' for _ in active_roster_ids) - - # We cast steam_id_64 to TEXT to ensure match even if stored as int - our_result_sql = f""" - SELECT mp.match_id, mp.team_id, m.winner_team, COUNT(*) as our_count - FROM fact_match_players mp - JOIN fact_matches m ON mp.match_id = m.match_id - WHERE mp.match_id IN ({placeholders}) - AND CAST(mp.steam_id_64 AS TEXT) IN ({roster_placeholders}) - GROUP BY mp.match_id, mp.team_id + uid_sql = f""" + SELECT DISTINCT steam_id_64, uid + FROM fact_match_players + WHERE match_id IN ({placeholders}) + AND CAST(steam_id_64 AS TEXT) IN ({roster_placeholders}) """ + combined_args_uid = match_ids + active_roster_ids + uid_rows = query_db('l2', uid_sql, combined_args_uid) - # Combine args: match_ids + roster_ids - combined_args = match_ids + active_roster_ids - our_rows = query_db('l2', our_result_sql, combined_args) + # Set of "Our UIDs" (as strings) + our_uids = set() + for r in uid_rows: + if r['uid']: + our_uids.add(str(r['uid'])) - # Map match_id -> result ('win', 'loss', 'draw', 'mixed') + # 2. Get Group UIDs and Winner info from fact_match_teams + # We need to know which group contains our UIDs + teams_sql = f""" + SELECT fmt.match_id, fmt.group_id, fmt.group_uids, m.winner_team + FROM fact_match_teams fmt + JOIN fact_matches m ON fmt.match_id = m.match_id + WHERE fmt.match_id IN ({placeholders}) + """ + teams_rows = query_db('l2', teams_sql, match_ids) + + # 3. Determine Result per Match result_map = {} - match_sides = {} - match_winners = {} + # Group data by match + match_groups = {} # match_id -> {group_id: [uids...], winner: int} - for r in our_rows: + for r in teams_rows: mid = r['match_id'] - if mid not in match_sides: match_sides[mid] = {} - match_sides[mid][r['team_id']] = r['our_count'] - match_winners[mid] = r['winner_team'] + gid = r['group_id'] + uids_str = r['group_uids'] or "" + # Split and clean UIDs + uids = set(str(u).strip() for u in uids_str.split(',') if u.strip()) - for mid, sides in match_sides.items(): - winner = match_winners.get(mid) - if not winner: - result_map[mid] = 'draw' - continue - - our_on_winner = sides.get(winner, 0) - loser = 2 if winner == 1 else 1 - our_on_loser = sides.get(loser, 0) + if mid not in match_groups: + match_groups[mid] = {'groups': {}, 'winner': r['winner_team']} - if our_on_winner > 0 and our_on_loser == 0: + match_groups[mid]['groups'][gid] = uids + + # Analyze + for mid, data in match_groups.items(): + winner_gid = data['winner'] + groups = data['groups'] + + our_in_winner = False + our_in_loser = False + + # Check each group + for gid, uids in groups.items(): + # Intersection of Our UIDs and Group UIDs + common = our_uids.intersection(uids) + if common: + if gid == winner_gid: + our_in_winner = True + else: + our_in_loser = True + + if our_in_winner and not our_in_loser: result_map[mid] = 'win' - elif our_on_loser > 0 and our_on_winner == 0: + elif our_in_loser and not our_in_winner: result_map[mid] = 'loss' - elif our_on_winner > 0 and our_on_loser > 0: - result_map[mid] = 'mixed' + elif our_in_winner and our_in_loser: + result_map[mid] = 'mixed' else: - result_map[mid] = None + # Fallback: If UID matching failed (maybe missing UIDs), try old team_id method? + # Or just leave it as None (safe) + pass + + # Convert to dict to modify + matches = [dict(m) for m in matches] + for m in matches: + m['avg_elo'] = elo_map.get(m['match_id'], 0) + m['max_party'] = party_map.get(m['match_id'], 1) + m['our_result'] = result_map.get(m['match_id']) # Convert to dict to modify matches = [dict(m) for m in matches] @@ -387,3 +422,78 @@ class StatsService: """ return query_db('l2', sql) + @staticmethod + def get_head_to_head_stats(match_id): + """ + Returns a matrix of kills between players. + List of {attacker_steam_id, victim_steam_id, kills} + """ + sql = """ + SELECT attacker_steam_id, victim_steam_id, COUNT(*) as kills + FROM fact_round_events + WHERE match_id = ? AND event_type = 'kill' + GROUP BY attacker_steam_id, victim_steam_id + """ + return query_db('l2', sql, [match_id]) + + @staticmethod + def get_match_round_details(match_id): + """ + Returns a detailed dictionary of rounds, events, and economy. + { + round_num: { + info: {winner_side, win_reason_desc, end_time_stamp...}, + events: [ {event_type, event_time, attacker..., weapon...}, ... ], + economy: { steam_id: {main_weapon, equipment_value...}, ... } + } + } + """ + # 1. Base Round Info + rounds_sql = "SELECT * FROM fact_rounds WHERE match_id = ? ORDER BY round_num" + rounds_rows = query_db('l2', rounds_sql, [match_id]) + + if not rounds_rows: + return {} + + # 2. Events + events_sql = """ + SELECT * FROM fact_round_events + WHERE match_id = ? + ORDER BY round_num, event_time + """ + events_rows = query_db('l2', events_sql, [match_id]) + + # 3. Economy (if avail) + eco_sql = """ + SELECT * FROM fact_round_player_economy + WHERE match_id = ? + """ + eco_rows = query_db('l2', eco_sql, [match_id]) + + # Structure Data + result = {} + + # Initialize rounds + for r in rounds_rows: + r_num = r['round_num'] + result[r_num] = { + 'info': dict(r), + 'events': [], + 'economy': {} + } + + # Group events + for e in events_rows: + r_num = e['round_num'] + if r_num in result: + result[r_num]['events'].append(dict(e)) + + # Group economy + for eco in eco_rows: + r_num = eco['round_num'] + sid = eco['steam_id_64'] + if r_num in result: + result[r_num]['economy'][sid] = dict(eco) + + return result + diff --git a/web/templates/matches/detail.html b/web/templates/matches/detail.html index 591e6be..33b854d 100644 --- a/web/templates/matches/detail.html +++ b/web/templates/matches/detail.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block content %} -
+
@@ -20,74 +20,273 @@ Download Raw JSON
+ + +
+ +
- -
-
-

Team 1

+ +
+ +
+
+

Team 1

+
+
+ + + + + + + + + + + + + + + {% for p in team1_players %} + + + + + + + + + + + {% endfor %} + +
PlayerKDA+/-ADRKASTRating
+
+
+ {% if p.avatar_url %} + + {% else %} +
+ {{ (p.username or p.steam_id_64)[:2] | upper }} +
+ {% endif %} +
+
+
+ + {{ p.username or p.steam_id_64 }} + + {% if p.party_size > 1 %} + {% set pc = p.party_size %} + {% set p_color = 'bg-blue-100 text-blue-800' %} + {% if pc == 2 %}{% set p_color = 'bg-indigo-100 text-indigo-800' %} + {% elif pc == 3 %}{% set p_color = 'bg-blue-100 text-blue-800' %} + {% elif pc == 4 %}{% set p_color = 'bg-purple-100 text-purple-800' %} + {% elif pc >= 5 %}{% set p_color = 'bg-orange-100 text-orange-800' %} + {% endif %} + + + + + {{ p.party_size }} + + {% endif %} +
+
+
+
{{ p.kills }}{{ p.deaths }}{{ p.assists }} + {{ p.kills - p.deaths }} + {{ "%.1f"|format(p.adr or 0) }}{{ "%.1f"|format(p.kast or 0) }}%{{ "%.2f"|format(p.rating or 0) }}
+
+ + +
+
+

Team 2

+
+
+ + + + + + + + + + + + + + + {% for p in team2_players %} + + + + + + + + + + + {% endfor %} + +
PlayerKDA+/-ADRKASTRating
+
+
+ {% if p.avatar_url %} + + {% else %} +
+ {{ (p.username or p.steam_id_64)[:2] | upper }} +
+ {% endif %} +
+
+
+ + {{ p.username or p.steam_id_64 }} + + {% if p.party_size > 1 %} + {% set pc = p.party_size %} + {% set p_color = 'bg-blue-100 text-blue-800' %} + {% if pc == 2 %}{% set p_color = 'bg-indigo-100 text-indigo-800' %} + {% elif pc == 3 %}{% set p_color = 'bg-blue-100 text-blue-800' %} + {% elif pc == 4 %}{% set p_color = 'bg-purple-100 text-purple-800' %} + {% elif pc >= 5 %}{% set p_color = 'bg-orange-100 text-orange-800' %} + {% endif %} + + + + + {{ p.party_size }} + + {% endif %} +
+
+
+
{{ p.kills }}{{ p.deaths }}{{ p.assists }} + {{ p.kills - p.deaths }} + {{ "%.1f"|format(p.adr or 0) }}{{ "%.1f"|format(p.kast or 0) }}%{{ "%.2f"|format(p.rating or 0) }}
+
+
+
+ + + +
+
+ {% endfor %}
+ {% endif %} + + + + {% endblock %}