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 player quests (any status) const playerQuests = await this.playerQuestRepo.find({ where: { characterId }, select: ['questId', 'status'], }); const questStatusMap = new Map(playerQuests.map((pq) => [pq.questId, pq.status])); const quests = await this.questRepo.find({ relations: ['arc'], order: { arcOrder: 'ASC' }, }); return quests.filter((q) => { if (q.minLevel > character.level) return false; const status = questStatusMap.get(q.id); // Already active or completed (waiting claim) → not available if (status === 'active' || status === 'completed') return false; // Already claimed and not repeatable → not available if (status === 'claimed' && !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 (repeatable quests don't count toward the limit) if (!quest.repeatable) { const activeNonRepeatable = await this.playerQuestRepo .createQueryBuilder('pq') .innerJoin('pq.quest', 'q') .where('pq.character_id = :characterId', { characterId }) .andWhere('pq.status = :status', { status: 'active' }) .andWhere('q.repeatable = false') .getCount(); if (activeNonRepeatable >= MAX_ACTIVE_QUESTS) { throw new BadRequestException(`Maximum ${MAX_ACTIVE_QUESTS} quêtes actives (hors répétables)`); } } // 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, }); } } async abandon(playerQuestId: string, characterId: string) { const pq = await this.playerQuestRepo.findOne({ where: { id: playerQuestId, characterId }, relations: ['quest'], }); if (!pq) throw new NotFoundException('Quête introuvable'); if (pq.status !== 'active') throw new BadRequestException('Seules les quêtes actives peuvent être abandonnées'); await this.playerQuestRepo.remove(pq); return { abandoned: true, quest: pq.quest.name }; } // --- 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, }, })); } }