diff --git a/src/app.module.ts b/src/app.module.ts index 33891e3..ffce419 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ 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 { QuestModule } from './quest/quest.module'; import { HealthController } from './common/health.controller'; @Module({ @@ -57,6 +58,7 @@ import { HealthController } from './common/health.controller'; CommunityModule, HallOfFameModule, ProfileModule, + QuestModule, ], controllers: [HealthController], }) diff --git a/src/character/character.service.ts b/src/character/character.service.ts index 7446b67..24cb25d 100644 --- a/src/character/character.service.ts +++ b/src/character/character.service.ts @@ -14,8 +14,8 @@ import { User } from '../user/user.entity'; import { xpRequiredForLevel } from '../combat/combat.engine'; const STAT_POOL = 10; // 5 stats × 1 base + 5 points à distribuer -const ENDURANCE_REGEN_MINUTES = 6; // 1 pt d'endurance toutes les 6 min = 10 pts/heure -const REST_ENDURANCE_COST = 20; +const ENDURANCE_REGEN_MINUTES = 3; // 1 pt d'endurance toutes les 3 min = 20 pts/heure +const REST_ENDURANCE_COST = 10; const REST_HP_REGEN_RATIO = 0.5; // +50% hpMax @Injectable() diff --git a/src/combat/combat.engine.ts b/src/combat/combat.engine.ts index 6eb5f09..3a956aa 100644 --- a/src/combat/combat.engine.ts +++ b/src/combat/combat.engine.ts @@ -53,7 +53,7 @@ function statForAttackType(stats: CombatantStats): number { export function calcPlayerDamage(player: CombatantStats, monsterDefense: number): number { const stat = statForAttackType(player); - const raw = player.attack + Math.floor(stat * 1.5); + const raw = 3 + player.attack + Math.floor(stat * 1.5); // +3 base damage return Math.max(1, raw - monsterDefense); } diff --git a/src/combat/combat.service.ts b/src/combat/combat.service.ts index db8ede2..2083916 100644 --- a/src/combat/combat.service.ts +++ b/src/combat/combat.service.ts @@ -18,8 +18,8 @@ import { CombatantStats, } from './combat.engine'; -const COMBAT_ENDURANCE_COST = 10; -const DEFEAT_ENDURANCE_PENALTY = 50; +const COMBAT_ENDURANCE_COST = 5; +const DEFEAT_ENDURANCE_PENALTY = 25; const DEFEAT_HP_RATIO = 0.2; // 20% hpMax à la défaite const VICTORY_HP_REGEN_RATIO = 0.1; // +10% hpMax à la victoire const DEFEAT_GOLD_LOSS_RATIO = 0.05; // perte 5% or à la défaite @@ -171,6 +171,9 @@ export class CombatService { if (result.winner === 'player' && monster.dropMaterialId && Math.random() < 0.4) { await this.materialService.addMaterial(character.id, monster.dropMaterialId, 1); lootMaterial = { name: 'matériau', quantity: 1 }; + this.eventEmitter.emit('quest.progress', { + characterId: character.id, type: 'gather_material', targetId: monster.dropMaterialId, increment: 1, + }); } // Persister le log @@ -203,6 +206,13 @@ export class CombatService { this.eventEmitter.emit('community.contribute', { characterId: character.id, type: 'total_gold_earned', increment: result.goldEarned, }); + // Quest progress + this.eventEmitter.emit('quest.progress', { + characterId: character.id, type: 'kill_any', increment: 1, + }); + this.eventEmitter.emit('quest.progress', { + characterId: character.id, type: 'kill_monster', targetId: monster.id, increment: 1, + }); } // Construire la réponse diff --git a/src/craft/craft.service.ts b/src/craft/craft.service.ts index e1cdbcb..61c3bb5 100644 --- a/src/craft/craft.service.ts +++ b/src/craft/craft.service.ts @@ -115,12 +115,15 @@ export class CraftService { job.collected = true; await this.craftJobRepository.save(job); - // Emit achievement event + // Emit achievement + quest events this.eventEmitter.emit('achievement.check', { characterId: char.id, type: 'craft_completed', increment: 1, }); + this.eventEmitter.emit('quest.progress', { + characterId: char.id, type: 'craft_item', increment: 1, + }); return { collected: true, diff --git a/src/database/data-source.ts b/src/database/data-source.ts index 321fc7b..00ddddb 100644 --- a/src/database/data-source.ts +++ b/src/database/data-source.ts @@ -20,6 +20,10 @@ 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'; +import { Quest } from '../quest/quest.entity'; +import { QuestArc } from '../quest/quest-arc.entity'; +import { PlayerQuest } from '../quest/player-quest.entity'; +import { PlayerQuestArc } from '../quest/player-quest-arc.entity'; // DataSource pour le CLI TypeORM (migrations manuelles) export const AppDataSource = new DataSource({ @@ -46,6 +50,10 @@ export const AppDataSource = new DataSource({ CommunityContribution, ActiveBoost, HallOfFame, + Quest, + QuestArc, + PlayerQuest, + PlayerQuestArc, ], migrations: [__dirname + '/migrations/*{.ts,.js}'], synchronize: false, diff --git a/src/database/quests-seed.ts b/src/database/quests-seed.ts new file mode 100644 index 0000000..4a46293 --- /dev/null +++ b/src/database/quests-seed.ts @@ -0,0 +1,176 @@ +import { DataSource } from 'typeorm'; +import { QuestArc } from '../quest/quest-arc.entity'; +import { Quest } from '../quest/quest.entity'; +import { Achievement } from '../achievement/achievement.entity'; + +export async function seedQuests(dataSource: DataSource) { + const arcRepo = dataSource.getRepository(QuestArc); + const questRepo = dataSource.getRepository(Quest); + const achievementRepo = dataSource.getRepository(Achievement); + + // --- Arc 1: Les Marais du Têtard --- + let arc = await arcRepo.findOne({ where: { name: 'Les Marais du Têtard' } }); + if (!arc) { + arc = await arcRepo.save(arcRepo.create({ + name: 'Les Marais du Têtard', + description: 'Les marais grouillent de créatures hostiles. Nettoyez la zone pour prouver votre valeur.', + zone: 'marais', + sortOrder: 1, + minLevel: 1, + })); + } + + // Get monster IDs for targeted quests + const monsters = await dataSource.query('SELECT id, name FROM monsters'); + const monsterMap = new Map(monsters.map((m: any) => [m.name, m.id])); + + const materials = await dataSource.query('SELECT id, name FROM materials'); + const materialMap = new Map(materials.map((m: any) => [m.name, m.id])); + + const QUESTS = [ + { + name: 'Premiers pas', + description: 'Éliminez 3 Têtards Vase pour sécuriser les abords du village.', + objectiveType: 'kill_monster', + objectiveTargetId: monsterMap.get('Têtard Vase') ?? null as string | null, + objectiveCount: 3, + rewardXp: 100, + rewardGold: 30, + rewardTitle: null, + arcId: arc.id, + arcOrder: 1, + minLevel: 1, + repeatable: false, + }, + { + name: 'Chasseur de grenouilles', + description: 'Les Grenouilles Boueuses menacent les récoltes. Abattez-en 5.', + objectiveType: 'kill_monster', + objectiveTargetId: monsterMap.get('Grenouille Boueuse') ?? null as string | null, + objectiveCount: 5, + rewardXp: 200, + rewardGold: 60, + rewardTitle: null, + arcId: arc.id, + arcOrder: 2, + minLevel: 2, + repeatable: false, + }, + { + name: 'Apprenti combattant', + description: 'Prouvez votre endurance en remportant 10 combats.', + objectiveType: 'kill_any', + objectiveTargetId: null, + objectiveCount: 10, + rewardXp: 150, + rewardGold: 50, + rewardTitle: null, + arcId: arc.id, + arcOrder: 3, + minLevel: 1, + repeatable: false, + }, + { + name: 'Le gardien des marais', + description: 'Le Golem de Boue terrorise les marais depuis trop longtemps. Mettez fin à son règne.', + objectiveType: 'kill_monster', + objectiveTargetId: monsterMap.get('Golem de Boue') ?? null as string | null, + objectiveCount: 1, + rewardXp: 500, + rewardGold: 200, + rewardTitle: 'Nettoyeur des Marais', + arcId: arc.id, + arcOrder: 4, + minLevel: 5, + repeatable: false, + }, + ]; + + // Standalone repeatable quests (daily feel) + const STANDALONE = [ + { + name: 'Chasse du jour', + description: 'Remportez 5 combats.', + objectiveType: 'kill_any', + objectiveTargetId: null, + objectiveCount: 5, + rewardXp: 80, + rewardGold: 25, + rewardTitle: null, + arcId: null, + arcOrder: 0, + minLevel: 1, + repeatable: true, + }, + { + name: 'Apprenti forgeron', + description: 'Améliorez un item à la forge.', + objectiveType: 'forge_item', + objectiveTargetId: null, + objectiveCount: 1, + rewardXp: 60, + rewardGold: 20, + rewardTitle: null, + arcId: null, + arcOrder: 0, + minLevel: 1, + repeatable: true, + }, + { + name: 'Artisan du jour', + description: 'Complétez un craft.', + objectiveType: 'craft_item', + objectiveTargetId: null, + objectiveCount: 1, + rewardXp: 50, + rewardGold: 15, + rewardTitle: null, + arcId: null, + arcOrder: 0, + minLevel: 1, + repeatable: true, + }, + ]; + + for (const data of [...QUESTS, ...STANDALONE]) { + const existing = await questRepo.findOne({ where: { name: data.name } }); + if (!existing) { + await questRepo.save(questRepo.create(data)); + } + } + + // --- Quest achievements --- + const QUEST_ACHIEVEMENTS = [ + { key: 'quests_5', name: 'Aventurier Curieux', description: 'Compléter 5 quêtes', category: 'progression', tier: 'bronze', criteriaType: 'quests_completed', criteriaValue: 5, rewardGold: 100, rewardTitle: null }, + { key: 'quests_25', name: 'Héros du Peuple', description: 'Compléter 25 quêtes', category: 'progression', tier: 'silver', criteriaType: 'quests_completed', criteriaValue: 25, rewardGold: 500, rewardTitle: 'Héros du Peuple' }, + { key: 'quests_100', name: 'Légende Quêteuse', description: 'Compléter 100 quêtes', category: 'progression', tier: 'gold', criteriaType: 'quests_completed', criteriaValue: 100, rewardGold: 2000, rewardTitle: 'Légende Quêteuse' }, + { key: 'arc_1', name: 'Premier Arc', description: 'Compléter un arc narratif', category: 'progression', tier: 'bronze', criteriaType: 'quest_arc_completed', criteriaValue: 1, rewardGold: 300, rewardTitle: null }, + { key: 'arc_3', name: 'Conteur', description: 'Compléter 3 arcs narratifs', category: 'progression', tier: 'silver', criteriaType: 'quest_arc_completed', criteriaValue: 3, rewardGold: 1000, rewardTitle: 'Conteur' }, + ]; + + for (const data of QUEST_ACHIEVEMENTS) { + const existing = await achievementRepo.findOne({ where: { key: data.key } }); + if (!existing) { + await achievementRepo.save(achievementRepo.create(data)); + } + } + + console.log(`✅ 1 arc + ${QUESTS.length + STANDALONE.length} quêtes + ${QUEST_ACHIEVEMENTS.length} achievements seedés`); +} + +// Update monster XP rewards (nerf for quest-driven progression) +export async function nerfMonsterXp(dataSource: DataSource) { + const updates = [ + { name: 'Têtard Vase', xpReward: 8 }, + { name: 'Grenouille Boueuse', xpReward: 15 }, + { name: 'Serpent des Marais', xpReward: 25 }, + { name: 'Champi Vénéneux', xpReward: 20 }, + { name: 'Golem de Boue', xpReward: 50 }, + ]; + + for (const u of updates) { + await dataSource.query('UPDATE monsters SET xp_reward = ? WHERE name = ?', [u.xpReward, u.name]); + } + + console.log('✅ XP monstres ajustée (nerf pour progression par quêtes)'); +} diff --git a/src/database/seed-sprint5.ts b/src/database/seed-sprint5.ts new file mode 100644 index 0000000..78ac5b4 --- /dev/null +++ b/src/database/seed-sprint5.ts @@ -0,0 +1,19 @@ +import 'reflect-metadata'; +import { AppDataSource } from './data-source'; +import { seedQuests, nerfMonsterXp } from './quests-seed'; + +async function seed() { + await AppDataSource.initialize(); + console.log('DB connectée (MySQL)'); + + await seedQuests(AppDataSource); + await nerfMonsterXp(AppDataSource); + + await AppDataSource.destroy(); + console.log('✅ Sprint 5 seed terminé'); +} + +seed().catch((err) => { + console.error('Seed échoué :', err); + process.exit(1); +}); diff --git a/src/forge/forge.service.ts b/src/forge/forge.service.ts index f3aaa62..7348ce6 100644 --- a/src/forge/forge.service.ts +++ b/src/forge/forge.service.ts @@ -104,6 +104,9 @@ export class ForgeService { this.eventEmitter.emit('community.contribute', { characterId: char.id, type: 'total_forge_upgrades', increment: 1, }); + this.eventEmitter.emit('quest.progress', { + characterId: char.id, type: 'forge_item', increment: 1, + }); } await manager.save(char); diff --git a/src/quest/player-quest-arc.entity.ts b/src/quest/player-quest-arc.entity.ts new file mode 100644 index 0000000..3dd2f6f --- /dev/null +++ b/src/quest/player-quest-arc.entity.ts @@ -0,0 +1,38 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Character } from '../character/entities/character.entity'; +import { QuestArc } from './quest-arc.entity'; + +@Entity('player_quest_arcs') +@Index(['characterId', 'questArcId'], { unique: true }) +export class PlayerQuestArc { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'character_id' }) + @Index() + characterId: string; + + @ManyToOne(() => Character) + @JoinColumn({ name: 'character_id' }) + character: Character; + + @Column({ name: 'quest_arc_id' }) + questArcId: string; + + @ManyToOne(() => QuestArc) + @JoinColumn({ name: 'quest_arc_id' }) + questArc: QuestArc; + + @Column({ default: false }) + completed: boolean; + + @Column({ name: 'completed_at', type: 'timestamp', nullable: true }) + completedAt: Date | null; +} diff --git a/src/quest/player-quest.entity.ts b/src/quest/player-quest.entity.ts new file mode 100644 index 0000000..8a2b16a --- /dev/null +++ b/src/quest/player-quest.entity.ts @@ -0,0 +1,46 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + Index, + CreateDateColumn, +} from 'typeorm'; +import { Character } from '../character/entities/character.entity'; +import { Quest } from './quest.entity'; + +@Entity('player_quests') +@Index(['characterId', 'questId'], { unique: true }) +export class PlayerQuest { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'character_id' }) + @Index() + characterId: string; + + @ManyToOne(() => Character) + @JoinColumn({ name: 'character_id' }) + character: Character; + + @Column({ name: 'quest_id' }) + @Index() + questId: string; + + @ManyToOne(() => Quest, { eager: true }) + @JoinColumn({ name: 'quest_id' }) + quest: Quest; + + @Column({ default: 0 }) + progress: number; + + @Column({ length: 20, default: 'active' }) + status: string; // 'active' | 'completed' | 'claimed' + + @CreateDateColumn({ name: 'accepted_at' }) + acceptedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamp', nullable: true }) + completedAt: Date | null; +} diff --git a/src/quest/quest-arc.entity.ts b/src/quest/quest-arc.entity.ts new file mode 100644 index 0000000..2a772bf --- /dev/null +++ b/src/quest/quest-arc.entity.ts @@ -0,0 +1,31 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToMany, +} from 'typeorm'; +import { Quest } from './quest.entity'; + +@Entity('quest_arcs') +export class QuestArc { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 100 }) + name: string; + + @Column('text') + description: string; + + @Column({ length: 50, nullable: true, type: 'varchar' }) + zone: string | null; // 'marais', 'foret', etc. + + @Column({ name: 'sort_order', default: 0 }) + sortOrder: number; + + @Column({ name: 'min_level', default: 1 }) + minLevel: number; + + @OneToMany(() => Quest, (q) => q.arc) + quests: Quest[]; +} diff --git a/src/quest/quest.controller.ts b/src/quest/quest.controller.ts new file mode 100644 index 0000000..560175d --- /dev/null +++ b/src/quest/quest.controller.ts @@ -0,0 +1,60 @@ +import { Controller, Get, Post, Param, Req, UseGuards, BadRequestException } from '@nestjs/common'; +import { QuestService } from './quest.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('quests') +@UseGuards(AuthGuard) +export class QuestController { + constructor( + private readonly questService: QuestService, + @InjectRepository(Character) + private readonly characterRepo: Repository, + ) {} + + @Get('available') + async getAvailable(@Req() req: Request) { + const char = await this.getCharacter(req); + return this.questService.getAvailable(char.id); + } + + @Get('active') + async getActive(@Req() req: Request) { + const char = await this.getCharacter(req); + return this.questService.getActive(char.id); + } + + @Get('completed') + async getCompleted(@Req() req: Request) { + const char = await this.getCharacter(req); + return this.questService.getCompleted(char.id); + } + + @Post('accept/:id') + async accept(@Param('id') questId: string, @Req() req: Request) { + const char = await this.getCharacter(req); + return this.questService.accept(questId, char.id); + } + + @Post('claim/:id') + async claim(@Param('id') playerQuestId: string, @Req() req: Request) { + const char = await this.getCharacter(req); + return this.questService.claim(playerQuestId, char.id); + } + + @Get('arcs') + async getArcs(@Req() req: Request) { + const char = await this.getCharacter(req); + return this.questService.getArcs(char.id); + } + + private async getCharacter(req: Request): Promise { + const user = (req as any).user; + const character = await this.characterRepo.findOne({ where: { userId: user.id } }); + if (!character) throw new BadRequestException('Aucun personnage trouvé'); + return character; + } +} diff --git a/src/quest/quest.entity.ts b/src/quest/quest.entity.ts new file mode 100644 index 0000000..81a65ec --- /dev/null +++ b/src/quest/quest.entity.ts @@ -0,0 +1,60 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { QuestArc } from './quest-arc.entity'; + +@Entity('quests') +export class Quest { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 100 }) + name: string; + + @Column('text') + description: string; + + // Objectif + @Column({ name: 'objective_type', length: 30 }) + objectiveType: string; // 'kill_monster' | 'kill_any' | 'gather_material' | 'craft_item' | 'forge_item' + + @Column({ name: 'objective_target_id', type: 'varchar', length: 255, nullable: true }) + objectiveTargetId: string | null; // monster ID or material ID (null for kill_any) + + @Column({ name: 'objective_count', default: 1 }) + objectiveCount: number; + + // Récompenses + @Column({ name: 'reward_xp', default: 0 }) + rewardXp: number; + + @Column({ name: 'reward_gold', default: 0 }) + rewardGold: number; + + @Column({ name: 'reward_title', type: 'varchar', length: 100, nullable: true }) + rewardTitle: string | null; + + // Arc (optionnel) + @Column({ name: 'arc_id', type: 'varchar', nullable: true }) + @Index() + arcId: string | null; + + @ManyToOne(() => QuestArc, (a) => a.quests, { nullable: true }) + @JoinColumn({ name: 'arc_id' }) + arc: QuestArc | null; + + @Column({ name: 'arc_order', default: 0 }) + arcOrder: number; // order within the arc + + // Availability + @Column({ name: 'min_level', default: 1 }) + minLevel: number; + + @Column({ name: 'repeatable', default: false }) + repeatable: boolean; +} diff --git a/src/quest/quest.module.ts b/src/quest/quest.module.ts new file mode 100644 index 0000000..eff44cf --- /dev/null +++ b/src/quest/quest.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Quest } from './quest.entity'; +import { QuestArc } from './quest-arc.entity'; +import { PlayerQuest } from './player-quest.entity'; +import { PlayerQuestArc } from './player-quest-arc.entity'; +import { QuestService } from './quest.service'; +import { QuestController } from './quest.controller'; +import { AuthModule } from '../auth/auth.module'; +import { Character } from '../character/entities/character.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Quest, QuestArc, PlayerQuest, PlayerQuestArc, Character]), + AuthModule, + ], + controllers: [QuestController], + providers: [QuestService], + exports: [QuestService], +}) +export class QuestModule {} diff --git a/src/quest/quest.service.ts b/src/quest/quest.service.ts new file mode 100644 index 0000000..943db6a --- /dev/null +++ b/src/quest/quest.service.ts @@ -0,0 +1,292 @@ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { OnEvent } from '@nestjs/event-emitter'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Quest } from './quest.entity'; +import { QuestArc } from './quest-arc.entity'; +import { PlayerQuest } from './player-quest.entity'; +import { PlayerQuestArc } from './player-quest-arc.entity'; +import { Character } from '../character/entities/character.entity'; + +const MAX_ACTIVE_QUESTS = 3; + +@Injectable() +export class QuestService { + constructor( + @InjectRepository(Quest) + private readonly questRepo: Repository, + @InjectRepository(QuestArc) + private readonly arcRepo: Repository, + @InjectRepository(PlayerQuest) + private readonly playerQuestRepo: Repository, + @InjectRepository(PlayerQuestArc) + private readonly playerArcRepo: Repository, + @InjectRepository(Character) + private readonly characterRepo: Repository, + private readonly dataSource: DataSource, + private readonly eventEmitter: EventEmitter2, + ) {} + + async getAvailable(characterId: string) { + const character = await this.characterRepo.findOne({ where: { id: characterId } }); + if (!character) throw new BadRequestException('Aucun personnage'); + + // All quests the player hasn't completed (or repeatable) + const completed = await this.playerQuestRepo.find({ + where: { characterId, status: 'claimed' }, + select: ['questId'], + }); + const completedIds = new Set(completed.map((pq) => pq.questId)); + + const quests = await this.questRepo.find({ + where: { minLevel: undefined }, // load all, filter in code + relations: ['arc'], + order: { arcOrder: 'ASC' }, + }); + + return quests.filter((q) => { + if (q.minLevel > character.level) return false; + if (completedIds.has(q.id) && !q.repeatable) return false; + return true; + }); + } + + async getActive(characterId: string) { + return this.playerQuestRepo.find({ + where: { characterId, status: 'active' }, + relations: ['quest', 'quest.arc'], + order: { acceptedAt: 'ASC' }, + }); + } + + async getCompleted(characterId: string) { + return this.playerQuestRepo.find({ + where: [ + { characterId, status: 'completed' }, + { characterId, status: 'claimed' }, + ], + relations: ['quest', 'quest.arc'], + order: { completedAt: 'DESC' }, + take: 20, + }); + } + + async accept(questId: string, characterId: string) { + const quest = await this.questRepo.findOne({ where: { id: questId } }); + if (!quest) throw new NotFoundException('Quête introuvable'); + + const character = await this.characterRepo.findOne({ where: { id: characterId } }); + if (!character) throw new BadRequestException('Aucun personnage'); + + if (quest.minLevel > character.level) { + throw new BadRequestException(`Niveau ${quest.minLevel} requis`); + } + + // Check active quest count + const activeCount = await this.playerQuestRepo.count({ + where: { characterId, status: 'active' }, + }); + if (activeCount >= MAX_ACTIVE_QUESTS) { + throw new BadRequestException(`Maximum ${MAX_ACTIVE_QUESTS} quêtes actives`); + } + + // Check not already active + const existing = await this.playerQuestRepo.findOne({ + where: { characterId, questId }, + }); + if (existing && existing.status === 'active') { + throw new BadRequestException('Quête déjà acceptée'); + } + if (existing && existing.status === 'claimed' && !quest.repeatable) { + throw new BadRequestException('Quête déjà complétée'); + } + + // If repeatable and already claimed, reset + if (existing && quest.repeatable) { + existing.progress = 0; + existing.status = 'active'; + existing.completedAt = null; + return this.playerQuestRepo.save(existing); + } + + const pq = this.playerQuestRepo.create({ + characterId, + questId, + progress: 0, + status: 'active', + }); + return this.playerQuestRepo.save(pq); + } + + async claim(playerQuestId: string, characterId: string) { + return this.dataSource.transaction(async (manager) => { + const pq = await manager.getRepository(PlayerQuest).findOne({ + where: { id: playerQuestId, characterId }, + relations: ['quest', 'quest.arc'], + }); + if (!pq) throw new NotFoundException('Quête introuvable'); + if (pq.status !== 'completed') throw new BadRequestException('Quête pas encore terminée'); + + pq.status = 'claimed'; + await manager.save(pq); + + // Credit rewards + const character = await manager + .getRepository(Character) + .createQueryBuilder('c') + .setLock('pessimistic_write') + .where('c.id = :id', { id: characterId }) + .getOne(); + + if (character) { + character.xp += pq.quest.rewardXp; + character.gold += pq.quest.rewardGold; + character.totalGoldEarned = Number(character.totalGoldEarned ?? 0) + pq.quest.rewardGold; + + // Apply level up + const { applyXpGain } = require('../combat/combat.engine'); + const levelUp = applyXpGain(character.level, character.xp, 0); + // applyXpGain with 0 earned recalculates from existing XP + // Actually we need to apply the XP gain properly + const result = applyXpGain(character.level, character.xp - pq.quest.rewardXp, pq.quest.rewardXp); + character.level = result.newLevel; + character.xp = result.newXp; + character.statPoints = (character.statPoints ?? 0) + result.statPointsGained; + + if (result.statPointsGained > 0) { + character.hpMax += 0; // no auto HP increase from quests + } + + await manager.save(character); + } + + // Emit achievement events + this.eventEmitter.emit('achievement.check', { + characterId, type: 'quests_completed', increment: 1, + }); + + // Check arc completion + if (pq.quest.arcId) { + await this.checkArcCompletion(characterId, pq.quest.arcId, manager); + } + + return { + claimed: true, + quest: pq.quest.name, + rewardXp: pq.quest.rewardXp, + rewardGold: pq.quest.rewardGold, + rewardTitle: pq.quest.rewardTitle, + levelUp: character ? { level: character.level, statPoints: character.statPoints } : null, + }; + }); + } + + private async checkArcCompletion(characterId: string, arcId: string, manager: any) { + // Get all quests in this arc + const arcQuests = await manager.getRepository(Quest).find({ + where: { arcId }, + }); + + // Check all are claimed + const claimed = await manager.getRepository(PlayerQuest).find({ + where: arcQuests.map((q) => ({ characterId, questId: q.id, status: 'claimed' as const })), + }); + + if (claimed.length >= arcQuests.length) { + // Arc complete! + let playerArc = await manager.getRepository(PlayerQuestArc).findOne({ + where: { characterId, questArcId: arcId }, + }); + + if (!playerArc) { + playerArc = manager.getRepository(PlayerQuestArc).create({ + characterId, + questArcId: arcId, + completed: true, + completedAt: new Date(), + }); + } else if (!playerArc.completed) { + playerArc.completed = true; + playerArc.completedAt = new Date(); + } else { + return; // already completed + } + + await manager.save(playerArc); + + this.eventEmitter.emit('achievement.check', { + characterId, type: 'quest_arc_completed', increment: 1, + }); + } + } + + // --- Event listeners for quest progress --- + + @OnEvent('quest.progress') + async handleQuestProgress(event: { + characterId: string; + type: string; + targetId?: string; + increment: number; + }) { + const { characterId, type, targetId, increment } = event; + + // Find active quests matching this event + const activeQuests = await this.playerQuestRepo.find({ + where: { characterId, status: 'active' }, + relations: ['quest'], + }); + + for (const pq of activeQuests) { + const q = pq.quest; + if (q.objectiveType !== type) continue; + + // For targeted objectives, check target matches + if (q.objectiveTargetId && q.objectiveTargetId !== targetId) continue; + + pq.progress = Math.min(pq.progress + increment, q.objectiveCount); + + if (pq.progress >= q.objectiveCount) { + pq.status = 'completed'; + pq.completedAt = new Date(); + } + + await this.playerQuestRepo.save(pq); + } + } + + // --- Arcs --- + + async getArcs(characterId: string) { + const arcs = await this.arcRepo.find({ + relations: ['quests'], + order: { sortOrder: 'ASC' }, + }); + + const playerArcs = await this.playerArcRepo.find({ where: { characterId } }); + const arcMap = new Map(playerArcs.map((pa) => [pa.questArcId, pa])); + + const playerQuests = await this.playerQuestRepo.find({ + where: { characterId }, + select: ['questId', 'status'], + }); + const questStatusMap = new Map(playerQuests.map((pq) => [pq.questId, pq.status])); + + return arcs.map((arc) => ({ + ...arc, + completed: arcMap.get(arc.id)?.completed ?? false, + completedAt: arcMap.get(arc.id)?.completedAt ?? null, + quests: arc.quests + .sort((a, b) => a.arcOrder - b.arcOrder) + .map((q) => ({ + ...q, + playerStatus: questStatusMap.get(q.id) ?? 'available', + })), + progress: { + completed: arc.quests.filter((q) => questStatusMap.get(q.id) === 'claimed').length, + total: arc.quests.length, + }, + })); + } +}