From efe4b4e3720e81d90c9de67b418745763a1b8cbb Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Tue, 24 Mar 2026 20:21:44 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20multi-combat=20=C3=975/=C3=9710=20+=20c?= =?UTF-8?q?ooldown=20anti-spam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: startMultiCombat boucle séquentielle, arrêt sur défaite - Frontend: cooldown 1.5s entre combats, boutons ×1/×5/×10 - Frontend: résumé multi-combat (wins/losses, XP/Or/loot totaux) - Fix: lock contention par spam de clics résolu --- frontend/src/api/endpoints.ts | 2 +- frontend/src/api/types.ts | 16 ++++++ frontend/src/pages/CombatPage.tsx | 91 ++++++++++++++++++++++++------ src/combat/combat.controller.ts | 4 ++ src/combat/combat.service.ts | 34 +++++++++++ src/combat/dto/start-combat.dto.ts | 8 ++- 6 files changed, 136 insertions(+), 19 deletions(-) diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index b89017b..37fdbfb 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -27,7 +27,7 @@ export const characterApi = { export const combatApi = { zones: () => api.get('/monsters/zones'), monsters: () => api.get('/monsters'), - start: (monsterId: string, attackType: string) => api.post('/combat/start', { monsterId, attackType }), + start: (monsterId: string, attackType: string, count?: number) => api.post('/combat/start', { monsterId, attackType, ...(count && count > 1 ? { count } : {}) }), history: () => api.get('/combat/history'), }; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 024ac69..8dbc543 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -85,6 +85,22 @@ export interface CombatResult { character: CombatCharacterState; } +export interface MultiCombatResult { + mode: 'multi'; + count: number; + totals: { + wins: number; + losses: number; + xp: number; + gold: number; + goldLost: number; + loot: { name: string; quantity: number }[]; + levelsGained: number; + }; + lastResult: CombatResult; + character: CombatCharacterState; +} + export interface CombatLog { id: string; winner: 'player' | 'monster'; diff --git a/frontend/src/pages/CombatPage.tsx b/frontend/src/pages/CombatPage.tsx index d0d8b2a..c9e4f2b 100644 --- a/frontend/src/pages/CombatPage.tsx +++ b/frontend/src/pages/CombatPage.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { combatApi, characterApi } from '../api/endpoints'; -import type { Monster, CombatResult, CombatLog } from '../api/types'; +import type { Monster, CombatResult, MultiCombatResult, CombatLog } from '../api/types'; import { Swords, Trophy, Skull, Clock, Zap, Heart, Lock } from 'lucide-react'; const COMBAT_COST = 5; @@ -91,6 +91,39 @@ function CombatLogView({ result }: { result: CombatResult }) { ); } +function MultiCombatView({ result }: { result: MultiCombatResult }) { + const t = result.totals; + return ( +
+
+
0 ? '#e84040' : '#3ddc84' }}> + {t.losses > 0 ? : } + {result.count} combat{result.count > 1 ? 's' : ''} — {t.wins}V / {t.losses}D +
+
+ +{t.xp} XP +{t.gold} Or + {t.goldLost > 0 && −{t.goldLost} Or} +
+ {t.levelsGained > 0 && ( +
+ 🎉 {t.levelsGained} level up{t.levelsGained > 1 ? 's' : ''} ! +
+ )} + {t.loot.length > 0 && ( +
+ 🎁 Loot : {t.loot.reduce((sum, l) => sum + l.quantity, 0)} matériaux +
+ )} + {t.losses > 0 && ( +
+ Série interrompue par une défaite +
+ )} +
+
+ ); +} + function HistoryEntry({ h }: { h: CombatLog }) { return (
@@ -109,6 +142,8 @@ export function CombatPage() { const [selectedMonster, setSelectedMonster] = useState(null); const [attackType, setAttackType] = useState('melee'); const [lastResult, setLastResult] = useState(null); + const [lastMultiResult, setLastMultiResult] = useState(null); + const [cooldown, setCooldown] = useState(false); const { data: char } = useQuery({ queryKey: ['character'], queryFn: characterApi.me }); const endurance = char?.enduranceCurrent ?? 0; @@ -137,14 +172,28 @@ export function CombatPage() { queryFn: combatApi.history, }); + const startCooldown = useCallback(() => { + setCooldown(true); + setTimeout(() => setCooldown(false), 1500); + }, []); + const fight = useMutation({ - mutationFn: () => combatApi.start(selectedMonster!.id, attackType), + mutationFn: (count: number = 1) => combatApi.start(selectedMonster!.id, attackType, count), onSuccess: (result) => { - setLastResult(result); + if (result.mode === 'multi') { + setLastMultiResult(result as MultiCombatResult); + setLastResult(null); + } else { + setLastResult(result as CombatResult); + setLastMultiResult(null); + } qc.invalidateQueries({ queryKey: ['character'] }); qc.invalidateQueries({ queryKey: ['combatHistory'] }); qc.invalidateQueries({ queryKey: ['questsActive'] }); + qc.invalidateQueries({ queryKey: ['materialsInventory'] }); + startCooldown(); }, + onError: () => startCooldown(), }); if (isLoading) return
Chargement des monstres…
; @@ -252,19 +301,26 @@ export function CombatPage() { {canFight && ({Math.floor(endurance / COMBAT_COST)} combats)}
- {/* Bouton combattre */} - + {/* Boutons combattre */} +
+ {[1, 5, 10].map(n => ( + + ))} +
{fight.isError && (

{(fight.error as Error).message}

@@ -285,6 +341,7 @@ export function CombatPage() { {/* Résultat du dernier combat */} + {lastMultiResult && } {lastResult && } ); diff --git a/src/combat/combat.controller.ts b/src/combat/combat.controller.ts index 1fed2b3..8d8d70c 100644 --- a/src/combat/combat.controller.ts +++ b/src/combat/combat.controller.ts @@ -16,6 +16,10 @@ export class CombatController { @Body() dto: StartCombatDto, @Req() req: Request & { user: User }, ) { + const count = dto.count ?? 1; + if (count > 1) { + return this.combatService.startMultiCombat(dto, req.user, count); + } return this.combatService.startCombat(dto, req.user); } diff --git a/src/combat/combat.service.ts b/src/combat/combat.service.ts index 1700d81..7cdcdc9 100644 --- a/src/combat/combat.service.ts +++ b/src/combat/combat.service.ts @@ -287,6 +287,40 @@ export class CombatService { return txResult.response; } + async startMultiCombat(dto: StartCombatDto, user: User, count: number) { + const results: any[] = []; + const totals = { wins: 0, losses: 0, xp: 0, gold: 0, goldLost: 0, loot: [] as { name: string; quantity: number }[], levelsGained: 0 }; + + for (let i = 0; i < count; i++) { + try { + const result = await this.startCombat(dto, user); + results.push(result); + if (result.winner === 'player') { + totals.wins++; + totals.xp += result.rewards.xp; + totals.gold += result.rewards.gold; + if (result.rewards.levelUp) totals.levelsGained++; + if (result.rewards.loot) totals.loot.push(result.rewards.loot); + } else { + totals.losses++; + totals.goldLost += result.rewards.goldLost ?? 0; + break; // Défaite = arrêt de la série + } + } catch { + break; // Endurance insuffisante ou autre erreur = arrêt + } + } + + const lastResult = results[results.length - 1]; + return { + mode: 'multi', + count: results.length, + totals, + lastResult, + character: lastResult?.character, + }; + } + async getHistory(user: User) { const character = await this.characterRepository.findOne({ where: { userId: user.id }, diff --git a/src/combat/dto/start-combat.dto.ts b/src/combat/dto/start-combat.dto.ts index 20c02db..499bee5 100644 --- a/src/combat/dto/start-combat.dto.ts +++ b/src/combat/dto/start-combat.dto.ts @@ -1,4 +1,4 @@ -import { IsUUID, IsIn } from 'class-validator'; +import { IsUUID, IsIn, IsOptional, IsInt, Min, Max } from 'class-validator'; import { AttackType } from '../../monster/monster.entity'; export class StartCombatDto { @@ -7,4 +7,10 @@ export class StartCombatDto { @IsIn(['melee', 'ranged', 'magic']) attackType: AttackType; + + @IsOptional() + @IsInt() + @Min(1) + @Max(10) + count?: number; }