Files
TetaRdPG/src/combat/combat.service.ts
Tetardtek 3ccb4a867c
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 27s
fix: multi-combat n'émettait pas quest.progress kill_monster
Le combat x5 émettait kill_any mais pas kill_monster — les quêtes ciblant
un monstre spécifique ne progressaient pas en batch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 17:47:18 +02:00

510 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { CharacterMaterial } from '../material/character-material.entity';
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
/** Ajouter un matériau dans la transaction courante (pas de connexion séparée). */
async function addMaterialInTx(manager: EntityManager, characterId: string, materialId: string, quantity: number) {
const repo = manager.getRepository(CharacterMaterial);
let entry = await repo.findOne({ where: { characterId, materialId } });
if (entry) {
entry.quantity += quantity;
} else {
entry = repo.create({ characterId, materialId, quantity });
}
await repo.save(entry);
}
const COOLDOWN_SINGLE_MS = 2_000;
const COOLDOWN_MULTI_MS = 8_000;
@Injectable()
export class CombatService {
private readonly cooldowns = new Map<string, number>(); // userId → timestamp
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,
) {}
private checkCooldown(userId: string): void {
const lastCombat = this.cooldowns.get(userId) ?? 0;
const remaining = lastCombat - Date.now();
if (remaining > 0) {
throw new BadRequestException(
`Cooldown actif — attendez ${Math.ceil(remaining / 1000)}s`,
);
}
}
private setCooldown(userId: string, durationMs: number): void {
this.cooldowns.set(userId, Date.now() + durationMs);
}
async startCombat(dto: StartCombatDto, user: User) {
this.checkCooldown(user.id);
// 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 addMaterialInTx(manager, 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,
lootMaterialId: lootedMaterialId,
lootQuantity: lootMaterial?.quantity ?? 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 });
}
}
this.setCooldown(user.id, COOLDOWN_SINGLE_MS);
return txResult.response;
}
async startMultiCombat(dto: StartCombatDto, user: User, count: number) {
this.checkCooldown(user.id);
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++) {
// 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;
let combatLootMatId: string | null = null;
let combatLootQty = 0;
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.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 addMaterialInTx(manager, character.id, monster.dropMaterialId, dropQty);
totals.loot.push({ name: 'matériau', quantity: dropQty });
lootedMaterialIds.push(monster.dropMaterialId);
combatLootMatId = monster.dropMaterialId;
combatLootQty = dropQty;
}
}
} 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,
lootMaterialId: combatLootMatId, lootQuantity: combatLootQty,
}));
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 });
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_monster', targetId: monster.id, 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 });
}
}
this.setCooldown(user.id, COOLDOWN_MULTI_MS);
return {
mode: 'multi',
count: txResult.combatsDone,
totals: txResult.totals,
character: txResult.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,
lootQuantity: true,
createdAt: true,
monster: { id: true, name: true, minLevel: true, maxLevel: true } as any,
},
});
}
}