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

@@ -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],
})

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';
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;
}

View File

@@ -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;
}

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,
};
});
}
}