Files
TetaRdPG/src/character/character.service.ts
Tetardtek 214045c7ce
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
fix: level-up formula uses current level, add xpToNextLevel to API
XP threshold was computed on level+1 (target), making early levels too
steep (283 XP for level 2 instead of 100). Now uses current level:
level 1→2 = 100 XP, level 2→3 = 283 XP, level 10→11 = 3162 XP.

Added xpToNextLevel field to character and combat responses so the
frontend can display accurate progress bars.
2026-03-24 16:02:51 +01:00

213 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Character>,
@InjectRepository(LevelThreshold)
private readonly levelThresholdRepository: Repository<LevelThreshold>,
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,
};
});
}
}