CoolFace
Apppublic

thenuke02/cs2-analyzer

sourceHugging Faceupdated 7mo agoView on Hugging Face
0likes
player_tracker.py1162 linesDownload Raw Back to ai
1"""2Cross-Match Player Development Tracking.3 4Tracks player metrics over time across multiple demos, identifies5improvement trends and regression, provides role-specific benchmarks6by competitive level, and generates practice recommendations.7 8Builds on top of the existing MatchHistory table and DatabaseManager9infrastructure rather than creating separate storage.10"""11 12from __future__ import annotations13 14import logging15import statistics16from dataclasses import dataclass, field17from typing import Any18 19logger = logging.getLogger(__name__)20 21 22# =============================================================================23# Data Models24# =============================================================================25 26 27@dataclass28class MatchSnapshot:29    """Snapshot of a single match's key metrics for tracking.30 31    Can be constructed from orchestrator output or from MatchHistory DB rows.32    """33 34    # Identity35    steam_id: str36    demo_hash: str37    map_name: str | None = None38    result: str | None = None  # "win", "loss", "draw"39    analyzed_at: str | None = None40 41    # Core stats42    kills: int = 043    deaths: int = 044    assists: int = 045    adr: float = 0.046    kast: float = 0.047    hs_pct: float = 0.048    rounds_played: int = 049 50    # Ratings51    hltv_rating: float = 1.052    aim_rating: float = 0.053    utility_rating: float = 0.054 55    # Advanced56    ttd_median_ms: float | None = None57    cp_median_deg: float | None = None58 59    # Duel stats60    entry_attempts: int = 061    entry_success: int = 062    clutch_situations: int = 063    clutch_wins: int = 064    trade_kill_success: int = 065    trade_kill_attempts: int = 066 67    # Utility68    enemies_flashed: int = 069    flash_assists: int = 070    he_damage: int = 071 72    def to_dict(self) -> dict[str, Any]:73        """Serialize to dict."""74        return {75            "steam_id": self.steam_id,76            "demo_hash": self.demo_hash,77            "map_name": self.map_name,78            "result": self.result,79            "analyzed_at": self.analyzed_at,80            "kills": self.kills,81            "deaths": self.deaths,82            "assists": self.assists,83            "adr": round(self.adr, 1),84            "kast": round(self.kast, 1),85            "hs_pct": round(self.hs_pct, 1),86            "rounds_played": self.rounds_played,87            "hltv_rating": round(self.hltv_rating, 2),88            "aim_rating": round(self.aim_rating, 1),89            "utility_rating": round(self.utility_rating, 1),90            "ttd_median_ms": (91                round(self.ttd_median_ms, 1) if self.ttd_median_ms is not None else None92            ),93            "cp_median_deg": (94                round(self.cp_median_deg, 1) if self.cp_median_deg is not None else None95            ),96            "entry_attempts": self.entry_attempts,97            "entry_success": self.entry_success,98            "clutch_situations": self.clutch_situations,99            "clutch_wins": self.clutch_wins,100            "trade_kill_success": self.trade_kill_success,101            "trade_kill_attempts": self.trade_kill_attempts,102            "enemies_flashed": self.enemies_flashed,103            "flash_assists": self.flash_assists,104            "he_damage": self.he_damage,105        }106 107 108@dataclass109class TrendAnalysis:110    """Trend analysis for a single metric across match windows.111 112    Compares: current match vs recent (last 5) vs historical (all matches).113    """114 115    metric_name: str116    current_value: float117    recent_avg: float  # Last RECENT_WINDOW matches118    historical_avg: float  # All matches119    recent_std: float = 0.0120    historical_std: float = 0.0121    direction: str = "stable"  # "improving", "declining", "stable"122    change_pct: float = 0.0  # % change from historical to current123    sample_count: int = 0124 125    def to_dict(self) -> dict[str, Any]:126        """Serialize to dict."""127        return {128            "metric": self.metric_name,129            "current": round(self.current_value, 2),130            "recent_avg": round(self.recent_avg, 2),131            "historical_avg": round(self.historical_avg, 2),132            "recent_std": round(self.recent_std, 2),133            "historical_std": round(self.historical_std, 2),134            "direction": self.direction,135            "change_pct": round(self.change_pct, 1),136            "sample_count": self.sample_count,137        }138 139 140@dataclass141class RoleBenchmark:142    """Benchmark comparison for a metric against competitive levels."""143 144    metric_name: str145    player_value: float146    level: str  # "beginner", "intermediate", "advanced", "elite"147    level_avg: float148    level_low: float149    level_high: float150    percentile_in_level: float  # 0-100, where in this level the player falls151    verdict: str  # "below", "at", "above" relative to level avg152 153    def to_dict(self) -> dict[str, Any]:154        """Serialize to dict."""155        return {156            "metric": self.metric_name,157            "player_value": round(self.player_value, 2),158            "level": self.level,159            "level_avg": round(self.level_avg, 2),160            "level_range": [round(self.level_low, 2), round(self.level_high, 2)],161            "percentile_in_level": round(self.percentile_in_level, 1),162            "verdict": self.verdict,163        }164 165 166@dataclass167class PracticeRecommendation:168    """A specific practice recommendation based on trend analysis."""169 170    area: str  # "aim", "utility", "positioning", "economy", "trading", "entry"171    priority: str  # "high", "medium", "low"172    description: str173    current_value: float174    target_value: float175    drill: str176 177    def to_dict(self) -> dict[str, Any]:178        """Serialize to dict."""179        return {180            "area": self.area,181            "priority": self.priority,182            "description": self.description,183            "current_value": round(self.current_value, 2),184            "target_value": round(self.target_value, 2),185            "drill": self.drill,186        }187 188 189@dataclass190class DevelopmentReport:191    """Comprehensive player development report across matches."""192 193    steam_id: str194    match_count: int195    date_range: tuple[str, str] | None = None  # (earliest, latest) ISO dates196    current_snapshot: MatchSnapshot | None = None197    trends: list[TrendAnalysis] = field(default_factory=list)198    benchmarks: list[RoleBenchmark] = field(default_factory=list)199    recommendations: list[PracticeRecommendation] = field(default_factory=list)200    estimated_level: str = "intermediate"201    strengths: list[str] = field(default_factory=list)202    weaknesses: list[str] = field(default_factory=list)203    improvement_velocity: float = 0.0  # -1.0 to +1.0204    summary: str = ""205 206    def to_dict(self) -> dict[str, Any]:207        """Serialize to dict."""208        return {209            "steam_id": self.steam_id,210            "match_count": self.match_count,211            "date_range": list(self.date_range) if self.date_range else None,212            "current_snapshot": self.current_snapshot.to_dict() if self.current_snapshot else None,213            "trends": [t.to_dict() for t in self.trends],214            "benchmarks": [b.to_dict() for b in self.benchmarks],215            "recommendations": [r.to_dict() for r in self.recommendations],216            "estimated_level": self.estimated_level,217            "strengths": self.strengths,218            "weaknesses": self.weaknesses,219            "improvement_velocity": round(self.improvement_velocity, 2),220            "summary": self.summary,221        }222 223 224# =============================================================================225# Constants226# =============================================================================227 228# Metrics to track for trend analysis229# Maps: metric name -> (MatchSnapshot attr, higher_is_better)230TRACKED_METRICS: dict[str, tuple[str, bool]] = {231    "hltv_rating": ("hltv_rating", True),232    "adr": ("adr", True),233    "kast": ("kast", True),234    "hs_pct": ("hs_pct", True),235    "aim_rating": ("aim_rating", True),236    "utility_rating": ("utility_rating", True),237    "kills": ("kills", True),238    "deaths": ("deaths", False),  # Lower is better239}240 241# History column names to track (for DB-based trend analysis)242TRACKED_HISTORY_METRICS: list[str] = [243    "kills",244    "deaths",245    "adr",246    "kast",247    "hs_pct",248    "hltv_rating",249    "aim_rating",250    "utility_rating",251    "trade_kill_success",252    "trade_kill_attempts",253    "entry_success",254    "entry_attempts",255    "clutch_wins",256    "clutch_situations",257    "he_damage",258    "enemies_flashed",259    "flash_assists",260    "ttd_median_ms",261    "cp_median_deg",262]263 264# Metrics where lower is better265LOWER_IS_BETTER = {"deaths", "ttd_median_ms", "cp_median_deg"}266 267# Threshold for trend detection (5% relative change)268TREND_THRESHOLD = 0.05269 270# Recent window size for trend comparison271RECENT_WINDOW = 5272 273# Minimum matches required for meaningful analysis274MIN_MATCHES = 3275 276# Competitive level benchmarks for CS2277# Based on industry data: ESEA ranks, FACEIT levels, community averages278LEVEL_BENCHMARKS: dict[str, dict[str, dict[str, float]]] = {279    "beginner": {280        "hltv_rating": {"low": 0.0, "avg": 0.75, "high": 0.90},281        "adr": {"low": 0.0, "avg": 55.0, "high": 70.0},282        "kast": {"low": 0.0, "avg": 55.0, "high": 65.0},283        "hs_pct": {"low": 0.0, "avg": 30.0, "high": 40.0},284        "entry_win_rate": {"low": 0.0, "avg": 35.0, "high": 45.0},285        "clutch_win_rate": {"low": 0.0, "avg": 10.0, "high": 20.0},286        "trade_rate": {"low": 0.0, "avg": 30.0, "high": 40.0},287        "utility_rating": {"low": 0.0, "avg": 25.0, "high": 40.0},288    },289    "intermediate": {290        "hltv_rating": {"low": 0.80, "avg": 1.00, "high": 1.15},291        "adr": {"low": 60.0, "avg": 75.0, "high": 85.0},292        "kast": {"low": 60.0, "avg": 68.0, "high": 75.0},293        "hs_pct": {"low": 35.0, "avg": 42.0, "high": 50.0},294        "entry_win_rate": {"low": 40.0, "avg": 48.0, "high": 55.0},295        "clutch_win_rate": {"low": 15.0, "avg": 22.0, "high": 30.0},296        "trade_rate": {"low": 35.0, "avg": 45.0, "high": 55.0},297        "utility_rating": {"low": 30.0, "avg": 45.0, "high": 55.0},298    },299    "advanced": {300        "hltv_rating": {"low": 1.00, "avg": 1.15, "high": 1.30},301        "adr": {"low": 75.0, "avg": 85.0, "high": 95.0},302        "kast": {"low": 68.0, "avg": 73.0, "high": 80.0},303        "hs_pct": {"low": 42.0, "avg": 48.0, "high": 55.0},304        "entry_win_rate": {"low": 48.0, "avg": 52.0, "high": 58.0},305        "clutch_win_rate": {"low": 20.0, "avg": 28.0, "high": 35.0},306        "trade_rate": {"low": 45.0, "avg": 52.0, "high": 60.0},307        "utility_rating": {"low": 45.0, "avg": 55.0, "high": 65.0},308    },309    "elite": {310        "hltv_rating": {"low": 1.15, "avg": 1.30, "high": 1.50},311        "adr": {"low": 85.0, "avg": 92.0, "high": 105.0},312        "kast": {"low": 73.0, "avg": 78.0, "high": 85.0},313        "hs_pct": {"low": 48.0, "avg": 52.0, "high": 60.0},314        "entry_win_rate": {"low": 52.0, "avg": 55.0, "high": 62.0},315        "clutch_win_rate": {"low": 25.0, "avg": 32.0, "high": 40.0},316        "trade_rate": {"low": 50.0, "avg": 58.0, "high": 65.0},317        "utility_rating": {"low": 55.0, "avg": 65.0, "high": 75.0},318    },319}320 321# Persona ID -> role mapping (for practice recommendations)322PERSONA_ROLE_MAP: dict[str, str] = {323    "the_opener": "entry_fragger",324    "the_headhunter": "entry_fragger",325    "the_anchor": "anchor",326    "the_survivor": "anchor",327    "the_utility_master": "support",328    "the_flash_master": "support",329    "the_cleanup": "trader",330    "the_terminator": "trader",331    "the_lurker": "lurker",332    "the_damage_dealer": "fragger",333    "the_competitor": "fragger",334}335 336# Role-specific practice targets (what a good player in this role should hit)337ROLE_PRACTICE_TARGETS: dict[str, dict[str, float]] = {338    "entry_fragger": {339        "hs_pct": 55.0,340        "entry_success_rate": 55.0,341        "adr": 80.0,342        "hltv_rating": 1.10,343        "ttd_median_ms": 300.0,344        "cp_median_deg": 10.0,345    },346    "anchor": {347        "kast": 75.0,348        "clutch_success_rate": 30.0,349        "adr": 70.0,350        "hltv_rating": 1.05,351        "ttd_median_ms": 350.0,352        "cp_median_deg": 12.0,353    },354    "support": {355        "flash_assists": 3.0,356        "enemies_flashed": 8.0,357        "he_damage": 30.0,358        "kast": 72.0,359        "hltv_rating": 1.0,360        "adr": 65.0,361    },362    "trader": {363        "trade_success_rate": 55.0,364        "adr": 75.0,365        "kast": 72.0,366        "hltv_rating": 1.05,367        "ttd_median_ms": 320.0,368        "cp_median_deg": 11.0,369    },370    "lurker": {371        "adr": 80.0,372        "deaths": 15.0,  # lower is better373        "hltv_rating": 1.10,374        "kast": 72.0,375        "ttd_median_ms": 300.0,376        "cp_median_deg": 10.0,377    },378    "fragger": {379        "kills": 22.0,380        "adr": 85.0,381        "hltv_rating": 1.15,382        "hs_pct": 50.0,383        "ttd_median_ms": 300.0,384        "cp_median_deg": 10.0,385    },386}387 388 389# =============================================================================390# Player Tracker Engine391# =============================================================================392 393 394class PlayerTracker:395    """396    Cross-match player development tracker.397 398    Uses the existing DatabaseManager and MatchHistory table for storage.399    Provides snapshot extraction, trend analysis, level benchmarks,400    practice recommendations, and full development reports.401    """402 403    def __init__(self, db: Any | None = None) -> None:404        """Initialize with optional DatabaseManager instance.405 406        Args:407            db: DatabaseManager instance. If None, uses get_db().408        """409        self._db = db410 411    @property412    def db(self) -> Any:413        """Lazy-load database manager."""414        if self._db is None:415            from opensight.infra.database import get_db416 417            self._db = get_db()418        return self._db419 420    # =========================================================================421    # Snapshot Extraction422    # =========================================================================423 424    def extract_snapshot(425        self,426        orchestrator_result: dict[str, Any],427        steam_id: str,428    ) -> MatchSnapshot | None:429        """Extract a MatchSnapshot from an orchestrator result for a specific player.430 431        Args:432            orchestrator_result: Full orchestrator output dict433            steam_id: Player's Steam ID434 435        Returns:436            MatchSnapshot if player found, None otherwise437        """438        players = orchestrator_result.get("players", {})439        player = players.get(steam_id)440        if player is None:441            return None442 443        stats = player.get("stats", {})444        rating = player.get("rating", {})445        advanced = player.get("advanced", {})446        duels = player.get("duels", {})447        utility = player.get("utility", {})448        demo_info = orchestrator_result.get("demo_info", {})449 450        return MatchSnapshot(451            steam_id=steam_id,452            demo_hash=demo_info.get("demo_hash", ""),453            map_name=demo_info.get("map"),454            result=None,  # Determined after score comparison455            kills=stats.get("kills", 0),456            deaths=stats.get("deaths", 0),457            assists=stats.get("assists", 0),458            adr=stats.get("adr", 0.0),459            kast=rating.get("kast_percentage", 0.0),460            hs_pct=stats.get("headshot_pct", 0.0),461            rounds_played=stats.get("rounds_played", 0),462            hltv_rating=rating.get("hltv_rating", 1.0),463            aim_rating=rating.get("aim_rating", 0.0),464            utility_rating=rating.get("utility_rating", 0.0),465            ttd_median_ms=advanced.get("ttd_median_ms"),466            cp_median_deg=advanced.get("cp_median_error_deg"),467            entry_attempts=duels.get("opening_kills", 0) + duels.get("opening_deaths", 0),468            entry_success=duels.get("opening_kills", 0),469            clutch_situations=duels.get("clutch_attempts", 0),470            clutch_wins=duels.get("clutch_wins", 0),471            trade_kill_success=duels.get("trade_kills", 0),472            trade_kill_attempts=duels.get("trade_kill_opportunities", 0),473            enemies_flashed=utility.get("enemies_flashed", 0),474            flash_assists=utility.get("flash_assists", 0),475            he_damage=utility.get("he_damage", 0),476        )477 478    def snapshot_from_history(self, history_row: dict[str, Any]) -> MatchSnapshot:479        """Convert a get_player_history_full() row to a MatchSnapshot.480 481        Args:482            history_row: Dict from DatabaseManager.get_player_history_full()483 484        Returns:485            MatchSnapshot486        """487        return MatchSnapshot(488            steam_id=history_row.get("steam_id", ""),489            demo_hash=history_row.get("demo_hash", ""),490            map_name=history_row.get("map_name"),491            result=history_row.get("result"),492            analyzed_at=history_row.get("analyzed_at"),493            kills=history_row.get("kills", 0),494            deaths=history_row.get("deaths", 0),495            assists=history_row.get("assists", 0),496            adr=history_row.get("adr", 0.0),497            kast=history_row.get("kast", 0.0),498            hs_pct=history_row.get("hs_pct", 0.0),499            rounds_played=history_row.get("rounds_played", 0),500            hltv_rating=history_row.get("hltv_rating", 1.0),501            aim_rating=history_row.get("aim_rating", 0.0),502            utility_rating=history_row.get("utility_rating", 0.0),503            ttd_median_ms=history_row.get("ttd_median_ms"),504            cp_median_deg=history_row.get("cp_median_deg"),505            entry_attempts=history_row.get("entry_attempts", 0),506            entry_success=history_row.get("entry_success", 0),507            clutch_situations=history_row.get("clutch_situations", 0),508            clutch_wins=history_row.get("clutch_wins", 0),509            trade_kill_success=history_row.get("trade_kill_success", 0),510            trade_kill_attempts=history_row.get("trade_kill_attempts", 0),511            enemies_flashed=history_row.get("enemies_flashed", 0),512            flash_assists=history_row.get("flash_assists", 0),513            he_damage=history_row.get("he_damage", 0),514        )515 516    # =========================================================================517    # Trend Analysis518    # =========================================================================519 520    def analyze_trends(521        self,522        snapshots: list[MatchSnapshot],523        current: MatchSnapshot | None = None,524    ) -> list[TrendAnalysis]:525        """Analyze metric trends across match snapshots.526 527        Compares current match performance against:528        - Recent window (last RECENT_WINDOW matches)529        - Historical average (all matches)530 531        Args:532            snapshots: List of MatchSnapshots ordered oldest-first533            current: Optional current match snapshot (if not already in list)534 535        Returns:536            List of TrendAnalysis for each tracked metric537        """538        if not snapshots:539            return []540 541        all_snapshots = list(snapshots)542        if current is not None:543            all_snapshots.append(current)544 545        if len(all_snapshots) < MIN_MATCHES:546            return []547 548        trends = []549        for metric_name, (attr_name, higher_is_better) in TRACKED_METRICS.items():550            values = []551            for s in all_snapshots:552                v = getattr(s, attr_name, None)553                if v is not None:554                    values.append(float(v))555 556            if len(values) < MIN_MATCHES:557                continue558 559            current_value = values[-1]560            recent_values = values[-RECENT_WINDOW:]561            historical_values = values562 563            recent_avg = sum(recent_values) / len(recent_values)564            historical_avg = sum(historical_values) / len(historical_values)565            recent_std = statistics.stdev(recent_values) if len(recent_values) > 1 else 0.0566            historical_std = (567                statistics.stdev(historical_values) if len(historical_values) > 1 else 0.0568            )569 570            direction = _compute_trend_direction(recent_avg, historical_avg, higher_is_better)571 572            change_pct = 0.0573            if historical_avg != 0:574                change_pct = ((current_value - historical_avg) / abs(historical_avg)) * 100575 576            trends.append(577                TrendAnalysis(578                    metric_name=metric_name,579                    current_value=current_value,580                    recent_avg=recent_avg,581                    historical_avg=historical_avg,582                    recent_std=recent_std,583                    historical_std=historical_std,584                    direction=direction,585                    change_pct=change_pct,586                    sample_count=len(historical_values),587                )588            )589 590        return trends591 592    def analyze_trends_from_db(593        self, steam_id: str, min_matches: int = MIN_MATCHES594    ) -> list[TrendAnalysis]:595        """Analyze trends directly from DB history (convenience method).596 597        Args:598            steam_id: Player's Steam ID599            min_matches: Minimum matches required600 601        Returns:602            List of TrendAnalysis603        """604        history_rows = self.db.get_player_history_full(steam_id, limit=30)605        if len(history_rows) < min_matches:606            return []607 608        # History is newest-first; reverse for chronological order609        snapshots = [self.snapshot_from_history(row) for row in reversed(history_rows)]610        return self.analyze_trends(snapshots)611 612    # =========================================================================613    # Level Estimation & Benchmarking614    # =========================================================================615 616    def estimate_level(self, snapshots: list[MatchSnapshot]) -> str:617        """Estimate a player's competitive level from their match history.618 619        Uses HLTV rating as the primary indicator:620        - <0.85: beginner621        - 0.85-1.05: intermediate622        - 1.05-1.20: advanced623        - >1.20: elite624 625        Args:626            snapshots: List of MatchSnapshots627 628        Returns:629            Level string: "beginner", "intermediate", "advanced", "elite"630        """631        if not snapshots:632            return "intermediate"633 634        ratings = [s.hltv_rating for s in snapshots if s.hltv_rating is not None]635        if not ratings:636            return "intermediate"637 638        avg_rating = sum(ratings) / len(ratings)639 640        if avg_rating < 0.85:641            return "beginner"642        elif avg_rating < 1.05:643            return "intermediate"644        elif avg_rating < 1.20:645            return "advanced"646        return "elite"647 648    def compute_benchmarks(649        self,650        snapshots: list[MatchSnapshot],651        level: str | None = None,652    ) -> list[RoleBenchmark]:653        """Compare player metrics against competitive level benchmarks.654 655        Args:656            snapshots: List of MatchSnapshots657            level: Competitive level to compare against.658                   If None, auto-estimated from snapshots.659 660        Returns:661            List of RoleBenchmark comparisons662        """663        if not snapshots:664            return []665 666        if level is None:667            level = self.estimate_level(snapshots)668 669        if level not in LEVEL_BENCHMARKS:670            level = "intermediate"671 672        level_benchmarks = LEVEL_BENCHMARKS[level]673        benchmarks = []674 675        player_avgs = _calculate_player_averages(snapshots)676 677        for metric_name, bench in level_benchmarks.items():678            player_value = player_avgs.get(metric_name)679            if player_value is None:680                continue681 682            level_low = bench["low"]683            level_avg = bench["avg"]684            level_high = bench["high"]685 686            # Calculate percentile within level range687            level_range = level_high - level_low688            if level_range > 0:689                percentile = ((player_value - level_low) / level_range) * 100690                percentile = max(0.0, min(100.0, percentile))691            else:692                percentile = 50.0693 694            # Determine verdict695            if player_value >= level_avg * 1.05:696                verdict = "above"697            elif player_value <= level_avg * 0.95:698                verdict = "below"699            else:700                verdict = "at"701 702            benchmarks.append(703                RoleBenchmark(704                    metric_name=metric_name,705                    player_value=player_value,706                    level=level,707                    level_avg=level_avg,708                    level_low=level_low,709                    level_high=level_high,710                    percentile_in_level=percentile,711                    verdict=verdict,712                )713            )714 715        return benchmarks716 717    # =========================================================================718    # Practice Recommendations719    # =========================================================================720 721    def generate_recommendations(722        self,723        steam_id: str,724        trends: list[TrendAnalysis] | None = None,725    ) -> list[PracticeRecommendation]:726        """Generate practice recommendations based on trends and role.727 728        Args:729            steam_id: Player's Steam ID730            trends: Pre-computed trends (if None, computed from DB)731 732        Returns:733            Sorted list of PracticeRecommendation734        """735        if trends is None:736            trends = self.analyze_trends_from_db(steam_id)737 738        if not trends:739            return []740 741        role = self._get_player_role(steam_id)742        targets = ROLE_PRACTICE_TARGETS.get(role, ROLE_PRACTICE_TARGETS["fragger"])743 744        # Also need DB averages for rate-based metrics745        history = self.db.get_player_history_full(steam_id, limit=30)746        averages = _compute_history_averages(history) if history else {}747 748        trend_map = {t.metric_name: t for t in trends}749        recs: list[PracticeRecommendation] = []750 751        # Rule 1: HS% below target752        hs = trend_map.get("hs_pct")753        target_hs = targets.get("hs_pct", 50.0)754        if hs and hs.recent_avg < target_hs * 0.85:755            recs.append(756                PracticeRecommendation(757                    area="aim",758                    priority="medium",759                    description="Headshot percentage below role target",760                    current_value=hs.recent_avg,761                    target_value=target_hs,762                    drill="Practice headshot-only deathmatch to build muscle memory",763                )764            )765 766        # Rule 2: KAST declining767        kast = trend_map.get("kast")768        if kast and kast.direction == "declining":769            recs.append(770                PracticeRecommendation(771                    area="positioning",772                    priority="high",773                    description="KAST is declining — less round impact",774                    current_value=kast.recent_avg,775                    target_value=targets.get("kast", 72.0),776                    drill="Focus on staying alive and getting at least one contribution per round",777                )778            )779 780        # Rule 3: ADR declining781        adr = trend_map.get("adr")782        if adr and adr.direction == "declining":783            recs.append(784                PracticeRecommendation(785                    area="aim",786                    priority="high",787                    description="Damage output is declining",788                    current_value=adr.recent_avg,789                    target_value=targets.get("adr", 80.0),790                    drill="Be more aggressive in engagements, use utility to deal damage",791                )792            )793 794        # Rule 4: Deaths increasing795        deaths = trend_map.get("deaths")796        if deaths and deaths.direction == "declining":  # "declining" = getting worse for deaths797            recs.append(798                PracticeRecommendation(799                    area="positioning",800                    priority="medium",801                    description="Dying more often — improve survival discipline",802                    current_value=deaths.recent_avg,803                    target_value=targets.get("deaths", 15.0),804                    drill="Focus on information gathering before peeking, use utility before engaging",805                )806            )807 808        # Rule 5: Entry success below target809        entry_rate = averages.get("entry_success_rate", 0)810        entry_target = targets.get("entry_success_rate", 55.0)811        if entry_rate > 0 and entry_rate < entry_target * 0.80:812            recs.append(813                PracticeRecommendation(814                    area="entry",815                    priority="medium",816                    description="Entry success rate below role target",817                    current_value=entry_rate,818                    target_value=entry_target,819                    drill="Practice entry routes with utility on specific maps",820                )821            )822 823        # Rule 6: Utility rating declining824        util = trend_map.get("utility_rating")825        if util and util.direction == "declining":826            recs.append(827                PracticeRecommendation(828                    area="utility",829                    priority="medium",830                    description="Utility effectiveness is declining",831                    current_value=util.recent_avg,832                    target_value=targets.get("utility_rating", 50.0)833                    if "utility_rating" in targets834                    else 50.0,835                    drill="Learn pop-flash lineups and HE/molotov spots for your most-played maps",836                )837            )838 839        # Sort by priority840        priority_order = {"high": 0, "medium": 1, "low": 2}841        recs.sort(key=lambda r: priority_order.get(r.priority, 3))842 843        return recs844 845    # =========================================================================846    # Development Report847    # =========================================================================848 849    def generate_report(850        self,851        steam_id: str,852        limit: int = 30,853    ) -> DevelopmentReport:854        """Generate a comprehensive development report for a player.855 856        Pulls match history from DB, analyzes trends, benchmarks against857        competitive levels, generates recommendations, and identifies858        strengths/weaknesses.859 860        Args:861            steam_id: Player's Steam ID (17 digits)862            limit: Max matches to analyze (default 30)863 864        Returns:865            DevelopmentReport866        """867        history_rows = self.db.get_player_history_full(steam_id, limit=limit)868 869        if not history_rows:870            return DevelopmentReport(steam_id=steam_id, match_count=0)871 872        # Convert to snapshots (history_rows are newest-first, reverse for oldest-first)873        snapshots = [self.snapshot_from_history(row) for row in reversed(history_rows)]874 875        current = snapshots[-1] if snapshots else None876        match_count = len(snapshots)877 878        # Date range879        latest = history_rows[0].get("analyzed_at", "unknown")880        earliest = history_rows[-1].get("analyzed_at", "unknown")881        date_range = (str(earliest), str(latest))882 883        # Analyze trends884        trends = self.analyze_trends(snapshots)885 886        # Estimate level and compute benchmarks887        level = self.estimate_level(snapshots)888        benchmarks = self.compute_benchmarks(snapshots, level)889 890        # Generate recommendations891        recommendations = self.generate_recommendations(steam_id, trends=trends)892 893        # Identify strengths and weaknesses894        strengths, weaknesses = _identify_strengths_weaknesses(benchmarks, trends)895 896        # Calculate improvement velocity897        improvement_velocity = _calculate_improvement_velocity(trends)898 899        # Build summary900        summary = _build_summary(trends, benchmarks, recommendations, match_count)901 902        return DevelopmentReport(903            steam_id=steam_id,904            match_count=match_count,905            date_range=date_range,906            current_snapshot=current,907            trends=trends,908            benchmarks=benchmarks,909            recommendations=recommendations,910            estimated_level=level,911            strengths=strengths,912            weaknesses=weaknesses,913            improvement_velocity=improvement_velocity,914            summary=summary,915        )916 917    # =========================================================================918    # Helpers919    # =========================================================================920 921    def _get_player_role(self, steam_id: str) -> str:922        """Get the player's role based on their persona."""923        try:924            persona = self.db.get_player_persona(steam_id)925            if persona:926                persona_id = persona.get("persona", "the_competitor")927                return PERSONA_ROLE_MAP.get(persona_id, "fragger")928        except Exception:929            logger.debug("Could not fetch persona for %s, defaulting to fragger", steam_id)930        return "fragger"931 932 933# =============================================================================934# Module-level Helpers935# =============================================================================936 937 938def _compute_trend_direction(939    recent_avg: float,940    historical_avg: float,941    higher_is_better: bool,942) -> str:943    """Determine trend direction by comparing recent vs historical average.944 945    Uses a 5% threshold relative to historical average to filter noise.946    """947    if historical_avg == 0:948        return "stable"949 950    pct_change = (recent_avg - historical_avg) / abs(historical_avg)951 952    if higher_is_better:953        if pct_change > TREND_THRESHOLD:954            return "improving"955        elif pct_change < -TREND_THRESHOLD:956            return "declining"957    else:958        # Lower is better (e.g., deaths)959        if pct_change < -TREND_THRESHOLD:960            return "improving"961        elif pct_change > TREND_THRESHOLD:962            return "declining"963 964    return "stable"965 966 967def _calculate_player_averages(968    snapshots: list[MatchSnapshot],969) -> dict[str, float]:970    """Calculate average metrics from snapshots for benchmarking."""971    if not snapshots:972        return {}973 974    n = len(snapshots)975    avgs: dict[str, float] = {}976 977    # Core averages978    avgs["hltv_rating"] = sum(s.hltv_rating for s in snapshots) / n979    avgs["adr"] = sum(s.adr for s in snapshots) / n980    avgs["kast"] = sum(s.kast for s in snapshots) / n981    avgs["hs_pct"] = sum(s.hs_pct for s in snapshots) / n982 983    # Utility rating (skip 0 values = missing data)984    util_values = [s.utility_rating for s in snapshots if s.utility_rating > 0]985    if util_values:986        avgs["utility_rating"] = sum(util_values) / len(util_values)987 988    # Entry win rate989    total_attempts = sum(s.entry_attempts for s in snapshots)990    total_success = sum(s.entry_success for s in snapshots)991    if total_attempts > 0:992        avgs["entry_win_rate"] = (total_success / total_attempts) * 100993 994    # Clutch win rate995    total_clutch_sit = sum(s.clutch_situations for s in snapshots)996    total_clutch_wins = sum(s.clutch_wins for s in snapshots)997    if total_clutch_sit > 0:998        avgs["clutch_win_rate"] = (total_clutch_wins / total_clutch_sit) * 100999 1000    # Trade rate1001    total_trade_attempts = sum(s.trade_kill_attempts for s in snapshots)1002    total_trade_success = sum(s.trade_kill_success for s in snapshots)1003    if total_trade_attempts > 0:1004        avgs["trade_rate"] = (total_trade_success / total_trade_attempts) * 1001005 1006    return avgs1007 1008 1009def _compute_history_averages(history: list[dict]) -> dict[str, float]:1010    """Compute metric averages from match history dicts, including derived rates."""1011    n = len(history) if len(history) > 0 else 11012    totals: dict[str, float] = {}1013 1014    for h in history:1015        for metric in TRACKED_HISTORY_METRICS:1016            _v = h.get(metric)1017            val = float(_v if _v is not None else 0)1018            totals[metric] = totals.get(metric, 0) + val1019 1020    averages = {m: round(totals.get(m, 0) / n, 2) for m in TRACKED_HISTORY_METRICS}1021 1022    # Derived rates1023    total_entry_attempts = totals.get("entry_attempts", 0)1024    if total_entry_attempts > 0:1025        averages["entry_success_rate"] = round(1026            (totals.get("entry_success", 0) / total_entry_attempts) * 100, 11027        )1028    else:1029        averages["entry_success_rate"] = 0.01030 1031    total_clutch_situations = totals.get("clutch_situations", 0)1032    if total_clutch_situations > 0:1033        averages["clutch_success_rate"] = round(1034            (totals.get("clutch_wins", 0) / total_clutch_situations) * 100, 11035        )1036    else:1037        averages["clutch_success_rate"] = 0.01038 1039    total_trade_attempts = totals.get("trade_kill_attempts", 0)1040    if total_trade_attempts > 0:1041        averages["trade_success_rate"] = round(1042            (totals.get("trade_kill_success", 0) / total_trade_attempts) * 100, 11043        )1044    else:1045        averages["trade_success_rate"] = 0.01046 1047    return averages1048 1049 1050def _identify_strengths_weaknesses(1051    benchmarks: list[RoleBenchmark],1052    trends: list[TrendAnalysis],1053) -> tuple[list[str], list[str]]:1054    """Identify player strengths and weaknesses from benchmarks and trends."""1055    strengths: list[str] = []1056    weaknesses: list[str] = []1057 1058    metric_labels = {1059        "hltv_rating": "HLTV Rating",1060        "adr": "ADR",1061        "kast": "KAST",1062        "hs_pct": "Headshot %",1063        "entry_win_rate": "Entry Duels",1064        "clutch_win_rate": "Clutch Play",1065        "trade_rate": "Trading",1066        "utility_rating": "Utility Usage",1067        "aim_rating": "Aim",1068        "kills": "Kill Count",1069        "deaths": "Survivability",1070    }1071 1072    for b in benchmarks:1073        label = metric_labels.get(b.metric_name, b.metric_name)1074        if b.verdict == "above":1075            strengths.append(1076                f"{label} above {b.level} average ({b.player_value:.1f} vs {b.level_avg:.1f})"1077            )1078        elif b.verdict == "below":1079            weaknesses.append(1080                f"{label} below {b.level} average ({b.player_value:.1f} vs {b.level_avg:.1f})"1081            )1082 1083    for t in trends:1084        label = metric_labels.get(t.metric_name, t.metric_name)1085        if t.direction == "improving" and abs(t.change_pct) > 5:1086            strengths.append(f"{label} trending up ({t.change_pct:+.1f}%)")1087        elif t.direction == "declining" and abs(t.change_pct) > 5:1088            weaknesses.append(f"{label} trending down ({t.change_pct:+.1f}%)")1089 1090    return strengths, weaknesses1091 1092 1093def _calculate_improvement_velocity(trends: list[TrendAnalysis]) -> float:1094    """Calculate overall improvement velocity from trends.1095 1096    Returns:1097        Float from -1.0 (all declining) to +1.0 (all improving)1098    """1099    if not trends:1100        return 0.01101 1102    direction_scores = []1103    for t in trends:1104        if t.direction == "improving":1105            direction_scores.append(1.0)1106        elif t.direction == "declining":1107            direction_scores.append(-1.0)1108        else:1109            direction_scores.append(0.0)1110 1111    return sum(direction_scores) / len(direction_scores)1112 1113 1114def _build_summary(1115    trends: list[TrendAnalysis],1116    benchmarks: list[RoleBenchmark],1117    recommendations: list[PracticeRecommendation],1118    match_count: int,1119) -> str:1120    """Build a human-readable summary string."""1121    improving = [t for t in trends if t.direction == "improving"]1122    declining = [t for t in trends if t.direction == "declining"]1123 1124    parts = [f"Based on {match_count} matches analyzed:"]1125 1126    if improving:1127        names = ", ".join(t.metric_name.replace("_", " ") for t in improving[:3])1128        parts.append(f"Improving in: {names}.")1129 1130    if declining:1131        names = ", ".join(t.metric_name.replace("_", " ") for t in declining[:3])1132        parts.append(f"Declining in: {names}.")1133 1134    if not improving and not declining:1135        parts.append("Performance is stable across all metrics.")1136 1137    weak = [b for b in benchmarks if b.verdict == "below"]1138    if weak:1139        names = ", ".join(b.metric_name.replace("_", " ") for b in weak[:3])1140        parts.append(f"Below benchmark in: {names}.")1141 1142    high_recs = [r for r in recommendations if r.priority == "high"]1143    if high_recs:1144        parts.append(f"{len(high_recs)} high-priority area(s) to focus on.")1145 1146    return " ".join(parts)1147 1148 1149# =============================================================================1150# Singleton Access1151# =============================================================================1152 1153_tracker: PlayerTracker | None = None1154 1155 1156def get_player_tracker() -> PlayerTracker:1157    """Get the singleton PlayerTracker instance."""1158    global _tracker1159    if _tracker is None:1160        _tracker = PlayerTracker()1161    return _tracker1162