from __future__ import annotations from typing import Any 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") if search: dim_rows = query_db( "l2", """ SELECT steam_id_64 FROM dim_players WHERE LOWER(username) LIKE LOWER(?) OR steam_id_64 LIKE ? """, [f"%{search}%", f"%{search}%"], ) matching_ids = [str(row["steam_id_64"]) for row in dim_rows] rows = [] for start in range(0, len(matching_ids), 500): chunk = matching_ids[start:start + 500] placeholders = ",".join("?" for _ in chunk) rows.extend(query_db( "l3", f"SELECT * FROM dm_player_features " f"WHERE steam_id_64 IN ({placeholders})", chunk, )) rows = sorted( rows, key=lambda row: row[order_col] if row[order_col] is not None else float("-inf"), reverse=True, ) total = len(rows) rows = rows[offset:offset + per_page] else: rows = query_db( "l3", f"SELECT * FROM dm_player_features " f"ORDER BY {order_col} DESC LIMIT ? OFFSET ?", [per_page, offset], ) total_row = query_db( "l3", "SELECT COUNT(*) as cnt FROM dm_player_features", 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.team_context_service import TeamContextService roster_ids = TeamContextService.get_active_roster_ids() if str(target_steam_id) not in roster_ids: 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", "int_trade_response_time", "tac_avg_fd", "tac_fd_rate", "core_avg_match_duration", "core_dpr", "meta_rating_volatility", "meta_map_stability", "meta_elo_tier_stability", } 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) if v is None: continue try: values.append(float(v)) except (ValueError, TypeError): continue target_val_raw = (stats_map.get(target_steam_id) or {}).get(m) if target_val_raw is None or not values: result[m] = None continue try: target_val = float(target_val_raw) except (ValueError, TypeError): result[m] = None continue 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), "max": max(values_sorted), "avg": sum(values_sorted) / len(values_sorted), "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