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,40 @@
import {
Controller,
Post,
Get,
Body,
UseGuards,
Req,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { Request } from 'express';
import { CharacterService } from './character.service';
import { CreateCharacterDto } from './dto/create-character.dto';
import { AuthGuard } from '../auth/guards/auth.guard';
import { User } from '../user/user.entity';
@Controller('characters')
@UseGuards(AuthGuard)
export class CharacterController {
constructor(private readonly characterService: CharacterService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
create(
@Body() dto: CreateCharacterDto,
@Req() req: Request & { user: User },
) {
return this.characterService.create(dto, req.user);
}
@Get('me')
findMe(@Req() req: Request & { user: User }) {
return this.characterService.findByUser(req.user);
}
@Get('me/endurance')
getEndurance(@Req() req: Request & { user: User }) {
return this.characterService.getEndurance(req.user);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from './entities/character.entity';
import { LevelThreshold } from './entities/level-threshold.entity';
import { CharacterController } from './character.controller';
import { CharacterService } from './character.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([Character, LevelThreshold]),
AuthModule, // pour AuthGuard + User repository
],
controllers: [CharacterController],
providers: [CharacterService],
})
export class CharacterModule {}

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,
};
}
}

View File

@@ -0,0 +1,35 @@
import { IsInt, IsString, Length, Min, Max } from 'class-validator';
// 5 points de stats à répartir — chaque stat démarre à 1
// Contrainte : force + agilite + intelligence + chance + vitalite = 10 (5 base + 5 extra)
// Validé dans le service
export class CreateCharacterDto {
@IsString()
@Length(2, 100)
name: string;
@IsInt()
@Min(1)
@Max(6)
force: number;
@IsInt()
@Min(1)
@Max(6)
agilite: number;
@IsInt()
@Min(1)
@Max(6)
intelligence: number;
@IsInt()
@Min(1)
@Max(6)
chance: number;
@IsInt()
@Min(1)
@Max(6)
vitalite: number;
}

View File

@@ -0,0 +1,75 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Unique,
} from 'typeorm';
import { User } from '../../user/user.entity';
@Entity('characters')
@Unique(['userId'])
export class Character {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' })
userId: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ length: 100 })
name: string;
@Column({ default: 1 })
level: number;
@Column({ default: 0 })
xp: number;
@Column({ default: 0 })
gold: number;
// Stats (cap : 101)
@Column({ default: 1 })
force: number;
@Column({ default: 1 })
agilite: number;
@Column({ default: 1 })
intelligence: number;
@Column({ default: 1 })
chance: number;
@Column({ default: 1 })
vitalite: number;
@Column({ name: 'hp_current', default: 100 })
hpCurrent: number;
@Column({ name: 'hp_max', default: 100 })
hpMax: number;
// Endurance — lazy calculation (pas de timer actif)
@Column({ name: 'endurance_saved', default: 100 })
enduranceSaved: number;
@Column({ name: 'last_endurance_ts', type: 'timestamp', default: () => 'NOW()' })
lastEnduranceTs: Date;
@Column({ name: 'endurance_max', default: 100 })
enduranceMax: number;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -0,0 +1,10 @@
import { Entity, PrimaryColumn, Column } from 'typeorm';
@Entity('level_thresholds')
export class LevelThreshold {
@PrimaryColumn()
level: number;
@Column({ name: 'xp_required' })
xpRequired: number;
}