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) {
|
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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user