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.
203 lines
5.3 KiB
TypeScript
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,
|
|
};
|
|
}
|