fix: quest progression (events after tx), abandon quest, endurance display
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 34s
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:
@@ -57,6 +57,7 @@ export const questApi = {
|
||||
completed: () => api.get<any[]>('/quests/completed'),
|
||||
accept: (questId: string) => api.post<any>(`/quests/accept/${questId}`),
|
||||
claim: (playerQuestId: string) => api.post<any>(`/quests/claim/${playerQuestId}`),
|
||||
abandon: (playerQuestId: string) => api.post<any>(`/quests/abandon/${playerQuestId}`),
|
||||
arcs: () => api.get<any[]>('/quests/arcs'),
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -197,9 +197,9 @@ export function DashboardPage() {
|
||||
<span style={{ fontSize: 12, color: '#5ba4f5', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Zap size={11} /> Endurance
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#6b7a99' }}>{char.endurance} / {char.enduranceMax}</span>
|
||||
<span style={{ fontSize: 11, color: '#6b7a99' }}>{char.enduranceCurrent ?? char.endurance} / {char.enduranceMax}</span>
|
||||
</div>
|
||||
<Bar value={char.endurance} max={char.enduranceMax} type="end" showValues={false} />
|
||||
<Bar value={char.enduranceCurrent ?? char.endurance} max={char.enduranceMax} type="end" showValues={false} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
|
||||
@@ -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 acceptMut = useMutation({
|
||||
mutationFn: () => questApi.accept(quest.id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['quests'] });
|
||||
qc.invalidateQueries({ queryKey: ['questsActive'] });
|
||||
qc.invalidateQueries({ queryKey: ['questsAvailable'] });
|
||||
},
|
||||
});
|
||||
|
||||
const claimMut = useMutation({
|
||||
mutationFn: () => questApi.claim(pq.id),
|
||||
onSuccess: () => {
|
||||
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: invalidateAll,
|
||||
});
|
||||
|
||||
const claimMut = useMutation({
|
||||
mutationFn: () => questApi.claim(pq.id),
|
||||
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'}
|
||||
</button>
|
||||
)}
|
||||
{mode === 'active' && !isCompleted && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 10, padding: '0.2rem 0.5rem', color: '#6b7a99' }}
|
||||
disabled={abandonMut.isPending}
|
||||
onClick={() => abandonMut.mutate()}
|
||||
>
|
||||
{abandonMut.isPending ? '…' : '✕ Abandonner'}
|
||||
</button>
|
||||
)}
|
||||
{acceptMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 4 }}>{(acceptMut.error as Error).message}</p>}
|
||||
{claimMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 4 }}>{(claimMut.error as Error).message}</p>}
|
||||
{abandonMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 4 }}>{(abandonMut.error as Error).message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,6 +206,9 @@ export class CombatService {
|
||||
}
|
||||
|
||||
return {
|
||||
characterId: character.id,
|
||||
lootedMaterialId,
|
||||
response: {
|
||||
winner: result.winner,
|
||||
rounds: result.rounds,
|
||||
summary: summaryParts.join(' '),
|
||||
@@ -256,8 +232,26 @@ export class CombatService {
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user