diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index a71fffc..3d807cd 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -57,6 +57,7 @@ export const questApi = { completed: () => api.get('/quests/completed'), accept: (questId: string) => api.post(`/quests/accept/${questId}`), claim: (playerQuestId: string) => api.post(`/quests/claim/${playerQuestId}`), + abandon: (playerQuestId: string) => api.post(`/quests/abandon/${playerQuestId}`), arcs: () => api.get('/quests/arcs'), }; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index cc9411d..a41d5fa 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -18,9 +18,13 @@ export interface Character { vitalite: number; hpCurrent: number; hpMax: number; - endurance: number; // calculé à la lecture + endurance: number; + enduranceCurrent: number; // calculé à la lecture (backend field) enduranceMax: number; statPoints: number; + xpToNextLevel: number; + activeTitle: string | null; + totalGoldEarned: number; createdAt: string; } diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index b8ec6cb..46eb06b 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -197,9 +197,9 @@ export function DashboardPage() { Endurance - {char.endurance} / {char.enduranceMax} + {char.enduranceCurrent ?? char.endurance} / {char.enduranceMax} - +
diff --git a/frontend/src/pages/QuestPage.tsx b/frontend/src/pages/QuestPage.tsx index e044234..c186d3c 100644 --- a/frontend/src/pages/QuestPage.tsx +++ b/frontend/src/pages/QuestPage.tsx @@ -18,25 +18,28 @@ function QuestCard({ pq, mode }: { pq: any; mode: 'active' | 'available' | 'comp const status = mode === 'active' ? pq.status : 'available'; const pct = Math.min(100, Math.floor((progress / quest.objectiveCount) * 100)); + const invalidateAll = () => { + qc.invalidateQueries({ queryKey: ['quests'] }); + qc.invalidateQueries({ queryKey: ['questsActive'] }); + qc.invalidateQueries({ queryKey: ['questsAvailable'] }); + qc.invalidateQueries({ queryKey: ['questsCompleted'] }); + qc.invalidateQueries({ queryKey: ['questArcs'] }); + qc.invalidateQueries({ queryKey: ['character'] }); + }; + const acceptMut = useMutation({ mutationFn: () => questApi.accept(quest.id), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['quests'] }); - qc.invalidateQueries({ queryKey: ['questsActive'] }); - qc.invalidateQueries({ queryKey: ['questsAvailable'] }); - }, + onSuccess: invalidateAll, }); const claimMut = useMutation({ mutationFn: () => questApi.claim(pq.id), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['quests'] }); - qc.invalidateQueries({ queryKey: ['questsActive'] }); - qc.invalidateQueries({ queryKey: ['questsAvailable'] }); - qc.invalidateQueries({ queryKey: ['questsCompleted'] }); - qc.invalidateQueries({ queryKey: ['questArcs'] }); - qc.invalidateQueries({ queryKey: ['character'] }); - }, + onSuccess: invalidateAll, + }); + + const abandonMut = useMutation({ + mutationFn: () => questApi.abandon(pq.id), + onSuccess: invalidateAll, }); const isCompleted = status === 'completed'; @@ -96,8 +99,19 @@ function QuestCard({ pq, mode }: { pq: any; mode: 'active' | 'available' | 'comp {claimMut.isPending ? 'Réclamation…' : '🎁 Réclamer la récompense'} )} + {mode === 'active' && !isCompleted && ( + + )} {acceptMut.isError &&

{(acceptMut.error as Error).message}

} {claimMut.isError &&

{(claimMut.error as Error).message}

} + {abandonMut.isError &&

{(abandonMut.error as Error).message}

}
); } diff --git a/src/combat/combat.service.ts b/src/combat/combat.service.ts index 2083916..38bd7ce 100644 --- a/src/combat/combat.service.ts +++ b/src/combat/combat.service.ts @@ -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) { diff --git a/src/quest/quest.controller.ts b/src/quest/quest.controller.ts index 560175d..b72bb60 100644 --- a/src/quest/quest.controller.ts +++ b/src/quest/quest.controller.ts @@ -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); diff --git a/src/quest/quest.service.ts b/src/quest/quest.service.ts index 943db6a..0241e04 100644 --- a/src/quest/quest.service.ts +++ b/src/quest/quest.service.ts @@ -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')