Moteur combat stateless (POST /api/combat/start résout le combat complet). Formules GDD : Mêlée/Distance/Magie × 1.5, critique (5% + Chance×0.2%), esquive (5% + Chance×0.1%). 5 monstres seedés (Têtard Vase → Golem de Boue, level 1–9). Level up : XP → seuil atteint → level++, +5 statPoints. Persiste combat_logs (jsonb rounds). Validé : victoire, défaite, 401, 400, 404.
196 lines
6.8 KiB
TypeScript
196 lines
6.8 KiB
TypeScript
import { Injectable, BadRequestException } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { Repository } from 'typeorm';
|
||
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 {
|
||
resolveCombat,
|
||
applyXpGain,
|
||
CombatantStats,
|
||
} from './combat.engine';
|
||
|
||
const COMBAT_ENDURANCE_COST = 10;
|
||
const DEFEAT_ENDURANCE_PENALTY = 50;
|
||
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,
|
||
) {}
|
||
|
||
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
|
||
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);
|
||
|
||
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');
|
||
}
|
||
|
||
// 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: 0, // pas d'arme Sprint 2
|
||
defense: 0, // pas d'armure Sprint 2
|
||
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);
|
||
}
|
||
|
||
// Sauvegarder l'endurance (lazy reset)
|
||
character.hpCurrent = newHp;
|
||
character.enduranceSaved = newEnduranceSaved;
|
||
character.lastEnduranceTs = new Date();
|
||
await this.characterRepository.save(character);
|
||
|
||
// 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.`);
|
||
}
|
||
} else {
|
||
summaryParts.push(`Défaite au tour ${result.totalRounds}. Retour à l'auberge.`);
|
||
if (goldLost > 0) summaryParts.push(`−${goldLost} Or perdu.`);
|
||
}
|
||
|
||
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,
|
||
},
|
||
character: {
|
||
level: character.level,
|
||
xp: character.xp,
|
||
gold: character.gold,
|
||
hpCurrent: character.hpCurrent,
|
||
hpMax: character.hpMax,
|
||
enduranceCurrent: character.enduranceSaved, // déjà le nouveau enduranceSaved post-combat
|
||
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,
|
||
},
|
||
});
|
||
}
|
||
}
|