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:
202
src/combat/combat.engine.ts
Normal file
202
src/combat/combat.engine.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { AttackType } from '../monster/monster.entity';
|
||||
|
||||
// ---------- Types ----------
|
||||
|
||||
export interface CombatantStats {
|
||||
name: string;
|
||||
hpCurrent: number;
|
||||
hpMax: number;
|
||||
force: number;
|
||||
agilite: number;
|
||||
intelligence: number;
|
||||
chance: number;
|
||||
attack: number; // arme (0 Sprint 2) pour joueur, flat pour monstre
|
||||
defense: number; // 0 joueur Sprint 2
|
||||
attackType: AttackType;
|
||||
}
|
||||
|
||||
export interface AttackResult {
|
||||
damage: number;
|
||||
isCrit: boolean;
|
||||
isDodged: boolean;
|
||||
log: string;
|
||||
}
|
||||
|
||||
export interface RoundLog {
|
||||
round: number;
|
||||
playerAttack: AttackResult;
|
||||
monsterAttack: AttackResult;
|
||||
playerHp: number;
|
||||
monsterHp: number;
|
||||
log: string[];
|
||||
}
|
||||
|
||||
export interface CombatResult {
|
||||
winner: 'player' | 'monster';
|
||||
rounds: RoundLog[];
|
||||
xpEarned: number;
|
||||
goldEarned: number;
|
||||
totalRounds: number;
|
||||
}
|
||||
|
||||
// ---------- Formules (déterministes pour les tests, mais utilisent Math.random) ----------
|
||||
|
||||
const MAX_ROUNDS = 30; // sécurité anti-boucle infinie
|
||||
|
||||
function statForAttackType(stats: CombatantStats): number {
|
||||
switch (stats.attackType) {
|
||||
case 'melee': return stats.force;
|
||||
case 'ranged': return stats.agilite;
|
||||
case 'magic': return stats.intelligence;
|
||||
}
|
||||
}
|
||||
|
||||
export function calcPlayerDamage(player: CombatantStats, monsterDefense: number): number {
|
||||
const stat = statForAttackType(player);
|
||||
const raw = player.attack + Math.floor(stat * 1.5);
|
||||
return Math.max(1, raw - monsterDefense);
|
||||
}
|
||||
|
||||
export function calcMonsterDamage(monster: CombatantStats, playerDefense: number): number {
|
||||
return Math.max(1, monster.attack - playerDefense);
|
||||
}
|
||||
|
||||
export function rollCrit(chance: number): boolean {
|
||||
const rate = 0.05 + chance * 0.002;
|
||||
return Math.random() < rate;
|
||||
}
|
||||
|
||||
export function rollDodge(chance: number): boolean {
|
||||
const rate = 0.05 + chance * 0.001;
|
||||
return Math.random() < rate;
|
||||
}
|
||||
|
||||
function resolvePlayerAttack(
|
||||
player: CombatantStats,
|
||||
monster: CombatantStats,
|
||||
): AttackResult {
|
||||
// Les monstres ne dodgent pas en Sprint 2
|
||||
const baseDamage = calcPlayerDamage(player, monster.defense);
|
||||
const isCrit = rollCrit(player.chance);
|
||||
const damage = isCrit ? Math.floor(baseDamage * 1.5) : baseDamage;
|
||||
|
||||
const critText = isCrit ? ' (CRITIQUE !)' : '';
|
||||
const log = `${player.name} attaque ${monster.name} pour ${damage} dégâts${critText}`;
|
||||
|
||||
return { damage, isCrit, isDodged: false, log };
|
||||
}
|
||||
|
||||
function resolveMonsterAttack(
|
||||
monster: CombatantStats,
|
||||
player: CombatantStats,
|
||||
): AttackResult {
|
||||
const isDodged = rollDodge(player.chance);
|
||||
if (isDodged) {
|
||||
return {
|
||||
damage: 0,
|
||||
isCrit: false,
|
||||
isDodged: true,
|
||||
log: `${player.name} esquive l'attaque de ${monster.name} !`,
|
||||
};
|
||||
}
|
||||
|
||||
const damage = calcMonsterDamage(monster, player.defense);
|
||||
return {
|
||||
damage,
|
||||
isCrit: false,
|
||||
isDodged: false,
|
||||
log: `${monster.name} attaque ${player.name} pour ${damage} dégâts.`,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- Résolution complète ----------
|
||||
|
||||
export function resolveCombat(
|
||||
player: CombatantStats,
|
||||
monster: CombatantStats,
|
||||
xpReward: number,
|
||||
goldMin: number,
|
||||
goldMax: number,
|
||||
): CombatResult {
|
||||
let playerHp = player.hpCurrent;
|
||||
let monsterHp = monster.hpCurrent;
|
||||
const rounds: RoundLog[] = [];
|
||||
|
||||
for (let round = 1; round <= MAX_ROUNDS; round++) {
|
||||
// -- Tour joueur --
|
||||
const playerAttack = resolvePlayerAttack(player, monster);
|
||||
monsterHp = Math.max(0, monsterHp - playerAttack.damage);
|
||||
|
||||
// -- Tour monstre (seulement si encore vivant) --
|
||||
let monsterAttack: AttackResult;
|
||||
if (monsterHp > 0) {
|
||||
monsterAttack = resolveMonsterAttack(monster, player);
|
||||
playerHp = Math.max(0, playerHp - monsterAttack.damage);
|
||||
} else {
|
||||
monsterAttack = { damage: 0, isCrit: false, isDodged: false, log: '' };
|
||||
}
|
||||
|
||||
rounds.push({
|
||||
round,
|
||||
playerAttack,
|
||||
monsterAttack,
|
||||
playerHp,
|
||||
monsterHp,
|
||||
log: [
|
||||
playerAttack.log,
|
||||
...(monsterAttack.log ? [monsterAttack.log] : []),
|
||||
`HP — ${player.name}: ${playerHp}/${player.hpMax} | ${monster.name}: ${monsterHp}/${monster.hpCurrent}`,
|
||||
],
|
||||
});
|
||||
|
||||
if (monsterHp <= 0) {
|
||||
const goldEarned = goldMin + Math.floor(Math.random() * (goldMax - goldMin + 1));
|
||||
return { winner: 'player', rounds, xpEarned: xpReward, goldEarned, totalRounds: round };
|
||||
}
|
||||
|
||||
if (playerHp <= 0) {
|
||||
return { winner: 'monster', rounds, xpEarned: 0, goldEarned: 0, totalRounds: round };
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout (ne devrait pas arriver avec des valeurs équilibrées)
|
||||
return { winner: 'monster', rounds, xpEarned: 0, goldEarned: 0, totalRounds: MAX_ROUNDS };
|
||||
}
|
||||
|
||||
// ---------- Level up ----------
|
||||
|
||||
export function xpRequiredForLevel(level: number): number {
|
||||
return Math.round(100 * Math.pow(level, 1.5));
|
||||
}
|
||||
|
||||
export interface LevelUpResult {
|
||||
newLevel: number;
|
||||
newXp: number;
|
||||
statPointsGained: number;
|
||||
levelsGained: number;
|
||||
}
|
||||
|
||||
export function applyXpGain(currentLevel: number, currentXp: number, xpEarned: number): LevelUpResult {
|
||||
let level = currentLevel;
|
||||
let xp = currentXp + xpEarned;
|
||||
let statPointsGained = 0;
|
||||
|
||||
// Chaîne de level up
|
||||
while (level < 100) {
|
||||
const required = xpRequiredForLevel(level + 1);
|
||||
if (xp >= required) {
|
||||
xp -= required;
|
||||
level++;
|
||||
statPointsGained += 5;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
newLevel: level,
|
||||
newXp: xp,
|
||||
statPointsGained,
|
||||
levelsGained: level - currentLevel,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user