thenuke02/cs2-analyzer
0
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 