2026-01-26 02:13:06 +08:00
|
|
|
from web.database import query_db
|
|
|
|
|
|
|
|
|
|
class StatsService:
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_recent_matches(limit=5):
|
|
|
|
|
sql = """
|
|
|
|
|
SELECT m.match_id, m.start_time, m.map_name, m.score_team1, m.score_team2, m.winner_team,
|
|
|
|
|
p.username as mvp_name
|
|
|
|
|
FROM fact_matches m
|
|
|
|
|
LEFT JOIN dim_players p ON m.mvp_uid = p.uid
|
|
|
|
|
ORDER BY m.start_time DESC
|
|
|
|
|
LIMIT ?
|
|
|
|
|
"""
|
|
|
|
|
return query_db('l2', sql, [limit])
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_matches(page=1, per_page=20, map_name=None, date_from=None, date_to=None):
|
|
|
|
|
offset = (page - 1) * per_page
|
|
|
|
|
args = []
|
|
|
|
|
where_clauses = ["1=1"]
|
|
|
|
|
|
|
|
|
|
if map_name:
|
|
|
|
|
where_clauses.append("map_name = ?")
|
|
|
|
|
args.append(map_name)
|
|
|
|
|
|
|
|
|
|
if date_from:
|
|
|
|
|
where_clauses.append("start_time >= ?")
|
|
|
|
|
args.append(date_from)
|
|
|
|
|
|
|
|
|
|
if date_to:
|
|
|
|
|
where_clauses.append("start_time <= ?")
|
|
|
|
|
args.append(date_to)
|
|
|
|
|
|
|
|
|
|
where_str = " AND ".join(where_clauses)
|
|
|
|
|
|
|
|
|
|
sql = f"""
|
|
|
|
|
SELECT m.match_id, m.start_time, m.map_name, m.score_team1, m.score_team2, m.winner_team, m.duration
|
|
|
|
|
FROM fact_matches m
|
|
|
|
|
WHERE {where_str}
|
|
|
|
|
ORDER BY m.start_time DESC
|
|
|
|
|
LIMIT ? OFFSET ?
|
|
|
|
|
"""
|
|
|
|
|
args.extend([per_page, offset])
|
|
|
|
|
|
|
|
|
|
matches = query_db('l2', sql, args)
|
|
|
|
|
|
|
|
|
|
# Count total for pagination
|
|
|
|
|
count_sql = f"SELECT COUNT(*) as cnt FROM fact_matches WHERE {where_str}"
|
|
|
|
|
total = query_db('l2', count_sql, args[:-2], one=True)['cnt']
|
|
|
|
|
|
|
|
|
|
return matches, total
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_match_detail(match_id):
|
|
|
|
|
sql = "SELECT * FROM fact_matches WHERE match_id = ?"
|
|
|
|
|
return query_db('l2', sql, [match_id], one=True)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_match_players(match_id):
|
|
|
|
|
sql = """
|
|
|
|
|
SELECT mp.*, p.username, p.avatar_url
|
|
|
|
|
FROM fact_match_players mp
|
|
|
|
|
LEFT JOIN dim_players p ON mp.steam_id_64 = p.steam_id_64
|
|
|
|
|
WHERE mp.match_id = ?
|
|
|
|
|
ORDER BY mp.team_id, mp.rating DESC
|
|
|
|
|
"""
|
|
|
|
|
return query_db('l2', sql, [match_id])
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_match_rounds(match_id):
|
|
|
|
|
sql = "SELECT * FROM fact_rounds WHERE match_id = ? ORDER BY round_num"
|
|
|
|
|
return query_db('l2', sql, [match_id])
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_players(page=1, per_page=20, search=None, sort_by='rating_desc'):
|
|
|
|
|
offset = (page - 1) * per_page
|
|
|
|
|
args = []
|
|
|
|
|
where_clauses = ["1=1"]
|
|
|
|
|
|
|
|
|
|
if search:
|
|
|
|
|
# Force case-insensitive search
|
|
|
|
|
where_clauses.append("(LOWER(username) LIKE LOWER(?) OR steam_id_64 LIKE ?)")
|
|
|
|
|
args.append(f"%{search}%")
|
|
|
|
|
args.append(f"%{search}%")
|
|
|
|
|
|
|
|
|
|
where_str = " AND ".join(where_clauses)
|
|
|
|
|
|
|
|
|
|
# Sort mapping
|
|
|
|
|
order_clause = "rating DESC" # Default logic (this query needs refinement as L2 dim_players doesn't store avg rating)
|
|
|
|
|
# Wait, dim_players only has static info. We need aggregated stats.
|
|
|
|
|
# Ideally, we should fetch from L3 for player list stats.
|
|
|
|
|
# But StatsService is for L2.
|
|
|
|
|
# For the Player List, we usually want L3 data (Career stats).
|
|
|
|
|
# I will leave the detailed stats logic for FeatureService or do a join here if necessary.
|
|
|
|
|
# For now, just listing players from dim_players.
|
|
|
|
|
|
|
|
|
|
sql = f"""
|
|
|
|
|
SELECT * FROM dim_players
|
|
|
|
|
WHERE {where_str}
|
|
|
|
|
LIMIT ? OFFSET ?
|
|
|
|
|
"""
|
|
|
|
|
args.extend([per_page, offset])
|
|
|
|
|
|
|
|
|
|
players = query_db('l2', sql, args)
|
|
|
|
|
total = query_db('l2', f"SELECT COUNT(*) as cnt FROM dim_players WHERE {where_str}", args[:-2], one=True)['cnt']
|
|
|
|
|
|
|
|
|
|
return players, total
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_player_info(steam_id):
|
|
|
|
|
sql = "SELECT * FROM dim_players WHERE steam_id_64 = ?"
|
|
|
|
|
return query_db('l2', sql, [steam_id], one=True)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_daily_match_counts(days=365):
|
|
|
|
|
# Return list of {date: 'YYYY-MM-DD', count: N}
|
|
|
|
|
sql = """
|
|
|
|
|
SELECT date(start_time, 'unixepoch') as day, COUNT(*) as count
|
|
|
|
|
FROM fact_matches
|
|
|
|
|
WHERE start_time > strftime('%s', 'now', ?)
|
|
|
|
|
GROUP BY day
|
|
|
|
|
ORDER BY day
|
|
|
|
|
"""
|
|
|
|
|
# sqlite modifier for 'now' needs format like '-365 days'
|
|
|
|
|
modifier = f'-{days} days'
|
|
|
|
|
rows = query_db('l2', sql, [modifier])
|
|
|
|
|
return rows
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_players_by_ids(steam_ids):
|
|
|
|
|
if not steam_ids:
|
|
|
|
|
return []
|
|
|
|
|
placeholders = ','.join('?' for _ in steam_ids)
|
|
|
|
|
sql = f"SELECT * FROM dim_players WHERE steam_id_64 IN ({placeholders})"
|
|
|
|
|
return query_db('l2', sql, steam_ids)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_player_basic_stats(steam_id):
|
|
|
|
|
# Calculate stats from fact_match_players
|
|
|
|
|
# Prefer calculating from sums (kills/deaths) for K/D accuracy
|
|
|
|
|
# AVG(adr) is used as damage_total might be missing in some sources
|
|
|
|
|
sql = """
|
|
|
|
|
SELECT
|
|
|
|
|
AVG(rating) as rating,
|
|
|
|
|
SUM(kills) as total_kills,
|
|
|
|
|
SUM(deaths) as total_deaths,
|
|
|
|
|
AVG(kd_ratio) as avg_kd,
|
|
|
|
|
AVG(kast) as kast,
|
|
|
|
|
AVG(adr) as adr,
|
|
|
|
|
COUNT(*) as matches_played
|
|
|
|
|
FROM fact_match_players
|
|
|
|
|
WHERE steam_id_64 = ?
|
|
|
|
|
"""
|
|
|
|
|
row = query_db('l2', sql, [steam_id], one=True)
|
|
|
|
|
|
|
|
|
|
if row and row['matches_played'] > 0:
|
|
|
|
|
res = dict(row)
|
|
|
|
|
|
|
|
|
|
# Calculate K/D: Sum Kills / Sum Deaths
|
|
|
|
|
kills = res.get('total_kills') or 0
|
|
|
|
|
deaths = res.get('total_deaths') or 0
|
|
|
|
|
|
|
|
|
|
if deaths > 0:
|
|
|
|
|
res['kd'] = kills / deaths
|
|
|
|
|
else:
|
|
|
|
|
res['kd'] = kills # If 0 deaths, K/D is kills (or infinity, but kills is safer for display)
|
|
|
|
|
|
|
|
|
|
# Fallback to avg_kd if calculation failed (e.g. both 0) but avg_kd exists
|
|
|
|
|
if res['kd'] == 0 and res['avg_kd'] and res['avg_kd'] > 0:
|
|
|
|
|
res['kd'] = res['avg_kd']
|
|
|
|
|
|
|
|
|
|
# ADR validation
|
|
|
|
|
if res['adr'] is None:
|
|
|
|
|
res['adr'] = 0.0
|
|
|
|
|
|
|
|
|
|
return res
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_shared_matches(steam_ids):
|
2026-01-26 02:22:09 +08:00
|
|
|
# Find matches where ALL steam_ids were present
|
|
|
|
|
if not steam_ids or len(steam_ids) < 1:
|
2026-01-26 02:13:06 +08:00
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
placeholders = ','.join('?' for _ in steam_ids)
|
|
|
|
|
count = len(steam_ids)
|
|
|
|
|
|
2026-01-26 02:22:09 +08:00
|
|
|
# We need to know which team the players were on to determine win/loss
|
|
|
|
|
# Assuming they were on the SAME team for "shared experience"
|
|
|
|
|
# If count=1, it's just match history
|
|
|
|
|
|
|
|
|
|
# Query: Get matches where all steam_ids are present
|
|
|
|
|
# Also join to get team_id to check if they were on the same team (optional but better)
|
|
|
|
|
# For simplicity in v1: Just check presence in the match.
|
|
|
|
|
# AND check if the player won.
|
|
|
|
|
|
|
|
|
|
# We need to return: match_id, map_name, score, result (Win/Loss)
|
|
|
|
|
# "Result" is relative to the lineup.
|
|
|
|
|
# If they were on the winning team, it's a Win.
|
|
|
|
|
|
2026-01-26 02:13:06 +08:00
|
|
|
sql = f"""
|
2026-01-26 02:22:09 +08:00
|
|
|
SELECT m.match_id, m.start_time, m.map_name, m.score_team1, m.score_team2, m.winner_team,
|
|
|
|
|
MAX(mp.team_id) as player_team_id -- Just take one team_id (assuming same)
|
2026-01-26 02:13:06 +08:00
|
|
|
FROM fact_matches m
|
|
|
|
|
JOIN fact_match_players mp ON m.match_id = mp.match_id
|
|
|
|
|
WHERE mp.steam_id_64 IN ({placeholders})
|
|
|
|
|
GROUP BY m.match_id
|
|
|
|
|
HAVING COUNT(DISTINCT mp.steam_id_64) = ?
|
|
|
|
|
ORDER BY m.start_time DESC
|
|
|
|
|
LIMIT 20
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
args = list(steam_ids)
|
|
|
|
|
args.append(count)
|
|
|
|
|
|
2026-01-26 02:22:09 +08:00
|
|
|
rows = query_db('l2', sql, args)
|
|
|
|
|
|
|
|
|
|
results = []
|
|
|
|
|
for r in rows:
|
|
|
|
|
# Determine if Win
|
|
|
|
|
# winner_team in DB is 'Team 1' or 'Team 2' usually, or the team name.
|
|
|
|
|
# fact_matches.winner_team stores the NAME of the winner? Or 'team1'/'team2'?
|
|
|
|
|
# Let's check how L2_Builder stores it. Usually it stores the name.
|
|
|
|
|
# But fact_match_players.team_id stores the name too.
|
|
|
|
|
|
|
|
|
|
# Logic: If m.winner_team == mp.team_id, then Win.
|
|
|
|
|
is_win = (r['winner_team'] == r['player_team_id'])
|
|
|
|
|
|
|
|
|
|
# If winner_team is NULL or empty, it's a draw?
|
|
|
|
|
if not r['winner_team']:
|
|
|
|
|
result_str = 'Draw'
|
|
|
|
|
elif is_win:
|
|
|
|
|
result_str = 'Win'
|
|
|
|
|
else:
|
|
|
|
|
result_str = 'Loss'
|
|
|
|
|
|
|
|
|
|
res = dict(r)
|
|
|
|
|
res['is_win'] = is_win # Boolean for styling
|
|
|
|
|
res['result_str'] = result_str # Text for display
|
|
|
|
|
results.append(res)
|
|
|
|
|
|
|
|
|
|
return results
|
2026-01-26 02:13:06 +08:00
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_player_trend(steam_id, limit=20):
|
|
|
|
|
sql = """
|
|
|
|
|
SELECT m.start_time, mp.rating, mp.kd_ratio, mp.adr, m.match_id, m.map_name
|
|
|
|
|
FROM fact_match_players mp
|
|
|
|
|
JOIN fact_matches m ON mp.match_id = m.match_id
|
|
|
|
|
WHERE mp.steam_id_64 = ?
|
|
|
|
|
ORDER BY m.start_time ASC
|
|
|
|
|
"""
|
|
|
|
|
# We fetch all then slice last 'limit' in python or use subquery.
|
|
|
|
|
# DESC LIMIT gets recent, but we want chronological for chart.
|
|
|
|
|
# So: SELECT ... ORDER BY time DESC LIMIT ? -> then reverse in code.
|
|
|
|
|
|
|
|
|
|
sql = """
|
|
|
|
|
SELECT * FROM (
|
|
|
|
|
SELECT m.start_time, mp.rating, mp.kd_ratio, mp.adr, m.match_id, m.map_name, mp.is_win
|
|
|
|
|
FROM fact_match_players mp
|
|
|
|
|
JOIN fact_matches m ON mp.match_id = m.match_id
|
|
|
|
|
WHERE mp.steam_id_64 = ?
|
|
|
|
|
ORDER BY m.start_time DESC
|
|
|
|
|
LIMIT ?
|
|
|
|
|
) ORDER BY start_time ASC
|
|
|
|
|
"""
|
|
|
|
|
return query_db('l2', sql, [steam_id, limit])
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_live_matches():
|
|
|
|
|
# Query matches started in last 2 hours with no winner
|
|
|
|
|
# Assuming we have a way to ingest live matches.
|
|
|
|
|
# For now, this query is 'formal' but will likely return empty on static dataset.
|
|
|
|
|
sql = """
|
|
|
|
|
SELECT m.match_id, m.map_name, m.score_team1, m.score_team2, m.start_time
|
|
|
|
|
FROM fact_matches m
|
|
|
|
|
WHERE m.winner_team IS NULL
|
|
|
|
|
AND m.start_time > strftime('%s', 'now', '-2 hours')
|
|
|
|
|
"""
|
|
|
|
|
return query_db('l2', sql)
|
|
|
|
|
|