Files
TetaRdPG/src/character/character.service.ts
Tetardtek 7651f3d8aa
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
feat(sprint5): quest system + arcs + rebalance endurance/damage/xp
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
2026-03-24 16:34:37 +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 = 3; // 1 pt d'endurance toutes les 3 min = 20 pts/heure
const REST_ENDURANCE_COST = 10;
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,
};
});
}
}