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
|