From 1ffde61f97f5fea22f0ac2c12ed3966e72e1e288 Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Tue, 24 Mar 2026 17:46:21 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20boutique=20+=20zones=20(=C3=A9gouts,=20?= =?UTF-8?q?d=C3=A9sert)=20+=2010=20monstres=20+=2014=20items=20+=20potions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/App.tsx | 2 + frontend/src/components/Layout.tsx | 3 +- frontend/src/pages/ShopPage.tsx | 163 +++++++++++++++++++++++++++++ src/app.module.ts | 2 + src/database/zones-seed.ts | 84 +++++++++++++++ src/item/item.entity.ts | 11 +- src/monster/monster.entity.ts | 3 + src/shop/shop.controller.ts | 42 ++++++++ src/shop/shop.module.ts | 18 ++++ src/shop/shop.service.ts | 136 ++++++++++++++++++++++++ 10 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/ShopPage.tsx create mode 100644 src/database/zones-seed.ts create mode 100644 src/shop/shop.controller.ts create mode 100644 src/shop/shop.module.ts create mode 100644 src/shop/shop.service.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6ae75d2..7269022 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> ); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 60e829a..206d957 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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' }, ]; diff --git a/frontend/src/pages/ShopPage.tsx b/frontend/src/pages/ShopPage.tsx new file mode 100644 index 0000000..6f674ab --- /dev/null +++ b/frontend/src/pages/ShopPage.tsx @@ -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 = { + common: '#9ca3af', + rare: '#5ba4f5', + epic: '#a78bfa', + legendary: '#f4c94e', +}; + +const TYPE_EMOJI: Record = { + weapon: '⚔️', + armor: '🛡️', + consumable: '🧪', +}; + +const ZONE_LABELS: Record = { + 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 ( +
+
+ {TYPE_EMOJI[item.type] ?? '📦'} +
+
+ {item.name} + + {item.rarity} + +
+ {item.description &&

{item.description}

} + +
+ {item.attackBonus > 0 && +{item.attackBonus} ATK} + {item.defenseBonus > 0 && +{item.defenseBonus} DEF} + {item.type === 'consumable' && +50% PV} + {item.minLevel > 1 && Niv. {item.minLevel}+} +
+
+ +
+
+ {item.buyPrice} +
+ + {!item.levelOk &&
Niv. {item.minLevel} requis
} +
+
+
+ ); +} + +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('/shop'), + }); + + const buyMut = useMutation({ + mutationFn: (itemId: string) => api.post(`/shop/buy/${itemId}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['character'] }); + qc.invalidateQueries({ queryKey: ['shop'] }); + qc.invalidateQueries({ queryKey: ['inventory'] }); + }, + }); + + if (isLoading) return
Chargement…
; + + // Group by zone + const zones = new Map(); + 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 ( +
+
+

+ Boutique +

+ {char && ( + + {char.gold} or + + )} +
+ + {buyMut.isSuccess && ( +
+ {(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)` + } +
+ )} + {buyMut.isError && ( +
{(buyMut.error as Error).message}
+ )} + + {sortedZones.map(([zone, items]) => ( +
+

+ {zone === 'general' ? '🧪 Consommables' : ZONE_LABELS[zone] ?? zone} +

+
+ {items.map(item => ( + buyMut.mutate(item.id)} + buying={buyMut.isPending} + /> + ))} +
+
+ ))} +
+ ); +} diff --git a/src/app.module.ts b/src/app.module.ts index ffce419..2fbc8ad 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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], }) diff --git a/src/database/zones-seed.ts b/src/database/zones-seed.ts new file mode 100644 index 0000000..817e700 --- /dev/null +++ b/src/database/zones-seed.ts @@ -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`); +} diff --git a/src/item/item.entity.ts b/src/item/item.entity.ts index 0905ded..29c464a 100644 --- a/src/item/item.entity.ts +++ b/src/item/item.entity.ts @@ -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; } diff --git a/src/monster/monster.entity.ts b/src/monster/monster.entity.ts index a6bb341..ab1b074 100644 --- a/src/monster/monster.entity.ts +++ b/src/monster/monster.entity.ts @@ -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; } diff --git a/src/shop/shop.controller.ts b/src/shop/shop.controller.ts new file mode 100644 index 0000000..3e4f90b --- /dev/null +++ b/src/shop/shop.controller.ts @@ -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, + ) {} + + @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 { + 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; + } +} diff --git a/src/shop/shop.module.ts b/src/shop/shop.module.ts new file mode 100644 index 0000000..3c108b7 --- /dev/null +++ b/src/shop/shop.module.ts @@ -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 {} diff --git a/src/shop/shop.service.ts b/src/shop/shop.service.ts new file mode 100644 index 0000000..1bcff40 --- /dev/null +++ b/src/shop/shop.service.ts @@ -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, + @InjectRepository(CharacterItem) + private readonly charItemRepo: Repository, + @InjectRepository(Character) + private readonly characterRepo: Repository, + 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, + }; + }); + } +}