diff --git a/SPRINT3.md b/SPRINT3.md new file mode 100644 index 0000000..790dab6 --- /dev/null +++ b/SPRINT3.md @@ -0,0 +1,244 @@ +# TetaRdPG — Brief Sprint 3 + +> Statut : 🔄 En cours +> Objectif : Items + Inventaire + Artisanat (Craft) + Forge +> Stack : NestJS · PostgreSQL · TypeORM (synchronize dev) +> Prérequis : Sprint 2 livré ✅ + +--- + +## Scope Sprint 3 + +### ✅ In scope + +- Entité `items` (armes + armures) avec bonus stats +- Inventaire joueur (`character_items`) — possession + équipement actif +- Intégration combat : `player.attack` et `player.defense` depuis l'équipement équipé +- Entité `materials` + inventaire joueur (`character_materials`) — loot post-combat +- Entité `recipes` + ingrédients en jsonb +- Artisanat (`craft_jobs`) — lazy calc timer (même pattern que endurance) +- Forge — amélioration item niveau 1–5 avec risque croissant +- Seeds : 5 items de base, 5 matériaux, 3 recettes +- API : voir section dédiée + +### ❌ Out of scope + +- Boutique (achat/vente) — Sprint 4 +- Bonus de sets d'équipement — Sprint 4 +- TetardCoin (accélération craft, forge garantie) — sprint monétisation +- Twitch, PvP, guildes +- Frontend React + +--- + +## Décisions de design (game-designer) + +| Décision | Valeur | Justification | +|----------|--------|---------------| +| Slots équipement | weapon + armor | Simplifié Sprint 3 — casque/bottes Sprint 4 | +| Player.attack en combat | `char.weapon?.attackBonus ?? 0` | Remplace attack=0 Sprint 2 | +| Player.defense en combat | `char.armor?.defenseBonus ?? 0` | Remplace defense=0 Sprint 2 | +| Loot drop | 40% de chance après victoire | 1 matériau aléatoire parmi ceux du monstre | +| Craft timer | lazy calc : `startedAt + durationMs` | Même pattern endurance — zéro job schedulé | +| Forge risque | Niv.1–2 : 0% | 3 : 20% | 4 : 30% | 5 : 40% | GDD exact | +| Forge succès garanti | 12 TetardCoin (non implémenté Sprint 3) | Placeholder, champ `forcedSuccess` | +| Forge échec | item inchangé, pas de coût matériaux Sprint 3 | coût matériaux forge = Sprint 4 | +| Durée craft | court: 15s (dev) / long: 60s (dev) | Valeurs réelles en prod : 15min–2h | + +--- + +## Schéma DB + +### `items` +``` +id uuid PK +name varchar(100) +description text +type varchar(20) -- 'weapon' | 'armor' +rarity varchar(20) -- 'common' | 'rare' | 'epic' | 'legendary' +attack_bonus int default 0 +defense_bonus int default 0 +force_bonus int default 0 +agilite_bonus int default 0 +intelligence_bonus int default 0 +chance_bonus int default 0 +vitalite_bonus int default 0 +``` + +### `character_items` +``` +id uuid PK +character_id uuid FK characters +item_id uuid FK items +forge_level int default 0 -- 0 = non forgé +equipped boolean default false +acquired_at timestamp +``` + +### `materials` +``` +id uuid PK +name varchar(100) +description text +rarity varchar(20) +``` + +### `character_materials` +``` +id uuid PK +character_id uuid FK characters +material_id uuid FK materials +quantity int default 0 +``` + +### `recipes` +``` +id uuid PK +name varchar(100) +result_item_id uuid FK items +craft_duration_seconds int +endurance_cost int +ingredients jsonb -- [{ materialId, quantity }] +``` + +### `craft_jobs` +``` +id uuid PK +character_id uuid FK characters +recipe_id uuid FK recipes +started_at timestamp +completed_at timestamp -- lazy : startedAt + duration +collected boolean default false +``` + +--- + +## Seeds + +### Items (5) + +| Nom | Type | Rareté | Attack | Defense | Notes | +|-----|------|--------|--------|---------|-------| +| Bâton de Roseau | weapon | common | 3 | 0 | Arme de départ | +| Dague Rouillée | weapon | common | 5 | 0 | | +| Épée Courte | weapon | rare | 9 | 0 | | +| Gilet de Cuir | armor | common | 0 | 3 | | +| Cotte de Mailles | armor | rare | 0 | 7 | | + +### Matériaux (5) + +| Nom | Rareté | Sources | +|-----|--------|---------| +| Bave de Têtard | common | Têtard Vase | +| Écailles de Grenouille | common | Grenouille Boueuse | +| Venin de Serpent | rare | Serpent des Marais | +| Spores Vénéneuses | rare | Champi Vénéneux | +| Fragment de Boue | common | Golem de Boue | + +### Recettes (3) + +| Nom | Résultat | Durée (dev) | Endurance | Ingrédients | +|-----|----------|-------------|-----------|-------------| +| Forge Bâton Renforcé | Bâton de Roseau+? | 15s | 5 | 2× Bave de Têtard | +| Craft Dague | Dague Rouillée | 15s | 8 | 3× Bave + 1× Écaille | +| Craft Gilet de Cuir | Gilet de Cuir | 30s | 10 | 3× Écaille + 2× Fragment | + +--- + +## API Sprint 3 + +``` +# Items +GET /api/items → liste tous les items (catalogue) +GET /api/items/inventory → inventaire du personnage connecté +POST /api/items/equip/:itemId → équiper un item (character_items.id) +POST /api/items/unequip/:slot → déséquiper un slot (weapon|armor) + +# Materials +GET /api/materials → catalogue matériaux +GET /api/materials/inventory → matériaux du personnage connecté + +# Craft +GET /api/craft/recipes → liste recettes +POST /api/craft/start → { recipeId } → lance le craft +GET /api/craft/active → craft en cours (lazy status) +POST /api/craft/collect/:jobId → collecter si terminé + +# Forge +POST /api/forge/upgrade → { characterItemId } → tente amélioration +``` + +--- + +## Architecture modules + +``` +src/ +├── item/ +│ ├── item.entity.ts +│ ├── character-item.entity.ts +│ ├── item.module.ts +│ ├── item.service.ts +│ └── item.controller.ts +├── material/ +│ ├── material.entity.ts +│ ├── character-material.entity.ts +│ ├── material.module.ts +│ ├── material.service.ts +│ └── material.controller.ts +├── craft/ +│ ├── recipe.entity.ts +│ ├── craft-job.entity.ts +│ ├── craft.module.ts +│ ├── craft.service.ts +│ └── craft.controller.ts +├── forge/ +│ ├── forge.module.ts +│ ├── forge.service.ts +│ └── forge.controller.ts +└── database/ + └── items-seed.ts +``` + +--- + +## Intégration combat (CombatEngine) + +```typescript +// Dans CombatService.startCombat() — charger l'équipement +const weaponBonus = char.equippedWeapon?.item.attackBonus ?? 0; +const armorBonus = char.equippedArmor?.item.defenseBonus ?? 0; + +const playerStats: CombatantStats = { + ... + attack: weaponBonus, // était 0 Sprint 2 + defense: armorBonus, // était 0 Sprint 2 +}; +``` + +Loot post-victoire : +```typescript +if (result.winner === 'player' && Math.random() < 0.4) { + // créditer 1 matériau aléatoire dans character_materials +} +``` + +--- + +## Critères de validation integrator + +- [ ] `GET /api/items` → 5 items seedés +- [ ] `GET /api/materials` → 5 matériaux seedés +- [ ] `POST /api/items/equip/:id` → item équipé, character mis à jour +- [ ] Combat avec arme équipée → player.attack = weaponBonus +- [ ] Victoire combat → chance de loot matériau (40%) +- [ ] `GET /api/materials/inventory` → quantité augmentée après loot +- [ ] `GET /api/craft/recipes` → 3 recettes disponibles +- [ ] `POST /api/craft/start` → job créé, endurance déduite +- [ ] `GET /api/craft/active` → status `pending` | `ready` | `none` +- [ ] `POST /api/craft/collect/:jobId` → item ajouté à l'inventaire +- [ ] Collect avant fin → 400 "not ready yet" +- [ ] `POST /api/forge/upgrade` → niveau 1 : succès garanti +- [ ] Forge niveau 3 → 20% chance échec (matériaux déduits, forgeLvl inchangé) +- [ ] Sans cookie → 401 sur toutes les routes protégées +- [ ] Endurance insuffisante pour craft → 400 diff --git a/package.json b/package.json index 0afe56b..67919f9 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "start:prod": "node dist/main", "seed": "ts-node -r tsconfig-paths/register src/database/seed.ts", "seed:monsters": "ts-node -r tsconfig-paths/register src/database/monsters-seed.ts", + "seed:items": "ts-node -r tsconfig-paths/register src/database/items-seed.ts", "typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts" }, "dependencies": { diff --git a/src/app.module.ts b/src/app.module.ts index 961900a..4191ede 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,10 @@ import { AuthModule } from './auth/auth.module'; import { CharacterModule } from './character/character.module'; import { MonsterModule } from './monster/monster.module'; import { CombatModule } from './combat/combat.module'; +import { ItemModule } from './item/item.module'; +import { MaterialModule } from './material/material.module'; +import { CraftModule } from './craft/craft.module'; +import { ForgeModule } from './forge/forge.module'; import { HealthController } from './common/health.controller'; @Module({ @@ -35,6 +39,10 @@ import { HealthController } from './common/health.controller'; CharacterModule, MonsterModule, CombatModule, + ItemModule, + MaterialModule, + CraftModule, + ForgeModule, ], controllers: [HealthController], }) diff --git a/src/combat/combat.module.ts b/src/combat/combat.module.ts index 7cc4250..9810203 100644 --- a/src/combat/combat.module.ts +++ b/src/combat/combat.module.ts @@ -6,12 +6,16 @@ import { CombatService } from './combat.service'; import { CombatController } from './combat.controller'; import { MonsterModule } from '../monster/monster.module'; import { AuthModule } from '../auth/auth.module'; +import { ItemModule } from '../item/item.module'; +import { MaterialModule } from '../material/material.module'; @Module({ imports: [ TypeOrmModule.forFeature([Character, CombatLog]), MonsterModule, AuthModule, + ItemModule, + MaterialModule, ], controllers: [CombatController], providers: [CombatService], diff --git a/src/combat/combat.service.ts b/src/combat/combat.service.ts index 6bfab53..d336603 100644 --- a/src/combat/combat.service.ts +++ b/src/combat/combat.service.ts @@ -7,6 +7,8 @@ import { MonsterService } from '../monster/monster.service'; import { CombatLog } from './combat-log.entity'; import { StartCombatDto } from './dto/start-combat.dto'; import { User } from '../user/user.entity'; +import { ItemService } from '../item/item.service'; +import { MaterialService } from '../material/material.service'; import { resolveCombat, applyXpGain, @@ -27,6 +29,8 @@ export class CombatService { @InjectRepository(CombatLog) private readonly combatLogRepository: Repository, private readonly monsterService: MonsterService, + private readonly itemService: ItemService, + private readonly materialService: MaterialService, ) {} async startCombat(dto: StartCombatDto, user: User) { @@ -54,6 +58,16 @@ export class CombatService { throw new BadRequestException('Votre personnage est KO — récupérez d\'abord vos PV'); } + // Charger l'équipement actif du personnage + const equipped = await this.itemService.getEquippedItems(character.id); + const FORGE_BONUS_PER_LEVEL = 2; + const weaponAttack = equipped.weapon + ? equipped.weapon.item.attackBonus + equipped.weapon.forgeLevel * FORGE_BONUS_PER_LEVEL + : 0; + const armorDefense = equipped.armor + ? equipped.armor.item.defenseBonus + equipped.armor.forgeLevel * FORGE_BONUS_PER_LEVEL + : 0; + // Construire les stats des combattants const playerStats: CombatantStats = { name: character.name, @@ -63,8 +77,8 @@ export class CombatService { agilite: character.agilite, intelligence: character.intelligence, chance: character.chance, - attack: 0, // pas d'arme Sprint 2 - defense: 0, // pas d'armure Sprint 2 + attack: weaponAttack, + defense: armorDefense, attackType: dto.attackType, }; @@ -118,6 +132,13 @@ export class CombatService { character.lastEnduranceTs = new Date(); await this.characterRepository.save(character); + // Loot matériaux — 40% de chance après victoire si le monstre a un drop_material_id + let lootMaterial: { name: string; quantity: number } | null = null; + if (result.winner === 'player' && monster.dropMaterialId && Math.random() < 0.4) { + await this.materialService.addMaterial(character.id, monster.dropMaterialId, 1); + lootMaterial = { name: 'matériau', quantity: 1 }; + } + // Persister le log const combatLog = this.combatLogRepository.create({ characterId: character.id, @@ -144,6 +165,10 @@ export class CombatService { if (goldLost > 0) summaryParts.push(`−${goldLost} Or perdu.`); } + if (lootMaterial) { + summaryParts.push(`Loot : 1 matériau obtenu !`); + } + return { winner: result.winner, rounds: result.rounds, @@ -155,6 +180,7 @@ export class CombatService { levelUp: levelUpData.levelsGained > 0, newLevel: levelUpData.newLevel, statPointsGained: levelUpData.statPointsGained, + loot: lootMaterial, }, character: { level: character.level, diff --git a/src/craft/craft-job.entity.ts b/src/craft/craft-job.entity.ts new file mode 100644 index 0000000..61f62fe --- /dev/null +++ b/src/craft/craft-job.entity.ts @@ -0,0 +1,39 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; +import { Character } from '../character/entities/character.entity'; +import { Recipe } from './recipe.entity'; + +@Entity('craft_jobs') +export class CraftJob { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'character_id' }) + characterId: string; + + @ManyToOne(() => Character) + @JoinColumn({ name: 'character_id' }) + character: Character; + + @Column({ name: 'recipe_id' }) + recipeId: string; + + @ManyToOne(() => Recipe, { eager: true }) + @JoinColumn({ name: 'recipe_id' }) + recipe: Recipe; + + @CreateDateColumn({ name: 'started_at' }) + startedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamp' }) + completedAt: Date; + + @Column({ default: false }) + collected: boolean; +} diff --git a/src/craft/craft.controller.ts b/src/craft/craft.controller.ts new file mode 100644 index 0000000..bc76603 --- /dev/null +++ b/src/craft/craft.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, Post, Param, Body, UseGuards, Req } from '@nestjs/common'; +import { Request } from 'express'; +import { IsUUID } from 'class-validator'; +import { CraftService } from './craft.service'; +import { AuthGuard } from '../auth/guards/auth.guard'; +import { User } from '../user/user.entity'; + +class StartCraftDto { + @IsUUID() + recipeId: string; +} + +@Controller('craft') +export class CraftController { + constructor(private readonly craftService: CraftService) {} + + @Get('recipes') + findRecipes() { + return this.craftService.findAllRecipes(); + } + + @Post('start') + @UseGuards(AuthGuard) + start(@Body() dto: StartCraftDto, @Req() req: Request & { user: User }) { + return this.craftService.startCraft(dto.recipeId, req.user); + } + + @Get('active') + @UseGuards(AuthGuard) + getActive(@Req() req: Request & { user: User }) { + return this.craftService.getActiveJob(req.user); + } + + @Post('collect/:jobId') + @UseGuards(AuthGuard) + collect(@Param('jobId') jobId: string, @Req() req: Request & { user: User }) { + return this.craftService.collectCraft(jobId, req.user); + } +} diff --git a/src/craft/craft.module.ts b/src/craft/craft.module.ts new file mode 100644 index 0000000..a3d6cee --- /dev/null +++ b/src/craft/craft.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Recipe } from './recipe.entity'; +import { CraftJob } from './craft-job.entity'; +import { CraftService } from './craft.service'; +import { CraftController } from './craft.controller'; +import { AuthModule } from '../auth/auth.module'; +import { ItemModule } from '../item/item.module'; +import { MaterialModule } from '../material/material.module'; +import { Character } from '../character/entities/character.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Recipe, CraftJob, Character]), + AuthModule, + ItemModule, + MaterialModule, + ], + controllers: [CraftController], + providers: [CraftService], +}) +export class CraftModule {} diff --git a/src/craft/craft.service.ts b/src/craft/craft.service.ts new file mode 100644 index 0000000..306f1a4 --- /dev/null +++ b/src/craft/craft.service.ts @@ -0,0 +1,128 @@ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Recipe } from './recipe.entity'; +import { CraftJob } from './craft-job.entity'; +import { Character } from '../character/entities/character.entity'; +import { ItemService } from '../item/item.service'; +import { MaterialService } from '../material/material.service'; +import { User } from '../user/user.entity'; + +@Injectable() +export class CraftService { + constructor( + @InjectRepository(Recipe) + private readonly recipeRepository: Repository, + @InjectRepository(CraftJob) + private readonly craftJobRepository: Repository, + @InjectRepository(Character) + private readonly characterRepository: Repository, + private readonly itemService: ItemService, + private readonly materialService: MaterialService, + ) {} + + findAllRecipes() { + return this.recipeRepository.find({ order: { name: 'ASC' } }); + } + + async startCraft(recipeId: string, user: User) { + const char = await this.getCharacter(user); + const recipe = await this.recipeRepository.findOne({ where: { id: recipeId } }); + if (!recipe) throw new NotFoundException('Recette introuvable'); + + // Vérifier qu'aucun craft actif + const activeCraft = await this.craftJobRepository.findOne({ + where: { characterId: char.id, collected: false }, + }); + if (activeCraft) throw new BadRequestException('Un craft est déjà en cours'); + + // Calculer endurance actuelle (lazy pattern) + const elapsedMinutes = (Date.now() - char.lastEnduranceTs.getTime()) / 60_000; + const recharge = Math.floor(elapsedMinutes / 6); + const enduranceCurrent = Math.min(char.enduranceSaved + recharge, char.enduranceMax); + + if (enduranceCurrent < recipe.enduranceCost) { + throw new BadRequestException( + `Endurance insuffisante (${enduranceCurrent}/${recipe.enduranceCost} requis)`, + ); + } + + // Consommer les matériaux (vérifie la dispo et déduit) + await this.materialService.consumeMaterials(char.id, recipe.ingredients); + + // Déduire l'endurance + char.enduranceSaved = enduranceCurrent - recipe.enduranceCost; + char.lastEnduranceTs = new Date(); + await this.characterRepository.save(char); + + // Créer le job (lazy timer) + const startedAt = new Date(); + const completedAt = new Date(startedAt.getTime() + recipe.craftDurationSeconds * 1000); + const job = this.craftJobRepository.create({ + characterId: char.id, + recipeId: recipe.id, + startedAt, + completedAt, + collected: false, + }); + await this.craftJobRepository.save(job); + + return { + jobId: job.id, + recipe: recipe.name, + startedAt, + completedAt, + remainingSeconds: recipe.craftDurationSeconds, + status: 'pending', + }; + } + + async getActiveJob(user: User) { + const char = await this.getCharacter(user); + const job = await this.craftJobRepository.findOne({ + where: { characterId: char.id, collected: false }, + }); + if (!job) return { status: 'none' }; + + const now = new Date(); + const remaining = Math.max(0, Math.floor((job.completedAt.getTime() - now.getTime()) / 1000)); + return { + status: now >= job.completedAt ? 'ready' : 'pending', + jobId: job.id, + recipe: job.recipe.name, + completedAt: job.completedAt, + remainingSeconds: remaining, + }; + } + + async collectCraft(jobId: string, user: User) { + const char = await this.getCharacter(user); + const job = await this.craftJobRepository.findOne({ + where: { id: jobId, characterId: char.id, collected: false }, + }); + if (!job) throw new NotFoundException('Craft introuvable'); + + const remaining = Math.ceil((job.completedAt.getTime() - Date.now()) / 1000); + if (remaining > 0) { + throw new BadRequestException(`Craft non terminé — encore ${remaining}s`); + } + + // Ajouter l'item crafté à l'inventaire + const charItem = await this.itemService.addItemToInventory(char.id, job.recipe.resultItemId); + + job.collected = true; + await this.craftJobRepository.save(job); + + return { + collected: true, + item: charItem.item, + message: `${job.recipe.resultItem.name} ajouté à l'inventaire !`, + }; + } + + private async getCharacter(user: User): Promise { + const char = await this.characterRepository.findOne({ where: { userId: user.id } }); + if (!char) throw new BadRequestException('Aucun personnage trouvé'); + return char; + } +} diff --git a/src/craft/recipe.entity.ts b/src/craft/recipe.entity.ts new file mode 100644 index 0000000..f24a420 --- /dev/null +++ b/src/craft/recipe.entity.ts @@ -0,0 +1,32 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { Item } from '../item/item.entity'; + +export interface RecipeIngredient { + materialId: string; + quantity: number; +} + +@Entity('recipes') +export class Recipe { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 100 }) + name: string; + + @Column({ name: 'result_item_id' }) + resultItemId: string; + + @ManyToOne(() => Item, { eager: true }) + @JoinColumn({ name: 'result_item_id' }) + resultItem: Item; + + @Column({ name: 'craft_duration_seconds' }) + craftDurationSeconds: number; + + @Column({ name: 'endurance_cost' }) + enduranceCost: number; + + @Column({ type: 'jsonb' }) + ingredients: RecipeIngredient[]; +} diff --git a/src/database/items-seed.ts b/src/database/items-seed.ts new file mode 100644 index 0000000..0a5b7d9 --- /dev/null +++ b/src/database/items-seed.ts @@ -0,0 +1,144 @@ +import 'reflect-metadata'; +import { DataSource } from 'typeorm'; +import { Item } from '../item/item.entity'; +import { Material } from '../material/material.entity'; +import { Recipe } from '../craft/recipe.entity'; +import { Monster } from '../monster/monster.entity'; + +const dataSource = new DataSource({ + type: 'postgres', + url: process.env.DATABASE_URL ?? 'postgresql://tetardpg:password@localhost:5432/tetardpg', + entities: [Item, Material, Recipe, Monster], + synchronize: false, +}); + +const ITEMS = [ + { name: 'Bâton de Roseau', description: 'Un bâton taillé dans un roseau des marais.', type: 'weapon' as const, rarity: 'common' as const, attackBonus: 3 }, + { name: 'Dague Rouillée', description: 'Une dague usée mais encore tranchante.', type: 'weapon' as const, rarity: 'common' as const, attackBonus: 5 }, + { name: 'Épée Courte', description: 'Une épée courte bien équilibrée.', type: 'weapon' as const, rarity: 'rare' as const, attackBonus: 9 }, + { name: 'Gilet de Cuir', description: 'Un gilet de cuir tanné offrant une protection basique.', type: 'armor' as const, rarity: 'common' as const, defenseBonus: 3 }, + { name: 'Cotte de Mailles', description: 'Une cotte de mailles robuste.', type: 'armor' as const, rarity: 'rare' as const, defenseBonus: 7 }, +]; + +const MATERIALS = [ + { name: 'Bave de Têtard', description: 'Substance visqueuse sécrétée par les têtards vases.', rarity: 'common' as const }, + { name: 'Écailles de Grenouille', description: 'Écailles dures et brillantes de grenouilles boueuses.', rarity: 'common' as const }, + { name: 'Venin de Serpent', description: 'Venin concentré extrait d\'un serpent des marais.', rarity: 'rare' as const }, + { name: 'Spores Vénéneuses', description: 'Spores toxiques récoltées sur les champi vénéneux.', rarity: 'rare' as const }, + { name: 'Fragment de Boue', description: 'Éclat de boue cristallisée prélevé sur un golem.', rarity: 'common' as const }, +]; + +// Loot mapping : monster name → material name +const MONSTER_LOOT: Record = { + 'Têtard Vase': 'Bave de Têtard', + 'Grenouille Boueuse': 'Écailles de Grenouille', + 'Serpent des Marais': 'Venin de Serpent', + 'Champi Vénéneux': 'Spores Vénéneuses', + 'Golem de Boue': 'Fragment de Boue', +}; + +async function seed() { + await dataSource.initialize(); + console.log('DB connectée'); + + const itemRepo = dataSource.getRepository(Item); + const materialRepo = dataSource.getRepository(Material); + const recipeRepo = dataSource.getRepository(Recipe); + const monsterRepo = dataSource.getRepository(Monster); + + // Seed matériaux + const materialMap: Record = {}; + for (const data of MATERIALS) { + let mat = await materialRepo.findOne({ where: { name: data.name } }); + if (!mat) { + mat = await materialRepo.save(materialRepo.create(data)); + console.log(`✅ Matériau "${data.name}" seedé`); + } else { + console.log(`⏭ Matériau "${data.name}" déjà présent`); + } + materialMap[data.name] = mat.id; + } + + // Seed items + const itemMap: Record = {}; + for (const data of ITEMS) { + let item = await itemRepo.findOne({ where: { name: data.name } }); + if (!item) { + item = await itemRepo.save(itemRepo.create(data)); + console.log(`✅ Item "${data.name}" seedé`); + } else { + console.log(`⏭ Item "${data.name}" déjà présent`); + } + itemMap[data.name] = item.id; + } + + // Seed recettes + const RECIPES = [ + { + name: 'Craft Dague Rouillée', + resultItemName: 'Dague Rouillée', + craftDurationSeconds: 15, + enduranceCost: 8, + ingredients: [ + { materialId: materialMap['Bave de Têtard'], quantity: 3 }, + { materialId: materialMap['Écailles de Grenouille'], quantity: 1 }, + ], + }, + { + name: 'Craft Gilet de Cuir', + resultItemName: 'Gilet de Cuir', + craftDurationSeconds: 30, + enduranceCost: 10, + ingredients: [ + { materialId: materialMap['Écailles de Grenouille'], quantity: 3 }, + { materialId: materialMap['Fragment de Boue'], quantity: 2 }, + ], + }, + { + name: 'Craft Épée Courte', + resultItemName: 'Épée Courte', + craftDurationSeconds: 60, + enduranceCost: 15, + ingredients: [ + { materialId: materialMap['Venin de Serpent'], quantity: 2 }, + { materialId: materialMap['Fragment de Boue'], quantity: 3 }, + { materialId: materialMap['Écailles de Grenouille'], quantity: 2 }, + ], + }, + ]; + + for (const data of RECIPES) { + const exists = await recipeRepo.findOne({ where: { name: data.name } }); + if (!exists) { + await recipeRepo.save(recipeRepo.create({ + name: data.name, + resultItemId: itemMap[data.resultItemName], + craftDurationSeconds: data.craftDurationSeconds, + enduranceCost: data.enduranceCost, + ingredients: data.ingredients, + })); + console.log(`✅ Recette "${data.name}" seedée`); + } else { + console.log(`⏭ Recette "${data.name}" déjà présente`); + } + } + + // Mise à jour monsters avec leur drop_material_id + for (const [monsterName, materialName] of Object.entries(MONSTER_LOOT)) { + const monster = await monsterRepo.findOne({ where: { name: monsterName } }); + const materialId = materialMap[materialName]; + if (monster && materialId && monster.dropMaterialId !== materialId) { + monster.dropMaterialId = materialId; + await monsterRepo.save(monster); + console.log(`✅ Monstre "${monsterName}" → drop "${materialName}" mis à jour`); + } + } + + console.log('✅ Seed Sprint 3 terminé'); + await dataSource.destroy(); +} + +seed().catch((err) => { + console.error('Seed Sprint 3 échoué :', err); + process.exit(1); +}); diff --git a/src/forge/forge.controller.ts b/src/forge/forge.controller.ts new file mode 100644 index 0000000..eca9501 --- /dev/null +++ b/src/forge/forge.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Post, Param, UseGuards, Req } from '@nestjs/common'; +import { Request } from 'express'; +import { ForgeService } from './forge.service'; +import { AuthGuard } from '../auth/guards/auth.guard'; +import { User } from '../user/user.entity'; + +@Controller('forge') +@UseGuards(AuthGuard) +export class ForgeController { + constructor(private readonly forgeService: ForgeService) {} + + @Post('upgrade/:charItemId') + upgrade(@Param('charItemId') charItemId: string, @Req() req: Request & { user: User }) { + return this.forgeService.upgradeItem(charItemId, req.user); + } +} diff --git a/src/forge/forge.module.ts b/src/forge/forge.module.ts new file mode 100644 index 0000000..d768548 --- /dev/null +++ b/src/forge/forge.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { ForgeService } from './forge.service'; +import { ForgeController } from './forge.controller'; +import { AuthModule } from '../auth/auth.module'; +import { ItemModule } from '../item/item.module'; +import { Character } from '../character/entities/character.entity'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +@Module({ + imports: [TypeOrmModule.forFeature([Character]), AuthModule, ItemModule], + controllers: [ForgeController], + providers: [ForgeService], +}) +export class ForgeModule {} diff --git a/src/forge/forge.service.ts b/src/forge/forge.service.ts new file mode 100644 index 0000000..669c14a --- /dev/null +++ b/src/forge/forge.service.ts @@ -0,0 +1,68 @@ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CharacterItem } from '../item/character-item.entity'; +import { Character } from '../character/entities/character.entity'; +import { User } from '../user/user.entity'; + +const MAX_FORGE_LEVEL = 5; +const FORGE_BONUS_PER_LEVEL = 2; // +2 attack (weapon) ou +2 defense (armor) par niveau affiché + +// Risque d'échec par niveau cible (GDD exact) +const FORGE_FAIL_CHANCE: Record = { + 1: 0, + 2: 0, + 3: 0.20, + 4: 0.30, + 5: 0.40, +}; + +@Injectable() +export class ForgeService { + constructor( + @InjectRepository(CharacterItem) + private readonly charItemRepository: Repository, + @InjectRepository(Character) + private readonly characterRepository: Repository, + ) {} + + async upgradeItem(charItemId: string, user: User) { + const char = await this.characterRepository.findOne({ where: { userId: user.id } }); + if (!char) throw new BadRequestException('Aucun personnage trouvé'); + + const charItem = await this.charItemRepository.findOne({ + where: { id: charItemId, characterId: char.id }, + }); + if (!charItem) throw new NotFoundException('Item non trouvé dans l\'inventaire'); + if (charItem.forgeLevel >= MAX_FORGE_LEVEL) { + throw new BadRequestException(`Niveau de forge maximum atteint (${MAX_FORGE_LEVEL})`); + } + + const targetLevel = charItem.forgeLevel + 1; + const failChance = FORGE_FAIL_CHANCE[targetLevel] ?? 0; + const success = Math.random() >= failChance; + + if (success) { + charItem.forgeLevel = targetLevel; + await this.charItemRepository.save(charItem); + + const statLabel = charItem.item.type === 'weapon' + ? `+${FORGE_BONUS_PER_LEVEL} ATK` + : `+${FORGE_BONUS_PER_LEVEL} DEF`; + + return { + success: true, + forgeLevel: charItem.forgeLevel, + item: charItem.item.name, + message: `Forge réussie ! ${charItem.item.name} [+${charItem.forgeLevel}] (${statLabel}).`, + }; + } + + return { + success: false, + forgeLevel: charItem.forgeLevel, + item: charItem.item.name, + message: `Échec de forge ! ${charItem.item.name} reste au niveau [+${charItem.forgeLevel}].`, + }; + } +} diff --git a/src/item/character-item.entity.ts b/src/item/character-item.entity.ts new file mode 100644 index 0000000..54a4d8c --- /dev/null +++ b/src/item/character-item.entity.ts @@ -0,0 +1,39 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; +import { Character } from '../character/entities/character.entity'; +import { Item } from './item.entity'; + +@Entity('character_items') +export class CharacterItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'character_id' }) + characterId: string; + + @ManyToOne(() => Character) + @JoinColumn({ name: 'character_id' }) + character: Character; + + @Column({ name: 'item_id' }) + itemId: string; + + @ManyToOne(() => Item, { eager: true }) + @JoinColumn({ name: 'item_id' }) + item: Item; + + @Column({ name: 'forge_level', default: 0 }) + forgeLevel: number; + + @Column({ default: false }) + equipped: boolean; + + @CreateDateColumn({ name: 'acquired_at' }) + acquiredAt: Date; +} diff --git a/src/item/item.controller.ts b/src/item/item.controller.ts new file mode 100644 index 0000000..585473c --- /dev/null +++ b/src/item/item.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Post, Param, UseGuards, Req } from '@nestjs/common'; +import { Request } from 'express'; +import { ItemService } from './item.service'; +import { AuthGuard } from '../auth/guards/auth.guard'; +import { User } from '../user/user.entity'; + +@Controller('items') +export class ItemController { + constructor(private readonly itemService: ItemService) {} + + @Get() + findAll() { + return this.itemService.findAll(); + } + + @Get('inventory') + @UseGuards(AuthGuard) + getInventory(@Req() req: Request & { user: User }) { + return this.itemService.getInventory(req.user); + } + + @Post('equip/:charItemId') + @UseGuards(AuthGuard) + equip(@Param('charItemId') charItemId: string, @Req() req: Request & { user: User }) { + return this.itemService.equip(charItemId, req.user); + } + + @Post('unequip/:slot') + @UseGuards(AuthGuard) + unequip(@Param('slot') slot: 'weapon' | 'armor', @Req() req: Request & { user: User }) { + return this.itemService.unequip(slot, req.user); + } +} diff --git a/src/item/item.entity.ts b/src/item/item.entity.ts new file mode 100644 index 0000000..0905ded --- /dev/null +++ b/src/item/item.entity.ts @@ -0,0 +1,43 @@ +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; + +export type ItemType = 'weapon' | 'armor'; +export type ItemRarity = 'common' | 'rare' | 'epic' | 'legendary'; + +@Entity('items') +export class Item { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'varchar', length: 20 }) + type: ItemType; + + @Column({ type: 'varchar', length: 20 }) + rarity: ItemRarity; + + @Column({ name: 'attack_bonus', default: 0 }) + attackBonus: number; + + @Column({ name: 'defense_bonus', default: 0 }) + defenseBonus: number; + + @Column({ name: 'force_bonus', default: 0 }) + forceBonus: number; + + @Column({ name: 'agilite_bonus', default: 0 }) + agiliteBonus: number; + + @Column({ name: 'intelligence_bonus', default: 0 }) + intelligenceBonus: number; + + @Column({ name: 'chance_bonus', default: 0 }) + chanceBonus: number; + + @Column({ name: 'vitalite_bonus', default: 0 }) + vitaliteBonus: number; +} diff --git a/src/item/item.module.ts b/src/item/item.module.ts new file mode 100644 index 0000000..5391eec --- /dev/null +++ b/src/item/item.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Item } from './item.entity'; +import { CharacterItem } from './character-item.entity'; +import { ItemService } from './item.service'; +import { ItemController } from './item.controller'; +import { AuthModule } from '../auth/auth.module'; +import { Character } from '../character/entities/character.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Item, CharacterItem, Character]), AuthModule], + controllers: [ItemController], + providers: [ItemService], + exports: [ItemService, TypeOrmModule], +}) +export class ItemModule {} diff --git a/src/item/item.service.ts b/src/item/item.service.ts new file mode 100644 index 0000000..218d385 --- /dev/null +++ b/src/item/item.service.ts @@ -0,0 +1,100 @@ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Item } from './item.entity'; +import { CharacterItem } from './character-item.entity'; +import { Character } from '../character/entities/character.entity'; +import { User } from '../user/user.entity'; + +@Injectable() +export class ItemService { + constructor( + @InjectRepository(Item) + private readonly itemRepository: Repository, + @InjectRepository(CharacterItem) + private readonly charItemRepository: Repository, + @InjectRepository(Character) + private readonly characterRepository: Repository, + ) {} + + findAll() { + return this.itemRepository.find({ order: { rarity: 'ASC', name: 'ASC' } }); + } + + async getInventory(user: User) { + const char = await this.getCharacter(user); + return this.charItemRepository.find({ + where: { characterId: char.id }, + order: { acquiredAt: 'DESC' }, + }); + } + + async equip(charItemId: string, user: User) { + const char = await this.getCharacter(user); + const charItem = await this.charItemRepository.findOne({ + where: { id: charItemId, characterId: char.id }, + }); + if (!charItem) throw new NotFoundException('Item non trouvé dans l\'inventaire'); + + // Déséquiper l'item du même slot (type) si existe + const currentEquipped = await this.charItemRepository + .createQueryBuilder('ci') + .leftJoinAndSelect('ci.item', 'item') + .where('ci.characterId = :cid', { cid: char.id }) + .andWhere('ci.equipped = true') + .andWhere('item.type = :type', { type: charItem.item.type }) + .getOne(); + + if (currentEquipped) { + currentEquipped.equipped = false; + await this.charItemRepository.save(currentEquipped); + } + + charItem.equipped = true; + await this.charItemRepository.save(charItem); + return { equipped: true, item: charItem }; + } + + async unequip(slot: 'weapon' | 'armor', user: User) { + const char = await this.getCharacter(user); + const charItem = await this.charItemRepository + .createQueryBuilder('ci') + .leftJoinAndSelect('ci.item', 'item') + .where('ci.characterId = :cid', { cid: char.id }) + .andWhere('ci.equipped = true') + .andWhere('item.type = :type', { type: slot }) + .getOne(); + + if (!charItem) throw new NotFoundException(`Aucun ${slot} équipé`); + charItem.equipped = false; + await this.charItemRepository.save(charItem); + return { unequipped: true }; + } + + // Utilisé par CombatService pour charger l'équipement actif + async getEquippedItems(characterId: string): Promise<{ weapon: CharacterItem | null; armor: CharacterItem | null }> { + const equipped = await this.charItemRepository + .createQueryBuilder('ci') + .leftJoinAndSelect('ci.item', 'item') + .where('ci.characterId = :cid', { cid: characterId }) + .andWhere('ci.equipped = true') + .getMany(); + + return { + weapon: equipped.find(ci => ci.item.type === 'weapon') ?? null, + armor: equipped.find(ci => ci.item.type === 'armor') ?? null, + }; + } + + // Utilisé par CraftService pour ajouter un item crafté + async addItemToInventory(characterId: string, itemId: string): Promise { + const charItem = this.charItemRepository.create({ characterId, itemId, forgeLevel: 0, equipped: false }); + return this.charItemRepository.save(charItem); + } + + private async getCharacter(user: User): Promise { + const char = await this.characterRepository.findOne({ where: { userId: user.id } }); + if (!char) throw new BadRequestException('Aucun personnage trouvé'); + return char; + } +} diff --git a/src/material/character-material.entity.ts b/src/material/character-material.entity.ts new file mode 100644 index 0000000..39c9315 --- /dev/null +++ b/src/material/character-material.entity.ts @@ -0,0 +1,32 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Character } from '../character/entities/character.entity'; +import { Material } from './material.entity'; + +@Entity('character_materials') +export class CharacterMaterial { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'character_id' }) + characterId: string; + + @ManyToOne(() => Character) + @JoinColumn({ name: 'character_id' }) + character: Character; + + @Column({ name: 'material_id' }) + materialId: string; + + @ManyToOne(() => Material, { eager: true }) + @JoinColumn({ name: 'material_id' }) + material: Material; + + @Column({ default: 0 }) + quantity: number; +} diff --git a/src/material/material.controller.ts b/src/material/material.controller.ts new file mode 100644 index 0000000..81a7bf0 --- /dev/null +++ b/src/material/material.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get, UseGuards, Req } from '@nestjs/common'; +import { Request } from 'express'; +import { MaterialService } from './material.service'; +import { AuthGuard } from '../auth/guards/auth.guard'; +import { User } from '../user/user.entity'; + +@Controller('materials') +export class MaterialController { + constructor(private readonly materialService: MaterialService) {} + + @Get() + findAll() { + return this.materialService.findAll(); + } + + @Get('inventory') + @UseGuards(AuthGuard) + getInventory(@Req() req: Request & { user: User }) { + return this.materialService.getInventory(req.user); + } +} diff --git a/src/material/material.entity.ts b/src/material/material.entity.ts new file mode 100644 index 0000000..e26833b --- /dev/null +++ b/src/material/material.entity.ts @@ -0,0 +1,18 @@ +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; + +export type MaterialRarity = 'common' | 'rare' | 'epic'; + +@Entity('materials') +export class Material { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'varchar', length: 20 }) + rarity: MaterialRarity; +} diff --git a/src/material/material.module.ts b/src/material/material.module.ts new file mode 100644 index 0000000..bb24ca1 --- /dev/null +++ b/src/material/material.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Material } from './material.entity'; +import { CharacterMaterial } from './character-material.entity'; +import { MaterialService } from './material.service'; +import { MaterialController } from './material.controller'; +import { AuthModule } from '../auth/auth.module'; +import { Character } from '../character/entities/character.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Material, CharacterMaterial, Character]), AuthModule], + controllers: [MaterialController], + providers: [MaterialService], + exports: [MaterialService, TypeOrmModule], +}) +export class MaterialModule {} diff --git a/src/material/material.service.ts b/src/material/material.service.ts new file mode 100644 index 0000000..725f947 --- /dev/null +++ b/src/material/material.service.ts @@ -0,0 +1,61 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MoreThan, Repository } from 'typeorm'; +import { Material } from './material.entity'; +import { CharacterMaterial } from './character-material.entity'; +import { Character } from '../character/entities/character.entity'; +import { User } from '../user/user.entity'; + +@Injectable() +export class MaterialService { + constructor( + @InjectRepository(Material) + private readonly materialRepository: Repository, + @InjectRepository(CharacterMaterial) + private readonly charMatRepository: Repository, + @InjectRepository(Character) + private readonly characterRepository: Repository, + ) {} + + findAll() { + return this.materialRepository.find({ order: { rarity: 'ASC', name: 'ASC' } }); + } + + async getInventory(user: User) { + const char = await this.getCharacter(user); + return this.charMatRepository.find({ + where: { characterId: char.id, quantity: MoreThan(0) }, + }); + } + + // Appelé par CombatService après victoire (loot) + async addMaterial(characterId: string, materialId: string, quantity: number): Promise { + let entry = await this.charMatRepository.findOne({ where: { characterId, materialId } }); + if (entry) { + entry.quantity += quantity; + } else { + entry = this.charMatRepository.create({ characterId, materialId, quantity }); + } + return this.charMatRepository.save(entry); + } + + // Appelé par CraftService pour consommer les ingrédients + async consumeMaterials(characterId: string, ingredients: { materialId: string; quantity: number }[]): Promise { + for (const ing of ingredients) { + const entry = await this.charMatRepository.findOne({ + where: { characterId, materialId: ing.materialId }, + }); + if (!entry || entry.quantity < ing.quantity) { + throw new BadRequestException('Matériaux insuffisants pour ce craft'); + } + entry.quantity -= ing.quantity; + await this.charMatRepository.save(entry); + } + } + + private async getCharacter(user: User): Promise { + const char = await this.characterRepository.findOne({ where: { userId: user.id } }); + if (!char) throw new BadRequestException('Aucun personnage trouvé'); + return char; + } +} diff --git a/src/monster/monster.entity.ts b/src/monster/monster.entity.ts index 55706ad..a6bb341 100644 --- a/src/monster/monster.entity.ts +++ b/src/monster/monster.entity.ts @@ -36,4 +36,7 @@ export class Monster { @Column({ name: 'gold_max' }) goldMax: number; + + @Column({ name: 'drop_material_id', type: 'varchar', nullable: true }) + dropMaterialId: string | null; }