Files
TetaRdPG/src/combat/combat.engine.ts
Tetardtek 6d1230d16a 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.
2026-03-15 06:10:06 +01:00

203 lines
5.3 KiB
TypeScript

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,
};
}