feat(sprint5): quest system + arcs + rebalance endurance/damage/xp
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s

Quest system:
  4 entities (quest_arcs, quests, player_quests, player_quest_arcs)
  Arc "Les Marais du Têtard" (4 quêtes narratives)
  3 quêtes standalone répétables (chasse/forge/craft)
  5 achievements liés (quests_completed + quest_arc_completed)
  Event-driven: combat/forge/craft/loot émettent quest.progress
  API: available, active, completed, accept, claim, arcs

Rebalance:
  Endurance coût combat 10→5, regen 6min→3min (20/h), repos 20→10
  Dégâts joueur +3 base (plus de combats de 13 tours au level 1)
  Défaite endurance penalty 50→25
  XP monstres réduite (25→8 Têtard, 130→50 Golem) — quêtes = source principale
This commit is contained in:
2026-03-24 16:34:37 +01:00
parent 93b34b1f7b
commit 7651f3d8aa
16 changed files with 775 additions and 6 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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<Character>,
) {}
@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<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;
}
}

60
src/quest/quest.entity.ts Normal file
View File

@@ -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;
}

21
src/quest/quest.module.ts Normal file
View File

@@ -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 {}

292
src/quest/quest.service.ts Normal file
View File

@@ -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<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 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,
},
}));
}
}