From 6d1230d16acf7046ae9a9b188f6db54196edbca6 Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Sun, 15 Mar 2026 06:10:06 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=202=20=E2=80=94=20moteur=20de=20?= =?UTF-8?q?combat=20PvE=20TetaRdPG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moteur combat stateless (POST /api/combat/start résout le combat complet). Formules GDD : Mêlée/Distance/Magie × 1.5, critique (5% + Chance×0.2%), esquive (5% + Chance×0.1%). 5 monstres seedés (Têtard Vase → Golem de Boue, level 1–9). Level up : XP → seuil atteint → level++, +5 statPoints. Persiste combat_logs (jsonb rounds). Validé : victoire, défaite, 401, 400, 404. --- SPRINT2.md | 126 +++++++++++++ package.json | 1 + src/app.module.ts | 4 + src/character/entities/character.entity.ts | 4 + src/combat/combat-log.entity.ts | 52 ++++++ src/combat/combat.controller.ts | 26 +++ src/combat/combat.engine.ts | 202 +++++++++++++++++++++ src/combat/combat.module.ts | 19 ++ src/combat/combat.service.ts | 195 ++++++++++++++++++++ src/combat/dto/start-combat.dto.ts | 10 + src/database/monsters-seed.ts | 78 ++++++++ src/monster/monster.controller.ts | 14 ++ src/monster/monster.entity.ts | 39 ++++ src/monster/monster.module.ts | 14 ++ src/monster/monster.service.ts | 22 +++ 15 files changed, 806 insertions(+) create mode 100644 SPRINT2.md create mode 100644 src/combat/combat-log.entity.ts create mode 100644 src/combat/combat.controller.ts create mode 100644 src/combat/combat.engine.ts create mode 100644 src/combat/combat.module.ts create mode 100644 src/combat/combat.service.ts create mode 100644 src/combat/dto/start-combat.dto.ts create mode 100644 src/database/monsters-seed.ts create mode 100644 src/monster/monster.controller.ts create mode 100644 src/monster/monster.entity.ts create mode 100644 src/monster/monster.module.ts create mode 100644 src/monster/monster.service.ts diff --git a/SPRINT2.md b/SPRINT2.md new file mode 100644 index 0000000..4c05327 --- /dev/null +++ b/SPRINT2.md @@ -0,0 +1,126 @@ +# TetaRdPG — Brief Sprint 2 + +> Statut : 🔄 En cours +> Objectif : Moteur de combat PvE + retours textuels (logs) +> Stack : NestJS · PostgreSQL · TypeORM (synchronize dev) +> Prérequis : Sprint 1 livré ✅ + +--- + +## Scope Sprint 2 + +### ✅ In scope + +- Entité `monsters` (5 monstres seedés, level 1–8) +- Moteur de combat PvE — résolution stateless côté serveur (un POST = combat complet) +- Formules GDD : Mêlée/Distance/Magie × 1.5 | Critique | Esquive +- Fin de combat : Victoire (XP + Or + +10% PV) / Défaite (20% PV + −50 endurance + perte or) +- Persistance des combats (`combat_logs`) +- Mise à jour character post-combat : hpCurrent, endurance, xp, gold, level, statPoints +- Level up basique : XP → seuil atteint → level++, +5 statPoints +- API : `GET /api/monsters`, `POST /api/combat/start`, `GET /api/combat/history` + +### ❌ Out of scope + +- Interactivité tour par tour (choix action par round) — Sprint 3 +- Équipement / armure / arme → défense joueur = 0 Sprint 2 +- Forge, artisanat, boutique +- Twitch, PvP +- Frontend React + +--- + +## Décisions de design (game-designer) + +| Décision | Valeur Sprint 2 | Justification | +|----------|----------------|---------------| +| Dégâts joueur | `Math.floor(stat × 1.5)` | Arme = 0 (pas d'équipement) | +| Défense joueur | 0 | Pas d'armure Sprint 2 | +| Crit | `5% + Chance × 0.2%` → dégâts ×1.5 | GDD | +| Esquive | `5% + Chance × 0.1%` → annule dégâts | GDD | +| Crit/Esquive monstres | Non (Sprint 2) | Simplification, fairness joueur | +| Défaite HP | 20% hpMax (retour auberge) | GDD "retour auberge" | +| Coût combat défaite | −10 (start) + −50 (peine) = −60 | GDD | +| Level up | `XP requise = round(100 × level^1.5)` | Formule GDD | +| Stat points / level | +5 par level franchi | GDD | + +--- + +## Monstres seedés + +| Nom | Level min | Level max | HP | Attaque | Défense | Type | XP | Or (min-max) | +|-----|-----------|-----------|-----|---------|---------|------|----|-------------| +| Têtard Vase | 1 | 2 | 40 | 5 | 0 | melee | 25 | 3–8 | +| Grenouille Boueuse | 2 | 4 | 65 | 8 | 1 | melee | 45 | 6–15 | +| Serpent des Marais | 3 | 6 | 90 | 11 | 2 | ranged | 70 | 10–25 | +| Champi Vénéneux | 2 | 5 | 75 | 9 | 3 | magic | 60 | 8–20 | +| Golem de Boue | 6 | 9 | 150 | 16 | 5 | melee | 130 | 25–60 | + +--- + +## API Sprint 2 + +``` +GET /api/monsters → liste tous les monstres +POST /api/combat/start → { monsterId, attackType: 'melee'|'ranged'|'magic' } +GET /api/combat/history → combats du personnage connecté +``` + +### Format réponse POST /api/combat/start + +```json +{ + "winner": "player", + "rounds": [ + { + "round": 1, + "playerDamage": 6, "playerCrit": true, "monsterDodged": false, + "monsterDamage": 5, "playerDodged": false, + "playerHp": 95, "monsterHp": 34, + "log": ["Tetard frappe le Têtard Vase pour 6 dégâts (CRITIQUE) !", "Le Têtard Vase frappe Tetard pour 5 dégâts."] + } + ], + "summary": "Victoire en 7 tours ! +25 XP, +5 Or.", + "rewards": { "xp": 25, "gold": 5, "levelUp": false }, + "character": { "level": 1, "xp": 25, "gold": 5, "hpCurrent": 75, "enduranceCurrent": 90 } +} +``` + +--- + +## Architecture modules + +``` +src/ +├── monster/ +│ ├── monster.entity.ts +│ ├── monster.module.ts +│ ├── monster.service.ts +│ └── monster.controller.ts +├── combat/ +│ ├── combat.engine.ts → fonctions pures (pas de dépendances NestJS) +│ ├── combat-log.entity.ts +│ ├── combat.service.ts +│ ├── combat.controller.ts +│ ├── combat.module.ts +│ └── dto/start-combat.dto.ts +└── database/ + └── monsters-seed.ts +``` + +--- + +## Critères de validation integrator + +- [ ] `GET /api/monsters` → liste 5 monstres +- [ ] `POST /api/combat/start` → combat résolu, log retourné +- [ ] Personnage level 1 peut vaincre Têtard Vase +- [ ] XP et or crédités après victoire +- [ ] `hpCurrent` mis à jour en DB après combat +- [ ] Endurance déduite (−10) après combat +- [ ] Défaite : `hpCurrent` = 20% hpMax, endurance −60 total +- [ ] `GET /api/combat/history` → historique retourné +- [ ] Sans cookie → 401 +- [ ] Endurance insuffisante (< 10) → 400 +- [ ] Monster inexistant → 404 +- [ ] Level up déclenché si XP seuil atteint diff --git a/package.json b/package.json index 80ba27f..0afe56b 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start:dev": "nest start --watch", "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", "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 d1ecf39..961900a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,6 +4,8 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ThrottlerModule } from '@nestjs/throttler'; 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 { HealthController } from './common/health.controller'; @Module({ @@ -31,6 +33,8 @@ import { HealthController } from './common/health.controller'; AuthModule, CharacterModule, + MonsterModule, + CombatModule, ], controllers: [HealthController], }) diff --git a/src/character/entities/character.entity.ts b/src/character/entities/character.entity.ts index c3fb341..952df45 100644 --- a/src/character/entities/character.entity.ts +++ b/src/character/entities/character.entity.ts @@ -67,6 +67,10 @@ export class Character { @Column({ name: 'endurance_max', default: 100 }) enduranceMax: number; + // Points de stats disponibles (non distribués) — +5 par level up + @Column({ name: 'stat_points', default: 0 }) + statPoints: number; + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; diff --git a/src/combat/combat-log.entity.ts b/src/combat/combat-log.entity.ts new file mode 100644 index 0000000..9b11780 --- /dev/null +++ b/src/combat/combat-log.entity.ts @@ -0,0 +1,52 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; +import { Character } from '../character/entities/character.entity'; +import { Monster } from '../monster/monster.entity'; + +@Entity('combat_logs') +export class CombatLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'character_id' }) + characterId: string; + + @ManyToOne(() => Character) + @JoinColumn({ name: 'character_id' }) + character: Character; + + @Column({ name: 'monster_id' }) + monsterId: string; + + @ManyToOne(() => Monster) + @JoinColumn({ name: 'monster_id' }) + monster: Monster; + + @Column({ type: 'varchar', length: 10 }) + winner: 'player' | 'monster'; + + @Column({ name: 'total_rounds' }) + totalRounds: number; + + // Stocker les rounds en JSON — lecture replay + @Column({ name: 'rounds_data', type: 'jsonb' }) + roundsData: object; + + @Column({ name: 'xp_earned', default: 0 }) + xpEarned: number; + + @Column({ name: 'gold_earned', default: 0 }) + goldEarned: number; + + @Column({ name: 'level_up', default: false }) + levelUp: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/src/combat/combat.controller.ts b/src/combat/combat.controller.ts new file mode 100644 index 0000000..1fed2b3 --- /dev/null +++ b/src/combat/combat.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Post, Get, Body, UseGuards, Req, HttpCode, HttpStatus } from '@nestjs/common'; +import { Request } from 'express'; +import { CombatService } from './combat.service'; +import { StartCombatDto } from './dto/start-combat.dto'; +import { AuthGuard } from '../auth/guards/auth.guard'; +import { User } from '../user/user.entity'; + +@Controller('combat') +@UseGuards(AuthGuard) +export class CombatController { + constructor(private readonly combatService: CombatService) {} + + @Post('start') + @HttpCode(HttpStatus.OK) + startCombat( + @Body() dto: StartCombatDto, + @Req() req: Request & { user: User }, + ) { + return this.combatService.startCombat(dto, req.user); + } + + @Get('history') + getHistory(@Req() req: Request & { user: User }) { + return this.combatService.getHistory(req.user); + } +} diff --git a/src/combat/combat.engine.ts b/src/combat/combat.engine.ts new file mode 100644 index 0000000..96b8c55 --- /dev/null +++ b/src/combat/combat.engine.ts @@ -0,0 +1,202 @@ +import { AttackType } from '../monster/monster.entity'; + +// ---------- Types ---------- + +export interface CombatantStats { + name: string; + hpCurrent: number; + hpMax: number; + force: number; + agilite: number; + intelligence: number; + chance: number; + attack: number; // arme (0 Sprint 2) pour joueur, flat pour monstre + defense: number; // 0 joueur Sprint 2 + attackType: AttackType; +} + +export interface AttackResult { + damage: number; + isCrit: boolean; + isDodged: boolean; + log: string; +} + +export interface RoundLog { + round: number; + playerAttack: AttackResult; + monsterAttack: AttackResult; + playerHp: number; + monsterHp: number; + log: string[]; +} + +export interface CombatResult { + winner: 'player' | 'monster'; + rounds: RoundLog[]; + xpEarned: number; + goldEarned: number; + totalRounds: number; +} + +// ---------- Formules (déterministes pour les tests, mais utilisent Math.random) ---------- + +const MAX_ROUNDS = 30; // sécurité anti-boucle infinie + +function statForAttackType(stats: CombatantStats): number { + switch (stats.attackType) { + case 'melee': return stats.force; + case 'ranged': return stats.agilite; + case 'magic': return stats.intelligence; + } +} + +export function calcPlayerDamage(player: CombatantStats, monsterDefense: number): number { + const stat = statForAttackType(player); + const raw = player.attack + Math.floor(stat * 1.5); + return Math.max(1, raw - monsterDefense); +} + +export function calcMonsterDamage(monster: CombatantStats, playerDefense: number): number { + return Math.max(1, monster.attack - playerDefense); +} + +export function rollCrit(chance: number): boolean { + const rate = 0.05 + chance * 0.002; + return Math.random() < rate; +} + +export function rollDodge(chance: number): boolean { + const rate = 0.05 + chance * 0.001; + return Math.random() < rate; +} + +function resolvePlayerAttack( + player: CombatantStats, + monster: CombatantStats, +): AttackResult { + // Les monstres ne dodgent pas en Sprint 2 + const baseDamage = calcPlayerDamage(player, monster.defense); + const isCrit = rollCrit(player.chance); + const damage = isCrit ? Math.floor(baseDamage * 1.5) : baseDamage; + + const critText = isCrit ? ' (CRITIQUE !)' : ''; + const log = `${player.name} attaque ${monster.name} pour ${damage} dégâts${critText}`; + + return { damage, isCrit, isDodged: false, log }; +} + +function resolveMonsterAttack( + monster: CombatantStats, + player: CombatantStats, +): AttackResult { + const isDodged = rollDodge(player.chance); + if (isDodged) { + return { + damage: 0, + isCrit: false, + isDodged: true, + log: `${player.name} esquive l'attaque de ${monster.name} !`, + }; + } + + const damage = calcMonsterDamage(monster, player.defense); + return { + damage, + isCrit: false, + isDodged: false, + log: `${monster.name} attaque ${player.name} pour ${damage} dégâts.`, + }; +} + +// ---------- Résolution complète ---------- + +export function resolveCombat( + player: CombatantStats, + monster: CombatantStats, + xpReward: number, + goldMin: number, + goldMax: number, +): CombatResult { + let playerHp = player.hpCurrent; + let monsterHp = monster.hpCurrent; + const rounds: RoundLog[] = []; + + for (let round = 1; round <= MAX_ROUNDS; round++) { + // -- Tour joueur -- + const playerAttack = resolvePlayerAttack(player, monster); + monsterHp = Math.max(0, monsterHp - playerAttack.damage); + + // -- Tour monstre (seulement si encore vivant) -- + let monsterAttack: AttackResult; + if (monsterHp > 0) { + monsterAttack = resolveMonsterAttack(monster, player); + playerHp = Math.max(0, playerHp - monsterAttack.damage); + } else { + monsterAttack = { damage: 0, isCrit: false, isDodged: false, log: '' }; + } + + rounds.push({ + round, + playerAttack, + monsterAttack, + playerHp, + monsterHp, + log: [ + playerAttack.log, + ...(monsterAttack.log ? [monsterAttack.log] : []), + `HP — ${player.name}: ${playerHp}/${player.hpMax} | ${monster.name}: ${monsterHp}/${monster.hpCurrent}`, + ], + }); + + if (monsterHp <= 0) { + const goldEarned = goldMin + Math.floor(Math.random() * (goldMax - goldMin + 1)); + return { winner: 'player', rounds, xpEarned: xpReward, goldEarned, totalRounds: round }; + } + + if (playerHp <= 0) { + return { winner: 'monster', rounds, xpEarned: 0, goldEarned: 0, totalRounds: round }; + } + } + + // Timeout (ne devrait pas arriver avec des valeurs équilibrées) + return { winner: 'monster', rounds, xpEarned: 0, goldEarned: 0, totalRounds: MAX_ROUNDS }; +} + +// ---------- Level up ---------- + +export function xpRequiredForLevel(level: number): number { + return Math.round(100 * Math.pow(level, 1.5)); +} + +export interface LevelUpResult { + newLevel: number; + newXp: number; + statPointsGained: number; + levelsGained: number; +} + +export function applyXpGain(currentLevel: number, currentXp: number, xpEarned: number): LevelUpResult { + let level = currentLevel; + let xp = currentXp + xpEarned; + let statPointsGained = 0; + + // Chaîne de level up + while (level < 100) { + const required = xpRequiredForLevel(level + 1); + if (xp >= required) { + xp -= required; + level++; + statPointsGained += 5; + } else { + break; + } + } + + return { + newLevel: level, + newXp: xp, + statPointsGained, + levelsGained: level - currentLevel, + }; +} diff --git a/src/combat/combat.module.ts b/src/combat/combat.module.ts new file mode 100644 index 0000000..7cc4250 --- /dev/null +++ b/src/combat/combat.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Character } from '../character/entities/character.entity'; +import { CombatLog } from './combat-log.entity'; +import { CombatService } from './combat.service'; +import { CombatController } from './combat.controller'; +import { MonsterModule } from '../monster/monster.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Character, CombatLog]), + MonsterModule, + AuthModule, + ], + controllers: [CombatController], + providers: [CombatService], +}) +export class CombatModule {} diff --git a/src/combat/combat.service.ts b/src/combat/combat.service.ts new file mode 100644 index 0000000..6bfab53 --- /dev/null +++ b/src/combat/combat.service.ts @@ -0,0 +1,195 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Character } from '../character/entities/character.entity'; +import { Monster } from '../monster/monster.entity'; +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 { + resolveCombat, + applyXpGain, + CombatantStats, +} from './combat.engine'; + +const COMBAT_ENDURANCE_COST = 10; +const DEFEAT_ENDURANCE_PENALTY = 50; +const DEFEAT_HP_RATIO = 0.2; // 20% hpMax à la défaite +const VICTORY_HP_REGEN_RATIO = 0.1; // +10% hpMax à la victoire +const DEFEAT_GOLD_LOSS_RATIO = 0.05; // perte 5% or à la défaite + +@Injectable() +export class CombatService { + constructor( + @InjectRepository(Character) + private readonly characterRepository: Repository, + @InjectRepository(CombatLog) + private readonly combatLogRepository: Repository, + private readonly monsterService: MonsterService, + ) {} + + async startCombat(dto: StartCombatDto, user: User) { + // Charger le personnage + const character = await this.characterRepository.findOne({ + where: { userId: user.id }, + }); + if (!character) throw new BadRequestException('Aucun personnage trouvé'); + + // Charger le monstre + const monster = await this.monsterService.findOne(dto.monsterId); + + // Calculer l'endurance actuelle (lazy pattern) + const elapsedMinutes = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000; + const recharge = Math.floor(elapsedMinutes / 6); + const enduranceCurrent = Math.min(character.enduranceSaved + recharge, character.enduranceMax); + + if (enduranceCurrent < COMBAT_ENDURANCE_COST) { + throw new BadRequestException( + `Endurance insuffisante (${enduranceCurrent}/${COMBAT_ENDURANCE_COST} requis)`, + ); + } + + if (character.hpCurrent <= 0) { + throw new BadRequestException('Votre personnage est KO — récupérez d\'abord vos PV'); + } + + // Construire les stats des combattants + const playerStats: CombatantStats = { + name: character.name, + hpCurrent: character.hpCurrent, + hpMax: character.hpMax, + force: character.force, + agilite: character.agilite, + intelligence: character.intelligence, + chance: character.chance, + attack: 0, // pas d'arme Sprint 2 + defense: 0, // pas d'armure Sprint 2 + attackType: dto.attackType, + }; + + const monsterStats: CombatantStats = { + name: monster.name, + hpCurrent: monster.hp, + hpMax: monster.hp, + force: 0, + agilite: 0, + intelligence: 0, + chance: 0, // pas de crit/esquive pour les monstres Sprint 2 + attack: monster.attack, + defense: monster.defense, + attackType: monster.attackType, + }; + + // Résolution combat + const result = resolveCombat( + playerStats, + monsterStats, + monster.xpReward, + monster.goldMin, + monster.goldMax, + ); + + // Appliquer les effets post-combat sur le personnage + let newHp = character.hpCurrent; + let newEnduranceSaved = enduranceCurrent - COMBAT_ENDURANCE_COST; + let goldLost = 0; + let levelUpData = { levelsGained: 0, statPointsGained: 0, newLevel: character.level, newXp: character.xp }; + + if (result.winner === 'player') { + // Victoire : XP + Or + récup 10% PV + levelUpData = applyXpGain(character.level, character.xp, result.xpEarned); + character.xp = levelUpData.newXp; + character.level = levelUpData.newLevel; + character.statPoints = (character.statPoints ?? 0) + levelUpData.statPointsGained; + character.gold += result.goldEarned; + newHp = Math.min(character.hpMax, character.hpCurrent + Math.floor(character.hpMax * VICTORY_HP_REGEN_RATIO)); + } else { + // Défaite : retour auberge + pénalités + newEnduranceSaved = Math.max(0, newEnduranceSaved - DEFEAT_ENDURANCE_PENALTY); + newHp = Math.max(1, Math.floor(character.hpMax * DEFEAT_HP_RATIO)); + goldLost = Math.floor(character.gold * DEFEAT_GOLD_LOSS_RATIO); + character.gold = Math.max(0, character.gold - goldLost); + } + + // Sauvegarder l'endurance (lazy reset) + character.hpCurrent = newHp; + character.enduranceSaved = newEnduranceSaved; + character.lastEnduranceTs = new Date(); + await this.characterRepository.save(character); + + // Persister le log + const combatLog = this.combatLogRepository.create({ + characterId: character.id, + monsterId: monster.id, + winner: result.winner, + totalRounds: result.totalRounds, + roundsData: result.rounds, + xpEarned: result.xpEarned, + goldEarned: result.goldEarned, + levelUp: levelUpData.levelsGained > 0, + }); + await this.combatLogRepository.save(combatLog); + + // Construire la réponse + const summaryParts: string[] = []; + if (result.winner === 'player') { + summaryParts.push(`Victoire en ${result.totalRounds} tours !`); + summaryParts.push(`+${result.xpEarned} XP, +${result.goldEarned} Or.`); + if (levelUpData.levelsGained > 0) { + summaryParts.push(`LEVEL UP ! Niveau ${levelUpData.newLevel} atteint. +${levelUpData.statPointsGained} points de stats.`); + } + } else { + summaryParts.push(`Défaite au tour ${result.totalRounds}. Retour à l'auberge.`); + if (goldLost > 0) summaryParts.push(`−${goldLost} Or perdu.`); + } + + return { + winner: result.winner, + rounds: result.rounds, + summary: summaryParts.join(' '), + rewards: { + xp: result.xpEarned, + gold: result.goldEarned, + goldLost, + levelUp: levelUpData.levelsGained > 0, + newLevel: levelUpData.newLevel, + statPointsGained: levelUpData.statPointsGained, + }, + character: { + level: character.level, + xp: character.xp, + gold: character.gold, + hpCurrent: character.hpCurrent, + hpMax: character.hpMax, + enduranceCurrent: character.enduranceSaved, // déjà le nouveau enduranceSaved post-combat + enduranceMax: character.enduranceMax, + statPoints: character.statPoints ?? 0, + }, + }; + } + + async getHistory(user: User) { + const character = await this.characterRepository.findOne({ + where: { userId: user.id }, + }); + if (!character) throw new BadRequestException('Aucun personnage trouvé'); + + return this.combatLogRepository.find({ + where: { characterId: character.id }, + order: { createdAt: 'DESC' }, + take: 20, + relations: ['monster'], + select: { + id: true, + winner: true, + totalRounds: true, + xpEarned: true, + goldEarned: true, + levelUp: true, + createdAt: true, + monster: { id: true, name: true, minLevel: true, maxLevel: true } as any, + }, + }); + } +} diff --git a/src/combat/dto/start-combat.dto.ts b/src/combat/dto/start-combat.dto.ts new file mode 100644 index 0000000..20c02db --- /dev/null +++ b/src/combat/dto/start-combat.dto.ts @@ -0,0 +1,10 @@ +import { IsUUID, IsIn } from 'class-validator'; +import { AttackType } from '../../monster/monster.entity'; + +export class StartCombatDto { + @IsUUID() + monsterId: string; + + @IsIn(['melee', 'ranged', 'magic']) + attackType: AttackType; +} diff --git a/src/database/monsters-seed.ts b/src/database/monsters-seed.ts new file mode 100644 index 0000000..619af6c --- /dev/null +++ b/src/database/monsters-seed.ts @@ -0,0 +1,78 @@ +import 'reflect-metadata'; +import { DataSource } from 'typeorm'; +import { Monster } from '../monster/monster.entity'; + +const dataSource = new DataSource({ + type: 'postgres', + url: process.env.DATABASE_URL ?? 'postgresql://tetardpg:password@localhost:5432/tetardpg', + entities: [Monster], + synchronize: false, +}); + +const MONSTERS = [ + { + name: 'Têtard Vase', + minLevel: 1, maxLevel: 2, + hp: 40, attack: 5, defense: 0, + attackType: 'melee' as const, + xpReward: 25, goldMin: 3, goldMax: 8, + }, + { + name: 'Grenouille Boueuse', + minLevel: 2, maxLevel: 4, + hp: 65, attack: 8, defense: 1, + attackType: 'melee' as const, + xpReward: 45, goldMin: 6, goldMax: 15, + }, + { + name: 'Serpent des Marais', + minLevel: 3, maxLevel: 6, + hp: 90, attack: 11, defense: 2, + attackType: 'ranged' as const, + xpReward: 70, goldMin: 10, goldMax: 25, + }, + { + name: 'Champi Vénéneux', + minLevel: 2, maxLevel: 5, + hp: 75, attack: 9, defense: 3, + attackType: 'magic' as const, + xpReward: 60, goldMin: 8, goldMax: 20, + }, + { + name: 'Golem de Boue', + minLevel: 6, maxLevel: 9, + hp: 150, attack: 16, defense: 5, + attackType: 'melee' as const, + xpReward: 130, goldMin: 25, goldMax: 60, + }, +]; + +async function seed() { + await dataSource.initialize(); + console.log('DB connectée'); + + const repo = dataSource.getRepository(Monster); + const existing = await repo.count(); + + if (existing >= MONSTERS.length) { + console.log('Monstres déjà seedés — skip'); + await dataSource.destroy(); + return; + } + + for (const data of MONSTERS) { + const exists = await repo.findOne({ where: { name: data.name } }); + if (!exists) { + await repo.save(repo.create(data)); + console.log(`✅ ${data.name} seedé`); + } + } + + console.log('✅ Seed monstres terminé'); + await dataSource.destroy(); +} + +seed().catch((err) => { + console.error('Seed monstres échoué :', err); + process.exit(1); +}); diff --git a/src/monster/monster.controller.ts b/src/monster/monster.controller.ts new file mode 100644 index 0000000..8d1c794 --- /dev/null +++ b/src/monster/monster.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { MonsterService } from './monster.service'; +import { AuthGuard } from '../auth/guards/auth.guard'; + +@Controller('monsters') +@UseGuards(AuthGuard) +export class MonsterController { + constructor(private readonly monsterService: MonsterService) {} + + @Get() + findAll() { + return this.monsterService.findAll(); + } +} diff --git a/src/monster/monster.entity.ts b/src/monster/monster.entity.ts new file mode 100644 index 0000000..55706ad --- /dev/null +++ b/src/monster/monster.entity.ts @@ -0,0 +1,39 @@ +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; + +export type AttackType = 'melee' | 'ranged' | 'magic'; + +@Entity('monsters') +export class Monster { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 100 }) + name: string; + + @Column({ name: 'min_level' }) + minLevel: number; + + @Column({ name: 'max_level' }) + maxLevel: number; + + @Column() + hp: number; + + @Column() + attack: number; + + @Column({ default: 0 }) + defense: number; + + @Column({ name: 'attack_type', type: 'varchar', length: 20 }) + attackType: AttackType; + + @Column({ name: 'xp_reward' }) + xpReward: number; + + @Column({ name: 'gold_min' }) + goldMin: number; + + @Column({ name: 'gold_max' }) + goldMax: number; +} diff --git a/src/monster/monster.module.ts b/src/monster/monster.module.ts new file mode 100644 index 0000000..9ef40b1 --- /dev/null +++ b/src/monster/monster.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Monster } from './monster.entity'; +import { MonsterService } from './monster.service'; +import { MonsterController } from './monster.controller'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([Monster]), AuthModule], + controllers: [MonsterController], + providers: [MonsterService], + exports: [MonsterService, TypeOrmModule], +}) +export class MonsterModule {} diff --git a/src/monster/monster.service.ts b/src/monster/monster.service.ts new file mode 100644 index 0000000..79b9bd4 --- /dev/null +++ b/src/monster/monster.service.ts @@ -0,0 +1,22 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Monster } from './monster.entity'; + +@Injectable() +export class MonsterService { + constructor( + @InjectRepository(Monster) + private readonly monsterRepository: Repository, + ) {} + + findAll(): Promise { + return this.monsterRepository.find({ order: { minLevel: 'ASC' } }); + } + + async findOne(id: string): Promise { + const monster = await this.monsterRepository.findOne({ where: { id } }); + if (!monster) throw new NotFoundException(`Monstre introuvable`); + return monster; + } +}