feat: titres sélectionnables + prix revente forge inclus
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s
Dashboard: titre actif affiché "« Champion »" + sélecteur avec tous les titres débloqués (achievements claimed avec rewardTitle). Header: titre visible à côté du level. Revente: prix inclut l'investissement forge (50% des coûts cumulés). Épée +5 (investissement 1900 or) → revente base + 950 au lieu de base seul. API client: ajout méthode PUT.
This commit is contained in:
@@ -55,5 +55,6 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(path: string) => request<T>(path),
|
get: <T>(path: string) => request<T>(path),
|
||||||
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
||||||
|
put: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
|
||||||
del: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
del: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const characterApi = {
|
|||||||
distributeStats: (stats: Record<string, number>) =>
|
distributeStats: (stats: Record<string, number>) =>
|
||||||
api.post<Character>('/characters/stats', stats),
|
api.post<Character>('/characters/stats', stats),
|
||||||
rest: () => api.post<{ hpBefore: number; hpAfter: number; hpMax: number; healed: number }>('/characters/rest'),
|
rest: () => api.post<{ hpBefore: number; hpAfter: number; hpMax: number; healed: number }>('/characters/rest'),
|
||||||
|
setTitle: (title: string | null) => api.put<any>('/profile/title', { title }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Combat
|
// Combat
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { characterApi, itemApi } from '../api/endpoints';
|
import { characterApi, itemApi } from '../api/endpoints';
|
||||||
|
import { api } from '../api/client';
|
||||||
import { Bar } from '../components/Bar';
|
import { Bar } from '../components/Bar';
|
||||||
import { Zap, Heart, Star, Coins, Sword, Shield, BedDouble } from 'lucide-react';
|
import { Zap, Heart, Star, Coins, Sword, Shield, BedDouble } from 'lucide-react';
|
||||||
|
|
||||||
@@ -134,6 +135,58 @@ function StatDistributor({ char }: { char: any }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TitleSelector({ char }: { char: any }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data: achievements } = useQuery({
|
||||||
|
queryKey: ['achievements'],
|
||||||
|
queryFn: () => api.get<any[]>('/achievements/me'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const titleMut = useMutation({
|
||||||
|
mutationFn: (title: string | null) => characterApi.setTitle(title),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['character'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect unlocked titles from claimed achievements
|
||||||
|
const unlockedTitles: string[] = [];
|
||||||
|
if (achievements) {
|
||||||
|
for (const a of achievements) {
|
||||||
|
if (a.claimed && a.rewardTitle) {
|
||||||
|
unlockedTitles.push(a.rewardTitle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unlockedTitles.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<p style={{ margin: '0 0 0.5rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>🏅 Titre actif</p>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
className={`btn ${!char.activeTitle ? 'btn-gold' : 'btn-ghost'}`}
|
||||||
|
style={{ fontSize: 11, padding: '0.2rem 0.6rem' }}
|
||||||
|
disabled={titleMut.isPending}
|
||||||
|
onClick={() => titleMut.mutate(null)}
|
||||||
|
>
|
||||||
|
Aucun
|
||||||
|
</button>
|
||||||
|
{unlockedTitles.map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
className={`btn ${char.activeTitle === t ? 'btn-gold' : 'btn-ghost'}`}
|
||||||
|
style={{ fontSize: 11, padding: '0.2rem 0.6rem' }}
|
||||||
|
disabled={titleMut.isPending}
|
||||||
|
onClick={() => titleMut.mutate(t)}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CombatStatsPanel({ char }: { char: any }) {
|
function CombatStatsPanel({ char }: { char: any }) {
|
||||||
const { data: inventory } = useQuery({ queryKey: ['inventory'], queryFn: itemApi.inventory });
|
const { data: inventory } = useQuery({ queryKey: ['inventory'], queryFn: itemApi.inventory });
|
||||||
|
|
||||||
@@ -204,6 +257,9 @@ export function DashboardPage() {
|
|||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
|
||||||
<h2 style={{ margin: 0, fontSize: 22, color: '#f4c94e' }}>{char.name}</h2>
|
<h2 style={{ margin: 0, fontSize: 22, color: '#f4c94e' }}>{char.name}</h2>
|
||||||
<span style={{ fontSize: 13, color: '#6b7a99' }}>Niveau {char.level}</span>
|
<span style={{ fontSize: 13, color: '#6b7a99' }}>Niveau {char.level}</span>
|
||||||
|
{char.activeTitle && (
|
||||||
|
<span style={{ fontSize: 11, color: '#a78bfa', fontStyle: 'italic' }}>« {char.activeTitle} »</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 16, marginTop: 6, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 16, marginTop: 6, flexWrap: 'wrap' }}>
|
||||||
<span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}>
|
<span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
@@ -301,6 +357,9 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Titres */}
|
||||||
|
<TitleSelector char={char} />
|
||||||
|
|
||||||
{/* Équipement résumé */}
|
{/* Équipement résumé */}
|
||||||
<CombatStatsPanel char={char} />
|
<CombatStatsPanel char={char} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ function ItemCard({ ci, onEquip, onUnequip, onSell, selling }: {
|
|||||||
const forgeBonusDEF = item.type === 'armor' ? ci.forgeLevel * 2 : 0;
|
const forgeBonusDEF = item.type === 'armor' ? ci.forgeLevel * 2 : 0;
|
||||||
const totalATK = item.attackBonus + forgeBonusATK;
|
const totalATK = item.attackBonus + forgeBonusATK;
|
||||||
const totalDEF = item.defenseBonus + forgeBonusDEF;
|
const totalDEF = item.defenseBonus + forgeBonusDEF;
|
||||||
const sellPrice = Math.floor((item as any).buyPrice * 0.4) || 0;
|
const FORGE_COSTS: Record<number, number> = { 1: 50, 2: 100, 3: 250, 4: 500, 5: 1000 };
|
||||||
|
let forgeInvestment = 0;
|
||||||
|
for (let i = 1; i <= ci.forgeLevel; i++) forgeInvestment += FORGE_COSTS[i] ?? 0;
|
||||||
|
const sellPrice = Math.floor(((item as any).buyPrice || 0) * 0.4 + forgeInvestment * 0.5);
|
||||||
|
|
||||||
const bonuses = [
|
const bonuses = [
|
||||||
totalATK > 0 && `+${totalATK} ATK${forgeBonusATK > 0 ? ` (${item.attackBonus}+${forgeBonusATK})` : ''}`,
|
totalATK > 0 && `+${totalATK} ATK${forgeBonusATK > 0 ? ` (${item.attackBonus}+${forgeBonusATK})` : ''}`,
|
||||||
|
|||||||
@@ -153,7 +153,13 @@ export class ShopService {
|
|||||||
if (!charItem) throw new NotFoundException('Item non trouvé dans l\'inventaire');
|
if (!charItem) throw new NotFoundException('Item non trouvé dans l\'inventaire');
|
||||||
if (charItem.equipped) throw new BadRequestException('Déséquipez l\'item avant de le vendre');
|
if (charItem.equipped) throw new BadRequestException('Déséquipez l\'item avant de le vendre');
|
||||||
|
|
||||||
const sellPrice = Math.floor(charItem.item.buyPrice * SELL_RATIO);
|
// Prix de vente = base + investissement forge (coûts cumulés * 50%)
|
||||||
|
const FORGE_GOLD_COST: Record<number, number> = { 1: 50, 2: 100, 3: 250, 4: 500, 5: 1000 };
|
||||||
|
let forgeInvestment = 0;
|
||||||
|
for (let i = 1; i <= charItem.forgeLevel; i++) {
|
||||||
|
forgeInvestment += FORGE_GOLD_COST[i] ?? 0;
|
||||||
|
}
|
||||||
|
const sellPrice = Math.floor(charItem.item.buyPrice * SELL_RATIO + forgeInvestment * 0.5);
|
||||||
|
|
||||||
const char = await manager
|
const char = await manager
|
||||||
.getRepository(Character)
|
.getRepository(Character)
|
||||||
|
|||||||
Reference in New Issue
Block a user