CoolFace
Apppublic

thenuke02/cs2-analyzer

sourceHugging Faceupdated 7mo agoView on Hugging Face
0likes
game_plan.py859 linesDownload Raw Back to ai
1"""2AI Game Plan Generator.3 4Combines your team's strengths with opponent weaknesses to produce5a structured stratbook for match preparation.6 7Design principle: HUMAN IN THE LOOP.8Every recommendation includes confidence level + statistical evidence.9The IGL decides — the AI suggests.10 11Architecture:12  - Input: Your team's recent demos (orchestrator results) + opponent scouting data13  - Processing: Heuristic matchup analysis → LLM tactical generation14  - Model: ModelTier.DEEP (Sonnet 4.5) — premium capstone feature15  - Output: GamePlan dataclass with structured sections16"""17 18import json19import logging20import uuid21from dataclasses import dataclass, field22from datetime import UTC, datetime23from typing import Any24 25logger = logging.getLogger(__name__)26 27 28# =============================================================================29# Dataclasses30# =============================================================================31 32 33@dataclass34class StratCall:35    """A specific tactical call with evidence."""36 37    name: str  # "A Split through Squeaky + Main"38    description: str  # step-by-step execution39    utility_sequence: list[str] = field(default_factory=list)40    player_assignments: dict[str, str] = field(default_factory=dict)41    when_to_call: str = ""  # "Default after winning pistol"42    confidence: float = 0.5  # 0.0-1.043    evidence: str = ""  # statistical evidence44    expected_success_rate: str = ""  # "~60% based on ..."45 46    def to_dict(self) -> dict[str, Any]:47        """Convert to dictionary for JSON serialization."""48        return {49            "name": self.name,50            "description": self.description,51            "utility_sequence": self.utility_sequence,52            "player_assignments": self.player_assignments,53            "when_to_call": self.when_to_call,54            "confidence": round(self.confidence, 2),55            "evidence": self.evidence,56            "expected_success_rate": self.expected_success_rate,57        }58 59 60@dataclass61class EconomyPlan:62    """Round-type-specific economy guidance."""63 64    pistol_round_buy: str = "kevlar + utility"65    anti_eco_setup: str = "SMGs + hold angles, play for exit kills"66    force_buy_threshold: int = 320067    save_triggers: list[str] = field(default_factory=list)68    double_eco_into_full: str = ""69 70    def to_dict(self) -> dict[str, Any]:71        """Convert to dictionary for JSON serialization."""72        return {73            "pistol_round_buy": self.pistol_round_buy,74            "anti_eco_setup": self.anti_eco_setup,75            "force_buy_threshold": self.force_buy_threshold,76            "save_triggers": self.save_triggers,77            "double_eco_into_full": self.double_eco_into_full,78        }79 80 81@dataclass82class GamePlan:83    """Complete stratbook for an upcoming match."""84 85    plan_id: str = ""86    opponent: str = ""87    map_name: str = ""88    generated_at: str = ""89    confidence_overall: float = 0.590 91    # Executive brief92    executive_summary: str = ""93    key_advantage: str = ""94    key_risk: str = ""95 96    # CT-side plan97    ct_default: StratCall | None = None98    ct_adjustments: list[StratCall] = field(default_factory=list)99    ct_retake_priorities: dict[str, str] = field(default_factory=dict)100 101    # T-side plan102    t_default: StratCall | None = None103    t_executes: list[StratCall] = field(default_factory=list)104    t_read_based: list[StratCall] = field(default_factory=list)105 106    # Economy107    economy_plan: EconomyPlan = field(default_factory=EconomyPlan)108 109    # Player assignments110    player_roles: dict[str, str] = field(default_factory=dict)111    player_matchups: list[str] = field(default_factory=list)112 113    # Situational114    if_losing: list[str] = field(default_factory=list)115    if_winning: list[str] = field(default_factory=list)116    timeout_triggers: list[str] = field(default_factory=list)117 118    # Anti-strat119    opponent_exploits: list[str] = field(default_factory=list)120 121    # Metadata122    model_used: str = ""123    generation_error: str = ""124 125    def to_dict(self) -> dict[str, Any]:126        """Convert to dictionary for JSON serialization."""127        return {128            "plan_id": self.plan_id,129            "opponent": self.opponent,130            "map_name": self.map_name,131            "generated_at": self.generated_at,132            "confidence_overall": round(self.confidence_overall, 2),133            "executive_summary": self.executive_summary,134            "key_advantage": self.key_advantage,135            "key_risk": self.key_risk,136            "ct_side": {137                "default": self.ct_default.to_dict() if self.ct_default else None,138                "adjustments": [s.to_dict() for s in self.ct_adjustments],139                "retake_priorities": self.ct_retake_priorities,140            },141            "t_side": {142                "default": self.t_default.to_dict() if self.t_default else None,143                "executes": [s.to_dict() for s in self.t_executes],144                "read_based": [s.to_dict() for s in self.t_read_based],145            },146            "economy_plan": self.economy_plan.to_dict(),147            "player_roles": self.player_roles,148            "player_matchups": self.player_matchups,149            "situational": {150                "if_losing": self.if_losing,151                "if_winning": self.if_winning,152                "timeout_triggers": self.timeout_triggers,153            },154            "opponent_exploits": self.opponent_exploits,155            "model_used": self.model_used,156            "generation_error": self.generation_error,157        }158 159 160# =============================================================================161# Matchup Analysis (heuristic, no LLM)162# =============================================================================163 164 165def _safe_div(numerator: float, denominator: float, default: float = 0.0) -> float:166    """Safe division with default for zero denominator."""167    return numerator / denominator if denominator > 0 else default168 169 170def _compute_team_averages(orchestrator_results: list[dict]) -> dict[str, float]:171    """172    Compute average team stats from multiple orchestrator results.173 174    Returns dict with averaged stats across all players and matches.175    """176    totals: dict[str, float] = {177        "kills": 0,178        "deaths": 0,179        "adr": 0,180        "kast": 0,181        "rating": 0,182        "hs_pct": 0,183        "opening_duel_wins": 0,184        "opening_duel_total": 0,185        "clutch_wins": 0,186        "clutch_total": 0,187        "trade_kills": 0,188        "trade_opps": 0,189        "flash_assists": 0,190        "he_damage": 0,191        "utility_thrown": 0,192        "rounds_played": 0,193    }194    player_count = 0195 196    for result in orchestrator_results:197        players = result.get("players") or {}198        for _sid, pdata in players.items():199            stats = pdata.get("stats") or {}200            rating = pdata.get("rating") or {}201            duels = pdata.get("duels") or {}202            utility = pdata.get("utility") or {}203 204            totals["kills"] += stats.get("kills", 0)205            totals["deaths"] += stats.get("deaths", 0)206            totals["adr"] += stats.get("adr", 0.0)207            totals["kast"] += rating.get("kast_percentage", 0.0)208            totals["rating"] += rating.get("hltv_rating", 0.0)209            totals["hs_pct"] += stats.get("headshot_pct", 0.0)210            totals["opening_duel_wins"] += duels.get("opening_kills", 0)211            totals["opening_duel_total"] += duels.get("opening_kills", 0) + duels.get(212                "opening_deaths", 0213            )214            totals["clutch_wins"] += duels.get("clutch_wins", 0)215            totals["clutch_total"] += duels.get("clutch_attempts", 0)216            totals["trade_kills"] += duels.get("trade_kills", 0)217            totals["trade_opps"] += duels.get("trade_kill_opportunities", 0)218            totals["flash_assists"] += utility.get("flash_assists", 0)219            totals["he_damage"] += utility.get("he_damage", 0)220 221            flashes = utility.get("flashbangs_thrown", 0)222            smokes = utility.get("smokes_thrown", 0)223            he = utility.get("he_thrown", 0)224            molotovs = utility.get("molotovs_thrown", 0)225            totals["utility_thrown"] += flashes + smokes + he + molotovs226 227            totals["rounds_played"] += stats.get("rounds_played", 0)228            player_count += 1229 230    if player_count == 0:231        return totals232 233    # Average per player234    return {235        "avg_kills": totals["kills"] / player_count,236        "avg_deaths": totals["deaths"] / player_count,237        "avg_adr": totals["adr"] / player_count,238        "avg_kast": totals["kast"] / player_count,239        "avg_rating": totals["rating"] / player_count,240        "avg_hs_pct": totals["hs_pct"] / player_count,241        "opening_duel_win_rate": _safe_div(242            totals["opening_duel_wins"], totals["opening_duel_total"]243        ),244        "clutch_win_rate": _safe_div(totals["clutch_wins"], totals["clutch_total"]),245        "trade_success_rate": _safe_div(totals["trade_kills"], totals["trade_opps"]),246        "avg_flash_assists": totals["flash_assists"] / player_count,247        "avg_utility_damage": totals["he_damage"] / player_count,248        "avg_utility_thrown": totals["utility_thrown"] / player_count,249        "total_rounds": totals["rounds_played"] / max(player_count, 1),250    }251 252 253def _extract_opponent_stats(opponent_scouting: dict) -> dict[str, float]:254    """255    Extract averaged stats from opponent scouting data (TeamScoutReport.to_dict()).256    """257    players = opponent_scouting.get("players") or []258    if not players:259        return {}260 261    totals: dict[str, float] = {262        "kpr": 0,263        "adr": 0,264        "kast": 0,265        "hs_rate": 0,266        "entry_success": 0,267        "entry_attempts": 0,268        "clutch_wins": 0,269        "clutch_attempts": 0,270        "force_buy_rate": 0,271    }272 273    for p in players:274        totals["kpr"] += p.get("avg_kills_per_round", 0)275        totals["adr"] += p.get("avg_adr", 0)276        totals["kast"] += p.get("avg_kast", 0)277        totals["hs_rate"] += p.get("headshot_rate", 0)278        totals["entry_success"] += p.get("entry_success_rate", 0)279        totals["entry_attempts"] += p.get("entry_attempt_rate", 0)280        totals["clutch_wins"] += p.get("clutch_wins", 0)281        totals["clutch_attempts"] += p.get("clutch_attempts", 0)282 283    n = len(players)284    economy = opponent_scouting.get("economy") or {}285 286    return {287        "avg_kpr": totals["kpr"] / n,288        "avg_adr": totals["adr"] / n,289        "avg_kast": totals["kast"] / n,290        "avg_hs_rate": totals["hs_rate"] / n,291        "avg_entry_success": totals["entry_success"] / n,292        "avg_entry_attempts": totals["entry_attempts"] / n,293        "total_clutch_wins": totals["clutch_wins"],294        "total_clutch_attempts": totals["clutch_attempts"],295        "force_buy_rate": economy.get("force_buy_rate", 0),296        "eco_round_rate": economy.get("eco_round_rate", 0),297    }298 299 300def build_matchup_analysis(301    your_data: list[dict],302    opponent_data: dict,303) -> str:304    """305    Compare team profiles to find exploitable matchups.306 307    Returns structured text for LLM context — no LLM call here.308    """309    your_stats = _compute_team_averages(your_data)310    opp_stats = _extract_opponent_stats(opponent_data)311 312    if not your_stats.get("avg_rating") or not opp_stats:313        return "<matchup_analysis>\nInsufficient data for matchup analysis.\n</matchup_analysis>"314 315    lines: list[str] = ["<matchup_analysis>"]316 317    # Opening duel comparison318    your_od = your_stats.get("opening_duel_win_rate", 0)319    opp_entry = opp_stats.get("avg_entry_success", 0) / 100  # scouting is %320    od_advantage = your_od - opp_entry321    if od_advantage > 0.05:322        lines.append(323            f"ADVANTAGE — Opening duels: Your win rate {your_od:.0%} "324            f"vs their entry success {opp_entry:.0%} (+{od_advantage:.0%})"325        )326    elif od_advantage < -0.05:327        lines.append(328            f"DISADVANTAGE — Opening duels: Your win rate {your_od:.0%} "329            f"vs their entry success {opp_entry:.0%} ({od_advantage:.0%})"330        )331    else:332        lines.append(f"EVEN — Opening duels: Your {your_od:.0%} vs their {opp_entry:.0%}")333 334    # ADR / firepower comparison335    your_adr = your_stats.get("avg_adr", 0)336    opp_adr = opp_stats.get("avg_adr", 0)337    adr_diff = your_adr - opp_adr338    if abs(adr_diff) > 5:339        tag = "ADVANTAGE" if adr_diff > 0 else "DISADVANTAGE"340        lines.append(341            f"{tag} — Firepower: Your ADR {your_adr:.1f} vs their {opp_adr:.1f} "342            f"(diff: {adr_diff:+.1f})"343        )344 345    # Trade discipline346    your_trade = your_stats.get("trade_success_rate", 0)347    lines.append(f"Trade discipline: Your trade success rate {your_trade:.0%}")348 349    # Clutch comparison350    your_clutch = your_stats.get("clutch_win_rate", 0)351    opp_clutch_w = opp_stats.get("total_clutch_wins", 0)352    opp_clutch_a = opp_stats.get("total_clutch_attempts", 0)353    opp_clutch_rate = _safe_div(opp_clutch_w, opp_clutch_a)354    lines.append(355        f"Clutch: Your win rate {your_clutch:.0%} vs their "356        f"{opp_clutch_w}/{opp_clutch_a} ({opp_clutch_rate:.0%})"357    )358 359    # Economy discipline360    opp_force = opp_stats.get("force_buy_rate", 0)361    if opp_force > 30:362        lines.append(363            f"EXPLOIT — Economy: Opponent force-buys {opp_force:.0f}% of rounds "364            f"(aggressive, punishable)"365        )366    elif opp_force < 15:367        lines.append(368            f"NOTE — Economy: Opponent is disciplined (force rate {opp_force:.0f}%) "369            f"— harder to break economically"370        )371 372    # Utility effectiveness373    your_flash = your_stats.get("avg_flash_assists", 0)374    your_util_dmg = your_stats.get("avg_utility_damage", 0)375    lines.append(376        f"Utility: Your avg flash assists {your_flash:.1f}/player, "377        f"avg utility damage {your_util_dmg:.0f}/player"378    )379 380    # KAST comparison381    your_kast = your_stats.get("avg_kast", 0)382    opp_kast = opp_stats.get("avg_kast", 0)383    kast_diff = your_kast - opp_kast384    if abs(kast_diff) > 3:385        tag = "ADVANTAGE" if kast_diff > 0 else "DISADVANTAGE"386        lines.append(387            f"{tag} — Consistency: Your KAST {your_kast:.0f}% vs their {opp_kast:.0f}% "388            f"(diff: {kast_diff:+.0f}%)"389        )390 391    # Player-specific threats from opponent392    players = opponent_data.get("players") or []393    high_threats = [p for p in players if p.get("avg_kills_per_round", 0) > 0.8]394    if high_threats:395        lines.append("")396        lines.append("HIGH-THREAT PLAYERS:")397        for p in high_threats:398            name = p.get("name", "Unknown")399            kpr = p.get("avg_kills_per_round", 0)400            style = p.get("play_style", "unknown")401            lines.append(f"  - {name}: {kpr:.2f} KPR, {style} style")402 403    lines.append("</matchup_analysis>")404    return "\n".join(lines)405 406 407def _build_economy_plan(408    your_data: list[dict],409    opponent_data: dict,410) -> EconomyPlan:411    """412    Generate economy plan from heuristic analysis (no LLM needed).413    """414    opp_economy = opponent_data.get("economy") or {}415    opp_force_rate = opp_economy.get("force_buy_rate", 20)416 417    save_triggers = [418        "Team money < $10,000 combined AND loss bonus building",419        "After losing pistol round — save for round 4 full buy",420        "Score is close (within 2 rounds) — preserve economy for crucial rounds",421    ]422 423    # Adjust force threshold based on opponent tendencies424    force_threshold = 3200425    if opp_force_rate > 30:426        # Opponent force-buys a lot — we can be more conservative427        save_triggers.append(428            f"Opponent force-buys {opp_force_rate:.0f}% — hold saves, they'll give you free rounds"429        )430    elif opp_force_rate < 15:431        # Opponent saves discipline — we need to be more aggressive on anti-ecos432        force_threshold = 2800433 434    # Anti-eco advice based on opponent eco round rate435    opp_eco_rate = opp_economy.get("eco_round_rate", 20)436    if opp_eco_rate > 25:437        anti_eco = (438            f"Opponent ecos {opp_eco_rate:.0f}% of rounds — expect frequent eco rushes. "439            "SMGs + utility, hold disciplined angles, deny exit kills."440        )441    else:442        anti_eco = (443            "Standard anti-eco: SMGs for $600 kill reward, hold angles, use utility to slow pushes."444        )445 446    return EconomyPlan(447        pistol_round_buy="kevlar + utility (smoke + flash preferred)",448        anti_eco_setup=anti_eco,449        force_buy_threshold=force_threshold,450        save_triggers=save_triggers,451        double_eco_into_full=(452            "After pistol loss: eco rounds 2-3, guarantee rifles + full utility round 4. "453            "Loss bonus builds to $2400+ by round 4 — combined with eco savings, "454            "full buy is guaranteed. Never break this with a solo force."455        ),456    )457 458 459# =============================================================================460# LLM Prompt Construction461# =============================================================================462 463GAME_PLAN_SYSTEM_PROMPT = """You are a professional CS2 IGL and tactical analyst generating a structured game plan.464 465Your output will be parsed as JSON and fed directly into a match preparation tool.466Every recommendation MUST include:4671. Statistical evidence from the data provided4682. A confidence level (high/medium/low mapped to 0.8+/0.5-0.8/below 0.5)4693. A fallback plan if the primary strategy fails470 471## Rules472- Be SPECIFIC about positions, utility, and timing. Not "play aggressive" but473  "push B main with flash from kix, foe holds connector for rotation."474- Use actual player names from the roster in assignments.475- Every strategy must cite evidence from the matchup analysis or scouting data.476- CT default and T default are mandatory — adjustments are optional.477- Limit to 3-5 T-side executes — quality over quantity.478- Economy plan must follow CS2 economy rules (loss bonus ladder, etc.).479"""480 481 482def _build_game_plan_prompt(483    matchup_analysis: str,484    opponent_scouting_text: str,485    your_team_text: str,486    map_name: str,487    roster: dict[str, str],488) -> str:489    """490    Build the LLM prompt for game plan generation.491    """492    roster_str = "\n".join(f"  - {name}: {role}" for name, role in roster.items())493 494    return f"""<your_team>495{your_team_text}496 497Roster:498{roster_str}499</your_team>500 501<opponent>502{opponent_scouting_text}503</opponent>504 505{matchup_analysis}506 507<task>508Generate a complete game plan for {map_name}.509 510Output ONLY valid JSON with these exact keys:511{{512  "executive_summary": "3-4 sentence brief readable in 30 seconds",513  "key_advantage": "your team's biggest advantage over this opponent",514  "key_risk": "biggest risk to manage in this match",515  "ct_default": {{516    "name": "CT default setup name",517    "description": "step-by-step default positions and roles",518    "utility_sequence": ["utility1", "utility2"],519    "player_assignments": {{"player_name": "assignment"}},520    "when_to_call": "when to use this",521    "confidence": 0.8,522    "evidence": "statistical evidence",523    "expected_success_rate": "estimated rate"524  }},525  "ct_adjustments": [526    {{same format as ct_default, "when_to_call": "trigger condition"}}527  ],528  "ct_retake_priorities": {{"A": "retake plan for A", "B": "retake plan for B"}},529  "t_default": {{same format as ct_default}},530  "t_executes": [{{same format, 3-5 specific execute calls}}],531  "t_read_based": [{{same format, mid-round reads}}],532  "player_roles": {{"player_name": "role assignment"}},533  "player_matchups": ["specific player-vs-player matchup advice"],534  "if_losing": ["adjustment when down 0-3", "adjustment when losing on CT"],535  "if_winning": ["how to maintain lead"],536  "timeout_triggers": ["when to call timeout"],537  "opponent_exploits": ["specific weakness to exploit with evidence"]538}}539 540IMPORTANT:541- Use the actual roster names: {", ".join(roster.keys())}542- Every strategy must cite evidence from the data above543- Be specific about positions, utility, and timing for {map_name}544- Output ONLY valid JSON, no markdown fencing545</task>"""546 547 548def _build_your_team_summary(your_data: list[dict]) -> str:549    """Build a text summary of your team's stats for LLM context."""550    lines: list[str] = []551    avgs = _compute_team_averages(your_data)552 553    lines.append("Team Performance Summary:")554    lines.append(f"  Average Rating: {avgs.get('avg_rating', 0):.2f}")555    lines.append(f"  Average ADR: {avgs.get('avg_adr', 0):.1f}")556    lines.append(f"  Average KAST: {avgs.get('avg_kast', 0):.0f}%")557    lines.append(f"  Opening Duel Win Rate: {avgs.get('opening_duel_win_rate', 0):.0%}")558    lines.append(f"  Trade Success Rate: {avgs.get('trade_success_rate', 0):.0%}")559    lines.append(f"  Clutch Win Rate: {avgs.get('clutch_win_rate', 0):.0%}")560    lines.append(f"  Avg Flash Assists: {avgs.get('avg_flash_assists', 0):.1f}/player")561 562    # Per-player breakdown from most recent demo563    if your_data:564        latest = your_data[-1]565        players = latest.get("players") or {}566        if players:567            lines.append("\nPlayer Breakdown (most recent match):")568            for sid, pdata in players.items():569                name = pdata.get("name", sid[:8])570                stats = pdata.get("stats") or {}571                rating_data = pdata.get("rating") or {}572                lines.append(573                    f"  {name}: {stats.get('kills', 0)}K/{stats.get('deaths', 0)}D, "574                    f"Rating {rating_data.get('hltv_rating', 0):.2f}, "575                    f"ADR {stats.get('adr', 0):.1f}"576                )577 578    return "\n".join(lines)579 580 581# =============================================================================582# GamePlanGenerator583# =============================================================================584 585 586class GamePlanGenerator:587    """588    Generates complete game plans from team data + opponent scouting.589 590    Uses ModelTier.DEEP (Sonnet 4.5) — this is the capstone premium feature.591    """592 593    def __init__(self, api_key: str | None = None):594        """595        Initialize the game plan generator.596 597        Args:598            api_key: Anthropic API key (defaults to ANTHROPIC_API_KEY env var)599        """600        import os601 602        self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")603        self._client = None604 605    def _get_client(self):606        """Lazy initialization of Anthropic client."""607        if self._client is None:608            try:609                import anthropic610 611                self._client = anthropic.Anthropic(612                    api_key=self.api_key,613                    timeout=120,  # Long timeout for complex generation614                )615            except ImportError as e:616                raise ImportError(617                    "Anthropic library not installed. Install with: pip install anthropic"618                ) from e619        return self._client620 621    def generate(622        self,623        your_team_demos: list[dict],624        opponent_scouting: dict,625        map_name: str,626        roster: dict[str, str],627    ) -> GamePlan:628        """629        Generate complete game plan.630 631        Uses ModelTier.DEEP — this is the premium feature.632        Combines structured data analysis with LLM narrative generation.633 634        Args:635            your_team_demos: Orchestrator results from your recent matches636            opponent_scouting: Scouting engine data on opponent (TeamScoutReport.to_dict())637            map_name: Map name (e.g., "de_ancient")638            roster: Player name → role mapping (e.g., {"foe": "igl", "kix": "entry"})639 640        Returns:641            GamePlan with all sections populated642        """643        from opensight.ai.antistrat_report import _build_scouting_prompt644        from opensight.ai.llm_client import ModelTier, _build_cached_system, _log_usage645 646        plan_id = str(uuid.uuid4())647        opponent_name = opponent_scouting.get("team_name", "Unknown")648        now = datetime.now(UTC).isoformat()649 650        # Compute data quality confidence651        demos_analyzed = opponent_scouting.get("demos_analyzed", 0)652        confidence = self._compute_confidence(len(your_team_demos), demos_analyzed)653 654        plan = GamePlan(655            plan_id=plan_id,656            opponent=opponent_name,657            map_name=map_name,658            generated_at=now,659            confidence_overall=confidence,660            model_used=ModelTier.DEEP.value,661        )662 663        # 1. Build economy plan (heuristic, no LLM)664        plan.economy_plan = _build_economy_plan(your_team_demos, opponent_scouting)665 666        # 2. Build matchup analysis (heuristic, no LLM)667        matchup_text = build_matchup_analysis(your_team_demos, opponent_scouting)668 669        # 3. Build team summary670        your_team_text = _build_your_team_summary(your_team_demos)671 672        # 4. Build opponent scouting text673        opponent_text = _build_scouting_prompt(opponent_scouting)674 675        # 5. Set roster676        plan.player_roles = dict(roster)677 678        # If no API key, return data-only plan679        if not self.api_key:680            plan.generation_error = (681                "ANTHROPIC_API_KEY not configured. "682                "Plan contains economy guidance and matchup analysis "683                "but no LLM-generated tactical sections."684            )685            plan.executive_summary = (686                f"Game plan for {map_name} vs {opponent_name}. "687                f"Data confidence: {confidence:.0%}. "688                "LLM generation skipped — see economy plan and matchup analysis."689            )690            return plan691 692        # 6. Build LLM prompt693        user_prompt = _build_game_plan_prompt(694            matchup_analysis=matchup_text,695            opponent_scouting_text=opponent_text,696            your_team_text=your_team_text,697            map_name=map_name,698            roster=roster,699        )700 701        # 7. Call LLM702        try:703            client = self._get_client()704 705            logger.info(706                "Generating game plan: opponent=%s, map=%s, roster=%d players, confidence=%.0f%%",707                opponent_name,708                map_name,709                len(roster),710                confidence * 100,711            )712 713            message = client.messages.create(714                model=ModelTier.DEEP.value,715                max_tokens=4096,716                system=_build_cached_system(GAME_PLAN_SYSTEM_PROMPT),717                messages=[{"role": "user", "content": user_prompt}],718            )719 720            _log_usage(ModelTier.DEEP, message.usage)721 722            response_text = message.content[0].text723            self._populate_plan_from_llm(plan, response_text, roster)724 725            logger.info(726                "Game plan generated: %d T executes, %d CT adjustments, %d exploits",727                len(plan.t_executes),728                len(plan.ct_adjustments),729                len(plan.opponent_exploits),730            )731 732        except Exception as e:733            logger.error("Game plan LLM generation failed: %s", e)734            plan.generation_error = f"LLM generation failed: {type(e).__name__}: {e}"735 736        return plan737 738    def _compute_confidence(self, your_demos: int, opponent_demos: int) -> float:739        """740        Compute overall confidence based on data availability.741 742        More demos = higher confidence.743        """744        # Each side contributes up to 0.5745        your_conf = min(your_demos / 5, 1.0) * 0.5746        opp_conf = min(opponent_demos / 4, 1.0) * 0.5747        return your_conf + opp_conf748 749    def _populate_plan_from_llm(750        self, plan: GamePlan, response_text: str, roster: dict[str, str]751    ) -> None:752        """Parse LLM JSON response and populate the GamePlan."""753        # Strip markdown fencing if present754        json_text = response_text.strip()755        if json_text.startswith("```"):756            lines = json_text.split("\n")757            start = 1758            end = len(lines)759            for i in range(len(lines) - 1, 0, -1):760                if lines[i].strip() == "```":761                    end = i762                    break763            json_text = "\n".join(lines[start:end])764 765        try:766            data = json.loads(json_text)767        except json.JSONDecodeError as e:768            logger.warning("Failed to parse game plan JSON: %s", e)769            plan.generation_error = f"JSON parse error: {e}"770            plan.executive_summary = response_text[:500]771            return772 773        # Executive brief774        plan.executive_summary = data.get("executive_summary", "")775        plan.key_advantage = data.get("key_advantage", "")776        plan.key_risk = data.get("key_risk", "")777 778        # CT-side779        ct_default_data = data.get("ct_default")780        if ct_default_data:781            plan.ct_default = self._parse_strat_call(ct_default_data)782 783        for adj in data.get("ct_adjustments", []):784            plan.ct_adjustments.append(self._parse_strat_call(adj))785 786        plan.ct_retake_priorities = data.get("ct_retake_priorities", {})787 788        # T-side789        t_default_data = data.get("t_default")790        if t_default_data:791            plan.t_default = self._parse_strat_call(t_default_data)792 793        for exe in data.get("t_executes", []):794            plan.t_executes.append(self._parse_strat_call(exe))795 796        for read in data.get("t_read_based", []):797            plan.t_read_based.append(self._parse_strat_call(read))798 799        # Player assignments800        plan.player_roles = data.get("player_roles", dict(roster))801        plan.player_matchups = data.get("player_matchups", [])802 803        # Situational804        plan.if_losing = data.get("if_losing", [])805        plan.if_winning = data.get("if_winning", [])806        plan.timeout_triggers = data.get("timeout_triggers", [])807 808        # Anti-strat809        plan.opponent_exploits = data.get("opponent_exploits", [])810 811    def _parse_strat_call(self, data: dict) -> StratCall:812        """Parse a StratCall from LLM JSON output."""813        return StratCall(814            name=data.get("name", ""),815            description=data.get("description", ""),816            utility_sequence=data.get("utility_sequence", []),817            player_assignments=data.get("player_assignments", {}),818            when_to_call=data.get("when_to_call", ""),819            confidence=float(data.get("confidence", 0.5)),820            evidence=data.get("evidence", ""),821            expected_success_rate=data.get("expected_success_rate", ""),822        )823 824 825# =============================================================================826# In-memory plan cache827# =============================================================================828 829_plan_cache: dict[str, GamePlan] = {}830 831 832def cache_plan(plan: GamePlan) -> None:833    """Store a generated plan in the cache."""834    _plan_cache[plan.plan_id] = plan835    # Keep cache bounded836    if len(_plan_cache) > 50:837        oldest_key = next(iter(_plan_cache))838        del _plan_cache[oldest_key]839 840 841def get_cached_plan(plan_id: str) -> GamePlan | None:842    """Retrieve a cached plan by ID."""843    return _plan_cache.get(plan_id)844 845 846# =============================================================================847# Module-level convenience848# =============================================================================849 850_generator_instance: GamePlanGenerator | None = None851 852 853def get_game_plan_generator() -> GamePlanGenerator:854    """Get or create singleton GamePlanGenerator instance."""855    global _generator_instance856    if _generator_instance is None:857        _generator_instance = GamePlanGenerator()858    return _generator_instance859