Files
TetaRdPG/src/quest/quest.service.ts
Tetardtek 95fcf325dc
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 36s
fix: quest available filtering + 6 side quests level 2-4
Fix: getAvailable filtre maintenant les quêtes active/completed (pas juste
claimed). Plus de doublons dailies, plus d'internal server error.

6 quêtes secondaires pour combler le gap level 2-5:
  Chasseur de champignons (lv2, 150 XP), La menace rampante (lv3, 180 XP),
  Guerrier éprouvé (lv2, 250 XP), Collecteur de trophées (lv3, 500 XP),
  Exterminateur (lv4, 400 XP), Première forge (lv2, 120 XP).
2026-03-24 18:08:49 +01:00

316 lines
10 KiB
TypeScript

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<Quest>,
@InjectRepository(QuestArc)
private readonly arcRepo: Repository<QuestArc>,
@InjectRepository(PlayerQuest)
private readonly playerQuestRepo: Repository<PlayerQuest>,
@InjectRepository(PlayerQuestArc)
private readonly playerArcRepo: Repository<PlayerQuestArc>,
@InjectRepository(Character)
private readonly characterRepo: Repository<Character>,
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,
},
}));
}
}