Files
hsbg-ai/hsbg_ai/backend/ai/engine/heuristics.py
2026-03-31 13:10:46 +02:00

255 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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