Files
TetaRdPG/src/combat/combat.service.ts
Tetardtek 7651f3d8aa
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
feat(sprint5): quest system + arcs + rebalance endurance/damage/xp
Quest system:
  4 entities (quest_arcs, quests, player_quests, player_quest_arcs)
  Arc "Les Marais du Têtard" (4 quêtes narratives)
  3 quêtes standalone répétables (chasse/forge/craft)
  5 achievements liés (quests_completed + quest_arc_completed)
  Event-driven: combat/forge/craft/loot émettent quest.progress
  API: available, active, completed, accept, claim, arcs

Rebalance:
  Endurance coût combat 10→5, regen 6min→3min (20/h), repos 20→10
  Dégâts joueur +3 base (plus de combats de 13 tours au level 1)
  Défaite endurance penalty 50→25
  XP monstres réduite (25→8 Têtard, 130→50 Golem) — quêtes = source principale
2026-03-24 16:34:37 +01:00

287 lines
11 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, Repository } from 'typeorm';
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;
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
@Injectable()
export class CombatService {
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,
) {}
async startCombat(dto: StartCombatDto, user: User) {
// 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
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 (!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 / 6);
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 — 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 };
this.eventEmitter.emit('quest.progress', {
characterId: character.id, type: 'gather_material', targetId: monster.dropMaterialId, increment: 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,
});
// Quest progress
this.eventEmitter.emit('quest.progress', {
characterId: character.id, type: 'kill_any', increment: 1,
});
this.eventEmitter.emit('quest.progress', {
characterId: character.id, type: 'kill_monster', targetId: monster.id, increment: 1,
});
}
// 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,
xpToNextLevel: xpRequiredForLevel(character.level),
gold: character.gold,
hpCurrent: character.hpCurrent,
hpMax: character.hpMax,
enduranceCurrent: character.enduranceSaved,
enduranceMax: character.enduranceMax,
statPoints: character.statPoints ?? 0,
},
};
});
}
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,
createdAt: true,
monster: { id: true, name: true, minLevel: true, maxLevel: true } as any,
},
});
}
}