feat: Sprint 1 — backend fondations TetaRdPG

Auth SuperOAuth (JWT validation + httpOnly cookie), entités users/characters/level_thresholds,
lazy calculation endurance, seed 100 niveaux, config prod-ready (trust proxy, helmet, CORS, rate limit).
Validé : health 200, auth flow, character CRUD, endurance lazy, 401 sans cookie.
This commit is contained in:
2026-03-15 05:51:02 +01:00
commit da3237bf3f
29 changed files with 7249 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
import {
Injectable,
ConflictException,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Character } from './entities/character.entity';
import { LevelThreshold } from './entities/level-threshold.entity';
import { CreateCharacterDto } from './dto/create-character.dto';
import { User } from '../user/user.entity';
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
@Injectable()
export class CharacterService {
constructor(
@InjectRepository(Character)
private readonly characterRepository: Repository<Character>,
@InjectRepository(LevelThreshold)
private readonly levelThresholdRepository: Repository<LevelThreshold>,
) {}
// 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): Promise<Character & { enduranceCurrent: number }> {
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) };
}
async findByUser(user: User): Promise<Character & { enduranceCurrent: number }> {
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) };
}
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,
};
}
}