Initial commit
This commit is contained in:
254
hsbg_ai/backend/ai/engine/heuristics.py
Normal file
254
hsbg_ai/backend/ai/engine/heuristics.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user