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

49
.gitignore vendored Normal file
View File

@@ -0,0 +1,49 @@
# ── Node.js / React ───────────────────────────────────────────────────────────
node_modules/
dist/
build/
.next/
.nuxt/
.vite/
*.tsbuildinfo
# ── Python ────────────────────────────────────────────────────────────────────
__pycache__/
*.py[cod]
venv/
.venv/
env/
# ── Secrets ────────────────────────────────────────────────────────────────────
.env
.env.*
.env.local
.env.production
secret.env
*.secret
credentials.json
# ── Logs ──────────────────────────────────────────────────────────────────────
*.log
logs/
npm-debug.log*
yarn-debug.log*
# ── Modèles IA (trop lourds) ──────────────────────────────────────────────────
*.gguf
*.bin
*.safetensors
models/
# ── Cache ─────────────────────────────────────────────────────────────────────
.cache/
.parcel-cache/
.pytest_cache/
.coverage
# ── IDE ───────────────────────────────────────────────────────────────────────
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db

View File

View File

View File

View File

@@ -0,0 +1,196 @@
"""
Moteur de décision IA principal - Architecture hybride.
Flux: StateAnalyzer → HeuristicEngine + LLMAdvisor → Fusion → FullAdvice
"""
import time
from dataclasses import dataclass, field
import structlog
log = structlog.get_logger()
@dataclass
class GameState:
"""État complet d'un tour HSBG."""
turn: int = 0
tavern_tier: int = 1
gold: int = 3
hero_id: str = ""
hero_hp: int = 40
tavern_minions: list = field(default_factory=list)
board_minions: list = field(default_factory=list)
hand_minions: list = field(default_factory=list)
freeze: bool = False
can_upgrade: bool = True
upgrade_cost: int = 5
available_spells: list = field(default_factory=list)
opponent_boards: list = field(default_factory=list)
current_placement: int = 5
player_count: int = 8
phase: str = "recruit"
@dataclass
class Decision:
"""Une décision IA avec justification."""
action: str # buy|sell|freeze|upgrade|reposition|hero_power|wait
target: dict = None # Carte ou minion ciblé
priority: int = 5 # 1-10
confidence: float = 0.5 # 0.0-1.0
reasoning: str = "" # Explication textuelle
alternatives: list = field(default_factory=list)
synergies_highlighted: list = field(default_factory=list)
warnings: list = field(default_factory=list)
@dataclass
class FullAdvice:
"""Conseil complet pour un tour entier."""
main_decision: Decision
secondary_decisions: list = field(default_factory=list)
board_analysis: str = ""
strategy_long_term: str = ""
threat_assessment: str = ""
processing_ms: int = 0
model_used: str = "heuristic"
confidence_overall: float = 0.5
class DecisionEngine:
"""
Moteur de décision hybride HSBG.
Architecture:
1. StateAnalyzer → Parse l'état brut en GameState typé
2. HeuristicEngine → Règles métier rapides et déterministes
3. LLMAdvisor → Raisonnement LLM pour enrichir (si disponible)
4. Fusion → Combine les deux avec pondération par confiance
"""
def __init__(self, settings):
self.settings = settings
self._initialized = False
# Import lazy pour éviter les imports circulaires
from backend.ai.engine.heuristics import HeuristicEngine
from backend.ai.engine.llm_advisor import LLMAdvisor
from backend.ai.engine.state_analyzer import StateAnalyzer
self.heuristic = HeuristicEngine()
self.llm = LLMAdvisor(settings)
self.analyzer = StateAnalyzer()
async def initialize(self):
"""Initialise les composants asynchrones (LLM)."""
await self.llm.initialize()
self._initialized = True
log.info("decision_engine.ready", model=self.settings.llm_model)
async def get_advice(self, raw_state: dict) -> FullAdvice:
"""
Point d'entrée principal.
Args:
raw_state: Dict brut (depuis API ou OCR)
Returns:
FullAdvice avec conseil principal + alternatives
"""
start = time.perf_counter()
# 1. Parser l'état brut
state = await self.analyzer.parse(raw_state)
# 2. Heuristiques (toujours disponibles, < 5ms)
heuristic_decisions = self.heuristic.evaluate(state)
# 3. LLM (si disponible, ~500-2000ms)
llm_advice = None
if self._initialized:
try:
llm_advice = await self.llm.advise(state, heuristic_decisions)
except Exception as e:
log.warning("llm.failed_gracefully", error=str(e))
# 4. Fusion avec pondération
final = self._fuse(state, heuristic_decisions, llm_advice)
final.processing_ms = int((time.perf_counter() - start) * 1000)
log.info(
"advice.generated",
action=final.main_decision.action,
confidence=round(final.main_decision.confidence, 2),
ms=final.processing_ms,
model=final.model_used,
)
return final
def _fuse(self, state: GameState, heuristics: list, llm: "FullAdvice | None") -> FullAdvice:
"""
Fusionne heuristiques + LLM.
Règle: LLM prioritaire si confiance > 0.7, sinon heuristiques.
"""
if not heuristics:
return FullAdvice(
main_decision=Decision(action="wait", reasoning="Aucune action identifiée"),
model_used="fallback",
)
# LLM haute confiance → priorité totale
if llm and llm.confidence_overall > 0.7:
llm.model_used = f"{self.settings.llm_model}+heuristic"
return llm
# Heuristiques comme base
main = max(heuristics, key=lambda d: d.priority * d.confidence)
secondary = sorted(
[d for d in heuristics if d != main],
key=lambda d: d.priority,
reverse=True,
)[:3]
result = FullAdvice(
main_decision=main,
secondary_decisions=secondary,
board_analysis=self._analyze_board(state),
model_used="heuristic",
confidence_overall=main.confidence,
)
# Enrichissement LLM partiel (stratégie + menaces même si confiance faible)
if llm:
result.strategy_long_term = llm.strategy_long_term
result.threat_assessment = llm.threat_assessment
result.model_used = f"heuristic+{self.settings.llm_model}"
return result
def _analyze_board(self, state: GameState) -> str:
"""Génère une analyse textuelle rapide du board."""
parts = []
if len(state.board_minions) == 7:
parts.append("⚠️ Board plein — vendre avant d'acheter")
if state.gold >= 10 and state.tavern_tier < 6:
parts.append(f"💰 Or abondant — envisager tier {state.tavern_tier + 1}")
if state.current_placement > 5 and state.turn > 8:
parts.append("🚨 Position critique — trouver synergie forte rapidement")
# Détecter synergie dominante
from collections import Counter
races = []
for m in state.board_minions:
r = m.get("race", [])
races.extend(r if isinstance(r, list) else [r])
if races:
top_race, top_count = Counter(races).most_common(1)[0]
if top_count >= 3:
parts.append(f"✨ Synergie {top_race} détectée ({top_count}/7)")
if state.freeze:
parts.append("❄️ Taverne gelée — les cartes sont réservées")
return " | ".join(parts) if parts else "Board standard — continuer normalement"
async def shutdown(self):
"""Arrête proprement les composants."""
await self.llm.shutdown()

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

View File

@@ -0,0 +1,134 @@
"""Conseiller LLM local via Ollama — enrichit les décisions heuristiques."""
import json
import httpx
import structlog
from backend.ai.engine.decision_engine import GameState, Decision, FullAdvice
log = structlog.get_logger()
SYSTEM_PROMPT = """Tu es un expert Hearthstone Battlegrounds rang Légende.
Analyse l'état de jeu et donne un conseil tactique optimal.
Réponds UNIQUEMENT avec un objet JSON valide, sans texte avant/après, sans markdown:
{"main_action":"buy|sell|freeze|upgrade|reposition|hero_power|wait","target_card":null,"priority":7,"confidence":0.8,"reasoning":"explication courte","strategy":"stratégie long terme 1 phrase","threats":"principale menace adversaire","warnings":[]}"""
class LLMAdvisor:
"""Interface avec Ollama pour les conseils LLM."""
def __init__(self, settings):
self.settings = settings
self.base_url = settings.llm_base_url
self.model = settings.llm_model
self._client: httpx.AsyncClient | None = None
self._available = False
async def initialize(self):
"""Teste la disponibilité d'Ollama et du modèle."""
self._client = httpx.AsyncClient(timeout=10)
try:
r = await self._client.get(f"{self.base_url}/api/tags")
if r.status_code == 200:
models = r.json().get("models", [])
model_names = [m.get("name", "") for m in models]
self._available = any(self.model in name for name in model_names)
if self._available:
log.info("llm.ready", model=self.model)
else:
log.warning("llm.model_not_found", model=self.model,
available=model_names,
hint=f"Exécutez: ollama pull {self.model}")
except Exception as e:
log.warning("llm.ollama_unreachable", url=self.base_url, error=str(e),
hint="Installez Ollama: curl -fsSL https://ollama.ai/install.sh | sh")
self._available = False
async def advise(self, state: GameState, heuristics: list[Decision]) -> FullAdvice | None:
"""Demande un conseil au LLM local."""
if not self._available or not self._client:
return None
prompt = self._build_prompt(state, heuristics)
try:
r = await self._client.post(
f"{self.base_url}/api/generate",
json={
"model": self.model,
"prompt": prompt,
"system": SYSTEM_PROMPT,
"stream": False,
"options": {
"temperature": self.settings.llm_temperature,
"num_predict": self.settings.llm_max_tokens,
},
},
timeout=self.settings.llm_timeout,
)
if r.status_code == 200:
raw = r.json().get("response", "")
return self._parse(raw)
except Exception as e:
log.warning("llm.request_failed", error=str(e))
return None
def _build_prompt(self, state: GameState, decisions: list[Decision]) -> str:
"""Construit le prompt depuis l'état de jeu."""
board = ", ".join(
f"{m.get('name','?')}({m.get('attack',0)}/{m.get('health',0)})"
+ (" [DIVINE]" if m.get("has_divine") else "")
+ (" [TAUNT]" if m.get("has_taunt") else "")
for m in state.board_minions
) or "vide"
tavern = ", ".join(
f"{m.get('name','?')}[{m.get('cost',3)}g T{m.get('tier',1)}]"
for m in state.tavern_minions
) or "vide"
top_h = decisions[0].reasoning if decisions else "aucune heuristique"
return f"""=== ÉTAT DU JEU ===
Tour: {state.turn} | Tier taverne: {state.tavern_tier} | Or: {state.gold}g
Héros: {state.hero_id} | HP: {state.hero_hp} | Position: {state.current_placement}/{state.player_count}
Board ({len(state.board_minions)}/7): {board}
Taverne: {tavern}
Gel: {'OUI' if state.freeze else 'NON'} | Upgrade possible: {'OUI' if state.can_upgrade else 'NON'} ({state.upgrade_cost}g)
Phase: {state.phase}
=== MEILLEURE HEURISTIQUE ===
{top_h}
Analyse et donne ta recommandation JSON."""
def _parse(self, raw: str) -> FullAdvice | None:
"""Parse la réponse JSON du LLM."""
try:
start = raw.find("{")
end = raw.rfind("}") + 1
if start == -1 or end == 0:
return None
data = json.loads(raw[start:end])
main = Decision(
action=data.get("main_action", "wait"),
priority=min(10, max(1, int(data.get("priority", 5)))),
confidence=min(1.0, max(0.0, float(data.get("confidence", 0.5)))),
reasoning=data.get("reasoning", ""),
warnings=data.get("warnings", []) or [],
)
return FullAdvice(
main_decision=main,
strategy_long_term=data.get("strategy", ""),
threat_assessment=data.get("threats", ""),
confidence_overall=main.confidence,
model_used=self.model,
)
except (json.JSONDecodeError, ValueError, TypeError) as e:
log.warning("llm.parse_failed", error=str(e), raw_preview=raw[:300])
return None
async def shutdown(self):
if self._client:
await self._client.aclose()

View File

@@ -0,0 +1,26 @@
"""Parse et normalise l'état de jeu brut en GameState typé."""
from backend.ai.engine.decision_engine import GameState
class StateAnalyzer:
"""Convertit les données brutes (API, OCR, manuel) en GameState."""
async def parse(self, raw: dict) -> GameState:
return GameState(
turn=int(raw.get("turn", 0)),
tavern_tier=int(raw.get("tavern_tier", 1)),
gold=int(raw.get("gold", 3)),
hero_id=str(raw.get("hero_id", "")),
hero_hp=int(raw.get("hero_hp", 40)),
tavern_minions=list(raw.get("tavern_minions", [])),
board_minions=list(raw.get("board_minions", [])),
hand_minions=list(raw.get("hand_minions", [])),
freeze=bool(raw.get("freeze", False)),
can_upgrade=bool(raw.get("can_upgrade", True)),
upgrade_cost=int(raw.get("upgrade_cost", 5)),
available_spells=list(raw.get("available_spells", [])),
opponent_boards=list(raw.get("opponent_boards", [])),
current_placement=int(raw.get("current_placement", 5)),
player_count=int(raw.get("player_count", 8)),
phase=str(raw.get("phase", "recruit")),
)

View File

View File

@@ -0,0 +1,117 @@
"""Système d'apprentissage par feedback utilisateur."""
import json
import os
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from backend.database.models import AIDecision, LearningFeedback
import structlog
log = structlog.get_logger()
class FeedbackProcessor:
"""
Traite les retours utilisateur pour améliorer l'IA.
Workflow:
1. Utilisateur évalue une décision (bon/mauvais/neutre)
2. Le feedback est persisté en DB
3. Un buffer accumule les feedbacks
4. Quand le buffer est plein → export JSON pour entraînement futur
"""
def __init__(self, settings):
self.settings = settings
self._buffer: list[dict] = []
self._trained_count = 0
async def record_feedback(
self,
db: AsyncSession,
decision_id: int,
rating: str,
better_action: dict | None = None,
comment: str | None = None,
) -> LearningFeedback:
"""Enregistre un feedback et met à jour la décision associée."""
decision = await db.get(AIDecision, decision_id)
if not decision:
raise ValueError(f"Décision {decision_id} introuvable")
# Créer le feedback
fb = LearningFeedback(
decision_id=decision_id,
rating=rating,
better_action=better_action,
comment=comment,
)
db.add(fb)
# Mettre à jour la décision avec le résultat
decision.outcome_rating = {"good": 1, "neutral": 0, "bad": -1}.get(rating, 0)
decision.user_feedback = comment
if better_action:
decision.better_decision = better_action
await db.flush()
# Buffer pour entraînement
self._buffer.append({
"decision_id": decision_id,
"game_state": decision.game_state,
"recommendation": decision.recommendation,
"rating": rating,
"better_action": better_action,
"ts": datetime.utcnow().isoformat(),
})
log.info("feedback.recorded", id=decision_id, rating=rating,
buffer=len(self._buffer))
# Auto-flush si buffer plein
if (self.settings.learning_auto_save
and len(self._buffer) >= self.settings.learning_batch_size):
await self._flush_buffer()
return fb
async def _flush_buffer(self):
"""Exporte le buffer en JSON pour entraînement."""
if not self._buffer:
return
os.makedirs("data/learning/feedback", exist_ok=True)
fname = f"data/learning/feedback/batch_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
try:
import aiofiles
async with aiofiles.open(fname, "w") as f:
await f.write(json.dumps(self._buffer, indent=2, ensure_ascii=False))
self._trained_count += len(self._buffer)
log.info("feedback.batch_saved", count=len(self._buffer), file=fname)
except Exception as e:
log.error("feedback.flush_failed", error=str(e))
finally:
self._buffer.clear()
async def force_flush(self):
"""Flush manuel du buffer."""
await self._flush_buffer()
async def get_stats(self, db: AsyncSession) -> dict:
"""Statistiques globales du système d'apprentissage."""
result = await db.execute(select(LearningFeedback))
feedbacks = result.scalars().all()
total = len(feedbacks)
good = sum(1 for f in feedbacks if f.rating == "good")
bad = sum(1 for f in feedbacks if f.rating == "bad")
neutral = total - good - bad
return {
"total": total,
"good": good,
"bad": bad,
"neutral": neutral,
"good_rate": round(good / total * 100, 1) if total > 0 else 0.0,
"trained": self._trained_count,
"buffer_pending": len(self._buffer),
"learning_enabled": self.settings.learning_enabled,
}

View File

View File

View File

@@ -0,0 +1,115 @@
"""Routes API — Conseils IA."""
from fastapi import APIRouter, Depends, Request, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from backend.database.db import get_db
from backend.database.models import AIDecision
router = APIRouter()
class AdviceRequest(BaseModel):
session_id: int | None = None
turn: int = 0
tavern_tier: int = 1
gold: int = 3
hero_id: str = ""
hero_hp: int = 40
board_minions: list = []
tavern_minions: list = []
hand_minions: list = []
freeze: bool = False
can_upgrade: bool = True
upgrade_cost: int = 5
available_spells: list = []
current_placement: int = 5
player_count: int = 8
phase: str = "recruit"
def serialize_advice(advice) -> dict:
"""Sérialise un objet FullAdvice en dict JSON-serializable."""
return {
"main_decision": {
"action": advice.main_decision.action,
"target": advice.main_decision.target,
"priority": advice.main_decision.priority,
"confidence": advice.main_decision.confidence,
"reasoning": advice.main_decision.reasoning,
"synergies": advice.main_decision.synergies_highlighted,
"warnings": advice.main_decision.warnings,
},
"secondary_decisions": [
{
"action": d.action,
"target": d.target,
"priority": d.priority,
"confidence": d.confidence,
"reasoning": d.reasoning,
}
for d in advice.secondary_decisions
],
"board_analysis": advice.board_analysis,
"strategy_long_term": advice.strategy_long_term,
"threat_assessment": advice.threat_assessment,
"processing_ms": advice.processing_ms,
"model_used": advice.model_used,
"confidence_overall": advice.confidence_overall,
}
@router.post("/")
async def get_advice(
req: AdviceRequest,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""Génère un conseil IA pour l'état de jeu fourni."""
ai = request.app.state.ai_service
if not ai:
raise HTTPException(503, "Service IA non initialisé")
state = req.model_dump()
advice = await ai.get_advice(state)
data = serialize_advice(advice)
# Persister si session active
if req.session_id:
dec = AIDecision(
session_id=req.session_id,
turn=req.turn,
phase=req.phase,
game_state=state,
recommendation=data,
reasoning=advice.main_decision.reasoning,
confidence=advice.confidence_overall,
model_used=advice.model_used,
processing_ms=advice.processing_ms,
)
db.add(dec)
await db.flush()
data["decision_id"] = dec.id
return data
@router.get("/from-screen")
async def advice_from_screen(request: Request, db: AsyncSession = Depends(get_db)):
"""Génère un conseil depuis la capture d'écran en cours."""
vis = request.app.state.vision_service
ai = request.app.state.ai_service
if not vis:
raise HTTPException(503, "Service vision non disponible")
# Obtenir l'état actuel (ou déclencher une capture)
state = vis.get_current_state()
if not state:
state = await vis.capture_now()
advice = await ai.get_advice(state)
return {
"advice": serialize_advice(advice),
"screenshot": vis.get_screenshot_b64(),
"extracted_state": state,
}

View File

@@ -0,0 +1,146 @@
"""Routes API — Base de données HSBG (héros, serviteurs, sorts)."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from backend.database.db import get_db
from backend.database.models import Hero, Minion, Spell
router = APIRouter()
# ─── Héros ────────────────────────────────────────────────────────────────────
@router.get("/heroes")
async def list_heroes(search: str | None = None, db: AsyncSession = Depends(get_db)):
q = select(Hero).where(Hero.is_active == True)
if search:
q = q.where(Hero.name.ilike(f"%{search}%"))
r = await db.execute(q)
return [
{"id": h.id, "card_id": h.card_id, "name": h.name, "hero_power": h.hero_power,
"description": h.description, "strengths": h.strengths, "weaknesses": h.weaknesses,
"synergies": h.synergies, "tier_rating": h.tier_rating, "patch_added": h.patch_added}
for h in r.scalars().all()
]
class HeroIn(BaseModel):
card_id: str
name: str
hero_power: str = ""
description: str = ""
strengths: list = []
weaknesses: list = []
synergies: list = []
tier_rating: float = 5.0
patch_added: str = ""
@router.post("/heroes", status_code=201)
async def create_hero(data: HeroIn, db: AsyncSession = Depends(get_db)):
hero = Hero(**data.model_dump())
db.add(hero)
await db.flush()
return {"id": hero.id, "name": hero.name}
@router.put("/heroes/{hero_id}")
async def update_hero(hero_id: int, data: HeroIn, db: AsyncSession = Depends(get_db)):
hero = await db.get(Hero, hero_id)
if not hero:
raise HTTPException(404, "Héros introuvable")
for k, v in data.model_dump().items():
setattr(hero, k, v)
return {"id": hero.id, "name": hero.name}
@router.delete("/heroes/{hero_id}")
async def delete_hero(hero_id: int, db: AsyncSession = Depends(get_db)):
hero = await db.get(Hero, hero_id)
if not hero:
raise HTTPException(404, "Héros introuvable")
hero.is_active = False
return {"status": "deactivated"}
# ─── Serviteurs ───────────────────────────────────────────────────────────────
@router.get("/minions")
async def list_minions(
tier: int | None = None,
race: str | None = None,
search: str | None = None,
db: AsyncSession = Depends(get_db),
):
q = select(Minion).where(Minion.is_active == True)
if tier:
q = q.where(Minion.tier == str(tier))
if search:
q = q.where(Minion.name.ilike(f"%{search}%"))
r = await db.execute(q)
minions = r.scalars().all()
if race:
minions = [m for m in minions if race in (m.race or [])]
return [
{"id": m.id, "card_id": m.card_id, "name": m.name, "tier": m.tier,
"race": m.race, "attack": m.attack, "health": m.health,
"has_divine": m.has_divine, "has_taunt": m.has_taunt,
"has_windfury": m.has_windfury, "has_poisonous": m.has_poisonous,
"has_reborn": m.has_reborn, "battlecry": m.battlecry,
"deathrattle": m.deathrattle, "passive": m.passive,
"synergies": m.synergies, "keywords": m.keywords, "patch_added": m.patch_added}
for m in minions
]
class MinionIn(BaseModel):
card_id: str
name: str
tier: str = "1"
race: list = []
attack: int = 0
health: int = 0
tavern_cost: int = 3
has_divine: bool = False
has_taunt: bool = False
has_windfury: bool = False
has_poisonous: bool = False
has_reborn: bool = False
battlecry: str = ""
deathrattle: str = ""
on_attack: str = ""
passive: str = ""
synergies: list = []
keywords: list = []
patch_added: str = ""
@router.post("/minions", status_code=201)
async def create_minion(data: MinionIn, db: AsyncSession = Depends(get_db)):
minion = Minion(**data.model_dump())
db.add(minion)
await db.flush()
return {"id": minion.id, "name": minion.name}
@router.put("/minions/{minion_id}")
async def update_minion(minion_id: int, data: MinionIn, db: AsyncSession = Depends(get_db)):
minion = await db.get(Minion, minion_id)
if not minion:
raise HTTPException(404, "Serviteur introuvable")
for k, v in data.model_dump().items():
setattr(minion, k, v)
return {"id": minion.id, "name": minion.name}
# ─── Sorts ────────────────────────────────────────────────────────────────────
@router.get("/spells")
async def list_spells(db: AsyncSession = Depends(get_db)):
r = await db.execute(select(Spell).where(Spell.is_active == True))
return [
{"id": s.id, "card_id": s.card_id, "name": s.name, "tier": s.tier,
"cost": s.cost, "effect": s.effect, "target": s.target}
for s in r.scalars().all()
]

View File

@@ -0,0 +1,70 @@
"""Routes API — Gestion des sessions de jeu."""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from backend.database.db import get_db
from backend.database.models import GameSession
router = APIRouter()
@router.post("/start")
async def start_game(hero_id: str = "", player_count: int = 8,
db: AsyncSession = Depends(get_db)):
"""Démarre une nouvelle session de jeu."""
s = GameSession(is_active=True, session_meta={"player_count": player_count, "hero_id": hero_id})
db.add(s)
await db.flush()
return {"session_id": s.id, "started_at": s.started_at.isoformat()}
@router.post("/{session_id}/end")
async def end_game(session_id: int, final_place: int = 4,
db: AsyncSession = Depends(get_db)):
"""Termine une session avec la place finale."""
s = await db.get(GameSession, session_id)
if not s:
raise HTTPException(404, "Session introuvable")
s.is_active = False
s.ended_at = datetime.utcnow()
s.final_place = final_place
return {"status": "ended", "session_id": session_id, "final_place": final_place}
@router.get("/active")
async def get_active(db: AsyncSession = Depends(get_db)):
"""Retourne la session active ou {'active': false}."""
r = await db.execute(select(GameSession).where(GameSession.is_active == True))
s = r.scalar_one_or_none()
if not s:
return {"active": False}
return {
"active": True,
"session_id": s.id,
"started_at": s.started_at.isoformat(),
"total_turns": s.total_turns,
}
@router.get("/history")
async def get_history(limit: int = 10, db: AsyncSession = Depends(get_db)):
"""Historique des parties terminées."""
from sqlalchemy import desc
r = await db.execute(
select(GameSession)
.where(GameSession.is_active == False)
.order_by(desc(GameSession.ended_at))
.limit(limit)
)
sessions = r.scalars().all()
return [
{
"id": s.id,
"started_at": s.started_at.isoformat(),
"ended_at": s.ended_at.isoformat() if s.ended_at else None,
"final_place": s.final_place,
"total_turns": s.total_turns,
}
for s in sessions
]

View File

@@ -0,0 +1,92 @@
"""Routes API — Mode Apprentissage."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from typing import Literal
from backend.database.db import get_db
from backend.database.models import AIDecision, LearningFeedback
router = APIRouter()
_processor = None
def _get_processor():
global _processor
if not _processor:
from backend.config.settings import get_settings
from backend.ai.learning.feedback_processor import FeedbackProcessor
_processor = FeedbackProcessor(get_settings())
return _processor
class FeedbackIn(BaseModel):
decision_id: int
rating: Literal["good", "bad", "neutral"]
better_action: dict | None = None
comment: str | None = None
@router.post("/feedback")
async def submit_feedback(req: FeedbackIn, db: AsyncSession = Depends(get_db)):
"""Soumet un retour utilisateur sur une décision IA."""
proc = _get_processor()
try:
fb = await proc.record_feedback(
db,
decision_id=req.decision_id,
rating=req.rating,
better_action=req.better_action,
comment=req.comment,
)
return {
"feedback_id": fb.id,
"rating": fb.rating,
"message": "Feedback enregistré — merci pour votre contribution!",
}
except ValueError as e:
raise HTTPException(404, str(e))
@router.get("/stats")
async def get_stats(db: AsyncSession = Depends(get_db)):
"""Statistiques globales du système d'apprentissage."""
return await _get_processor().get_stats(db)
@router.get("/decisions")
async def get_decisions(
session_id: int | None = None,
limit: int = 20,
db: AsyncSession = Depends(get_db),
):
"""Historique des décisions IA avec leurs feedbacks."""
q = select(AIDecision).order_by(desc(AIDecision.created_at)).limit(limit)
if session_id:
q = q.where(AIDecision.session_id == session_id)
result = await db.execute(q)
decisions = result.scalars().all()
return [
{
"id": d.id,
"session_id": d.session_id,
"turn": d.turn,
"phase": d.phase,
"recommendation": d.recommendation,
"reasoning": d.reasoning,
"confidence": d.confidence,
"outcome_rating": d.outcome_rating,
"user_feedback": d.user_feedback,
"model_used": d.model_used,
"processing_ms": d.processing_ms,
"created_at": d.created_at.isoformat(),
}
for d in decisions
]
@router.post("/flush")
async def flush_buffer():
"""Force l'export du buffer d'apprentissage."""
await _get_processor().force_flush()
return {"status": "flushed"}

View File

@@ -0,0 +1,25 @@
"""Routes API — Paramètres."""
from fastapi import APIRouter
from backend.config.settings import get_settings
router = APIRouter()
@router.get("/")
async def get_config():
"""Retourne la configuration active (lecture seule)."""
s = get_settings()
return {
"llm_provider": s.llm_provider,
"llm_model": s.llm_model,
"llm_base_url": s.llm_base_url,
"llm_temperature": s.llm_temperature,
"llm_max_tokens": s.llm_max_tokens,
"vision_enabled": s.vision_enabled,
"screenshot_interval": s.screenshot_interval,
"learning_enabled": s.learning_enabled,
"learning_rate": s.learning_rate,
"learning_batch_size": s.learning_batch_size,
"debug": s.debug,
"current_patch": s.current_patch,
}

View File

@@ -0,0 +1,76 @@
"""Routes WebSocket — Mises à jour en temps réel."""
import asyncio
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Request
import structlog
router = APIRouter()
log = structlog.get_logger()
_clients: list[WebSocket] = []
@router.websocket("/game")
async def ws_game(websocket: WebSocket, request: Request):
"""WebSocket principal pour conseils en temps réel."""
await websocket.accept()
_clients.append(websocket)
log.info("ws.client_connected", total=len(_clients))
try:
while True:
raw = await websocket.receive_text()
msg = json.loads(raw)
msg_type = msg.get("type")
if msg_type == "state_update":
# L'état du jeu a changé → calculer un conseil
ai = request.app.state.ai_service
if ai:
advice = await ai.get_advice(msg.get("state", {}))
await websocket.send_json({
"type": "advice",
"data": {
"action": advice.main_decision.action,
"reasoning": advice.main_decision.reasoning,
"confidence": advice.main_decision.confidence,
"warnings": advice.main_decision.warnings,
"board_analysis": advice.board_analysis,
"model": advice.model_used,
"ms": advice.processing_ms,
}
})
elif msg_type == "ping":
await websocket.send_json({"type": "pong", "ts": asyncio.get_event_loop().time()})
elif msg_type == "screenshot_request":
# Demande de capture d'écran
vis = request.app.state.vision_service
if vis:
state = await vis.capture_now()
await websocket.send_json({
"type": "screenshot_result",
"state": state,
"screenshot": vis.get_screenshot_b64(),
})
except WebSocketDisconnect:
_clients.remove(websocket)
log.info("ws.client_disconnected", total=len(_clients))
except Exception as e:
log.error("ws.error", error=str(e))
if websocket in _clients:
_clients.remove(websocket)
async def broadcast(message: dict):
"""Diffuse un message à tous les clients connectés."""
disconnected = []
for client in _clients:
try:
await client.send_json(message)
except Exception:
disconnected.append(client)
for c in disconnected:
_clients.remove(c)

View File

View File

@@ -0,0 +1,67 @@
"""Configuration centralisée via Pydantic Settings."""
from functools import lru_cache
from pydantic_settings import BaseSettings
from pydantic import field_validator
from typing import List
class Settings(BaseSettings):
# Serveur
backend_host: str = "127.0.0.1"
backend_port: int = 8000
debug: bool = True
secret_key: str = "changeme"
# Base de données
database_url: str = "sqlite:///./data/hsbg_ai.db"
# LLM
llm_provider: str = "ollama"
llm_base_url: str = "http://localhost:11434"
llm_model: str = "llama3.2"
llm_fallback_model: str = "mistral"
llm_timeout: int = 30
llm_max_tokens: int = 2048
llm_temperature: float = 0.1
# Vision
vision_enabled: bool = True
screenshot_interval: float = 2.0
tesseract_path: str = "/usr/bin/tesseract"
# Apprentissage
learning_enabled: bool = True
learning_rate: float = 0.001
learning_batch_size: int = 32
learning_auto_save: bool = True
learning_save_interval: int = 300
# Frontend
frontend_host: str = "127.0.0.1"
frontend_port: int = 3000
cors_origins: List[str] = ["http://localhost:3000", "http://127.0.0.1:3000"]
# Logs
log_level: str = "INFO"
log_file: str = "./logs/hsbg_ai.log"
# Data
hsbg_data_path: str = "./data/hsbg"
current_patch: str = "30.2"
@field_validator("cors_origins", mode="before")
@classmethod
def parse_cors(cls, v):
if isinstance(v, str):
return [x.strip() for x in v.split(",")]
return v
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = False
@lru_cache()
def get_settings() -> Settings:
return Settings()

View File

View File

@@ -0,0 +1,33 @@
"""Connexion et sessions de base de données async."""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from backend.config.settings import get_settings
from backend.database.models import Base
settings = get_settings()
# Convertir l'URL SQLite en version async
DB_URL = settings.database_url
if DB_URL.startswith("sqlite:///"):
DB_URL = DB_URL.replace("sqlite:///", "sqlite+aiosqlite:///")
engine = create_async_engine(DB_URL, echo=settings.debug, pool_pre_ping=True)
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def init_db():
"""Crée toutes les tables si elles n'existent pas."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def get_db():
"""Dépendance FastAPI - fournit une session DB."""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

View File

@@ -0,0 +1,128 @@
"""Modèles SQLAlchemy - Base de données HSBG AI."""
from datetime import datetime
from sqlalchemy import (
Column, Integer, String, Float, Boolean, Text,
DateTime, JSON, ForeignKey
)
from sqlalchemy.orm import DeclarativeBase, relationship
class Base(DeclarativeBase):
pass
class Hero(Base):
__tablename__ = "heroes"
id = Column(Integer, primary_key=True, index=True)
card_id = Column(String(64), unique=True, index=True, nullable=False)
name = Column(String(128), nullable=False)
hero_power = Column(Text, default="")
hp_cost = Column(Integer, default=0)
hp_cooldown = Column(Integer, default=0)
description = Column(Text, default="")
strengths = Column(JSON, default=list)
weaknesses = Column(JSON, default=list)
synergies = Column(JSON, default=list)
tier_rating = Column(Float, default=5.0)
patch_added = Column(String(16), default="")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Minion(Base):
__tablename__ = "minions"
id = Column(Integer, primary_key=True, index=True)
card_id = Column(String(64), unique=True, index=True, nullable=False)
name = Column(String(128), nullable=False)
tier = Column(String(2), nullable=False, default="1")
race = Column(JSON, default=list)
attack = Column(Integer, default=0)
health = Column(Integer, default=0)
tavern_cost = Column(Integer, default=3)
is_golden = Column(Boolean, default=False)
has_divine = Column(Boolean, default=False)
has_taunt = Column(Boolean, default=False)
has_windfury = Column(Boolean, default=False)
has_poisonous = Column(Boolean, default=False)
has_reborn = Column(Boolean, default=False)
battlecry = Column(Text, default="")
deathrattle = Column(Text, default="")
on_attack = Column(Text, default="")
passive = Column(Text, default="")
synergies = Column(JSON, default=list)
keywords = Column(JSON, default=list)
patch_added = Column(String(16), default="")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Spell(Base):
__tablename__ = "spells"
id = Column(Integer, primary_key=True, index=True)
card_id = Column(String(64), unique=True, index=True, nullable=False)
name = Column(String(128), nullable=False)
tier = Column(String(2), default="1")
cost = Column(Integer, default=0)
effect = Column(Text, default="")
target = Column(String(64), default="minion")
keywords = Column(JSON, default=list)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
class GameSession(Base):
__tablename__ = "game_sessions"
id = Column(Integer, primary_key=True, index=True)
started_at = Column(DateTime, default=datetime.utcnow)
ended_at = Column(DateTime, nullable=True)
hero_id = Column(Integer, ForeignKey("heroes.id"), nullable=True)
final_place = Column(Integer, nullable=True)
total_turns = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
session_meta = Column(JSON, default=dict)
hero = relationship("Hero")
decisions = relationship("AIDecision", back_populates="session")
class AIDecision(Base):
__tablename__ = "ai_decisions"
id = Column(Integer, primary_key=True, index=True)
session_id = Column(Integer, ForeignKey("game_sessions.id"), nullable=False)
turn = Column(Integer, default=0)
phase = Column(String(32), default="recruit")
game_state = Column(JSON, default=dict)
recommendation = Column(JSON, default=dict)
reasoning = Column(Text, default="")
confidence = Column(Float, default=0.5)
was_followed = Column(Boolean, nullable=True)
outcome_rating = Column(Integer, nullable=True)
user_feedback = Column(Text, nullable=True)
better_decision = Column(JSON, nullable=True)
model_used = Column(String(64), default="heuristic")
processing_ms = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
session = relationship("GameSession", back_populates="decisions")
class LearningFeedback(Base):
__tablename__ = "learning_feedback"
id = Column(Integer, primary_key=True, index=True)
decision_id = Column(Integer, ForeignKey("ai_decisions.id"), nullable=False)
rating = Column(String(8), default="neutral")
better_action = Column(JSON, nullable=True)
comment = Column(Text, nullable=True)
trained = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
decision = relationship("AIDecision")
class Patch(Base):
__tablename__ = "patches"
id = Column(Integer, primary_key=True, index=True)
version = Column(String(16), unique=True, nullable=False)
release_date = Column(DateTime)
changes = Column(JSON, default=dict)
notes = Column(Text, default="")
created_at = Column(DateTime, default=datetime.utcnow)

View File

@@ -0,0 +1,225 @@
"""
Peuplement initial de la base de données HSBG.
Usage: python -m backend.database.seeds.seed_data
"""
import asyncio
import sys
import os
# Ajouter le répertoire racine au path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")))
from backend.database.db import AsyncSessionLocal, init_db
from backend.database.models import Hero, Minion, Spell
# ─── Données Héros ────────────────────────────────────────────────────────────
HEROES = [
{
"card_id": "HERO_ragnaros", "name": "Ragnaros l'Illuminé", "tier_rating": 7.0,
"hero_power": "Niveau de lancement: Donne +3 ATT à un serviteur ami aléatoire.",
"hp_cost": 2, "hp_cooldown": 0,
"description": "Excellent en mid/late game agressif. Meilleur avec des boards denses.",
"strengths": ["combat", "aggressive_boards", "mid_game"],
"weaknesses": ["divine_shield_heavy", "defensive_boards"],
"synergies": ["beast", "demon", "all"], "patch_added": "15.0",
},
{
"card_id": "HERO_millificent", "name": "Millificent Manastorm", "tier_rating": 6.5,
"hero_power": "Passif: La taverne propose toujours au moins un Mécanisme.",
"hp_cost": 0, "hp_cooldown": 0,
"description": "Setup méca rapide et consistant. Idéal pour les synergies méca.",
"strengths": ["mech_setup", "consistency", "divine_shield_generation"],
"weaknesses": ["slow_early", "no_mechs_in_rotation"],
"synergies": ["mech"], "patch_added": "15.0",
},
{
"card_id": "HERO_finley", "name": "Sire Finley de Mrglton", "tier_rating": 7.5,
"hero_power": "Avance vos serviteurs de la taverne. Coût: 2.",
"hp_cost": 2, "hp_cooldown": 0,
"description": "Très polyvalent, s'adapte à toutes les compositions. Top pick en général.",
"strengths": ["flexible", "tempo", "any_comp"],
"weaknesses": ["gold_hungry"],
"synergies": ["all"], "patch_added": "15.0",
},
{
"card_id": "HERO_mukla", "name": "Roi Mukla", "tier_rating": 4.0,
"hero_power": "Passif: Gagnez 1 or de plus par tour (mais donne des Bananes aux adversaires).",
"hp_cost": 0, "hp_cooldown": 0,
"description": "Avantage économique constant mais renforce les adversaires.",
"strengths": ["economy", "fast_tier", "late_game_scaling"],
"weaknesses": ["banana_enemies", "weak_early_game"],
"synergies": ["economy", "late_game"], "patch_added": "16.0",
},
{
"card_id": "HERO_deathwing", "name": "Deathwing", "tier_rating": 5.5,
"hero_power": "Passif: Tous vos serviteurs gagnent +3 ATT.",
"hp_cost": 0, "hp_cooldown": 0,
"description": "Buff d'attaque passif. Excellent pour les compositions agressives early.",
"strengths": ["early_aggression", "tempo", "all_minions"],
"weaknesses": ["late_game_scaling", "no_defensive_option"],
"synergies": ["all"], "patch_added": "15.0",
},
{
"card_id": "HERO_jaraxxus", "name": "Lord Jaraxxus", "tier_rating": 6.0,
"hero_power": "Octroie +1/+1 à un Démon ami aléatoire. Coût: 1.",
"hp_cost": 1, "hp_cooldown": 0,
"description": "Scale très bien avec une composition démon.",
"strengths": ["demon_comp", "consistent_scaling"],
"weaknesses": ["requires_demons", "slow_if_no_demons"],
"synergies": ["demon"], "patch_added": "15.0",
},
{
"card_id": "HERO_patchwork", "name": "Patchwork", "tier_rating": 6.0,
"hero_power": "Passif: Votre premier serviteur vendu chaque tour donne +1/+1 à un serviteur ami aléatoire.",
"hp_cost": 0, "hp_cooldown": 0,
"description": "Excellent pour les compositions avec beaucoup de ventes.",
"strengths": ["sell_synergy", "flexible", "economy"],
"weaknesses": ["requires_selling_strategy"],
"synergies": ["all"], "patch_added": "17.0",
},
]
# ─── Données Serviteurs ───────────────────────────────────────────────────────
MINIONS = [
# ── Tier 1 ──
{"card_id": "BGS_t1_01", "name": "Gardien de la Sécurité", "tier": "1",
"race": ["mech"], "attack": 2, "health": 3, "tavern_cost": 3,
"has_divine": True, "deathrattle": "Donne +3 ATT à un méca ami aléatoire.",
"synergies": ["mech"], "keywords": ["divine_shield", "deathrattle"], "patch_added": "16.0"},
{"card_id": "BGS_t1_02", "name": "Hyène Affamée", "tier": "1",
"race": ["beast"], "attack": 1, "health": 1, "tavern_cost": 3,
"passive": "Quand un Murloc ami meurt: gagne +2 ATT.",
"synergies": ["beast", "murloc"], "keywords": ["passive"], "patch_added": "15.0"},
{"card_id": "BGS_t1_03", "name": "Murloc Tidecaller", "tier": "1",
"race": ["murloc"], "attack": 1, "health": 2, "tavern_cost": 3,
"passive": "Gagne +1 ATT chaque fois qu'un Murloc est invoqué.",
"synergies": ["murloc"], "keywords": ["passive"], "patch_added": "15.0"},
{"card_id": "BGS_t1_04", "name": "Drone Déchiqueté", "tier": "1",
"race": ["mech"], "attack": 1, "health": 1, "tavern_cost": 3,
"deathrattle": "Invoque deux 1/1 Drones.",
"synergies": ["mech", "token"], "keywords": ["deathrattle"], "patch_added": "15.0"},
# ── Tier 2 ──
{"card_id": "BGS_t2_01", "name": "Dragueur de Taverne", "tier": "2",
"race": ["none"], "attack": 2, "health": 4, "tavern_cost": 3,
"battlecry": "Donne +2/+2 à un serviteur ami aléatoire.",
"synergies": [], "keywords": ["battlecry"], "patch_added": "15.0"},
{"card_id": "BGS_t2_02", "name": "Attaquant du Fouet", "tier": "2",
"race": ["murloc"], "attack": 2, "health": 1, "tavern_cost": 3,
"has_poisonous": True, "battlecry": "Donne Venimeux à un Murloc ami aléatoire.",
"synergies": ["murloc"], "keywords": ["poisonous", "battlecry"], "patch_added": "15.0"},
{"card_id": "BGS_t2_03", "name": "Paladin Défenseur", "tier": "2",
"race": ["none"], "attack": 2, "health": 2, "tavern_cost": 3,
"has_divine": True, "has_taunt": True,
"synergies": [], "keywords": ["divine_shield", "taunt"], "patch_added": "15.0"},
# ── Tier 3 ──
{"card_id": "BGS_t3_01", "name": "Infernal de Sang", "tier": "3",
"race": ["demon"], "attack": 3, "health": 4, "tavern_cost": 4,
"has_taunt": True, "deathrattle": "Invoque un 3/3 Infernal.",
"synergies": ["demon"], "keywords": ["taunt", "deathrattle"], "patch_added": "15.0"},
{"card_id": "BGS_t3_02", "name": "Vif-Argent Libre", "tier": "3",
"race": ["mech"], "attack": 3, "health": 4, "tavern_cost": 4,
"has_divine": True,
"passive": "Quand un méca ami gagne un Bouclier Divin: gagne +2 ATT.",
"synergies": ["mech"], "keywords": ["divine_shield", "passive"], "patch_added": "16.0"},
{"card_id": "BGS_t3_03", "name": "Infesteur de Crève-Mort", "tier": "3",
"race": ["undead"], "attack": 3, "health": 3, "tavern_cost": 4,
"deathrattle": "Infeste un serviteur ami aléatoire avec +3/+3.",
"synergies": ["undead"], "keywords": ["deathrattle"], "patch_added": "22.0"},
{"card_id": "BGS_t3_04", "name": "Défenseur de Sombrebourg", "tier": "3",
"race": ["none"], "attack": 2, "health": 5, "tavern_cost": 4,
"has_taunt": True, "passive": "Chaque fois qu'un serviteur ami gagne du Bouclier Divin: gagne +2/+2.",
"synergies": ["divine_shield"], "keywords": ["taunt", "passive"], "patch_added": "16.0"},
# ── Tier 4 ──
{"card_id": "BGS_t4_01", "name": "Brann Bronzebeard", "tier": "4",
"race": ["none"], "attack": 2, "health": 4, "tavern_cost": 5,
"passive": "Vos cris de guerre se déclenchent deux fois.",
"synergies": ["battlecry"], "keywords": ["passive"], "patch_added": "17.0"},
{"card_id": "BGS_t4_02", "name": "Chevauche-Tempête", "tier": "4",
"race": ["dragon"], "attack": 5, "health": 4, "tavern_cost": 5,
"battlecry": "Donne +3 ATT à vos dragons amis.",
"synergies": ["dragon"], "keywords": ["battlecry"], "patch_added": "17.0"},
{"card_id": "BGS_t4_03", "name": "Paladin Gardien", "tier": "4",
"race": ["none"], "attack": 3, "health": 3, "tavern_cost": 5,
"has_divine": True, "has_taunt": True,
"passive": "Au début de votre tour: donne Bouclier Divin à un serviteur ami aléatoire.",
"synergies": ["divine_shield"], "keywords": ["divine_shield", "taunt", "passive"], "patch_added": "17.0"},
# ── Tier 5 ──
{"card_id": "BGS_t5_01", "name": "Kangaro Boxeur", "tier": "5",
"race": ["beast"], "attack": 5, "health": 5, "tavern_cost": 6,
"battlecry": "Gagne +1 ATT par serviteur ami sur le board.",
"synergies": ["beast"], "keywords": ["battlecry"], "patch_added": "18.0"},
{"card_id": "BGS_t5_02", "name": "Murozond", "tier": "5",
"race": ["dragon"], "attack": 5, "health": 5, "tavern_cost": 6,
"battlecry": "Gagne les capacités de vos autres types de races sur le board.",
"synergies": ["dragon", "all"], "keywords": ["battlecry"], "patch_added": "19.0"},
# ── Tier 6 ──
{"card_id": "BGS_t6_01", "name": "Amalgame de l'Égout", "tier": "6",
"race": ["all"], "attack": 6, "health": 4, "tavern_cost": 7,
"passive": "Hérite des capacités de toutes les races de vos serviteurs amis.",
"synergies": ["all"], "keywords": ["passive"], "patch_added": "17.0"},
{"card_id": "BGS_t6_02", "name": "Zapp Brannigan", "tier": "6",
"race": ["mech"], "attack": 7, "health": 10, "tavern_cost": 7,
"passive": "Attaque toujours le serviteur ennemi avec l'ATT la plus faible.",
"synergies": ["mech"], "keywords": ["passive"], "patch_added": "15.0"},
]
# ─── Données Sorts ────────────────────────────────────────────────────────────
SPELLS = [
{"card_id": "SP_01", "name": "Soif de Sang", "tier": "1", "cost": 0,
"effect": "Donne +3/+1 à un serviteur ami.", "target": "minion", "keywords": ["buff"]},
{"card_id": "SP_02", "name": "Bière Frelatée", "tier": "1", "cost": 0,
"effect": "Donne +1/+1 à tous vos serviteurs.", "target": "board", "keywords": ["aoe_buff"]},
{"card_id": "SP_03", "name": "Armure Réactive", "tier": "2", "cost": 1,
"effect": "Votre héros gagne +4 armure.", "target": "hero", "keywords": ["armor"]},
{"card_id": "SP_04", "name": "Maître en Cris de Guerre", "tier": "3", "cost": 2,
"effect": "Déclenche le cri de guerre d'un serviteur ami.", "target": "minion", "keywords": ["battlecry"]},
{"card_id": "SP_05", "name": "Huile de Pierre Sacrée", "tier": "4", "cost": 2,
"effect": "Donne Bouclier Divin à tous vos serviteurs.", "target": "board", "keywords": ["divine_shield"]},
]
async def seed():
print("\n🌱 Peuplement de la base de données HSBG...")
await init_db()
async with AsyncSessionLocal() as db:
# Héros
for h in HEROES:
db.add(Hero(**h))
await db.commit()
print(f"{len(HEROES)} héros ajoutés")
# Serviteurs
for m in MINIONS:
db.add(Minion(**m))
await db.commit()
print(f"{len(MINIONS)} serviteurs ajoutés")
# Sorts
for s in SPELLS:
db.add(Spell(**s))
await db.commit()
print(f"{len(SPELLS)} sorts ajoutés")
print("\n✅ Base de données HSBG prête!\n")
if __name__ == "__main__":
asyncio.run(seed())

109
hsbg_ai/backend/main.py Normal file
View File

@@ -0,0 +1,109 @@
"""HSBG AI Assistant — Application FastAPI principale."""
from contextlib import asynccontextmanager
from pathlib import Path
import structlog
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from backend.api.routes.advice import router as advice_router
from backend.api.routes.database_routes import router as db_router
from backend.api.routes.game import router as game_router
from backend.api.routes.learning import router as learning_router
from backend.api.routes.settings_routes import router as settings_router
from backend.api.routes.websocket_routes import router as ws_router
from backend.config.settings import get_settings
from backend.database.db import init_db
from backend.services.ai_service import AIService
from backend.services.vision_service import VisionService
log = structlog.get_logger()
cfg = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Cycle de vie: démarrage → running → arrêt."""
log.info("hsbg_ai.starting", version="1.0.0")
# Initialiser la base de données
await init_db()
log.info("database.ready")
# Initialiser le service IA
ai = AIService(cfg)
await ai.initialize()
app.state.ai_service = ai
# Initialiser le service Vision
vis = VisionService(cfg)
if cfg.vision_enabled:
await vis.start()
app.state.vision_service = vis
log.info("hsbg_ai.ready", port=cfg.backend_port, llm=cfg.llm_model)
yield # Application en cours d'exécution
# Arrêt propre
log.info("hsbg_ai.stopping")
await vis.stop()
await ai.shutdown()
log.info("hsbg_ai.stopped")
def create_app() -> FastAPI:
app = FastAPI(
title="HSBG AI Assistant",
description="IA d'assistance temps réel pour Hearthstone Battlegrounds",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
)
# CORS pour le frontend React
app.add_middleware(
CORSMiddleware,
allow_origins=cfg.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routes API REST
app.include_router(game_router, prefix="/api/game", tags=["Partie"])
app.include_router(advice_router, prefix="/api/advice", tags=["Conseils IA"])
app.include_router(learning_router, prefix="/api/learning", tags=["Apprentissage"])
app.include_router(db_router, prefix="/api/database", tags=["Base HSBG"])
app.include_router(settings_router, prefix="/api/settings", tags=["Paramètres"])
app.include_router(ws_router, prefix="/ws", tags=["WebSocket"])
# Servir le frontend buildé si disponible
frontend_dist = Path("frontend/dist")
if frontend_dist.exists():
app.mount("/", StaticFiles(directory=str(frontend_dist), html=True), name="static")
@app.get("/health", tags=["Système"])
async def health():
return {
"status": "ok",
"version": "1.0.0",
"llm_model": cfg.llm_model,
"patch": cfg.current_patch,
}
return app
app = create_app()
if __name__ == "__main__":
uvicorn.run(
"backend.main:app",
host=cfg.backend_host,
port=cfg.backend_port,
reload=cfg.debug,
log_config=None,
)

View File

View File

@@ -0,0 +1,24 @@
"""Service IA — façade pour le moteur de décision."""
from backend.ai.engine.decision_engine import DecisionEngine, FullAdvice
from backend.ai.learning.feedback_processor import FeedbackProcessor
import structlog
log = structlog.get_logger()
class AIService:
def __init__(self, settings):
self.settings = settings
self.engine = DecisionEngine(settings)
self.feedback = FeedbackProcessor(settings)
async def initialize(self):
await self.engine.initialize()
log.info("ai_service.ready")
async def get_advice(self, state: dict) -> FullAdvice:
return await self.engine.get_advice(state)
async def shutdown(self):
await self.engine.shutdown()
log.info("ai_service.stopped")

View File

@@ -0,0 +1,26 @@
"""Service Vision — façade pour le gestionnaire de captures d'écran."""
from backend.vision.screenshot_manager import ScreenshotManager
import structlog
log = structlog.get_logger()
class VisionService:
def __init__(self, settings):
self.settings = settings
self._mgr = ScreenshotManager(settings)
async def start(self):
await self._mgr.start()
async def stop(self):
await self._mgr.stop()
def get_current_state(self) -> dict:
return self._mgr.get_state()
def get_screenshot_b64(self) -> str | None:
return self._mgr.get_b64()
async def capture_now(self) -> dict:
return await self._mgr.capture_now()

View File

View File

View File

@@ -0,0 +1,195 @@
"""Capture d'écran continue + extraction OCR de l'état HSBG."""
import asyncio
import base64
import io
import re
import structlog
log = structlog.get_logger()
try:
import mss
import mss.tools
MSS_OK = True
except ImportError:
MSS_OK = False
log.warning("vision.mss_missing", install="pip install mss")
try:
from PIL import Image, ImageEnhance, ImageFilter
PIL_OK = True
except ImportError:
PIL_OK = False
try:
import pytesseract
OCR_OK = True
except ImportError:
OCR_OK = False
log.warning("vision.tesseract_missing",
install="sudo apt-get install tesseract-ocr && pip install pytesseract")
class ScreenshotManager:
"""
Capture et analyse les frames du jeu HSBG.
Zones de capture (coordonnées relatives 0.0-1.0):
- gold: haut gauche (or disponible)
- tier: haut droite (niveau de taverne)
- hero_hp: centre haut (HP du héros)
- board: centre (board du joueur)
- tavern: bas (serviteurs en taverne)
"""
ZONES = {
"gold": (0.02, 0.02, 0.12, 0.10),
"tier": (0.85, 0.02, 0.98, 0.14),
"hero_hp": (0.44, 0.01, 0.56, 0.09),
"board": (0.10, 0.38, 0.90, 0.72),
"tavern": (0.02, 0.72, 0.98, 0.98),
}
def __init__(self, settings):
self.settings = settings
self._running = False
self._latest_bytes: bytes | None = None
self._latest_state: dict = {}
self._task: asyncio.Task | None = None
if OCR_OK and settings.tesseract_path:
pytesseract.pytesseract.tesseract_cmd = settings.tesseract_path
async def start(self):
"""Démarre la boucle de capture en arrière-plan."""
if not MSS_OK:
log.warning("vision.skipped", reason="mss non installé")
return
self._running = True
self._task = asyncio.create_task(self._loop())
log.info("vision.capture_started", interval=self.settings.screenshot_interval)
async def stop(self):
"""Arrête proprement la boucle de capture."""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
log.info("vision.capture_stopped")
async def _loop(self):
"""Boucle principale de capture."""
while self._running:
try:
await self._tick()
await asyncio.sleep(self.settings.screenshot_interval)
except asyncio.CancelledError:
break
except Exception as e:
log.warning("vision.loop_error", error=str(e))
await asyncio.sleep(2)
async def _tick(self):
"""Une itération: capture + analyse."""
loop = asyncio.get_event_loop()
data = await loop.run_in_executor(None, self._grab)
if data:
self._latest_bytes = data
state = await loop.run_in_executor(None, self._extract, data)
if state:
self._latest_state = state
def _grab(self) -> bytes | None:
"""Prend une capture d'écran (exécuté dans un thread)."""
if not MSS_OK:
return None
try:
with mss.mss() as sct:
monitor = sct.monitors[1] # Écran principal
frame = sct.grab(monitor)
return mss.tools.to_png(frame.rgb, frame.size)
except Exception as e:
log.warning("screenshot.grab_failed", error=str(e))
return None
def _extract(self, data: bytes) -> dict:
"""Extrait les valeurs numériques via OCR (exécuté dans un thread)."""
if not PIL_OK or not OCR_OK:
return {}
try:
img = Image.open(io.BytesIO(data))
state = {}
for zone_name, coords in self.ZONES.items():
crop = self._crop(img, coords)
if crop:
text = self._ocr(crop)
if text:
state[f"raw_{zone_name}"] = text
# Parser les valeurs numériques
state["gold"] = self._parse_num(state.get("raw_gold", ""))
state["tavern_tier"] = self._parse_num(state.get("raw_tier", ""))
state["hero_hp"] = self._parse_num(state.get("raw_hero_hp", ""))
return state
except Exception as e:
log.warning("vision.extract_failed", error=str(e))
return {}
def _crop(self, img, coords: tuple):
"""Découpe une zone de l'image."""
if not PIL_OK:
return None
try:
w, h = img.size
x1, y1, x2, y2 = coords
return img.crop((int(x1 * w), int(y1 * h), int(x2 * w), int(y2 * h)))
except Exception:
return None
def _ocr(self, img) -> str:
"""OCR optimisé pour HSBG (chiffres + lettres)."""
if not OCR_OK or not PIL_OK:
return ""
try:
# Pipeline de prétraitement pour améliorer l'OCR
enhanced = ImageEnhance.Contrast(img).enhance(2.5)
enhanced = enhanced.convert("L") # Grayscale
enhanced = enhanced.filter(ImageFilter.SHARPEN)
# Agrandir pour meilleure précision
w, h = enhanced.size
enhanced = enhanced.resize((w * 2, h * 2), Image.LANCZOS)
text = pytesseract.image_to_string(
enhanced,
config="--psm 7 --oem 3 -c tessedit_char_whitelist=0123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ",
)
return text.strip()
except Exception:
return ""
def _parse_num(self, text: str) -> int:
"""Extrait un nombre d'un texte OCR."""
nums = re.findall(r"\d+", text or "")
return int(nums[0]) if nums else 0
# ─── API publique ─────────────────────────────────────────────────────────
def get_b64(self) -> str | None:
"""Dernière capture en base64 (pour affichage frontend)."""
if self._latest_bytes:
return base64.b64encode(self._latest_bytes).decode()
return None
def get_state(self) -> dict:
"""Dernier état extrait."""
return self._latest_state.copy()
async def capture_now(self) -> dict:
"""Déclenche une capture manuelle et retourne l'état."""
await self._tick()
return self.get_state()

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HSBG AI Assistant</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,26 @@
{
"name": "hsbg-ai-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"axios": "^1.7.7",
"lucide-react": "^0.447.0",
"recharts": "^2.12.7",
"clsx": "^2.1.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"vite": "^5.4.8"
}
}

View File

@@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }

View File

@@ -0,0 +1,56 @@
import { Routes, Route, NavLink } from 'react-router-dom'
import { Activity, Sword, Brain, Database, Settings } from 'lucide-react'
import DashboardPage from './pages/DashboardPage'
import GamePage from './pages/GamePage'
import LearningPage from './pages/LearningPage'
import DatabasePage from './pages/DatabasePage'
import SettingsPage from './pages/SettingsPage'
const NAV = [
{ to: '/', icon: Activity, label: 'Dashboard' },
{ to: '/game', icon: Sword, label: 'Partie' },
{ to: '/learning', icon: Brain, label: 'Apprentissage' },
{ to: '/database', icon: Database, label: 'Base HSBG' },
{ to: '/settings', icon: Settings, label: 'Paramètres' },
]
export default function App() {
return (
<div className="flex h-screen overflow-hidden">
<aside className="w-14 md:w-52 bg-gray-900/95 border-r border-yellow-900/20 flex flex-col pt-4 shrink-0">
<div className="px-3 mb-6 hidden md:block">
<p className="text-yellow-400 font-bold text-lg tracking-wide">HSBG AI</p>
<p className="text-gray-500 text-xs mt-0.5">Assistant Battlegrounds</p>
</div>
<nav className="flex flex-col gap-1 px-2 flex-1">
{NAV.map(({ to, icon: Icon, label }) => (
<NavLink
key={to} to={to} end={to === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-all
${isActive
? 'bg-yellow-900/40 text-yellow-400 font-medium'
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-800/80'}`
}
>
<Icon size={17} className="shrink-0" />
<span className="hidden md:inline">{label}</span>
</NavLink>
))}
</nav>
<div className="px-3 py-3 border-t border-gray-800 hidden md:block">
<p className="text-gray-600 text-xs">v1.0.0 · Local AI</p>
</div>
</aside>
<main className="flex-1 overflow-y-auto">
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/game" element={<GamePage />} />
<Route path="/learning" element={<LearningPage />} />
<Route path="/database" element={<DatabasePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</main>
</div>
)
}

View File

@@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-gray-950 text-gray-100;
background: radial-gradient(ellipse at top, #1c1010 0%, #080808 100%);
min-height: 100vh;
}
.card-glow { box-shadow: 0 0 20px rgba(245,179,10,0.3); }
.confidence-bar { @apply h-1.5 rounded-full transition-all; }

View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
)

View File

@@ -0,0 +1,105 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
import { Activity, Brain, Zap, Target, CheckCircle, XCircle } from 'lucide-react'
function StatCard({ icon: Icon, label, value, sub, color }) {
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 flex items-center gap-4 hover:border-gray-700 transition-colors">
<div className={`p-2.5 rounded-xl ${color}`}><Icon size={20} /></div>
<div>
<p className="text-gray-400 text-xs mb-0.5">{label}</p>
<p className="text-white font-bold text-xl leading-none">{value}</p>
{sub && <p className="text-gray-500 text-xs mt-1">{sub}</p>}
</div>
</div>
)
}
export default function DashboardPage() {
const [health, setHealth] = useState(null)
const [stats, setStats] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
axios.get('/health').then(r => setHealth(r.data)).catch(() => {}),
axios.get('/api/learning/stats').then(r => setStats(r.data)).catch(() => {}),
]).finally(() => setLoading(false))
}, [])
return (
<div className="p-6 max-w-5xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-yellow-400 mb-1">HSBG AI Assistant</h1>
<p className="text-gray-400">Intelligence artificielle locale pour Hearthstone Battlegrounds</p>
</div>
{/* Status serveur */}
<div className="mb-6 flex items-center gap-3">
{health ? (
<span className="flex items-center gap-2 bg-green-900/30 border border-green-700/50 rounded-full px-4 py-1.5 text-green-400 text-sm">
<CheckCircle size={14} />
Serveur actif · v{health.version} · Modèle: {health.llm_model}
</span>
) : (
<span className="flex items-center gap-2 bg-red-900/30 border border-red-700/50 rounded-full px-4 py-1.5 text-red-400 text-sm">
<XCircle size={14} />
Serveur hors ligne
</span>
)}
</div>
{/* Stats */}
{!loading && stats && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatCard icon={Brain} label="Feedbacks total" value={stats.total}
color="bg-purple-900/50 text-purple-400" />
<StatCard icon={Activity} label="Taux de réussite" value={`${stats.good_rate}%`}
sub={`${stats.good} bons / ${stats.bad} mauvais`} color="bg-green-900/50 text-green-400" />
<StatCard icon={Zap} label="Entraînements" value={stats.trained}
color="bg-blue-900/50 text-blue-400" />
<StatCard icon={Target} label="En attente" value={stats.buffer_pending}
sub="dans le buffer" color="bg-yellow-900/50 text-yellow-400" />
</div>
)}
{/* Guide rapide */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
<h3 className="font-semibold text-white mb-4">🚀 Démarrage rapide</h3>
<ol className="space-y-2.5 text-sm text-gray-300">
{[
['Partie', 'Démarrer une session et saisir l\'état du jeu'],
['Conseil', 'L\'IA génère des recommandations en temps réel'],
['Feedback', 'Évaluer les conseils pour entraîner l\'IA'],
['Base HSBG', 'Consulter et enrichir les données de cartes'],
['Paramètres', 'Configurer le LLM et la vision'],
].map(([page, desc], i) => (
<li key={i} className="flex gap-2">
<span className="text-yellow-400 font-bold shrink-0">{i + 1}.</span>
<span><strong className="text-yellow-300">{page}</strong> {desc}</span>
</li>
))}
</ol>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
<h3 className="font-semibold text-white mb-4">🧠 Configuration LLM</h3>
<div className="space-y-3 text-sm">
<div className="bg-gray-800 rounded-lg p-3">
<p className="text-gray-300 font-mono text-xs mb-1"># Installer Ollama</p>
<p className="text-yellow-300 font-mono text-xs">curl -fsSL https://ollama.ai/install.sh | sh</p>
</div>
<div className="bg-gray-800 rounded-lg p-3">
<p className="text-gray-300 font-mono text-xs mb-1"># Télécharger un modèle</p>
<p className="text-yellow-300 font-mono text-xs">ollama pull llama3.2</p>
</div>
<p className="text-gray-400 text-xs">
Sans LLM, l'IA fonctionne en mode heuristique (rapide, pas de raisonnement naturel).
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,142 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
import { Database, Shield, Sword, Search } from 'lucide-react'
const TIER_COLORS = {
'1': 'bg-gray-600', '2': 'bg-green-800', '3': 'bg-blue-800',
'4': 'bg-purple-800', '5': 'bg-orange-800', '6': 'bg-red-800',
}
export default function DatabasePage() {
const [tab, setTab] = useState('heroes')
const [data, setData] = useState([])
const [search, setSearch] = useState('')
const [tier, setTier] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
setLoading(true)
const params = new URLSearchParams()
if (search) params.append('search', search)
if (tier && tab === 'minions') params.append('tier', tier)
const url = `/api/database/${tab === 'heroes' ? 'heroes' : 'minions'}?${params}`
axios.get(url).then(r => setData(r.data)).catch(() => setData([])).finally(() => setLoading(false))
}, [tab, search, tier])
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Database size={24} className="text-blue-400" />
<div>
<h2 className="text-2xl font-bold text-white">Base de données HSBG</h2>
<p className="text-gray-400 text-sm">Héros, serviteurs et sorts du patch {import.meta.env?.VITE_PATCH || 'actuel'}</p>
</div>
</div>
<div className="flex gap-2 mb-4">
{[{ id: 'heroes', icon: Shield, label: 'Héros' }, { id: 'minions', icon: Sword, label: 'Serviteurs' }].map(({ id, icon: Icon, label }) => (
<button key={id} onClick={() => { setTab(id); setSearch(''); setTier('') }}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors
${tab === id ? 'bg-blue-700 text-white' : 'bg-gray-800 text-gray-300 hover:bg-gray-700'}`}>
<Icon size={15} />{label}
</button>
))}
</div>
<div className="flex gap-3 mb-5">
<div className="relative flex-1">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder={`Rechercher ${tab === 'heroes' ? 'un héros' : 'un serviteur'}...`}
className="w-full bg-gray-900 border border-gray-700 rounded-xl pl-9 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-600" />
</div>
{tab === 'minions' && (
<select value={tier} onChange={e => setTier(e.target.value)}
className="bg-gray-900 border border-gray-700 rounded-xl px-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-600">
<option value="">Tous tiers</option>
{[1,2,3,4,5,6].map(t => <option key={t} value={t}>Tier {t}</option>)}
</select>
)}
</div>
{loading ? (
<p className="text-gray-400 text-center py-10">Chargement...</p>
) : tab === 'heroes' ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{data.map(h => (
<div key={h.id} className="bg-gray-900 border border-gray-800 rounded-xl p-4 hover:border-yellow-800/40 transition-colors">
<div className="flex justify-between items-start mb-2">
<h3 className="font-bold text-yellow-400">{h.name}</h3>
<div className="flex items-center gap-1">
<span className="text-yellow-500 text-sm font-bold">{h.tier_rating}</span>
<span className="text-gray-600 text-xs">/10</span>
</div>
</div>
<p className="text-gray-300 text-xs mb-2 leading-relaxed">{h.hero_power}</p>
{h.description && <p className="text-gray-500 text-xs mb-3">{h.description}</p>}
<div className="flex flex-wrap gap-1 mb-1">
{(h.strengths || []).map(s => (
<span key={s} className="text-xs bg-green-900/40 text-green-400 border border-green-800/30 px-1.5 py-0.5 rounded">{s}</span>
))}
</div>
<div className="flex flex-wrap gap-1">
{(h.weaknesses || []).map(w => (
<span key={w} className="text-xs bg-red-900/30 text-red-400 border border-red-800/30 px-1.5 py-0.5 rounded">{w}</span>
))}
</div>
</div>
))}
</div>
) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-800 text-xs text-gray-400 font-medium">
{['Nom', 'Tier', 'Race', 'ATT', 'HP', 'Capacités', 'Description'].map(h => (
<th key={h} className="px-3 py-2.5 text-left whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{data.map(m => (
<tr key={m.id} className="hover:bg-gray-800/30 transition-colors">
<td className="px-3 py-2.5 font-semibold text-white whitespace-nowrap">{m.name}</td>
<td className="px-3 py-2.5">
<span className={`text-xs px-2 py-0.5 rounded font-medium text-white ${TIER_COLORS[m.tier] || 'bg-gray-600'}`}>
T{m.tier}
</span>
</td>
<td className="px-3 py-2.5 text-blue-400 text-xs whitespace-nowrap">
{Array.isArray(m.race) ? m.race.join(', ') : m.race}
</td>
<td className="px-3 py-2.5 text-green-400 font-bold text-center">{m.attack}</td>
<td className="px-3 py-2.5 text-red-400 font-bold text-center">{m.health}</td>
<td className="px-3 py-2.5">
<div className="flex flex-wrap gap-1">
{m.has_divine && <span className="text-xs bg-yellow-900/40 text-yellow-300 border border-yellow-800/30 px-1.5 py-0.5 rounded">Divine</span>}
{m.has_taunt && <span className="text-xs bg-gray-700 text-gray-300 px-1.5 py-0.5 rounded">Taunt</span>}
{m.has_windfury && <span className="text-xs bg-purple-900/40 text-purple-300 px-1.5 py-0.5 rounded">Windfury</span>}
{m.has_poisonous && <span className="text-xs bg-green-900/40 text-green-300 px-1.5 py-0.5 rounded">Venin</span>}
{m.has_reborn && <span className="text-xs bg-blue-900/40 text-blue-300 px-1.5 py-0.5 rounded">Reborn</span>}
{m.battlecry && <span className="text-xs bg-purple-900/30 text-purple-400 px-1.5 py-0.5 rounded">Cri</span>}
{m.deathrattle && <span className="text-xs bg-red-900/30 text-red-400 px-1.5 py-0.5 rounded">Mort</span>}
{m.passive && <span className="text-xs bg-gray-700 text-gray-400 px-1.5 py-0.5 rounded">Passif</span>}
</div>
</td>
<td className="px-3 py-2.5 text-gray-400 text-xs max-w-[200px]">
{m.battlecry || m.deathrattle || m.passive || '—'}
</td>
</tr>
))}
</tbody>
</table>
{data.length === 0 && (
<p className="text-gray-500 text-center py-8">Aucun résultat vérifiez la base de données</p>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,406 @@
import { useState, useCallback } from 'react'
import axios from 'axios'
import { Play, Square, RefreshCw, Monitor, Swords, Plus, X, ThumbsUp, ThumbsDown, Minus } from 'lucide-react'
const ACTION_MAP = {
buy: { label: 'Acheter', color: 'text-green-400' },
sell: { label: 'Vendre', color: 'text-red-400' },
freeze: { label: 'Geler', color: 'text-blue-400' },
upgrade: { label: 'Monter tier', color: 'text-yellow-400' },
reposition: { label: 'Repositionner', color: 'text-purple-400' },
hero_power: { label: 'Pouvoir héros', color: 'text-orange-400' },
wait: { label: 'Attendre', color: 'text-gray-400' },
}
function ConfBar({ value }) {
const pct = Math.round((value || 0) * 100)
const col = pct >= 70 ? 'bg-green-500' : pct >= 40 ? 'bg-yellow-500' : 'bg-red-500'
return (
<div className="mt-2">
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span>Confiance</span><span>{pct}%</span>
</div>
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${col} transition-all duration-500`} style={{ width: `${pct}%` }} />
</div>
</div>
)
}
function MinionChip({ minion, onRemove }) {
return (
<div className="relative bg-gray-800 border border-gray-600 rounded-lg p-2 text-xs group">
{onRemove && (
<button onClick={onRemove}
className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<X size={10} />
</button>
)}
<p className="font-semibold text-yellow-300 truncate max-w-[90px]">{minion.name || '?'}</p>
<p className="text-gray-300">{minion.attack || 0}/{minion.health || 0}</p>
{minion.race && (
<p className="text-blue-400 text-[10px]">
{Array.isArray(minion.race) ? minion.race.join(', ') : minion.race}
</p>
)}
</div>
)
}
function AddMinionButton({ onClick }) {
return (
<button onClick={onClick}
className="border border-dashed border-gray-600 rounded-lg p-2 text-xs text-gray-500 hover:border-yellow-600 hover:text-yellow-500 transition-colors flex flex-col items-center justify-center min-h-[64px]">
<Plus size={14} className="mb-1" />
Ajouter
</button>
)
}
function FeedbackBar({ decisionId, onDone }) {
const [sent, setSent] = useState(false)
const [better, setBetter] = useState('')
const [comment, setComment] = useState('')
const send = async (rating) => {
try {
await axios.post('/api/learning/feedback', {
decision_id: decisionId, rating,
better_action: better ? { action: better } : null,
comment: comment || null,
})
setSent(true)
onDone && onDone()
} catch { alert('Erreur envoi feedback') }
}
if (sent) return (
<div className="bg-green-900/20 border border-green-700/40 rounded-xl p-3 text-green-400 text-sm text-center">
Feedback envoyé merci!
</div>
)
return (
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4">
<p className="text-xs text-gray-400 mb-3">Ce conseil était-il bon?</p>
<div className="flex gap-2 mb-3">
<button onClick={() => send('good')} className="flex-1 flex items-center justify-center gap-1.5 bg-green-700 hover:bg-green-600 py-2 rounded-lg text-sm font-medium transition-colors">
<ThumbsUp size={13} /> Bon
</button>
<button onClick={() => send('neutral')} className="flex-1 flex items-center justify-center gap-1.5 bg-gray-700 hover:bg-gray-600 py-2 rounded-lg text-sm font-medium transition-colors">
<Minus size={13} /> Neutre
</button>
<button onClick={() => send('bad')} className="flex-1 flex items-center justify-center gap-1.5 bg-red-700 hover:bg-red-600 py-2 rounded-lg text-sm font-medium transition-colors">
<ThumbsDown size={13} /> Mauvais
</button>
</div>
<input value={better} onChange={e => setBetter(e.target.value)}
placeholder="Meilleure action? (buy/sell/freeze/upgrade/wait...)"
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-3 py-1.5 text-xs text-white mb-2 focus:outline-none focus:border-yellow-600" />
<input value={comment} onChange={e => setComment(e.target.value)}
placeholder="Commentaire (optionnel)"
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-3 py-1.5 text-xs text-white focus:outline-none focus:border-yellow-600" />
</div>
)
}
export default function GamePage() {
const [session, setSession] = useState(null)
const [advice, setAdvice] = useState(null)
const [loading, setLoading] = useState(false)
const [screenshot, setScreenshot] = useState(null)
const [state, setState] = useState({
turn: 1, tavern_tier: 1, gold: 3, hero_hp: 40,
board_minions: [], tavern_minions: [],
freeze: false, can_upgrade: true, upgrade_cost: 5,
current_placement: 5, player_count: 8, phase: 'recruit',
})
const startSession = async () => {
try {
const r = await axios.post('/api/game/start')
setSession(r.data)
setAdvice(null)
} catch { alert('Erreur démarrage session') }
}
const endSession = async () => {
const place = parseInt(prompt('Place finale (1-8)?', '4') || '4')
if (!place) return
try {
await axios.post(`/api/game/${session.session_id}/end?final_place=${place}`)
setSession(null); setAdvice(null)
} catch { alert('Erreur fin de session') }
}
const getAdvice = useCallback(async () => {
setLoading(true)
try {
const r = await axios.post('/api/advice/', { ...state, session_id: session?.session_id })
setAdvice(r.data)
} catch { alert('Service IA non disponible — vérifiez que le backend tourne sur :8000') }
finally { setLoading(false) }
}, [state, session])
const captureScreen = async () => {
setLoading(true)
try {
const r = await axios.get('/api/advice/from-screen')
setAdvice(r.data.advice)
if (r.data.screenshot) setScreenshot(r.data.screenshot)
if (r.data.extracted_state?.gold) setState(s => ({ ...s, ...r.data.extracted_state }))
} catch { alert('Capture non disponible — activez la vision dans les paramètres') }
finally { setLoading(false) }
}
const addMinion = (field) => {
const name = prompt('Nom du serviteur?')
if (!name) return
const attack = parseInt(prompt('Attaque?', '2') || '2')
const health = parseInt(prompt('Points de vie?', '2') || '2')
const race = prompt('Race? (mech/beast/murloc/demon/dragon/none)', 'none') || 'none'
setState(s => ({
...s,
[field]: [...s[field], { name, attack, health, race: [race] }]
}))
}
const removeMinion = (field, idx) => {
setState(s => ({ ...s, [field]: s[field].filter((_, i) => i !== idx) }))
}
const setField = (key, val) => setState(s => ({ ...s, [key]: val }))
const mainAction = advice?.main_decision
const actionInfo = ACTION_MAP[mainAction?.action] || { label: mainAction?.action, color: 'text-white' }
return (
<div className="p-4 lg:p-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-yellow-400">Partie en cours</h2>
<p className="text-gray-500 text-sm">
{session ? `Session #${session.session_id}` : 'Aucune session active'}
</p>
</div>
<div className="flex gap-2">
{!session ? (
<button onClick={startSession}
className="flex items-center gap-2 bg-green-600 hover:bg-green-500 px-4 py-2 rounded-xl text-sm font-semibold transition-colors">
<Play size={15} /> Démarrer
</button>
) : (
<button onClick={endSession}
className="flex items-center gap-2 bg-red-600 hover:bg-red-500 px-4 py-2 rounded-xl text-sm font-semibold transition-colors">
<Square size={15} /> Terminer
</button>
)}
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-5">
{/* Colonne gauche: état du jeu */}
<div className="xl:col-span-2 space-y-4">
{/* Stats numériques */}
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<h3 className="text-sm font-semibold text-gray-300 mb-4">État du tour</h3>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
{[
['Tour', 'turn', 1, 50],
['Tier', 'tavern_tier', 1, 6],
['Or (g)', 'gold', 0, 20],
['HP Héros', 'hero_hp', 1, 40],
['Position', 'current_placement', 1, 8],
['Coût up.', 'upgrade_cost', 0, 10],
].map(([label, key, min, max]) => (
<div key={key}>
<label className="text-xs text-gray-500 block mb-1">{label}</label>
<input
type="number" min={min} max={max}
value={state[key]}
onChange={e => setField(key, parseInt(e.target.value) || 0)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-2 py-1.5 text-sm text-white text-center focus:outline-none focus:border-yellow-600"
/>
</div>
))}
</div>
<div className="flex gap-6 mt-3 pt-3 border-t border-gray-800">
{[['freeze', '❄️ Gel actif'], ['can_upgrade', '⬆️ Peut monter']].map(([key, label]) => (
<label key={key} className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" checked={state[key]}
onChange={e => setField(key, e.target.checked)}
className="w-4 h-4 accent-yellow-500" />
{label}
</label>
))}
</div>
</div>
{/* Board */}
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<div className="flex justify-between items-center mb-3">
<h3 className="text-sm font-semibold text-gray-300">
Board <span className="text-gray-500">({state.board_minions.length}/7)</span>
</h3>
</div>
<div className="grid grid-cols-4 sm:grid-cols-7 gap-2">
{state.board_minions.map((m, i) => (
<MinionChip key={i} minion={m} onRemove={() => removeMinion('board_minions', i)} />
))}
{state.board_minions.length < 7 && (
<AddMinionButton onClick={() => addMinion('board_minions')} />
)}
</div>
</div>
{/* Taverne */}
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<div className="flex justify-between items-center mb-3">
<h3 className="text-sm font-semibold text-gray-300">
Taverne <span className="text-gray-500">({state.tavern_minions.length})</span>
</h3>
</div>
<div className="grid grid-cols-4 sm:grid-cols-7 gap-2">
{state.tavern_minions.map((m, i) => (
<MinionChip key={i} minion={m} onRemove={() => removeMinion('tavern_minions', i)} />
))}
{state.tavern_minions.length < 7 && (
<AddMinionButton onClick={() => addMinion('tavern_minions')} />
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-3">
<button onClick={getAdvice} disabled={loading}
className="flex-1 flex items-center justify-center gap-2 bg-yellow-600 hover:bg-yellow-500 disabled:opacity-50 disabled:cursor-not-allowed px-4 py-3 rounded-xl font-semibold transition-colors">
{loading
? <><RefreshCw size={16} className="animate-spin" /> Analyse...</>
: <><Swords size={16} /> Obtenir conseil</>
}
</button>
<button onClick={captureScreen} disabled={loading}
title="Capturer l'écran automatiquement"
className="flex items-center gap-2 bg-blue-700 hover:bg-blue-600 disabled:opacity-50 px-4 py-3 rounded-xl text-sm font-medium transition-colors">
<Monitor size={16} /> Capturer
</button>
</div>
</div>
{/* Colonne droite: conseils */}
<div className="space-y-4">
{advice ? (
<>
{/* Conseil principal */}
<div className="bg-gray-900 border border-yellow-800/40 rounded-xl p-5 card-glow">
<div className="flex justify-between items-center mb-1">
<span className="text-xs text-gray-400 uppercase tracking-wider">Conseil principal</span>
<span className="text-xs text-gray-600 bg-gray-800 px-2 py-0.5 rounded">{advice.model_used}</span>
</div>
<p className={`text-3xl font-bold mt-2 ${actionInfo.color}`}>
{actionInfo.label}
</p>
{mainAction?.target && (
<p className="text-yellow-300 text-sm mt-1">
{mainAction.target?.name || JSON.stringify(mainAction.target)}
</p>
)}
<ConfBar value={mainAction?.confidence} />
<p className="text-gray-200 text-sm mt-3 leading-relaxed">{mainAction?.reasoning}</p>
{mainAction?.warnings?.length > 0 && (
<div className="mt-2 space-y-1">
{mainAction.warnings.map((w, i) => (
<p key={i} className="text-yellow-400 text-xs"> {w}</p>
))}
</div>
)}
{mainAction?.synergies?.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{mainAction.synergies.map(s => (
<span key={s} className="text-xs bg-blue-900/40 text-blue-400 border border-blue-800/30 px-2 py-0.5 rounded">
{s}
</span>
))}
</div>
)}
</div>
{/* Analyse du board */}
{advice.board_analysis && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<p className="text-xs text-gray-400 uppercase tracking-wider mb-2">Analyse</p>
<p className="text-gray-200 text-sm leading-relaxed">{advice.board_analysis}</p>
</div>
)}
{/* Stratégie LLM */}
{advice.strategy_long_term && (
<div className="bg-gray-900 border border-blue-800/30 rounded-xl p-4">
<p className="text-xs text-blue-400 uppercase tracking-wider mb-2">Stratégie</p>
<p className="text-gray-200 text-sm">{advice.strategy_long_term}</p>
</div>
)}
{/* Menaces */}
{advice.threat_assessment && (
<div className="bg-gray-900 border border-red-900/30 rounded-xl p-4">
<p className="text-xs text-red-400 uppercase tracking-wider mb-2">Menaces</p>
<p className="text-gray-200 text-sm">{advice.threat_assessment}</p>
</div>
)}
{/* Alternatives */}
{advice.secondary_decisions?.length > 0 && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<p className="text-xs text-gray-400 uppercase tracking-wider mb-2">Alternatives</p>
<div className="space-y-1">
{advice.secondary_decisions.slice(0, 4).map((d, i) => {
const info = ACTION_MAP[d.action] || { label: d.action, color: 'text-gray-400' }
return (
<div key={i} className="flex items-center justify-between py-1.5 border-b border-gray-800 last:border-0">
<span className={`text-sm font-medium ${info.color}`}>{info.label}</span>
<div className="flex items-center gap-2">
<span className="text-gray-500 text-xs truncate max-w-[120px]">{d.reasoning?.slice(0, 40)}...</span>
<span className="text-gray-600 text-xs">{Math.round(d.confidence * 100)}%</span>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Feedback */}
{advice.decision_id && (
<FeedbackBar decisionId={advice.decision_id} />
)}
{/* Meta */}
<p className="text-gray-700 text-xs text-center">{advice.processing_ms}ms de traitement</p>
</>
) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-8 text-center">
<Swords size={40} className="mx-auto mb-4 text-gray-700" />
<p className="text-gray-400 font-medium mb-2">Prêt à analyser</p>
<p className="text-gray-600 text-sm">Renseignez l'état du jeu puis cliquez sur "Obtenir conseil"</p>
</div>
)}
</div>
</div>
{/* Screenshot preview */}
{screenshot && (
<div className="mt-4 bg-gray-900 border border-gray-800 rounded-xl p-4">
<div className="flex justify-between items-center mb-2">
<p className="text-sm text-gray-400">Capture d'écran</p>
<button onClick={() => setScreenshot(null)} className="text-gray-600 hover:text-gray-400">
<X size={14} />
</button>
</div>
<img src={`data:image/png;base64,${screenshot}`} className="max-h-48 rounded-lg" alt="capture" />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,163 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
import { Brain, ThumbsUp, ThumbsDown, Minus, RefreshCw, BarChart2 } from 'lucide-react'
const RATING_ICONS = {
1: <ThumbsUp size={13} className="text-green-400" />,
0: <Minus size={13} className="text-gray-400" />,
'-1': <ThumbsDown size={13} className="text-red-400" />,
}
const ACTION_COLORS = {
buy: 'text-green-400', sell: 'text-red-400', freeze: 'text-blue-400',
upgrade: 'text-yellow-400', reposition: 'text-purple-400', wait: 'text-gray-400',
}
export default function LearningPage() {
const [stats, setStats] = useState(null)
const [decisions, setDecisions] = useState([])
const [loading, setLoading] = useState(true)
const load = async () => {
setLoading(true)
try {
const [s, d] = await Promise.all([
axios.get('/api/learning/stats'),
axios.get('/api/learning/decisions?limit=30'),
])
setStats(s.data)
setDecisions(d.data)
} catch {}
finally { setLoading(false) }
}
useEffect(() => { load() }, [])
const sendFeedback = async (id, rating) => {
try {
await axios.post('/api/learning/feedback', { decision_id: id, rating })
load()
} catch { alert('Erreur envoi feedback') }
}
return (
<div className="p-6 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Brain size={24} className="text-purple-400" />
<div>
<h2 className="text-2xl font-bold text-white">Mode Apprentissage</h2>
<p className="text-gray-400 text-sm">Évaluez les décisions IA pour améliorer le modèle</p>
</div>
</div>
<button onClick={load} className="flex items-center gap-1.5 text-gray-400 hover:text-white text-sm transition-colors">
<RefreshCw size={14} /> Actualiser
</button>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
{[
['Feedbacks total', stats.total, 'text-white'],
[`Bons (${stats.good_rate}%)`, stats.good, 'text-green-400'],
['Mauvais', stats.bad, 'text-red-400'],
['Entraînements', stats.trained, 'text-blue-400'],
].map(([label, val, color]) => (
<div key={label} className="bg-gray-900 border border-gray-800 rounded-xl p-4 text-center">
<p className={`text-2xl font-bold ${color}`}>{val}</p>
<p className="text-gray-500 text-xs mt-1">{label}</p>
</div>
))}
</div>
)}
{/* Barre de progression good rate */}
{stats && stats.total > 0 && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-6">
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-300 flex items-center gap-1.5"><BarChart2 size={14} /> Qualité des conseils</span>
<span className="text-gray-400">{stats.good_rate}% de réussite</span>
</div>
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-green-600 to-green-400 rounded-full transition-all"
style={{ width: `${stats.good_rate}%` }} />
</div>
{stats.buffer_pending > 0 && (
<p className="text-yellow-400 text-xs mt-2"> {stats.buffer_pending} feedbacks en attente d'export</p>
)}
</div>
)}
{/* Historique */}
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-800">
<h3 className="font-semibold text-white">Historique des décisions</h3>
<p className="text-gray-500 text-xs mt-0.5">Cliquez 👍/👎 pour noter les conseils non évalués</p>
</div>
{loading ? (
<p className="text-gray-400 text-center py-8">Chargement...</p>
) : decisions.length === 0 ? (
<div className="text-center py-12">
<Brain size={32} className="mx-auto mb-3 text-gray-700" />
<p className="text-gray-500">Aucune décision enregistrée</p>
<p className="text-gray-600 text-sm mt-1">Lancez une partie et demandez des conseils!</p>
</div>
) : (
<div className="divide-y divide-gray-800">
{decisions.map(d => {
const action = d.recommendation?.main_decision?.action
return (
<div key={d.id} className="px-4 py-3 hover:bg-gray-800/30 transition-colors">
<div className="flex justify-between items-start mb-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`font-semibold text-sm ${ACTION_COLORS[action] || 'text-white'}`}>
{action?.toUpperCase() || '?'}
</span>
<span className="text-gray-600 text-xs">Tour {d.turn}</span>
<span className="text-gray-700 text-xs">{d.phase}</span>
<span className="text-gray-700 text-xs bg-gray-800 px-1.5 py-0.5 rounded">{d.model_used}</span>
<span className="text-gray-700 text-xs">{d.processing_ms}ms</span>
</div>
<div className="flex items-center gap-1 ml-2 shrink-0">
{d.outcome_rating !== null && d.outcome_rating !== undefined
? RATING_ICONS[String(d.outcome_rating)]
: (
<>
<button onClick={() => sendFeedback(d.id, 'good')}
className="p-1 hover:text-green-400 text-gray-600 transition-colors" title="Bon conseil">
<ThumbsUp size={13} />
</button>
<button onClick={() => sendFeedback(d.id, 'neutral')}
className="p-1 hover:text-gray-300 text-gray-600 transition-colors" title="Neutre">
<Minus size={13} />
</button>
<button onClick={() => sendFeedback(d.id, 'bad')}
className="p-1 hover:text-red-400 text-gray-600 transition-colors" title="Mauvais conseil">
<ThumbsDown size={13} />
</button>
</>
)
}
</div>
</div>
<p className="text-gray-300 text-xs leading-relaxed">{d.reasoning}</p>
<div className="flex items-center gap-3 mt-1">
<div className="flex-1 h-1 bg-gray-800 rounded-full overflow-hidden">
<div className="h-full bg-blue-600 rounded-full"
style={{ width: `${Math.round(d.confidence * 100)}%` }} />
</div>
<span className="text-gray-600 text-xs">{Math.round(d.confidence * 100)}%</span>
</div>
{d.user_feedback && (
<p className="text-gray-500 text-xs mt-1 italic">💬 {d.user_feedback}</p>
)}
</div>
)
})}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
import { Settings, Cpu, Eye, Brain, AlertCircle } from 'lucide-react'
function Section({ title, icon: Icon, children }) {
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
<div className="flex items-center gap-2 mb-4 pb-3 border-b border-gray-800">
<Icon size={16} className="text-gray-400" />
<h3 className="font-semibold text-white">{title}</h3>
</div>
{children}
</div>
)
}
function Row({ label, value, mono = false, highlight = false }) {
return (
<div className="flex justify-between items-center py-2 border-b border-gray-800 last:border-0">
<span className="text-gray-400 text-sm">{label}</span>
<span className={`text-sm font-medium max-w-[60%] text-right truncate
${mono ? 'font-mono text-yellow-300' : highlight ? 'text-green-400' : 'text-white'}`}>
{String(value)}
</span>
</div>
)
}
export default function SettingsPage() {
const [cfg, setCfg] = useState(null)
useEffect(() => {
axios.get('/api/settings/').then(r => setCfg(r.data)).catch(() => {})
}, [])
if (!cfg) return <div className="p-6 text-gray-400">Chargement...</div>
return (
<div className="p-6 max-w-3xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Settings size={24} className="text-gray-400" />
<div>
<h2 className="text-2xl font-bold text-white">Paramètres</h2>
<p className="text-gray-400 text-sm">Configuration de l'IA et des services</p>
</div>
</div>
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-xl p-4 mb-6 flex gap-3">
<AlertCircle size={16} className="text-yellow-400 shrink-0 mt-0.5" />
<p className="text-yellow-300 text-sm">
Pour modifier ces paramètres, éditez <code className="bg-gray-800 px-1 rounded">.env</code> puis redémarrez le backend avec <code className="bg-gray-800 px-1 rounded">bash start_backend.sh</code>
</p>
</div>
<div className="space-y-4">
<Section title="LLM Local (Ollama)" icon={Cpu}>
<Row label="Fournisseur" value={cfg.llm_provider} mono />
<Row label="Modèle actif" value={cfg.llm_model} mono />
<Row label="URL Ollama" value={cfg.llm_base_url} mono />
<Row label="Température" value={cfg.llm_temperature} />
<Row label="Tokens max" value={cfg.llm_max_tokens} />
</Section>
<Section title="Vision & OCR" icon={Eye}>
<Row label="Activée" value={cfg.vision_enabled ? ' Oui' : ' Non'}
highlight={cfg.vision_enabled} />
<Row label="Intervalle de capture" value={`${cfg.screenshot_interval}s`} />
</Section>
<Section title="Apprentissage" icon={Brain}>
<Row label="Activé" value={cfg.learning_enabled ? ' Oui' : ' Non'}
highlight={cfg.learning_enabled} />
<Row label="Taux d'apprentissage" value={cfg.learning_rate} />
<Row label="Taille du batch" value={cfg.learning_batch_size} />
</Section>
<Section title="Système" icon={Settings}>
<Row label="Patch HSBG" value={cfg.current_patch} />
<Row label="Mode debug" value={cfg.debug ? 'Oui' : 'Non'} />
</Section>
</div>
{/* Guides d'installation */}
<div className="mt-6 space-y-4">
<div className="bg-blue-900/20 border border-blue-800/40 rounded-xl p-5">
<h4 className="font-semibold text-blue-300 mb-3">🧠 Installer Ollama (LLM local)</h4>
<div className="space-y-2 font-mono text-xs">
{[
['# 1. Installer Ollama', ''],
['curl -fsSL https://ollama.ai/install.sh | sh', 'text-yellow-300'],
['# 2. Télécharger llama3.2 (4.7GB)', ''],
['ollama pull llama3.2', 'text-yellow-300'],
['# 3. Ou un modèle plus léger', ''],
['ollama pull mistral:7b', 'text-yellow-300'],
].map(([cmd, color], i) => (
<p key={i} className={`${color || 'text-gray-500'}`}>{cmd}</p>
))}
</div>
</div>
<div className="bg-green-900/20 border border-green-800/40 rounded-xl p-5">
<h4 className="font-semibold text-green-300 mb-3">👁️ Activer la vision OCR</h4>
<div className="space-y-2 font-mono text-xs">
<p className="text-gray-500"># Installer Tesseract OCR</p>
<p className="text-yellow-300">sudo apt-get install tesseract-ocr tesseract-ocr-fra</p>
<p className="text-gray-500"># Puis dans .env:</p>
<p className="text-yellow-300">VISION_ENABLED=true</p>
<p className="text-yellow-300">TESSERACT_PATH=/usr/bin/tesseract</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
export default {
content: ["./index.html","./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
hsbg: { gold:'#F5B30A', dark:'#1A0A00', card:'#2A1500', board:'#0D1F0D' }
}
}
},
plugins: []
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': { target: 'http://localhost:8000', changeOrigin: true },
'/ws': { target: 'ws://localhost:8000', ws: true }
}
},
build: { outDir: 'dist' }
})

30
hsbg_ai/requirements.txt Normal file
View File

@@ -0,0 +1,30 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
python-multipart==0.0.9
websockets==13.0
sqlalchemy==2.0.35
alembic==1.13.3
aiosqlite==0.20.0
langchain==0.3.0
langchain-community==0.3.0
ollama==0.3.3
sentence-transformers==3.1.1
numpy==1.26.4
scikit-learn==1.5.2
Pillow==10.4.0
pytesseract==0.3.13
opencv-python-headless==4.10.0.84
mss==9.0.2
pydantic==2.9.2
pydantic-settings==2.5.2
python-dotenv==1.0.1
httpx==0.27.2
aiofiles==24.1.0
structlog==24.4.0
rich==13.8.1
click==8.1.7
pandas==2.2.3
pytest==8.3.3
pytest-asyncio==0.24.0
black==24.8.0
ruff==0.6.8

6
hsbg_ai/seed_db.sh Normal file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
# Recharge les données HSBG dans la base
cd "$(dirname "$0")"
source .venv/bin/activate
echo "🌱 Rechargement de la base de données HSBG..."
python -m backend.database.seeds.seed_data

17
hsbg_ai/start_backend.sh Normal file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
# Lance le backend HSBG AI
cd "$(dirname "$0")"
source .venv/bin/activate
echo "╔══════════════════════════════════════╗"
echo "║ HSBG AI - Backend (FastAPI) ║"
echo "║ http://localhost:8000 ║"
echo "║ API Docs: /docs ║"
echo "╚══════════════════════════════════════╝"
echo ""
python -m uvicorn backend.main:app \
--host 0.0.0.0 \
--port 8000 \
--reload \
--log-level info

11
hsbg_ai/start_frontend.sh Normal file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
# Lance le frontend HSBG AI (React + Vite)
cd "$(dirname "$0")/frontend"
echo "╔══════════════════════════════════════╗"
echo "║ HSBG AI - Frontend (React) ║"
echo "║ http://localhost:3000 ║"
echo "╚══════════════════════════════════════╝"
echo ""
npm run dev

139
init_project.sh Normal file
View File

@@ -0,0 +1,139 @@
#!/bin/bash
# =============================================================================
# HSBG AI ASSISTANT - Script d'initialisation
# Compatible: WSL (Ubuntu), Linux
# Usage: bash init_project.sh
# =============================================================================
set -e
# Couleurs
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_ok() { echo -e "${GREEN}[ OK ]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERR ]${NC} $1"; }
log_step() { echo -e "\n${BOLD}${CYAN}━━━ $1 ━━━${NC}"; }
# =============================================================================
echo -e "${BOLD}${CYAN}"
echo " ╔═══════════════════════════════════════════════════════╗"
echo " ║ HSBG AI ASSISTANT - Initialisation ║"
echo " ║ Hearthstone Battlegrounds - Intelligence IA ║"
echo " ╚═══════════════════════════════════════════════════════╝"
echo -e "${NC}"
# =============================================================================
PROJECT_DIR="$(pwd)/hsbg_ai"
VENV_DIR="${PROJECT_DIR}/.venv"
# 1. Prérequis
log_step "VÉRIFICATION DES PRÉREQUIS"
command -v python3 >/dev/null 2>&1 || { log_error "Python3 requis (3.10+)"; exit 1; }
log_ok "Python $(python3 --version | grep -oP '\d+\.\d+') trouvé"
command -v pip3 >/dev/null 2>&1 || { log_error "pip3 requis"; exit 1; }
log_ok "pip3 trouvé"
HAS_NODE=0
if command -v node >/dev/null 2>&1; then
HAS_NODE=1
log_ok "Node.js $(node --version) trouvé"
else
log_warn "Node.js non trouvé - frontend sera lancé en mode dev séparé"
fi
# 2. Vérifier que le dossier projet existe (créé par les fichiers précédents)
log_step "VÉRIFICATION DU PROJET"
if [ ! -d "${PROJECT_DIR}" ]; then
log_error "Dossier ${PROJECT_DIR} introuvable. Assurez-vous que tous les fichiers sont extraits."
exit 1
fi
log_ok "Projet trouvé: ${PROJECT_DIR}"
# 3. Virtualenv Python
log_step "ENVIRONNEMENT PYTHON"
log_info "Création du virtualenv..."
python3 -m venv "${VENV_DIR}"
source "${VENV_DIR}/bin/activate"
pip install --upgrade pip setuptools wheel -q
log_ok "Virtualenv créé"
log_info "Installation des dépendances Python (5-10 minutes)..."
pip install -r "${PROJECT_DIR}/requirements.txt" -q --no-warn-script-location
log_ok "Dépendances Python installées"
# 4. Dépendances frontend
if [ "${HAS_NODE}" = "1" ]; then
log_step "FRONTEND NODE.JS"
cd "${PROJECT_DIR}/frontend"
npm install -q
log_ok "Dépendances npm installées"
cd - >/dev/null
fi
# 5. Initialisation de la base de données
log_step "BASE DE DONNÉES"
cd "${PROJECT_DIR}"
source "${VENV_DIR}/bin/activate"
python -m backend.database.seeds.seed_data
log_ok "Base de données HSBG initialisée"
cd - >/dev/null
# 6. Git
if command -v git >/dev/null 2>&1; then
log_step "INITIALISATION GIT"
cd "${PROJECT_DIR}"
git init -q
cat > .gitignore << 'GITEOF'
.venv/
__pycache__/
*.pyc
*.pyo
.env
data/hsbg_ai.db
data/screenshots/
data/learning/feedback/
data/learning/sessions/
logs/*.log
frontend/node_modules/
frontend/dist/
*.egg-info/
.DS_Store
Thumbs.db
GITEOF
git add .
git commit -m "feat: init HSBG AI project v1.0.0" -q
log_ok "Dépôt git initialisé"
cd - >/dev/null
fi
# =============================================================================
# RÉSUMÉ FINAL
# =============================================================================
echo ""
echo -e "${BOLD}${GREEN}╔═══════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${GREEN}║ ✅ INSTALLATION TERMINÉE AVEC SUCCÈS! ║${NC}"
echo -e "${BOLD}${GREEN}╚═══════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${BOLD}📁 Projet:${NC} ${PROJECT_DIR}"
echo ""
echo -e "${BOLD}🚀 DÉMARRAGE (2 terminaux):${NC}"
echo -e " ${CYAN}Terminal 1 (Backend):${NC}"
echo -e " cd hsbg_ai && bash start_backend.sh"
echo ""
echo -e " ${CYAN}Terminal 2 (Frontend):${NC}"
echo -e " cd hsbg_ai && bash start_frontend.sh"
echo ""
echo -e "${BOLD}🌐 Accès:${NC}"
echo -e " Interface web: ${YELLOW}http://localhost:3000${NC}"
echo -e " API + Docs: ${YELLOW}http://localhost:8000/docs${NC}"
echo -e " Health check: ${YELLOW}http://localhost:8000/health${NC}"
echo ""
echo -e "${BOLD}🧠 LLM Local (optionnel, recommandé):${NC}"
echo -e " ${CYAN}1.${NC} curl -fsSL https://ollama.ai/install.sh | sh"
echo -e " ${CYAN}2.${NC} ollama pull llama3.2"
echo ""
echo -e "${BOLD}👁️ OCR Vision (optionnel):${NC}"
echo -e " sudo apt-get install tesseract-ocr tesseract-ocr-fra"
echo ""