feat(sprint4): achievements, community goals, hall of fame, profile
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 37s

4 modules: achievement (15 succès, 5 catégories, 3 paliers), community
(objectifs collectifs + boosts globaux), halloffame (classement mensuel),
profile (titre actif + badges + % progression).

Event-driven: combat/forge/craft émettent des events via @nestjs/event-emitter.
Character entity: +activeTitle, +totalGoldEarned.
Seeds: 15 achievements + 3 community goals.
This commit is contained in:
2026-03-24 14:51:53 +01:00
parent 77052d9219
commit 8ee50805ea
30 changed files with 1078 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
import { Controller, Get, Put, Body, Req, UseGuards, BadRequestException } from '@nestjs/common';
import { ProfileService } from './profile.service';
import { AuthGuard } from '../auth/guards/auth.guard';
import { Request } from 'express';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Character } from '../character/entities/character.entity';
@Controller('api/profile')
export class ProfileController {
constructor(
private readonly profileService: ProfileService,
@InjectRepository(Character)
private readonly characterRepo: Repository<Character>,
) {}
@Get('me')
@UseGuards(AuthGuard)
async getProfile(@Req() req: Request) {
const character = await this.getCharacter(req);
return this.profileService.getProfile(character.id);
}
@Put('title')
@UseGuards(AuthGuard)
async setTitle(@Body('title') title: string, @Req() req: Request) {
const character = await this.getCharacter(req);
return this.profileService.setActiveTitle(character.id, title);
}
private async getCharacter(req: Request): Promise<Character> {
const user = (req as any).user;
const character = await this.characterRepo.findOne({ where: { userId: user.id } });
if (!character) throw new BadRequestException('Aucun personnage trouvé');
return character;
}
}

View File

@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProfileService } from './profile.service';
import { ProfileController } from './profile.controller';
import { Character } from '../character/entities/character.entity';
import { PlayerAchievement } from '../achievement/player-achievement.entity';
import { HallOfFame } from '../halloffame/hall-of-fame.entity';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([Character, PlayerAchievement, HallOfFame]),
AuthModule,
],
controllers: [ProfileController],
providers: [ProfileService],
})
export class ProfileModule {}

View File

@@ -0,0 +1,82 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Character } from '../character/entities/character.entity';
import { PlayerAchievement } from '../achievement/player-achievement.entity';
import { HallOfFame } from '../halloffame/hall-of-fame.entity';
@Injectable()
export class ProfileService {
constructor(
@InjectRepository(Character)
private readonly characterRepo: Repository<Character>,
@InjectRepository(PlayerAchievement)
private readonly playerAchievementRepo: Repository<PlayerAchievement>,
@InjectRepository(HallOfFame)
private readonly hofRepo: Repository<HallOfFame>,
) {}
async getProfile(characterId: string) {
const character = await this.characterRepo.findOne({ where: { id: characterId } });
if (!character) throw new BadRequestException('Personnage introuvable');
// Achievement stats
const achievements = await this.playerAchievementRepo.find({
where: { characterId },
relations: ['achievement'],
});
const unlocked = achievements.filter((a) => a.unlocked);
// Total achievements count
const totalCount = await this.playerAchievementRepo.manager
.getRepository('Achievement')
.count();
// Badges from Hall of Fame
const badges = await this.hofRepo.find({
where: { characterId },
order: { period: 'DESC' },
});
return {
name: character.name,
level: character.level,
xp: character.xp,
gold: character.gold,
activeTitle: character.activeTitle,
stats: {
force: character.force,
agilite: character.agilite,
intelligence: character.intelligence,
chance: character.chance,
vitalite: character.vitalite,
},
achievements: {
unlocked: unlocked.length,
total: totalCount,
percentage: totalCount > 0 ? Math.floor((unlocked.length / totalCount) * 100) : 0,
},
badges: badges.map((b) => ({ badge: b.badge, period: b.period, rank: b.rank })),
};
}
async setActiveTitle(characterId: string, title: string) {
// Verify the player has unlocked this title
if (title) {
const hasTitle = await this.playerAchievementRepo
.createQueryBuilder('pa')
.innerJoin('pa.achievement', 'a')
.where('pa.character_id = :characterId', { characterId })
.andWhere('pa.unlocked = true')
.andWhere('a.reward_title = :title', { title })
.getCount();
if (!hasTitle) {
throw new BadRequestException('Titre non débloqué');
}
}
await this.characterRepo.update(characterId, { activeTitle: title || null });
return { activeTitle: title || null };
}
}