""" Moteur heuristique HSBG. Règles métier codées en dur — rapides, déterministes, toujours disponibles. """ from collections import Counter from backend.ai.engine.decision_engine import GameState, Decision import structlog log = structlog.get_logger() class HeuristicEngine: """ Évalue l'état de jeu avec des règles métier HSBG. Chaque règle retourne une Decision ou None. Triées par priorité × confiance décroissante. """ def evaluate(self, state: GameState) -> list[Decision]: """Évalue toutes les règles et retourne les décisions candidates.""" rules = [ self._rule_triple, # Priorité max: triple = version dorée self._rule_upgrade, # Montée de tier self._rule_freeze, # Geler bonnes cartes self._rule_buy_synergy, # Acheter dans la synergie self._rule_sell, # Vendre les faibles self._rule_economy, # Gestion de l'or self._rule_reposition, # Positionnement board ] decisions = [] for rule in rules: try: result = rule(state) if result: decisions.append(result) except Exception as e: log.warning("heuristic.error", rule=rule.__name__, error=str(e)) return sorted(decisions, key=lambda d: d.priority * d.confidence, reverse=True) # ─── Règles ─────────────────────────────────────────────────────────────── def _rule_triple(self, state: GameState) -> Decision | None: """Détecter et compléter les triples — priorité maximale.""" all_minions = state.board_minions + state.hand_minions counts = Counter(m.get("name") for m in all_minions if m.get("name")) for name, count in counts.items(): if count >= 2: # Chercher le 3ème en taverne for m in state.tavern_minions: if m.get("name") == name: can_afford = m.get("cost", 3) <= state.gold return Decision( action="buy", target=m, priority=9, confidence=0.90, reasoning=f"🏆 TRIPLE en vue! Acheter {name} → version dorée!", warnings=[] if can_afford else [ f"Manque {m.get('cost',3) - state.gold}g — vendre un serviteur si nécessaire" ], ) return None def _rule_upgrade(self, state: GameState) -> Decision | None: """Monter de tier au bon moment.""" if not state.can_upgrade or state.gold < state.upgrade_cost: return None # Montée d'urgence (HP bas + mauvaise position) if state.current_placement >= 6 and state.hero_hp < 20 and state.upgrade_cost <= 4: return Decision( action="upgrade", priority=8, confidence=0.80, reasoning=f"🚨 Urgence: HP={state.hero_hp}, position={state.current_placement} → tier {state.tavern_tier + 1}", ) # Calendrier optimal de montée optimal = {1: 3, 2: 5, 3: 7, 4: 10, 5: 14} target_turn = optimal.get(state.tavern_tier, 99) if state.turn >= target_turn: return Decision( action="upgrade", priority=6, confidence=0.65, reasoning=f"📈 Tour {state.turn}: montée optimale vers tier {state.tavern_tier + 1} ({state.upgrade_cost}g)", ) # Montée accélérée si beaucoup d'or en retard if state.gold >= state.upgrade_cost + 4 and state.turn >= target_turn - 2: return Decision( action="upgrade", priority=5, confidence=0.55, reasoning=f"💰 Or abondant ({state.gold}g) — montée anticipée tier {state.tavern_tier + 1}", ) return None def _rule_freeze(self, state: GameState) -> Decision | None: """Geler si de bonnes cartes ne sont pas achetables ce tour.""" if state.freeze: return None # Déjà gelé strong = [m for m in state.tavern_minions if self._is_strong(m, state)] if len(strong) < 2: return None affordable = [m for m in strong if m.get("cost", 3) <= state.gold] unaffordable = [m for m in strong if m.get("cost", 3) > state.gold] if unaffordable: return Decision( action="freeze", priority=7, confidence=0.68, reasoning=f"❄️ Geler: {len(strong)} bonne(s) carte(s), {len(unaffordable)} non achetable(s) ce tour", synergies_highlighted=[m.get("name", "") for m in strong], ) return None def _rule_buy_synergy(self, state: GameState) -> Decision | None: """Acheter une carte qui renforce la synergie de race principale.""" if state.gold < 3 or not state.tavern_minions: return None # Calculer la race dominante sur le board races = [] for m in state.board_minions: r = m.get("race", []) races.extend(r if isinstance(r, list) else [r]) if not races: return None race_counts = Counter(races) top_race, top_count = race_counts.most_common(1)[0] if top_count < 2 or top_race in ("none", "", "all"): return None # Chercher en taverne une carte de cette race synergy_targets = [] for m in state.tavern_minions: m_races = m.get("race", []) if isinstance(m_races, str): m_races = [m_races] if top_race in m_races and m.get("cost", 3) <= state.gold: synergy_targets.append(m) if synergy_targets: # Préférer les cartes avec des effets best = max(synergy_targets, key=lambda m: ( int(m.get("has_divine", 0)) * 3 + int(bool(m.get("battlecry"))) * 2 + int(bool(m.get("deathrattle"))) * 2 + m.get("attack", 0) + m.get("health", 0) )) return Decision( action="buy", target=best, priority=7, confidence=0.72, reasoning=f"🔗 Renforcer synergie {top_race} ({top_count} sur board): acheter {best.get('name', '?')}", synergies_highlighted=[top_race], ) return None def _rule_sell(self, state: GameState) -> Decision | None: """Vendre les serviteurs trop faibles pour libérer de la place.""" if len(state.board_minions) < 6: return None weak = [m for m in state.board_minions if self._is_weak(m, state)] if not weak: return None worst = min(weak, key=lambda m: m.get("attack", 0) + m.get("health", 0)) return Decision( action="sell", target=worst, priority=5, confidence=0.62, reasoning=f"🗑️ Vendre {worst.get('name', '?')} ({worst.get('attack',0)}/{worst.get('health',0)}) — trop faible en tier {state.tavern_tier}", ) def _rule_economy(self, state: GameState) -> Decision | None: """Gérer prudemment l'or en début de partie.""" if state.gold <= 2 and state.turn < 4: return Decision( action="wait", priority=3, confidence=0.50, reasoning=f"💸 Or limité ({state.gold}g) en tour {state.turn} — économiser pour la suite", warnings=["Éviter les gels coûteux en early game"], ) return None def _rule_reposition(self, state: GameState) -> Decision | None: """Suggérer un repositionnement si des cartes clés sont mal placées.""" if len(state.board_minions) < 3: return None has_taunt = any(m.get("has_taunt") for m in state.board_minions) has_divine = any(m.get("has_divine") for m in state.board_minions) has_cleave = any(m.get("on_attack") and "adjacent" in m.get("on_attack","").lower() for m in state.board_minions) if has_taunt or has_divine or has_cleave: tips = [] if has_taunt: tips.append("Taunt à gauche (absorbe les attaques)") if has_divine: tips.append("Divine Shield au centre ou protégé") if has_cleave: tips.append("Cleave en position 1 ou 3") return Decision( action="reposition", priority=4, confidence=0.58, reasoning=f"🗺️ Optimiser le board: {' | '.join(tips)}", ) return None # ─── Helpers ────────────────────────────────────────────────────────────── def _is_strong(self, minion: dict, state: GameState) -> bool: """Un serviteur est fort si ses stats dépassent le seuil du tier actuel.""" stat_thresholds = {1: 4, 2: 7, 3: 10, 4: 14, 5: 18, 6: 22} min_stats = stat_thresholds.get(state.tavern_tier, 8) stats = minion.get("attack", 0) + minion.get("health", 0) return ( stats >= min_stats or minion.get("has_divine", False) or minion.get("has_taunt", False) or bool(minion.get("battlecry", "")) or bool(minion.get("deathrattle", "")) or bool(minion.get("passive", "")) ) def _is_weak(self, minion: dict, state: GameState) -> bool: """Un serviteur tier 1-2 sans capacité est faible en mid/late game.""" if state.turn < 8: return False tier = int(minion.get("tier", "1")) if tier > 2: return False stats = minion.get("attack", 0) + minion.get("health", 0) has_ability = ( minion.get("has_divine") or minion.get("has_taunt") or minion.get("battlecry") or minion.get("deathrattle") or minion.get("passive") ) return stats < 6 and not has_ability