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:
20
package-lock.json
generated
20
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^3.0.0",
|
"@nestjs/config": "^3.0.0",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
|
"@nestjs/event-emitter": "^3.0.1",
|
||||||
"@nestjs/jwt": "^10.0.0",
|
"@nestjs/jwt": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@nestjs/throttler": "^5.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": {
|
"node_modules/@nestjs/jwt": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz",
|
||||||
@@ -3826,6 +3840,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/events": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^3.0.0",
|
"@nestjs/config": "^3.0.0",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
|
"@nestjs/event-emitter": "^3.0.1",
|
||||||
"@nestjs/jwt": "^10.0.0",
|
"@nestjs/jwt": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@nestjs/throttler": "^5.0.0",
|
"@nestjs/throttler": "^5.0.0",
|
||||||
|
|||||||
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 { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { CharacterModule } from './character/character.module';
|
import { CharacterModule } from './character/character.module';
|
||||||
import { MonsterModule } from './monster/monster.module';
|
import { MonsterModule } from './monster/monster.module';
|
||||||
@@ -12,11 +13,16 @@ import { CraftModule } from './craft/craft.module';
|
|||||||
import { ForgeModule } from './forge/forge.module';
|
import { ForgeModule } from './forge/forge.module';
|
||||||
import { EconomyModule } from './economy/economy.module';
|
import { EconomyModule } from './economy/economy.module';
|
||||||
import { TwitchModule } from './twitch/twitch.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';
|
import { HealthController } from './common/health.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
EventEmitterModule.forRoot(),
|
||||||
|
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
@@ -47,6 +53,10 @@ import { HealthController } from './common/health.controller';
|
|||||||
ForgeModule,
|
ForgeModule,
|
||||||
EconomyModule,
|
EconomyModule,
|
||||||
TwitchModule,
|
TwitchModule,
|
||||||
|
AchievementModule,
|
||||||
|
CommunityModule,
|
||||||
|
HallOfFameModule,
|
||||||
|
ProfileModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ export class Character {
|
|||||||
@Column({ name: 'stat_points', default: 0 })
|
@Column({ name: 'stat_points', default: 0 })
|
||||||
statPoints: number;
|
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' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { MonsterModule } from '../monster/monster.module';
|
|||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { ItemModule } from '../item/item.module';
|
import { ItemModule } from '../item/item.module';
|
||||||
import { MaterialModule } from '../material/material.module';
|
import { MaterialModule } from '../material/material.module';
|
||||||
|
import { CommunityModule } from '../community/community.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -16,6 +17,7 @@ import { MaterialModule } from '../material/material.module';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
ItemModule,
|
ItemModule,
|
||||||
MaterialModule,
|
MaterialModule,
|
||||||
|
CommunityModule,
|
||||||
],
|
],
|
||||||
controllers: [CombatController],
|
controllers: [CombatController],
|
||||||
providers: [CombatService],
|
providers: [CombatService],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { Character } from '../character/entities/character.entity';
|
import { Character } from '../character/entities/character.entity';
|
||||||
import { Monster } from '../monster/monster.entity';
|
import { Monster } from '../monster/monster.entity';
|
||||||
import { MonsterService } from '../monster/monster.service';
|
import { MonsterService } from '../monster/monster.service';
|
||||||
@@ -9,6 +10,7 @@ import { StartCombatDto } from './dto/start-combat.dto';
|
|||||||
import { User } from '../user/user.entity';
|
import { User } from '../user/user.entity';
|
||||||
import { ItemService } from '../item/item.service';
|
import { ItemService } from '../item/item.service';
|
||||||
import { MaterialService } from '../material/material.service';
|
import { MaterialService } from '../material/material.service';
|
||||||
|
import { CommunityService } from '../community/community.service';
|
||||||
import {
|
import {
|
||||||
resolveCombat,
|
resolveCombat,
|
||||||
applyXpGain,
|
applyXpGain,
|
||||||
@@ -31,6 +33,8 @@ export class CombatService {
|
|||||||
private readonly monsterService: MonsterService,
|
private readonly monsterService: MonsterService,
|
||||||
private readonly itemService: ItemService,
|
private readonly itemService: ItemService,
|
||||||
private readonly materialService: MaterialService,
|
private readonly materialService: MaterialService,
|
||||||
|
private readonly communityService: CommunityService,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async startCombat(dto: StartCombatDto, user: User) {
|
async startCombat(dto: StartCombatDto, user: User) {
|
||||||
@@ -126,12 +130,63 @@ export class CombatService {
|
|||||||
character.gold = Math.max(0, character.gold - goldLost);
|
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)
|
// Sauvegarder l'endurance (lazy reset)
|
||||||
character.hpCurrent = newHp;
|
character.hpCurrent = newHp;
|
||||||
character.enduranceSaved = newEnduranceSaved;
|
character.enduranceSaved = newEnduranceSaved;
|
||||||
character.lastEnduranceTs = new Date();
|
character.lastEnduranceTs = new Date();
|
||||||
await this.characterRepository.save(character);
|
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
|
// 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;
|
let lootMaterial: { name: string; quantity: number } | null = null;
|
||||||
if (result.winner === 'player' && monster.dropMaterialId && Math.random() < 0.4) {
|
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 { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { Recipe } from './recipe.entity';
|
import { Recipe } from './recipe.entity';
|
||||||
import { CraftJob } from './craft-job.entity';
|
import { CraftJob } from './craft-job.entity';
|
||||||
import { Character } from '../character/entities/character.entity';
|
import { Character } from '../character/entities/character.entity';
|
||||||
@@ -19,6 +20,7 @@ export class CraftService {
|
|||||||
private readonly characterRepository: Repository<Character>,
|
private readonly characterRepository: Repository<Character>,
|
||||||
private readonly itemService: ItemService,
|
private readonly itemService: ItemService,
|
||||||
private readonly materialService: MaterialService,
|
private readonly materialService: MaterialService,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
findAllRecipes() {
|
findAllRecipes() {
|
||||||
@@ -113,6 +115,13 @@ export class CraftService {
|
|||||||
job.collected = true;
|
job.collected = true;
|
||||||
await this.craftJobRepository.save(job);
|
await this.craftJobRepository.save(job);
|
||||||
|
|
||||||
|
// Emit achievement event
|
||||||
|
this.eventEmitter.emit('achievement.check', {
|
||||||
|
characterId: char.id,
|
||||||
|
type: 'craft_completed',
|
||||||
|
increment: 1,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
collected: true,
|
collected: true,
|
||||||
item: charItem.item,
|
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 { Recipe } from '../craft/recipe.entity';
|
||||||
import { CraftJob } from '../craft/craft-job.entity';
|
import { CraftJob } from '../craft/craft-job.entity';
|
||||||
import { Monster } from '../monster/monster.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)
|
// DataSource pour le CLI TypeORM (migrations manuelles)
|
||||||
export const AppDataSource = new DataSource({
|
export const AppDataSource = new DataSource({
|
||||||
@@ -34,6 +40,12 @@ export const AppDataSource = new DataSource({
|
|||||||
Recipe,
|
Recipe,
|
||||||
CraftJob,
|
CraftJob,
|
||||||
Monster,
|
Monster,
|
||||||
|
Achievement,
|
||||||
|
PlayerAchievement,
|
||||||
|
CommunityGoal,
|
||||||
|
CommunityContribution,
|
||||||
|
ActiveBoost,
|
||||||
|
HallOfFame,
|
||||||
],
|
],
|
||||||
migrations: [__dirname + '/migrations/*{.ts,.js}'],
|
migrations: [__dirname + '/migrations/*{.ts,.js}'],
|
||||||
synchronize: false,
|
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 { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { CharacterItem } from '../item/character-item.entity';
|
import { CharacterItem } from '../item/character-item.entity';
|
||||||
import { Character } from '../character/entities/character.entity';
|
import { Character } from '../character/entities/character.entity';
|
||||||
import { User } from '../user/user.entity';
|
import { User } from '../user/user.entity';
|
||||||
@@ -24,6 +25,7 @@ export class ForgeService {
|
|||||||
private readonly charItemRepository: Repository<CharacterItem>,
|
private readonly charItemRepository: Repository<CharacterItem>,
|
||||||
@InjectRepository(Character)
|
@InjectRepository(Character)
|
||||||
private readonly characterRepository: Repository<Character>,
|
private readonly characterRepository: Repository<Character>,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async upgradeItem(charItemId: string, user: User) {
|
async upgradeItem(charItemId: string, user: User) {
|
||||||
@@ -46,6 +48,18 @@ export class ForgeService {
|
|||||||
charItem.forgeLevel = targetLevel;
|
charItem.forgeLevel = targetLevel;
|
||||||
await this.charItemRepository.save(charItem);
|
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'
|
const statLabel = charItem.item.type === 'weapon'
|
||||||
? `+${FORGE_BONUS_PER_LEVEL} ATK`
|
? `+${FORGE_BONUS_PER_LEVEL} ATK`
|
||||||
: `+${FORGE_BONUS_PER_LEVEL} DEF`;
|
: `+${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