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
213 lines
6.9 KiB
TypeScript
213 lines
6.9 KiB
TypeScript
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,
|
||
};
|
||
});
|
||
}
|
||
}
|