Files
TetaRdPG/src/combat/combat.service.ts
Tetardtek efe4b4e372
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 30s
feat: multi-combat ×5/×10 + cooldown anti-spam
- Backend: startMultiCombat boucle séquentielle, arrêt sur défaite
- Frontend: cooldown 1.5s entre combats, boutons ×1/×5/×10
- Frontend: résumé multi-combat (wins/losses, XP/Or/loot totaux)
- Fix: lock contention par spam de clics résolu
2026-03-24 20:21:44 +01:00

348 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
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
@Injectable()
export class CombatService {
constructor(
@InjectRepository(Character)
private readonly characterRepository: Repository<Character>,
@InjectRepository(CombatLog)
private readonly combatLogRepository: Repository<CombatLog>,
private readonly monsterService: MonsterService,
private readonly itemService: ItemService,
private readonly materialService: MaterialService,
private readonly communityService: CommunityService,
private readonly eventEmitter: EventEmitter2,
private readonly dataSource: DataSource,
) {}
async startCombat(dto: StartCombatDto, user: User) {
// 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 this.materialService.addMaterial(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,
});
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 });
}
}
return txResult.response;
}
async startMultiCombat(dto: StartCombatDto, user: User, count: number) {
const results: any[] = [];
const totals = { wins: 0, losses: 0, xp: 0, gold: 0, goldLost: 0, loot: [] as { name: string; quantity: number }[], levelsGained: 0 };
for (let i = 0; i < count; i++) {
try {
const result = await this.startCombat(dto, user);
results.push(result);
if (result.winner === 'player') {
totals.wins++;
totals.xp += result.rewards.xp;
totals.gold += result.rewards.gold;
if (result.rewards.levelUp) totals.levelsGained++;
if (result.rewards.loot) totals.loot.push(result.rewards.loot);
} else {
totals.losses++;
totals.goldLost += result.rewards.goldLost ?? 0;
break; // Défaite = arrêt de la série
}
} catch {
break; // Endurance insuffisante ou autre erreur = arrêt
}
}
const lastResult = results[results.length - 1];
return {
mode: 'multi',
count: results.length,
totals,
lastResult,
character: lastResult?.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,
createdAt: true,
monster: { id: true, name: true, minLevel: true, maxLevel: true } as any,
},
});
}
}