375 lines
16 KiB
Python
375 lines
16 KiB
Python
from web.database import query_db
|
|
from web.services.web_service import WebService
|
|
import json
|
|
|
|
class OpponentService:
|
|
@staticmethod
|
|
def _get_active_roster_ids():
|
|
lineups = WebService.get_lineups()
|
|
active_roster_ids = []
|
|
if lineups:
|
|
try:
|
|
raw_ids = json.loads(lineups[0]['player_ids_json'])
|
|
active_roster_ids = [str(uid) for uid in raw_ids]
|
|
except:
|
|
pass
|
|
return 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
|
|
|
|
# Placeholders
|
|
roster_ph = ','.join('?' for _ in roster_ids)
|
|
|
|
# 1. Identify Matches involving our roster (at least 1 member? usually 2 for 'team' match)
|
|
# Let's say at least 1 for broader coverage as requested ("1 match sample")
|
|
# But "Our Team" usually implies the entity. Let's stick to matches where we can identify "Us".
|
|
# If we use >=1, we catch solo Q matches of roster members. The user said "Non-team members or 1 match sample",
|
|
# but implied "facing different our team lineups".
|
|
# Let's use the standard "candidate matches" logic (>=2 roster members) to represent "The Team".
|
|
# OR, if user wants "Opponent Analysis" for even 1 match, maybe they mean ANY match in DB?
|
|
# "Left Top add Opponent Analysis... (non-team member or 1 sample)"
|
|
# This implies we analyze PLAYERS who are NOT us.
|
|
# Let's stick to matches where >= 1 roster member played, to define "Us" vs "Them".
|
|
|
|
# Actually, let's look at ALL matches in DB, and any player NOT in active roster is an "Opponent".
|
|
# This covers "1 sample".
|
|
|
|
# Query:
|
|
# Select all players who are NOT in active roster.
|
|
# Group by steam_id.
|
|
# Aggregate stats.
|
|
|
|
where_clauses = [f"CAST(mp.steam_id_64 AS TEXT) NOT IN ({roster_ph})"]
|
|
args = 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"
|
|
|
|
# Main Aggregation Query
|
|
# We need to join fact_matches to get match info (win/loss, elo) if needed,
|
|
# but fact_match_players has is_win (boolean) usually? No, it has team_id.
|
|
# We need to determine if THEY won.
|
|
# fact_match_players doesn't store is_win directly in schema (I should check schema, but stats_service calculates it).
|
|
# Wait, stats_service.get_player_trend uses `mp.is_win`?
|
|
# Let's check schema. `fact_match_players` usually has `match_id`, `team_id`.
|
|
# `fact_matches` has `winner_team`.
|
|
# So we join.
|
|
|
|
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 = []
|
|
for r in rows:
|
|
d = dict(r)
|
|
d['win_rate'] = (d['wins'] / d['matches']) if d['matches'] else 0
|
|
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})
|
|
GROUP BY mp.steam_id_64
|
|
"""
|
|
|
|
rows = query_db('l2', sql, 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
|
|
|
|
# 2. Match History vs Us (All matches this player played)
|
|
# We define "Us" as matches where this player is an opponent.
|
|
# But actually, we just show ALL their matches in our DB, assuming our DB only contains matches relevant to us?
|
|
# Usually yes, but if we have a huge DB, we might want to filter by "Contains Roster Member".
|
|
# For now, show all matches in DB for this player.
|
|
|
|
sql_history = """
|
|
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 = ?
|
|
ORDER BY m.start_time DESC
|
|
"""
|
|
history = query_db('l2', sql_history, [steam_id])
|
|
|
|
# 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 SUM(t.rating * t.round_total) / SUM(t.round_total) FROM fact_match_players_t t WHERE t.steam_id_64 = ?) as rating_t,
|
|
(SELECT SUM(ct.rating * ct.round_total) / SUM(ct.round_total) FROM fact_match_players_ct ct WHERE ct.steam_id_64 = ?) as rating_ct,
|
|
(SELECT SUM(t.kd_ratio * t.round_total) / SUM(t.round_total) FROM fact_match_players_t t WHERE t.steam_id_64 = ?) as kd_t,
|
|
(SELECT SUM(ct.kd_ratio * ct.round_total) / SUM(ct.round_total) 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': info,
|
|
'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 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)
|
|
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
|