feat(sprint5): quest system + arcs + rebalance endurance/damage/xp
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
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:
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
176
src/database/quests-seed.ts
Normal file
176
src/database/quests-seed.ts
Normal file
@@ -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<string, string>(monsters.map((m: any) => [m.name, m.id]));
|
||||
|
||||
const materials = await dataSource.query('SELECT id, name FROM materials');
|
||||
const materialMap = new Map<string, string>(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)');
|
||||
}
|
||||
19
src/database/seed-sprint5.ts
Normal file
19
src/database/seed-sprint5.ts
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
38
src/quest/player-quest-arc.entity.ts
Normal file
38
src/quest/player-quest-arc.entity.ts
Normal 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;
|
||||
}
|
||||
46
src/quest/player-quest.entity.ts
Normal file
46
src/quest/player-quest.entity.ts
Normal 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;
|
||||
}
|
||||
31
src/quest/quest-arc.entity.ts
Normal file
31
src/quest/quest-arc.entity.ts
Normal 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[];
|
||||
}
|
||||
60
src/quest/quest.controller.ts
Normal file
60
src/quest/quest.controller.ts
Normal 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
60
src/quest/quest.entity.ts
Normal 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
21
src/quest/quest.module.ts
Normal 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
292
src/quest/quest.service.ts
Normal 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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user