feat: boutique + zones (égouts, désert) + 10 monstres + 14 items + potions
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s

Shop module: GET /api/shop, POST /api/shop/buy/:id, POST /api/shop/sell/:id
Potions: achat instantané, heal 50% HP, pas d'inventaire.
Items: buyPrice + minLevel + zone ajoutés à l'entité.
12 équipements (4 par zone: marais/égouts/désert) + 2 potions.

Monstres: zone field ajouté, 10 nouveaux monstres:
  Égouts (lv4-10): Rat, Slime, Araignée, Crocodile, Roi des Rats
  Désert (lv8-15): Scorpion, Vautour, Momie, Ver des Sables, Sphinx

Frontend: page /shop groupée par zone, rarity colors, achat/vente.
Sidebar: icône ShoppingBag pour la boutique.
This commit is contained in:
2026-03-24 17:46:21 +01:00
parent 4d254692b0
commit 1ffde61f97
10 changed files with 462 additions and 2 deletions

View File

@@ -11,6 +11,7 @@ import { CraftPage } from './pages/CraftPage';
import { ForgePage } from './pages/ForgePage'; import { ForgePage } from './pages/ForgePage';
import { QuestPage } from './pages/QuestPage'; import { QuestPage } from './pages/QuestPage';
import { AchievementsPage } from './pages/AchievementsPage'; import { AchievementsPage } from './pages/AchievementsPage';
import { ShopPage } from './pages/ShopPage';
const qc = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } } }); const qc = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } } });
@@ -37,6 +38,7 @@ function AppRoutes() {
<Route path="/craft" element={<ProtectedLayout><CraftPage /></ProtectedLayout>} /> <Route path="/craft" element={<ProtectedLayout><CraftPage /></ProtectedLayout>} />
<Route path="/forge" element={<ProtectedLayout><ForgePage /></ProtectedLayout>} /> <Route path="/forge" element={<ProtectedLayout><ForgePage /></ProtectedLayout>} />
<Route path="/achievements" element={<ProtectedLayout><AchievementsPage /></ProtectedLayout>} /> <Route path="/achievements" element={<ProtectedLayout><AchievementsPage /></ProtectedLayout>} />
<Route path="/shop" element={<ProtectedLayout><ShopPage /></ProtectedLayout>} />
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>
); );

View File

@@ -1,6 +1,6 @@
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { Swords, Package, Hammer, User, LogOut, Shield, Scroll, Trophy } from 'lucide-react'; import { Swords, Package, Hammer, User, LogOut, Shield, Scroll, Trophy, ShoppingBag } from 'lucide-react';
import { HudBar } from './HudBar'; import { HudBar } from './HudBar';
const NAV = [ const NAV = [
@@ -10,6 +10,7 @@ const NAV = [
{ to: '/inventory', icon: Package, label: 'Inventaire' }, { to: '/inventory', icon: Package, label: 'Inventaire' },
{ to: '/craft', icon: Hammer, label: 'Artisanat' }, { to: '/craft', icon: Hammer, label: 'Artisanat' },
{ to: '/forge', icon: Shield, label: 'Forge' }, { to: '/forge', icon: Shield, label: 'Forge' },
{ to: '/shop', icon: ShoppingBag, label: 'Boutique' },
{ to: '/achievements', icon: Trophy, label: 'Succès' }, { to: '/achievements', icon: Trophy, label: 'Succès' },
]; ];

View File

@@ -0,0 +1,163 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { characterApi } from '../api/endpoints';
import { api } from '../api/client';
import { Coins, ShoppingBag, Sword, Shield, Heart } from 'lucide-react';
const RARITY_COLORS: Record<string, string> = {
common: '#9ca3af',
rare: '#5ba4f5',
epic: '#a78bfa',
legendary: '#f4c94e',
};
const TYPE_EMOJI: Record<string, string> = {
weapon: '⚔️',
armor: '🛡️',
consumable: '🧪',
};
const ZONE_LABELS: Record<string, string> = {
marais: '🌿 Marais',
egouts: '🕳️ Égouts',
desert: '🏜️ Désert',
};
interface ShopItem {
id: string;
name: string;
description: string | null;
type: string;
rarity: string;
attackBonus: number;
defenseBonus: number;
buyPrice: number;
minLevel: number;
zone: string | null;
affordable: boolean;
levelOk: boolean;
sellPrice: number;
}
function ShopItemCard({ item, onBuy, buying }: { item: ShopItem; onBuy: () => void; buying: boolean }) {
const canBuy = item.affordable && item.levelOk;
const rarityColor = RARITY_COLORS[item.rarity] ?? '#9ca3af';
return (
<div className="card" style={{ padding: '0.75rem 1rem', opacity: item.levelOk ? 1 : 0.5 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<span style={{ fontSize: 24 }}>{TYPE_EMOJI[item.type] ?? '📦'}</span>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<span style={{ fontWeight: 700, fontSize: 13, color: rarityColor }}>{item.name}</span>
<span style={{ fontSize: 9, padding: '1px 5px', borderRadius: 4, background: rarityColor + '22', color: rarityColor, textTransform: 'uppercase' }}>
{item.rarity}
</span>
</div>
{item.description && <p style={{ margin: '0 0 4px', fontSize: 11, color: '#6b7a99' }}>{item.description}</p>}
<div style={{ display: 'flex', gap: 12, fontSize: 11, color: '#6b7a99' }}>
{item.attackBonus > 0 && <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}><Sword size={10} color="#f4c94e" /> +{item.attackBonus} ATK</span>}
{item.defenseBonus > 0 && <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}><Shield size={10} color="#5ba4f5" /> +{item.defenseBonus} DEF</span>}
{item.type === 'consumable' && <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}><Heart size={10} color="#e84040" /> +50% PV</span>}
{item.minLevel > 1 && <span>Niv. {item.minLevel}+</span>}
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: item.affordable ? '#f4c94e' : '#e84040', display: 'flex', alignItems: 'center', gap: 4, justifyContent: 'flex-end' }}>
<Coins size={12} /> {item.buyPrice}
</div>
<button
className={canBuy ? 'btn btn-gold' : 'btn btn-ghost'}
style={{ marginTop: 4, fontSize: 11, padding: '0.2rem 0.6rem', opacity: canBuy ? 1 : 0.5 }}
disabled={!canBuy || buying}
onClick={onBuy}
>
{buying ? '...' : item.type === 'consumable' ? 'Utiliser' : 'Acheter'}
</button>
{!item.levelOk && <div style={{ fontSize: 9, color: '#e84040', marginTop: 2 }}>Niv. {item.minLevel} requis</div>}
</div>
</div>
</div>
);
}
export function ShopPage() {
const qc = useQueryClient();
const { data: char } = useQuery({ queryKey: ['character'], queryFn: characterApi.me });
const { data: catalogue, isLoading } = useQuery({
queryKey: ['shop'],
queryFn: () => api.get<ShopItem[]>('/shop'),
});
const buyMut = useMutation({
mutationFn: (itemId: string) => api.post<any>(`/shop/buy/${itemId}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['shop'] });
qc.invalidateQueries({ queryKey: ['inventory'] });
},
});
if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement</div>;
// Group by zone
const zones = new Map<string, ShopItem[]>();
for (const item of (catalogue ?? [])) {
const zone = item.zone ?? 'general';
const list = zones.get(zone) ?? [];
list.push(item);
zones.set(zone, list);
}
// Order: general first, then by zone
const zoneOrder = ['general', 'marais', 'egouts', 'desert'];
const sortedZones = Array.from(zones.entries()).sort((a, b) =>
zoneOrder.indexOf(a[0]) - zoneOrder.indexOf(b[0])
);
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ margin: 0, color: '#f4c94e', fontSize: 20 }}>
<ShoppingBag size={18} style={{ display: 'inline', marginRight: 8 }} />Boutique
</h2>
{char && (
<span style={{ fontSize: 14, fontWeight: 700, color: '#f4c94e', display: 'flex', alignItems: 'center', gap: 6 }}>
<Coins size={14} /> {char.gold} or
</span>
)}
</div>
{buyMut.isSuccess && (
<div className="card card-gold" style={{ marginBottom: '1rem', padding: '0.5rem 1rem', fontSize: 13, textAlign: 'center' }}>
{(buyMut.data as any)?.type === 'consumable'
? `🧪 ${(buyMut.data as any)?.item} utilisé ! +${(buyMut.data as any)?.effect?.healed} PV`
: `${(buyMut.data as any)?.item} acheté ! (-${(buyMut.data as any)?.goldSpent} or)`
}
</div>
)}
{buyMut.isError && (
<div style={{ marginBottom: '1rem', color: '#e84040', fontSize: 12 }}>{(buyMut.error as Error).message}</div>
)}
{sortedZones.map(([zone, items]) => (
<div key={zone} style={{ marginBottom: '1.5rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>
{zone === 'general' ? '🧪 Consommables' : ZONE_LABELS[zone] ?? zone}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{items.map(item => (
<ShopItemCard
key={item.id}
item={item}
onBuy={() => buyMut.mutate(item.id)}
buying={buyMut.isPending}
/>
))}
</div>
</div>
))}
</div>
);
}

View File

@@ -18,6 +18,7 @@ import { CommunityModule } from './community/community.module';
import { HallOfFameModule } from './halloffame/halloffame.module'; import { HallOfFameModule } from './halloffame/halloffame.module';
import { ProfileModule } from './profile/profile.module'; import { ProfileModule } from './profile/profile.module';
import { QuestModule } from './quest/quest.module'; import { QuestModule } from './quest/quest.module';
import { ShopModule } from './shop/shop.module';
import { HealthController } from './common/health.controller'; import { HealthController } from './common/health.controller';
@Module({ @Module({
@@ -59,6 +60,7 @@ import { HealthController } from './common/health.controller';
HallOfFameModule, HallOfFameModule,
ProfileModule, ProfileModule,
QuestModule, QuestModule,
ShopModule,
], ],
controllers: [HealthController], controllers: [HealthController],
}) })

View File

@@ -0,0 +1,84 @@
import { DataSource } from 'typeorm';
// --- MONSTRES PAR ZONE ---
const MONSTERS = [
// Marais (existants — juste ajouter zone)
{ name: 'Têtard Vase', zone: 'marais' },
{ name: 'Grenouille Boueuse', zone: 'marais' },
{ name: 'Serpent des Marais', zone: 'marais' },
{ name: 'Champi Vénéneux', zone: 'marais' },
{ name: 'Golem de Boue', zone: 'marais' },
// Égouts (nouveau)
{ name: 'Rat d\'Égout', zone: 'egouts', minLevel: 4, maxLevel: 6, hp: 55, attack: 9, defense: 2, attackType: 'melee', xpReward: 18, goldMin: 8, goldMax: 18 },
{ name: 'Slime Toxique', zone: 'egouts', minLevel: 5, maxLevel: 7, hp: 70, attack: 7, defense: 4, attackType: 'magic', xpReward: 22, goldMin: 10, goldMax: 22 },
{ name: 'Araignée Géante', zone: 'egouts', minLevel: 5, maxLevel: 8, hp: 85, attack: 12, defense: 3, attackType: 'ranged', xpReward: 28, goldMin: 12, goldMax: 28 },
{ name: 'Crocodile', zone: 'egouts', minLevel: 6, maxLevel: 9, hp: 120, attack: 15, defense: 5, attackType: 'melee', xpReward: 38, goldMin: 18, goldMax: 40 },
{ name: 'Roi des Rats', zone: 'egouts', minLevel: 7, maxLevel: 10, hp: 180, attack: 18, defense: 7, attackType: 'melee', xpReward: 60, goldMin: 30, goldMax: 70 },
// Désert (nouveau)
{ name: 'Scorpion', zone: 'desert', minLevel: 8, maxLevel: 11, hp: 100, attack: 14, defense: 6, attackType: 'melee', xpReward: 35, goldMin: 15, goldMax: 35 },
{ name: 'Vautour', zone: 'desert', minLevel: 9, maxLevel: 12, hp: 80, attack: 16, defense: 3, attackType: 'ranged', xpReward: 40, goldMin: 18, goldMax: 40 },
{ name: 'Momie', zone: 'desert', minLevel: 10, maxLevel: 13, hp: 150, attack: 13, defense: 8, attackType: 'magic', xpReward: 48, goldMin: 22, goldMax: 50 },
{ name: 'Ver des Sables', zone: 'desert', minLevel: 11, maxLevel: 14, hp: 200, attack: 20, defense: 6, attackType: 'melee', xpReward: 55, goldMin: 30, goldMax: 65 },
{ name: 'Sphinx', zone: 'desert', minLevel: 12, maxLevel: 15, hp: 280, attack: 24, defense: 10, attackType: 'magic', xpReward: 80, goldMin: 50, goldMax: 120 },
];
// --- ITEMS BOUTIQUE ---
const ITEMS = [
// Marais — starter
{ name: 'Épée rouillée', type: 'weapon', rarity: 'common', attackBonus: 3, defenseBonus: 0, buyPrice: 30, minLevel: 1, zone: 'marais', description: 'Une lame usée mais encore tranchante.' },
{ name: 'Bouclier de bois', type: 'armor', rarity: 'common', attackBonus: 0, defenseBonus: 2, buyPrice: 25, minLevel: 1, zone: 'marais', description: 'Quelques planches assemblées à la va-vite.' },
{ name: 'Lame des marais', type: 'weapon', rarity: 'common', attackBonus: 6, defenseBonus: 0, buyPrice: 80, minLevel: 3, zone: 'marais', description: 'Forgée dans la boue des marais, étrangement solide.' },
{ name: 'Cotte de mailles', type: 'armor', rarity: 'common', attackBonus: 0, defenseBonus: 5, buyPrice: 70, minLevel: 3, zone: 'marais', description: 'Protection basique mais fiable.' },
// Égouts — mid game
{ name: 'Dague empoisonnée', type: 'weapon', rarity: 'rare', attackBonus: 9, defenseBonus: 0, buyPrice: 200, minLevel: 5, zone: 'egouts', description: 'La lame suinte d\'un liquide verdâtre.' },
{ name: 'Armure de cuir clouté', type: 'armor', rarity: 'rare', attackBonus: 0, defenseBonus: 7, buyPrice: 180, minLevel: 5, zone: 'egouts', description: 'Renforcée avec des clous récupérés dans les égouts.' },
{ name: 'Épée de guerre', type: 'weapon', rarity: 'rare', attackBonus: 13, defenseBonus: 0, buyPrice: 400, minLevel: 7, zone: 'egouts', description: 'Une lame imposante qui fait trembler les rats.' },
{ name: 'Armure de fer', type: 'armor', rarity: 'rare', attackBonus: 0, defenseBonus: 10, buyPrice: 350, minLevel: 7, zone: 'egouts', description: 'Lourde mais résistante.' },
// Désert — late game
{ name: 'Cimeterre du désert', type: 'weapon', rarity: 'epic', attackBonus: 18, defenseBonus: 0, buyPrice: 800, minLevel: 10, zone: 'desert', description: 'Lame courbe forgée dans le sable brûlant.' },
{ name: 'Armure de sable', type: 'armor', rarity: 'epic', attackBonus: 0, defenseBonus: 14, buyPrice: 700, minLevel: 10, zone: 'desert', description: 'Enchantée pour dévier les coups du vent.' },
{ name: 'Lame du Sphinx', type: 'weapon', rarity: 'epic', attackBonus: 25, defenseBonus: 0, buyPrice: 1500, minLevel: 13, zone: 'desert', description: 'Seuls les plus dignes peuvent la manier.' },
{ name: 'Armure du Pharaon', type: 'armor', rarity: 'epic', attackBonus: 0, defenseBonus: 18, buyPrice: 1300, minLevel: 13, zone: 'desert', description: 'Dorée et ancienne, elle irradie de puissance.' },
// Potions — consommables
{ name: 'Potion de soin', type: 'consumable', rarity: 'common', attackBonus: 0, defenseBonus: 0, buyPrice: 15, minLevel: 1, zone: null, description: 'Restaure 50% des PV.' },
{ name: 'Grande potion de soin', type: 'consumable', rarity: 'rare', attackBonus: 0, defenseBonus: 0, buyPrice: 40, minLevel: 5, zone: null, description: 'Restaure 50% des PV. (même effet, plus cher — placeholder pour futur)' },
];
export async function seedZones(dataSource: DataSource) {
// Update existing monsters with zone
for (const m of MONSTERS) {
if (!('hp' in m)) {
// Existing monster — just set zone
await dataSource.query('UPDATE monsters SET zone = ? WHERE name = ?', [m.zone, m.name]);
} else {
// New monster
const existing = await dataSource.query('SELECT id FROM monsters WHERE name = ?', [m.name]);
if (existing.length === 0) {
await dataSource.query(
'INSERT INTO monsters (id, name, zone, min_level, max_level, hp, attack, defense, attack_type, xp_reward, gold_min, gold_max) VALUES (UUID(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[m.name, m.zone, (m as any).minLevel, (m as any).maxLevel, (m as any).hp, (m as any).attack, (m as any).defense, (m as any).attackType, (m as any).xpReward, (m as any).goldMin, (m as any).goldMax],
);
console.log(`+ ${m.name} (${m.zone})`);
}
}
}
// Seed items
const itemRepo = dataSource.getRepository('Item');
for (const item of ITEMS) {
const existing = await itemRepo.findOne({ where: { name: item.name } });
if (!existing) {
await itemRepo.save(itemRepo.create(item));
console.log(`+ ${item.name} (${item.type}, ${item.buyPrice}💰)`);
}
}
console.log(`${MONSTERS.filter(m => 'hp' in m).length} monstres + ${ITEMS.length} items seedés`);
}

View File

@@ -1,6 +1,6 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
export type ItemType = 'weapon' | 'armor'; export type ItemType = 'weapon' | 'armor' | 'consumable';
export type ItemRarity = 'common' | 'rare' | 'epic' | 'legendary'; export type ItemRarity = 'common' | 'rare' | 'epic' | 'legendary';
@Entity('items') @Entity('items')
@@ -40,4 +40,13 @@ export class Item {
@Column({ name: 'vitalite_bonus', default: 0 }) @Column({ name: 'vitalite_bonus', default: 0 })
vitaliteBonus: number; vitaliteBonus: number;
@Column({ name: 'buy_price', default: 0 })
buyPrice: number;
@Column({ name: 'min_level', default: 1 })
minLevel: number;
@Column({ name: 'zone', type: 'varchar', length: 50, nullable: true })
zone: string | null;
} }

View File

@@ -39,4 +39,7 @@ export class Monster {
@Column({ name: 'drop_material_id', type: 'varchar', nullable: true }) @Column({ name: 'drop_material_id', type: 'varchar', nullable: true })
dropMaterialId: string | null; dropMaterialId: string | null;
@Column({ name: 'zone', type: 'varchar', length: 50, default: 'marais' })
zone: string;
} }

View File

@@ -0,0 +1,42 @@
import { Controller, Get, Post, Param, Req, UseGuards, BadRequestException } from '@nestjs/common';
import { ShopService } from './shop.service';
import { AuthGuard } from '../auth/guards/auth.guard';
import { Request } from 'express';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Character } from '../character/entities/character.entity';
@Controller('shop')
@UseGuards(AuthGuard)
export class ShopController {
constructor(
private readonly shopService: ShopService,
@InjectRepository(Character)
private readonly characterRepo: Repository<Character>,
) {}
@Get()
async getCatalogue(@Req() req: Request) {
const char = await this.getCharacter(req);
return this.shopService.getCatalogue(char.id);
}
@Post('buy/:itemId')
async buy(@Param('itemId') itemId: string, @Req() req: Request) {
const char = await this.getCharacter(req);
return this.shopService.buy(itemId, char.id);
}
@Post('sell/:charItemId')
async sell(@Param('charItemId') charItemId: string, @Req() req: Request) {
const char = await this.getCharacter(req);
return this.shopService.sell(charItemId, char.id);
}
private async getCharacter(req: Request): Promise<Character> {
const user = (req as any).user;
const character = await this.characterRepo.findOne({ where: { userId: user.id } });
if (!character) throw new BadRequestException('Aucun personnage trouvé');
return character;
}
}

18
src/shop/shop.module.ts Normal file
View File

@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ShopService } from './shop.service';
import { ShopController } from './shop.controller';
import { Item } from '../item/item.entity';
import { CharacterItem } from '../item/character-item.entity';
import { Character } from '../character/entities/character.entity';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([Item, CharacterItem, Character]),
AuthModule,
],
controllers: [ShopController],
providers: [ShopService],
})
export class ShopModule {}

136
src/shop/shop.service.ts Normal file
View File

@@ -0,0 +1,136 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository, LessThanOrEqual } from 'typeorm';
import { Item } from '../item/item.entity';
import { CharacterItem } from '../item/character-item.entity';
import { Character } from '../character/entities/character.entity';
const SELL_RATIO = 0.4; // 40% du prix d'achat
const POTION_HEAL_RATIO = 0.5; // 50% HP max
@Injectable()
export class ShopService {
constructor(
@InjectRepository(Item)
private readonly itemRepo: Repository<Item>,
@InjectRepository(CharacterItem)
private readonly charItemRepo: Repository<CharacterItem>,
@InjectRepository(Character)
private readonly characterRepo: Repository<Character>,
private readonly dataSource: DataSource,
) {}
async getCatalogue(characterId: string) {
const char = await this.characterRepo.findOne({ where: { id: characterId } });
if (!char) throw new BadRequestException('Aucun personnage');
const items = await this.itemRepo.find({
where: { buyPrice: LessThanOrEqual(999999) },
order: { type: 'ASC', minLevel: 'ASC', buyPrice: 'ASC' },
});
return items
.filter(i => i.buyPrice > 0)
.map(i => ({
...i,
affordable: char.gold >= i.buyPrice,
levelOk: char.level >= i.minLevel,
sellPrice: Math.floor(i.buyPrice * SELL_RATIO),
}));
}
async buy(itemId: string, characterId: string) {
return this.dataSource.transaction(async (manager) => {
const item = await manager.getRepository(Item).findOne({ where: { id: itemId } });
if (!item) throw new NotFoundException('Item introuvable');
if (item.buyPrice <= 0) throw new BadRequestException('Item non achetable');
const char = await manager
.getRepository(Character)
.createQueryBuilder('c')
.setLock('pessimistic_write')
.where('c.id = :id', { id: characterId })
.getOne();
if (!char) throw new BadRequestException('Aucun personnage');
if (char.level < item.minLevel) {
throw new BadRequestException(`Niveau ${item.minLevel} requis`);
}
if (char.gold < item.buyPrice) {
throw new BadRequestException(`Or insuffisant (${char.gold}/${item.buyPrice})`);
}
// Consumable = effet immédiat, pas d'inventaire
if (item.type === 'consumable') {
char.gold -= item.buyPrice;
if (char.hpCurrent >= char.hpMax) {
throw new BadRequestException('PV déjà au maximum');
}
const hpBefore = char.hpCurrent;
char.hpCurrent = Math.min(char.hpMax, char.hpCurrent + Math.floor(char.hpMax * POTION_HEAL_RATIO));
await manager.save(char);
return {
bought: true,
item: item.name,
type: 'consumable',
goldSpent: item.buyPrice,
effect: { hpBefore, hpAfter: char.hpCurrent, healed: char.hpCurrent - hpBefore },
};
}
// Equipment = ajout à l'inventaire
char.gold -= item.buyPrice;
await manager.save(char);
const charItem = manager.getRepository(CharacterItem).create({
characterId,
itemId: item.id,
forgeLevel: 0,
equipped: false,
});
await manager.save(charItem);
return {
bought: true,
item: item.name,
type: item.type,
goldSpent: item.buyPrice,
charItemId: charItem.id,
};
});
}
async sell(charItemId: string, characterId: string) {
return this.dataSource.transaction(async (manager) => {
const charItem = await manager.getRepository(CharacterItem).findOne({
where: { id: charItemId, characterId },
relations: ['item'],
});
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');
const sellPrice = Math.floor(charItem.item.buyPrice * SELL_RATIO);
const char = await manager
.getRepository(Character)
.createQueryBuilder('c')
.setLock('pessimistic_write')
.where('c.id = :id', { id: characterId })
.getOne();
if (!char) throw new BadRequestException('Aucun personnage');
char.gold += sellPrice;
await manager.save(char);
await manager.remove(charItem);
return {
sold: true,
item: charItem.item.name,
goldEarned: sellPrice,
};
});
}
}