Files
yrtv/web/services/opponent_service.py

381 lines
16 KiB
Python
Raw Normal View History

2026-01-27 19:06:20 +08:00
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 = []
2026-01-27 19:23:05 +08:00
# Resolve avatar fallback from local static if missing
from web.services.stats_service import StatsService
for r in rows or []:
2026-01-27 19:06:20 +08:00
d = dict(r)
d['win_rate'] = (d['wins'] / d['matches']) if d['matches'] else 0
2026-01-27 19:23:05 +08:00
d['avatar_url'] = StatsService.resolve_avatar_url(d.get('steam_id_64'), d.get('avatar_url'))
2026-01-27 19:06:20 +08:00
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
2026-01-27 19:23:05 +08:00
from web.services.stats_service import StatsService
player = dict(info)
player['avatar_url'] = StatsService.resolve_avatar_url(steam_id, player.get('avatar_url'))
2026-01-27 19:06:20 +08:00
# 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 {
2026-01-27 19:23:05 +08:00
'player': player,
2026-01-27 19:06:20 +08:00
'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