Files
hsbg-ai/hsbg_ai/frontend/src/pages/GamePage.jsx
2026-03-31 13:10:46 +02:00

407 lines
18 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}