fix: multi-combat single transaction — élimine lock contention
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s

This commit is contained in:
2026-03-24 20:51:31 +01:00
parent 6ffc867ef7
commit 909b8da77f

View File

@@ -288,36 +288,151 @@ export class CombatService {
} }
async startMultiCombat(dto: StartCombatDto, user: User, count: number) { async startMultiCombat(dto: StartCombatDto, user: User, count: number) {
const results: any[] = []; 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 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++) { for (let i = 0; i < count; i++) {
try { // Endurance lazy calc
const result = await this.startCombat(dto, user); const elapsed = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
results.push(result); 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;
if (result.winner === 'player') { 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.wins++;
totals.xp += result.rewards.xp; totals.xp += result.xpEarned;
totals.gold += result.rewards.gold; totals.gold += result.goldEarned;
if (result.rewards.levelUp) totals.levelsGained++; if (levelUp.levelsGained > 0) totals.levelsGained += levelUp.levelsGained;
if (result.rewards.loot) totals.loot.push(result.rewards.loot);
} else { // Loot
totals.losses++; if (monster.dropMaterialId) {
totals.goldLost += result.rewards.goldLost ?? 0; const { dropRate, dropQty } = computeDropRate(character.level, monster.minLevel, monster.maxLevel);
break; // Défaite = arrêt de la série if (Math.random() < dropRate) {
await this.materialService.addMaterial(character.id, monster.dropMaterialId, dropQty);
totals.loot.push({ name: 'matériau', quantity: dropQty });
lootedMaterialIds.push(monster.dropMaterialId);
} }
} catch { }
break; // Endurance insuffisante ou autre erreur = arrêt } 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,
}));
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 });
for (const matId of txResult.lootedMaterialIds) {
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'gather_material', targetId: matId, increment: 1 });
} }
} }
const lastResult = results[results.length - 1];
return { return {
mode: 'multi', mode: 'multi',
count: results.length, count: txResult.combatsDone,
totals, totals: txResult.totals,
lastResult, character: txResult.character,
character: lastResult?.character,
}; };
} }