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,43 @@
import { Controller, Get, Post, Param, Req, UseGuards } from '@nestjs/common';
import { AchievementService } from './achievement.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';
import { BadRequestException } from '@nestjs/common';
@Controller('api/achievements')
export class AchievementController {
constructor(
private readonly achievementService: AchievementService,
@InjectRepository(Character)
private readonly characterRepo: Repository<Character>,
) {}
@Get()
findAll() {
return this.achievementService.findAll();
}
@Get('me')
@UseGuards(AuthGuard)
async getMyProgress(@Req() req: Request) {
const character = await this.getCharacter(req);
return this.achievementService.getMyProgress(character.id);
}
@Post('claim/:id')
@UseGuards(AuthGuard)
async claim(@Param('id') achievementId: string, @Req() req: Request) {
const character = await this.getCharacter(req);
return this.achievementService.claim(achievementId, character.id);
}
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,38 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
} from 'typeorm';
@Entity('achievements')
export class Achievement {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 50, unique: true })
key: string;
@Column({ length: 100 })
name: string;
@Column('text')
description: string;
@Column({ length: 20 })
category: string; // 'progression' | 'combat' | 'zones' | 'equipment' | 'economy'
@Column({ length: 10 })
tier: string; // 'bronze' | 'silver' | 'gold'
@Column({ name: 'criteria_type', length: 30 })
criteriaType: string; // 'combat_wins' | 'level_reached' | 'gold_accumulated' | ...
@Column({ name: 'criteria_value' })
criteriaValue: number;
@Column({ name: 'reward_gold', default: 0 })
rewardGold: number;
@Column({ name: 'reward_title', length: 100, nullable: true })
rewardTitle: string | null;
}

View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Achievement } from './achievement.entity';
import { PlayerAchievement } from './player-achievement.entity';
import { AchievementService } from './achievement.service';
import { AchievementController } from './achievement.controller';
import { AuthModule } from '../auth/auth.module';
import { Character } from '../character/entities/character.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Achievement, PlayerAchievement, Character]),
AuthModule,
],
controllers: [AchievementController],
providers: [AchievementService],
exports: [AchievementService],
})
export class AchievementModule {}

View File

@@ -0,0 +1,123 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OnEvent } from '@nestjs/event-emitter';
import { Achievement } from './achievement.entity';
import { PlayerAchievement } from './player-achievement.entity';
import { Character } from '../character/entities/character.entity';
export interface AchievementCheckEvent {
characterId: string;
type: string; // matches achievement.criteriaType
increment: number;
absolute?: number; // for 'level_reached', 'gold_accumulated' — set progress directly
}
@Injectable()
export class AchievementService {
constructor(
@InjectRepository(Achievement)
private readonly achievementRepo: Repository<Achievement>,
@InjectRepository(PlayerAchievement)
private readonly playerAchievementRepo: Repository<PlayerAchievement>,
@InjectRepository(Character)
private readonly characterRepo: Repository<Character>,
) {}
findAll() {
return this.achievementRepo.find({ order: { category: 'ASC', criteriaValue: 'ASC' } });
}
async getMyProgress(characterId: string) {
const achievements = await this.achievementRepo.find();
const playerAchievements = await this.playerAchievementRepo.find({
where: { characterId },
});
const progressMap = new Map(playerAchievements.map((pa) => [pa.achievementId, pa]));
return achievements.map((a) => {
const pa = progressMap.get(a.id);
return {
...a,
progress: pa?.progress ?? 0,
unlocked: pa?.unlocked ?? false,
unlockedAt: pa?.unlockedAt ?? null,
claimed: pa?.claimed ?? false,
percentage: Math.min(100, Math.floor(((pa?.progress ?? 0) / a.criteriaValue) * 100)),
};
});
}
async claim(achievementId: string, characterId: string) {
const pa = await this.playerAchievementRepo.findOne({
where: { achievementId, characterId },
relations: ['achievement'],
});
if (!pa) throw new NotFoundException('Succès introuvable');
if (!pa.unlocked) throw new BadRequestException('Succès pas encore débloqué');
if (pa.claimed) throw new BadRequestException('Récompense déjà réclamée');
pa.claimed = true;
await this.playerAchievementRepo.save(pa);
// Credit gold reward
const character = await this.characterRepo.findOne({ where: { id: characterId } });
if (character && pa.achievement.rewardGold > 0) {
character.gold += pa.achievement.rewardGold;
await this.characterRepo.save(character);
}
return {
claimed: true,
achievement: pa.achievement.name,
rewardGold: pa.achievement.rewardGold,
rewardTitle: pa.achievement.rewardTitle,
};
}
@OnEvent('achievement.check')
async handleAchievementCheck(event: AchievementCheckEvent) {
const { characterId, type, increment, absolute } = event;
// Find all achievements matching this criteria type
const achievements = await this.achievementRepo.find({
where: { criteriaType: type },
});
if (!achievements.length) return;
for (const achievement of achievements) {
// Get or create player progress
let pa = await this.playerAchievementRepo.findOne({
where: { characterId, achievementId: achievement.id },
});
if (!pa) {
pa = this.playerAchievementRepo.create({
characterId,
achievementId: achievement.id,
progress: 0,
unlocked: false,
claimed: false,
});
}
if (pa.unlocked) continue; // already unlocked, skip
// Update progress
if (absolute !== undefined) {
pa.progress = Math.max(pa.progress, absolute);
} else {
pa.progress += increment;
}
// Check unlock
if (pa.progress >= achievement.criteriaValue) {
pa.unlocked = true;
pa.unlockedAt = new Date();
}
await this.playerAchievementRepo.save(pa);
}
}
}

View File

@@ -0,0 +1,43 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { Character } from '../character/entities/character.entity';
import { Achievement } from './achievement.entity';
@Entity('player_achievements')
@Unique(['characterId', 'achievementId'])
export class PlayerAchievement {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'character_id' })
characterId: string;
@ManyToOne(() => Character)
@JoinColumn({ name: 'character_id' })
character: Character;
@Column({ name: 'achievement_id' })
achievementId: string;
@ManyToOne(() => Achievement)
@JoinColumn({ name: 'achievement_id' })
achievement: Achievement;
@Column({ default: 0 })
progress: number;
@Column({ default: false })
unlocked: boolean;
@Column({ name: 'unlocked_at', type: 'timestamp', nullable: true })
unlockedAt: Date | null;
@Column({ default: false })
claimed: boolean;
}