Initial commit

This commit is contained in:
2026-03-31 13:10:46 +02:00
commit f60d9628e0
52 changed files with 3383 additions and 0 deletions

View 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