Remastered Startup.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import subprocess
|
||||
import os
|
||||
import sys
|
||||
from web.config import Config
|
||||
|
||||
class EtlService:
|
||||
@staticmethod
|
||||
def run_script(script_name, args=None):
|
||||
"""
|
||||
Executes an ETL script located in the ETL directory.
|
||||
Returns (success, message)
|
||||
"""
|
||||
script_path = os.path.join(Config.BASE_DIR, 'ETL', script_name)
|
||||
|
||||
if not os.path.exists(script_path):
|
||||
return False, f"Script not found: {script_path}"
|
||||
|
||||
try:
|
||||
# Use the same python interpreter
|
||||
python_exe = sys.executable
|
||||
|
||||
cmd = [python_exe, script_path]
|
||||
if args:
|
||||
cmd.extend(args)
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=Config.BASE_DIR,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300 # 5 min timeout
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True, f"Success:\n{result.stdout}"
|
||||
else:
|
||||
return False, f"Failed (Code {result.returncode}):\n{result.stderr}\n{result.stdout}"
|
||||
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
|
||||
from web.database import query_db
|
||||
|
||||
|
||||
class FeatureService:
|
||||
@staticmethod
|
||||
def _normalize_features(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not row:
|
||||
return None
|
||||
|
||||
f = dict(row)
|
||||
|
||||
alias_map: dict[str, str] = {
|
||||
"matches_played": "total_matches",
|
||||
"rounds_played": "total_rounds",
|
||||
"basic_avg_rating": "core_avg_rating",
|
||||
"basic_avg_rating2": "core_avg_rating2",
|
||||
"basic_avg_kd": "core_avg_kd",
|
||||
"basic_avg_adr": "core_avg_adr",
|
||||
"basic_avg_kast": "core_avg_kast",
|
||||
"basic_avg_rws": "core_avg_rws",
|
||||
"basic_avg_headshot_kills": "core_avg_hs_kills",
|
||||
"basic_headshot_rate": "core_hs_rate",
|
||||
"basic_avg_assisted_kill": "core_avg_assists",
|
||||
"basic_avg_awp_kill": "core_avg_awp_kills",
|
||||
"basic_avg_knife_kill": "core_avg_knife_kills",
|
||||
"basic_avg_zeus_kill": "core_avg_zeus_kills",
|
||||
"basic_zeus_pick_rate": "core_zeus_buy_rate",
|
||||
"basic_avg_mvps": "core_avg_mvps",
|
||||
"basic_avg_plants": "core_avg_plants",
|
||||
"basic_avg_defuses": "core_avg_defuses",
|
||||
"basic_avg_flash_assists": "core_avg_flash_assists",
|
||||
"basic_avg_first_kill": "tac_avg_fk",
|
||||
"basic_avg_first_death": "tac_avg_fd",
|
||||
"basic_first_kill_rate": "tac_fk_rate",
|
||||
"basic_first_death_rate": "tac_fd_rate",
|
||||
"basic_avg_kill_2": "tac_avg_2k",
|
||||
"basic_avg_kill_3": "tac_avg_3k",
|
||||
"basic_avg_kill_4": "tac_avg_4k",
|
||||
"basic_avg_kill_5": "tac_avg_5k",
|
||||
"util_usage_rate": "tac_util_usage_rate",
|
||||
"util_avg_nade_dmg": "tac_util_nade_dmg_per_round",
|
||||
"util_avg_flash_time": "tac_util_flash_time_per_round",
|
||||
"util_avg_flash_enemy": "tac_util_flash_enemies_per_round",
|
||||
"eco_avg_damage_per_1k": "tac_eco_dmg_per_1k",
|
||||
"eco_rating_eco_rounds": "tac_eco_kpr_eco_rounds",
|
||||
"pace_trade_kill_rate": "int_trade_kill_rate",
|
||||
"pace_avg_time_to_first_contact": "int_timing_first_contact_time",
|
||||
"score_sta": "score_stability",
|
||||
"score_bat": "score_aim",
|
||||
"score_hps": "score_clutch",
|
||||
"score_ptl": "score_pistol",
|
||||
"score_tct": "score_defense",
|
||||
"score_util": "score_utility",
|
||||
"score_eco": "score_economy",
|
||||
"score_pace": "score_pace",
|
||||
"side_rating_ct": "meta_side_ct_rating",
|
||||
"side_rating_t": "meta_side_t_rating",
|
||||
"side_kd_ct": "meta_side_ct_kd",
|
||||
"side_kd_t": "meta_side_t_kd",
|
||||
"side_win_rate_ct": "meta_side_ct_win_rate",
|
||||
"side_win_rate_t": "meta_side_t_win_rate",
|
||||
"side_first_kill_rate_ct": "meta_side_ct_fk_rate",
|
||||
"side_first_kill_rate_t": "meta_side_t_fk_rate",
|
||||
"sta_rating_volatility": "meta_rating_volatility",
|
||||
"sta_recent_form_rating": "meta_recent_form_rating",
|
||||
"sta_win_rating": "meta_win_rating",
|
||||
"sta_loss_rating": "meta_loss_rating",
|
||||
"map_best_map": "meta_map_best_map",
|
||||
"map_best_rating": "meta_map_best_rating",
|
||||
"map_worst_map": "meta_map_worst_map",
|
||||
"map_worst_rating": "meta_map_worst_rating",
|
||||
"map_pool_size": "meta_map_pool_size",
|
||||
"map_diversity": "meta_map_diversity",
|
||||
}
|
||||
|
||||
for legacy_key, l3_key in alias_map.items():
|
||||
if legacy_key not in f or f.get(legacy_key) is None:
|
||||
f[legacy_key] = f.get(l3_key)
|
||||
|
||||
if f.get("matches_played") is None:
|
||||
f["matches_played"] = f.get("total_matches", 0) or 0
|
||||
if f.get("rounds_played") is None:
|
||||
f["rounds_played"] = f.get("total_rounds", 0) or 0
|
||||
|
||||
return f
|
||||
|
||||
@staticmethod
|
||||
def get_player_features(steam_id: str) -> dict[str, Any] | None:
|
||||
row = query_db("l3", "SELECT * FROM dm_player_features WHERE steam_id_64 = ?", [steam_id], one=True)
|
||||
return FeatureService._normalize_features(dict(row) if row else None)
|
||||
|
||||
@staticmethod
|
||||
def _attach_player_dim(players: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
if not players:
|
||||
return players
|
||||
steam_ids = [p["steam_id_64"] for p in players if p.get("steam_id_64")]
|
||||
if not steam_ids:
|
||||
return players
|
||||
|
||||
placeholders = ",".join("?" for _ in steam_ids)
|
||||
dim_rows = query_db(
|
||||
"l2",
|
||||
f"SELECT steam_id_64, username, avatar_url FROM dim_players WHERE steam_id_64 IN ({placeholders})",
|
||||
steam_ids,
|
||||
)
|
||||
dim_map = {str(r["steam_id_64"]): dict(r) for r in dim_rows} if dim_rows else {}
|
||||
|
||||
# Import StatsService here to avoid circular dependency
|
||||
from web.services.stats_service import StatsService
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for p in players:
|
||||
sid = str(p.get("steam_id_64"))
|
||||
d = dim_map.get(sid, {})
|
||||
merged = dict(p)
|
||||
merged.setdefault("username", d.get("username") or sid)
|
||||
|
||||
# Resolve avatar URL (check local override first)
|
||||
db_avatar_url = d.get("avatar_url")
|
||||
merged.setdefault("avatar_url", StatsService.resolve_avatar_url(sid, db_avatar_url))
|
||||
|
||||
out.append(merged)
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def get_players_list(page: int = 1, per_page: int = 20, sort_by: str = "rating", search: str | None = None):
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
sort_map = {
|
||||
"rating": "core_avg_rating",
|
||||
"kd": "core_avg_kd",
|
||||
"kast": "core_avg_kast",
|
||||
"matches": "total_matches",
|
||||
}
|
||||
order_col = sort_map.get(sort_by, "core_avg_rating")
|
||||
|
||||
where = []
|
||||
args: list[Any] = []
|
||||
if search:
|
||||
where.append("steam_id_64 IN (SELECT steam_id_64 FROM dim_players WHERE username LIKE ?)")
|
||||
args.append(f"%{search}%")
|
||||
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
|
||||
|
||||
rows = query_db(
|
||||
"l3",
|
||||
f"SELECT * FROM dm_player_features {where_sql} ORDER BY {order_col} DESC LIMIT ? OFFSET ?",
|
||||
args + [per_page, offset],
|
||||
)
|
||||
total_row = query_db("l3", f"SELECT COUNT(*) as cnt FROM dm_player_features {where_sql}", args, one=True)
|
||||
total = int(total_row["cnt"]) if total_row else 0
|
||||
|
||||
players = [FeatureService._normalize_features(dict(r)) for r in rows] if rows else []
|
||||
players = [p for p in players if p]
|
||||
players = FeatureService._attach_player_dim(players)
|
||||
return players, total
|
||||
|
||||
@staticmethod
|
||||
def get_roster_features_distribution(target_steam_id: str):
|
||||
from web.services.web_service import WebService
|
||||
import json
|
||||
|
||||
lineups = WebService.get_lineups()
|
||||
roster_ids: list[str] = []
|
||||
|
||||
if lineups:
|
||||
try:
|
||||
p_ids = [str(i) for i in json.loads(lineups[0].get("player_ids_json") or "[]")]
|
||||
if str(target_steam_id) in p_ids:
|
||||
roster_ids = p_ids
|
||||
except Exception:
|
||||
roster_ids = []
|
||||
|
||||
if not roster_ids:
|
||||
return None
|
||||
|
||||
placeholders = ",".join("?" for _ in roster_ids)
|
||||
rows = query_db("l3", f"SELECT * FROM dm_player_features WHERE steam_id_64 IN ({placeholders})", roster_ids)
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
stats_map = {str(r["steam_id_64"]): FeatureService._normalize_features(dict(r)) for r in rows}
|
||||
target_steam_id = str(target_steam_id)
|
||||
if target_steam_id not in stats_map:
|
||||
stats_map[target_steam_id] = {}
|
||||
|
||||
# Define excluded keys (metadata, text fields)
|
||||
excluded_keys = {
|
||||
"steam_id_64", "last_updated", "first_match_date", "last_match_date",
|
||||
"core_top_weapon", "int_pos_favorite_position", "meta_side_preference",
|
||||
"meta_map_best_map", "meta_map_worst_map", "tier_classification",
|
||||
"username", "avatar_url"
|
||||
}
|
||||
|
||||
# Get all keys from the first available player record to determine what to calculate
|
||||
sample_keys = []
|
||||
for p in stats_map.values():
|
||||
if p:
|
||||
sample_keys = list(p.keys())
|
||||
break
|
||||
|
||||
lower_is_better = {"int_timing_first_contact_time", "tac_avg_fd", "core_avg_match_duration"}
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
for m in sample_keys:
|
||||
if m in excluded_keys:
|
||||
continue
|
||||
|
||||
# Check if value is numeric (using the first non-None value found)
|
||||
is_numeric = False
|
||||
for p in stats_map.values():
|
||||
val = (p or {}).get(m)
|
||||
if val is not None:
|
||||
if isinstance(val, (int, float)):
|
||||
is_numeric = True
|
||||
break
|
||||
|
||||
if not is_numeric:
|
||||
continue
|
||||
|
||||
values = []
|
||||
for p in stats_map.values():
|
||||
v = (p or {}).get(m)
|
||||
try:
|
||||
values.append(float(v) if v is not None else 0.0)
|
||||
except (ValueError, TypeError):
|
||||
values.append(0.0)
|
||||
|
||||
target_val_raw = (stats_map.get(target_steam_id) or {}).get(m)
|
||||
try:
|
||||
target_val = float(target_val_raw) if target_val_raw is not None else 0.0
|
||||
except (ValueError, TypeError):
|
||||
target_val = 0.0
|
||||
|
||||
is_reverse = m not in lower_is_better
|
||||
# Sort values. For standard metrics, higher is better (reverse=True).
|
||||
# For lower-is-better (like death rate, contact time), we want sort ascending.
|
||||
values_sorted = sorted(values, reverse=is_reverse)
|
||||
|
||||
try:
|
||||
# Find rank. Index is 0-based, so +1.
|
||||
# Note: this finds the first occurrence.
|
||||
rank = values_sorted.index(target_val) + 1
|
||||
except ValueError:
|
||||
rank = len(values_sorted)
|
||||
|
||||
result[m] = {
|
||||
"val": target_val,
|
||||
"rank": rank,
|
||||
"total": len(values_sorted),
|
||||
"min": min(values_sorted) if values_sorted else 0,
|
||||
"max": max(values_sorted) if values_sorted else 0,
|
||||
"avg": (sum(values_sorted) / len(values_sorted)) if values_sorted else 0,
|
||||
"inverted": not is_reverse,
|
||||
}
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def rebuild_all_features(min_matches: int = 5):
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"FeatureService.rebuild_all_features() 已废弃,请直接运行 database/L3/L3_Builder.py",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return -1
|
||||
@@ -0,0 +1,404 @@
|
||||
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 = []
|
||||
# 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})
|
||||
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
|
||||
from web.services.stats_service import StatsService
|
||||
player = dict(info)
|
||||
player['avatar_url'] = StatsService.resolve_avatar_url(steam_id, player.get('avatar_url'))
|
||||
|
||||
# 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 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 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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WeaponInfo:
|
||||
name: str
|
||||
price: int
|
||||
side: str
|
||||
category: str
|
||||
|
||||
|
||||
_WEAPON_TABLE = {
|
||||
"glock": WeaponInfo(name="Glock-18", price=200, side="T", category="pistol"),
|
||||
"hkp2000": WeaponInfo(name="P2000", price=200, side="CT", category="pistol"),
|
||||
"usp_silencer": WeaponInfo(name="USP-S", price=200, side="CT", category="pistol"),
|
||||
"elite": WeaponInfo(name="Dual Berettas", price=300, side="Both", category="pistol"),
|
||||
"p250": WeaponInfo(name="P250", price=300, side="Both", category="pistol"),
|
||||
"tec9": WeaponInfo(name="Tec-9", price=500, side="T", category="pistol"),
|
||||
"fiveseven": WeaponInfo(name="Five-SeveN", price=500, side="CT", category="pistol"),
|
||||
"cz75a": WeaponInfo(name="CZ75-Auto", price=500, side="Both", category="pistol"),
|
||||
"revolver": WeaponInfo(name="R8 Revolver", price=600, side="Both", category="pistol"),
|
||||
"deagle": WeaponInfo(name="Desert Eagle", price=700, side="Both", category="pistol"),
|
||||
"mac10": WeaponInfo(name="MAC-10", price=1050, side="T", category="smg"),
|
||||
"mp9": WeaponInfo(name="MP9", price=1250, side="CT", category="smg"),
|
||||
"ump45": WeaponInfo(name="UMP-45", price=1200, side="Both", category="smg"),
|
||||
"bizon": WeaponInfo(name="PP-Bizon", price=1400, side="Both", category="smg"),
|
||||
"mp7": WeaponInfo(name="MP7", price=1500, side="Both", category="smg"),
|
||||
"mp5sd": WeaponInfo(name="MP5-SD", price=1500, side="Both", category="smg"),
|
||||
"nova": WeaponInfo(name="Nova", price=1050, side="Both", category="shotgun"),
|
||||
"mag7": WeaponInfo(name="MAG-7", price=1300, side="CT", category="shotgun"),
|
||||
"sawedoff": WeaponInfo(name="Sawed-Off", price=1100, side="T", category="shotgun"),
|
||||
"xm1014": WeaponInfo(name="XM1014", price=2000, side="Both", category="shotgun"),
|
||||
"galilar": WeaponInfo(name="Galil AR", price=1800, side="T", category="rifle"),
|
||||
"famas": WeaponInfo(name="FAMAS", price=2050, side="CT", category="rifle"),
|
||||
"ak47": WeaponInfo(name="AK-47", price=2700, side="T", category="rifle"),
|
||||
"m4a1": WeaponInfo(name="M4A4", price=2900, side="CT", category="rifle"),
|
||||
"m4a1_silencer": WeaponInfo(name="M4A1-S", price=2900, side="CT", category="rifle"),
|
||||
"aug": WeaponInfo(name="AUG", price=3300, side="CT", category="rifle"),
|
||||
"sg556": WeaponInfo(name="SG 553", price=3300, side="T", category="rifle"),
|
||||
"awp": WeaponInfo(name="AWP", price=4750, side="Both", category="sniper"),
|
||||
"scar20": WeaponInfo(name="SCAR-20", price=5000, side="CT", category="sniper"),
|
||||
"g3sg1": WeaponInfo(name="G3SG1", price=5000, side="T", category="sniper"),
|
||||
"negev": WeaponInfo(name="Negev", price=1700, side="Both", category="lmg"),
|
||||
"m249": WeaponInfo(name="M249", price=5200, side="Both", category="lmg"),
|
||||
}
|
||||
|
||||
_ALIASES = {
|
||||
"weapon_glock": "glock",
|
||||
"weapon_hkp2000": "hkp2000",
|
||||
"weapon_usp_silencer": "usp_silencer",
|
||||
"weapon_elite": "elite",
|
||||
"weapon_p250": "p250",
|
||||
"weapon_tec9": "tec9",
|
||||
"weapon_fiveseven": "fiveseven",
|
||||
"weapon_cz75a": "cz75a",
|
||||
"weapon_revolver": "revolver",
|
||||
"weapon_deagle": "deagle",
|
||||
"weapon_mac10": "mac10",
|
||||
"weapon_mp9": "mp9",
|
||||
"weapon_ump45": "ump45",
|
||||
"weapon_bizon": "bizon",
|
||||
"weapon_mp7": "mp7",
|
||||
"weapon_mp5sd": "mp5sd",
|
||||
"weapon_nova": "nova",
|
||||
"weapon_mag7": "mag7",
|
||||
"weapon_sawedoff": "sawedoff",
|
||||
"weapon_xm1014": "xm1014",
|
||||
"weapon_galilar": "galilar",
|
||||
"weapon_famas": "famas",
|
||||
"weapon_ak47": "ak47",
|
||||
"weapon_m4a1": "m4a1",
|
||||
"weapon_m4a1_silencer": "m4a1_silencer",
|
||||
"weapon_aug": "aug",
|
||||
"weapon_sg556": "sg556",
|
||||
"weapon_awp": "awp",
|
||||
"weapon_scar20": "scar20",
|
||||
"weapon_g3sg1": "g3sg1",
|
||||
"weapon_negev": "negev",
|
||||
"weapon_m249": "m249",
|
||||
"m4a4": "m4a1",
|
||||
"m4a1-s": "m4a1_silencer",
|
||||
"m4a1s": "m4a1_silencer",
|
||||
"sg553": "sg556",
|
||||
"pp-bizon": "bizon",
|
||||
}
|
||||
|
||||
|
||||
def normalize_weapon_name(raw: Optional[str]) -> str:
|
||||
if not raw:
|
||||
return ""
|
||||
s = str(raw).strip().lower()
|
||||
if not s:
|
||||
return ""
|
||||
s = s.replace(" ", "").replace("\t", "").replace("\n", "")
|
||||
s = s.replace("weapon_", "weapon_")
|
||||
if s in _ALIASES:
|
||||
return _ALIASES[s]
|
||||
if s.startswith("weapon_") and s in _ALIASES:
|
||||
return _ALIASES[s]
|
||||
if s.startswith("weapon_"):
|
||||
s2 = s[len("weapon_") :]
|
||||
return _ALIASES.get(s2, s2)
|
||||
return _ALIASES.get(s, s)
|
||||
|
||||
|
||||
def get_weapon_info(raw: Optional[str]) -> Optional[WeaponInfo]:
|
||||
key = normalize_weapon_name(raw)
|
||||
if not key:
|
||||
return None
|
||||
return _WEAPON_TABLE.get(key)
|
||||
|
||||
|
||||
def get_weapon_price(raw: Optional[str]) -> Optional[int]:
|
||||
info = get_weapon_info(raw)
|
||||
return info.price if info else None
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
from web.database import query_db, execute_db
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
class WebService:
|
||||
# --- Comments ---
|
||||
@staticmethod
|
||||
def get_comments(target_type, target_id):
|
||||
sql = "SELECT * FROM comments WHERE target_type = ? AND target_id = ? AND is_hidden = 0 ORDER BY created_at DESC"
|
||||
return query_db('web', sql, [target_type, target_id])
|
||||
|
||||
@staticmethod
|
||||
def add_comment(user_id, username, target_type, target_id, content):
|
||||
sql = """
|
||||
INSERT INTO comments (user_id, username, target_type, target_id, content)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
"""
|
||||
return execute_db('web', sql, [user_id, username, target_type, target_id, content])
|
||||
|
||||
@staticmethod
|
||||
def like_comment(comment_id):
|
||||
sql = "UPDATE comments SET likes = likes + 1 WHERE id = ?"
|
||||
return execute_db('web', sql, [comment_id])
|
||||
|
||||
# --- Wiki ---
|
||||
@staticmethod
|
||||
def get_wiki_page(path):
|
||||
sql = "SELECT * FROM wiki_pages WHERE path = ?"
|
||||
return query_db('web', sql, [path], one=True)
|
||||
|
||||
@staticmethod
|
||||
def get_all_wiki_pages():
|
||||
sql = "SELECT path, title FROM wiki_pages ORDER BY path"
|
||||
return query_db('web', sql)
|
||||
|
||||
@staticmethod
|
||||
def save_wiki_page(path, title, content, updated_by):
|
||||
# Upsert logic
|
||||
check = query_db('web', "SELECT id FROM wiki_pages WHERE path = ?", [path], one=True)
|
||||
if check:
|
||||
sql = "UPDATE wiki_pages SET title=?, content=?, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE path=?"
|
||||
execute_db('web', sql, [title, content, updated_by, path])
|
||||
else:
|
||||
sql = "INSERT INTO wiki_pages (path, title, content, updated_by) VALUES (?, ?, ?, ?)"
|
||||
execute_db('web', sql, [path, title, content, updated_by])
|
||||
|
||||
# --- Team Lineups ---
|
||||
@staticmethod
|
||||
def save_lineup(name, description, player_ids, lineup_id=None):
|
||||
# player_ids is a list
|
||||
ids_json = json.dumps(player_ids)
|
||||
if lineup_id:
|
||||
sql = "UPDATE team_lineups SET name=?, description=?, player_ids_json=? WHERE id=?"
|
||||
return execute_db('web', sql, [name, description, ids_json, lineup_id])
|
||||
else:
|
||||
sql = "INSERT INTO team_lineups (name, description, player_ids_json) VALUES (?, ?, ?)"
|
||||
return execute_db('web', sql, [name, description, ids_json])
|
||||
|
||||
@staticmethod
|
||||
def get_lineups():
|
||||
return query_db('web', "SELECT * FROM team_lineups ORDER BY created_at DESC")
|
||||
|
||||
@staticmethod
|
||||
def get_lineup(lineup_id):
|
||||
return query_db('web', "SELECT * FROM team_lineups WHERE id = ?", [lineup_id], one=True)
|
||||
|
||||
|
||||
# --- Users / Auth ---
|
||||
@staticmethod
|
||||
def get_user_by_token(token):
|
||||
sql = "SELECT * FROM users WHERE token = ?"
|
||||
return query_db('web', sql, [token], one=True)
|
||||
|
||||
# --- Player Metadata ---
|
||||
@staticmethod
|
||||
def get_player_metadata(steam_id):
|
||||
sql = "SELECT * FROM player_metadata WHERE steam_id_64 = ?"
|
||||
row = query_db('web', sql, [steam_id], one=True)
|
||||
if row:
|
||||
res = dict(row)
|
||||
try:
|
||||
res['tags'] = json.loads(res['tags']) if res['tags'] else []
|
||||
except:
|
||||
res['tags'] = []
|
||||
return res
|
||||
return {'steam_id_64': steam_id, 'notes': '', 'tags': []}
|
||||
|
||||
@staticmethod
|
||||
def update_player_metadata(steam_id, notes=None, tags=None):
|
||||
# Upsert
|
||||
check = query_db('web', "SELECT steam_id_64 FROM player_metadata WHERE steam_id_64 = ?", [steam_id], one=True)
|
||||
|
||||
tags_json = json.dumps(tags) if tags is not None else None
|
||||
|
||||
if check:
|
||||
# Update
|
||||
clauses = []
|
||||
args = []
|
||||
if notes is not None:
|
||||
clauses.append("notes = ?")
|
||||
args.append(notes)
|
||||
if tags is not None:
|
||||
clauses.append("tags = ?")
|
||||
args.append(tags_json)
|
||||
|
||||
if clauses:
|
||||
clauses.append("updated_at = CURRENT_TIMESTAMP")
|
||||
sql = f"UPDATE player_metadata SET {', '.join(clauses)} WHERE steam_id_64 = ?"
|
||||
args.append(steam_id)
|
||||
execute_db('web', sql, args)
|
||||
else:
|
||||
# Insert
|
||||
sql = "INSERT INTO player_metadata (steam_id_64, notes, tags) VALUES (?, ?, ?)"
|
||||
execute_db('web', sql, [steam_id, notes or '', tags_json or '[]'])
|
||||
|
||||
# --- Strategy Board ---
|
||||
@staticmethod
|
||||
def save_strategy_board(title, map_name, data_json, created_by):
|
||||
sql = "INSERT INTO strategy_boards (title, map_name, data_json, created_by) VALUES (?, ?, ?, ?)"
|
||||
return execute_db('web', sql, [title, map_name, data_json, created_by])
|
||||
Reference in New Issue
Block a user