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;
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
30
src/community/active-boost.entity.ts
Normal file
30
src/community/active-boost.entity.ts
Normal 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;
|
||||
}
|
||||
34
src/community/community-contribution.entity.ts
Normal file
34
src/community/community-contribution.entity.ts
Normal 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;
|
||||
}
|
||||
47
src/community/community-goal.entity.ts
Normal file
47
src/community/community-goal.entity.ts
Normal 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;
|
||||
}
|
||||
22
src/community/community.controller.ts
Normal file
22
src/community/community.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
17
src/community/community.module.ts
Normal file
17
src/community/community.module.ts
Normal 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 {}
|
||||
112
src/community/community.service.ts
Normal file
112
src/community/community.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
42
src/database/achievements-seed.ts
Normal file
42
src/database/achievements-seed.ts
Normal 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`);
|
||||
}
|
||||
58
src/database/community-goals-seed.ts
Normal file
58
src/database/community-goals-seed.ts
Normal 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`);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
20
src/database/seed-sprint4.ts
Normal file
20
src/database/seed-sprint4.ts
Normal 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);
|
||||
});
|
||||
@@ -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`;
|
||||
|
||||
33
src/halloffame/hall-of-fame.entity.ts
Normal file
33
src/halloffame/hall-of-fame.entity.ts
Normal 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'
|
||||
}
|
||||
17
src/halloffame/halloffame.controller.ts
Normal file
17
src/halloffame/halloffame.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
src/halloffame/halloffame.module.ts
Normal file
16
src/halloffame/halloffame.module.ts
Normal 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 {}
|
||||
97
src/halloffame/halloffame.service.ts
Normal file
97
src/halloffame/halloffame.service.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
37
src/profile/profile.controller.ts
Normal file
37
src/profile/profile.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
18
src/profile/profile.module.ts
Normal file
18
src/profile/profile.module.ts
Normal 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 {}
|
||||
82
src/profile/profile.service.ts
Normal file
82
src/profile/profile.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user