from web.database import query_db from web.services.team_context_service import TeamContextService class OpponentService: @staticmethod def _get_active_roster_ids(): return TeamContextService.get_active_roster_ids() @staticmethod def get_opponent_list(page=1, per_page=20, sort_by='matches', search=None): roster_ids = OpponentService._get_active_roster_ids() if not roster_ids: return [], 0 roster_ph = ','.join('?' for _ in roster_ids) where_clauses = [ f"CAST(mp.steam_id_64 AS TEXT) NOT IN ({roster_ph})", f""" EXISTS ( SELECT 1 FROM fact_match_players roster_mp WHERE roster_mp.match_id = mp.match_id AND CAST(roster_mp.steam_id_64 AS TEXT) IN ({roster_ph}) AND roster_mp.team_id != mp.team_id ) """, ] args = list(roster_ids) + list(roster_ids) if search: where_clauses.append("(LOWER(p.username) LIKE LOWER(?) OR mp.steam_id_64 LIKE ?)") args.extend([f"%{search}%", f"%{search}%"]) where_str = " AND ".join(where_clauses) # Sort mapping sort_sql = "matches DESC" if sort_by == 'rating': sort_sql = "avg_rating DESC" elif sort_by == 'kd': sort_sql = "avg_kd DESC" elif sort_by == 'win_rate': sort_sql = "win_rate DESC" offset = (page - 1) * per_page sql = f""" SELECT mp.steam_id_64, MAX(p.username) as username, MAX(p.avatar_url) as avatar_url, COUNT(DISTINCT mp.match_id) as matches, AVG(mp.rating) as avg_rating, AVG(mp.kd_ratio) as avg_kd, AVG(mp.adr) as avg_adr, SUM(CASE WHEN mp.is_win = 1 THEN 1 ELSE 0 END) as wins, AVG(NULLIF(COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo), 0)) as avg_match_elo FROM fact_match_players mp JOIN fact_matches m ON mp.match_id = m.match_id LEFT JOIN dim_players p ON mp.steam_id_64 = p.steam_id_64 LEFT JOIN fact_match_teams fmt_gid ON mp.match_id = fmt_gid.match_id AND fmt_gid.group_id = mp.team_id LEFT JOIN fact_match_teams fmt_tid ON mp.match_id = fmt_tid.match_id AND fmt_tid.group_tid = mp.match_team_id WHERE {where_str} GROUP BY mp.steam_id_64 ORDER BY {sort_sql} LIMIT ? OFFSET ? """ # Count query count_sql = f""" SELECT COUNT(DISTINCT mp.steam_id_64) as cnt FROM fact_match_players mp LEFT JOIN dim_players p ON mp.steam_id_64 = p.steam_id_64 WHERE {where_str} """ query_args = args + [per_page, offset] rows = query_db('l2', sql, query_args) total = query_db('l2', count_sql, args, one=True)['cnt'] # Post-process for derived stats results = [] # Resolve avatar fallback from local static if missing from web.services.stats_service import StatsService for r in rows or []: d = dict(r) d['win_rate'] = (d['wins'] / d['matches']) if d['matches'] else 0 d['avatar_url'] = StatsService.resolve_avatar_url(d.get('steam_id_64'), d.get('avatar_url')) results.append(d) return results, total @staticmethod def get_global_opponent_stats(): """ Calculates aggregate statistics for ALL opponents. Returns: { 'elo_dist': {'<1200': 10, '1200-1500': 20...}, 'rating_dist': {'<0.8': 5, '0.8-1.0': 15...}, 'win_rate_dist': {'<40%': 5, '40-60%': 10...} (Opponent Win Rate) } """ roster_ids = OpponentService._get_active_roster_ids() if not roster_ids: return {} roster_ph = ','.join('?' for _ in roster_ids) # 1. Fetch Aggregated Stats for ALL opponents # We group by steam_id first to get each opponent's AVG stats sql = f""" SELECT mp.steam_id_64, COUNT(DISTINCT mp.match_id) as matches, AVG(mp.rating) as avg_rating, AVG(NULLIF(COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo), 0)) as avg_match_elo, SUM(CASE WHEN mp.is_win = 1 THEN 1 ELSE 0 END) as wins FROM fact_match_players mp JOIN fact_matches m ON mp.match_id = m.match_id LEFT JOIN fact_match_teams fmt_gid ON mp.match_id = fmt_gid.match_id AND fmt_gid.group_id = mp.team_id LEFT JOIN fact_match_teams fmt_tid ON mp.match_id = fmt_tid.match_id AND fmt_tid.group_tid = mp.match_team_id WHERE CAST(mp.steam_id_64 AS TEXT) NOT IN ({roster_ph}) AND EXISTS ( SELECT 1 FROM fact_match_players roster_mp WHERE roster_mp.match_id = mp.match_id AND CAST(roster_mp.steam_id_64 AS TEXT) IN ({roster_ph}) AND roster_mp.team_id != mp.team_id ) GROUP BY mp.steam_id_64 """ rows = query_db('l2', sql, roster_ids + roster_ids) # Initialize Buckets elo_buckets = {'<1000': 0, '1000-1200': 0, '1200-1400': 0, '1400-1600': 0, '1600-1800': 0, '1800-2000': 0, '>2000': 0} rating_buckets = {'<0.8': 0, '0.8-1.0': 0, '1.0-1.2': 0, '1.2-1.4': 0, '>1.4': 0} win_rate_buckets = {'<30%': 0, '30-45%': 0, '45-55%': 0, '55-70%': 0, '>70%': 0} elo_values = [] rating_values = [] for r in rows: elo_val = r['avg_match_elo'] if elo_val is None or elo_val <= 0: pass else: elo = elo_val if elo < 1000: k = '<1000' elif elo < 1200: k = '1000-1200' elif elo < 1400: k = '1200-1400' elif elo < 1600: k = '1400-1600' elif elo < 1800: k = '1600-1800' elif elo < 2000: k = '1800-2000' else: k = '>2000' elo_buckets[k] += 1 elo_values.append(float(elo)) rtg = r['avg_rating'] or 0 if rtg < 0.8: k = '<0.8' elif rtg < 1.0: k = '0.8-1.0' elif rtg < 1.2: k = '1.0-1.2' elif rtg < 1.4: k = '1.2-1.4' else: k = '>1.4' rating_buckets[k] += 1 rating_values.append(float(rtg)) matches = r['matches'] or 0 if matches > 0: wr = (r['wins'] or 0) / matches if wr < 0.30: k = '<30%' elif wr < 0.45: k = '30-45%' elif wr < 0.55: k = '45-55%' elif wr < 0.70: k = '55-70%' else: k = '>70%' win_rate_buckets[k] += 1 return { 'elo_dist': elo_buckets, 'rating_dist': rating_buckets, 'win_rate_dist': win_rate_buckets, 'elo_values': elo_values, 'rating_values': rating_values } @staticmethod def get_opponent_detail(steam_id): # 1. Basic Info info = query_db('l2', "SELECT * FROM dim_players WHERE steam_id_64 = ?", [steam_id], one=True) if not info: return None from web.services.stats_service import StatsService player = dict(info) player['avatar_url'] = StatsService.resolve_avatar_url(steam_id, player.get('avatar_url')) roster_ids = OpponentService._get_active_roster_ids() if not roster_ids: return None roster_ph = ','.join('?' for _ in roster_ids) sql_history = f""" SELECT m.match_id, m.start_time, m.map_name, m.score_team1, m.score_team2, m.winner_team, mp.team_id, mp.match_team_id, mp.rating, mp.kd_ratio, mp.adr, mp.kills, mp.deaths, mp.is_win as is_win, CASE WHEN COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo) > 0 THEN COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo) END as elo FROM fact_match_players mp JOIN fact_matches m ON mp.match_id = m.match_id LEFT JOIN fact_match_teams fmt_gid ON mp.match_id = fmt_gid.match_id AND fmt_gid.group_id = mp.team_id LEFT JOIN fact_match_teams fmt_tid ON mp.match_id = fmt_tid.match_id AND fmt_tid.group_tid = mp.match_team_id WHERE mp.steam_id_64 = ? AND EXISTS ( SELECT 1 FROM fact_match_players roster_mp WHERE roster_mp.match_id = mp.match_id AND CAST(roster_mp.steam_id_64 AS TEXT) IN ({roster_ph}) AND roster_mp.team_id != mp.team_id ) ORDER BY m.start_time DESC """ history = query_db('l2', sql_history, [steam_id] + roster_ids) # 3. Aggregation by ELO elo_buckets = { '<1200': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0}, '1200-1500': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0}, '1500-1800': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0}, '1800-2100': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0}, '>2100': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0} } # 4. Aggregation by Side (T/CT) # Using fact_match_players_t / ct sql_side = """ SELECT (SELECT CASE WHEN SUM(CASE WHEN t.rating2 IS NOT NULL AND t.rating2 != 0 THEN t.round_total END) > 0 THEN SUM(CASE WHEN t.rating2 IS NOT NULL AND t.rating2 != 0 THEN t.rating2 * t.round_total END) / SUM(CASE WHEN t.rating2 IS NOT NULL AND t.rating2 != 0 THEN t.round_total END) WHEN COUNT(*) > 0 THEN AVG(NULLIF(t.rating2, 0)) END FROM fact_match_players_t t WHERE t.steam_id_64 = ?) as rating_t, (SELECT CASE WHEN SUM(CASE WHEN ct.rating2 IS NOT NULL AND ct.rating2 != 0 THEN ct.round_total END) > 0 THEN SUM(CASE WHEN ct.rating2 IS NOT NULL AND ct.rating2 != 0 THEN ct.rating2 * ct.round_total END) / SUM(CASE WHEN ct.rating2 IS NOT NULL AND ct.rating2 != 0 THEN ct.round_total END) WHEN COUNT(*) > 0 THEN AVG(NULLIF(ct.rating2, 0)) END FROM fact_match_players_ct ct WHERE ct.steam_id_64 = ?) as rating_ct, (SELECT CASE WHEN SUM(t.deaths) > 0 THEN SUM(t.kills) * 1.0 / SUM(t.deaths) WHEN SUM(t.kills) > 0 THEN SUM(t.kills) * 1.0 WHEN COUNT(*) > 0 THEN AVG(NULLIF(t.kd_ratio, 0)) END FROM fact_match_players_t t WHERE t.steam_id_64 = ?) as kd_t, (SELECT CASE WHEN SUM(ct.deaths) > 0 THEN SUM(ct.kills) * 1.0 / SUM(ct.deaths) WHEN SUM(ct.kills) > 0 THEN SUM(ct.kills) * 1.0 WHEN COUNT(*) > 0 THEN AVG(NULLIF(ct.kd_ratio, 0)) END FROM fact_match_players_ct ct WHERE ct.steam_id_64 = ?) as kd_ct, (SELECT SUM(t.round_total) FROM fact_match_players_t t WHERE t.steam_id_64 = ?) as rounds_t, (SELECT SUM(ct.round_total) FROM fact_match_players_ct ct WHERE ct.steam_id_64 = ?) as rounds_ct """ side_stats = query_db('l2', sql_side, [steam_id, steam_id, steam_id, steam_id, steam_id, steam_id], one=True) # Process History for ELO & KD Diff # We also want "Our Team KD" in these matches to calc Diff. # This requires querying the OTHER team in these matches. match_ids = [h['match_id'] for h in history] # Get Our Team Stats per match # "Our Team" = All players in the match EXCEPT this opponent (and their teammates?) # Simplification: "Avg Lobby KD" vs "Opponent KD". # Or better: "Avg KD of Opposing Team". match_stats_map = {} if match_ids: ph = ','.join('?' for _ in match_ids) # Calculate Avg KD of the team that is NOT the opponent's team opp_stats_sql = f""" SELECT match_id, match_team_id, AVG(kd_ratio) as team_avg_kd FROM fact_match_players WHERE match_id IN ({ph}) GROUP BY match_id, match_team_id """ opp_rows = query_db('l2', opp_stats_sql, match_ids) # Organize by match for r in opp_rows: mid = r['match_id'] tid = r['match_team_id'] if mid not in match_stats_map: match_stats_map[mid] = {} match_stats_map[mid][tid] = r['team_avg_kd'] processed_history = [] for h in history: # ELO Bucketing elo = h['elo'] or 0 if elo < 1200: b = '<1200' elif elo < 1500: b = '1200-1500' elif elo < 1800: b = '1500-1800' elif elo < 2100: b = '1800-2100' else: b = '>2100' elo_buckets[b]['matches'] += 1 elo_buckets[b]['rating_sum'] += (h['rating'] or 0) elo_buckets[b]['kd_sum'] += (h['kd_ratio'] or 0) # KD Diff # Find the OTHER team's avg KD my_tid = h['match_team_id'] # Assuming 2 teams: if my_tid is 1, other is 2. But IDs can be anything. # Look at match_stats_map[mid] keys. mid = h['match_id'] other_team_kd = 1.0 # Default if mid in match_stats_map: for tid, avg_kd in match_stats_map[mid].items(): if tid != my_tid: other_team_kd = avg_kd break kd_diff = (h['kd_ratio'] or 0) - other_team_kd d = dict(h) d['kd_diff'] = kd_diff d['other_team_kd'] = other_team_kd processed_history.append(d) # Format ELO Stats elo_stats = [] for k, v in elo_buckets.items(): if v['matches'] > 0: elo_stats.append({ 'range': k, 'matches': v['matches'], 'avg_rating': v['rating_sum'] / v['matches'], 'avg_kd': v['kd_sum'] / v['matches'] }) return { 'player': player, 'history': processed_history, 'elo_stats': elo_stats, 'side_stats': dict(side_stats) if side_stats else {} } @staticmethod def get_map_opponent_stats(): roster_ids = OpponentService._get_active_roster_ids() if not roster_ids: return [] roster_ph = ','.join('?' for _ in roster_ids) sql = f""" SELECT m.map_name as map_name, COUNT(DISTINCT mp.match_id) as matches, AVG(mp.rating) as avg_rating, AVG(mp.kd_ratio) as avg_kd, AVG(NULLIF(COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo), 0)) as avg_elo, COUNT(DISTINCT CASE WHEN mp.is_win = 1 THEN mp.match_id END) as wins, COUNT(DISTINCT CASE WHEN mp.rating > 1.5 THEN mp.match_id END) as shark_matches FROM fact_match_players mp JOIN fact_matches m ON mp.match_id = m.match_id LEFT JOIN fact_match_teams fmt_gid ON mp.match_id = fmt_gid.match_id AND fmt_gid.group_id = mp.team_id LEFT JOIN fact_match_teams fmt_tid ON mp.match_id = fmt_tid.match_id AND fmt_tid.group_tid = mp.match_team_id WHERE CAST(mp.steam_id_64 AS TEXT) NOT IN ({roster_ph}) AND EXISTS ( SELECT 1 FROM fact_match_players roster_mp WHERE roster_mp.match_id = mp.match_id AND CAST(roster_mp.steam_id_64 AS TEXT) IN ({roster_ph}) AND roster_mp.team_id != mp.team_id ) AND m.map_name IS NOT NULL AND m.map_name <> '' GROUP BY m.map_name ORDER BY matches DESC """ rows = query_db('l2', sql, roster_ids + roster_ids) results = [] for r in rows: d = dict(r) matches = d.get('matches') or 0 wins = d.get('wins') or 0 d['win_rate'] = (wins / matches) if matches else 0 results.append(d) return results