255 lines
10 KiB
Python
255 lines
10 KiB
Python
"""
|
||
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
|