feat: Combat tour par tour — Phases A-D complètes

TurnManager stateless avec sessions en mémoire (TTL 10min).
SpellSystem : 15 sorts (5 par voie du Dao), mana, cooldowns, buffs/debuffs.
CompanionAI : Mira (heal/support) et Vell (tank/dps) — IA contextuelle.
Monster AI : 3 profils (agressif, défensif, chaotique).

Nouvelles entités : Spell, PlayerSpell, PlayerDaoPath.
Character +mana. Monster +aiProfile +isBoss.
Migration : 1743004800000-TurnCombatSystem.

Frontend : TurnCombatPage (select/combat/result), sélecteur compagnon,
barres HP/MP, log scrollable, sous-menu sorts avec cooldowns.

Endpoints : 8 routes sous /combat/turn/ (start, action, session, spells,
unlocked, unlock, dao, dao/choose).

Combat simple (POST /combat/start) et grind ×5/×10 inchangés.
This commit is contained in:
2026-03-25 00:58:47 +01:00
parent 4beb1b2ed9
commit 9d50adf523
21 changed files with 2904 additions and 5 deletions

View File

@@ -0,0 +1,420 @@
import { useState, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { combatApi, turnCombatApi, characterApi } from '../api/endpoints';
import type { Monster, TurnResult, TurnSpell, TurnBuff } from '../api/types';
import { Swords, Sparkles, PackageOpen, ArrowLeft, Zap, Heart, Shield, Skull, Trophy, Users } from 'lucide-react';
import { COMBAT_COST, ATTACK_TYPES, ZONE_INFO } from '../constants';
import { MonsterCard } from '../components/MonsterCard';
type Phase = 'select' | 'combat' | 'result';
export function TurnCombatPage() {
const qc = useQueryClient();
const [phase, setPhase] = useState<Phase>('select');
const [selectedMonster, setSelectedMonster] = useState<Monster | null>(null);
const [attackType, setAttackType] = useState('melee');
const [combat, setCombat] = useState<TurnResult | null>(null);
const [companion, setCompanion] = useState<'mira' | 'vell' | null>(null);
const [spellMenuOpen, setSpellMenuOpen] = useState(false);
const logRef = useRef<HTMLDivElement>(null);
const { data: char } = useQuery({ queryKey: ['character'], queryFn: characterApi.me });
const { data: monsters } = useQuery({ queryKey: ['monsters'], queryFn: combatApi.monsters });
const { data: zones } = useQuery({ queryKey: ['zones'], queryFn: combatApi.zones });
const { data: spells } = useQuery({ queryKey: ['turnSpells'], queryFn: turnCombatApi.unlockedSpells });
const endurance = char?.enduranceCurrent ?? 0;
const canFight = endurance >= COMBAT_COST;
// Scroll log vers le bas
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [combat?.events]);
// --- Start combat ---
const startMut = useMutation({
mutationFn: () => turnCombatApi.start(selectedMonster!.id, attackType, companion),
onSuccess: (result) => {
setCombat(result);
setPhase('combat');
setSpellMenuOpen(false);
},
onError: (err: Error) => toast.error(err.message),
});
// --- Submit action ---
const actionMut = useMutation({
mutationFn: (params: { type: string; spellId?: string }) =>
turnCombatApi.action(combat!.sessionId, params.type, params.spellId),
onSuccess: (result) => {
setCombat(result);
setSpellMenuOpen(false);
if (result.status === 'finished') {
setPhase('result');
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['combatHistory'] });
qc.invalidateQueries({ queryKey: ['questsActive'] });
}
},
onError: (err: Error) => toast.error(err.message),
});
const doAction = (type: string, spellId?: string) => {
if (actionMut.isPending) return;
actionMut.mutate({ type, spellId });
};
// ========== PHASE: SELECT ==========
if (phase === 'select') {
const monstersByZone = new Map<string, Monster[]>();
for (const m of (monsters ?? [])) {
const zone = (m as any).zone ?? 'marais';
const list = monstersByZone.get(zone) ?? [];
list.push(m);
monstersByZone.set(zone, list);
}
const lockedZones = (zones ?? []).filter((z: any) => !z.unlocked);
return (
<div>
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}>
<Swords size={18} style={{ display: 'inline', marginRight: 6 }} />
Combat Tactique
</h2>
<div className="grid-2" style={{ marginBottom: '1rem' }}>
<div>
{Array.from(monstersByZone.entries()).map(([zone, zoneMonsters]) => {
const info = ZONE_INFO[zone] ?? { name: zone, emoji: '📍' };
return (
<div key={zone} style={{ marginBottom: '1rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#9ca3af' }}>
{info.emoji} {info.name}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{zoneMonsters.sort((a, b) => a.minLevel - b.minLevel).map(m => (
<MonsterCard
key={m.id}
m={m}
selected={selectedMonster?.id === m.id}
onSelect={() => setSelectedMonster(m)}
playerLevel={char?.level ?? 1}
/>
))}
</div>
</div>
);
})}
{lockedZones.map((z: any) => (
<div key={z.id} className="card" style={{ marginBottom: '0.5rem', opacity: 0.4, textAlign: 'center', padding: '1rem' }}>
<span style={{ fontSize: 13, color: '#6b7a99' }}>{z.emoji} {z.name} Verrouillee</span>
</div>
))}
</div>
<div>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
Type d'attaque
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: '1rem' }}>
{ATTACK_TYPES.map(a => (
<div
key={a.id}
className={`card card-hover ${attackType === a.id ? 'card-gold' : ''}`}
onClick={() => setAttackType(a.id)}
style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }}
>
<span style={{ fontSize: 18 }}>{a.emoji}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 13, color: attackType === a.id ? '#f4c94e' : '#dce4f0' }}>{a.label}</div>
<div style={{ fontSize: 11, color: '#6b7a99' }}>{a.stat}</div>
</div>
</div>
))}
</div>
{/* Compagnon */}
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
<Users size={11} style={{ display: 'inline', marginRight: 4 }} />
Compagnon (optionnel)
</p>
<div style={{ display: 'flex', gap: 6, marginBottom: '1rem' }}>
{[
{ id: null as 'mira' | 'vell' | null, label: 'Solo', emoji: '🐸', desc: '' },
{ id: 'mira' as const, label: 'Mira', emoji: '🌊', desc: 'Support heal + buff' },
{ id: 'vell' as const, label: 'Vell', emoji: '🪨', desc: 'Tank taunt + DPS' },
].map(c => (
<div
key={c.label}
className={`card card-hover ${companion === c.id ? 'card-gold' : ''}`}
onClick={() => setCompanion(c.id)}
style={{ flex: 1, cursor: 'pointer', textAlign: 'center', padding: '0.5rem' }}
>
<div style={{ fontSize: 20 }}>{c.emoji}</div>
<div style={{ fontWeight: 700, fontSize: 12, color: companion === c.id ? '#f4c94e' : '#dce4f0' }}>{c.label}</div>
{c.desc && <div style={{ fontSize: 10, color: '#6b7a99' }}>{c.desc}</div>}
</div>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginBottom: 8, fontSize: 12, color: canFight ? '#5ba4f5' : '#e84040' }}>
<Zap size={12} /> Cout : {COMBAT_COST} endurance — Dispo : {endurance}
</div>
<button
className="btn btn-red"
style={{ width: '100%', fontSize: 14, padding: '0.75rem', opacity: canFight && selectedMonster ? 1 : 0.5 }}
disabled={!selectedMonster || startMut.isPending || !canFight}
onClick={() => startMut.mutate()}
>
{startMut.isPending ? 'Lancement...' : `Lancer le combat tactique (${COMBAT_COST} endurance)`}
</button>
</div>
</div>
</div>
);
}
// ========== PHASE: COMBAT ==========
if (phase === 'combat' && combat) {
const playerHpPct = Math.round((combat.playerHp / combat.playerHpMax) * 100);
const monsterHpPct = Math.round((combat.monsterHp / combat.monsterHpMax) * 100);
const manaPct = Math.round((combat.playerMana / combat.playerManaMax) * 100);
const isActing = actionMut.isPending;
return (
<div>
<h2 style={{ margin: '0 0 0.5rem', color: '#f4c94e', fontSize: 16 }}>
Tour {combat.round}
</h2>
{/* Combatants */}
<div style={{ display: 'flex', gap: 16, marginBottom: 12 }}>
{/* Player */}
<div className="card" style={{ flex: 1, padding: '0.75rem' }}>
<div style={{ fontWeight: 700, fontSize: 14, color: '#3ddc84', marginBottom: 4 }}>
{combat.playerName}
</div>
<BarDisplay label="HP" value={combat.playerHp} max={combat.playerHpMax} pct={playerHpPct} color="#3ddc84" />
<BarDisplay label="MP" value={combat.playerMana} max={combat.playerManaMax} pct={manaPct} color="#5ba4f5" />
<BuffList buffs={combat.activeBuffs} debuffs={combat.activeDebuffs} />
</div>
{/* Companion */}
{combat.companion && (
<div className="card" style={{ flex: 1, padding: '0.75rem', opacity: combat.companion.hpCurrent <= 0 ? 0.4 : 1 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: combat.companion.type === 'mira' ? '#5ba4f5' : '#f4c94e', marginBottom: 4 }}>
{combat.companion.type === 'mira' ? '🌊' : '🪨'} {combat.companion.name}
{combat.companion.hpCurrent <= 0 && ' (KO)'}
</div>
<BarDisplay
label="HP"
value={combat.companion.hpCurrent}
max={combat.companion.hpMax}
pct={Math.round((combat.companion.hpCurrent / combat.companion.hpMax) * 100)}
color={combat.companion.type === 'mira' ? '#5ba4f5' : '#f4c94e'}
/>
<BarDisplay
label="MP"
value={combat.companion.manaCurrent}
max={combat.companion.manaMax}
pct={Math.round((combat.companion.manaCurrent / combat.companion.manaMax) * 100)}
color="#a78bfa"
/>
<BuffList buffs={combat.companion.activeBuffs} debuffs={combat.companion.activeDebuffs} />
</div>
)}
{/* Monster */}
<div className="card" style={{ flex: 1, padding: '0.75rem' }}>
<div style={{ fontWeight: 700, fontSize: 14, color: '#e84040', marginBottom: 4 }}>
{combat.monsterName}
</div>
<BarDisplay label="HP" value={combat.monsterHp} max={combat.monsterHpMax} pct={monsterHpPct} color="#e84040" />
<BuffList buffs={combat.monsterBuffs} debuffs={combat.monsterDebuffs} />
</div>
</div>
{/* Log */}
<div
ref={logRef}
className="card"
style={{ padding: '0.75rem', marginBottom: 12, maxHeight: 200, overflowY: 'auto', fontSize: 12 }}
>
{combat.events.length === 0 && (
<p style={{ color: '#6b7a99', margin: 0 }}>Le combat commence...</p>
)}
{combat.events.map((e, i) => (
<div key={i} style={{ marginBottom: 2, color: eventColor(e.actor, combat!) }}>
<span style={{ color: '#6b7a99' }}>[T{e.round}]</span> {e.detail}
</div>
))}
</div>
{/* Actions */}
{!spellMenuOpen ? (
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-red" style={{ flex: 2 }} disabled={isActing} onClick={() => doAction('attack')}>
<Swords size={14} style={{ display: 'inline', marginRight: 4 }} />
Attaque
</button>
<button
className="btn btn-blue"
style={{ flex: 2 }}
disabled={isActing || !spells?.length}
onClick={() => setSpellMenuOpen(true)}
>
<Sparkles size={14} style={{ display: 'inline', marginRight: 4 }} />
Sorts
</button>
<button className="btn btn-ghost" style={{ flex: 1 }} disabled={isActing} onClick={() => doAction('item')}>
<PackageOpen size={14} style={{ display: 'inline', marginRight: 4 }} />
Items
</button>
<button className="btn btn-ghost" style={{ flex: 1 }} disabled={isActing} onClick={() => doAction('flee')}>
<ArrowLeft size={14} style={{ display: 'inline', marginRight: 4 }} />
Fuir
</button>
</div>
) : (
<div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{(spells ?? []).map(spell => {
const cd = combat.spellCooldowns[spell.id] ?? 0;
const notEnoughMana = combat.playerMana < spell.manaCost;
const disabled = isActing || cd > 0 || notEnoughMana;
return (
<button
key={spell.id}
className={`card card-hover ${disabled ? '' : 'card-gold'}`}
style={{ textAlign: 'left', cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.5 : 1, padding: '0.5rem 0.75rem' }}
disabled={disabled}
onClick={() => !disabled && doAction('spell', spell.id)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<span style={{ fontWeight: 700, fontSize: 13, color: pathColor(spell.path) }}>{spell.name}</span>
<span style={{ fontSize: 11, color: '#6b7a99', marginLeft: 8 }}>{spell.manaCost} MP</span>
</div>
<div style={{ fontSize: 11 }}>
{cd > 0 ? (
<span style={{ color: '#e84040' }}>CD: {cd}</span>
) : (
<span style={{ color: '#3ddc84' }}>PRET</span>
)}
</div>
</div>
<div style={{ fontSize: 11, color: '#6b7a99', marginTop: 2 }}>{spell.description}</div>
</button>
);
})}
</div>
<button
className="btn btn-ghost"
style={{ width: '100%', marginTop: 8, fontSize: 12 }}
onClick={() => setSpellMenuOpen(false)}
>
Retour
</button>
</div>
)}
</div>
);
}
// ========== PHASE: RESULT ==========
if (phase === 'result' && combat) {
const won = combat.winner === 'player';
return (
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
<div style={{ fontSize: 48, marginBottom: 8 }}>
{won ? <Trophy size={48} color="#f4c94e" /> : <Skull size={48} color="#e84040" />}
</div>
<h2 style={{ color: won ? '#f4c94e' : '#e84040', fontSize: 24, margin: '0 0 8px' }}>
{won ? 'Victoire !' : 'Defaite...'}
</h2>
<p style={{ color: '#6b7a99', fontSize: 14, margin: '0 0 16px' }}>
Combat termine en {combat.round} tour{combat.round > 1 ? 's' : ''}
</p>
{won && combat.rewards && (
<div className="card" style={{ display: 'inline-block', padding: '1rem 2rem', textAlign: 'left', marginBottom: 16 }}>
<div style={{ fontSize: 13, color: '#dce4f0', marginBottom: 4 }}>
+{combat.rewards.xp} XP &nbsp; +{combat.rewards.gold} Or
</div>
{combat.rewards.levelUp && (
<div style={{ fontSize: 14, color: '#f4c94e', fontWeight: 700 }}>
LEVEL UP ! Niveau {combat.rewards.newLevel} (+{combat.rewards.statPointsGained} stat points)
</div>
)}
</div>
)}
<div>
<button
className="btn btn-gold"
style={{ fontSize: 14, padding: '0.75rem 2rem' }}
onClick={() => { setPhase('select'); setCombat(null); }}
>
Retour
</button>
</div>
</div>
);
}
return null;
}
// ========== Sous-composants ==========
function BarDisplay({ label, value, max, pct, color }: { label: string; value: number; max: number; pct: number; color: string }) {
return (
<div style={{ marginBottom: 4 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#6b7a99', marginBottom: 2 }}>
<span>{label}</span>
<span>{value}/{max}</span>
</div>
<div style={{ height: 6, background: '#1a1f2e', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 3, transition: 'width 0.3s' }} />
</div>
</div>
);
}
function BuffList({ buffs, debuffs }: { buffs: TurnBuff[]; debuffs: TurnBuff[] }) {
if (!buffs.length && !debuffs.length) return null;
return (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 4 }}>
{buffs.map(b => (
<span key={b.id} className="badge badge-green" style={{ fontSize: 10 }}>
<Shield size={8} style={{ display: 'inline', marginRight: 2 }} />
{b.name} ({b.remainingTurns})
</span>
))}
{debuffs.map(d => (
<span key={d.id} className="badge badge-red" style={{ fontSize: 10 }}>
{d.name} ({d.remainingTurns})
</span>
))}
</div>
);
}
function eventColor(actor: string, combat: TurnResult): string {
if (actor === combat.playerName) return '#3ddc84';
if (actor === combat.monsterName) return '#e84040';
if (actor === 'Mira') return '#5ba4f5';
if (actor === 'Vell') return '#f4c94e';
return '#dce4f0';
}
function pathColor(path: string): string {
switch (path) {
case 'ecoute': return '#5ba4f5';
case 'resonance': return '#e84040';
case 'harmonie': return '#3ddc84';
default: return '#dce4f0';
}
}