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

20
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

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

View File

@@ -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],
})

View File

@@ -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;

View File

@@ -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],

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

@@ -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();
}
}

View File

@@ -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 {}

View File

@@ -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<CommunityGoal>,
@InjectRepository(CommunityContribution)
private readonly contributionRepo: Repository<CommunityContribution>,
@InjectRepository(ActiveBoost)
private readonly boostRepo: Repository<ActiveBoost>,
) {}
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<number> {
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);
}
}
}

View File

@@ -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<Character>,
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,

View File

@@ -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`);
}

View File

@@ -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`);
}

View File

@@ -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,

View File

@@ -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);
});

View File

@@ -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<CharacterItem>,
@InjectRepository(Character)
private readonly characterRepository: Repository<Character>,
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`;

View File

@@ -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'
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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<HallOfFame>,
@InjectRepository(CommunityContribution)
private readonly contributionRepo: Repository<CommunityContribution>,
) {}
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}`;
}
}

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