feat(sprint4): achievements, community goals, hall of fame, profile
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 37s
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:
43
src/achievement/achievement.controller.ts
Normal file
43
src/achievement/achievement.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
38
src/achievement/achievement.entity.ts
Normal file
38
src/achievement/achievement.entity.ts
Normal 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;
|
||||
}
|
||||
19
src/achievement/achievement.module.ts
Normal file
19
src/achievement/achievement.module.ts
Normal 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 {}
|
||||
123
src/achievement/achievement.service.ts
Normal file
123
src/achievement/achievement.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/achievement/player-achievement.entity.ts
Normal file
43
src/achievement/player-achievement.entity.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user