Lore Bible (canon narratif complet) + Engine Design (séparation moteur/univers). 4 nouvelles zones (Ruisseau Miroir, Marais des Murmures, Torrent Brisé, Source du Courant) dans la chaîne d'unlock après desert (niv 16-25+). Module NPC complet (entity, service, controller) — 8 PNJ avec dialogues évolutifs par palier de niveau : Gorn (niv 1-15), Pierre-Mémoire (niv 16+), Mira, Vell, La Batracienne, Le Forgeron, Le Marchand. 20 monstres lore-friendly, 12 matériaux, 15 items (dont Bâton de Gorn légendaire). 17 quêtes narratives (4 arcs ch.9-12) avec textes acceptText/completeText qui racontent l'Odyssée. Nouveau type story_event pour les moments narratifs purs. 3 quêtes répétables optionnelles. Seed runner : npm run seed:odyssee Tout est additif — zéro impact sur le contenu existant niv 1-15.
347 lines
12 KiB
TypeScript
347 lines
12 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';
|
|
import { getUnlockedZones } from '../common/zone-access';
|
|
|
|
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]));
|
|
|
|
// Zone locking — only show quests from unlocked zones
|
|
const unlockedZones = await getUnlockedZones(characterId, this.arcRepo, this.playerArcRepo);
|
|
|
|
const quests = await this.questRepo.find({
|
|
relations: ['arc'],
|
|
order: { arcOrder: 'ASC' },
|
|
});
|
|
|
|
return quests.filter((q) => {
|
|
if (q.minLevel > character.level) return false;
|
|
// Arc quests managed from arc panel — not in available
|
|
if (q.arcId) return false;
|
|
|
|
const status = questStatusMap.get(q.id);
|
|
if (status === 'active' || status === 'completed') return false;
|
|
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 + craft/forge quests don't count toward the 3-slot limit
|
|
const isCraftQuest = ['forge_item', 'craft_item'].includes(quest.objectiveType);
|
|
if (!quest.repeatable && !isCraftQuest) {
|
|
const activeCombat = 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')
|
|
.andWhere('q.objective_type NOT IN (:...types)', { types: ['forge_item', 'craft_item'] })
|
|
.getCount();
|
|
if (activeCombat >= MAX_ACTIVE_QUESTS) {
|
|
throw new BadRequestException(`Maximum ${MAX_ACTIVE_QUESTS} quêtes de combat 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);
|
|
}
|
|
|
|
// story_event quests complete immediately — they're narrative moments, not grinds
|
|
const isStoryEvent = quest.objectiveType === 'story_event';
|
|
|
|
const pq = this.playerQuestRepo.create({
|
|
characterId,
|
|
questId,
|
|
progress: isStoryEvent ? 1 : 0,
|
|
status: isStoryEvent ? 'completed' : 'active',
|
|
completedAt: isStoryEvent ? new Date() : null,
|
|
});
|
|
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;
|
|
zone?: string;
|
|
}) {
|
|
const { characterId, type, targetId, increment, zone } = 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;
|
|
|
|
// Zone check: if quest has a zone, only count actions from that zone
|
|
if (q.zone && zone && q.zone !== zone) 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 character = await this.characterRepo.findOne({ where: { id: characterId } });
|
|
const playerLevel = character?.level ?? 1;
|
|
|
|
const unlockedZones = await getUnlockedZones(characterId, this.arcRepo, this.playerArcRepo);
|
|
|
|
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', 'id', 'progress'],
|
|
});
|
|
const questDataMap = new Map(playerQuests.map((pq) => [pq.questId, pq]));
|
|
|
|
return arcs.map((arc) => {
|
|
const zoneUnlocked = !arc.zone || unlockedZones.includes(arc.zone);
|
|
return {
|
|
...arc,
|
|
zoneUnlocked,
|
|
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) => {
|
|
const pq = questDataMap.get(q.id);
|
|
return {
|
|
...q,
|
|
playerStatus: pq?.status ?? 'available',
|
|
playerQuestId: pq?.id ?? null,
|
|
progress: pq?.progress ?? 0,
|
|
canAccept: zoneUnlocked && !pq && q.minLevel <= playerLevel,
|
|
levelOk: q.minLevel <= playerLevel,
|
|
};
|
|
}),
|
|
progress: {
|
|
completed: arc.quests.filter((q) => questDataMap.get(q.id)?.status === 'claimed').length,
|
|
total: arc.quests.length,
|
|
},
|
|
};
|
|
});
|
|
}
|
|
}
|