import { Injectable, ConflictException, NotFoundException, BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { Character } from './entities/character.entity'; import { LevelThreshold } from './entities/level-threshold.entity'; import { CreateCharacterDto } from './dto/create-character.dto'; import { DistributeStatsDto } from './dto/distribute-stats.dto'; import { User } from '../user/user.entity'; import { xpRequiredForLevel } from '../combat/combat.engine'; const STAT_POOL = 10; // 5 stats × 1 base + 5 points à distribuer const ENDURANCE_REGEN_MINUTES = 6; // 1 pt d'endurance toutes les 6 min = 10 pts/heure const REST_ENDURANCE_COST = 20; const REST_HP_REGEN_RATIO = 0.5; // +50% hpMax @Injectable() export class CharacterService { constructor( @InjectRepository(Character) private readonly characterRepository: Repository, @InjectRepository(LevelThreshold) private readonly levelThresholdRepository: Repository, private readonly dataSource: DataSource, ) {} // Pattern lazy calculation — pas de timer actif private calculateEndurance(character: Character): number { const elapsedMinutes = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000; const recharge = Math.floor(elapsedMinutes / ENDURANCE_REGEN_MINUTES); return Math.min(character.enduranceSaved + recharge, character.enduranceMax); } async create(dto: CreateCharacterDto, user: User) { const totalStats = dto.force + dto.agilite + dto.intelligence + dto.chance + dto.vitalite; if (totalStats !== STAT_POOL) { throw new BadRequestException( `La somme des stats doit être égale à ${STAT_POOL} (reçu : ${totalStats})`, ); } const existing = await this.characterRepository.findOne({ where: { userId: user.id }, }); if (existing) { throw new ConflictException('Ce joueur possède déjà un personnage'); } const character = this.characterRepository.create({ userId: user.id, name: dto.name, force: dto.force, agilite: dto.agilite, intelligence: dto.intelligence, chance: dto.chance, vitalite: dto.vitalite, enduranceSaved: 100, lastEnduranceTs: new Date(), enduranceMax: 100, }); const saved = await this.characterRepository.save(character); return { ...saved, enduranceCurrent: this.calculateEndurance(saved), xpToNextLevel: xpRequiredForLevel(saved.level), }; } async findByUser(user: User) { const character = await this.characterRepository.findOne({ where: { userId: user.id }, }); if (!character) { throw new NotFoundException('Aucun personnage trouvé pour ce joueur'); } return { ...character, enduranceCurrent: this.calculateEndurance(character), xpToNextLevel: xpRequiredForLevel(character.level), }; } async getEndurance( user: User, ): Promise<{ enduranceCurrent: number; enduranceMax: number; rechargeRatePerHour: number }> { const character = await this.characterRepository.findOne({ where: { userId: user.id }, }); if (!character) { throw new NotFoundException('Aucun personnage trouvé pour ce joueur'); } return { enduranceCurrent: this.calculateEndurance(character), enduranceMax: character.enduranceMax, rechargeRatePerHour: 60 / ENDURANCE_REGEN_MINUTES, }; } async distributeStats(dto: DistributeStatsDto, user: User) { return this.dataSource.transaction(async (manager) => { const character = await manager .getRepository(Character) .createQueryBuilder('c') .setLock('pessimistic_write') .where('c.user_id = :userId', { userId: user.id }) .getOne(); if (!character) throw new NotFoundException('Aucun personnage trouvé'); const totalToDistribute = (dto.force ?? 0) + (dto.agilite ?? 0) + (dto.intelligence ?? 0) + (dto.chance ?? 0) + (dto.vitalite ?? 0); if (totalToDistribute <= 0) { throw new BadRequestException('Aucun point à distribuer'); } if (totalToDistribute > (character.statPoints ?? 0)) { throw new BadRequestException( `Points insuffisants (${character.statPoints ?? 0} disponibles, ${totalToDistribute} demandés)`, ); } character.force += dto.force ?? 0; character.agilite += dto.agilite ?? 0; character.intelligence += dto.intelligence ?? 0; character.chance += dto.chance ?? 0; character.vitalite += dto.vitalite ?? 0; character.statPoints = (character.statPoints ?? 0) - totalToDistribute; // Vitalité augmente HP max (+10 par point) const vitaliteAdded = dto.vitalite ?? 0; if (vitaliteAdded > 0) { character.hpMax += vitaliteAdded * 10; character.hpCurrent += vitaliteAdded * 10; // bonus immédiat } await manager.save(character); return { statPoints: character.statPoints, stats: { force: character.force, agilite: character.agilite, intelligence: character.intelligence, chance: character.chance, vitalite: character.vitalite, }, hpMax: character.hpMax, }; }); } async rest(user: User) { return this.dataSource.transaction(async (manager) => { const character = await manager .getRepository(Character) .createQueryBuilder('c') .setLock('pessimistic_write') .where('c.user_id = :userId', { userId: user.id }) .getOne(); if (!character) throw new NotFoundException('Aucun personnage trouvé'); if (character.hpCurrent >= character.hpMax) { throw new BadRequestException('PV déjà au maximum'); } // Calculer endurance const elapsedMinutes = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000; const recharge = Math.floor(elapsedMinutes / ENDURANCE_REGEN_MINUTES); const enduranceCurrent = Math.min(character.enduranceSaved + recharge, character.enduranceMax); if (enduranceCurrent < REST_ENDURANCE_COST) { throw new BadRequestException( `Endurance insuffisante (${enduranceCurrent}/${REST_ENDURANCE_COST} requis)`, ); } const hpBefore = character.hpCurrent; character.hpCurrent = Math.min( character.hpMax, character.hpCurrent + Math.floor(character.hpMax * REST_HP_REGEN_RATIO), ); character.enduranceSaved = enduranceCurrent - REST_ENDURANCE_COST; character.lastEnduranceTs = new Date(); await manager.save(character); return { hpBefore, hpAfter: character.hpCurrent, hpMax: character.hpMax, healed: character.hpCurrent - hpBefore, enduranceCurrent: character.enduranceSaved, enduranceMax: character.enduranceMax, }; }); } }