From 8ee50805eac1e64d4d3985a26fc898198ade8bf5 Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Tue, 24 Mar 2026 14:51:53 +0100 Subject: [PATCH] feat(sprint4): achievements, community goals, hall of fame, profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package-lock.json | 20 +++ package.json | 1 + src/achievement/achievement.controller.ts | 43 ++++++ src/achievement/achievement.entity.ts | 38 ++++++ src/achievement/achievement.module.ts | 19 +++ src/achievement/achievement.service.ts | 123 ++++++++++++++++++ src/achievement/player-achievement.entity.ts | 43 ++++++ src/app.module.ts | 10 ++ src/character/entities/character.entity.ts | 7 + src/combat/combat.module.ts | 2 + src/combat/combat.service.ts | 55 ++++++++ src/community/active-boost.entity.ts | 30 +++++ .../community-contribution.entity.ts | 34 +++++ src/community/community-goal.entity.ts | 47 +++++++ src/community/community.controller.ts | 22 ++++ src/community/community.module.ts | 17 +++ src/community/community.service.ts | 112 ++++++++++++++++ src/craft/craft.service.ts | 9 ++ src/database/achievements-seed.ts | 42 ++++++ src/database/community-goals-seed.ts | 58 +++++++++ src/database/data-source.ts | 12 ++ src/database/seed-sprint4.ts | 20 +++ src/forge/forge.service.ts | 14 ++ src/halloffame/hall-of-fame.entity.ts | 33 +++++ src/halloffame/halloffame.controller.ts | 17 +++ src/halloffame/halloffame.module.ts | 16 +++ src/halloffame/halloffame.service.ts | 97 ++++++++++++++ src/profile/profile.controller.ts | 37 ++++++ src/profile/profile.module.ts | 18 +++ src/profile/profile.service.ts | 82 ++++++++++++ 30 files changed, 1078 insertions(+) create mode 100644 src/achievement/achievement.controller.ts create mode 100644 src/achievement/achievement.entity.ts create mode 100644 src/achievement/achievement.module.ts create mode 100644 src/achievement/achievement.service.ts create mode 100644 src/achievement/player-achievement.entity.ts create mode 100644 src/community/active-boost.entity.ts create mode 100644 src/community/community-contribution.entity.ts create mode 100644 src/community/community-goal.entity.ts create mode 100644 src/community/community.controller.ts create mode 100644 src/community/community.module.ts create mode 100644 src/community/community.service.ts create mode 100644 src/database/achievements-seed.ts create mode 100644 src/database/community-goals-seed.ts create mode 100644 src/database/seed-sprint4.ts create mode 100644 src/halloffame/hall-of-fame.entity.ts create mode 100644 src/halloffame/halloffame.controller.ts create mode 100644 src/halloffame/halloffame.module.ts create mode 100644 src/halloffame/halloffame.service.ts create mode 100644 src/profile/profile.controller.ts create mode 100644 src/profile/profile.module.ts create mode 100644 src/profile/profile.service.ts diff --git a/package-lock.json b/package-lock.json index 6cb6d26..bd22819 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.0.0", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/jwt": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/throttler": "^5.0.0", @@ -1616,6 +1617,19 @@ } } }, + "node_modules/@nestjs/event-emitter": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.0.1.tgz", + "integrity": "sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==", + "license": "MIT", + "dependencies": { + "eventemitter2": "6.4.9" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/jwt": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz", @@ -3826,6 +3840,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", diff --git a/package.json b/package.json index fee1a6c..c5b9137 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.0.0", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/jwt": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/throttler": "^5.0.0", diff --git a/src/achievement/achievement.controller.ts b/src/achievement/achievement.controller.ts new file mode 100644 index 0000000..606e306 --- /dev/null +++ b/src/achievement/achievement.controller.ts @@ -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, + ) {} + + @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 { + 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; + } +} diff --git a/src/achievement/achievement.entity.ts b/src/achievement/achievement.entity.ts new file mode 100644 index 0000000..da9fac0 --- /dev/null +++ b/src/achievement/achievement.entity.ts @@ -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; +} diff --git a/src/achievement/achievement.module.ts b/src/achievement/achievement.module.ts new file mode 100644 index 0000000..03b9355 --- /dev/null +++ b/src/achievement/achievement.module.ts @@ -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 {} diff --git a/src/achievement/achievement.service.ts b/src/achievement/achievement.service.ts new file mode 100644 index 0000000..d247650 --- /dev/null +++ b/src/achievement/achievement.service.ts @@ -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, + @InjectRepository(PlayerAchievement) + private readonly playerAchievementRepo: Repository, + @InjectRepository(Character) + private readonly characterRepo: Repository, + ) {} + + 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); + } + } +} diff --git a/src/achievement/player-achievement.entity.ts b/src/achievement/player-achievement.entity.ts new file mode 100644 index 0000000..863859c --- /dev/null +++ b/src/achievement/player-achievement.entity.ts @@ -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; +} diff --git a/src/app.module.ts b/src/app.module.ts index 1c2c8f1..33891e3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ThrottlerModule } from '@nestjs/throttler'; +import { EventEmitterModule } from '@nestjs/event-emitter'; import { AuthModule } from './auth/auth.module'; import { CharacterModule } from './character/character.module'; import { MonsterModule } from './monster/monster.module'; @@ -12,11 +13,16 @@ import { CraftModule } from './craft/craft.module'; import { ForgeModule } from './forge/forge.module'; import { EconomyModule } from './economy/economy.module'; import { TwitchModule } from './twitch/twitch.module'; +import { AchievementModule } from './achievement/achievement.module'; +import { CommunityModule } from './community/community.module'; +import { HallOfFameModule } from './halloffame/halloffame.module'; +import { ProfileModule } from './profile/profile.module'; import { HealthController } from './common/health.controller'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), + EventEmitterModule.forRoot(), TypeOrmModule.forRootAsync({ imports: [ConfigModule], @@ -47,6 +53,10 @@ import { HealthController } from './common/health.controller'; ForgeModule, EconomyModule, TwitchModule, + AchievementModule, + CommunityModule, + HallOfFameModule, + ProfileModule, ], controllers: [HealthController], }) diff --git a/src/character/entities/character.entity.ts b/src/character/entities/character.entity.ts index 952df45..fbd2641 100644 --- a/src/character/entities/character.entity.ts +++ b/src/character/entities/character.entity.ts @@ -71,6 +71,13 @@ export class Character { @Column({ name: 'stat_points', default: 0 }) statPoints: number; + // Sprint 4 — Profil enrichi + @Column({ name: 'active_title', length: 100, nullable: true }) + activeTitle: string | null; + + @Column({ name: 'total_gold_earned', type: 'bigint', default: 0 }) + totalGoldEarned: number; + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; diff --git a/src/combat/combat.module.ts b/src/combat/combat.module.ts index 9810203..865d8b6 100644 --- a/src/combat/combat.module.ts +++ b/src/combat/combat.module.ts @@ -8,6 +8,7 @@ import { MonsterModule } from '../monster/monster.module'; import { AuthModule } from '../auth/auth.module'; import { ItemModule } from '../item/item.module'; import { MaterialModule } from '../material/material.module'; +import { CommunityModule } from '../community/community.module'; @Module({ imports: [ @@ -16,6 +17,7 @@ import { MaterialModule } from '../material/material.module'; AuthModule, ItemModule, MaterialModule, + CommunityModule, ], controllers: [CombatController], providers: [CombatService], diff --git a/src/combat/combat.service.ts b/src/combat/combat.service.ts index d336603..76fb46d 100644 --- a/src/combat/combat.service.ts +++ b/src/combat/combat.service.ts @@ -1,6 +1,7 @@ import { Injectable, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { Character } from '../character/entities/character.entity'; import { Monster } from '../monster/monster.entity'; import { MonsterService } from '../monster/monster.service'; @@ -9,6 +10,7 @@ import { StartCombatDto } from './dto/start-combat.dto'; import { User } from '../user/user.entity'; import { ItemService } from '../item/item.service'; import { MaterialService } from '../material/material.service'; +import { CommunityService } from '../community/community.service'; import { resolveCombat, applyXpGain, @@ -31,6 +33,8 @@ export class CombatService { private readonly monsterService: MonsterService, private readonly itemService: ItemService, private readonly materialService: MaterialService, + private readonly communityService: CommunityService, + private readonly eventEmitter: EventEmitter2, ) {} async startCombat(dto: StartCombatDto, user: User) { @@ -126,12 +130,63 @@ export class CombatService { character.gold = Math.max(0, character.gold - goldLost); } + // Track total gold earned (for achievements) + if (result.winner === 'player') { + character.totalGoldEarned = Number(character.totalGoldEarned ?? 0) + result.goldEarned; + } + // Sauvegarder l'endurance (lazy reset) character.hpCurrent = newHp; character.enduranceSaved = newEnduranceSaved; character.lastEnduranceTs = new Date(); await this.characterRepository.save(character); + // Emit achievement & community events + if (result.winner === 'player') { + this.eventEmitter.emit('achievement.check', { + characterId: character.id, + type: 'combat_wins', + increment: 1, + }); + this.eventEmitter.emit('achievement.check', { + characterId: character.id, + type: 'level_reached', + increment: 0, + absolute: character.level, + }); + this.eventEmitter.emit('achievement.check', { + characterId: character.id, + type: 'gold_accumulated', + increment: 0, + absolute: Number(character.totalGoldEarned), + }); + this.eventEmitter.emit('community.contribute', { + characterId: character.id, + type: 'total_monsters_killed', + increment: 1, + }); + this.eventEmitter.emit('community.contribute', { + characterId: character.id, + type: 'total_gold_earned', + increment: result.goldEarned, + }); + } + + // Apply XP boost from community + if (result.winner === 'player') { + const xpBoost = await this.communityService.getActiveMultiplier('xp_boost'); + if (xpBoost > 1.0) { + const bonusXp = Math.floor(result.xpEarned * (xpBoost - 1)); + if (bonusXp > 0) { + const boosted = applyXpGain(character.level, character.xp, bonusXp); + character.xp = boosted.newXp; + character.level = boosted.newLevel; + character.statPoints = (character.statPoints ?? 0) + boosted.statPointsGained; + await this.characterRepository.save(character); + } + } + } + // Loot matériaux — 40% de chance après victoire si le monstre a un drop_material_id let lootMaterial: { name: string; quantity: number } | null = null; if (result.winner === 'player' && monster.dropMaterialId && Math.random() < 0.4) { diff --git a/src/community/active-boost.entity.ts b/src/community/active-boost.entity.ts new file mode 100644 index 0000000..6360be1 --- /dev/null +++ b/src/community/active-boost.entity.ts @@ -0,0 +1,30 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { CommunityGoal } from './community-goal.entity'; + +@Entity('active_boosts') +export class ActiveBoost { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'boost_type', length: 30 }) + boostType: string; // 'xp_boost' | 'loot_boost' + + @Column({ type: 'decimal', precision: 3, scale: 2 }) + multiplier: number; + + @Column({ name: 'expires_at', type: 'timestamp' }) + expiresAt: Date; + + @Column({ name: 'source_goal_id' }) + sourceGoalId: string; + + @ManyToOne(() => CommunityGoal) + @JoinColumn({ name: 'source_goal_id' }) + sourceGoal: CommunityGoal; +} diff --git a/src/community/community-contribution.entity.ts b/src/community/community-contribution.entity.ts new file mode 100644 index 0000000..537944c --- /dev/null +++ b/src/community/community-contribution.entity.ts @@ -0,0 +1,34 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { CommunityGoal } from './community-goal.entity'; +import { Character } from '../character/entities/character.entity'; + +@Entity('community_contributions') +@Unique(['communityGoalId', 'characterId']) +export class CommunityContribution { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'community_goal_id' }) + communityGoalId: string; + + @ManyToOne(() => CommunityGoal) + @JoinColumn({ name: 'community_goal_id' }) + communityGoal: CommunityGoal; + + @Column({ name: 'character_id' }) + characterId: string; + + @ManyToOne(() => Character) + @JoinColumn({ name: 'character_id' }) + character: Character; + + @Column({ name: 'contribution_value', type: 'bigint', default: 0 }) + contributionValue: number; +} diff --git a/src/community/community-goal.entity.ts b/src/community/community-goal.entity.ts new file mode 100644 index 0000000..ab10f13 --- /dev/null +++ b/src/community/community-goal.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, +} from 'typeorm'; + +@Entity('community_goals') +export class CommunityGoal { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 100 }) + name: string; + + @Column('text') + description: string; + + @Column({ name: 'criteria_type', length: 30 }) + criteriaType: string; + + @Column({ name: 'target_value', type: 'bigint' }) + targetValue: number; + + @Column({ name: 'current_value', type: 'bigint', default: 0 }) + currentValue: number; + + @Column({ name: 'reward_type', length: 30 }) + rewardType: string; // 'xp_boost' | 'loot_boost' + + @Column({ name: 'reward_multiplier', type: 'decimal', precision: 3, scale: 2 }) + rewardMultiplier: number; + + @Column({ name: 'reward_duration_hours' }) + rewardDurationHours: number; + + @Column({ name: 'period_start', type: 'date' }) + periodStart: Date; + + @Column({ name: 'period_end', type: 'date' }) + periodEnd: Date; + + @Column({ default: false }) + completed: boolean; + + @Column({ name: 'completed_at', type: 'timestamp', nullable: true }) + completedAt: Date | null; +} diff --git a/src/community/community.controller.ts b/src/community/community.controller.ts new file mode 100644 index 0000000..4da2758 --- /dev/null +++ b/src/community/community.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { CommunityService } from './community.service'; + +@Controller('api/community') +export class CommunityController { + constructor(private readonly communityService: CommunityService) {} + + @Get('goals') + getActiveGoals() { + return this.communityService.getActiveGoals(); + } + + @Get('goals/:id/top') + getTopContributors(@Param('id') goalId: string) { + return this.communityService.getTopContributors(goalId); + } + + @Get('boosts') + getActiveBoosts() { + return this.communityService.getActiveBoosts(); + } +} diff --git a/src/community/community.module.ts b/src/community/community.module.ts new file mode 100644 index 0000000..abf043d --- /dev/null +++ b/src/community/community.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CommunityGoal } from './community-goal.entity'; +import { CommunityContribution } from './community-contribution.entity'; +import { ActiveBoost } from './active-boost.entity'; +import { CommunityService } from './community.service'; +import { CommunityController } from './community.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([CommunityGoal, CommunityContribution, ActiveBoost]), + ], + controllers: [CommunityController], + providers: [CommunityService], + exports: [CommunityService], +}) +export class CommunityModule {} diff --git a/src/community/community.service.ts b/src/community/community.service.ts new file mode 100644 index 0000000..472e3de --- /dev/null +++ b/src/community/community.service.ts @@ -0,0 +1,112 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, MoreThan } from 'typeorm'; +import { OnEvent } from '@nestjs/event-emitter'; +import { CommunityGoal } from './community-goal.entity'; +import { CommunityContribution } from './community-contribution.entity'; +import { ActiveBoost } from './active-boost.entity'; + +export interface CommunityContributeEvent { + characterId: string; + type: string; // matches communityGoal.criteriaType + increment: number; +} + +@Injectable() +export class CommunityService { + constructor( + @InjectRepository(CommunityGoal) + private readonly goalRepo: Repository, + @InjectRepository(CommunityContribution) + private readonly contributionRepo: Repository, + @InjectRepository(ActiveBoost) + private readonly boostRepo: Repository, + ) {} + + async getActiveGoals() { + const now = new Date(); + return this.goalRepo.find({ + where: { periodEnd: MoreThan(now) }, + order: { periodStart: 'ASC' }, + }); + } + + async getTopContributors(goalId: string, limit = 10) { + return this.contributionRepo.find({ + where: { communityGoalId: goalId }, + order: { contributionValue: 'DESC' }, + take: limit, + relations: ['character'], + }); + } + + async getActiveBoosts() { + const now = new Date(); + return this.boostRepo.find({ + where: { expiresAt: MoreThan(now) }, + }); + } + + async getActiveMultiplier(boostType: string): Promise { + const now = new Date(); + const boosts = await this.boostRepo.find({ + where: { boostType, expiresAt: MoreThan(now) }, + }); + if (!boosts.length) return 1.0; + // Stack multiplicatively + return boosts.reduce((acc, b) => acc * Number(b.multiplier), 1.0); + } + + @OnEvent('community.contribute') + async handleContribution(event: CommunityContributeEvent) { + const { characterId, type, increment } = event; + + // Find active goals matching this criteria + const now = new Date(); + const goals = await this.goalRepo + .createQueryBuilder('g') + .where('g.criteria_type = :type', { type }) + .andWhere('g.completed = false') + .andWhere('g.period_end > :now', { now }) + .getMany(); + + for (const goal of goals) { + // Update individual contribution + let contribution = await this.contributionRepo.findOne({ + where: { communityGoalId: goal.id, characterId }, + }); + if (!contribution) { + contribution = this.contributionRepo.create({ + communityGoalId: goal.id, + characterId, + contributionValue: 0, + }); + } + contribution.contributionValue = Number(contribution.contributionValue) + increment; + await this.contributionRepo.save(contribution); + + // Update global counter + goal.currentValue = Number(goal.currentValue) + increment; + + // Check completion + if (goal.currentValue >= goal.targetValue && !goal.completed) { + goal.completed = true; + goal.completedAt = new Date(); + + // Create boost + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + goal.rewardDurationHours); + + const boost = this.boostRepo.create({ + boostType: goal.rewardType, + multiplier: goal.rewardMultiplier, + expiresAt, + sourceGoalId: goal.id, + }); + await this.boostRepo.save(boost); + } + + await this.goalRepo.save(goal); + } + } +} diff --git a/src/craft/craft.service.ts b/src/craft/craft.service.ts index 306f1a4..e1cdbcb 100644 --- a/src/craft/craft.service.ts +++ b/src/craft/craft.service.ts @@ -1,6 +1,7 @@ import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { Recipe } from './recipe.entity'; import { CraftJob } from './craft-job.entity'; import { Character } from '../character/entities/character.entity'; @@ -19,6 +20,7 @@ export class CraftService { private readonly characterRepository: Repository, private readonly itemService: ItemService, private readonly materialService: MaterialService, + private readonly eventEmitter: EventEmitter2, ) {} findAllRecipes() { @@ -113,6 +115,13 @@ export class CraftService { job.collected = true; await this.craftJobRepository.save(job); + // Emit achievement event + this.eventEmitter.emit('achievement.check', { + characterId: char.id, + type: 'craft_completed', + increment: 1, + }); + return { collected: true, item: charItem.item, diff --git a/src/database/achievements-seed.ts b/src/database/achievements-seed.ts new file mode 100644 index 0000000..721363c --- /dev/null +++ b/src/database/achievements-seed.ts @@ -0,0 +1,42 @@ +import { DataSource } from 'typeorm'; +import { Achievement } from '../achievement/achievement.entity'; + +const ACHIEVEMENTS_DATA = [ + // Combat + { key: 'combat_10', name: 'Apprenti Guerrier', description: 'Remporter 10 combats', category: 'combat', tier: 'bronze', criteriaType: 'combat_wins', criteriaValue: 10, rewardGold: 50, rewardTitle: null }, + { key: 'combat_100', name: 'Guerrier Aguerri', description: 'Remporter 100 combats', category: 'combat', tier: 'silver', criteriaType: 'combat_wins', criteriaValue: 100, rewardGold: 200, rewardTitle: 'Guerrier Aguerri' }, + { key: 'combat_1000', name: 'Légende du Combat', description: 'Remporter 1000 combats', category: 'combat', tier: 'gold', criteriaType: 'combat_wins', criteriaValue: 1000, rewardGold: 1000, rewardTitle: 'Légende' }, + + // Progression + { key: 'level_10', name: 'Aventurier', description: 'Atteindre le niveau 10', category: 'progression', tier: 'bronze', criteriaType: 'level_reached', criteriaValue: 10, rewardGold: 100, rewardTitle: null }, + { key: 'level_50', name: 'Héros', description: 'Atteindre le niveau 50', category: 'progression', tier: 'silver', criteriaType: 'level_reached', criteriaValue: 50, rewardGold: 500, rewardTitle: 'Héros' }, + { key: 'level_100', name: 'Légende Vivante', description: 'Atteindre le niveau 100', category: 'progression', tier: 'gold', criteriaType: 'level_reached', criteriaValue: 100, rewardGold: 2000, rewardTitle: 'Légende Vivante' }, + + // Economy + { key: 'gold_1000', name: 'Marchand', description: 'Accumuler 1 000 pièces d\'or', category: 'economy', tier: 'bronze', criteriaType: 'gold_accumulated', criteriaValue: 1000, rewardGold: 100, rewardTitle: null }, + { key: 'gold_10000', name: 'Négociant', description: 'Accumuler 10 000 pièces d\'or', category: 'economy', tier: 'silver', criteriaType: 'gold_accumulated', criteriaValue: 10000, rewardGold: 500, rewardTitle: 'Négociant' }, + { key: 'gold_100000', name: 'Magnat', description: 'Accumuler 100 000 pièces d\'or', category: 'economy', tier: 'gold', criteriaType: 'gold_accumulated', criteriaValue: 100000, rewardGold: 2000, rewardTitle: 'Magnat' }, + + // Equipment — Forge + { key: 'forge_5', name: 'Apprenti Forgeron', description: 'Réussir 5 améliorations de forge', category: 'equipment', tier: 'bronze', criteriaType: 'forge_upgrades', criteriaValue: 5, rewardGold: 100, rewardTitle: null }, + { key: 'forge_25', name: 'Maître Forgeron', description: 'Réussir 25 améliorations de forge', category: 'equipment', tier: 'silver', criteriaType: 'forge_upgrades', criteriaValue: 25, rewardGold: 500, rewardTitle: 'Maître Forgeron' }, + { key: 'forge_100', name: 'Forgeron Légendaire', description: 'Réussir 100 améliorations de forge', category: 'equipment', tier: 'gold', criteriaType: 'forge_upgrades', criteriaValue: 100, rewardGold: 2000, rewardTitle: 'Forgeron Légendaire' }, + + // Equipment — Craft + { key: 'craft_5', name: 'Artisan Novice', description: 'Compléter 5 crafts', category: 'equipment', tier: 'bronze', criteriaType: 'craft_completed', criteriaValue: 5, rewardGold: 75, rewardTitle: null }, + { key: 'craft_25', name: 'Artisan Confirmé', description: 'Compléter 25 crafts', category: 'equipment', tier: 'silver', criteriaType: 'craft_completed', criteriaValue: 25, rewardGold: 300, rewardTitle: 'Artisan' }, + { key: 'craft_100', name: 'Grand Artisan', description: 'Compléter 100 crafts', category: 'equipment', tier: 'gold', criteriaType: 'craft_completed', criteriaValue: 100, rewardGold: 1500, rewardTitle: 'Grand Artisan' }, +]; + +export async function seedAchievements(dataSource: DataSource) { + const repo = dataSource.getRepository(Achievement); + + for (const data of ACHIEVEMENTS_DATA) { + const existing = await repo.findOne({ where: { key: data.key } }); + if (!existing) { + await repo.save(repo.create(data)); + } + } + + console.log(`✅ ${ACHIEVEMENTS_DATA.length} achievements seedés`); +} diff --git a/src/database/community-goals-seed.ts b/src/database/community-goals-seed.ts new file mode 100644 index 0000000..4308444 --- /dev/null +++ b/src/database/community-goals-seed.ts @@ -0,0 +1,58 @@ +import { DataSource } from 'typeorm'; +import { CommunityGoal } from '../community/community-goal.entity'; + +export async function seedCommunityGoals(dataSource: DataSource) { + const repo = dataSource.getRepository(CommunityGoal); + + // Current month boundaries + const now = new Date(); + const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); + const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); + + const GOALS = [ + { + name: 'Chasse aux Monstres', + description: 'La communauté doit éliminer 10 000 monstres ce mois-ci !', + criteriaType: 'total_monsters_killed', + targetValue: 10000, + rewardType: 'xp_boost', + rewardMultiplier: 1.20, + rewardDurationHours: 72, + periodStart, + periodEnd, + }, + { + name: 'Trésor Communautaire', + description: 'Accumuler 1 000 000 pièces d\'or collectivement !', + criteriaType: 'total_gold_earned', + targetValue: 1000000, + rewardType: 'loot_boost', + rewardMultiplier: 1.15, + rewardDurationHours: 48, + periodStart, + periodEnd, + }, + { + name: 'Fièvre de la Forge', + description: 'Réaliser 500 améliorations de forge collectivement !', + criteriaType: 'total_forge_upgrades', + targetValue: 500, + rewardType: 'xp_boost', + rewardMultiplier: 1.10, + rewardDurationHours: 48, + periodStart, + periodEnd, + }, + ]; + + for (const data of GOALS) { + const existing = await repo.findOne({ + where: { criteriaType: data.criteriaType, periodStart: data.periodStart }, + }); + if (!existing) { + await repo.save(repo.create(data)); + } + } + + console.log(`✅ ${GOALS.length} community goals seedés`); +} diff --git a/src/database/data-source.ts b/src/database/data-source.ts index 74346c8..321fc7b 100644 --- a/src/database/data-source.ts +++ b/src/database/data-source.ts @@ -14,6 +14,12 @@ import { ProcessedEvent } from '../twitch/entities/processed-event.entity'; import { Recipe } from '../craft/recipe.entity'; import { CraftJob } from '../craft/craft-job.entity'; import { Monster } from '../monster/monster.entity'; +import { Achievement } from '../achievement/achievement.entity'; +import { PlayerAchievement } from '../achievement/player-achievement.entity'; +import { CommunityGoal } from '../community/community-goal.entity'; +import { CommunityContribution } from '../community/community-contribution.entity'; +import { ActiveBoost } from '../community/active-boost.entity'; +import { HallOfFame } from '../halloffame/hall-of-fame.entity'; // DataSource pour le CLI TypeORM (migrations manuelles) export const AppDataSource = new DataSource({ @@ -34,6 +40,12 @@ export const AppDataSource = new DataSource({ Recipe, CraftJob, Monster, + Achievement, + PlayerAchievement, + CommunityGoal, + CommunityContribution, + ActiveBoost, + HallOfFame, ], migrations: [__dirname + '/migrations/*{.ts,.js}'], synchronize: false, diff --git a/src/database/seed-sprint4.ts b/src/database/seed-sprint4.ts new file mode 100644 index 0000000..13cd2c3 --- /dev/null +++ b/src/database/seed-sprint4.ts @@ -0,0 +1,20 @@ +import 'reflect-metadata'; +import { AppDataSource } from './data-source'; +import { seedAchievements } from './achievements-seed'; +import { seedCommunityGoals } from './community-goals-seed'; + +async function seed() { + await AppDataSource.initialize(); + console.log('DB connectée (MySQL)'); + + await seedAchievements(AppDataSource); + await seedCommunityGoals(AppDataSource); + + await AppDataSource.destroy(); + console.log('✅ Sprint 4 seed terminé'); +} + +seed().catch((err) => { + console.error('Seed échoué :', err); + process.exit(1); +}); diff --git a/src/forge/forge.service.ts b/src/forge/forge.service.ts index 669c14a..65a4e99 100644 --- a/src/forge/forge.service.ts +++ b/src/forge/forge.service.ts @@ -1,6 +1,7 @@ import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { CharacterItem } from '../item/character-item.entity'; import { Character } from '../character/entities/character.entity'; import { User } from '../user/user.entity'; @@ -24,6 +25,7 @@ export class ForgeService { private readonly charItemRepository: Repository, @InjectRepository(Character) private readonly characterRepository: Repository, + private readonly eventEmitter: EventEmitter2, ) {} async upgradeItem(charItemId: string, user: User) { @@ -46,6 +48,18 @@ export class ForgeService { charItem.forgeLevel = targetLevel; await this.charItemRepository.save(charItem); + // Emit achievement & community events + this.eventEmitter.emit('achievement.check', { + characterId: char.id, + type: 'forge_upgrades', + increment: 1, + }); + this.eventEmitter.emit('community.contribute', { + characterId: char.id, + type: 'total_forge_upgrades', + increment: 1, + }); + const statLabel = charItem.item.type === 'weapon' ? `+${FORGE_BONUS_PER_LEVEL} ATK` : `+${FORGE_BONUS_PER_LEVEL} DEF`; diff --git a/src/halloffame/hall-of-fame.entity.ts b/src/halloffame/hall-of-fame.entity.ts new file mode 100644 index 0000000..c02374a --- /dev/null +++ b/src/halloffame/hall-of-fame.entity.ts @@ -0,0 +1,33 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Character } from '../character/entities/character.entity'; + +@Entity('hall_of_fame') +export class HallOfFame { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'character_id' }) + characterId: string; + + @ManyToOne(() => Character) + @JoinColumn({ name: 'character_id' }) + character: Character; + + @Column({ length: 7 }) + period: string; // 'YYYY-MM' + + @Column() + rank: number; + + @Column({ name: 'contribution_total', type: 'bigint' }) + contributionTotal: number; + + @Column({ length: 50 }) + badge: string; // 'top1_april_2026' +} diff --git a/src/halloffame/halloffame.controller.ts b/src/halloffame/halloffame.controller.ts new file mode 100644 index 0000000..639ec41 --- /dev/null +++ b/src/halloffame/halloffame.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { HallOfFameService } from './halloffame.service'; + +@Controller('api/halloffame') +export class HallOfFameController { + constructor(private readonly hallOfFameService: HallOfFameService) {} + + @Get('current') + getCurrentRanking() { + return this.hallOfFameService.getCurrentRanking(); + } + + @Get(':period') + getRankingByPeriod(@Param('period') period: string) { + return this.hallOfFameService.getRankingByPeriod(period); + } +} diff --git a/src/halloffame/halloffame.module.ts b/src/halloffame/halloffame.module.ts new file mode 100644 index 0000000..b5e8823 --- /dev/null +++ b/src/halloffame/halloffame.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { HallOfFame } from './hall-of-fame.entity'; +import { CommunityContribution } from '../community/community-contribution.entity'; +import { HallOfFameService } from './halloffame.service'; +import { HallOfFameController } from './halloffame.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([HallOfFame, CommunityContribution]), + ], + controllers: [HallOfFameController], + providers: [HallOfFameService], + exports: [HallOfFameService], +}) +export class HallOfFameModule {} diff --git a/src/halloffame/halloffame.service.ts b/src/halloffame/halloffame.service.ts new file mode 100644 index 0000000..90f753b --- /dev/null +++ b/src/halloffame/halloffame.service.ts @@ -0,0 +1,97 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { HallOfFame } from './hall-of-fame.entity'; +import { CommunityContribution } from '../community/community-contribution.entity'; + +@Injectable() +export class HallOfFameService { + constructor( + @InjectRepository(HallOfFame) + private readonly hofRepo: Repository, + @InjectRepository(CommunityContribution) + private readonly contributionRepo: Repository, + ) {} + + async getCurrentRanking() { + const period = this.getCurrentPeriod(); + return this.getRankingByPeriod(period); + } + + async getRankingByPeriod(period: string) { + // Check if already computed for this period + const existing = await this.hofRepo.find({ + where: { period }, + order: { rank: 'ASC' }, + relations: ['character'], + }); + if (existing.length) return existing; + + // Compute live from contributions (current month) + if (period === this.getCurrentPeriod()) { + return this.computeLiveRanking(); + } + + return []; + } + + private async computeLiveRanking() { + // Aggregate all contributions across all active goals for the current period + const results = await this.contributionRepo + .createQueryBuilder('cc') + .select('cc.character_id', 'characterId') + .addSelect('SUM(cc.contribution_value)', 'total') + .groupBy('cc.character_id') + .orderBy('total', 'DESC') + .limit(10) + .getRawMany(); + + return results.map((r, i) => ({ + rank: i + 1, + characterId: r.characterId, + contributionTotal: Number(r.total), + period: this.getCurrentPeriod(), + badge: i === 0 ? `top1_${this.getCurrentPeriod()}` : null, + })); + } + + /** + * Called at month end to freeze the Hall of Fame + * Can be triggered via cron or manual API call + */ + async freezePeriod(period: string) { + const existing = await this.hofRepo.find({ where: { period } }); + if (existing.length) return existing; // Already frozen + + const results = await this.contributionRepo + .createQueryBuilder('cc') + .select('cc.character_id', 'characterId') + .addSelect('SUM(cc.contribution_value)', 'total') + .groupBy('cc.character_id') + .orderBy('total', 'DESC') + .limit(10) + .getRawMany(); + + const entries: HallOfFame[] = []; + for (let i = 0; i < results.length; i++) { + const r = results[i]; + const badge = `top${i + 1}_${period}`; + const entry = this.hofRepo.create({ + characterId: r.characterId, + period, + rank: i + 1, + contributionTotal: Number(r.total), + badge, + }); + entries.push(await this.hofRepo.save(entry)); + } + + return entries; + } + + private getCurrentPeriod(): string { + const now = new Date(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + return `${now.getFullYear()}-${month}`; + } +} diff --git a/src/profile/profile.controller.ts b/src/profile/profile.controller.ts new file mode 100644 index 0000000..37dcd0f --- /dev/null +++ b/src/profile/profile.controller.ts @@ -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, + ) {} + + @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 { + 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; + } +} diff --git a/src/profile/profile.module.ts b/src/profile/profile.module.ts new file mode 100644 index 0000000..217a0aa --- /dev/null +++ b/src/profile/profile.module.ts @@ -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 {} diff --git a/src/profile/profile.service.ts b/src/profile/profile.service.ts new file mode 100644 index 0000000..30daab4 --- /dev/null +++ b/src/profile/profile.service.ts @@ -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, + @InjectRepository(PlayerAchievement) + private readonly playerAchievementRepo: Repository, + @InjectRepository(HallOfFame) + private readonly hofRepo: Repository, + ) {} + + 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 }; + } +}