fix: multi-combat single transaction — élimine lock contention
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
This commit is contained in:
@@ -288,36 +288,151 @@ export class CombatService {
|
||||
}
|
||||
|
||||
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 lootedMaterialIds: string[] = [];
|
||||
let combatsDone = 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
try {
|
||||
const result = await this.startCombat(dto, user);
|
||||
results.push(result);
|
||||
// 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;
|
||||
|
||||
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.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
|
||||
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 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 {
|
||||
mode: 'multi',
|
||||
count: results.length,
|
||||
totals,
|
||||
lastResult,
|
||||
character: lastResult?.character,
|
||||
count: txResult.combatsDone,
|
||||
totals: txResult.totals,
|
||||
character: txResult.character,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user