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:
@@ -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