Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 30s
- 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
348 lines
14 KiB
TypeScript
348 lines
14 KiB
TypeScript
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,
|
||
},
|
||
});
|
||
}
|
||
}
|