import { Injectable, BadRequestException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, Repository } from 'typeorm'; import { CharacterMaterial } from '../material/character-material.entity'; import { EventEmitter2 } from '@nestjs/event-emitter'; 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 { ItemService } from '../item/item.service'; import { MaterialService } from '../material/material.service'; import { CommunityService } from '../community/community.service'; import { resolveCombat, applyXpGain, xpRequiredForLevel, CombatantStats, } from './combat.engine'; const COMBAT_ENDURANCE_COST = 5; /** * Drop rate variable basé sur la difficulté relative monstre vs joueur. * Monstre facile = moins de drop, monstre difficile = plus de drop + quantité. * Boss de zone (maxLevel ≥ 9 et spread ≥ 3) = 80% + 2-3 drops. */ function computeDropRate( playerLevel: number, monsterMinLevel: number, monsterMaxLevel: number, ): { dropRate: number; dropQty: number } { const monsterAvgLevel = (monsterMinLevel + monsterMaxLevel) / 2; const diff = monsterAvgLevel - playerLevel; const isBoss = (monsterMaxLevel - monsterMinLevel) >= 3 && monsterMaxLevel >= 9; if (isBoss) { return { dropRate: 0.80, dropQty: 2 + (Math.random() < 0.5 ? 1 : 0) }; // 2-3 } if (diff >= 2) { return { dropRate: 0.60, dropQty: 1 + (Math.random() < 0.3 ? 1 : 0) }; // 1-2 } if (diff >= 0) { return { dropRate: 0.50, dropQty: 1 + (Math.random() < 0.2 ? 1 : 0) }; // 1-2 } if (diff >= -2) { return { dropRate: 0.40, dropQty: 1 }; } // Très facile (level >> monstre) return { dropRate: 0.25, dropQty: 1 }; } const DEFEAT_ENDURANCE_PENALTY = 25; 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 /** Ajouter un matériau dans la transaction courante (pas de connexion séparée). */ async function addMaterialInTx(manager: EntityManager, characterId: string, materialId: string, quantity: number) { const repo = manager.getRepository(CharacterMaterial); let entry = await repo.findOne({ where: { characterId, materialId } }); if (entry) { entry.quantity += quantity; } else { entry = repo.create({ characterId, materialId, quantity }); } await repo.save(entry); } const COOLDOWN_SINGLE_MS = 2_000; const COOLDOWN_MULTI_MS = 8_000; @Injectable() export class CombatService { private readonly cooldowns = new Map(); // userId → timestamp constructor( @InjectRepository(Character) private readonly characterRepository: Repository, @InjectRepository(CombatLog) private readonly combatLogRepository: Repository, private readonly monsterService: MonsterService, private readonly itemService: ItemService, private readonly materialService: MaterialService, private readonly communityService: CommunityService, private readonly eventEmitter: EventEmitter2, private readonly dataSource: DataSource, ) {} private checkCooldown(userId: string): void { const lastCombat = this.cooldowns.get(userId) ?? 0; const remaining = lastCombat - Date.now(); if (remaining > 0) { throw new BadRequestException( `Cooldown actif — attendez ${Math.ceil(remaining / 1000)}s`, ); } } private setCooldown(userId: string, durationMs: number): void { this.cooldowns.set(userId, Date.now() + durationMs); } async startCombat(dto: StartCombatDto, user: User) { this.checkCooldown(user.id); // Charger le monstre (hors transaction — lecture seule) const monster = await this.monsterService.findOne(dto.monsterId); // Transaction isolée — empêche les combats simultanés sur le même perso const txResult = await this.dataSource.transaction(async (manager) => { // SELECT ... FOR UPDATE — verrouille le personnage const character = await manager .getRepository(Character) .createQueryBuilder('c') .setLock('pessimistic_write') .where('c.user_id = :userId', { userId: user.id }) .getOne(); if (!character) throw new BadRequestException('Aucun personnage trouvé'); // Calculer l'endurance actuelle (lazy pattern) const elapsedMinutes = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000; const recharge = Math.floor(elapsedMinutes / 3); 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'); } // 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; // Item stat bonuses const itemForceBonus = (equipped.weapon?.item.forceBonus ?? 0) + (equipped.armor?.item.forceBonus ?? 0); const itemAgiliteBonus = (equipped.weapon?.item.agiliteBonus ?? 0) + (equipped.armor?.item.agiliteBonus ?? 0); const itemIntelligenceBonus = (equipped.weapon?.item.intelligenceBonus ?? 0) + (equipped.armor?.item.intelligenceBonus ?? 0); const itemChanceBonus = (equipped.weapon?.item.chanceBonus ?? 0) + (equipped.armor?.item.chanceBonus ?? 0); // Construire les stats des combattants const playerStats: CombatantStats = { name: character.name, hpCurrent: character.hpCurrent, hpMax: character.hpMax, force: character.force + itemForceBonus, agilite: character.agilite + itemAgiliteBonus, intelligence: character.intelligence + itemIntelligenceBonus, chance: character.chance + itemChanceBonus, attack: weaponAttack, defense: armorDefense, attackType: dto.attackType, }; const monsterStats: CombatantStats = { name: monster.name, hpCurrent: monster.hp, hpMax: monster.hp, force: 0, agilite: 0, intelligence: 0, chance: 0, 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; character.totalGoldEarned = Number(character.totalGoldEarned ?? 0) + 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 le personnage (dans la transaction) character.hpCurrent = newHp; character.enduranceSaved = newEnduranceSaved; character.lastEnduranceTs = new Date(); await manager.save(character); // Apply XP boost from community (dans la transaction) if (result.winner === 'player') { const xpBoost = await this.communityService.getActiveMultiplier('xp_boost'); if (xpBoost > 1.0) { const bonusXp = Math.floor(result.xpEarned * (xpBoost - 1)); if (bonusXp > 0) { const boosted = applyXpGain(character.level, character.xp, bonusXp); character.xp = boosted.newXp; character.level = boosted.newLevel; character.statPoints = (character.statPoints ?? 0) + boosted.statPointsGained; await manager.save(character); } } } // Loot matériaux — drop rate variable par difficulté relative let lootMaterial: { name: string; quantity: number } | null = null; let lootedMaterialId: string | null = null; if (result.winner === 'player' && monster.dropMaterialId) { const { dropRate, dropQty } = computeDropRate(character.level, monster.minLevel, monster.maxLevel); if (Math.random() < dropRate) { await addMaterialInTx(manager, character.id, monster.dropMaterialId, dropQty); lootMaterial = { name: 'matériau', quantity: dropQty }; lootedMaterialId = monster.dropMaterialId; } } // 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, lootMaterialId: lootedMaterialId, lootQuantity: lootMaterial?.quantity ?? 0, }); await manager.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.`); } if (lootMaterial) { summaryParts.push(`Loot : ${lootMaterial.quantity} matériau${lootMaterial.quantity > 1 ? 'x' : ''} obtenu${lootMaterial.quantity > 1 ? 's' : ''} !`); } return { characterId: character.id, lootedMaterialId, response: { 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, loot: lootMaterial, }, character: { level: character.level, xp: character.xp, xpToNextLevel: xpRequiredForLevel(character.level), gold: character.gold, hpCurrent: character.hpCurrent, hpMax: character.hpMax, enduranceCurrent: character.enduranceSaved, enduranceMax: character.enduranceMax, statPoints: character.statPoints ?? 0, }, }, }; }); // Events émis APRÈS la transaction (fire-and-forget) if (txResult.response.winner === 'player') { const cid = txResult.characterId; this.eventEmitter.emit('achievement.check', { characterId: cid, type: 'combat_wins', increment: 1 }); this.eventEmitter.emit('achievement.check', { characterId: cid, type: 'level_reached', increment: 0, absolute: txResult.response.character.level }); this.eventEmitter.emit('achievement.check', { characterId: cid, type: 'gold_accumulated', increment: 0, absolute: txResult.response.rewards.gold }); this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_monsters_killed', increment: 1 }); this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_gold_earned', increment: txResult.response.rewards.gold }); this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_any', increment: 1, zone: monster.zone }); this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_monster', targetId: monster.id, increment: 1, zone: monster.zone }); if (txResult.lootedMaterialId) { this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'gather_material', targetId: txResult.lootedMaterialId, increment: 1 }); } } this.setCooldown(user.id, COOLDOWN_SINGLE_MS); return txResult.response; } async startMultiCombat(dto: StartCombatDto, user: User, count: number) { this.checkCooldown(user.id); const monster = await this.monsterService.findOne(dto.monsterId); const txResult = await this.dataSource.transaction(async (manager) => { const character = await manager .getRepository(Character) .createQueryBuilder('c') .setLock('pessimistic_write') .where('c.user_id = :userId', { userId: user.id }) .getOne(); if (!character) throw new BadRequestException('Aucun personnage trouvé'); // Équipement (une seule fois) 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; const itemForceBonus = (equipped.weapon?.item.forceBonus ?? 0) + (equipped.armor?.item.forceBonus ?? 0); const itemAgiliteBonus = (equipped.weapon?.item.agiliteBonus ?? 0) + (equipped.armor?.item.agiliteBonus ?? 0); const itemIntelligenceBonus = (equipped.weapon?.item.intelligenceBonus ?? 0) + (equipped.armor?.item.intelligenceBonus ?? 0); const itemChanceBonus = (equipped.weapon?.item.chanceBonus ?? 0) + (equipped.armor?.item.chanceBonus ?? 0); const totals = { wins: 0, losses: 0, xp: 0, gold: 0, goldLost: 0, loot: [] as { name: string; quantity: number }[], levelsGained: 0 }; const lootedMaterialIds: string[] = []; let combatsDone = 0; for (let i = 0; i < count; i++) { // Endurance lazy calc const elapsed = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000; const recharge = Math.floor(elapsed / 3); const enduranceCurrent = Math.min(character.enduranceSaved + recharge, character.enduranceMax); if (enduranceCurrent < COMBAT_ENDURANCE_COST || character.hpCurrent <= 0) break; const playerStats: CombatantStats = { name: character.name, hpCurrent: character.hpCurrent, hpMax: character.hpMax, force: character.force + itemForceBonus, agilite: character.agilite + itemAgiliteBonus, intelligence: character.intelligence + itemIntelligenceBonus, chance: character.chance + itemChanceBonus, attack: weaponAttack, defense: armorDefense, attackType: dto.attackType, }; const monsterStats: CombatantStats = { name: monster.name, hpCurrent: monster.hp, hpMax: monster.hp, force: 0, agilite: 0, intelligence: 0, chance: 0, attack: monster.attack, defense: monster.defense, attackType: monster.attackType, }; const result = resolveCombat(playerStats, monsterStats, monster.xpReward, monster.goldMin, monster.goldMax); combatsDone++; let newEnduranceSaved = enduranceCurrent - COMBAT_ENDURANCE_COST; let combatLootMatId: string | null = null; let combatLootQty = 0; if (result.winner === 'player') { const levelUp = applyXpGain(character.level, character.xp, result.xpEarned); character.xp = levelUp.newXp; character.level = levelUp.newLevel; character.statPoints = (character.statPoints ?? 0) + levelUp.statPointsGained; character.gold += result.goldEarned; character.totalGoldEarned = Number(character.totalGoldEarned ?? 0) + result.goldEarned; character.hpCurrent = Math.min(character.hpMax, character.hpCurrent + Math.floor(character.hpMax * VICTORY_HP_REGEN_RATIO)); totals.wins++; totals.xp += result.xpEarned; totals.gold += result.goldEarned; if (levelUp.levelsGained > 0) totals.levelsGained += levelUp.levelsGained; // Loot if (monster.dropMaterialId) { const { dropRate, dropQty } = computeDropRate(character.level, monster.minLevel, monster.maxLevel); if (Math.random() < dropRate) { await addMaterialInTx(manager, character.id, monster.dropMaterialId, dropQty); totals.loot.push({ name: 'matériau', quantity: dropQty }); lootedMaterialIds.push(monster.dropMaterialId); combatLootMatId = monster.dropMaterialId; combatLootQty = dropQty; } } } else { newEnduranceSaved = Math.max(0, newEnduranceSaved - DEFEAT_ENDURANCE_PENALTY); character.hpCurrent = Math.max(1, Math.floor(character.hpMax * DEFEAT_HP_RATIO)); const goldLost = Math.floor(character.gold * DEFEAT_GOLD_LOSS_RATIO); character.gold = Math.max(0, character.gold - goldLost); totals.losses++; totals.goldLost += goldLost; } character.enduranceSaved = newEnduranceSaved; character.lastEnduranceTs = new Date(); // Log combat await manager.save(manager.getRepository(CombatLog).create({ characterId: character.id, monsterId: monster.id, winner: result.winner, totalRounds: result.totalRounds, roundsData: result.rounds, xpEarned: result.xpEarned, goldEarned: result.goldEarned, levelUp: false, lootMaterialId: combatLootMatId, lootQuantity: combatLootQty, })); if (result.winner !== 'player') break; // Arrêt sur défaite } // Save final character state await manager.save(character); return { characterId: character.id, lootedMaterialIds, combatsDone, totals, character: { level: character.level, xp: character.xp, xpToNextLevel: xpRequiredForLevel(character.level), gold: character.gold, hpCurrent: character.hpCurrent, hpMax: character.hpMax, enduranceCurrent: character.enduranceSaved, enduranceMax: character.enduranceMax, statPoints: character.statPoints ?? 0, }, }; }); // Events groupés APRÈS la transaction (une seule fois) const cid = txResult.characterId; if (txResult.totals.wins > 0) { this.eventEmitter.emit('achievement.check', { characterId: cid, type: 'combat_wins', increment: txResult.totals.wins }); this.eventEmitter.emit('achievement.check', { characterId: cid, type: 'level_reached', increment: 0, absolute: txResult.character.level }); this.eventEmitter.emit('achievement.check', { characterId: cid, type: 'gold_accumulated', increment: 0, absolute: txResult.character.gold }); this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_monsters_killed', increment: txResult.totals.wins }); this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_gold_earned', increment: txResult.totals.gold }); this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_any', increment: txResult.totals.wins, zone: monster.zone }); this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_monster', targetId: monster.id, increment: txResult.totals.wins, zone: monster.zone }); for (const matId of txResult.lootedMaterialIds) { this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'gather_material', targetId: matId, increment: 1 }); } } this.setCooldown(user.id, COOLDOWN_MULTI_MS); return { mode: 'multi', count: txResult.combatsDone, totals: txResult.totals, character: txResult.character, }; } 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, lootQuantity: true, createdAt: true, monster: { id: true, name: true, minLevel: true, maxLevel: true } as any, }, }); } }