feat: Sprint 2 — moteur de combat PvE TetaRdPG
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.
This commit is contained in:
195
src/combat/combat.service.ts
Normal file
195
src/combat/combat.service.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user