fix: quest progression (events after tx), abandon quest, endurance display
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 34s

- Events (achievement/community/quest) émis APRÈS la transaction combat
  au lieu de dedans — corrige les quêtes qui ne progressaient pas
- POST /api/quests/abandon/:id — abandonner une quête active
- Frontend: bouton "Abandonner" sur les quêtes actives non complétées
- Fix endurance display (enduranceCurrent field mapping)
- Types Character mis à jour (xpToNextLevel, activeTitle, enduranceCurrent)
This commit is contained in:
2026-03-24 16:52:48 +01:00
parent 8038ca5d0a
commit af247a1c6b
7 changed files with 99 additions and 68 deletions

View File

@@ -44,7 +44,7 @@ export class CombatService {
const monster = await this.monsterService.findOne(dto.monsterId);
// Transaction isolée — empêche les combats simultanés sur le même perso
return this.dataSource.transaction(async (manager) => {
const txResult = await this.dataSource.transaction(async (manager) => {
// SELECT ... FOR UPDATE — verrouille le personnage
const character = await manager
.getRepository(Character)
@@ -168,12 +168,11 @@ export class CombatService {
// Loot matériaux — 40% de chance après victoire
let lootMaterial: { name: string; quantity: number } | null = null;
let lootedMaterialId: string | null = null;
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,
});
lootedMaterialId = monster.dropMaterialId;
}
// Persister le log
@@ -189,32 +188,6 @@ export class CombatService {
});
await manager.save(combatLog);
// Events émis après la transaction (fire-and-forget)
if (result.winner === 'player') {
this.eventEmitter.emit('achievement.check', {
characterId: character.id, type: 'combat_wins', increment: 1,
});
this.eventEmitter.emit('achievement.check', {
characterId: character.id, type: 'level_reached', increment: 0, absolute: character.level,
});
this.eventEmitter.emit('achievement.check', {
characterId: character.id, type: 'gold_accumulated', increment: 0, absolute: Number(character.totalGoldEarned),
});
this.eventEmitter.emit('community.contribute', {
characterId: character.id, type: 'total_monsters_killed', increment: 1,
});
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
const summaryParts: string[] = [];
if (result.winner === 'player') {
@@ -233,31 +206,52 @@ export class CombatService {
}
return {
winner: result.winner,
rounds: result.rounds,
summary: summaryParts.join(' '),
rewards: {
xp: result.xpEarned,
gold: result.goldEarned,
goldLost,
levelUp: levelUpData.levelsGained > 0,
newLevel: levelUpData.newLevel,
statPointsGained: levelUpData.statPointsGained,
loot: lootMaterial,
},
character: {
level: character.level,
xp: character.xp,
xpToNextLevel: xpRequiredForLevel(character.level),
gold: character.gold,
hpCurrent: character.hpCurrent,
hpMax: character.hpMax,
enduranceCurrent: character.enduranceSaved,
enduranceMax: character.enduranceMax,
statPoints: character.statPoints ?? 0,
characterId: character.id,
lootedMaterialId,
response: {
winner: result.winner,
rounds: result.rounds,
summary: summaryParts.join(' '),
rewards: {
xp: result.xpEarned,
gold: result.goldEarned,
goldLost,
levelUp: levelUpData.levelsGained > 0,
newLevel: levelUpData.newLevel,
statPointsGained: levelUpData.statPointsGained,
loot: lootMaterial,
},
character: {
level: character.level,
xp: character.xp,
xpToNextLevel: xpRequiredForLevel(character.level),
gold: character.gold,
hpCurrent: character.hpCurrent,
hpMax: character.hpMax,
enduranceCurrent: character.enduranceSaved,
enduranceMax: character.enduranceMax,
statPoints: character.statPoints ?? 0,
},
},
};
});
// Events émis APRÈS la transaction (fire-and-forget)
if (txResult.response.winner === 'player') {
const cid = txResult.characterId;
this.eventEmitter.emit('achievement.check', { characterId: cid, type: 'combat_wins', increment: 1 });
this.eventEmitter.emit('achievement.check', { characterId: cid, type: 'level_reached', increment: 0, absolute: txResult.response.character.level });
this.eventEmitter.emit('achievement.check', { characterId: cid, type: 'gold_accumulated', increment: 0, absolute: txResult.response.rewards.gold });
this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_monsters_killed', increment: 1 });
this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_gold_earned', increment: txResult.response.rewards.gold });
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_any', increment: 1 });
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_monster', targetId: monster.id, increment: 1 });
if (txResult.lootedMaterialId) {
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'gather_material', targetId: txResult.lootedMaterialId, increment: 1 });
}
}
return txResult.response;
}
async getHistory(user: User) {

View File

@@ -45,6 +45,12 @@ export class QuestController {
return this.questService.claim(playerQuestId, char.id);
}
@Post('abandon/:id')
async abandon(@Param('id') playerQuestId: string, @Req() req: Request) {
const char = await this.getCharacter(req);
return this.questService.abandon(playerQuestId, char.id);
}
@Get('arcs')
async getArcs(@Req() req: Request) {
const char = await this.getCharacter(req);

View File

@@ -221,6 +221,18 @@ export class QuestService {
}
}
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')