feat: endurance tickets — coûts visibles partout + budget dashboard
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s

Combat: coût 5 affiché, compteur "X combats possibles", bouton disabled
Forge: coût 10 + or affiché (baissé de 15 à 10), bouton disabled
Dashboard: indicateur budget "X combats · Y forges · Z repos"
Repos: coût 10 affiché, disabled si insuffisant
This commit is contained in:
2026-03-24 17:09:06 +01:00
parent cfdc5c9b02
commit eafac3d8c7
4 changed files with 98 additions and 36 deletions

View File

@@ -1,8 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { combatApi } from '../api/endpoints'; import { combatApi, characterApi } from '../api/endpoints';
import type { Monster, CombatResult } from '../api/types'; import type { Monster, CombatResult } from '../api/types';
import { Swords, Trophy, Skull, Clock } from 'lucide-react'; import { Swords, Trophy, Skull, Clock, Zap } from 'lucide-react';
const COMBAT_COST = 5;
const ATTACK_TYPES = [ const ATTACK_TYPES = [
{ id: 'melee', label: 'Mêlée', emoji: '⚔️', stat: 'Force × 1.5' }, { id: 'melee', label: 'Mêlée', emoji: '⚔️', stat: 'Force × 1.5' },
@@ -84,6 +86,10 @@ export function CombatPage() {
const [attackType, setAttackType] = useState('melee'); const [attackType, setAttackType] = useState('melee');
const [lastResult, setLastResult] = useState<CombatResult | null>(null); const [lastResult, setLastResult] = useState<CombatResult | null>(null);
const { data: char } = useQuery({ queryKey: ['character'], queryFn: characterApi.me });
const endurance = (char as any)?.enduranceCurrent ?? (char as any)?.endurance ?? 0;
const canFight = endurance >= COMBAT_COST;
const { data: monsters, isLoading } = useQuery({ const { data: monsters, isLoading } = useQuery({
queryKey: ['monsters'], queryKey: ['monsters'],
queryFn: combatApi.monsters, queryFn: combatApi.monsters,
@@ -150,17 +156,23 @@ export function CombatPage() {
))} ))}
</div> </div>
{/* Coût endurance */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginBottom: 6, fontSize: 12, color: canFight ? '#5ba4f5' : '#e84040' }}>
<Zap size={12} /> Coût : {COMBAT_COST} endurance — Disponible : {endurance}
{canFight && <span style={{ color: '#6b7a99' }}>({Math.floor(endurance / COMBAT_COST)} combats possibles)</span>}
</div>
{/* Bouton combattre */} {/* Bouton combattre */}
<button <button
className="btn btn-red" className="btn btn-red"
style={{ width: '100%', fontSize: 15, padding: '0.75rem' }} style={{ width: '100%', fontSize: 15, padding: '0.75rem', opacity: canFight ? 1 : 0.5 }}
disabled={!selectedMonster || fight.isPending} disabled={!selectedMonster || fight.isPending || !canFight}
onClick={() => fight.mutate()} onClick={() => fight.mutate()}
> >
{fight.isPending ? ( {fight.isPending ? (
<span><Swords size={14} style={{ display: 'inline', marginRight: 6 }} />Combat…</span> <span><Swords size={14} style={{ display: 'inline', marginRight: 6 }} />Combat…</span>
) : ( ) : (
<span>⚔️ Combattre {selectedMonster ? `— ${selectedMonster.name}` : ''}</span> <span>⚔️ Combattre {selectedMonster ? `— ${selectedMonster.name}` : ''} ({COMBAT_COST}⚡)</span>
)} )}
</button> </button>

View File

@@ -153,6 +153,11 @@ export function DashboardPage() {
const xpNext = (char as any).xpToNextLevel ?? Math.round(100 * Math.pow(char.level, 1.5)); const xpNext = (char as any).xpToNextLevel ?? Math.round(100 * Math.pow(char.level, 1.5));
const statPoints = (char as any).statPoints ?? 0; const statPoints = (char as any).statPoints ?? 0;
const needsHeal = char.hpCurrent < char.hpMax; const needsHeal = char.hpCurrent < char.hpMax;
const endurance = (char as any).enduranceCurrent ?? (char as any).endurance ?? 0;
const REST_COST = 10;
const COMBAT_COST = 5;
const FORGE_COST = 10;
const canRest = endurance >= REST_COST && needsHeal;
return ( return (
<div> <div>
@@ -210,17 +215,28 @@ export function DashboardPage() {
</div> </div>
<Bar value={char.xp} max={xpNext} type="xp" showValues={false} /> <Bar value={char.xp} max={xpNext} type="xp" showValues={false} />
</div> </div>
{/* Budget endurance */}
<div style={{ marginTop: 8, padding: '6px 8px', background: '#111620', borderRadius: 6, fontSize: 11, color: '#6b7a99' }}>
<span style={{ fontWeight: 700, color: '#5ba4f5' }}> Budget :</span>
{' '}{Math.floor(endurance / COMBAT_COST)} combats
{' · '}{Math.floor(endurance / FORGE_COST)} forges
{' · '}{Math.floor(endurance / REST_COST)} repos
</div>
{needsHeal && ( {needsHeal && (
<button <button
className="btn btn-ghost" className="btn btn-ghost"
style={{ marginTop: 4, fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center' }} style={{ marginTop: 4, fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center', opacity: canRest ? 1 : 0.5 }}
disabled={restMut.isPending} disabled={restMut.isPending || !canRest}
onClick={() => restMut.mutate()} onClick={() => restMut.mutate()}
> >
<BedDouble size={13} /> <BedDouble size={13} />
{restMut.isPending ? 'Repos…' : 'Se reposer (+50% PV, -20 endurance)'} {restMut.isPending ? 'Repos…' : `Se reposer (+50% PV, ${REST_COST}⚡)`}
</button> </button>
)} )}
{needsHeal && !canRest && endurance < REST_COST && (
<p style={{ fontSize: 10, color: '#e84040', textAlign: 'center', margin: '2px 0 0' }}>Endurance insuffisante pour se reposer</p>
)}
{restMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 2 }}>{(restMut.error as Error).message}</p>} {restMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 2 }}>{(restMut.error as Error).message}</p>}
</div> </div>
</div> </div>

View File

@@ -1,17 +1,71 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { itemApi, forgeApi } from '../api/endpoints'; import { itemApi, forgeApi, characterApi } from '../api/endpoints';
import type { CharacterItem } from '../api/types'; import type { CharacterItem } from '../api/types';
import { Shield, CheckCircle, XCircle, AlertTriangle } from 'lucide-react'; import { Shield, CheckCircle, XCircle, AlertTriangle, Zap, Coins } from 'lucide-react';
const FORGE_RISK = [0, 0, 0, 20, 30, 40]; const FORGE_RISK = [0, 0, 0, 20, 30, 40];
const FORGE_LABEL = ['—', '—', 'Garanti', '20% échec', '30% échec', '40% échec']; const FORGE_LABEL = ['—', '—', 'Garanti', '20% échec', '30% échec', '40% échec'];
const FORGE_ENDURANCE_COST = 10;
const FORGE_GOLD_COST: Record<number, number> = { 1: 50, 2: 100, 3: 250, 4: 500, 5: 1000 };
function ForgePanel({ nextLevel, risk, endurance, gold, isPending, onForge }: {
nextLevel: number; risk: number; endurance: number; gold: number; isPending: boolean; onForge: () => void;
}) {
const goldCost = FORGE_GOLD_COST[nextLevel] ?? 0;
const canForge = endurance >= FORGE_ENDURANCE_COST && gold >= goldCost;
return (
<>
<div className="card" style={{ marginBottom: '0.75rem', textAlign: 'center' }}>
<div style={{ fontSize: 12, color: '#6b7a99', marginBottom: 4 }}>Prochain niveau : +{nextLevel}</div>
<div style={{
fontSize: 14, fontWeight: 700,
color: risk === 0 ? '#3ddc84' : risk <= 20 ? '#f4c94e' : '#e84040',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4
}}>
{risk === 0
? <><CheckCircle size={14} /> Succès garanti</>
: <><AlertTriangle size={14} /> {FORGE_LABEL[nextLevel]}</>
}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'center', gap: 16, marginBottom: 8, fontSize: 12 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 4, color: endurance >= FORGE_ENDURANCE_COST ? '#5ba4f5' : '#e84040' }}>
<Zap size={11} /> {FORGE_ENDURANCE_COST} endurance
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4, color: gold >= goldCost ? '#f4c94e' : '#e84040' }}>
<Coins size={11} /> {goldCost} or
</span>
</div>
<button
className="btn btn-gold"
style={{ width: '100%', fontSize: 14, padding: '0.75rem', opacity: canForge ? 1 : 0.5 }}
disabled={isPending || !canForge}
onClick={onForge}
>
{isPending ? 'Forge en cours…' : `🔨 Forger → +${nextLevel} (${FORGE_ENDURANCE_COST}${goldCost}💰)`}
</button>
{!canForge && (
<p style={{ textAlign: 'center', fontSize: 11, color: '#e84040', marginTop: 4 }}>
{endurance < FORGE_ENDURANCE_COST ? 'Endurance insuffisante' : 'Or insuffisant'}
</p>
)}
</>
);
}
export function ForgePage() { export function ForgePage() {
const qc = useQueryClient(); const qc = useQueryClient();
const [selected, setSelected] = useState<CharacterItem | null>(null); const [selected, setSelected] = useState<CharacterItem | null>(null);
const [lastResult, setLastResult] = useState<{ success: boolean; newLevel: number } | null>(null); const [lastResult, setLastResult] = useState<{ success: boolean; newLevel: number } | null>(null);
const { data: char } = useQuery({ queryKey: ['character'], queryFn: characterApi.me });
const endurance = (char as any)?.enduranceCurrent ?? (char as any)?.endurance ?? 0;
const gold = char?.gold ?? 0;
const { data: inventory, isLoading } = useQuery({ const { data: inventory, isLoading } = useQuery({
queryKey: ['inventory'], queryKey: ['inventory'],
queryFn: itemApi.inventory, queryFn: itemApi.inventory,
@@ -22,7 +76,7 @@ export function ForgePage() {
onSuccess: (res) => { onSuccess: (res) => {
setLastResult({ success: res.success, newLevel: res.newForgeLevel }); setLastResult({ success: res.success, newLevel: res.newForgeLevel });
qc.invalidateQueries({ queryKey: ['inventory'] }); qc.invalidateQueries({ queryKey: ['inventory'] });
// Refresh selected item from updated inventory qc.invalidateQueries({ queryKey: ['character'] });
setSelected(res.item); setSelected(res.item);
}, },
}); });
@@ -88,30 +142,10 @@ export function ForgePage() {
</div> </div>
{!atMax ? ( {!atMax ? (
<> <ForgePanel
<div className="card" style={{ marginBottom: '1rem', textAlign: 'center' }}> nextLevel={nextLevel} risk={risk} endurance={endurance} gold={gold}
<div style={{ fontSize: 12, color: '#6b7a99', marginBottom: 4 }}>Prochain niveau : +{nextLevel}</div> isPending={forgeMut.isPending} onForge={() => forgeMut.mutate()}
<div style={{ />
fontSize: 14, fontWeight: 700,
color: risk === 0 ? '#3ddc84' : risk <= 20 ? '#f4c94e' : '#e84040',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4
}}>
{risk === 0
? <><CheckCircle size={14} /> Succès garanti</>
: <><AlertTriangle size={14} /> {FORGE_LABEL[nextLevel]}</>
}
</div>
</div>
<button
className="btn btn-gold"
style={{ width: '100%', fontSize: 14, padding: '0.75rem' }}
disabled={forgeMut.isPending}
onClick={() => forgeMut.mutate()}
>
{forgeMut.isPending ? 'Forge en cours' : `🔨 Forger → +${nextLevel}`}
</button>
</>
) : ( ) : (
<div style={{ textAlign: 'center', color: '#f4c94e', fontSize: 13, padding: '0.5rem' }}> <div style={{ textAlign: 'center', color: '#f4c94e', fontSize: 13, padding: '0.5rem' }}>
✨ Niveau maximum atteint (+5) ✨ Niveau maximum atteint (+5)

View File

@@ -18,7 +18,7 @@ const FORGE_GOLD_COST: Record<number, number> = {
5: 1000, 5: 1000,
}; };
const FORGE_ENDURANCE_COST = 15; const FORGE_ENDURANCE_COST = 10;
// Risque d'échec par niveau cible (GDD exact) // Risque d'échec par niveau cible (GDD exact)
const FORGE_FAIL_CHANCE: Record<number, number> = { const FORGE_FAIL_CHANCE: Record<number, number> = {