All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
Quest system: 4 entities (quest_arcs, quests, player_quests, player_quest_arcs) Arc "Les Marais du Têtard" (4 quêtes narratives) 3 quêtes standalone répétables (chasse/forge/craft) 5 achievements liés (quests_completed + quest_arc_completed) Event-driven: combat/forge/craft/loot émettent quest.progress API: available, active, completed, accept, claim, arcs Rebalance: Endurance coût combat 10→5, regen 6min→3min (20/h), repos 20→10 Dégâts joueur +3 base (plus de combats de 13 tours au level 1) Défaite endurance penalty 50→25 XP monstres réduite (25→8 Têtard, 130→50 Golem) — quêtes = source principale
287 lines
11 KiB
TypeScript
287 lines
11 KiB
TypeScript
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { DataSource, Repository } from 'typeorm';
|
||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||
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 { ItemService } from '../item/item.service';
|
||
import { MaterialService } from '../material/material.service';
|
||
import { CommunityService } from '../community/community.service';
|
||
import {
|
||
resolveCombat,
|
||
applyXpGain,
|
||
xpRequiredForLevel,
|
||
CombatantStats,
|
||
} from './combat.engine';
|
||
|
||
const COMBAT_ENDURANCE_COST = 5;
|
||
const DEFEAT_ENDURANCE_PENALTY = 25;
|
||
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,
|
||
private readonly itemService: ItemService,
|
||
private readonly materialService: MaterialService,
|
||
private readonly communityService: CommunityService,
|
||
private readonly eventEmitter: EventEmitter2,
|
||
private readonly dataSource: DataSource,
|
||
) {}
|
||
|
||
async startCombat(dto: StartCombatDto, user: User) {
|
||
// Charger le monstre (hors transaction — lecture seule)
|
||
const monster = await this.monsterService.findOne(dto.monsterId);
|
||
|
||
// Transaction isolée — empêche les combats simultanés sur le même perso
|
||
return this.dataSource.transaction(async (manager) => {
|
||
// SELECT ... FOR UPDATE — verrouille le personnage
|
||
const character = await manager
|
||
.getRepository(Character)
|
||
.createQueryBuilder('c')
|
||
.setLock('pessimistic_write')
|
||
.where('c.user_id = :userId', { userId: user.id })
|
||
.getOne();
|
||
|
||
if (!character) throw new BadRequestException('Aucun personnage trouvé');
|
||
|
||
// 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');
|
||
}
|
||
|
||
// Charger l'équipement actif du personnage
|
||
const equipped = await this.itemService.getEquippedItems(character.id);
|
||
const FORGE_BONUS_PER_LEVEL = 2;
|
||
const weaponAttack = equipped.weapon
|
||
? equipped.weapon.item.attackBonus + equipped.weapon.forgeLevel * FORGE_BONUS_PER_LEVEL
|
||
: 0;
|
||
const armorDefense = equipped.armor
|
||
? equipped.armor.item.defenseBonus + equipped.armor.forgeLevel * FORGE_BONUS_PER_LEVEL
|
||
: 0;
|
||
|
||
// Item stat bonuses
|
||
const itemForceBonus = (equipped.weapon?.item.forceBonus ?? 0) + (equipped.armor?.item.forceBonus ?? 0);
|
||
const itemAgiliteBonus = (equipped.weapon?.item.agiliteBonus ?? 0) + (equipped.armor?.item.agiliteBonus ?? 0);
|
||
const itemIntelligenceBonus = (equipped.weapon?.item.intelligenceBonus ?? 0) + (equipped.armor?.item.intelligenceBonus ?? 0);
|
||
const itemChanceBonus = (equipped.weapon?.item.chanceBonus ?? 0) + (equipped.armor?.item.chanceBonus ?? 0);
|
||
|
||
// Construire les stats des combattants
|
||
const playerStats: CombatantStats = {
|
||
name: character.name,
|
||
hpCurrent: character.hpCurrent,
|
||
hpMax: character.hpMax,
|
||
force: character.force + itemForceBonus,
|
||
agilite: character.agilite + itemAgiliteBonus,
|
||
intelligence: character.intelligence + itemIntelligenceBonus,
|
||
chance: character.chance + itemChanceBonus,
|
||
attack: weaponAttack,
|
||
defense: armorDefense,
|
||
attackType: dto.attackType,
|
||
};
|
||
|
||
const monsterStats: CombatantStats = {
|
||
name: monster.name,
|
||
hpCurrent: monster.hp,
|
||
hpMax: monster.hp,
|
||
force: 0,
|
||
agilite: 0,
|
||
intelligence: 0,
|
||
chance: 0,
|
||
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;
|
||
character.totalGoldEarned = Number(character.totalGoldEarned ?? 0) + 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 le personnage (dans la transaction)
|
||
character.hpCurrent = newHp;
|
||
character.enduranceSaved = newEnduranceSaved;
|
||
character.lastEnduranceTs = new Date();
|
||
await manager.save(character);
|
||
|
||
// Apply XP boost from community (dans la transaction)
|
||
if (result.winner === 'player') {
|
||
const xpBoost = await this.communityService.getActiveMultiplier('xp_boost');
|
||
if (xpBoost > 1.0) {
|
||
const bonusXp = Math.floor(result.xpEarned * (xpBoost - 1));
|
||
if (bonusXp > 0) {
|
||
const boosted = applyXpGain(character.level, character.xp, bonusXp);
|
||
character.xp = boosted.newXp;
|
||
character.level = boosted.newLevel;
|
||
character.statPoints = (character.statPoints ?? 0) + boosted.statPointsGained;
|
||
await manager.save(character);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Loot matériaux — 40% de chance après victoire
|
||
let lootMaterial: { name: string; quantity: number } | null = null;
|
||
if (result.winner === 'player' && monster.dropMaterialId && Math.random() < 0.4) {
|
||
await this.materialService.addMaterial(character.id, monster.dropMaterialId, 1);
|
||
lootMaterial = { name: 'matériau', quantity: 1 };
|
||
this.eventEmitter.emit('quest.progress', {
|
||
characterId: character.id, type: 'gather_material', targetId: monster.dropMaterialId, increment: 1,
|
||
});
|
||
}
|
||
|
||
// 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 manager.save(combatLog);
|
||
|
||
// Events émis après la transaction (fire-and-forget)
|
||
if (result.winner === 'player') {
|
||
this.eventEmitter.emit('achievement.check', {
|
||
characterId: character.id, type: 'combat_wins', increment: 1,
|
||
});
|
||
this.eventEmitter.emit('achievement.check', {
|
||
characterId: character.id, type: 'level_reached', increment: 0, absolute: character.level,
|
||
});
|
||
this.eventEmitter.emit('achievement.check', {
|
||
characterId: character.id, type: 'gold_accumulated', increment: 0, absolute: Number(character.totalGoldEarned),
|
||
});
|
||
this.eventEmitter.emit('community.contribute', {
|
||
characterId: character.id, type: 'total_monsters_killed', increment: 1,
|
||
});
|
||
this.eventEmitter.emit('community.contribute', {
|
||
characterId: character.id, type: 'total_gold_earned', increment: result.goldEarned,
|
||
});
|
||
// Quest progress
|
||
this.eventEmitter.emit('quest.progress', {
|
||
characterId: character.id, type: 'kill_any', increment: 1,
|
||
});
|
||
this.eventEmitter.emit('quest.progress', {
|
||
characterId: character.id, type: 'kill_monster', targetId: monster.id, increment: 1,
|
||
});
|
||
}
|
||
|
||
// 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.`);
|
||
}
|
||
|
||
if (lootMaterial) {
|
||
summaryParts.push(`Loot : 1 matériau obtenu !`);
|
||
}
|
||
|
||
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,
|
||
loot: lootMaterial,
|
||
},
|
||
character: {
|
||
level: character.level,
|
||
xp: character.xp,
|
||
xpToNextLevel: xpRequiredForLevel(character.level),
|
||
gold: character.gold,
|
||
hpCurrent: character.hpCurrent,
|
||
hpMax: character.hpMax,
|
||
enduranceCurrent: character.enduranceSaved,
|
||
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,
|
||
},
|
||
});
|
||
}
|
||
}
|