feat(sprint5): audit fixes — transactions, indexes, stat distribution, rest, forge cost
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 35s
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 35s
P0 — Race conditions fixées avec pessimistic_write transactions : combat (double-spend endurance), forge (double upgrade), craft (consumeMaterials atomique), equip (item swap). Forge : coût or (50-1000) + endurance (15) ajouté. Combat : item stat bonuses (force/agilite/intelligence/chance) appliqués. P1 — Features manquantes : POST /api/characters/stats — distribution stat points (avec lock). POST /api/characters/rest — repos auberge (+50% HP, -20 endurance). Vitalité : +10 HP max par point distribué. P2 — Indexes DB ajoutés : character_id sur character_items, character_materials, combat_logs, craft_jobs, player_achievements, community_contributions. Composite (characterId, materialId) sur character_materials. period sur hall_of_fame. achievement_id sur player_achievements. P3 — Cleanup : @nestjs/jwt et pg retirés de package.json.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from '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';
|
||||
@@ -35,219 +35,217 @@ export class CombatService {
|
||||
private readonly materialService: MaterialService,
|
||||
private readonly communityService: CommunityService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async startCombat(dto: StartCombatDto, user: User) {
|
||||
// Charger le personnage
|
||||
const character = await this.characterRepository.findOne({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
if (!character) throw new BadRequestException('Aucun personnage trouvé');
|
||||
|
||||
// Charger le monstre
|
||||
// Charger le monstre (hors transaction — lecture seule)
|
||||
const monster = await this.monsterService.findOne(dto.monsterId);
|
||||
|
||||
// Calculer l'endurance actuelle (lazy pattern)
|
||||
const elapsedMinutes = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
|
||||
const recharge = Math.floor(elapsedMinutes / 6);
|
||||
const enduranceCurrent = Math.min(character.enduranceSaved + recharge, character.enduranceMax);
|
||||
// Transaction isolée — empêche les combats simultanés sur le même perso
|
||||
return 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 (enduranceCurrent < COMBAT_ENDURANCE_COST) {
|
||||
throw new BadRequestException(
|
||||
`Endurance insuffisante (${enduranceCurrent}/${COMBAT_ENDURANCE_COST} requis)`,
|
||||
);
|
||||
}
|
||||
if (!character) throw new BadRequestException('Aucun personnage trouvé');
|
||||
|
||||
if (character.hpCurrent <= 0) {
|
||||
throw new BadRequestException('Votre personnage est KO — récupérez d\'abord vos PV');
|
||||
}
|
||||
// Calculer l'endurance actuelle (lazy pattern)
|
||||
const elapsedMinutes = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
|
||||
const recharge = Math.floor(elapsedMinutes / 6);
|
||||
const enduranceCurrent = Math.min(character.enduranceSaved + recharge, character.enduranceMax);
|
||||
|
||||
// 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;
|
||||
|
||||
// Construire les stats des combattants
|
||||
const playerStats: CombatantStats = {
|
||||
name: character.name,
|
||||
hpCurrent: character.hpCurrent,
|
||||
hpMax: character.hpMax,
|
||||
force: character.force,
|
||||
agilite: character.agilite,
|
||||
intelligence: character.intelligence,
|
||||
chance: character.chance,
|
||||
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, // pas de crit/esquive pour les monstres Sprint 2
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
// Track total gold earned (for achievements)
|
||||
if (result.winner === 'player') {
|
||||
character.totalGoldEarned = Number(character.totalGoldEarned ?? 0) + result.goldEarned;
|
||||
}
|
||||
|
||||
// Sauvegarder l'endurance (lazy reset)
|
||||
character.hpCurrent = newHp;
|
||||
character.enduranceSaved = newEnduranceSaved;
|
||||
character.lastEnduranceTs = new Date();
|
||||
await this.characterRepository.save(character);
|
||||
|
||||
// Emit achievement & community events
|
||||
if (result.winner === 'player') {
|
||||
this.eventEmitter.emit('achievement.check', {
|
||||
characterId: character.id,
|
||||
type: 'combat_wins',
|
||||
increment: 1,
|
||||
});
|
||||
this.eventEmitter.emit('achievement.check', {
|
||||
characterId: character.id,
|
||||
type: 'level_reached',
|
||||
increment: 0,
|
||||
absolute: character.level,
|
||||
});
|
||||
this.eventEmitter.emit('achievement.check', {
|
||||
characterId: character.id,
|
||||
type: 'gold_accumulated',
|
||||
increment: 0,
|
||||
absolute: Number(character.totalGoldEarned),
|
||||
});
|
||||
this.eventEmitter.emit('community.contribute', {
|
||||
characterId: character.id,
|
||||
type: 'total_monsters_killed',
|
||||
increment: 1,
|
||||
});
|
||||
this.eventEmitter.emit('community.contribute', {
|
||||
characterId: character.id,
|
||||
type: 'total_gold_earned',
|
||||
increment: result.goldEarned,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply XP boost from community
|
||||
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 this.characterRepository.save(character);
|
||||
}
|
||||
if (enduranceCurrent < COMBAT_ENDURANCE_COST) {
|
||||
throw new BadRequestException(
|
||||
`Endurance insuffisante (${enduranceCurrent}/${COMBAT_ENDURANCE_COST} requis)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Loot matériaux — 40% de chance après victoire si le monstre a un drop_material_id
|
||||
let lootMaterial: { name: string; quantity: number } | null = null;
|
||||
if (result.winner === 'player' && monster.dropMaterialId && Math.random() < 0.4) {
|
||||
await this.materialService.addMaterial(character.id, monster.dropMaterialId, 1);
|
||||
lootMaterial = { name: 'matériau', quantity: 1 };
|
||||
}
|
||||
|
||||
// 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 this.combatLogRepository.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.`);
|
||||
if (character.hpCurrent <= 0) {
|
||||
throw new BadRequestException('Votre personnage est KO — récupérez d\'abord vos PV');
|
||||
}
|
||||
} 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 : 1 matériau obtenu !`);
|
||||
}
|
||||
// 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;
|
||||
|
||||
return {
|
||||
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,
|
||||
gold: character.gold,
|
||||
// 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,
|
||||
enduranceCurrent: character.enduranceSaved, // déjà le nouveau enduranceSaved post-combat
|
||||
enduranceMax: character.enduranceMax,
|
||||
statPoints: character.statPoints ?? 0,
|
||||
},
|
||||
};
|
||||
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 — 40% de chance après victoire
|
||||
let lootMaterial: { name: string; quantity: number } | null = null;
|
||||
if (result.winner === 'player' && monster.dropMaterialId && Math.random() < 0.4) {
|
||||
await this.materialService.addMaterial(character.id, monster.dropMaterialId, 1);
|
||||
lootMaterial = { name: 'matériau', quantity: 1 };
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Events émis après la transaction (fire-and-forget)
|
||||
if (result.winner === 'player') {
|
||||
this.eventEmitter.emit('achievement.check', {
|
||||
characterId: character.id, type: 'combat_wins', increment: 1,
|
||||
});
|
||||
this.eventEmitter.emit('achievement.check', {
|
||||
characterId: character.id, type: 'level_reached', increment: 0, absolute: character.level,
|
||||
});
|
||||
this.eventEmitter.emit('achievement.check', {
|
||||
characterId: character.id, type: 'gold_accumulated', increment: 0, absolute: Number(character.totalGoldEarned),
|
||||
});
|
||||
this.eventEmitter.emit('community.contribute', {
|
||||
characterId: character.id, type: 'total_monsters_killed', increment: 1,
|
||||
});
|
||||
this.eventEmitter.emit('community.contribute', {
|
||||
characterId: character.id, type: 'total_gold_earned', increment: result.goldEarned,
|
||||
});
|
||||
}
|
||||
|
||||
// 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 : 1 matériau obtenu !`);
|
||||
}
|
||||
|
||||
return {
|
||||
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,
|
||||
gold: character.gold,
|
||||
hpCurrent: character.hpCurrent,
|
||||
hpMax: character.hpMax,
|
||||
enduranceCurrent: character.enduranceSaved,
|
||||
enduranceMax: character.enduranceMax,
|
||||
statPoints: character.statPoints ?? 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getHistory(user: User) {
|
||||
|
||||
Reference in New Issue
Block a user