thenuke02/cs2-analyzer
0
1"""2Map veto optimizer for CS2 best-of-1 and best-of-3 formats.3 4Combines map win rates with recency weighting and opponent data5to generate optimal ban/pick sequences.6 7Entirely heuristic — no LLM needed. Pure computation.8"""9 10import logging11import math12from dataclasses import dataclass, field13from typing import Any14 15logger = logging.getLogger(__name__)16 17ACTIVE_MAP_POOL = [18 "de_mirage",19 "de_inferno",20 "de_nuke",21 "de_ancient",22 "de_anubis",23 "de_dust2",24 "de_vertigo",25]26 27 28# =============================================================================29# Dataclasses30# =============================================================================31 32 33@dataclass34class MapStrength:35 """Team's performance on a specific map."""36 37 map_name: str38 matches_played: int = 039 win_rate: float = 0.5 # 0.0-1.040 ct_win_rate: float = 0.541 t_win_rate: float = 0.542 avg_rounds_won: float = 0.043 recency_weighted_win_rate: float = 0.544 confidence: float = 0.0 # higher with more matches played45 46 def to_dict(self) -> dict[str, Any]:47 """Convert to dictionary for JSON serialization."""48 return {49 "map_name": self.map_name,50 "matches_played": self.matches_played,51 "win_rate": round(self.win_rate, 3),52 "ct_win_rate": round(self.ct_win_rate, 3),53 "t_win_rate": round(self.t_win_rate, 3),54 "avg_rounds_won": round(self.avg_rounds_won, 1),55 "recency_weighted_win_rate": round(self.recency_weighted_win_rate, 3),56 "confidence": round(self.confidence, 3),57 }58 59 60@dataclass61class VetoRecommendation:62 """Recommended ban/pick for one step of the veto."""63 64 action: str # "ban" or "pick"65 map_name: str66 reason: str67 your_win_rate: float = 0.568 opponent_win_rate: float = 0.569 net_advantage: float = 0.0 # your WR - opponent WR70 71 def to_dict(self) -> dict[str, Any]:72 """Convert to dictionary for JSON serialization."""73 return {74 "action": self.action,75 "map_name": self.map_name,76 "reason": self.reason,77 "your_win_rate": round(self.your_win_rate, 3),78 "opponent_win_rate": round(self.opponent_win_rate, 3),79 "net_advantage": round(self.net_advantage, 3),80 }81 82 83@dataclass84class VetoAnalysis:85 """Complete veto analysis for a matchup."""86 87 your_map_pool: list[MapStrength] = field(default_factory=list)88 opponent_map_pool: list[MapStrength] = field(default_factory=list)89 recommended_veto_sequence: list[VetoRecommendation] = field(default_factory=list)90 best_map_for_you: str = ""91 worst_map_for_you: str = ""92 predicted_decider_map: str = ""93 confidence: str = "low"94 format: str = "bo1"95 96 def to_dict(self) -> dict[str, Any]:97 """Convert to dictionary for JSON serialization."""98 return {99 "your_map_pool": [ms.to_dict() for ms in self.your_map_pool],100 "opponent_map_pool": [ms.to_dict() for ms in self.opponent_map_pool],101 "recommended_veto_sequence": [vr.to_dict() for vr in self.recommended_veto_sequence],102 "best_map_for_you": self.best_map_for_you,103 "worst_map_for_you": self.worst_map_for_you,104 "predicted_decider_map": self.predicted_decider_map,105 "confidence": self.confidence,106 "format": self.format,107 }108 109 110# =============================================================================111# VetoOptimizer112# =============================================================================113 114 115class VetoOptimizer:116 """117 Generates optimal map veto sequences for CS2 matches.118 119 Supports BO1 (6 bans, 1 remaining) and BO3 (2 bans, 2 picks, 2 bans, 1 decider).120 """121 122 def analyze(123 self,124 your_demos: list[dict],125 opponent_data: list[dict] | dict,126 format: str = "bo1",127 ) -> VetoAnalysis:128 """129 Generate veto recommendations for an upcoming match.130 131 Args:132 your_demos: List of orchestrator results from your team's matches133 opponent_data: Either a list of orchestrator results OR a scouting134 dict (TeamScoutReport.to_dict() format)135 format: "bo1" or "bo3"136 137 Returns:138 VetoAnalysis with map strengths and recommended veto sequence139 """140 # Compute your map strengths141 your_maps = self._compute_map_strengths(your_demos)142 143 # Compute opponent map strengths144 if isinstance(opponent_data, dict):145 opp_maps = self._map_strengths_from_scouting(opponent_data)146 else:147 opp_maps = self._compute_map_strengths(opponent_data)148 149 # Fill in any missing maps from the active pool with defaults150 your_maps = self._fill_pool(your_maps)151 opp_maps = self._fill_pool(opp_maps)152 153 # Sort by win rate descending for readability154 your_maps.sort(key=lambda m: m.recency_weighted_win_rate, reverse=True)155 opp_maps.sort(key=lambda m: m.recency_weighted_win_rate, reverse=True)156 157 # Generate veto sequence158 if format == "bo3":159 sequence = self._generate_bo3_veto(your_maps, opp_maps)160 else:161 sequence = self._generate_bo1_veto(your_maps, opp_maps)162 163 # Determine best/worst maps164 best = max(your_maps, key=lambda m: m.recency_weighted_win_rate)165 worst = min(your_maps, key=lambda m: m.recency_weighted_win_rate)166 167 # Predict decider (last map in sequence or the non-banned map)168 decider = self._predict_decider(your_maps, opp_maps, format)169 170 # Overall confidence171 total_your_matches = sum(m.matches_played for m in your_maps)172 total_opp_matches = sum(m.matches_played for m in opp_maps)173 if total_your_matches >= 10 and total_opp_matches >= 10:174 confidence = "high"175 elif total_your_matches >= 5 or total_opp_matches >= 5:176 confidence = "medium"177 else:178 confidence = "low"179 180 return VetoAnalysis(181 your_map_pool=your_maps,182 opponent_map_pool=opp_maps,183 recommended_veto_sequence=sequence,184 best_map_for_you=best.map_name,185 worst_map_for_you=worst.map_name,186 predicted_decider_map=decider,187 confidence=confidence,188 format=format,189 )190 191 # =========================================================================192 # Map Strength Computation193 # =========================================================================194 195 def _compute_map_strengths(self, demos: list[dict]) -> list[MapStrength]:196 """197 Compute win rates per map from orchestrator result data.198 199 Assumes team1 (CT first half, score_ct) perspective.200 """201 # Group demos by map202 map_demos: dict[str, list[dict]] = {}203 for demo in demos:204 demo_info = demo.get("demo_info") or {}205 map_name = demo_info.get("map", "")206 if not map_name:207 continue208 map_demos.setdefault(map_name, []).append(demo)209 210 strengths: list[MapStrength] = []211 212 for map_name, demos_list in map_demos.items():213 wins = 0214 ct_round_wins = 0215 ct_round_total = 0216 t_round_wins = 0217 t_round_total = 0218 total_rounds_won = 0219 220 for demo in demos_list:221 demo_info = demo.get("demo_info") or {}222 score_ct = demo_info.get("score_ct", 0)223 score_t = demo_info.get("score_t", 0)224 225 # Team1 (CT first half) is "our" team226 total_rounds_won += score_ct227 if score_ct > score_t:228 wins += 1229 230 # CT/T side split from round_timeline231 timeline = demo.get("round_timeline") or []232 for rdata in timeline:233 winner = rdata.get("winner", "")234 round_num = rdata.get("round_num", 0)235 is_first_half = round_num <= 12236 237 if is_first_half:238 # Our team is CT in first half239 ct_round_total += 1240 if winner == "CT":241 ct_round_wins += 1242 else:243 # Our team is T in second half244 t_round_total += 1245 if winner == "T":246 t_round_wins += 1247 248 n = len(demos_list)249 win_rate = wins / n if n > 0 else 0.5250 ct_wr = ct_round_wins / ct_round_total if ct_round_total > 0 else 0.5251 t_wr = t_round_wins / t_round_total if t_round_total > 0 else 0.5252 avg_rounds = total_rounds_won / n if n > 0 else 0.0253 254 recency_wr = self._recency_weight(demos_list)255 confidence = 1 - math.exp(-n / 3)256 257 strengths.append(258 MapStrength(259 map_name=map_name,260 matches_played=n,261 win_rate=win_rate,262 ct_win_rate=ct_wr,263 t_win_rate=t_wr,264 avg_rounds_won=avg_rounds,265 recency_weighted_win_rate=recency_wr,266 confidence=confidence,267 )268 )269 270 return strengths271 272 def _map_strengths_from_scouting(self, scouting: dict) -> list[MapStrength]:273 """274 Estimate map strengths from scouting data (TeamScoutReport.to_dict()).275 276 Since scouting data doesn't contain explicit win rates, we estimate277 based on which maps the opponent plays on (maps in their map_tendencies278 are assumed to be their stronger maps).279 """280 map_tendencies = scouting.get("map_tendencies") or []281 282 # Demos analyzed gives us confidence283 demos_analyzed = scouting.get("demos_analyzed", 0)284 base_confidence = 1 - math.exp(-demos_analyzed / 3) if demos_analyzed > 0 else 0.0285 286 strengths: list[MapStrength] = []287 for mt in map_tendencies:288 map_name = mt.get("map_name", "")289 if not map_name:290 continue291 292 # Estimate strength from tendencies293 t_side = mt.get("t_side") or {}294 ct_side = mt.get("ct_side") or {}295 296 t_aggression = t_side.get("aggression", 50)297 ct_aggression = ct_side.get("aggression", 50)298 299 # Higher aggression on T-side + low CT aggression = T-sided team300 # Estimate a moderate win rate for maps they play on301 estimated_wr = 0.55 # They chose to play this map302 303 strengths.append(304 MapStrength(305 map_name=map_name,306 matches_played=demos_analyzed,307 win_rate=estimated_wr,308 ct_win_rate=0.5 + (ct_aggression - 50) * 0.002,309 t_win_rate=0.5 + (t_aggression - 50) * 0.002,310 avg_rounds_won=12.0, # approximate311 recency_weighted_win_rate=estimated_wr,312 confidence=base_confidence * 0.5, # lower confidence from scouting313 )314 )315 316 return strengths317 318 def _fill_pool(self, strengths: list[MapStrength]) -> list[MapStrength]:319 """320 Ensure all active pool maps are represented.321 Missing maps get 50% win rate with 0 confidence.322 """323 existing = {ms.map_name for ms in strengths}324 for map_name in ACTIVE_MAP_POOL:325 if map_name not in existing:326 strengths.append(MapStrength(map_name=map_name))327 return strengths328 329 def _recency_weight(self, demos: list[dict], half_life: int = 5) -> float:330 """331 Apply exponential decay weighting — recent matches matter more.332 333 Args:334 demos: List of demo dicts (assumed roughly chronological)335 half_life: Number of matches for weight to halve336 337 Returns:338 Recency-weighted win rate (0.0-1.0)339 """340 if not demos:341 return 0.5342 343 total_weight = 0.0344 weighted_wins = 0.0345 346 # Iterate from most recent (end of list) to oldest347 for i, demo in enumerate(reversed(demos)):348 weight = math.exp(-i / half_life)349 total_weight += weight350 351 demo_info = demo.get("demo_info") or {}352 score_ct = demo_info.get("score_ct", 0)353 score_t = demo_info.get("score_t", 0)354 355 if score_ct > score_t:356 weighted_wins += weight357 358 return weighted_wins / total_weight if total_weight > 0 else 0.5359 360 # =========================================================================361 # BO1 Veto Generation362 # =========================================================================363 364 def _generate_bo1_veto(365 self,366 your_maps: list[MapStrength],367 opp_maps: list[MapStrength],368 ) -> list[VetoRecommendation]:369 """370 BO1 veto: each team bans 3 maps, remaining map is played.371 372 Standard CS2 sequence: A ban, B ban, A ban, B ban, A ban, B ban, remaining.373 Strategy: ban opponent's best maps that give them the biggest advantage.374 """375 your_lookup = {ms.map_name: ms for ms in your_maps}376 opp_lookup = {ms.map_name: ms for ms in opp_maps}377 378 remaining = set(ACTIVE_MAP_POOL)379 sequence: list[VetoRecommendation] = []380 381 for step in range(6):382 is_our_ban = step % 2 == 0 # We ban on even steps383 384 if is_our_ban:385 # Our ban: ban the map where opponent has biggest advantage386 best_ban = self._pick_ban_target(387 remaining, your_lookup, opp_lookup, perspective="ours"388 )389 else:390 # Simulate opponent ban: they'd ban our best map391 best_ban = self._pick_ban_target(392 remaining, your_lookup, opp_lookup, perspective="theirs"393 )394 395 if best_ban is None:396 break397 398 your_ms = your_lookup.get(best_ban, MapStrength(map_name=best_ban))399 opp_ms = opp_lookup.get(best_ban, MapStrength(map_name=best_ban))400 net = your_ms.recency_weighted_win_rate - opp_ms.recency_weighted_win_rate401 402 if is_our_ban:403 reason = (404 f"Ban {best_ban} — opponent "405 f"{opp_ms.recency_weighted_win_rate:.0%} WR"406 f" ({opp_ms.matches_played} matches)"407 )408 else:409 reason = (410 f"Opponent bans {best_ban} — your "411 f"{your_ms.recency_weighted_win_rate:.0%} WR"412 f" ({your_ms.matches_played} matches)"413 )414 415 sequence.append(416 VetoRecommendation(417 action="ban",418 map_name=best_ban,419 reason=reason,420 your_win_rate=your_ms.recency_weighted_win_rate,421 opponent_win_rate=opp_ms.recency_weighted_win_rate,422 net_advantage=net,423 )424 )425 remaining.discard(best_ban)426 427 # Remaining map is the decider428 if remaining:429 decider = remaining.pop()430 your_ms = your_lookup.get(decider, MapStrength(map_name=decider))431 opp_ms = opp_lookup.get(decider, MapStrength(map_name=decider))432 net = your_ms.recency_weighted_win_rate - opp_ms.recency_weighted_win_rate433 434 sequence.append(435 VetoRecommendation(436 action="pick",437 map_name=decider,438 reason=f"Remaining map — your {your_ms.recency_weighted_win_rate:.0%} "439 f"vs their {opp_ms.recency_weighted_win_rate:.0%}",440 your_win_rate=your_ms.recency_weighted_win_rate,441 opponent_win_rate=opp_ms.recency_weighted_win_rate,442 net_advantage=net,443 )444 )445 446 return sequence447 448 # =========================================================================449 # BO3 Veto Generation450 # =========================================================================451 452 def _generate_bo3_veto(453 self,454 your_maps: list[MapStrength],455 opp_maps: list[MapStrength],456 ) -> list[VetoRecommendation]:457 """458 BO3 veto: ban-ban-pick-pick-ban-ban-decider.459 460 Standard CS2 sequence:461 1. Team A ban462 2. Team B ban463 3. Team A pick464 4. Team B pick465 5. Team A ban466 6. Team B ban467 7. Remaining map is decider468 """469 your_lookup = {ms.map_name: ms for ms in your_maps}470 opp_lookup = {ms.map_name: ms for ms in opp_maps}471 472 remaining = set(ACTIVE_MAP_POOL)473 sequence: list[VetoRecommendation] = []474 475 # Step 1: Our ban — ban opponent's best map476 target = self._pick_ban_target(remaining, your_lookup, opp_lookup, "ours")477 if target:478 self._add_step(479 sequence, "ban", target, your_lookup, opp_lookup, "Ban opponent's strongest map"480 )481 remaining.discard(target)482 483 # Step 2: Opponent ban — they ban our best map484 target = self._pick_ban_target(remaining, your_lookup, opp_lookup, "theirs")485 if target:486 self._add_step(487 sequence, "ban", target, your_lookup, opp_lookup, "Opponent bans your strongest map"488 )489 remaining.discard(target)490 491 # Step 3: Our pick — pick our best remaining map492 target = self._pick_best_map(remaining, your_lookup, opp_lookup, "ours")493 if target:494 self._add_step(495 sequence,496 "pick",497 target,498 your_lookup,499 opp_lookup,500 "Pick your strongest remaining map",501 )502 remaining.discard(target)503 504 # Step 4: Opponent pick — they pick their best remaining map505 target = self._pick_best_map(remaining, your_lookup, opp_lookup, "theirs")506 if target:507 self._add_step(508 sequence,509 "pick",510 target,511 your_lookup,512 opp_lookup,513 "Opponent picks their strongest remaining map",514 )515 remaining.discard(target)516 517 # Step 5: Our ban518 target = self._pick_ban_target(remaining, your_lookup, opp_lookup, "ours")519 if target:520 self._add_step(521 sequence, "ban", target, your_lookup, opp_lookup, "Ban opponent's best remaining"522 )523 remaining.discard(target)524 525 # Step 6: Opponent ban526 target = self._pick_ban_target(remaining, your_lookup, opp_lookup, "theirs")527 if target:528 self._add_step(529 sequence,530 "ban",531 target,532 your_lookup,533 opp_lookup,534 "Opponent bans your best remaining",535 )536 remaining.discard(target)537 538 # Step 7: Remaining map is decider539 if remaining:540 decider = remaining.pop()541 self._add_step(sequence, "pick", decider, your_lookup, opp_lookup, "Decider map")542 543 return sequence544 545 # =========================================================================546 # Veto Helpers547 # =========================================================================548 549 def _pick_ban_target(550 self,551 remaining: set[str],552 your_lookup: dict[str, MapStrength],553 opp_lookup: dict[str, MapStrength],554 perspective: str,555 ) -> str | None:556 """557 Pick the best map to ban from remaining pool.558 559 perspective="ours": ban the map where opponent has biggest advantage560 perspective="theirs": simulate opponent banning our best map561 """562 if not remaining:563 return None564 565 if perspective == "ours":566 # Ban the map where opponent advantage is biggest567 # i.e., opp_wr - your_wr is maximized568 return max(569 remaining,570 key=lambda m: (571 opp_lookup.get(m, MapStrength(map_name=m)).recency_weighted_win_rate572 - your_lookup.get(m, MapStrength(map_name=m)).recency_weighted_win_rate573 ),574 )575 else:576 # Opponent bans our best map577 # i.e., your_wr - opp_wr is maximized578 return max(579 remaining,580 key=lambda m: (581 your_lookup.get(m, MapStrength(map_name=m)).recency_weighted_win_rate582 - opp_lookup.get(m, MapStrength(map_name=m)).recency_weighted_win_rate583 ),584 )585 586 def _pick_best_map(587 self,588 remaining: set[str],589 your_lookup: dict[str, MapStrength],590 opp_lookup: dict[str, MapStrength],591 perspective: str,592 ) -> str | None:593 """594 Pick the best map to play from remaining pool.595 596 perspective="ours": pick map with highest net advantage for us597 perspective="theirs": pick map with highest net advantage for opponent598 """599 if not remaining:600 return None601 602 if perspective == "ours":603 return max(604 remaining,605 key=lambda m: (606 your_lookup.get(m, MapStrength(map_name=m)).recency_weighted_win_rate607 - opp_lookup.get(m, MapStrength(map_name=m)).recency_weighted_win_rate608 ),609 )610 else:611 return max(612 remaining,613 key=lambda m: (614 opp_lookup.get(m, MapStrength(map_name=m)).recency_weighted_win_rate615 - your_lookup.get(m, MapStrength(map_name=m)).recency_weighted_win_rate616 ),617 )618 619 def _add_step(620 self,621 sequence: list[VetoRecommendation],622 action: str,623 map_name: str,624 your_lookup: dict[str, MapStrength],625 opp_lookup: dict[str, MapStrength],626 reason_prefix: str,627 ) -> None:628 """Add a veto step to the sequence."""629 your_ms = your_lookup.get(map_name, MapStrength(map_name=map_name))630 opp_ms = opp_lookup.get(map_name, MapStrength(map_name=map_name))631 net = your_ms.recency_weighted_win_rate - opp_ms.recency_weighted_win_rate632 633 reason = (634 f"{reason_prefix}: {map_name} — "635 f"your {your_ms.recency_weighted_win_rate:.0%} vs "636 f"their {opp_ms.recency_weighted_win_rate:.0%}"637 )638 639 sequence.append(640 VetoRecommendation(641 action=action,642 map_name=map_name,643 reason=reason,644 your_win_rate=your_ms.recency_weighted_win_rate,645 opponent_win_rate=opp_ms.recency_weighted_win_rate,646 net_advantage=net,647 )648 )649 650 def _predict_decider(651 self,652 your_maps: list[MapStrength],653 opp_maps: list[MapStrength],654 format: str,655 ) -> str:656 """657 Predict which map will be the decider after optimal vetoes.658 """659 your_lookup = {ms.map_name: ms for ms in your_maps}660 opp_lookup = {ms.map_name: ms for ms in opp_maps}661 662 remaining = set(ACTIVE_MAP_POOL)663 664 if format == "bo3":665 # Simulate: ban-ban-pick-pick-ban-ban-decider666 steps = [667 ("ours", "ban"),668 ("theirs", "ban"),669 ("ours", "pick"),670 ("theirs", "pick"),671 ("ours", "ban"),672 ("theirs", "ban"),673 ]674 else:675 # BO1: 3 bans each676 steps = [677 ("ours", "ban"),678 ("theirs", "ban"),679 ("ours", "ban"),680 ("theirs", "ban"),681 ("ours", "ban"),682 ("theirs", "ban"),683 ]684 685 for perspective, action in steps:686 if not remaining:687 break688 if action == "ban":689 target = self._pick_ban_target(remaining, your_lookup, opp_lookup, perspective)690 else:691 target = self._pick_best_map(remaining, your_lookup, opp_lookup, perspective)692 if target:693 remaining.discard(target)694 695 if remaining:696 return remaining.pop()697 return ""698 699 700# =============================================================================701# Module-level convenience702# =============================================================================703 704_optimizer_instance: VetoOptimizer | None = None705 706 707def get_veto_optimizer() -> VetoOptimizer:708 """Get or create singleton VetoOptimizer instance."""709 global _optimizer_instance710 if _optimizer_instance is None:711 _optimizer_instance = VetoOptimizer()712 return _optimizer_instance713 