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
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:
@@ -11,6 +11,7 @@ import { CraftPage } from './pages/CraftPage';
|
||||
import { ForgePage } from './pages/ForgePage';
|
||||
import { QuestPage } from './pages/QuestPage';
|
||||
import { AchievementsPage } from './pages/AchievementsPage';
|
||||
import { ShopPage } from './pages/ShopPage';
|
||||
|
||||
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="/forge" element={<ProtectedLayout><ForgePage /></ProtectedLayout>} />
|
||||
<Route path="/achievements" element={<ProtectedLayout><AchievementsPage /></ProtectedLayout>} />
|
||||
<Route path="/shop" element={<ProtectedLayout><ShopPage /></ProtectedLayout>} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
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';
|
||||
|
||||
const NAV = [
|
||||
@@ -10,6 +10,7 @@ const NAV = [
|
||||
{ to: '/inventory', icon: Package, label: 'Inventaire' },
|
||||
{ to: '/craft', icon: Hammer, label: 'Artisanat' },
|
||||
{ to: '/forge', icon: Shield, label: 'Forge' },
|
||||
{ to: '/shop', icon: ShoppingBag, label: 'Boutique' },
|
||||
{ to: '/achievements', icon: Trophy, label: 'Succès' },
|
||||
];
|
||||
|
||||
|
||||
163
frontend/src/pages/ShopPage.tsx
Normal file
163
frontend/src/pages/ShopPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { CommunityModule } from './community/community.module';
|
||||
import { HallOfFameModule } from './halloffame/halloffame.module';
|
||||
import { ProfileModule } from './profile/profile.module';
|
||||
import { QuestModule } from './quest/quest.module';
|
||||
import { ShopModule } from './shop/shop.module';
|
||||
import { HealthController } from './common/health.controller';
|
||||
|
||||
@Module({
|
||||
@@ -59,6 +60,7 @@ import { HealthController } from './common/health.controller';
|
||||
HallOfFameModule,
|
||||
ProfileModule,
|
||||
QuestModule,
|
||||
ShopModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
84
src/database/zones-seed.ts
Normal file
84
src/database/zones-seed.ts
Normal 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`);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
|
||||
|
||||
export type ItemType = 'weapon' | 'armor';
|
||||
export type ItemType = 'weapon' | 'armor' | 'consumable';
|
||||
export type ItemRarity = 'common' | 'rare' | 'epic' | 'legendary';
|
||||
|
||||
@Entity('items')
|
||||
@@ -40,4 +40,13 @@ export class Item {
|
||||
|
||||
@Column({ name: 'vitalite_bonus', default: 0 })
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -39,4 +39,7 @@ export class Monster {
|
||||
|
||||
@Column({ name: 'drop_material_id', type: 'varchar', nullable: true })
|
||||
dropMaterialId: string | null;
|
||||
|
||||
@Column({ name: 'zone', type: 'varchar', length: 50, default: 'marais' })
|
||||
zone: string;
|
||||
}
|
||||
|
||||
42
src/shop/shop.controller.ts
Normal file
42
src/shop/shop.controller.ts
Normal 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
18
src/shop/shop.module.ts
Normal 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
136
src/shop/shop.service.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user